-
-
-
-
-
- RN: ...
-
-
-
-
-
-
-
-
-
-
+
@@ -29,43 +22,54 @@ SPDX-License-Identifier: AGPL-3.0-only
diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts
index 860312ff64b0..9fad43a7db92 100644
--- a/packages/frontend/src/router/definition.ts
+++ b/packages/frontend/src/router/definition.ts
@@ -232,13 +232,26 @@ const routes: RouteDef[] = [{
component: page(() => import('@/pages/search.vue')),
query: {
q: 'query',
+ userId: 'userId',
+ username: 'username',
+ host: 'host',
channel: 'channel',
type: 'type',
origin: 'origin',
},
}, {
+ // Legacy Compatibility
path: '/authorize-follow',
- component: page(() => import('@/pages/follow.vue')),
+ redirect: '/lookup',
+ loginRequired: true,
+}, {
+ // Mastodon Compatibility
+ path: '/authorize_interaction',
+ redirect: '/lookup',
+ loginRequired: true,
+}, {
+ path: '/lookup',
+ component: page(() => import('@/pages/lookup.vue')),
loginRequired: true,
}, {
path: '/share',
diff --git a/packages/frontend/src/scripts/check-permissions.ts b/packages/frontend/src/scripts/check-permissions.ts
new file mode 100644
index 000000000000..ed86529d5b89
--- /dev/null
+++ b/packages/frontend/src/scripts/check-permissions.ts
@@ -0,0 +1,19 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { instance } from '@/instance.js';
+import { $i } from '@/account.js';
+
+export const notesSearchAvailable = (
+ // FIXME: instance.policies would be null in Vitest
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ ($i == null && instance.policies != null && instance.policies.canSearchNotes) ||
+ ($i != null && $i.policies.canSearchNotes) ||
+ false
+) as boolean;
+
+export const canSearchNonLocalNotes = (
+ instance.noteSearchableScope === 'global'
+);
diff --git a/packages/frontend/src/scripts/copy-to-clipboard.ts b/packages/frontend/src/scripts/copy-to-clipboard.ts
index 42d8799eb860..d2c73b50081b 100644
--- a/packages/frontend/src/scripts/copy-to-clipboard.ts
+++ b/packages/frontend/src/scripts/copy-to-clipboard.ts
@@ -7,8 +7,8 @@
* Clipboardに値をコピー(TODO: 文字列以外も対応)
* @deprecated
*/
-// eslint-disable-next-line import/no-default-export
-export default val => {
+// eslint-disable-next-line import/no-default-export, @typescript-eslint/no-explicit-any
+export default (val: any) => {
// 空div 生成
const tmp = document.createElement('div');
// 選択用のタグ生成
@@ -28,7 +28,7 @@ export default val => {
// body に追加
document.body.appendChild(tmp);
// 要素を選択
- document.getSelection().selectAllChildren(tmp);
+ document.getSelection()?.selectAllChildren(tmp);
// クリップボードにコピー
const result = document.execCommand('copy');
diff --git a/packages/frontend/src/scripts/focus-trap.ts b/packages/frontend/src/scripts/focus-trap.ts
new file mode 100644
index 000000000000..a5df36f52014
--- /dev/null
+++ b/packages/frontend/src/scripts/focus-trap.ts
@@ -0,0 +1,78 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js';
+
+const focusTrapElements = new Set
();
+const ignoreElements = [
+ 'script',
+ 'style',
+];
+
+function containsFocusTrappedElements(el: HTMLElement): boolean {
+ return Array.from(focusTrapElements).some((focusTrapElement) => {
+ return el.contains(focusTrapElement);
+ });
+}
+
+function releaseFocusTrap(el: HTMLElement): void {
+ focusTrapElements.delete(el);
+ if (el.inert === true) {
+ el.inert = false;
+ }
+ if (el.parentElement != null && el !== document.body) {
+ el.parentElement.childNodes.forEach((siblingNode) => {
+ const siblingEl = getHTMLElementOrNull(siblingNode);
+ if (!siblingEl) return;
+ if (siblingEl !== el && (focusTrapElements.has(siblingEl) || containsFocusTrappedElements(siblingEl) || focusTrapElements.size === 0)) {
+ siblingEl.inert = false;
+ } else if (
+ focusTrapElements.size > 0 &&
+ !containsFocusTrappedElements(siblingEl) &&
+ !focusTrapElements.has(siblingEl) &&
+ !ignoreElements.includes(siblingEl.tagName.toLowerCase())
+ ) {
+ siblingEl.inert = true;
+ } else {
+ siblingEl.inert = false;
+ }
+ });
+ releaseFocusTrap(el.parentElement);
+ }
+}
+
+export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls: boolean, parent: true): void;
+export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls?: boolean, parent?: false): { release: () => void; };
+export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls = false, parent = false): { release: () => void; } | void {
+ if (el.inert === true) {
+ el.inert = false;
+ }
+ if (el.parentElement != null && el !== document.body) {
+ el.parentElement.childNodes.forEach((siblingNode) => {
+ const siblingEl = getHTMLElementOrNull(siblingNode);
+ if (!siblingEl) return;
+ if (
+ siblingEl !== el &&
+ (
+ hasInteractionWithOtherFocusTrappedEls === false ||
+ (!focusTrapElements.has(siblingEl) && !containsFocusTrappedElements(siblingEl))
+ ) &&
+ !ignoreElements.includes(siblingEl.tagName.toLowerCase())
+ ) {
+ siblingEl.inert = true;
+ }
+ });
+ focusTrap(el.parentElement, hasInteractionWithOtherFocusTrappedEls, true);
+ }
+
+ if (!parent) {
+ focusTrapElements.add(el);
+
+ return {
+ release: () => {
+ releaseFocusTrap(el);
+ },
+ };
+ }
+}
diff --git a/packages/frontend/src/scripts/tms/focus.ts b/packages/frontend/src/scripts/focus.ts
similarity index 66%
rename from packages/frontend/src/scripts/tms/focus.ts
rename to packages/frontend/src/scripts/focus.ts
index 146ddcf99d67..eb2da5ad8665 100644
--- a/packages/frontend/src/scripts/tms/focus.ts
+++ b/packages/frontend/src/scripts/focus.ts
@@ -3,7 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { getElementOrNull, getNodeOrNull } from '@/scripts/tms/get-or-null.js';
+import { getScrollPosition, getScrollContainer, getStickyBottom, getStickyTop } from '@/scripts/scroll.js';
+import { getElementOrNull, getNodeOrNull } from '@/scripts/get-dom-node-or-null.js';
type MaybeHTMLElement = EventTarget | Node | Element | HTMLElement;
@@ -56,15 +57,24 @@ export const focusParent = (input: MaybeHTMLElement | null | undefined, self = f
};
const focusOrScroll = (element: HTMLElement, scroll: boolean) => {
- if (document.activeElement === element) {
- if (scroll) {
- element.scrollIntoView({
- behavior: 'instant',
- block: 'nearest',
- inline: 'nearest',
- });
+ if (scroll) {
+ const scrollContainer = getScrollContainer(element) ?? document.documentElement;
+ const scrollContainerTop = getScrollPosition(scrollContainer);
+ const stickyTop = getStickyTop(element, scrollContainer);
+ const stickyBottom = getStickyBottom(element, scrollContainer);
+ const top = element.getBoundingClientRect().top;
+ const bottom = element.getBoundingClientRect().bottom;
+
+ let scrollTo = scrollContainerTop;
+ if (top < stickyTop) {
+ scrollTo += top - stickyTop;
+ } else if (bottom > window.innerHeight - stickyBottom) {
+ scrollTo += bottom - window.innerHeight + stickyBottom;
}
- } else {
- element.focus({ preventScroll: !scroll });
+ scrollContainer.scrollTo({ top: scrollTo, behavior: 'instant' });
+ }
+
+ if (document.activeElement !== element) {
+ element.focus({ preventScroll: true });
}
};
diff --git a/packages/frontend/src/scripts/tms/get-or-null.ts b/packages/frontend/src/scripts/get-dom-node-or-null.ts
similarity index 100%
rename from packages/frontend/src/scripts/tms/get-or-null.ts
rename to packages/frontend/src/scripts/get-dom-node-or-null.ts
diff --git a/packages/frontend/src/scripts/get-drive-file-menu.ts b/packages/frontend/src/scripts/get-drive-file-menu.ts
index a76a9ca6e9f1..fc857d737aae 100644
--- a/packages/frontend/src/scripts/get-drive-file-menu.ts
+++ b/packages/frontend/src/scripts/get-drive-file-menu.ts
@@ -5,11 +5,11 @@
import * as Misskey from 'misskey-js';
import { defineAsyncComponent } from 'vue';
+import type { MenuItem } from '@/types/menu.js';
import { i18n } from '@/i18n.js';
import { copyText } from '@/scripts/tms/clipboard.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
-import { MenuItem } from '@/types/menu.js';
import { defaultStore } from '@/store.js';
function rename(file: Misskey.entities.DriveFile) {
@@ -41,6 +41,15 @@ function describe(file: Misskey.entities.DriveFile) {
});
}
+function move(file: Misskey.entities.DriveFile) {
+ os.selectDriveFolder(false).then(folder => {
+ misskeyApi('drive/files/update', {
+ fileId: file.id,
+ folderId: folder[0] ? folder[0].id : null,
+ });
+ });
+}
+
function toggleSensitive(file: Misskey.entities.DriveFile) {
misskeyApi('drive/files/update', {
fileId: file.id,
@@ -88,6 +97,10 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
text: i18n.ts.rename,
icon: 'ti ti-forms',
action: () => rename(file),
+ }, {
+ text: i18n.ts.move,
+ icon: 'ti ti-folder-symlink',
+ action: () => move(file),
}, {
text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
icon: file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation',
diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts
index be1a238c13b9..545a9d6e3d15 100644
--- a/packages/frontend/src/scripts/get-note-menu.ts
+++ b/packages/frontend/src/scripts/get-note-menu.ts
@@ -6,6 +6,7 @@
import { defineAsyncComponent, Ref, ShallowRef } from 'vue';
import * as Misskey from 'misskey-js';
import { claimAchievement } from './achievements.js';
+import type { MenuItem } from '@/types/menu.js';
import { $i } from '@/account.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
@@ -17,7 +18,6 @@ import { defaultStore, noteActions } from '@/store.js';
import { miLocalStorage } from '@/local-storage.js';
import { getUserMenu } from '@/scripts/get-user-menu.js';
import { clipsCache, favoritedChannelsCache } from '@/cache.js';
-import { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { isSupportShare } from '@/scripts/navigator.js';
import { parseErrorMessage } from '@/scripts/tms/error.js';
diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts
index 498a879eb8bf..78e432467172 100644
--- a/packages/frontend/src/scripts/get-user-menu.ts
+++ b/packages/frontend/src/scripts/get-user-menu.ts
@@ -6,6 +6,7 @@
import { toUnicode } from 'punycode';
import { defineAsyncComponent, ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
+import type { MenuItem } from '@/types/menu.js';
import { i18n } from '@/i18n.js';
import { copyText } from '@/scripts/tms/clipboard.js';
import { host, url } from '@/config.js';
@@ -13,6 +14,7 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { defaultStore, userActions } from '@/store.js';
import { $i, iAmModerator } from '@/account.js';
+import { notesSearchAvailable, canSearchNonLocalNotes } from '@/scripts/check-permissions.js';
import { IRouter } from '@/nirax.js';
import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
import { mainRouter } from '@/router/main.js';
@@ -81,15 +83,6 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
});
}
- async function toggleWithReplies() {
- os.apiWithDialog('following/update', {
- userId: user.id,
- withReplies: !user.withReplies,
- }).then(() => {
- user.withReplies = !user.withReplies;
- });
- }
-
async function toggleNotify() {
os.apiWithDialog('following/update', {
userId: user.id,
@@ -154,13 +147,20 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
});
}
- let menu = [{
+ let menu: MenuItem[] = [{
icon: 'ti ti-at',
text: i18n.ts.copyUsername,
action: () => {
copyText(`@${user.username}@${user.host ?? host}`);
},
- }, ...(iAmModerator ? [{
+ }, ...(notesSearchAvailable && (user.host == null || canSearchNonLocalNotes) ? [{
+ icon: 'ti ti-search',
+ text: i18n.ts.searchThisUsersNotes,
+ action: () => {
+ router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`);
+ },
+ }] : [])
+ , ...(iAmModerator ? [{
icon: 'ti ti-user-exclamation',
text: i18n.ts.moderation,
action: () => {
@@ -186,7 +186,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
copyText(`${url}/${canonical}`);
},
- }, {
+ }, ...($i ? [{
icon: 'ti ti-mail',
text: i18n.ts.sendMessage,
action: () => {
@@ -259,7 +259,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
},
}));
},
- }] as any;
+ }] : [])] as any;
if ($i && meId !== user.id) {
if (iAmModerator) {
@@ -305,17 +305,27 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
}
// フォローしたとしても user.isFollowing はリアルタイム更新されないので不便なため
- // if (user.isFollowing) {
+ //if (user.isFollowing) {
+ const withRepliesRef = ref(user.withReplies);
menu = menu.concat([{
- icon: user.withReplies ? 'ti ti-messages-off' : 'ti ti-messages',
- text: user.withReplies ? i18n.ts.hideRepliesToOthersInTimeline : i18n.ts.showRepliesToOthersInTimeline,
- action: toggleWithReplies,
+ type: 'switch',
+ icon: 'ti ti-messages',
+ text: i18n.ts.showRepliesToOthersInTimeline,
+ ref: withRepliesRef,
}, {
icon: user.notify === 'none' ? 'ti ti-bell' : 'ti ti-bell-off',
text: user.notify === 'none' ? i18n.ts.notifyNotes : i18n.ts.unnotifyNotes,
action: toggleNotify,
}]);
- // }
+ watch(withRepliesRef, (withReplies) => {
+ misskeyApi('following/update', {
+ userId: user.id,
+ withReplies,
+ }).then(() => {
+ user.withReplies = withReplies;
+ });
+ });
+ //}
menu = menu.concat([{ type: 'divider' }, {
icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off',
diff --git a/packages/frontend/src/scripts/tms/hotkey.ts b/packages/frontend/src/scripts/hotkey.ts
similarity index 72%
rename from packages/frontend/src/scripts/tms/hotkey.ts
rename to packages/frontend/src/scripts/hotkey.ts
index 8bb125d39037..04fb23569456 100644
--- a/packages/frontend/src/scripts/tms/hotkey.ts
+++ b/packages/frontend/src/scripts/hotkey.ts
@@ -2,9 +2,7 @@
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
-
-import { filterKeyboardNonComposing } from '@/scripts/tms/filter-keyboard.js';
-import { getHTMLElementOrNull } from '@/scripts/tms/get-or-null.js';
+import { getHTMLElementOrNull } from "@/scripts/get-dom-node-or-null.js";
//#region types
export type Keymap = Record;
@@ -47,23 +45,28 @@ const MODIFIER_KEYS = ['ctrl', 'alt', 'shift'];
const IGNORE_ELEMENTS = ['input', 'textarea'];
//#endregion
+//#region store
+let latestHotkey: Pattern & { callback: CallbackFunction } | null = null;
+//#endregion
+
//#region impl
export const makeHotkey = (keymap: Keymap) => {
const actions = parseKeymap(keymap);
- return filterKeyboardNonComposing(ev => {
+ return (ev: KeyboardEvent) => {
if ('pswp' in window && window.pswp != null) return;
if (document.activeElement != null) {
if (IGNORE_ELEMENTS.includes(document.activeElement.tagName.toLowerCase())) return;
if (getHTMLElementOrNull(document.activeElement)?.isContentEditable) return;
}
- for (const { patterns, callback, options } of actions) {
- if (matchPatterns(ev, patterns, options)) {
+ for (const action of actions) {
+ if (matchPatterns(ev, action)) {
ev.preventDefault();
ev.stopPropagation();
- callback(ev);
+ action.callback(ev);
+ storePattern(ev, action.callback);
}
}
- });
+ };
};
const parseKeymap = (keymap: Keymap) => {
@@ -98,17 +101,29 @@ const parseOptions = (rawCallback: Keymap[keyof Keymap]) => {
allowRepeat: false,
} as const satisfies Action['options'];
if (typeof rawCallback === 'object') {
- const { callback: _, ...rawOptions } = rawCallback;
+ const { callback, ...rawOptions } = rawCallback;
const options = { ...defaultOptions, ...rawOptions };
return { ...options } as const satisfies Action['options'];
}
return { ...defaultOptions } as const satisfies Action['options'];
};
-const matchPatterns = (ev: KeyboardEvent, patterns: Action['patterns'], options: Action['options']) => {
+const matchPatterns = (ev: KeyboardEvent, action: Action) => {
+ const { patterns, options, callback } = action;
if (ev.repeat && !options.allowRepeat) return false;
const key = ev.key.toLowerCase();
return patterns.some(({ which, ctrl, shift, alt }) => {
+ if (
+ options.allowRepeat === false &&
+ latestHotkey != null &&
+ latestHotkey.which.includes(key) &&
+ latestHotkey.ctrl === ctrl &&
+ latestHotkey.alt === alt &&
+ latestHotkey.shift === shift &&
+ latestHotkey.callback === callback
+ ) {
+ return false;
+ }
if (!which.includes(key)) return false;
if (ctrl !== (ev.ctrlKey || ev.metaKey)) return false;
if (alt !== ev.altKey) return false;
@@ -117,6 +132,26 @@ const matchPatterns = (ev: KeyboardEvent, patterns: Action['patterns'], options:
});
};
+let lastHotKeyStoreTimer: number | null = null;
+
+const storePattern = (ev: KeyboardEvent, callback: CallbackFunction) => {
+ if (lastHotKeyStoreTimer != null) {
+ clearTimeout(lastHotKeyStoreTimer);
+ }
+
+ latestHotkey = {
+ which: [ev.key.toLowerCase()],
+ ctrl: ev.ctrlKey || ev.metaKey,
+ alt: ev.altKey,
+ shift: ev.shiftKey,
+ callback,
+ };
+
+ lastHotKeyStoreTimer = window.setTimeout(() => {
+ latestHotkey = null;
+ }, 500);
+};
+
const parseKeyCode = (input?: string | null) => {
if (input == null) return [];
const raw = getValueByKey(KEY_ALIASES, input);
@@ -126,8 +161,8 @@ const parseKeyCode = (input?: string | null) => {
};
const getValueByKey = <
- T extends Record,
- K extends keyof T | string,
+ T extends Record,
+ K extends keyof T | keyof any,
R extends K extends keyof T ? T[K] : T[keyof T] | undefined,
>(obj: T, key: K) => {
return obj[key] as R;
diff --git a/packages/frontend/src/scripts/lookup.ts b/packages/frontend/src/scripts/lookup.ts
index 7f020b15cc2b..a261ec06691e 100644
--- a/packages/frontend/src/scripts/lookup.ts
+++ b/packages/frontend/src/scripts/lookup.ts
@@ -16,7 +16,7 @@ export async function lookup(router?: Router) {
title: i18n.ts.lookup,
});
const query = temp ? temp.trim() : '';
- if (canceled) return;
+ if (canceled || query.length <= 1) return;
if (query.startsWith('@') && !query.includes(' ')) {
_router.push(`/${query}`);
diff --git a/packages/frontend/src/scripts/merge.ts b/packages/frontend/src/scripts/merge.ts
index 4e39a0fa06ec..9794a300da02 100644
--- a/packages/frontend/src/scripts/merge.ts
+++ b/packages/frontend/src/scripts/merge.ts
@@ -6,7 +6,7 @@
import { deepClone } from './clone.js';
import type { Cloneable } from './clone.js';
-type DeepPartial = {
+export type DeepPartial = {
[P in keyof T]?: T[P] extends Record ? DeepPartial : T[P];
};
diff --git a/packages/frontend/src/scripts/mfm-function-picker.ts b/packages/frontend/src/scripts/mfm-function-picker.ts
index 8867a8c50ff6..9938e534c139 100644
--- a/packages/frontend/src/scripts/mfm-function-picker.ts
+++ b/packages/frontend/src/scripts/mfm-function-picker.ts
@@ -7,29 +7,24 @@ import { Ref, nextTick } from 'vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { MFM_TAGS } from '@/const.js';
+import type { MenuItem } from '@/types/menu.js';
/**
* MFMの装飾のリストを表示する
*/
-export function mfmFunctionPicker(src: any, textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref) {
- return new Promise((res, rej) => {
- os.popupMenu([{
- text: i18n.ts.addMfmFunction,
- type: 'label',
- }, ...getFunctionList(textArea, textRef)], src);
- });
+export function mfmFunctionPicker(src: HTMLElement | EventTarget | null, textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref) {
+ os.popupMenu([{
+ text: i18n.ts.addMfmFunction,
+ type: 'label',
+ }, ...getFunctionList(textArea, textRef)], src);
}
-function getFunctionList(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref) : object[] {
- const ret: object[] = [];
- MFM_TAGS.forEach(tag => {
- ret.push({
- text: tag,
- icon: 'ti ti-icons',
- action: () => add(textArea, textRef, tag),
- });
- });
- return ret;
+function getFunctionList(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref): MenuItem[] {
+ return MFM_TAGS.map(tag => ({
+ text: tag,
+ icon: 'ti ti-icons',
+ action: () => add(textArea, textRef, tag),
+ }));
}
function add(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref, type: string) {
diff --git a/packages/frontend/src/scripts/player-url-transform.ts b/packages/frontend/src/scripts/player-url-transform.ts
new file mode 100644
index 000000000000..53b2a9e44151
--- /dev/null
+++ b/packages/frontend/src/scripts/player-url-transform.ts
@@ -0,0 +1,26 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { hostname } from '@/config.js';
+
+export function transformPlayerUrl(url: string): string {
+ const urlObj = new URL(url);
+ if (!['https:', 'http:'].includes(urlObj.protocol)) throw new Error('Invalid protocol');
+
+ const urlParams = new URLSearchParams(urlObj.search);
+
+ if (urlObj.hostname === 'player.twitch.tv') {
+ // TwitchはCSPの制約あり
+ // https://dev.twitch.tv/docs/embed/video-and-clips/
+ urlParams.set('parent', hostname);
+ urlParams.set('allowfullscreen', '');
+ urlParams.set('autoplay', 'true');
+ } else {
+ urlParams.set('autoplay', '1');
+ urlParams.set('auto_play', '1');
+ }
+ urlObj.search = urlParams.toString();
+
+ return urlObj.toString();
+}
diff --git a/packages/frontend/src/scripts/please-login.ts b/packages/frontend/src/scripts/please-login.ts
index 363da5f63354..18f05bc7f428 100644
--- a/packages/frontend/src/scripts/please-login.ts
+++ b/packages/frontend/src/scripts/please-login.ts
@@ -8,12 +8,49 @@ import { $i } from '@/account.js';
import { i18n } from '@/i18n.js';
import { popup } from '@/os.js';
-export function pleaseLogin(path?: string) {
+export type OpenOnRemoteOptions = {
+ /**
+ * 外部のMisskey Webで特定のパスを開く
+ */
+ type: 'web';
+
+ /**
+ * 内部パス(例: `/settings`)
+ */
+ path: string;
+} | {
+ /**
+ * 外部のMisskey Webで照会する
+ */
+ type: 'lookup';
+
+ /**
+ * 照会したいエンティティのURL
+ *
+ * (例: `https://misskey.example.com/notes/abcdexxxxyz`)
+ */
+ url: string;
+} | {
+ /**
+ * 外部のMisskeyでノートする
+ */
+ type: 'share';
+
+ /**
+ * `/share` ページに渡すクエリストリング
+ *
+ * @see https://go.misskey-hub.net/spec/share/
+ */
+ params: Record;
+};
+
+export function pleaseLogin(path?: string, openOnRemote?: OpenOnRemoteOptions) {
if ($i) return;
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {
autoSet: true,
- message: i18n.ts.signinRequired,
+ message: openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired,
+ openOnRemote,
}, {
cancelled: () => {
if (path) {
diff --git a/packages/frontend/src/scripts/scroll.ts b/packages/frontend/src/scripts/scroll.ts
index 8edb6fca0539..f0274034b5b3 100644
--- a/packages/frontend/src/scripts/scroll.ts
+++ b/packages/frontend/src/scripts/scroll.ts
@@ -23,6 +23,14 @@ export function getStickyTop(el: HTMLElement, container: HTMLElement | null = nu
return getStickyTop(el.parentElement, container, newTop);
}
+export function getStickyBottom(el: HTMLElement, container: HTMLElement | null = null, bottom = 0) {
+ if (!el.parentElement) return bottom;
+ const data = el.dataset.stickyContainerFooterHeight;
+ const newBottom = data ? Number(data) + bottom : bottom;
+ if (el === container) return newBottom;
+ return getStickyBottom(el.parentElement, container, newBottom);
+}
+
export function getScrollPosition(el: HTMLElement | null): number {
const container = getScrollContainer(el);
return container == null ? window.scrollY : container.scrollTop;
diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts
index fcd59510df18..05f82fce7d27 100644
--- a/packages/frontend/src/scripts/sound.ts
+++ b/packages/frontend/src/scripts/sound.ts
@@ -74,8 +74,6 @@ export const soundsTypes = [
export const operationTypes = [
'noteMy',
'note',
- 'antenna',
- 'channel',
'notification',
'reaction',
] as const;
@@ -126,10 +124,33 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; })
*/
export function playMisskeySfx(operationType: OperationType) {
const sound = defaultStore.state[`sound_${operationType}`];
- if (sound.type == null || !canPlay || ('userActivation' in navigator && !navigator.userActivation.hasBeenActive)) return;
+ playMisskeySfxFile(sound).then((succeed) => {
+ if (!succeed && sound.type === '_driveFile_') {
+ // ドライブファイルが存在しない場合はデフォルトのサウンドを再生する
+ const soundName = defaultStore.def[`sound_${operationType}`].default.type as Exclude;
+ if (_DEV_) console.log(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`);
+ playMisskeySfxFileInternal({
+ type: soundName,
+ volume: sound.volume,
+ });
+ }
+ });
+}
+
+/**
+ * サウンド設定形式で指定された音声を再生する
+ * @param soundStore サウンド設定
+ */
+export async function playMisskeySfxFile(soundStore: SoundStore): Promise {
+ // 連続して再生しない
+ if (!canPlay) return false;
+ // ユーザーアクティベーションが必要な場合はそれがない場合は再生しない
+ if ('userActivation' in navigator && !navigator.userActivation.hasBeenActive) return false;
+ // サウンドがない場合は再生しない
+ if (soundStore.type === null || soundStore.type === '_driveFile_' && !soundStore.fileUrl) return false;
canPlay = false;
- playMisskeySfxFile(sound).finally(() => {
+ return await playMisskeySfxFileInternal(soundStore).finally(() => {
// ごく短時間に音が重複しないように
setTimeout(() => {
canPlay = true;
@@ -137,23 +158,22 @@ export function playMisskeySfx(operationType: OperationType) {
});
}
-/**
- * サウンド設定形式で指定された音声を再生する
- * @param soundStore サウンド設定
- */
-export async function playMisskeySfxFile(soundStore: SoundStore) {
+async function playMisskeySfxFileInternal(soundStore: SoundStore): Promise {
if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) {
- return;
+ return false;
}
const masterVolume = defaultStore.state.sound_masterVolume;
if (isMute() || masterVolume === 0 || soundStore.volume === 0) {
- return;
+ return true; // ミュート時は成功として扱う
}
const url = soundStore.type === '_driveFile_' ? soundStore.fileUrl : `/client-assets/sounds/${soundStore.type}.mp3`;
- const buffer = await loadAudio(url);
- if (!buffer) return;
+ const buffer = await loadAudio(url).catch(() => {
+ return undefined;
+ });
+ if (!buffer) return false;
const volume = soundStore.volume * masterVolume;
createSourceNode(buffer, { volume }).soundSource.start();
+ return true;
}
export async function playUrl(url: string, opts: {
diff --git a/packages/frontend/src/scripts/url.ts b/packages/frontend/src/scripts/url.ts
index e3072b3b7d2b..5a8265af9e10 100644
--- a/packages/frontend/src/scripts/url.ts
+++ b/packages/frontend/src/scripts/url.ts
@@ -21,3 +21,8 @@ export function query(obj: Record): string {
export function appendQuery(url: string, query: string): string {
return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`;
}
+
+export function extractDomain(url: string) {
+ const match = url.match(/^(?:https?:)?(?:\/\/)?(?:[^@\n]+@)?([^:\/\n]+)/im);
+ return match ? match[1] : null;
+}
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 970fa164a494..a5c92c1de6fc 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -454,6 +454,14 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: true,
},
+ confirmWhenRevealingSensitiveMedia: {
+ where: 'device',
+ default: false,
+ },
+ contextMenu: {
+ where: 'device',
+ default: 'app' as 'app' | 'appWithShift' | 'native',
+ },
sound_masterVolume: {
where: 'device',
@@ -479,14 +487,6 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: { type: 'syuilo/n-ea', volume: 1 } as SoundStore,
},
- sound_antenna: {
- where: 'device',
- default: { type: 'syuilo/triple', volume: 1 } as SoundStore,
- },
- sound_channel: {
- where: 'device',
- default: { type: 'syuilo/square-pico', volume: 1 } as SoundStore,
- },
sound_reaction: {
where: 'device',
default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore,
diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss
index e66a50f9ddec..6d6e5f0495a0 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -161,7 +161,7 @@ rt {
vertical-align: -12%;
line-height: 1em;
- &:before {
+ &::before {
font-size: 128%;
}
}
@@ -366,7 +366,7 @@ rt {
._formLinksGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
- grid-gap: 12px;
+ gap: 12px;
}
._beta {
diff --git a/packages/frontend/src/timelines.ts b/packages/frontend/src/timelines.ts
new file mode 100644
index 000000000000..94eda3545e1f
--- /dev/null
+++ b/packages/frontend/src/timelines.ts
@@ -0,0 +1,56 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { $i } from '@/account.js';
+import { instance } from '@/instance.js';
+
+export const basicTimelineTypes = [
+ 'home',
+ 'local',
+ 'social',
+ 'global',
+] as const;
+
+export type BasicTimelineType = typeof basicTimelineTypes[number];
+
+export function isBasicTimeline(timeline: string): timeline is BasicTimelineType {
+ return basicTimelineTypes.includes(timeline as BasicTimelineType);
+}
+
+export function basicTimelineIconClass(timeline: BasicTimelineType): string {
+ switch (timeline) {
+ case 'home':
+ return 'ti ti-home';
+ case 'local':
+ return 'ti ti-planet';
+ case 'social':
+ return 'ti ti-universe';
+ case 'global':
+ return 'ti ti-whirl';
+ }
+}
+
+export function isAvailableBasicTimeline(timeline: BasicTimelineType | undefined | null): boolean {
+ switch (timeline) {
+ case 'home':
+ return $i != null;
+ case 'local':
+ return ($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable);
+ case 'social':
+ return $i != null && $i.policies.ltlAvailable;
+ case 'global':
+ return ($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable);
+ default:
+ return false;
+ }
+}
+
+export function availableBasicTimelines(): BasicTimelineType[] {
+ return basicTimelineTypes.filter(isAvailableBasicTimeline);
+}
+
+export function hasWithReplies(timeline: BasicTimelineType | undefined | null): boolean {
+ return timeline === 'local' || timeline === 'social';
+}
diff --git a/packages/frontend/src/types/page-header.ts b/packages/frontend/src/types/page-header.ts
index 5b828ae790b7..e8b7879a5a51 100644
--- a/packages/frontend/src/types/page-header.ts
+++ b/packages/frontend/src/types/page-header.ts
@@ -8,5 +8,6 @@ export type PageHeaderItem = {
icon: string;
highlighted?: boolean;
asFullButton?: boolean;
+ disabled?: boolean;
handler: (ev: MouseEvent) => void;
};
diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue
index 822b5528372b..d7df2d10f9e9 100644
--- a/packages/frontend/src/ui/_common_/common.vue
+++ b/packages/frontend/src/ui/_common_/common.vue
@@ -227,7 +227,7 @@ if ($i) {
right: 15px;
pointer-events: none;
- &:before {
+ &::before {
content: "";
display: block;
width: 18px;
diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
index 44e23a8537c6..8bdb256e5a60 100644
--- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
+++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
@@ -110,23 +110,29 @@ function more() {
font-weight: bold;
text-align: left;
- &:before {
+ &::before {
content: "";
display: block;
width: calc(100% - 38px);
height: 100%;
margin: auto;
position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
+ inset: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
}
+ &:focus-visible {
+ outline: none;
+
+ &::before {
+ outline: 2px solid var(--focus);
+ outline-offset: -4px;
+ }
+ }
+
&:hover, &.active {
- &:before {
+ &::before {
background: var(--accentLighten);
}
}
@@ -165,28 +171,32 @@ function more() {
box-sizing: border-box;
color: var(--navFg);
- &:hover {
- text-decoration: none;
- color: var(--navHoverFg);
+ &::before {
+ content: "";
+ display: block;
+ width: calc(100% - 24px);
+ height: 100%;
+ margin: auto;
+ position: absolute;
+ inset: 0;
+ border-radius: 999px;
+ background: transparent;
}
- &.active {
- color: var(--navActive);
+ &:focus-visible {
+ outline: none;
+
+ &::before {
+ outline: 2px solid var(--focus);
+ outline-offset: -2px;
+ }
}
&:hover, &.active {
- &:before {
- content: "";
- display: block;
- width: calc(100% - 24px);
- height: 100%;
- margin: auto;
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- border-radius: 999px;
+ text-decoration: none;
+ color: var(--accent);
+
+ &::before {
background: var(--accentedBg);
}
}
diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue
index 4b0bd3ee480b..5f62efe3e96f 100644
--- a/packages/frontend/src/ui/_common_/navbar.vue
+++ b/packages/frontend/src/ui/_common_/navbar.vue
@@ -164,23 +164,29 @@ function more(ev: MouseEvent) {
font-weight: bold;
text-align: left;
- &:before {
+ &::before {
content: "";
display: block;
width: calc(100% - 38px);
height: 100%;
margin: auto;
position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
+ inset: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
}
+ &:focus-visible {
+ outline: none;
+
+ &::before {
+ outline: 2px solid var(--fgOnAccent);
+ outline-offset: -4px;
+ }
+ }
+
&:hover, &.active {
- &:before {
+ &::before {
background: var(--accentLighten);
}
}
@@ -223,30 +229,32 @@ function more(ev: MouseEvent) {
box-sizing: border-box;
color: var(--navFg);
- &:hover {
- text-decoration: none;
- color: var(--navHoverFg);
+ &::before {
+ content: "";
+ display: block;
+ width: calc(100% - 34px);
+ height: 100%;
+ margin: auto;
+ position: absolute;
+ inset: 0;
+ border-radius: 999px;
+ background: transparent;
}
- &.active {
- color: var(--navActive);
+ &:focus-visible {
+ outline: none;
+
+ &::before {
+ outline: 2px solid var(--focus);
+ outline-offset: -2px;
+ }
}
&:hover, &.active {
+ text-decoration: none;
color: var(--accent);
- &:before {
- content: "";
- display: block;
- width: calc(100% - 34px);
- height: 100%;
- margin: auto;
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- border-radius: 999px;
+ &::before {
background: var(--accentedBg);
}
}
@@ -317,23 +325,29 @@ function more(ev: MouseEvent) {
height: 52px;
text-align: center;
- &:before {
+ &::before {
content: "";
display: block;
position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
+ inset: 0;
margin: auto;
width: 52px;
- aspect-ratio: 1/1;
+ aspect-ratio: 1 / 1;
border-radius: 100%;
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
}
+ &:focus-visible {
+ outline: none;
+
+ &::before {
+ outline: 2px solid var(--fgOnAccent);
+ outline-offset: -4px;
+ }
+ }
+
&:hover, &.active {
- &:before {
+ &::before {
background: var(--accentLighten);
}
}
@@ -369,22 +383,32 @@ function more(ev: MouseEvent) {
width: 100%;
text-align: center;
+ &::before {
+ content: "";
+ display: block;
+ height: 100%;
+ aspect-ratio: 1 / 1;
+ margin: auto;
+ position: absolute;
+ inset: 0;
+ border-radius: 999px;
+ background: transparent;
+ }
+
+ &:focus-visible {
+ outline: none;
+
+ &::before {
+ outline: 2px solid var(--focus);
+ outline-offset: -2px;
+ }
+ }
+
&:hover, &.active {
text-decoration: none;
color: var(--accent);
- &:before {
- content: "";
- display: block;
- height: 100%;
- aspect-ratio: 1;
- margin: auto;
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- border-radius: 999px;
+ &::before {
background: var(--accentedBg);
}
diff --git a/packages/frontend/src/ui/_common_/upload.vue b/packages/frontend/src/ui/_common_/upload.vue
index 426cc5d6411a..9b1acccccc0b 100644
--- a/packages/frontend/src/ui/_common_/upload.vue
+++ b/packages/frontend/src/ui/_common_/upload.vue
@@ -41,16 +41,19 @@ const zIndex = os.claimZIndex('high');
pointer-events: none;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
border-radius: 8px;
+
+ &:empty {
+ display: none;
+ }
}
-.mk-uploader:empty {
- display: none;
-}
+
.mk-uploader > ol {
display: block;
margin: 0;
padding: 0;
list-style: none;
}
+
.mk-uploader > ol > li {
display: grid;
margin: 8px 0 0 0;
@@ -62,12 +65,14 @@ const zIndex = os.claimZIndex('high');
grid-template-rows: 1fr 8px;
column-gap: 8px;
box-sizing: content-box;
+
+ &:first-child {
+ margin: 0;
+ box-shadow: none;
+ border-top: none;
+ }
}
-.mk-uploader > ol > li:first-child {
- margin: 0;
- box-shadow: none;
- border-top: none;
-}
+
.mk-uploader > ol > li > .img {
display: block;
background-size: cover;
@@ -75,11 +80,13 @@ const zIndex = os.claimZIndex('high');
grid-column: 1/2;
grid-row: 1/3;
}
+
.mk-uploader > ol > li > .top {
display: flex;
grid-column: 2/3;
grid-row: 1/2;
}
+
.mk-uploader > ol > li > .top > .name {
display: block;
padding: 0 8px 0 0;
@@ -89,10 +96,12 @@ const zIndex = os.claimZIndex('high');
text-overflow: ellipsis;
overflow: hidden;
flex-shrink: 1;
+
+ > i {
+ margin-right: 4px;
+ }
}
-.mk-uploader > ol > li > .top > .name > i {
- margin-right: 4px;
-}
+
.mk-uploader > ol > li > .top > .status {
display: block;
margin: 0 0 0 auto;
@@ -100,18 +109,23 @@ const zIndex = os.claimZIndex('high');
font-size: 0.8em;
flex-shrink: 0;
}
+
.mk-uploader > ol > li > .top > .status > .initing {
}
+
.mk-uploader > ol > li > .top > .status > .kb {
}
+
.mk-uploader > ol > li > .top > .status > .percentage {
display: inline-block;
width: 48px;
text-align: right;
+
+ &::after {
+ content: '%';
+ }
}
-.mk-uploader > ol > li > .top > .status > .percentage:after {
- content: '%';
-}
+
.mk-uploader > ol > li > progress {
display: block;
background: transparent;
@@ -123,12 +137,13 @@ const zIndex = os.claimZIndex('high');
z-index: 2;
width: 100%;
height: 8px;
-}
-.mk-uploader > ol > li > progress::-webkit-progress-value {
- background: var(--accent);
-}
-.mk-uploader > ol > li > progress::-webkit-progress-bar {
- // background: var(--accentAlpha01);
- background: transparent;
+
+ &::-webkit-progress-value {
+ background: var(--accent);
+ }
+ &::-webkit-progress-bar {
+ // background: var(--accentAlpha01);
+ background: transparent;
+ }
}
diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue
index c95b047feb18..ede62ddd7ec6 100644
--- a/packages/frontend/src/ui/deck.vue
+++ b/packages/frontend/src/ui/deck.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:ref="id"
:key="id"
:class="$style.column"
- :column="columns.find(c => c.id === id)"
+ :column="columns.find(c => c.id === id)!"
:isStacked="ids.length > 1"
@headerWheel="onWheel"
/>
@@ -92,10 +92,11 @@ SPDX-License-Identifier: AGPL-3.0-only