diff options
| author | Hazelnoot <acomputerdog@gmail.com> | 2025-03-25 16:14:53 -0400 |
|---|---|---|
| committer | Hazelnoot <acomputerdog@gmail.com> | 2025-03-25 16:14:53 -0400 |
| commit | d8908ef2d8fa84d8e0fc1d30ab90a600a3d88054 (patch) | |
| tree | 0c8d3e0385ce7021c7187ef8b608f1abd87496e5 /packages/frontend/src | |
| parent | merge: enhance: Update de-DE.yml (!949) (diff) | |
| parent | enhance(frontend): 設定の移行を手動でトリガーできるように (diff) | |
| download | sharkey-d8908ef2d8fa84d8e0fc1d30ab90a600a3d88054.tar.gz sharkey-d8908ef2d8fa84d8e0fc1d30ab90a600a3d88054.tar.bz2 sharkey-d8908ef2d8fa84d8e0fc1d30ab90a600a3d88054.zip | |
merge upstream
Diffstat (limited to 'packages/frontend/src')
706 files changed, 15780 insertions, 9175 deletions
diff --git a/packages/frontend/src/_boot_.ts b/packages/frontend/src/_boot_.ts index 5c39955c28..68f998c7f3 100644 --- a/packages/frontend/src/_boot_.ts +++ b/packages/frontend/src/_boot_.ts @@ -10,9 +10,9 @@ import '@/style.scss'; import { mainBoot } from '@/boot/main-boot.js'; import { subBoot } from '@/boot/sub-boot.js'; -const subBootPaths = ['/share', '/auth', '/miauth', '/oauth', '/signup-complete']; +const subBootPaths = ['/share', '/auth', '/miauth', '/oauth', '/signup-complete', '/install-extensions']; -if (subBootPaths.some(i => location.pathname === i || location.pathname.startsWith(i + '/'))) { +if (subBootPaths.some(i => window.location.pathname === i || window.location.pathname.startsWith(i + '/'))) { subBoot(); } else { mainBoot(); diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts deleted file mode 100644 index b3fa151a22..0000000000 --- a/packages/frontend/src/account.ts +++ /dev/null @@ -1,393 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineAsyncComponent, reactive, ref } from 'vue'; -import * as Misskey from 'misskey-js'; -import { apiUrl } from '@@/js/config.js'; -import type { MenuItem, MenuButton } from '@/types/menu.js'; -import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; -import { i18n } from '@/i18n.js'; -import { miLocalStorage } from '@/local-storage.js'; -import { del, get, set } from '@/scripts/idb-proxy.js'; -import { waiting, popup, popupMenu, success, alert } from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { unisonReload, reloadChannel } from '@/scripts/unison-reload.js'; - -// TODO: 他のタブと永続化されたstateを同期 - -type Account = Misskey.entities.MeDetailed & { token: string }; - -const accountData = miLocalStorage.getItem('account'); - -// TODO: 外部からはreadonlyに -export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null; - -export const iAmModerator = $i != null && ($i.isAdmin === true || $i.isModerator === true); -export const iAmAdmin = $i != null && $i.isAdmin; - -export function signinRequired() { - if ($i == null) throw new Error('signin required'); - return $i; -} - -export let notesCount = $i == null ? 0 : $i.notesCount; -export function incNotesCount() { - notesCount++; -} - -export async function signout() { - if (!$i) return; - - waiting(); - miLocalStorage.removeItem('account'); - await removeAccount($i.id); - document.cookie = `token=; path=/; max-age=0${ location.protocol === 'https:' ? '; Secure' : ''}`; - const accounts = await getAccounts(); - - //#region Remove service worker registration - try { - if (navigator.serviceWorker.controller) { - const registration = await navigator.serviceWorker.ready; - const push = await registration.pushManager.getSubscription(); - if (push) { - await window.fetch(`${apiUrl}/sw/unregister`, { - method: 'POST', - body: JSON.stringify({ - i: $i.token, - endpoint: push.endpoint, - }), - headers: { - 'Content-Type': 'application/json', - }, - }); - } - } - - if (accounts.length === 0) { - await navigator.serviceWorker.getRegistrations() - .then(registrations => { - return Promise.all(registrations.map(registration => registration.unregister())); - }); - } - } catch (err) {} - //#endregion - - if (accounts.length > 0) login(accounts[0].token); - else unisonReload('/'); -} - -export async function getAccounts(): Promise<{ id: Account['id'], token: Account['token'] }[]> { - return (await get('accounts')) || []; -} - -export async function addAccount(id: Account['id'], token: Account['token']) { - const accounts = await getAccounts(); - if (!accounts.some(x => x.id === id)) { - await set('accounts', accounts.concat([{ id, token }])); - } -} - -export async function removeAccount(idOrToken: Account['id']) { - const accounts = await getAccounts(); - const i = accounts.findIndex(x => x.id === idOrToken || x.token === idOrToken); - if (i !== -1) accounts.splice(i, 1); - - if (accounts.length > 0) { - await set('accounts', accounts); - } else { - await del('accounts'); - } -} - -function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<Account> { - document.cookie = `token=; path=/; max-age=0${ location.protocol === 'https:' ? '; Secure' : ''}`; - document.cookie = `token=${token}; path=/queue; max-age=86400${ location.protocol === 'https:' ? '; SameSite=Strict; Secure' : ''}`; // bull dashboardの認証とかで使う - - return new Promise((done, fail) => { - window.fetch(`${apiUrl}/i`, { - method: 'POST', - body: JSON.stringify({ - i: token, - }), - headers: { - 'Content-Type': 'application/json', - }, - }) - .then(res => new Promise<Account | { error: Record<string, any> }>((done2, fail2) => { - if (res.status >= 500 && res.status < 600) { - // サーバーエラー(5xx)の場合をrejectとする - // (認証エラーなど4xxはresolve) - return fail2(res); - } - res.json().then(done2, fail2); - })) - .then(async res => { - if ('error' in res) { - if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { - // SUSPENDED - if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { - await showSuspendedDialog(); - } - } else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') { - // USER_IS_DELETED - // アカウントが削除されている - if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { - await alert({ - type: 'error', - title: i18n.ts.accountDeleted, - text: i18n.ts.accountDeletedDescription, - }); - } - } else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') { - // AUTHENTICATION_FAILED - // トークンが無効化されていたりアカウントが削除されたりしている - if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { - await alert({ - type: 'error', - title: i18n.ts.tokenRevoked, - text: i18n.ts.tokenRevokedDescription, - }); - } - } else if (res.error.id === 'd5826d14-3982-4d2e-8011-b9e9f02499ef') { - // rate limited - const timeToWait = res.error.info?.resetMs ?? 1000; - window.setTimeout(() => { - fetchAccount(token, id, forceShowDialog).then(done, fail); - }, timeToWait); - return; - } else { - await alert({ - type: 'error', - title: i18n.ts.failedToFetchAccountInformation, - text: JSON.stringify(res.error), - }); - } - - // rejectかつ理由がtrueの場合、削除対象であることを示す - fail(true); - } else { - (res as Account).token = token; - done(res as Account); - } - }) - .catch(fail); - }); -} - -export function updateAccount(accountData: Account) { - if (!$i) return; - for (const key of Object.keys($i)) { - delete $i[key]; - } - for (const [key, value] of Object.entries(accountData)) { - $i[key] = value; - } - miLocalStorage.setItem('account', JSON.stringify($i)); -} - -export function updateAccountPartial(accountData: Partial<Account>) { - if (!$i) return; - for (const [key, value] of Object.entries(accountData)) { - $i[key] = value; - } - miLocalStorage.setItem('account', JSON.stringify($i)); -} - -export async function refreshAccount() { - if (!$i) return; - return fetchAccount($i.token, $i.id) - .then(updateAccount, reason => { - if (reason === true) return signout(); - return; - }); -} - -export async function login(token: Account['token'], redirect?: string) { - const showing = ref(true); - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { - success: false, - showing: showing, - }, { - closed: () => dispose(), - }); - if (_DEV_) console.log('logging as token ', token); - const me = await fetchAccount(token, undefined, true) - .catch(reason => { - if (reason === true) { - // 削除対象の場合 - removeAccount(token); - } - - showing.value = false; - throw reason; - }); - miLocalStorage.setItem('account', JSON.stringify(me)); - await addAccount(me.id, token); - - if (redirect) { - // 他のタブは再読み込みするだけ - reloadChannel.postMessage(null); - // このページはredirectで指定された先に移動 - location.href = redirect; - return; - } - - unisonReload(); -} - -export async function openAccountMenu(opts: { - includeCurrentAccount?: boolean; - withExtraOperation: boolean; - active?: Misskey.entities.UserDetailed['id']; - onChoose?: (account: Misskey.entities.UserDetailed) => void; -}, ev: MouseEvent) { - if (!$i) return; - - async function switchAccount(account: Misskey.entities.UserDetailed) { - const storedAccounts = await getAccounts(); - const found = storedAccounts.find(x => x.id === account.id); - if (found == null) return; - switchAccountWithToken(found.token); - } - - function switchAccountWithToken(token: string) { - login(token); - } - - const storedAccounts = await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id)); - const accountsPromise = misskeyApi('users/show', { userIds: storedAccounts.map(x => x.id) }); - - function createItem(account: Misskey.entities.UserDetailed) { - return { - type: 'user' as const, - user: account, - active: opts.active != null ? opts.active === account.id : false, - action: () => { - if (opts.onChoose) { - opts.onChoose(account); - } else { - switchAccount(account); - } - }, - }; - } - - const accountItemPromises = storedAccounts.map(a => new Promise<ReturnType<typeof createItem> | MenuButton>(res => { - accountsPromise.then(accounts => { - const account = accounts.find(x => x.id === a.id); - if (account == null) return res({ - type: 'button' as const, - text: a.id, - action: () => { - switchAccountWithToken(a.token); - }, - }); - - res(createItem(account)); - }); - })); - - const menuItems: MenuItem[] = []; - - if (opts.withExtraOperation) { - menuItems.push({ - type: 'link', - text: i18n.ts.profile, - to: `/@${$i.username}`, - avatar: $i, - }, { - type: 'divider', - }); - - if (opts.includeCurrentAccount) { - menuItems.push(createItem($i)); - } - - menuItems.push(...accountItemPromises); - - menuItems.push({ - type: 'parent', - icon: 'ti ti-plus', - text: i18n.ts.addAccount, - children: [{ - text: i18n.ts.existingAccount, - action: () => { - getAccountWithSigninDialog().then(res => { - if (res != null) { - success(); - } - }); - }, - }, { - text: i18n.ts.createAccount, - action: () => { - getAccountWithSignupDialog().then(res => { - if (res != null) { - switchAccountWithToken(res.token); - } - }); - }, - }], - }, { - type: 'link', - icon: 'ti ti-users', - text: i18n.ts.manageAccounts, - to: '/settings/accounts', - }, { - type: 'button' as const, - icon: 'ph-power ph-bold ph-lg', - text: i18n.ts.logout, - action: () => { signout(); }, - }); - } else { - if (opts.includeCurrentAccount) { - menuItems.push(createItem($i)); - } - - menuItems.push(...accountItemPromises); - } - - popupMenu(menuItems, ev.currentTarget ?? ev.target, { - align: 'left', - }); -} - -export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> { - return new Promise((resolve) => { - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { - done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => { - await addAccount(res.id, res.i); - resolve({ id: res.id, token: res.i }); - }, - cancelled: () => { - resolve(null); - }, - closed: () => { - dispose(); - }, - }); - }); -} - -export function getAccountWithSignupDialog(): Promise<{ id: string, token: string } | null> { - return new Promise((resolve) => { - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { - done: async (res: Misskey.entities.SignupResponse) => { - await addAccount(res.id, res.token); - resolve({ id: res.id, token: res.token }); - }, - cancelled: () => { - resolve(null); - }, - closed: () => { - dispose(); - }, - }); - }); -} - -if (_DEV_) { - (window as any).$i = $i; -} diff --git a/packages/frontend/src/accounts.ts b/packages/frontend/src/accounts.ts new file mode 100644 index 0000000000..a25f3c51d1 --- /dev/null +++ b/packages/frontend/src/accounts.ts @@ -0,0 +1,339 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineAsyncComponent, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { apiUrl, host } from '@@/js/config.js'; +import type { MenuItem } from '@/types/menu.js'; +import { showSuspendedDialog } from '@/utility/show-suspended-dialog.js'; +import { i18n } from '@/i18n.js'; +import { miLocalStorage } from '@/local-storage.js'; +import { waiting, popup, popupMenu, success, alert } from '@/os.js'; +import { unisonReload, reloadChannel } from '@/utility/unison-reload.js'; +import { prefer } from '@/preferences.js'; +import { store } from '@/store.js'; +import { $i } from '@/i.js'; +import { signout } from '@/signout.js'; + +type AccountWithToken = Misskey.entities.MeDetailed & { token: string }; + +export async function getAccounts(): Promise<{ + host: string; + user: Misskey.entities.User; + token: string | null; +}[]> { + const tokens = store.s.accountTokens; + const accounts = prefer.s.accounts; + return accounts.map(([host, user]) => ({ + host, + user, + token: tokens[host + '/' + user.id] ?? null, + })); +} + +async function addAccount(host: string, user: Misskey.entities.User, token: AccountWithToken['token']) { + if (!prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) { + store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + user.id]: token }); + prefer.commit('accounts', [...prefer.s.accounts, [host, user]]); + } +} + +export async function removeAccount(host: string, id: AccountWithToken['id']) { + const tokens = JSON.parse(JSON.stringify(store.s.accountTokens)); + delete tokens[host + '/' + id]; + store.set('accountTokens', tokens); + prefer.commit('accounts', prefer.s.accounts.filter(x => x[0] !== host || x[1].id !== id)); +} + +const isAccountDeleted = Symbol('isAccountDeleted'); + +function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<Misskey.entities.MeDetailed> { + return new Promise((done, fail) => { + window.fetch(`${apiUrl}/i`, { + method: 'POST', + body: JSON.stringify({ + i: token, + }), + headers: { + 'Content-Type': 'application/json', + }, + }) + .then(res => new Promise<Misskey.entities.MeDetailed | { error: Record<string, any> }>((done2, fail2) => { + if (res.status >= 500 && res.status < 600) { + // サーバーエラー(5xx)の場合をrejectとする + // (認証エラーなど4xxはresolve) + return fail2(res); + } + res.json().then(done2, fail2); + })) + .then(async res => { + if ('error' in res) { + if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { + // SUSPENDED + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await showSuspendedDialog(); + } + } else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') { + // USER_IS_DELETED + // アカウントが削除されている + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ + type: 'error', + title: i18n.ts.accountDeleted, + text: i18n.ts.accountDeletedDescription, + }); + } + } else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') { + // AUTHENTICATION_FAILED + // トークンが無効化されていたりアカウントが削除されたりしている + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ + type: 'error', + title: i18n.ts.tokenRevoked, + text: i18n.ts.tokenRevokedDescription, + }); + } + } else { + await alert({ + type: 'error', + title: i18n.ts.failedToFetchAccountInformation, + text: JSON.stringify(res.error), + }); + } + + fail(isAccountDeleted); + } else { + done(res); + } + }) + .catch(fail); + }); +} + +export function updateCurrentAccount(accountData: Misskey.entities.MeDetailed) { + if (!$i) return; + const token = $i.token; + for (const key of Object.keys($i)) { + delete $i[key]; + } + for (const [key, value] of Object.entries(accountData)) { + $i[key] = value; + } + prefer.commit('accounts', prefer.s.accounts.map(([host, user]) => { + // TODO: $iのホストも比較したいけど通常null + if (user.id === $i.id) { + return [host, $i]; + } else { + return [host, user]; + } + })); + $i.token = token; + miLocalStorage.setItem('account', JSON.stringify($i)); +} + +export function updateCurrentAccountPartial(accountData: Partial<Misskey.entities.MeDetailed>) { + if (!$i) return; + for (const [key, value] of Object.entries(accountData)) { + $i[key] = value; + } + prefer.commit('accounts', prefer.s.accounts.map(([host, user]) => { + // TODO: $iのホストも比較したいけど通常null + if (user.id === $i.id) { + const newUser = JSON.parse(JSON.stringify($i)); + for (const [key, value] of Object.entries(accountData)) { + newUser[key] = value; + } + return [host, newUser]; + } + return [host, user]; + })); + miLocalStorage.setItem('account', JSON.stringify($i)); +} + +export async function refreshCurrentAccount() { + if (!$i) return; + return fetchAccount($i.token, $i.id).then(updateCurrentAccount).catch(reason => { + if (reason === isAccountDeleted) { + removeAccount(host, $i.id); + if (Object.keys(store.s.accountTokens).length > 0) { + login(Object.values(store.s.accountTokens)[0]); + } else { + signout(); + } + } + }); +} + +export async function login(token: AccountWithToken['token'], redirect?: string) { + const showing = ref(true); + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { + success: false, + showing: showing, + }, { + closed: () => dispose(), + }); + + const me = await fetchAccount(token, undefined, true).catch(reason => { + showing.value = false; + throw reason; + }); + + miLocalStorage.setItem('account', JSON.stringify({ + ...me, + token, + })); + + await addAccount(host, me, token); + + if (redirect) { + // 他のタブは再読み込みするだけ + reloadChannel.postMessage(null); + // このページはredirectで指定された先に移動 + window.location.href = redirect; + return; + } + + unisonReload(); +} + +export async function switchAccount(host: string, id: string) { + const token = store.s.accountTokens[host + '/' + id]; + if (token) { + login(token); + } else { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { + done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => { + store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + res.id]: res.i }); + login(res.i); + }, + closed: () => { + dispose(); + }, + }); + } +} + +export async function openAccountMenu(opts: { + includeCurrentAccount?: boolean; + withExtraOperation: boolean; + active?: Misskey.entities.User['id']; + onChoose?: (account: Misskey.entities.User) => void; +}, ev: MouseEvent) { + if (!$i) return; + + function createItem(host: string, account: Misskey.entities.User): MenuItem { + return { + type: 'user' as const, + user: account, + active: opts.active != null ? opts.active === account.id : false, + action: async () => { + if (opts.onChoose) { + opts.onChoose(account); + } else { + switchAccount(host, account.id); + } + }, + }; + } + + const menuItems: MenuItem[] = []; + + // TODO: $iのホストも比較したいけど通常null + const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.user.id !== $i.id))).map(a => createItem(a.host, a.user)); + + if (opts.withExtraOperation) { + menuItems.push({ + type: 'link', + text: i18n.ts.profile, + to: `/@${$i.username}`, + avatar: $i, + }, { + type: 'divider', + }); + + if (opts.includeCurrentAccount) { + menuItems.push(createItem(host, $i)); + } + + menuItems.push(...accountItems); + + menuItems.push({ + type: 'parent', + icon: 'ti ti-plus', + text: i18n.ts.addAccount, + children: [{ + text: i18n.ts.existingAccount, + action: () => { + getAccountWithSigninDialog().then(res => { + if (res != null) { + success(); + } + }); + }, + }, { + text: i18n.ts.createAccount, + action: () => { + getAccountWithSignupDialog().then(res => { + if (res != null) { + switchAccount(host, res.id); + } + }); + }, + }], + }, { + type: 'link', + icon: 'ti ti-users', + text: i18n.ts.manageAccounts, + to: '/settings/accounts', + }); + } else { + if (opts.includeCurrentAccount) { + menuItems.push(createItem(host, $i)); + } + + menuItems.push(...accountItems); + } + + popupMenu(menuItems, ev.currentTarget ?? ev.target, { + align: 'left', + }); +} + +export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> { + return new Promise((resolve) => { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { + done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => { + const user = await fetchAccount(res.i, res.id, true); + await addAccount(host, user, res.i); + resolve({ id: res.id, token: res.i }); + }, + cancelled: () => { + resolve(null); + }, + closed: () => { + dispose(); + }, + }); + }); +} + +export function getAccountWithSignupDialog(): Promise<{ id: string, token: string } | null> { + return new Promise((resolve) => { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { + done: async (res: Misskey.entities.SignupResponse) => { + const user = JSON.parse(JSON.stringify(res)); + delete user.token; + await addAccount(host, user, res.token); + resolve({ id: res.id, token: res.token }); + }, + cancelled: () => { + resolve(null); + }, + closed: () => { + dispose(); + }, + }); + }); +} diff --git a/packages/frontend/src/scripts/aiscript/api.ts b/packages/frontend/src/aiscript/api.ts index e203c51bba..e7e396023d 100644 --- a/packages/frontend/src/scripts/aiscript/api.ts +++ b/packages/frontend/src/aiscript/api.ts @@ -8,8 +8,8 @@ import * as Misskey from 'misskey-js'; import { url, lang } from '@@/js/config.js'; import { assertStringAndIsIn } from './common.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { $i } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { $i } from '@/i.js'; import { miLocalStorage } from '@/local-storage.js'; import { customEmojis } from '@/custom-emojis.js'; @@ -76,7 +76,7 @@ export function createAiScriptEnv(opts: { storageKey: string, token?: string }) // バグがあればundefinedもあり得るため念のため if (typeof token.value !== 'string') throw new Error('invalid token'); } - const actualToken: string|null = token?.value ?? opts.token ?? null; + const actualToken: string | null = token?.value ?? opts.token ?? null; if (param == null) { throw new errors.AiScriptRuntimeError('expected param'); } diff --git a/packages/frontend/src/scripts/aiscript/common.ts b/packages/frontend/src/aiscript/common.ts index de6fa1d633..ba5dfb8368 100644 --- a/packages/frontend/src/scripts/aiscript/common.ts +++ b/packages/frontend/src/aiscript/common.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { errors, utils, type values } from '@syuilo/aiscript'; +import { errors, utils } from '@syuilo/aiscript'; +import type { values } from '@syuilo/aiscript'; export function assertStringAndIsIn<A extends readonly string[]>(value: values.Value | undefined, expects: A): asserts value is values.VStr & { value: A[number] } { utils.assertString(value); diff --git a/packages/frontend/src/scripts/aiscript/ui.ts b/packages/frontend/src/aiscript/ui.ts index ca92b27ff5..46e193f7c1 100644 --- a/packages/frontend/src/scripts/aiscript/ui.ts +++ b/packages/frontend/src/aiscript/ui.ts @@ -5,7 +5,8 @@ import { utils, values } from '@syuilo/aiscript'; import { v4 as uuid } from 'uuid'; -import { ref, Ref } from 'vue'; +import { ref } from 'vue'; +import type { Ref } from 'vue'; import * as Misskey from 'misskey-js'; import { assertStringAndIsIn } from './common.js'; diff --git a/packages/frontend/src/analytics.ts b/packages/frontend/src/analytics.ts new file mode 100644 index 0000000000..e07a4e9258 --- /dev/null +++ b/packages/frontend/src/analytics.ts @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import type { AnalyticsInstance, AnalyticsPlugin } from 'analytics'; + +/** + * analytics moduleを読み込まなくても動作するようにするためのラッパー + */ +class AnalyticsProxy implements AnalyticsInstance { + private analytics?: AnalyticsInstance; + + constructor(analytics?: AnalyticsInstance) { + if (analytics) { + this.analytics = analytics; + } + } + + public setAnalytics(analytics: AnalyticsInstance) { + if (this.analytics) { + throw new Error('Analytics instance already exists.'); + } + this.analytics = analytics; + } + + public identify(...args: Parameters<AnalyticsInstance['identify']>) { + return this.analytics?.identify(...args) ?? Promise.resolve(); + } + + public track(...args: Parameters<AnalyticsInstance['track']>) { + return this.analytics?.track(...args) ?? Promise.resolve(); + } + + public page(...args: Parameters<AnalyticsInstance['page']>) { + return this.analytics?.page(...args) ?? Promise.resolve(); + } + + public user(...args: Parameters<AnalyticsInstance['user']>) { + return this.analytics?.user(...args) ?? Promise.resolve(); + } + + public reset(...args: Parameters<AnalyticsInstance['reset']>) { + return this.analytics?.reset(...args) ?? Promise.resolve(); + } + + public ready(...args: Parameters<AnalyticsInstance['ready']>) { + return this.analytics?.ready(...args) ?? function () { void 0; }; + } + + public on(...args: Parameters<AnalyticsInstance['on']>) { + return this.analytics?.on(...args) ?? function () { void 0; }; + } + + public once(...args: Parameters<AnalyticsInstance['once']>) { + return this.analytics?.once(...args) ?? function () { void 0; }; + } + + public getState(...args: Parameters<AnalyticsInstance['getState']>) { + return this.analytics?.getState(...args) ?? Promise.resolve(); + } + + public get storage() { + return this.analytics?.storage ?? { + getItem: () => null, + setItem: () => void 0, + removeItem: () => void 0, + }; + } + + public get plugins() { + return this.analytics?.plugins ?? { + enable: (p, c) => Promise.resolve(c ? c() : void 0), + disable: (p, c) => Promise.resolve(c ? c() : void 0), + }; + } +} + +export const analytics = new AnalyticsProxy(); + +export async function initAnalytics(instance: Misskey.entities.MetaDetailed) { + // アナリティクスプロバイダに関する設定がひとつもない場合は、アナリティクスモジュールを読み込まない + if (!instance.googleAnalyticsMeasurementId) { + return; + } + + const { default: Analytics } = await import('analytics'); + const plugins: AnalyticsPlugin[] = []; + + // Google Analytics + if (instance.googleAnalyticsMeasurementId) { + const { default: googleAnalytics } = await import('@analytics/google-analytics'); + + plugins.push(googleAnalytics({ + measurementIds: [instance.googleAnalyticsMeasurementId], + debug: _DEV_, + })); + } + + analytics.setAnalytics(Analytics({ + app: 'misskey', + version: _VERSION_, + debug: _DEV_, + plugins, + })); +} diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 46ec4533ec..62cc74ea09 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -3,27 +3,31 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { computed, watch, version as vueVersion, App } from 'vue'; +import { computed, watch, version as vueVersion } from 'vue'; import { compareVersions } from 'compare-versions'; import { version, lang, langsVersion, updateLocale, locale } from '@@/js/config.js'; +import defaultLightTheme from '@@/themes/l-light.json5'; +import defaultDarkTheme from '@@/themes/d-green-lime.json5'; +import type { App } from 'vue'; import widgets from '@/widgets/index.js'; import directives from '@/directives/index.js'; import components from '@/components/index.js'; -import { applyTheme } from '@/scripts/theme.js'; -import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js'; +import { applyTheme } from '@/theme.js'; +import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js'; import { updateI18n, i18n } from '@/i18n.js'; -import { $i, refreshAccount, login } from '@/account.js'; -import { defaultStore, ColdDeviceStorage } from '@/store.js'; +import { refreshCurrentAccount, login } from '@/accounts.js'; +import { store } from '@/store.js'; import { fetchInstance, instance } from '@/instance.js'; -import { deviceKind, updateDeviceKind } from '@/scripts/device-kind.js'; -import { reloadChannel } from '@/scripts/unison-reload.js'; -import { getUrlWithoutLoginId } from '@/scripts/login-id.js'; -import { getAccountFromId } from '@/scripts/get-account-from-id.js'; +import { deviceKind, updateDeviceKind } from '@/utility/device-kind.js'; +import { reloadChannel } from '@/utility/unison-reload.js'; +import { getUrlWithoutLoginId } from '@/utility/login-id.js'; +import { getAccountFromId } from '@/utility/get-account-from-id.js'; import { deckStore } from '@/ui/deck/deck-store.js'; +import { analytics, initAnalytics } from '@/analytics.js'; import { miLocalStorage } from '@/local-storage.js'; import { fetchCustomEmojis } from '@/custom-emojis.js'; -import { setupRouter } from '@/router/main.js'; -import { createMainRouter } from '@/router/definition.js'; +import { prefer } from '@/preferences.js'; +import { $i } from '@/i.js'; export async function common(createVue: () => App<Element>) { console.info(`Sharkey v${version}`); @@ -33,11 +37,6 @@ export async function common(createVue: () => App<Element>) { console.info(`vue ${vueVersion}`); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (window as any).$i = $i; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (window as any).$store = defaultStore; - window.addEventListener('error', event => { console.error(event); /* @@ -97,32 +96,32 @@ export async function common(createVue: () => App<Element>) { //#endregion // タッチデバイスでCSSの:hoverを機能させる - document.addEventListener('touchend', () => {}, { passive: true }); + window.document.addEventListener('touchend', () => {}, { passive: true }); // URLに#pswpを含む場合は取り除く - if (location.hash === '#pswp') { - history.replaceState(null, '', location.href.replace('#pswp', '')); + if (window.location.hash === '#pswp') { + window.history.replaceState(null, '', window.location.href.replace('#pswp', '')); } // 一斉リロード reloadChannel.addEventListener('message', path => { - if (path !== null) location.href = path; - else location.reload(); + if (path !== null) window.location.href = path; + else window.location.reload(); }); // If mobile, insert the viewport meta tag if (['smartphone', 'tablet'].includes(deviceKind)) { - const viewport = document.getElementsByName('viewport').item(0); + const viewport = window.document.getElementsByName('viewport').item(0); viewport.setAttribute('content', `${viewport.getAttribute('content')}, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`); } //#region Set lang attr - const html = document.documentElement; + const html = window.document.documentElement; html.setAttribute('lang', lang); //#endregion - await defaultStore.ready; + await store.ready; await deckStore.ready; const fetchInstanceMetaPromise = fetchInstance(); @@ -132,11 +131,11 @@ export async function common(createVue: () => App<Element>) { }); //#region loginId - const params = new URLSearchParams(location.search); + const params = new URLSearchParams(window.location.search); const loginId = params.get('loginId'); if (loginId) { - const target = getUrlWithoutLoginId(location.href); + const target = getUrlWithoutLoginId(window.location.href); if (!$i || $i.id !== loginId) { const account = await getAccountFromId(loginId); @@ -145,71 +144,78 @@ export async function common(createVue: () => App<Element>) { } } - history.replaceState({ misskey: 'loginId' }, '', target); + window.history.replaceState({ misskey: 'loginId' }, '', target); } //#endregion // NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため) - watch(defaultStore.reactiveState.darkMode, (darkMode) => { - applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme')); + watch(store.r.darkMode, (darkMode) => { + applyTheme(darkMode + ? (prefer.s.darkTheme ?? defaultDarkTheme) + : (prefer.s.lightTheme ?? defaultLightTheme), + ); }, { immediate: miLocalStorage.getItem('theme') == null }); - document.documentElement.dataset.colorScheme = defaultStore.state.darkMode ? 'dark' : 'light'; + window.document.documentElement.dataset.colorScheme = store.s.darkMode ? 'dark' : 'light'; - const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme')); - const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme')); + const darkTheme = prefer.model('darkTheme'); + const lightTheme = prefer.model('lightTheme'); watch(darkTheme, (theme) => { - if (defaultStore.state.darkMode) { - applyTheme(theme); + if (store.s.darkMode) { + applyTheme(theme ?? defaultDarkTheme); } }); watch(lightTheme, (theme) => { - if (!defaultStore.state.darkMode) { - applyTheme(theme); + if (!store.s.darkMode) { + applyTheme(theme ?? defaultLightTheme); } }); //#region Sync dark mode - if (ColdDeviceStorage.get('syncDeviceDarkMode')) { - defaultStore.set('darkMode', isDeviceDarkmode()); + if (prefer.s.syncDeviceDarkMode) { + store.set('darkMode', isDeviceDarkmode()); } window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => { - if (ColdDeviceStorage.get('syncDeviceDarkMode')) { - defaultStore.set('darkMode', mql.matches); + if (prefer.s.syncDeviceDarkMode) { + store.set('darkMode', mql.matches); } }); //#endregion + if (prefer.s.darkTheme && store.s.darkMode) { + if (miLocalStorage.getItem('themeId') !== prefer.s.darkTheme.id) applyTheme(prefer.s.darkTheme); + } else if (prefer.s.lightTheme && !store.s.darkMode) { + if (miLocalStorage.getItem('themeId') !== prefer.s.lightTheme.id) applyTheme(prefer.s.lightTheme); + } + fetchInstanceMetaPromise.then(() => { - if (defaultStore.state.themeInitial) { - if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme)); - if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme)); - defaultStore.set('themeInitial', false); - } + // TODO: instance.defaultLightTheme/instance.defaultDarkThemeが不正な形式だった場合のケア + if (prefer.s.lightTheme == null && instance.defaultLightTheme != null) prefer.commit('lightTheme', JSON.parse(instance.defaultLightTheme)); + if (prefer.s.darkTheme == null && instance.defaultDarkTheme != null) prefer.commit('darkTheme', JSON.parse(instance.defaultDarkTheme)); }); - watch(defaultStore.reactiveState.overridedDeviceKind, (kind) => { + watch(prefer.r.overridedDeviceKind, (kind) => { updateDeviceKind(kind); }, { immediate: true }); - watch(defaultStore.reactiveState.useBlurEffectForModal, v => { - document.documentElement.style.setProperty('--MI-modalBgFilter', v ? 'blur(4px)' : 'none'); + watch(prefer.r.useBlurEffectForModal, v => { + window.document.documentElement.style.setProperty('--MI-modalBgFilter', v ? 'blur(4px)' : 'none'); }, { immediate: true }); - watch(defaultStore.reactiveState.useBlurEffect, v => { + watch(prefer.r.useBlurEffect, v => { if (v) { - document.documentElement.style.removeProperty('--MI-blur'); + window.document.documentElement.style.removeProperty('--MI-blur'); } else { - document.documentElement.style.setProperty('--MI-blur', 'none'); + window.document.documentElement.style.setProperty('--MI-blur', 'none'); } }, { immediate: true }); // Keep screen on - const onVisibilityChange = () => document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') { + const onVisibilityChange = () => window.document.addEventListener('visibilitychange', () => { + if (window.document.visibilityState === 'visible') { try { navigator.wakeLock.request('screen'); } catch (err) { @@ -217,13 +223,13 @@ export async function common(createVue: () => App<Element>) { } } }); - if (defaultStore.state.keepScreenOn && 'wakeLock' in navigator) { + if (prefer.s.keepScreenOn && 'wakeLock' in navigator) { navigator.wakeLock.request('screen') .then(onVisibilityChange) .catch(() => { // On WebKit-based browsers, user activation is required to send wake lock request // https://webkit.org/blog/13862/the-user-activation-api/ - document.addEventListener( + window.document.addEventListener( 'click', () => navigator.wakeLock.request('screen').then(onVisibilityChange), { once: true }, @@ -231,13 +237,17 @@ export async function common(createVue: () => App<Element>) { }); } + if (prefer.s.makeEveryTextElementsSelectable) { + window.document.documentElement.classList.add('forceSelectableAll'); + } + //#region Fetch user if ($i && $i.token) { if (_DEV_) { console.log('account cache found. refreshing...'); } - refreshAccount(); + refreshCurrentAccount(); } //#endregion @@ -245,9 +255,20 @@ export async function common(createVue: () => App<Element>) { await fetchCustomEmojis(); } catch (err) { /* empty */ } - const app = createVue(); + // analytics + fetchInstanceMetaPromise.then(async () => { + await initAnalytics(instance); - setupRouter(app, createMainRouter); + if ($i) { + analytics.identify($i.id); + } + + analytics.page({ + path: window.location.pathname, + }); + }); + + const app = createVue(); if (_DEV_) { app.config.performance = true; @@ -262,16 +283,16 @@ export async function common(createVue: () => App<Element>) { const rootEl = ((): HTMLElement => { const MISSKEY_MOUNT_DIV_ID = 'sharkey_app'; - const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID); + const currentRoot = window.document.getElementById(MISSKEY_MOUNT_DIV_ID); if (currentRoot) { console.warn('multiple import detected'); return currentRoot; } - const root = document.createElement('div'); + const root = window.document.createElement('div'); root.id = MISSKEY_MOUNT_DIV_ID; - document.body.appendChild(root); + window.document.body.appendChild(root); return root; })(); @@ -284,34 +305,37 @@ export async function common(createVue: () => App<Element>) { removeSplash(); //#region Self-XSS 対策メッセージ - console.log( - `%c${i18n.ts._selfXssPrevention.warning}`, - 'color: #f00; background-color: #ff0; font-size: 36px; padding: 4px;', - ); - console.log( - `%c${i18n.ts._selfXssPrevention.title}`, - 'color: #f00; font-weight: 900; font-family: "Hiragino Sans W9", "Hiragino Kaku Gothic ProN", sans-serif; font-size: 24px;', - ); - console.log( - `%c${i18n.ts._selfXssPrevention.description1}`, - 'font-size: 16px; font-weight: 700;', - ); - console.log( - `%c${i18n.ts._selfXssPrevention.description2}`, - 'font-size: 16px;', - 'font-size: 20px; font-weight: 700; color: #f00;', - ); - console.log(i18n.tsx._selfXssPrevention.description3({ link: 'https://misskey-hub.net/docs/for-users/resources/self-xss/' })); + if (!_DEV_) { + console.log( + `%c${i18n.ts._selfXssPrevention.warning}`, + 'color: #f00; background-color: #ff0; font-size: 36px; padding: 4px;', + ); + console.log( + `%c${i18n.ts._selfXssPrevention.title}`, + 'color: #f00; font-weight: 900; font-family: "Hiragino Sans W9", "Hiragino Kaku Gothic ProN", sans-serif; font-size: 24px;', + ); + console.log( + `%c${i18n.ts._selfXssPrevention.description1}`, + 'font-size: 16px; font-weight: 700;', + ); + console.log( + `%c${i18n.ts._selfXssPrevention.description2}`, + 'font-size: 16px;', + 'font-size: 20px; font-weight: 700; color: #f00;', + ); + console.log(i18n.tsx._selfXssPrevention.description3({ link: 'https://misskey-hub.net/docs/for-users/resources/self-xss/' })); + } //#endregion return { isClientUpdated, + lastVersion, app, }; } function removeSplash() { - const splash = document.getElementById('splash'); + const splash = window.document.getElementById('splash'); if (splash) { splash.style.opacity = '0'; splash.style.pointerEvents = 'none'; diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 6c544feb2a..38471cd86a 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -5,36 +5,43 @@ import { createApp, defineAsyncComponent, markRaw } from 'vue'; import { ui } from '@@/js/config.js'; +import * as Misskey from 'misskey-js'; +import { compareVersions } from 'compare-versions'; import { common } from './common.js'; -import type * as Misskey from 'misskey-js'; import type { Component } from 'vue'; +import type { Keymap } from '@/utility/hotkey.js'; import { i18n } from '@/i18n.js'; -import { alert, confirm, popup, post, toast } from '@/os.js'; +import { alert, confirm, popup, post } from '@/os.js'; import { useStream } from '@/stream.js'; -import * as sound from '@/scripts/sound.js'; -import { $i, signout, updateAccountPartial } from '@/account.js'; +import * as sound from '@/utility/sound.js'; +import { $i } from '@/i.js'; import { instance } from '@/instance.js'; -import { ColdDeviceStorage, defaultStore } from '@/store.js'; -import { reactionPicker } from '@/scripts/reaction-picker.js'; +import { store } from '@/store.js'; +import { reactionPicker } from '@/utility/reaction-picker.js'; import { miLocalStorage } from '@/local-storage.js'; -import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js'; -import { initializeSw } from '@/scripts/initialize-sw.js'; +import { claimAchievement, claimedAchievements } from '@/utility/achievements.js'; +import { initializeSw } from '@/utility/initialize-sw.js'; import { deckStore } from '@/ui/deck/deck-store.js'; -import { emojiPicker } from '@/scripts/emoji-picker.js'; -import { mainRouter } from '@/router/main.js'; -import { setFavIconDot } from '@/scripts/favicon-dot.js'; -import { type Keymap, makeHotkey } from '@/scripts/hotkey.js'; +import { emojiPicker } from '@/utility/emoji-picker.js'; +import { mainRouter } from '@/router.js'; +import { setFavIconDot } from '@/utility/favicon-dot.js'; +import { type Keymap, makeHotkey } from '@/utility/hotkey.js'; import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js'; +import { prefer } from '@/preferences.js'; +import { launchPlugins } from '@/plugin.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; +import { signout } from '@/signout.js'; +import { migrateOldSettings } from '@/pref-migrate.js'; export async function mainBoot() { - const { isClientUpdated } = await common(() => { + const { isClientUpdated, lastVersion } = await common(() => { let uiStyle = ui; const searchParams = new URLSearchParams(window.location.search); if (!$i) uiStyle = 'visitor'; if (searchParams.has('zen')) uiStyle = 'zen'; - if (uiStyle === 'deck' && deckStore.state.useSimpleUiForNonRootPages && location.pathname !== '/') uiStyle = 'zen'; + if (uiStyle === 'deck' && prefer.s['deck.useSimpleUiForNonRootPages'] && window.location.pathname !== '/') uiStyle = 'zen'; if (searchParams.has('ui')) uiStyle = searchParams.get('ui'); @@ -67,13 +74,23 @@ export async function mainBoot() { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, { closed: () => dispose(), }); + + // prefereces migration + // TODO: そのうち消す + if (lastVersion && (compareVersions('2025.3.2-alpha.0', lastVersion) === 1)) { + console.log('Preferences migration'); + + migrateOldSettings(); + } } const stream = useStream(); let reloadDialogShowing = false; stream.on('_disconnected_', async () => { - if (defaultStore.state.serverDisconnectedBehavior === 'dialog') { + if (prefer.s.serverDisconnectedBehavior === 'reload') { + window.location.reload(); + } else if (prefer.s.serverDisconnectedBehavior === 'dialog') { if (reloadDialogShowing) return; reloadDialogShowing = true; const { canceled } = await confirm({ @@ -83,7 +100,7 @@ export async function mainBoot() { }); reloadDialogShowing = false; if (!canceled) { - location.reload(); + window.location.reload(); } } }); @@ -100,30 +117,24 @@ export async function mainBoot() { removeCustomEmojis(emojiData.emojis); }); - for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) { - import('@/plugin.js').then(async ({ install }) => { - // Workaround for https://bugs.webkit.org/show_bug.cgi?id=242740 - await new Promise(r => setTimeout(r, 0)); - install(plugin); - }); - } + launchPlugins(); try { - if (defaultStore.state.enableSeasonalScreenEffect) { + if (prefer.s.enableSeasonalScreenEffect) { const month = new Date().getMonth() + 1; - if (defaultStore.state.hemisphere === 'S') { + if (prefer.s.hemisphere === 'S') { // ▼南半球 if (month === 7 || month === 8) { - const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect; + const SnowfallEffect = (await import('@/utility/snowfall-effect.js')).SnowfallEffect; new SnowfallEffect({}).render(); } } else { // ▼北半球 if (month === 12 || month === 1) { - const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect; + const SnowfallEffect = (await import('@/utility/snowfall-effect.js')).SnowfallEffect; new SnowfallEffect({}).render(); } else if (month === 3 || month === 4) { - const SakuraEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect; + const SakuraEffect = (await import('@/utility/snowfall-effect.js')).SnowfallEffect; new SakuraEffect({ sakura: true, }).render(); @@ -136,8 +147,8 @@ export async function mainBoot() { } if ($i) { - defaultStore.loaded.then(() => { - if (defaultStore.state.accountSetupWizard !== -1) { + store.loaded.then(async () => { + if (store.s.accountSetupWizard !== -1) { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, { closed: () => dispose(), }); @@ -152,7 +163,7 @@ export async function mainBoot() { }); } - function onAnnouncementCreated (ev: { announcement: Misskey.entities.Announcement }) { + function onAnnouncementCreated(ev: { announcement: Misskey.entities.Announcement }) { const announcement = ev.announcement; if (announcement.display === 'dialog') { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { @@ -260,7 +271,7 @@ export async function mainBoot() { let lastVisibilityChangedAt = Date.now(); function claimPlainLucky() { - if (document.visibilityState !== 'visible') { + if (window.document.visibilityState !== 'visible') { if (justPlainLuckyTimer != null) window.clearTimeout(justPlainLuckyTimer); return; } @@ -275,7 +286,7 @@ export async function mainBoot() { window.addEventListener('visibilitychange', () => { const now = Date.now(); - if (document.visibilityState === 'visible') { + if (window.document.visibilityState === 'visible') { // タブを高速で切り替えたら取得処理が何度も走るのを防ぐ if ((now - lastVisibilityChangedAt) < 1000 * 10) { justPlainLuckyTimer = window.setTimeout(claimPlainLucky, 1000 * 10); @@ -320,7 +331,7 @@ export async function mainBoot() { const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt'); const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo'); - if (neverShowDonationInfo !== 'true' && (createdAt.getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) { + if (neverShowDonationInfo !== 'true' && (createdAt.getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !window.location.pathname.startsWith('/miauth')) { if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, { closed: () => dispose(), @@ -354,13 +365,13 @@ export async function mainBoot() { // 自分の情報が更新されたとき main.on('meUpdated', i => { - updateAccountPartial(i); + updateCurrentAccountPartial(i); }); main.on('readAllNotifications', () => { setFavIconDot(false); - updateAccountPartial({ + updateCurrentAccountPartial({ hasUnreadNotification: false, unreadNotificationsCount: 0, }); @@ -370,39 +381,24 @@ export async function mainBoot() { attemptShowNotificationDot(); const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1; - updateAccountPartial({ + updateCurrentAccountPartial({ hasUnreadNotification: true, unreadNotificationsCount, }); }); - main.on('unreadMention', () => { - updateAccountPartial({ hasUnreadMentions: true }); - }); - - main.on('readAllUnreadMentions', () => { - updateAccountPartial({ hasUnreadMentions: false }); - }); - - main.on('unreadSpecifiedNote', () => { - updateAccountPartial({ hasUnreadSpecifiedNotes: true }); - }); - - main.on('readAllUnreadSpecifiedNotes', () => { - updateAccountPartial({ hasUnreadSpecifiedNotes: false }); - }); - - main.on('readAllAntennas', () => { - updateAccountPartial({ hasUnreadAntenna: false }); - }); - main.on('unreadAntenna', () => { - updateAccountPartial({ hasUnreadAntenna: true }); + updateCurrentAccountPartial({ hasUnreadAntenna: true }); sound.playMisskeySfx('antenna'); }); + main.on('newChatMessage', () => { + updateCurrentAccountPartial({ hasUnreadChatMessages: true }); + sound.playMisskeySfx('chat'); + }); + main.on('readAllAnnouncements', () => { - updateAccountPartial({ hasUnreadAnnouncement: false }); + updateCurrentAccountPartial({ hasUnreadAnnouncement: false }); }); // 個人宛てお知らせが発行されたとき @@ -422,13 +418,13 @@ export async function mainBoot() { post(); }, 'd': () => { - defaultStore.set('darkMode', !defaultStore.state.darkMode); + store.set('darkMode', !store.s.darkMode); }, 's': () => { mainRouter.push('/search'); }, } as const satisfies Keymap; - document.addEventListener('keydown', makeHotkey(keymap), { passive: false }); + window.document.addEventListener('keydown', makeHotkey(keymap), { passive: false }); initializeSw(); } diff --git a/packages/frontend/src/boot/sub-boot.ts b/packages/frontend/src/boot/sub-boot.ts index 35c84d5568..e24c324dfb 100644 --- a/packages/frontend/src/boot/sub-boot.ts +++ b/packages/frontend/src/boot/sub-boot.ts @@ -5,7 +5,7 @@ import { createApp, defineAsyncComponent } from 'vue'; import { common } from './common.js'; -import { emojiPicker } from '@/scripts/emoji-picker.js'; +import { emojiPicker } from '@/utility/emoji-picker.js'; export async function subBoot() { const { isClientUpdated } = await common(() => createApp( diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts index bfe8fbe0e4..70078b410d 100644 --- a/packages/frontend/src/cache.ts +++ b/packages/frontend/src/cache.ts @@ -4,8 +4,8 @@ */ import * as Misskey from 'misskey-js'; -import { Cache } from '@/scripts/cache.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { Cache } from '@/utility/cache.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; export const clipsCache = new Cache<Misskey.entities.Clip[]>(1000 * 60 * 30, () => misskeyApi('clips/list')); export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/roles/list')); diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index d59c5b2c57..43eb2e5f80 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -88,9 +88,9 @@ import { i18n } from '@/i18n.js'; import { dateString } from '@/filters/date.js'; import MkFolder from '@/components/MkFolder.vue'; import RouterView from '@/components/global/RouterView.vue'; -import { useRouterFactory } from '@/router/supplier'; import MkTextarea from '@/components/MkTextarea.vue'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { createRouter } from '@/router.js'; const props = defineProps<{ report: Misskey.entities.AdminAbuseUserReportsResponse[number]; @@ -100,10 +100,9 @@ const emit = defineEmits<{ (ev: 'resolved', reportId: string): void; }>(); -const routerFactory = useRouterFactory(); -const targetRouter = routerFactory(`/admin/user/${props.report.targetUserId}`); +const targetRouter = createRouter(`/admin/user/${props.report.targetUserId}`); targetRouter.init(); -const reporterRouter = routerFactory(`/admin/user/${props.report.reporterId}`); +const reporterRouter = createRouter(`/admin/user/${props.report.reporterId}`); reporterRouter.init(); const moderationNote = ref(props.report.moderationNote ?? ''); @@ -135,7 +134,7 @@ function forward() { function showMenu(ev: MouseEvent) { os.popupMenu([{ - icon: 'ti ti-id', + icon: 'ti ti-hash', text: 'Copy ID', action: () => { copyToClipboard(props.report.id); diff --git a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts index 9df957f3ec..b62096bbe9 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts +++ b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue index a634a748e9..dbac5e9dd7 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.vue +++ b/packages/frontend/src/components/MkAbuseReportWindow.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { ref, shallowRef } from 'vue'; +import { ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import MkWindow from '@/components/MkWindow.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -47,7 +47,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const uiWindow = shallowRef<InstanceType<typeof MkWindow>>(); +const uiWindow = useTemplateRef('uiWindow'); const comment = ref(props.initialComment ?? ''); function send() { diff --git a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts index cad26de6e2..b907b5b25a 100644 --- a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts +++ b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; import { userDetailed } from '../../.storybook/fakes.js'; diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue index 0839955d9d..cb8032c019 100644 --- a/packages/frontend/src/components/MkAccountMoved.vue +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -17,7 +17,7 @@ import * as Misskey from 'misskey-js'; import MkMention from './MkMention.vue'; import { i18n } from '@/i18n.js'; import { host as localHost } from '@@/js/config.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const user = ref<Misskey.entities.UserLite>(); diff --git a/packages/frontend/src/components/MkAchievements.stories.impl.ts b/packages/frontend/src/components/MkAchievements.stories.impl.ts index 7614da51da..d838997616 100644 --- a/packages/frontend/src/components/MkAchievements.stories.impl.ts +++ b/packages/frontend/src/components/MkAchievements.stories.impl.ts @@ -4,12 +4,12 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAchievements from './MkAchievements.vue'; -import { ACHIEVEMENT_TYPES } from '@/scripts/achievements.js'; +import { ACHIEVEMENT_TYPES } from '@/utility/achievements.js'; export const Empty = { render(args) { return { diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue index 087ad51fe3..c4ea0a2142 100644 --- a/packages/frontend/src/components/MkAchievements.vue +++ b/packages/frontend/src/components/MkAchievements.vue @@ -55,9 +55,9 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { onMounted, ref, computed } from 'vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/scripts/achievements.js'; +import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/utility/achievements.js'; const props = withDefaults(defineProps<{ user: Misskey.entities.User; diff --git a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts index 270ca40825..a01d91ad20 100644 --- a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts +++ b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import isChromatic from 'chromatic/isChromatic'; import MkAnalogClock from './MkAnalogClock.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue index c8fa6246e0..eac1ea9534 100644 --- a/packages/frontend/src/components/MkAnalogClock.vue +++ b/packages/frontend/src/components/MkAnalogClock.vue @@ -82,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, onMounted, onBeforeUnmount, ref } from 'vue'; import tinycolor from 'tinycolor2'; import { globalEvents } from '@/events.js'; -import { defaultIdlingRenderScheduler } from '@/scripts/idle-render.js'; +import { defaultIdlingRenderScheduler } from '@/utility/idle-render.js'; // https://stackoverflow.com/questions/1878907/how-can-i-find-the-difference-between-two-angles const angleDiff = (a: number, b: number) => { @@ -192,7 +192,7 @@ function tick() { tick(); function calcColors() { - const computedStyle = getComputedStyle(document.documentElement); + const computedStyle = getComputedStyle(window.document.documentElement); const dark = tinycolor(computedStyle.getPropertyValue('--MI_THEME-bg')).isDark(); const accent = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(); majorGraduationColor.value = dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'; diff --git a/packages/frontend/src/components/MkAnimBg.vue b/packages/frontend/src/components/MkAnimBg.vue index 4bf6125af5..e57fbcdee3 100644 --- a/packages/frontend/src/components/MkAnimBg.vue +++ b/packages/frontend/src/components/MkAnimBg.vue @@ -4,14 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<canvas ref="canvasEl" style="width: 100%; height: 100%; pointer-events: none;"></canvas> +<canvas ref="canvasEl" style="display: block; width: 100%; height: 100%; pointer-events: none;"></canvas> </template> <script lang="ts" setup> -import { onMounted, onUnmounted, shallowRef } from 'vue'; +import { onMounted, onUnmounted, useTemplateRef } from 'vue'; import isChromatic from 'chromatic/isChromatic'; -const canvasEl = shallowRef<HTMLCanvasElement>(); +const canvasEl = useTemplateRef('canvasEl'); const props = withDefaults(defineProps<{ scale?: number; diff --git a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts index bf3ddb935b..627cb0c4ff 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAnnouncementDialog from './MkAnnouncementDialog.vue'; diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue index 6c335d71d9..56fd422c56 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.vue +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -22,22 +22,23 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef } from 'vue'; +import { onMounted, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { $i, updateAccountPartial } from '@/account.js'; +import { $i } from '@/i.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; const props = withDefaults(defineProps<{ announcement: Misskey.entities.Announcement; }>(), { }); -const rootEl = shallowRef<HTMLDivElement>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const rootEl = useTemplateRef('rootEl'); +const modal = useTemplateRef('modal'); async function ok() { if (props.announcement.needConfirmationToRead) { @@ -51,7 +52,7 @@ async function ok() { modal.value?.close(); misskeyApi('i/read-announcement', { announcementId: props.announcement.id }); - updateAccountPartial({ + updateCurrentAccountPartial({ unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== props.announcement.id), }); } diff --git a/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts b/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts index 1749e07a4e..4d921a4c48 100644 --- a/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts +++ b/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAntennaEditor from './MkAntennaEditor.vue'; diff --git a/packages/frontend/src/components/MkAntennaEditor.vue b/packages/frontend/src/components/MkAntennaEditor.vue index e622d57f1e..ac71618ee2 100644 --- a/packages/frontend/src/components/MkAntennaEditor.vue +++ b/packages/frontend/src/components/MkAntennaEditor.vue @@ -59,10 +59,10 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { deepMerge } from '@/scripts/merge.js'; -import type { DeepPartial } from '@/scripts/merge.js'; +import { deepMerge } from '@/utility/merge.js'; +import type { DeepPartial } from '@/utility/merge.js'; type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & { id?: string; diff --git a/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts b/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts index 1c6ca83b47..5878b52fb9 100644 --- a/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAntennaEditorDialog from './MkAntennaEditorDialog.vue'; diff --git a/packages/frontend/src/components/MkAntennaEditorDialog.vue b/packages/frontend/src/components/MkAntennaEditorDialog.vue index 6d815d29f3..0ebf5abf4c 100644 --- a/packages/frontend/src/components/MkAntennaEditorDialog.vue +++ b/packages/frontend/src/components/MkAntennaEditorDialog.vue @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef } from 'vue'; +import { useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import XAntennaEditor from '@/components/MkAntennaEditor.vue'; @@ -40,7 +40,7 @@ const emit = defineEmits<{ (ev: 'closed'): void, }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); function onAntennaCreated(newAntenna: Misskey.entities.Antenna) { emit('created', newAntenna); diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index 564d1fe7e3..86eddbca51 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -63,14 +63,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { Ref, ref, computed } from 'vue'; +import { ref, computed } from 'vue'; +import type { Ref } from 'vue'; import * as os from '@/os.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSelect from '@/components/MkSelect.vue'; -import { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/scripts/aiscript/ui.js'; +import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js'; import MkFolder from '@/components/MkFolder.vue'; import MkPostForm from '@/components/MkPostForm.vue'; diff --git a/packages/frontend/src/components/MkAuthConfirm.vue b/packages/frontend/src/components/MkAuthConfirm.vue index f78d2d38f0..00bf8e68d9 100644 --- a/packages/frontend/src/components/MkAuthConfirm.vue +++ b/packages/frontend/src/components/MkAuthConfirm.vue @@ -117,14 +117,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; - import MkButton from '@/components/MkButton.vue'; - -import { $i, getAccounts, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js'; +import { $i } from '@/i.js'; +import { getAccounts, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/accounts.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { getProxiedImageUrl } from '@/scripts/media-proxy.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { getProxiedImageUrl } from '@/utility/media-proxy.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const props = defineProps<{ name?: string; @@ -158,7 +157,7 @@ async function init() { const accounts = await getAccounts(); - const accountIdsToFetch = accounts.map(a => a.id).filter(id => !users.value.has(id)); + const accountIdsToFetch = accounts.map(a => a.user.id).filter(id => !users.value.has(id)); if (accountIdsToFetch.length > 0) { const usersRes = await misskeyApi('users/show', { @@ -170,7 +169,7 @@ async function init() { users.value.set(user.id, { ...user, - token: accounts.find(a => a.id === user.id)!.token, + token: accounts.find(a => a.user.id === user.id)!.token, }); } } diff --git a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts index ec24b8c240..64ccb708aa 100644 --- a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts +++ b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts @@ -6,13 +6,13 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; import { expect, userEvent, waitFor, within } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAutocomplete from './MkAutocomplete.vue'; import MkInput from './MkInput.vue'; -import { tick } from '@/scripts/test-utils.js'; +import { tick } from '@/utility/test-utils.js'; const common = { render(args) { return { diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index a0cd066c06..c73cf840ac 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -44,26 +44,28 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts"> -import { markRaw, ref, shallowRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; +import { markRaw, ref, useTemplateRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; import sanitizeHtml from 'sanitize-html'; import { emojilist, getEmojiName } from '@@/js/emojilist.js'; -import contains from '@/scripts/contains.js'; import { char2twemojiFilePath, char2fluentEmojiFilePath, char2tossfaceFilePath } from '@@/js/emoji-base.js'; +import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js'; +import type { EmojiDef } from '@/utility/search-emoji.js'; +import contains from '@/utility/contains.js'; import { acct } from '@/filters/user.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; import { miLocalStorage } from '@/local-storage.js'; import { customEmojis } from '@/custom-emojis.js'; -import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js'; -import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.js'; +import { searchEmoji } from '@/utility/search-emoji.js'; +import { prefer } from '@/preferences.js'; const lib = emojilist.filter(x => x.category !== 'flags'); const emojiDb = computed(() => { //#region Unicode Emoji - const char2path = defaultStore.reactiveState.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : defaultStore.reactiveState.emojiStyle.value === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath; + const char2path = prefer.r.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : prefer.r.emojiStyle.value === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath; const unicodeEmojiDB: EmojiDef[] = lib.map(x => ({ emoji: x.char, @@ -71,7 +73,7 @@ const emojiDb = computed(() => { url: char2path(x.char), })); - for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) { + for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) { for (const [emoji, keywords] of Object.entries(index)) { for (const k of keywords) { unicodeEmojiDB.push({ @@ -137,7 +139,7 @@ const emit = defineEmits<{ }>(); const suggests = ref<Element>(); -const rootEl = shallowRef<HTMLDivElement>(); +const rootEl = useTemplateRef('rootEl'); const fetching = ref(true); const users = ref<any[]>([]); @@ -153,10 +155,10 @@ function complete(type: string, value: any) { emit('done', { type, value }); emit('closed'); if (type === 'emoji') { - let recents = defaultStore.state.recentlyUsedEmojis; + let recents = store.s.recentlyUsedEmojis; recents = recents.filter((emoji: any) => emoji !== value); recents.unshift(value); - defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32)); + store.set('recentlyUsedEmojis', recents.splice(0, 32)); } } @@ -197,8 +199,10 @@ function exec() { users.value = JSON.parse(cache); fetching.value = false; } else { + const [username, host] = props.q.toString().split('@'); misskeyApi('users/search-by-username-and-host', { - username: props.q, + username: username, + host: host, limit: 10, detail: false, }).then(searchedUsers => { @@ -234,7 +238,7 @@ function exec() { } else if (props.type === 'emoji') { if (!props.q || props.q === '') { // 最近使った絵文字をサジェスト - emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => emojiDb.value.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[]; + emojis.value = store.s.recentlyUsedEmojis.map(emoji => emojiDb.value.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[]; return; } @@ -355,7 +359,7 @@ onMounted(() => { props.textarea.addEventListener('keydown', onKeydown); - document.body.addEventListener('mousedown', onMousedown); + window.document.body.addEventListener('mousedown', onMousedown); nextTick(() => { exec(); @@ -371,7 +375,7 @@ onMounted(() => { onBeforeUnmount(() => { props.textarea.removeEventListener('keydown', onKeydown); - document.body.removeEventListener('mousedown', onMousedown); + window.document.body.removeEventListener('mousedown', onMousedown); }); </script> @@ -407,7 +411,7 @@ onBeforeUnmount(() => { text-overflow: ellipsis; &:hover { - background: var(--MI_THEME-X3); + background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); } &[data-selected='true'] { diff --git a/packages/frontend/src/components/MkAvatars.stories.impl.ts b/packages/frontend/src/components/MkAvatars.stories.impl.ts index d2a4a9f03b..6e20294438 100644 --- a/packages/frontend/src/components/MkAvatars.stories.impl.ts +++ b/packages/frontend/src/components/MkAvatars.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkAvatars.vue b/packages/frontend/src/components/MkAvatars.vue index 8236d0ddb9..1c44ed60d8 100644 --- a/packages/frontend/src/components/MkAvatars.vue +++ b/packages/frontend/src/components/MkAvatars.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const props = withDefaults(defineProps<{ userIds: string[]; diff --git a/packages/frontend/src/components/MkButton.stories.impl.ts b/packages/frontend/src/components/MkButton.stories.impl.ts index e8802e4f8f..0a569b3beb 100644 --- a/packages/frontend/src/components/MkButton.stories.impl.ts +++ b/packages/frontend/src/components/MkButton.stories.impl.ts @@ -6,7 +6,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkButton from './MkButton.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index a6e5651d63..d37f7f39f8 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -7,11 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="!link" ref="el" class="_button" - :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]" + :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]" :type="type" :name="name" :value="value" - :disabled="disabled" + :disabled="disabled || wait" @click="emit('click', $event)" @mousedown="onMousedown" > @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only </button> <MkA v-else class="_button" - :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]" + :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]" :to="to ?? '#'" :behavior="linkBehavior" @mousedown="onMousedown" @@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted, shallowRef } from 'vue'; +import { nextTick, onMounted, useTemplateRef } from 'vue'; const props = defineProps<{ type?: 'button' | 'submit' | 'reset'; @@ -57,14 +57,15 @@ const props = defineProps<{ name?: string; value?: string; disabled?: boolean; + iconOnly?: boolean; }>(); const emit = defineEmits<{ (ev: 'click', payload: MouseEvent): void; }>(); -const el = shallowRef<HTMLElement | null>(null); -const ripples = shallowRef<HTMLElement | null>(null); +const el = useTemplateRef('el'); +const ripples = useTemplateRef('ripples'); onMounted(() => { if (props.autofocus) { @@ -91,7 +92,7 @@ function onMousedown(evt: MouseEvent): void { const target = evt.target! as HTMLElement; const rect = target.getBoundingClientRect(); - const ripple = document.createElement('div'); + const ripple = window.document.createElement('div'); ripple.classList.add(ripples.value!.dataset.childrenClass!); ripple.style.top = (evt.clientY - rect.top - 1).toString() + 'px'; ripple.style.left = (evt.clientX - rect.left - 1).toString() + 'px'; @@ -147,6 +148,11 @@ function onMousedown(evt: MouseEvent): void { background: var(--MI_THEME-buttonHoverBg); } + &.iconOnly { + padding: 7px; + min-width: auto; + } + &.small { font-size: 90%; padding: 6px 12px; @@ -220,28 +226,28 @@ function onMousedown(evt: MouseEvent): void { background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB)); &:not(:disabled):hover { - background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); + background: linear-gradient(90deg, hsl(from var(--MI_THEME-buttonGradateA) h s calc(l + 5)), hsl(from var(--MI_THEME-buttonGradateB) h s calc(l + 5))); } &:not(:disabled):active { - background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); + background: linear-gradient(90deg, hsl(from var(--MI_THEME-buttonGradateA) h s calc(l + 5)), hsl(from var(--MI_THEME-buttonGradateB) h s calc(l + 5))); } } &.danger { font-weight: bold; - color: #ff2a2a; + color: var(--MI_THEME-error); &.primary { color: #fff; - background: #ff2a2a; + background: var(--MI_THEME-error); &:not(:disabled):hover { - background: #ff4242; + background: hsl(from var(--MI_THEME-error) h s calc(l + 10)); } &:not(:disabled):active { - background: #d42e2e; + background: hsl(from var(--MI_THEME-error) h s calc(l - 10)); } } } @@ -250,6 +256,10 @@ function onMousedown(evt: MouseEvent): void { opacity: 0.5; } + &.wait { + cursor: wait !important; + } + &:focus-visible { outline-offset: 2px; } diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index aeed90722f..b7aceb3570 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -26,8 +26,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue'; -import { defaultStore } from '@/store.js'; +import { ref, useTemplateRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue'; +import { store } from '@/store.js'; // APIs provided by Captcha services // see: https://docs.hcaptcha.com/configuration/#javascript-api @@ -53,6 +53,8 @@ type CaptchaContainer = { }; declare global { + // Window を拡張してるため、空ではない + // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface Window extends CaptchaContainer { } } @@ -70,7 +72,7 @@ const emit = defineEmits<{ const available = ref(false); -const captchaEl = shallowRef<HTMLDivElement | undefined>(); +const captchaEl = useTemplateRef('captchaEl'); const captchaWidgetId = ref<string | undefined>(undefined); const testcaptchaInput = ref(''); const testcaptchaPassed = ref(false); @@ -115,7 +117,7 @@ watch(() => [props.instanceUrl, props.sitekey, props.secretKey], async () => { if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') { available.value = true; } else if (src.value !== null) { - (document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), { + (window.document.getElementById(scriptId.value) ?? window.document.head.appendChild(Object.assign(window.document.createElement('script'), { async: true, id: scriptId.value, src: src.value, @@ -152,12 +154,12 @@ async function requestRender() { if (captcha.value.render && captchaEl.value instanceof Element && props.sitekey) { // reCAPTCHAのレンダリング重複判定を回避するため、captchaEl配下に仮のdivを用意する. // (同じdivに対して複数回renderを呼び出すとreCAPTCHAはエラーを返すので) - const elem = document.createElement('div'); + const elem = window.document.createElement('div'); captchaEl.value.appendChild(elem); captchaWidgetId.value = captcha.value.render(elem, { sitekey: props.sitekey, - theme: defaultStore.state.darkMode ? 'dark' : 'light', + theme: store.s.darkMode ? 'dark' : 'light', callback: callback, 'expired-callback': () => callback(undefined), 'error-callback': () => callback(undefined), @@ -185,7 +187,7 @@ async function requestRender() { function clearWidget() { if (props.provider === 'mcaptcha') { - const container = document.getElementById('mcaptcha__widget-container'); + const container = window.document.getElementById('mcaptcha__widget-container'); if (container) { container.innerHTML = ''; } diff --git a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts index b9770670dc..a42e80c27a 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts +++ b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { action } from '@storybook/addon-actions'; import { expect, userEvent, within } from '@storybook/test'; diff --git a/packages/frontend/src/components/MkChannelFollowButton.vue b/packages/frontend/src/components/MkChannelFollowButton.vue index 7aa916134f..7ee1e98b48 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.vue +++ b/packages/frontend/src/components/MkChannelFollowButton.vue @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkChannelList.stories.impl.ts b/packages/frontend/src/components/MkChannelList.stories.impl.ts index f69b20c049..47ca864dc0 100644 --- a/packages/frontend/src/components/MkChannelList.stories.impl.ts +++ b/packages/frontend/src/components/MkChannelList.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { action } from '@storybook/addon-actions'; import { channel } from '../../.storybook/fakes.js'; diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue index 2850ecca16..fdb7d2a1c4 100644 --- a/packages/frontend/src/components/MkChannelList.vue +++ b/packages/frontend/src/components/MkChannelList.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination :pagination="pagination"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.notFound }}</div> </div> </template> @@ -19,8 +19,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import type { Paging } from '@/components/MkPagination.vue'; import MkChannelPreview from '@/components/MkChannelPreview.vue'; -import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; diff --git a/packages/frontend/src/components/MkChannelPreview.stories.impl.ts b/packages/frontend/src/components/MkChannelPreview.stories.impl.ts index de0193c78f..dbee069771 100644 --- a/packages/frontend/src/components/MkChannelPreview.stories.impl.ts +++ b/packages/frontend/src/components/MkChannelPreview.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { channel } from '../../.storybook/fakes.js'; import MkChannelPreview from './MkChannelPreview.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkChart.stories.impl.ts b/packages/frontend/src/components/MkChart.stories.impl.ts index 1bcb9c30d8..3caf01d34e 100644 --- a/packages/frontend/src/components/MkChart.stories.impl.ts +++ b/packages/frontend/src/components/MkChart.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; import { getChartResolver } from '../../.storybook/charts.js'; diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index d05f4921f6..7e164362c1 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -45,23 +45,19 @@ export type ChartSrc = </script> <script lang="ts" setup> -/* eslint-disable id-denylist -- - Chart.js has a `data` attribute in most chart definitions, which triggers the - id-denylist violation when setting it. This is causing about 60+ lint issues. - As this is part of Chart.js's API it makes sense to disable the check here. -*/ -import { onMounted, ref, shallowRef, watch } from 'vue'; + +import { onMounted, ref, useTemplateRef, watch } from 'vue'; import { Chart } from 'chart.js'; import * as Misskey from 'misskey-js'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { alpha } from '@/scripts/color.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { alpha } from '@/utility/color.js'; import date from '@/filters/date.js'; import bytes from '@/filters/bytes.js'; -import { initChart } from '@/scripts/init-chart.js'; -import { chartLegend } from '@/scripts/chart-legend.js'; +import { initChart } from '@/utility/init-chart.js'; +import { chartLegend } from '@/utility/chart-legend.js'; import MkChartLegend from '@/components/MkChartLegend.vue'; initChart(); @@ -96,7 +92,7 @@ const props = withDefaults(defineProps<{ nowForChromatic: undefined, }); -const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>(); +const legendEl = useTemplateRef('legendEl'); const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); const negate = arr => arr.map(x => -x); @@ -134,7 +130,7 @@ let chartData: { bytes?: boolean; } | null = null; -const chartEl = shallowRef<HTMLCanvasElement | null>(null); +const chartEl = useTemplateRef('chartEl'); const fetching = ref(true); const getDate = (ago: number) => { @@ -161,7 +157,7 @@ const render = () => { chartInstance.destroy(); } - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const maxes = chartData.series.map((x, i) => Math.max(...x.data.map(d => d.y))); @@ -849,7 +845,7 @@ watch(() => [props.src, props.span], fetchAndRender); onMounted(() => { fetchAndRender(); }); -/* eslint-enable id-denylist */ + </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkChartLegend.vue b/packages/frontend/src/components/MkChartLegend.vue index e28d6ad6ba..ed0c3412d2 100644 --- a/packages/frontend/src/components/MkChartLegend.vue +++ b/packages/frontend/src/components/MkChartLegend.vue @@ -14,7 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { shallowRef } from 'vue'; -import { Chart, LegendItem } from 'chart.js'; +import { Chart } from 'chart.js'; +import type { LegendItem } from 'chart.js'; const chart = shallowRef<Chart>(); const type = shallowRef<string>(); diff --git a/packages/frontend/src/components/MkClickerGame.stories.impl.ts b/packages/frontend/src/components/MkClickerGame.stories.impl.ts index 36313f965d..eb7e61f294 100644 --- a/packages/frontend/src/components/MkClickerGame.stories.impl.ts +++ b/packages/frontend/src/components/MkClickerGame.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { action } from '@storybook/addon-actions'; import { expect, userEvent, within } from '@storybook/test'; diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue index 9a0a9fba05..775964af50 100644 --- a/packages/frontend/src/components/MkClickerGame.vue +++ b/packages/frontend/src/components/MkClickerGame.vue @@ -23,9 +23,9 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'; import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue'; import * as os from '@/os.js'; import { useInterval } from '@@/js/use-interval.js'; -import * as game from '@/scripts/clicker-game.js'; +import * as game from '@/utility/clicker-game.js'; import number from '@/filters/number.js'; -import { claimAchievement } from '@/scripts/achievements.js'; +import { claimAchievement } from '@/utility/achievements.js'; const saveData = game.saveData; const cookies = computed(() => saveData.value?.cookies); diff --git a/packages/frontend/src/components/MkClipPreview.stories.impl.ts b/packages/frontend/src/components/MkClipPreview.stories.impl.ts index 62503fb98a..496dc09eed 100644 --- a/packages/frontend/src/components/MkClipPreview.stories.impl.ts +++ b/packages/frontend/src/components/MkClipPreview.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { clip } from '../../.storybook/fakes.js'; import MkClipPreview from './MkClipPreview.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkClipPreview.vue b/packages/frontend/src/components/MkClipPreview.vue index 5b09ec90dd..2154c08ab3 100644 --- a/packages/frontend/src/components/MkClipPreview.vue +++ b/packages/frontend/src/components/MkClipPreview.vue @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { computed } from 'vue'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import number from '@/filters/number.js'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue index e253b1b55f..40f41f5d0f 100644 --- a/packages/frontend/src/components/MkCode.core.vue +++ b/packages/frontend/src/components/MkCode.core.vue @@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ref, watch } from 'vue'; import { bundledLanguagesInfo } from 'shiki/langs'; import type { BundledLanguage } from 'shiki/langs'; -import { getHighlighter, getTheme } from '@/scripts/code-highlighter.js'; -import { defaultStore } from '@/store.js'; +import { getHighlighter, getTheme } from '@/utility/code-highlighter.js'; +import { store } from '@/store.js'; const props = defineProps<{ code: string; @@ -22,7 +22,7 @@ const props = defineProps<{ }>(); const highlighter = await getHighlighter(); -const darkMode = defaultStore.reactiveState.darkMode; +const darkMode = store.r.darkMode; const codeLang = ref<BundledLanguage | 'aiscript'>('js'); const [lightThemeName, darkThemeName] = await Promise.all([ @@ -93,7 +93,7 @@ watch(() => props.lang, (to) => { .codeBlockRoot :global(.shiki) { padding: 1em; - margin: .5em 0; + margin: 0; overflow: auto; border-radius: var(--MI-radius-sm); border: 1px solid var(--MI_THEME-divider); diff --git a/packages/frontend/src/components/MkCode.stories.impl.ts b/packages/frontend/src/components/MkCode.stories.impl.ts index b7e53e8e35..fae9d459fb 100644 --- a/packages/frontend/src/components/MkCode.stories.impl.ts +++ b/packages/frontend/src/components/MkCode.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkCode from './MkCode.vue'; const code = `for (let i, 100) { <: if (i % 15 == 0) "FizzBuzz" diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index a46b220101..a99ab3bd09 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only </button> <Suspense> <template #fallback> - <MkLoading /> + <MkLoading/> </template> <XCode v-if="show && lang" :code="code" :lang="lang"/> <pre v-else-if="show" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre> @@ -28,9 +28,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { defineAsyncComponent, ref } from 'vue'; import * as os from '@/os.js'; import MkLoading from '@/components/global/MkLoading.vue'; -import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ code: string; @@ -42,13 +42,12 @@ const props = withDefaults(defineProps<{ forceShow: false, }); -const show = ref(props.forceShow === true ? true : !defaultStore.state.dataSaver.code); +const show = ref(props.forceShow === true ? true : !prefer.s.dataSaver.code); const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue')); function copy() { copyToClipboard(props.code); - os.success(); } </script> diff --git a/packages/frontend/src/components/MkCodeEditor.stories.impl.ts b/packages/frontend/src/components/MkCodeEditor.stories.impl.ts index 5c410c4886..c76b6fd08e 100644 --- a/packages/frontend/src/components/MkCodeEditor.stories.impl.ts +++ b/packages/frontend/src/components/MkCodeEditor.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { action } from '@storybook/addon-actions'; import MkCodeEditor from './MkCodeEditor.vue'; const code = `for (let i, 100) { diff --git a/packages/frontend/src/components/MkCodeEditor.vue b/packages/frontend/src/components/MkCodeEditor.vue index 49b083815a..c3242a5914 100644 --- a/packages/frontend/src/components/MkCodeEditor.vue +++ b/packages/frontend/src/components/MkCodeEditor.vue @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, watch, toRefs, shallowRef, nextTick } from 'vue'; +import { ref, watch, toRefs, useTemplateRef, nextTick } from 'vue'; import { debounce } from 'throttle-debounce'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; @@ -61,7 +61,7 @@ const { modelValue } = toRefs(props); const v = ref<string>(modelValue.value ?? ''); const focused = ref(false); const changed = ref(false); -const inputEl = shallowRef<HTMLTextAreaElement>(); +const inputEl = useTemplateRef('inputEl'); const focus = () => inputEl.value?.focus(); diff --git a/packages/frontend/src/components/MkCodeInline.stories.impl.ts b/packages/frontend/src/components/MkCodeInline.stories.impl.ts index 51d4d106ff..c17be177cb 100644 --- a/packages/frontend/src/components/MkCodeInline.stories.impl.ts +++ b/packages/frontend/src/components/MkCodeInline.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkCodeInline from './MkCodeInline.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/MkColorInput.stories.impl.ts b/packages/frontend/src/components/MkColorInput.stories.impl.ts index 61383e2cae..3df92ca858 100644 --- a/packages/frontend/src/components/MkColorInput.stories.impl.ts +++ b/packages/frontend/src/components/MkColorInput.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { action } from '@storybook/addon-actions'; import MkColorInput from './MkColorInput.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkColorInput.vue b/packages/frontend/src/components/MkColorInput.vue index babf356942..9cf75d6528 100644 --- a/packages/frontend/src/components/MkColorInput.vue +++ b/packages/frontend/src/components/MkColorInput.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, toRefs } from 'vue'; +import { ref, useTemplateRef, toRefs } from 'vue'; const props = defineProps<{ modelValue: string | null; @@ -39,7 +39,7 @@ const emit = defineEmits<{ const { modelValue } = toRefs(props); const v = ref(modelValue.value); -const inputEl = shallowRef<HTMLElement>(); +const inputEl = useTemplateRef('inputEl'); const onInput = () => { emit('update:modelValue', v.value ?? ''); diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index 30a9b26bef..5b79ac6699 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -19,10 +19,10 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </header> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_toggle_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_toggle_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_toggle_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_toggle_leaveTo : ''" @enter="enter" @afterEnter="afterEnter" @leave="leave" @@ -39,8 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'; -import { defaultStore } from '@/store.js'; +import { onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'; +import { prefer } from '@/preferences.js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ @@ -58,9 +58,9 @@ const props = withDefaults(defineProps<{ maxHeight: null, }); -const rootEl = shallowRef<HTMLElement>(); -const contentEl = shallowRef<HTMLElement>(); -const headerEl = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); +const contentEl = useTemplateRef('contentEl'); +const headerEl = useTemplateRef('headerEl'); const showBody = ref(props.expanded); const ignoreOmit = ref(false); const omitted = ref(false); diff --git a/packages/frontend/src/components/MkContextMenu.stories.impl.ts b/packages/frontend/src/components/MkContextMenu.stories.impl.ts index 1ff0f51bd4..7a5e36131b 100644 --- a/packages/frontend/src/components/MkContextMenu.stories.impl.ts +++ b/packages/frontend/src/components/MkContextMenu.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { userEvent, within } from '@storybook/test'; import MkContextMenu from './MkContextMenu.vue'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index f51fefa0c0..9c6397a72c 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <Transition appear - :enterActiveClass="defaultStore.state.animation ? $style.transition_fade_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''" > <div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}"> <MkMenu :items="items" :align="'left'" @close="emit('closed')"/> @@ -18,11 +18,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onBeforeUnmount, shallowRef, ref } from 'vue'; +import { onMounted, onBeforeUnmount, useTemplateRef, ref } from 'vue'; import MkMenu from './MkMenu.vue'; import type { MenuItem } from '@/types/menu.js'; -import contains from '@/scripts/contains.js'; -import { defaultStore } from '@/store.js'; +import contains from '@/utility/contains.js'; +import { prefer } from '@/preferences.js'; import * as os from '@/os.js'; const props = defineProps<{ @@ -34,7 +34,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const rootEl = shallowRef<HTMLDivElement>(); +const rootEl = useTemplateRef('rootEl'); const zIndex = ref<number>(os.claimZIndex('high')); @@ -68,11 +68,11 @@ onMounted(() => { rootEl.value.style.left = `${left}px`; } - document.body.addEventListener('mousedown', onMousedown); + window.document.body.addEventListener('mousedown', onMousedown); }); onBeforeUnmount(() => { - document.body.removeEventListener('mousedown', onMousedown); + window.document.body.removeEventListener('mousedown', onMousedown); }); function onMousedown(evt: Event) { diff --git a/packages/frontend/src/components/MkCropperDialog.stories.impl.ts b/packages/frontend/src/components/MkCropperDialog.stories.impl.ts index ce13093975..78cb4120de 100644 --- a/packages/frontend/src/components/MkCropperDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkCropperDialog.stories.impl.ts @@ -3,14 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { action } from '@storybook/addon-actions'; import { file } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkCropperDialog from './MkCropperDialog.vue'; +import type { StoryObj } from '@storybook/vue3'; export const Default = { render(args) { return { @@ -55,7 +53,7 @@ export const Default = { http.get('/proxy/image.webp', async ({ request }) => { const url = new URL(request.url).searchParams.get('url'); if (url === 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true') { - const image = await (await fetch('client-assets/fedi.jpg')).blob(); + const image = await (await window.fetch('client-assets/fedi.jpg')).blob(); return new HttpResponse(image, { headers: { 'Content-Type': 'image/jpeg', diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index 0186cfc2c0..ba21394cbc 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -31,17 +31,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; +import { onMounted, useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import Cropper from 'cropperjs'; import tinycolor from 'tinycolor2'; +import { apiUrl } from '@@/js/config.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import * as os from '@/os.js'; -import { $i } from '@/account.js'; -import { defaultStore } from '@/store.js'; -import { apiUrl } from '@@/js/config.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { getProxiedImageUrl } from '@/scripts/media-proxy.js'; +import { getProxiedImageUrl } from '@/utility/media-proxy.js'; +import { prefer } from '@/preferences.js'; const emit = defineEmits<{ (ev: 'ok', cropped: Misskey.entities.DriveFile): void; @@ -56,8 +56,8 @@ const props = defineProps<{ }>(); const imgUrl = getProxiedImageUrl(props.file.url, undefined, true); -const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); -const imgEl = shallowRef<HTMLImageElement>(); +const dialogEl = useTemplateRef('dialogEl'); +const imgEl = useTemplateRef('imgEl'); let cropper: Cropper | null = null; const loading = ref(true); @@ -81,8 +81,8 @@ const ok = async () => { formData.append('i', $i!.token); if (props.uploadFolder) { formData.append('folderId', props.uploadFolder); - } else if (props.uploadFolder !== null && defaultStore.state.uploadFolder) { - formData.append('folderId', defaultStore.state.uploadFolder); + } else if (props.uploadFolder !== null && prefer.s.uploadFolder) { + formData.append('folderId', prefer.s.uploadFolder); } window.fetch(apiUrl + '/drive/files/create', { @@ -122,7 +122,7 @@ onMounted(() => { cropper = new Cropper(imgEl.value!, { }); - const computedStyle = getComputedStyle(document.documentElement); + const computedStyle = getComputedStyle(window.document.documentElement); const selection = cropper.getCropperSelection()!; selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(); diff --git a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.stories.impl.ts b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.stories.impl.ts index 8a05e06311..3da27dcedb 100644 --- a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { emojiDetailed } from '../../.storybook/fakes.js'; import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue index e6ab17417d..54fda6bf7c 100644 --- a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue +++ b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue @@ -57,14 +57,14 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { shallowRef } from 'vue'; +import { useTemplateRef } from 'vue'; import MkLink from '@/components/MkLink.vue'; import { i18n } from '@/i18n.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; const props = defineProps<{ - emoji: Misskey.entities.EmojiDetailed, + emoji: Misskey.entities.EmojiDetailed, }>(); const emit = defineEmits<{ @@ -73,7 +73,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialogEl = useTemplateRef('dialogEl'); function cancel() { emit('cancel'); @@ -85,7 +85,7 @@ function cancel() { .emojiImgWrapper { max-width: 100%; height: 40cqh; - background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-X5) 8px, var(--MI_THEME-X5) 14px); + background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)) 8px, light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)) 14px); border-radius: var(--MI-radius); margin: auto; overflow-y: hidden; @@ -101,7 +101,7 @@ function cancel() { display: inline-block; word-break: break-all; padding: 3px 10px; - background-color: var(--MI_THEME-X5); + background-color: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); border: solid 1px var(--MI_THEME-divider); border-radius: var(--MI-radius); } diff --git a/packages/frontend/src/components/MkCwButton.stories.impl.ts b/packages/frontend/src/components/MkCwButton.stories.impl.ts index 5d6ea56da9..bbe5f4eddb 100644 --- a/packages/frontend/src/components/MkCwButton.stories.impl.ts +++ b/packages/frontend/src/components/MkCwButton.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { action } from '@storybook/addon-actions'; import { expect, userEvent, within } from '@storybook/test'; import { file } from '../../.storybook/fakes.js'; diff --git a/packages/frontend/src/components/MkCwButton.vue b/packages/frontend/src/components/MkCwButton.vue index b5f6e78b6c..cc8bbf1104 100644 --- a/packages/frontend/src/components/MkCwButton.vue +++ b/packages/frontend/src/components/MkCwButton.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed } from 'vue'; import * as Misskey from 'misskey-js'; import type { PollEditorModelValue } from '@/components/MkPollEditor.vue'; -import { concat } from '@/scripts/array.js'; +import { concat } from '@/utility/array.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index eeeabb476e..f94f28de41 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -4,14 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only --> <script lang="ts"> -import { defineComponent, h, PropType, TransitionGroup, useCssModule } from 'vue'; +import { defineComponent, h, TransitionGroup, useCssModule } from 'vue'; +import type { PropType } from 'vue'; +import type { MisskeyEntity } from '@/types/date-separated-list.js'; import MkAd from '@/components/global/MkAd.vue'; import { isDebuggerEnabled, stackTraceInstances } from '@/debug.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { instance } from '@/instance.js'; -import { defaultStore } from '@/store.js'; -import { MisskeyEntity } from '@/types/date-separated-list.js'; +import { prefer } from '@/preferences.js'; import { $i } from '@/account.js'; export default defineComponent({ @@ -152,7 +153,7 @@ export default defineComponent({ [$style['direction-up']]: props.direction === 'up', }; - return () => defaultStore.state.animation ? h(TransitionGroup, { + return () => prefer.s.animation ? h(TransitionGroup, { class: classes, name: 'list', tag: 'div', @@ -170,21 +171,17 @@ export default defineComponent({ container-type: inline-size; &:global { - > .list-move { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); - } - - &.deny-move-transition > .list-move { - transition: none !important; - } + > .list-move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } - > .list-enter-active { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); - } + > .list-enter-active { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } - > *:empty { - display: none; - } + > *:empty { + display: none; + } } &:not(.date-separated-list-nogap) > *:not(:last-child) { diff --git a/packages/frontend/src/components/MkDialog.stories.impl.ts b/packages/frontend/src/components/MkDialog.stories.impl.ts index 2d8d3661f2..57c7916049 100644 --- a/packages/frontend/src/components/MkDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkDialog.stories.impl.ts @@ -5,7 +5,7 @@ import { action } from '@storybook/addon-actions'; import { expect, userEvent, waitFor, within } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { i18n } from '@/i18n.js'; import MkDialog from './MkDialog.vue'; const Base = { diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 9a59a9aac7..34a54a57bc 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -25,8 +25,8 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="type === 'question'" :class="$style.iconInner" class="ti ti-help-circle"></i> <MkLoading v-else-if="type === 'waiting'" :class="$style.iconInner" :em="true"/> </div> - <header v-if="title" :class="$style.title"><Mfm :text="title"/></header> - <div v-if="text" :class="$style.text"><Mfm :text="text" :isBlock="true" :plain="plain" /></div> + <header v-if="title" :class="$style.title" class="_selectable"><Mfm :text="title"/></header> + <div v-if="text" :class="$style.text" class="_selectable"><Mfm :text="text" :isBlock="true" :plain="plain"/></div> <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown"> <template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template> <template #caption> @@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, computed } from 'vue'; +import { ref, useTemplateRef, computed } from 'vue'; import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -118,7 +118,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); const inputValue = ref<string | number | null>(props.input?.default ?? null); const selectedValue = ref(props.select?.default ?? null); @@ -143,6 +143,7 @@ const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'character // overload function を使いたいので lint エラーを無視する function done(canceled: true): void; function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare + function done(canceled: boolean, result?: Result): void { // eslint-disable-line no-redeclare emit('done', { canceled, result } as { canceled: true } | { canceled: false, result: Result }); modal.value?.close(); diff --git a/packages/frontend/src/components/MkDigitalClock.stories.impl.ts b/packages/frontend/src/components/MkDigitalClock.stories.impl.ts index e3391bcf7e..af58f5c375 100644 --- a/packages/frontend/src/components/MkDigitalClock.stories.impl.ts +++ b/packages/frontend/src/components/MkDigitalClock.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import isChromatic from 'chromatic/isChromatic'; import MkDigitalClock from './MkDigitalClock.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkDigitalClock.vue b/packages/frontend/src/components/MkDigitalClock.vue index 2e2321e6ac..8198356a76 100644 --- a/packages/frontend/src/components/MkDigitalClock.vue +++ b/packages/frontend/src/components/MkDigitalClock.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, onUnmounted, ref, watch } from 'vue'; -import { defaultIdlingRenderScheduler } from '@/scripts/idle-render.js'; +import { defaultIdlingRenderScheduler } from '@/utility/idle-render.js'; const props = withDefaults(defineProps<{ showS?: boolean; diff --git a/packages/frontend/src/components/MkDisableSection.vue b/packages/frontend/src/components/MkDisableSection.vue new file mode 100644 index 0000000000..bd7ecf225d --- /dev/null +++ b/packages/frontend/src/components/MkDisableSection.vue @@ -0,0 +1,42 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[$style.root]"> + <div :inert="disabled" :class="[{ [$style.disabled]: disabled }]"> + <slot></slot> + </div> + <div v-if="disabled" :class="[$style.cover]"></div> +</div> +</template> + +<script lang="ts" setup> +defineProps<{ + disabled?: boolean; +}>(); +</script> + +<style lang="scss" module> +.root { + position: relative; +} + +.disabled { + opacity: 0.3; + filter: saturate(0.5); +} + +.cover { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + cursor: not-allowed; + --color: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); + background-size: auto auto; + background-image: repeating-linear-gradient(135deg, transparent, transparent 10px, var(--color) 4px, var(--color) 14px); +} +</style> diff --git a/packages/frontend/src/components/MkDonation.stories.impl.ts b/packages/frontend/src/components/MkDonation.stories.impl.ts index 27d6b7df6c..71d0c20c63 100644 --- a/packages/frontend/src/components/MkDonation.stories.impl.ts +++ b/packages/frontend/src/components/MkDonation.stories.impl.ts @@ -4,7 +4,7 @@ */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { onBeforeUnmount } from 'vue'; import MkDonation from './MkDonation.vue'; import { instance } from '@/instance.js'; diff --git a/packages/frontend/src/components/MkDrive.file.stories.impl.ts b/packages/frontend/src/components/MkDrive.file.stories.impl.ts index 5f6e6a0667..933383775c 100644 --- a/packages/frontend/src/components/MkDrive.file.stories.impl.ts +++ b/packages/frontend/src/components/MkDrive.file.stories.impl.ts @@ -4,7 +4,7 @@ */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkDrive_file from './MkDrive.file.vue'; import { file } from '../../.storybook/fakes.js'; export const Default = { diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 5dee448329..22a2673f33 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -48,10 +48,10 @@ import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import bytes from '@/filters/bytes.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; -import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js'; -import { deviceKind } from '@/scripts/device-kind.js'; -import { useRouter } from '@/router/supplier.js'; +import { $i } from '@/i.js'; +import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js'; +import { deviceKind } from '@/utility/device-kind.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); diff --git a/packages/frontend/src/components/MkDrive.folder.stories.impl.ts b/packages/frontend/src/components/MkDrive.folder.stories.impl.ts index 5f8ef48520..e6c7c2f645 100644 --- a/packages/frontend/src/components/MkDrive.folder.stories.impl.ts +++ b/packages/frontend/src/components/MkDrive.folder.stories.impl.ts @@ -4,7 +4,7 @@ */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { http, HttpResponse } from 'msw'; import * as Misskey from 'misskey-js'; import MkDrive_folder from './MkDrive.folder.vue'; diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 8496890f60..d8ae3e9562 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="!hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template> {{ folder.name }} </p> - <p v-if="defaultStore.state.uploadFolder == folder.id" :class="$style.upload"> + <p v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload"> {{ i18n.ts.uploadFolder }} </p> <button v-if="selectMode" class="_button" :class="$style.checkboxWrapper" @click.prevent.stop="checkboxClicked"> @@ -38,11 +38,11 @@ import { computed, defineAsyncComponent, ref } from 'vue'; import * as Misskey from 'misskey-js'; import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ folder: Misskey.entities.DriveFolder; @@ -244,8 +244,8 @@ function deleteFolder() { misskeyApi('drive/folders/delete', { folderId: props.folder.id, }).then(() => { - if (defaultStore.state.uploadFolder === props.folder.id) { - defaultStore.set('uploadFolder', null); + if (prefer.s.uploadFolder === props.folder.id) { + prefer.commit('uploadFolder', null); } }).catch(err => { switch (err.id) { @@ -266,7 +266,7 @@ function deleteFolder() { } function setAsUploadFolder() { - defaultStore.set('uploadFolder', props.folder.id); + prefer.commit('uploadFolder', props.folder.id); } function onContextmenu(ev: MouseEvent) { @@ -295,9 +295,9 @@ function onContextmenu(ev: MouseEvent) { danger: true, action: deleteFolder, }]; - if (defaultStore.state.devMode) { + if (prefer.s.devMode) { menu = menu.concat([{ type: 'divider' }, { - icon: 'ti ti-id', + icon: 'ti ti-hash', text: i18n.ts.copyFolderId, action: () => { copyToClipboard(props.folder.id); diff --git a/packages/frontend/src/components/MkDrive.navFolder.vue b/packages/frontend/src/components/MkDrive.navFolder.vue index 8df3c86ebf..7433aea061 100644 --- a/packages/frontend/src/components/MkDrive.navFolder.vue +++ b/packages/frontend/src/components/MkDrive.navFolder.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ diff --git a/packages/frontend/src/components/MkDrive.stories.impl.ts b/packages/frontend/src/components/MkDrive.stories.impl.ts index fe20e61415..4394eebfda 100644 --- a/packages/frontend/src/components/MkDrive.stories.impl.ts +++ b/packages/frontend/src/components/MkDrive.stories.impl.ts @@ -4,7 +4,7 @@ */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { http, HttpResponse } from 'msw'; import * as Misskey from 'misskey-js'; import MkDrive from './MkDrive.vue'; diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 5a0803f1e3..730dd0d55e 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -103,7 +103,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; +import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from './MkButton.vue'; import type { MenuItem } from '@/types/menu.js'; @@ -112,12 +112,12 @@ import XFolder from '@/components/MkDrive.folder.vue'; import XFile from '@/components/MkDrive.file.vue'; import MkInput from '@/components/MkInput.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; -import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; -import { uploadFile, uploads } from '@/scripts/upload.js'; -import { claimAchievement } from '@/scripts/achievements.js'; +import { uploadFile, uploads } from '@/utility/upload.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { prefer } from '@/preferences.js'; const searchQuery = ref(''); @@ -139,8 +139,8 @@ const emit = defineEmits<{ (ev: 'open-folder', v: Misskey.entities.DriveFolder): void; }>(); -const loadMoreFiles = shallowRef<InstanceType<typeof MkButton>>(); -const fileInput = shallowRef<HTMLInputElement>(); +const loadMoreFiles = useTemplateRef('loadMoreFiles'); +const fileInput = useTemplateRef('fileInput'); const folder = ref<Misskey.entities.DriveFolder | null>(null); const files = ref<Misskey.entities.DriveFile[]>([]); @@ -152,7 +152,7 @@ const selectedFiles = ref<Misskey.entities.DriveFile[]>([]); const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]); const uploadings = uploads; const connection = useStream().useChannel('drive'); -const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // 外部渡しが多いので$refは使わないほうがよい +const keepOriginal = ref<boolean>(prefer.s.keepOriginalUploading); // 外部渡しが多いので$refは使わないほうがよい // ドロップされようとしているか const draghover = ref(false); @@ -730,7 +730,7 @@ function onContextmenu(ev: MouseEvent) { } onMounted(() => { - if (defaultStore.state.enableInfiniteScroll && loadMoreFiles.value) { + if (prefer.s.enableInfiniteScroll && loadMoreFiles.value) { nextTick(() => { ilFilesObserver.observe(loadMoreFiles.value?.$el); }); @@ -751,7 +751,7 @@ onMounted(() => { }); onActivated(() => { - if (defaultStore.state.enableInfiniteScroll) { + if (prefer.s.enableInfiniteScroll) { nextTick(() => { ilFilesObserver.observe(loadMoreFiles.value?.$el); }); diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.stories.impl.ts b/packages/frontend/src/components/MkDriveFileThumbnail.stories.impl.ts index 3fa24d7edb..d259444e94 100644 --- a/packages/frontend/src/components/MkDriveFileThumbnail.stories.impl.ts +++ b/packages/frontend/src/components/MkDriveFileThumbnail.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkDriveFileThumbnail from './MkDriveFileThumbnail.vue'; import { file } from '../../.storybook/fakes.js'; export const Default = { diff --git a/packages/frontend/src/components/MkDriveSelectDialog.vue b/packages/frontend/src/components/MkDriveSelectDialog.vue index f1ecc27123..1b9455e3f3 100644 --- a/packages/frontend/src/components/MkDriveSelectDialog.vue +++ b/packages/frontend/src/components/MkDriveSelectDialog.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef } from 'vue'; +import { ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import XDrive from '@/components/MkDrive.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; @@ -43,7 +43,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]); diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue index 6e9eb75920..d18fe0ed0c 100644 --- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue +++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue @@ -89,7 +89,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { shallowRef, ref, computed, nextTick, onMounted, onDeactivated, onUnmounted } from 'vue'; +import { useTemplateRef, ref, computed, nextTick, onMounted, onDeactivated, onUnmounted } from 'vue'; import { url } from '@@/js/config.js'; import { embedRouteWithScrollbar } from '@@/js/embed-page.js'; import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js'; @@ -105,8 +105,8 @@ import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { normalizeEmbedParams, getEmbedCode } from '@/scripts/get-embed-code.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js'; const emit = defineEmits<{ (ev: 'ok'): void; @@ -121,7 +121,7 @@ const props = defineProps<{ }>(); //#region Modalの制御 -const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialogEl = useTemplateRef('dialogEl'); function cancel() { emit('cancel'); @@ -180,7 +180,7 @@ function applyToPreview() { nextTick(() => { if (currentPreviewUrl === embedPreviewUrl.value) { // URLが変わらなくてもリロード - iframeEl.value?.contentWindow?.location.reload(); + iframeEl.value?.contentWindow?.window.location.reload(); } }); } @@ -194,14 +194,13 @@ function generate() { function doCopy() { copyToClipboard(result.value); - os.success(); } //#endregion //#region プレビューのリサイズ -const resizerRootEl = shallowRef<HTMLDivElement>(); +const resizerRootEl = useTemplateRef('resizerRootEl'); const iframeLoading = ref(true); -const iframeEl = shallowRef<HTMLIFrameElement>(); +const iframeEl = useTemplateRef('iframeEl'); const iframeHeight = ref(0); const iframeScale = ref(1); const iframeStyle = computed(() => { diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue index a4f763e895..8142fdeb36 100644 --- a/packages/frontend/src/components/MkEmojiPicker.section.vue +++ b/packages/frontend/src/components/MkEmojiPicker.section.vue @@ -61,8 +61,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, computed, Ref } from 'vue'; -import { CustomEmojiFolderTree, getEmojiName } from '@@/js/emojilist.js'; +import { ref, computed } from 'vue'; +import type { Ref } from 'vue'; +import { getEmojiName } from '@@/js/emojilist.js'; +import type { CustomEmojiFolderTree } from '@@/js/emojilist.js'; import { i18n } from '@/i18n.js'; import { customEmojis } from '@/custom-emojis.js'; import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue'; diff --git a/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts b/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts index d38d8de808..bf4158a2c8 100644 --- a/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts +++ b/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts @@ -5,7 +5,7 @@ import { action } from '@storybook/addon-actions'; import { expect, userEvent, waitFor, within } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { i18n } from '@/i18n.js'; import MkEmojiPicker from './MkEmojiPicker.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index a782ae9d3b..880f12f3fb 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -115,31 +115,34 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, computed, watch, onMounted } from 'vue'; +import { ref, useTemplateRef, computed, watch, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import { emojilist, emojiCharByCategory, - UnicodeEmojiDef, unicodeEmojiCategories as categories, getEmojiName, - CustomEmojiFolderTree, getUnicodeEmoji, } from '@@/js/emojilist.js'; +import type { + UnicodeEmojiDef, + CustomEmojiFolderTree, +} from '@@/js/emojilist.js'; import XSection from '@/components/MkEmojiPicker.section.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import * as os from '@/os.js'; -import { isTouchUsing } from '@/scripts/touch.js'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { isTouchUsing } from '@/utility/touch.js'; +import { deviceKind } from '@/utility/device-kind.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-emojis.js'; -import { $i } from '@/account.js'; -import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js'; +import { $i } from '@/i.js'; +import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ showPinned?: boolean; - pinnedEmojis?: string[]; + pinnedEmojis?: string[]; maxHeight?: number; asDrawer?: boolean; asWindow?: boolean; @@ -154,15 +157,16 @@ const emit = defineEmits<{ (ev: 'esc'): void; }>(); -const searchEl = shallowRef<HTMLInputElement>(); -const emojisEl = shallowRef<HTMLDivElement>(); +const searchEl = useTemplateRef('searchEl'); +const emojisEl = useTemplateRef('emojisEl'); const { emojiPickerScale, emojiPickerWidth, emojiPickerHeight, - recentlyUsedEmojis, -} = defaultStore.reactiveState; +} = prefer.r; + +const recentlyUsedEmojis = store.r.recentlyUsedEmojis; const recentlyUsedEmojisDef = computed(() => { return recentlyUsedEmojis.value.map(getDef).filter(x => x != null); @@ -317,7 +321,7 @@ watch(q, () => { } if (matches.size >= max) return matches; - for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) { + for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) { for (const emoji of emojis) { if (keywords.every(keyword => index[emoji.char].some(k => k.includes(keyword)))) { matches.add(emoji); @@ -334,7 +338,7 @@ watch(q, () => { } if (matches.size >= max) return matches; - for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) { + for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) { for (const emoji of emojis) { if (index[emoji.char].some(k => k.startsWith(newQ))) { matches.add(emoji); @@ -351,7 +355,7 @@ watch(q, () => { } if (matches.size >= max) return matches; - for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) { + for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) { for (const emoji of emojis) { if (index[emoji.char].some(k => k.includes(newQ))) { matches.add(emoji); @@ -413,7 +417,7 @@ function computeButtonTitle(ev: MouseEvent): void { function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, ev?: MouseEvent) { const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined; - if (el) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -427,10 +431,10 @@ function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, // 最近使った絵文字更新 if (!pinned.value?.includes(key)) { - let recents = defaultStore.state.recentlyUsedEmojis; + let recents = store.s.recentlyUsedEmojis; recents = recents.filter((emoji) => emoji !== key); recents.unshift(key); - defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32)); + store.set('recentlyUsedEmojis', recents.splice(0, 32)); } } @@ -582,7 +586,7 @@ defineExpose({ &:disabled { cursor: not-allowed; - background: linear-gradient(-45deg, transparent 0% 48%, var(--MI_THEME-X6) 48% 52%, transparent 52% 100%); + background: linear-gradient(-45deg, transparent 0% 48%, light-dark(rgba(0, 0, 0, 0.25), rgba(255, 255, 255, 0.15)) 48% 52%, transparent 52% 100%); opacity: 1; > .emoji { @@ -617,7 +621,7 @@ defineExpose({ &:disabled { cursor: not-allowed; - background: linear-gradient(-45deg, transparent 0% 48%, var(--MI_THEME-X6) 48% 52%, transparent 52% 100%); + background: linear-gradient(-45deg, transparent 0% 48%, light-dark(rgba(0, 0, 0, 0.25), rgba(255, 255, 255, 0.15)) 48% 52%, transparent 52% 100%); opacity: 1; > .emoji { @@ -738,7 +742,7 @@ defineExpose({ &:disabled { cursor: not-allowed; - background: linear-gradient(-45deg, transparent 0% 48%, var(--MI_THEME-X6) 48% 52%, transparent 52% 100%); + background: linear-gradient(-45deg, transparent 0% 48%, light-dark(rgba(0, 0, 0, 0.25), rgba(255, 255, 255, 0.15)) 48% 52%, transparent 52% 100%); opacity: 1; > .emoji { diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue index 3178f72498..5c4400eaf8 100644 --- a/packages/frontend/src/components/MkEmojiPickerDialog.vue +++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only ref="modal" v-slot="{ type, maxHeight }" :zPriority="'middle'" - :preferType="defaultStore.state.emojiPickerStyle" + :preferType="prefer.s.emojiPickerStyle" :hasInteractionWithOtherFocusTrappedEls="true" :transparentBg="true" :manualShowing="manualShowing" @@ -37,19 +37,19 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { shallowRef } from 'vue'; +import { useTemplateRef } from 'vue'; import MkModal from '@/components/MkModal.vue'; import MkEmojiPicker from '@/components/MkEmojiPicker.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ manualShowing?: boolean | null; src?: HTMLElement; showPinned?: boolean; - pinnedEmojis?: string[], + pinnedEmojis?: string[], asReactionPicker?: boolean; targetNote?: Misskey.entities.Note; - choseAndClose?: boolean; + choseAndClose?: boolean; }>(), { manualShowing: null, showPinned: true, @@ -64,8 +64,8 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); -const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>(); +const modal = useTemplateRef('modal'); +const picker = useTemplateRef('picker'); function chosen(emoji: string) { emit('done', emoji); diff --git a/packages/frontend/src/components/MkExtensionInstaller.stories.impl.ts b/packages/frontend/src/components/MkExtensionInstaller.stories.impl.ts index 6763f7c546..f531762710 100644 --- a/packages/frontend/src/components/MkExtensionInstaller.stories.impl.ts +++ b/packages/frontend/src/components/MkExtensionInstaller.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkExtensionInstaller from './MkExtensionInstaller.vue'; import lightTheme from '@@/themes/_light.json5'; diff --git a/packages/frontend/src/components/MkExtensionInstaller.vue b/packages/frontend/src/components/MkExtensionInstaller.vue index d59b20435e..a2247d844b 100644 --- a/packages/frontend/src/components/MkExtensionInstaller.vue +++ b/packages/frontend/src/components/MkExtensionInstaller.vue @@ -11,54 +11,91 @@ SPDX-License-Identifier: AGPL-3.0-only <!-- 拡張用? --> <i v-else class="ti ti-download"></i> </div> - <h2 :class="$style.extInstallerTitle">{{ i18n.ts._externalResourceInstaller[`_${extension.type}`].title }}</h2> - <div :class="$style.extInstallerNormDesc">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</div> - <MkInfo v-if="isPlugin" :warn="true">{{ i18n.ts._plugin.installWarn }}</MkInfo> - <FormSection> - <template #label>{{ i18n.ts._externalResourceInstaller[`_${extension.type}`].metaTitle }}</template> - <div class="_gaps_s"> - <FormSplit> + + <h2 v-if="isPlugin" :class="$style.extInstallerTitle">{{ i18n.ts._externalResourceInstaller._plugin.title }}</h2> + <h2 v-else-if="isTheme" :class="$style.extInstallerTitle">{{ i18n.ts._externalResourceInstaller._theme.title }}</h2> + + <MkInfo :warn="true">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</MkInfo> + + <div v-if="isPlugin" class="_gaps_s"> + <MkFolder :defaultOpen="true"> + <template #icon><i class="ti ti-info-circle"></i></template> + <template #label>{{ i18n.ts.metadata }}</template> + + <div class="_gaps_s"> + <FormSplit> + <MkKeyValue> + <template #key>{{ i18n.ts.name }}</template> + <template #value>{{ extension.meta.name }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.author }}</template> + <template #value>{{ extension.meta.author }}</template> + </MkKeyValue> + </FormSplit> + <MkKeyValue> + <template #key>{{ i18n.ts.description }}</template> + <template #value>{{ extension.meta.description ?? i18n.ts.none }}</template> + </MkKeyValue> <MkKeyValue> - <template #key>{{ i18n.ts.name }}</template> - <template #value>{{ extension.meta.name }}</template> + <template #key>{{ i18n.ts.version }}</template> + <template #value>{{ extension.meta.version }}</template> </MkKeyValue> <MkKeyValue> - <template #key>{{ i18n.ts.author }}</template> - <template #value>{{ extension.meta.author }}</template> + <template #key>{{ i18n.ts.permission }}</template> + <template #value> + <ul v-if="extension.meta.permissions && extension.meta.permissions.length > 0" :class="$style.extInstallerKVList"> + <li v-for="permission in extension.meta.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li> + </ul> + <template v-else>{{ i18n.ts.none }}</template> + </template> </MkKeyValue> - </FormSplit> - <MkKeyValue v-if="isPlugin"> - <template #key>{{ i18n.ts.description }}</template> - <template #value>{{ extension.meta.description ?? i18n.ts.none }}</template> - </MkKeyValue> - <MkKeyValue v-if="isPlugin"> - <template #key>{{ i18n.ts.version }}</template> - <template #value>{{ extension.meta.version }}</template> - </MkKeyValue> - <MkKeyValue v-if="isPlugin"> - <template #key>{{ i18n.ts.permission }}</template> - <template #value> - <ul v-if="extension.meta.permissions && extension.meta.permissions.length > 0" :class="$style.extInstallerKVList"> - <li v-for="permission in extension.meta.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li> - </ul> - <template v-else>{{ i18n.ts.none }}</template> - </template> - </MkKeyValue> - <MkKeyValue v-if="isTheme"> - <template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template> - <template #value>{{ i18n.ts[extension.meta.base ?? 'none'] }}</template> - </MkKeyValue> - <MkFolder> - <template #icon><i class="ti ti-code"></i></template> - <template #label>{{ i18n.ts._plugin.viewSource }}</template> + </div> + </MkFolder> + + <MkFolder :withSpacer="false"> + <template #icon><i class="ti ti-code"></i></template> + <template #label>{{ i18n.ts._plugin.viewSource }}</template> + + <MkCode :code="extension.raw"/> + </MkFolder> + </div> + <div v-else-if="isTheme" class="_gaps_s"> + <MkFolder :defaultOpen="true"> + <template #icon><i class="ti ti-info-circle"></i></template> + <template #label>{{ i18n.ts.metadata }}</template> + + <div class="_gaps_s"> + <FormSplit> + <MkKeyValue> + <template #key>{{ i18n.ts.name }}</template> + <template #value>{{ extension.meta.name }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.author }}</template> + <template #value>{{ extension.meta.author }}</template> + </MkKeyValue> + </FormSplit> + <MkKeyValue> + <template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template> + <template #value>{{ i18n.ts[extension.meta.base ?? 'none'] }}</template> + </MkKeyValue> + </div> + </MkFolder> + + <MkFolder :withSpacer="false"> + <template #icon><i class="ti ti-code"></i></template> + <template #label>{{ i18n.ts._theme.code }}</template> + + <MkCode :code="extension.raw"/> + </MkFolder> + </div> - <MkCode :code="extension.raw"/> - </MkFolder> - </div> - </FormSection> <slot name="additionalInfo"/> + <div class="_buttonsCenter"> - <MkButton primary @click="emits('confirm')"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton> + <MkButton danger rounded large @click="emits('cancel')"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton> + <MkButton gradate rounded large @click="emits('confirm')"><i class="ti ti-download"></i> {{ i18n.ts.install }}</MkButton> </div> </div> </template> @@ -105,6 +142,7 @@ const props = defineProps<{ const emits = defineEmits<{ (ev: 'confirm'): void; + (ev: 'cancel'): void; }>(); </script> @@ -112,13 +150,13 @@ const emits = defineEmits<{ .extInstallerRoot { border-radius: var(--MI-radius); background: var(--MI_THEME-panel); - padding: 1.5rem; + padding: 20px; } .extInstallerIconWrapper { width: 48px; height: 48px; - font-size: 24px; + font-size: 20px; line-height: 48px; text-align: center; border-radius: 50%; @@ -135,10 +173,6 @@ const emits = defineEmits<{ margin: 0; } -.extInstallerNormDesc { - text-align: center; -} - .extInstallerKVList { margin-top: 0; margin-bottom: 0; diff --git a/packages/frontend/src/components/MkFeatureBanner.vue b/packages/frontend/src/components/MkFeatureBanner.vue new file mode 100644 index 0000000000..e990ffc8f0 --- /dev/null +++ b/packages/frontend/src/components/MkFeatureBanner.vue @@ -0,0 +1,43 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div v-panel :class="$style.root"> + <img :class="$style.img" :src="icon"/> + <div :class="$style.text"> + <slot></slot> + </div> +</div> +</template> + +<script setup lang="ts"> +withDefaults(defineProps<{ + icon: string; + color: string; +}>(), { +}); +</script> + +<style module lang="scss"> +.root { + padding: 20px 24px; + text-align: center; + border-radius: var(--MI-radius); + background: linear-gradient(180deg, color(from v-bind(color) srgb r g b / 0.1), color(from v-bind(color) srgb r g b / 0)); +} + +.img { + display: block; + margin: 0 auto; + width: 40px; + aspect-ratio: 1; +} + +.text { + margin-top: 12px; + font-size: 85%; + mix-blend-mode: luminosity; +} +</style> diff --git a/packages/frontend/src/components/MkFileCaptionEditWindow.vue b/packages/frontend/src/components/MkFileCaptionEditWindow.vue index 8754c72b7b..7b5eefdc10 100644 --- a/packages/frontend/src/components/MkFileCaptionEditWindow.vue +++ b/packages/frontend/src/components/MkFileCaptionEditWindow.vue @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef, ref } from 'vue'; +import { useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -42,7 +42,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const caption = ref(props.default); diff --git a/packages/frontend/src/components/MkFlashPreview.stories.impl.ts b/packages/frontend/src/components/MkFlashPreview.stories.impl.ts index fa5288b73d..4a751062c9 100644 --- a/packages/frontend/src/components/MkFlashPreview.stories.impl.ts +++ b/packages/frontend/src/components/MkFlashPreview.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkFlashPreview from './MkFlashPreview.vue'; import { flash } from './../../.storybook/fakes.js'; export const Public = { diff --git a/packages/frontend/src/components/MkFoldableSection.vue b/packages/frontend/src/components/MkFoldableSection.vue index 5bf3fdfe76..aa5c82fc16 100644 --- a/packages/frontend/src/components/MkFoldableSection.vue +++ b/packages/frontend/src/components/MkFoldableSection.vue @@ -14,10 +14,10 @@ SPDX-License-Identifier: AGPL-3.0-only </button> </header> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.folderToggleEnterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.folderToggleLeaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.folderToggleEnterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.folderToggleLeaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.folderToggleEnterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.folderToggleLeaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.folderToggleEnterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.folderToggleLeaveTo : ''" @enter="enter" @afterEnter="afterEnter" @leave="leave" @@ -31,10 +31,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, shallowRef, watch } from 'vue'; +import { onMounted, ref, useTemplateRef, watch } from 'vue'; import { miLocalStorage } from '@/local-storage.js'; -import { defaultStore } from '@/store.js'; -import { getBgColor } from '@/scripts/get-bg-color.js'; +import { prefer } from '@/preferences.js'; +import { getBgColor } from '@/utility/get-bg-color.js'; const miLocalStoragePrefix = 'ui:folder:' as const; @@ -46,7 +46,7 @@ const props = withDefaults(defineProps<{ persistKey: null, }); -const rootEl = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); const parentBg = ref<string | null>(null); // eslint-disable-next-line vue/no-setup-props-reactivity-loss const showBody = ref((props.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`) === 't') : props.expanded); diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 084c81bb52..0d062d0bd8 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -27,10 +27,10 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened"> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_toggle_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_toggle_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_toggle_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_toggle_leaveTo : ''" @enter="enter" @afterEnter="afterEnter" @leave="leave" @@ -56,9 +56,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted, ref, shallowRef } from 'vue'; -import { defaultStore } from '@/store.js'; -import { getBgColor } from '@/scripts/get-bg-color.js'; +import { nextTick, onMounted, ref, useTemplateRef } from 'vue'; +import { prefer } from '@/preferences.js'; +import { getBgColor } from '@/utility/get-bg-color.js'; const props = withDefaults(defineProps<{ defaultOpen?: boolean; @@ -74,7 +74,7 @@ const props = withDefaults(defineProps<{ spacerMax: 22, }); -const rootEl = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); const bgSame = ref(false); const opened = ref(props.defaultOpen); const openedAtLeastOnce = ref(props.defaultOpen); @@ -116,7 +116,7 @@ function toggle() { } onMounted(() => { - const computedStyle = getComputedStyle(document.documentElement); + const computedStyle = getComputedStyle(window.document.documentElement); const parentBg = getBgColor(rootEl.value?.parentElement) ?? 'transparent'; const myBg = computedStyle.getPropertyValue('--MI_THEME-panel'); bgSame.value = parentBg === myBg; diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index 93c3b481b2..3ae56dbe8d 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -39,13 +39,13 @@ import { onBeforeUnmount, onMounted, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { host } from '@@/js/config.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { pleaseLogin } from '@/scripts/please-login.js'; -import { $i } from '@/account.js'; -import { defaultStore } from '@/store.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import { $i } from '@/i.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ user: Misskey.entities.UserDetailed, @@ -106,7 +106,7 @@ async function onClick() { userId: props.user.id, }); } else { - if (defaultStore.state.alwaysConfirmFollow && !hasPendingFollowRequestFromYou.value) { + if (prefer.s.alwaysConfirmFollow && !hasPendingFollowRequestFromYou.value) { const { canceled } = await os.confirm({ type: 'question', text: i18n.tsx.followConfirm({ name: props.user.name || props.user.username }), @@ -136,11 +136,11 @@ async function onClick() { } else { await misskeyApi('following/create', { userId: props.user.id, - withReplies: defaultStore.state.defaultWithReplies, + withReplies: prefer.s.defaultFollowWithReplies, }); emit('update:user', { ...props.user, - withReplies: defaultStore.state.defaultWithReplies, + withReplies: prefer.s.defaultFollowWithReplies, }); hasPendingFollowRequestFromYou.value = true; diff --git a/packages/frontend/src/components/MkFormDialog.file.vue b/packages/frontend/src/components/MkFormDialog.file.vue index ecb6cf882b..0a902f3400 100644 --- a/packages/frontend/src/components/MkFormDialog.file.vue +++ b/packages/frontend/src/components/MkFormDialog.file.vue @@ -15,8 +15,8 @@ import * as Misskey from 'misskey-js'; import { computed, ref } from 'vue'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; -import { selectFile } from '@/scripts/select-file.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { selectFile } from '@/utility/select-file.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const props = defineProps<{ fileId?: string | null; diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index a639eae208..4756079e76 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -63,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </div> <div v-else class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> </MkSpacer> @@ -71,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { reactive, shallowRef } from 'vue'; +import { reactive, useTemplateRef } from 'vue'; import MkInput from './MkInput.vue'; import MkTextarea from './MkTextarea.vue'; import MkSwitch from './MkSwitch.vue'; @@ -80,7 +80,7 @@ import MkRange from './MkRange.vue'; import MkButton from './MkButton.vue'; import MkRadios from './MkRadios.vue'; import XFile from './MkFormDialog.file.vue'; -import type { Form } from '@/scripts/form.js'; +import type { Form } from '@/utility/form.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; @@ -99,7 +99,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const values = reactive({}); for (const item in props.form) { diff --git a/packages/frontend/src/components/MkFukidashi.vue b/packages/frontend/src/components/MkFukidashi.vue index 8b1c56fca4..e9544afa35 100644 --- a/packages/frontend/src/components/MkFukidashi.vue +++ b/packages/frontend/src/components/MkFukidashi.vue @@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only tail === 'left' ? $style.left : $style.right, negativeMargin === true && $style.negativeMargin, shadow === true && $style.shadow, + accented === true && $style.accented ]" > <div :class="$style.bg"> @@ -30,10 +31,12 @@ withDefaults(defineProps<{ tail?: 'left' | 'right' | 'none'; negativeMargin?: boolean; shadow?: boolean; + accented?: boolean; }>(), { tail: 'right', negativeMargin: false, shadow: false, + accented: false, }); </script> @@ -47,6 +50,10 @@ withDefaults(defineProps<{ min-height: calc(var(--fukidashi-radius) * 2); padding-top: calc(var(--fukidashi-radius) * .13); + &.accented { + --fukidashi-bg: var(--MI_THEME-accent); + } + &.shadow { filter: drop-shadow(0 4px 32px var(--MI_THEME-shadow)); } @@ -77,7 +84,7 @@ withDefaults(defineProps<{ .content { position: relative; - padding: 8px 12px; + padding: 10px 14px; } .tail { diff --git a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts index a433ad680b..616e04aabb 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts +++ b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect, userEvent, waitFor, within } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { galleryPost } from '../../.storybook/fakes.js'; import MkGalleryPostPreview from './MkGalleryPostPreview.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue index 22f8355acf..49a6c65170 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.vue +++ b/packages/frontend/src/components/MkGalleryPostPreview.vue @@ -35,14 +35,14 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { computed, ref } from 'vue'; import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ post: Misskey.entities.GalleryPost; }>(); const hover = ref(false); -const safe = computed(() => defaultStore.state.nsfw === 'ignore' || defaultStore.state.nsfw === 'respect' && !props.post.isSensitive); +const safe = computed(() => prefer.s.nsfw === 'ignore' || prefer.s.nsfw === 'respect' && !props.post.isSensitive); const show = computed(() => safe.value || hover.value); function enterHover(): void { diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue index 0cc0df9911..28bb936755 100644 --- a/packages/frontend/src/components/MkHeatmap.vue +++ b/packages/frontend/src/components/MkHeatmap.vue @@ -13,14 +13,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, nextTick, watch, shallowRef, ref } from 'vue'; +import { onMounted, nextTick, watch, useTemplateRef, ref } from 'vue'; import { Chart } from 'chart.js'; import * as Misskey from 'misskey-js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { alpha } from '@/scripts/color.js'; -import { initChart } from '@/scripts/init-chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { alpha } from '@/utility/color.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); @@ -35,8 +35,8 @@ const props = withDefaults(defineProps<{ label: '', }); -const rootEl = shallowRef<HTMLDivElement | null>(null); -const chartEl = shallowRef<HTMLCanvasElement | null>(null); +const rootEl = useTemplateRef('rootEl'); +const chartEl = useTemplateRef('chartEl'); const now = new Date(); let chartInstance: Chart | null = null; const fetching = ref(true); @@ -106,7 +106,7 @@ async function renderChart() { await nextTick(); - const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300'; + const color = store.s.darkMode ? '#b4e900' : '#86b300'; // 視覚上の分かりやすさのため上から最も大きい3つの値の平均を最大値とする const max = values.slice().sort((a, b) => b - a).slice(0, 3).reduce((a, b) => a + b, 0) / 3; diff --git a/packages/frontend/src/components/MkHorizontalSwipe.vue b/packages/frontend/src/components/MkHorizontalSwipe.vue index 196c962a06..bc63bef0b6 100644 --- a/packages/frontend/src/components/MkHorizontalSwipe.vue +++ b/packages/frontend/src/components/MkHorizontalSwipe.vue @@ -19,21 +19,20 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveToClass="transitionName === 'swipeAnimationLeft' ? $style.swipeAnimationLeft_leaveTo : $style.swipeAnimationRight_leaveTo" :style="`--swipe: ${pullDistance}px;`" > - <!-- 【注意】slot内の最上位要素に動的にkeyを設定すること --> - <!-- 各最上位要素にユニークなkeyの指定がないとTransitionがうまく動きません --> - <slot></slot> + <div :key="tabModel"> + <slot></slot> + </div> </Transition> </div> </template> <script lang="ts" setup> -import { ref, shallowRef, computed, nextTick, watch } from 'vue'; +import { ref, useTemplateRef, computed, nextTick, watch } from 'vue'; import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; -import { defaultStore } from '@/store.js'; -import { isHorizontalSwipeSwiping as isSwiping } from '@/scripts/touch.js'; +import { isHorizontalSwipeSwiping as isSwiping } from '@/utility/touch.js'; +import { prefer } from '@/preferences.js'; -const rootEl = shallowRef<HTMLDivElement>(); +const rootEl = useTemplateRef('rootEl'); -// eslint-disable-next-line no-undef const tabModel = defineModel<string>('tab'); const props = defineProps<{ @@ -44,7 +43,7 @@ const emit = defineEmits<{ (ev: 'swiped', newKey: string, direction: 'left' | 'right'): void; }>(); -const shouldAnimate = computed(() => defaultStore.reactiveState.enableHorizontalSwipe.value || defaultStore.reactiveState.animation.value); +const shouldAnimate = computed(() => prefer.r.enableHorizontalSwipe.value || prefer.r.animation.value); // ▼ しきい値 ▼ // @@ -72,7 +71,7 @@ const isSwipingForClass = ref(false); let swipeAborted = false; function touchStart(event: TouchEvent) { - if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return; + if (!prefer.r.enableHorizontalSwipe.value) return; if (event.touches.length !== 1) return; @@ -83,7 +82,7 @@ function touchStart(event: TouchEvent) { } function touchMove(event: TouchEvent) { - if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return; + if (!prefer.r.enableHorizontalSwipe.value) return; if (event.touches.length !== 1) return; @@ -134,7 +133,7 @@ function touchEnd(event: TouchEvent) { return; } - if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return; + if (!prefer.r.enableHorizontalSwipe.value) return; if (event.touches.length !== 0) return; diff --git a/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts b/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts new file mode 100644 index 0000000000..339e6d10f3 --- /dev/null +++ b/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { StoryObj } from '@storybook/vue3'; +import { file } from '../../.storybook/fakes.js'; +import MkImgPreviewDialog from './MkImgPreviewDialog.vue'; +export const Default = { + render(args) { + return { + components: { + MkImgPreviewDialog, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkImgPreviewDialog v-bind="props" />', + }; + }, + args: { + file: file(), + }, + parameters: { + chromatic: { + // NOTE: ロードが終わるまで待つ + delay: 3000, + }, + layout: 'centered', + }, +} satisfies StoryObj<typeof MkImgPreviewDialog>; diff --git a/packages/frontend/src/components/MkImgPreviewDialog.vue b/packages/frontend/src/components/MkImgPreviewDialog.vue new file mode 100644 index 0000000000..3e6e4e0ec9 --- /dev/null +++ b/packages/frontend/src/components/MkImgPreviewDialog.vue @@ -0,0 +1,58 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="modal" + :width="1800" + :height="900" + @close="close" + @esc="close" + @click="close" +> + <template #header>{{ file.name }}</template> + <div :class="$style.container"> + <img :src="file.url" :alt="file.comment ?? file.name" :class="$style.img"/> + </div> +</MkModalWindow> +</template> +<script lang="ts" setup> +import { defineProps, ref } from 'vue'; +import MkModalWindow from './MkModalWindow.vue'; +import type * as Misskey from 'misskey-js'; + +defineProps<{ + file: Misskey.entities.DriveFile; +}>(); + +const modal = ref<typeof MkModalWindow | null>(null); + +function close() { + modal.value?.close(); +} + +</script> +<style lang="scss" module> + .container { + box-sizing: border-box; + width: 100%; + height: 100%; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + background-color: var(--MI_THEME-bg); + background-size: auto auto; + background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px); + } + + .img { + width: 100%; + max-height: 100%; + object-fit: contain; + } +</style> diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index b0741aaf5e..b71cabd5ff 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -6,16 +6,42 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="root" :class="['chromatic-ignore', $style.root, { [$style.cover]: cover }]" :title="title ?? ''"> <TransitionGroup - :duration="defaultStore.state.animation && props.transition?.duration || undefined" - :enterActiveClass="defaultStore.state.animation && props.transition?.enterActiveClass || undefined" - :leaveActiveClass="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style.transition_leaveActive) || undefined" - :enterFromClass="defaultStore.state.animation && props.transition?.enterFromClass || undefined" - :leaveToClass="defaultStore.state.animation && props.transition?.leaveToClass || undefined" - :enterToClass="defaultStore.state.animation && props.transition?.enterToClass || undefined" - :leaveFromClass="defaultStore.state.animation && props.transition?.leaveFromClass || undefined" + :duration="prefer.s.animation && props.transition?.duration || undefined" + :enterActiveClass="prefer.s.animation && props.transition?.enterActiveClass || undefined" + :leaveActiveClass="prefer.s.animation && (props.transition?.leaveActiveClass ?? $style.transition_leaveActive) || undefined" + :enterFromClass="prefer.s.animation && props.transition?.enterFromClass || undefined" + :leaveToClass="prefer.s.animation && props.transition?.leaveToClass || undefined" + :enterToClass="prefer.s.animation && props.transition?.enterToClass || undefined" + :leaveFromClass="prefer.s.animation && props.transition?.leaveFromClass || undefined" > - <canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined" tabindex="-1"/> - <img v-show="!hide" key="img" ref="img" :height="imgHeight ?? undefined" :width="imgWidth ?? undefined" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async" tabindex="-1"/> + <canvas + v-show="hide" + key="canvas" + ref="canvas" + :class="$style.canvas" + :width="canvasWidth" + :height="canvasHeight" + :title="title ?? undefined" + draggable="false" + tabindex="-1" + style="-webkit-user-drag: none;" + /> + <img + v-show="!hide" + key="img" + ref="img" + :height="imgHeight ?? undefined" + :width="imgWidth ?? undefined" + :class="$style.img" + :src="src ?? undefined" + :title="title ?? undefined" + :alt="alt ?? undefined" + loading="eager" + decoding="async" + draggable="false" + tabindex="-1" + style="-webkit-user-drag: none;" + /> </TransitionGroup> </div> </template> @@ -29,7 +55,7 @@ import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurha const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => { // テスト環境で Web Worker インスタンスは作成できない if (import.meta.env.MODE === 'test') { - const canvas = document.createElement('canvas'); + const canvas = window.document.createElement('canvas'); canvas.width = 64; canvas.height = 64; resolve(canvas); @@ -43,13 +69,11 @@ const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resol Math.min(navigator.hardwareConcurrency - 1, 4), ); resolve(workers); - if (_DEV_) console.log('WebGL2 in worker is supported!'); } else { - const canvas = document.createElement('canvas'); + const canvas = window.document.createElement('canvas'); canvas.width = 64; canvas.height = 64; resolve(canvas); - if (_DEV_) console.log('WebGL2 in worker is not supported...'); } testWorker.terminate(); }); @@ -57,10 +81,10 @@ const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resol </script> <script lang="ts" setup> -import { computed, nextTick, onMounted, onUnmounted, shallowRef, watch, ref } from 'vue'; +import { computed, nextTick, onMounted, onUnmounted, useTemplateRef, watch, ref } from 'vue'; import { v4 as uuid } from 'uuid'; import { render } from 'buraha'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ transition?: { @@ -94,9 +118,9 @@ const props = withDefaults(defineProps<{ }); const viewId = uuid(); -const canvas = shallowRef<HTMLCanvasElement>(); -const root = shallowRef<HTMLDivElement>(); -const img = shallowRef<HTMLImageElement>(); +const canvas = useTemplateRef('canvas'); +const root = useTemplateRef('root'); +const img = useTemplateRef('img'); const loaded = ref(false); const canvasWidth = ref(64); const canvasHeight = ref(64); diff --git a/packages/frontend/src/components/MkInfo.vue b/packages/frontend/src/components/MkInfo.vue index bdc38f5142..edfddc9037 100644 --- a/packages/frontend/src/components/MkInfo.vue +++ b/packages/frontend/src/components/MkInfo.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="[$style.root, { [$style.warn]: warn }]"> +<div :class="[$style.root, { [$style.warn]: warn }]" class="_selectable"> <i v-if="warn" class="ti ti-alert-triangle" :class="$style.i"></i> <i v-else class="ti ti-info-circle" :class="$style.i"></i> <div><slot></slot></div> diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index ec299dce36..26c361c122 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> +<div class="_selectable"> <div :class="$style.label" @click="focus"><slot name="label"></slot></div> <div :class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused }]"> <div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div> @@ -44,12 +44,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, nextTick, ref, shallowRef, watch, computed, toRefs, InputHTMLAttributes } from 'vue'; +import { onMounted, onUnmounted, nextTick, ref, useTemplateRef, watch, computed, toRefs } from 'vue'; import { debounce } from 'throttle-debounce'; -import MkButton from '@/components/MkButton.vue'; import { useInterval } from '@@/js/use-interval.js'; +import type { InputHTMLAttributes } from 'vue'; +import type { SuggestionType } from '@/utility/autocomplete.js'; +import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js'; +import { Autocomplete } from '@/utility/autocomplete.js'; const props = defineProps<{ modelValue: string | number | null; @@ -90,9 +92,9 @@ const focused = ref(false); const changed = ref(false); const invalid = ref(false); const filled = computed(() => v.value !== '' && v.value != null); -const inputEl = shallowRef<HTMLInputElement>(); -const prefixEl = shallowRef<HTMLElement>(); -const suffixEl = shallowRef<HTMLElement>(); +const inputEl = useTemplateRef('inputEl'); +const prefixEl = useTemplateRef('prefixEl'); +const suffixEl = useTemplateRef('suffixEl'); const height = props.small ? 33 : props.large ? 39 : diff --git a/packages/frontend/src/components/MkInstanceCardMini.stories.impl.ts b/packages/frontend/src/components/MkInstanceCardMini.stories.impl.ts index 9e8de9d878..bd69fb2f82 100644 --- a/packages/frontend/src/components/MkInstanceCardMini.stories.impl.ts +++ b/packages/frontend/src/components/MkInstanceCardMini.stories.impl.ts @@ -3,13 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { federationInstance } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import { getChartResolver } from '../../.storybook/charts.js'; import MkInstanceCardMini from './MkInstanceCardMini.vue'; +import type { StoryObj } from '@storybook/vue3'; export const Default = { render(args) { @@ -48,7 +47,7 @@ export const Default = { const url = new URL(urlStr); if (url.href.startsWith('https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/')) { - const image = await (await fetch(`client-assets/${url.pathname.split('/').pop()}`)).blob(); + const image = await (await window.fetch(`client-assets/${url.pathname.split('/').pop()}`)).blob(); return new HttpResponse(image, { headers: { 'Content-Type': 'image/jpeg', diff --git a/packages/frontend/src/components/MkInstanceCardMini.vue b/packages/frontend/src/components/MkInstanceCardMini.vue index b063b82b17..d20b24a439 100644 --- a/packages/frontend/src/components/MkInstanceCardMini.vue +++ b/packages/frontend/src/components/MkInstanceCardMini.vue @@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkMiniChart from '@/components/MkMiniChart.vue'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; -import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; +import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; const props = defineProps<{ instance: Misskey.entities.FederationInstance; diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index d8066857fe..90391005bc 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -84,21 +84,22 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, computed, shallowRef } from 'vue'; +import { onMounted, ref, computed, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; +import type { HeatmapSource } from '@/components/MkHeatmap.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkChart from '@/components/MkChart.vue'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { $i } from '@/account.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { $i } from '@/i.js'; import * as os from '@/os.js'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import MkHeatmap, { type HeatmapSource } from '@/components/MkHeatmap.vue'; +import MkHeatmap from '@/components/MkHeatmap.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue'; import MkRetentionLineChart from '@/components/MkRetentionLineChart.vue'; -import { initChart } from '@/scripts/init-chart.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); @@ -108,8 +109,8 @@ const chartLimit = 500; const chartSpan = ref<'hour' | 'day'>('hour'); const chartSrc = ref('active-users'); const heatmapSrc = ref<HeatmapSource>('active-users'); -const subDoughnutEl = shallowRef<HTMLCanvasElement>(); -const pubDoughnutEl = shallowRef<HTMLCanvasElement>(); +const subDoughnutEl = useTemplateRef('subDoughnutEl'); +const pubDoughnutEl = useTemplateRef('pubDoughnutEl'); const { handler: externalTooltipHandler1 } = useChartTooltip({ position: 'middle', @@ -125,7 +126,7 @@ function createDoughnut(chartEl, tooltip, data) { labels: data.map(x => x.name), datasets: [{ backgroundColor: data.map(x => x.color), - borderColor: getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-panel'), + borderColor: getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-panel'), borderWidth: 2, hoverOffset: 0, data: data.map(x => x.value), diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue index 9d9cc76822..e0c07f1f9f 100644 --- a/packages/frontend/src/components/MkInstanceTicker.vue +++ b/packages/frontend/src/components/MkInstanceTicker.vue @@ -11,10 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, type CSSProperties } from 'vue'; +import { computed } from 'vue'; +import type { CSSProperties } from 'vue'; import { instanceName as localInstanceName } from '@@/js/config.js'; import { instance as localInstance } from '@/instance.js'; -import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; +import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; const props = defineProps<{ host: string | null; diff --git a/packages/frontend/src/components/MkInviteCode.stories.impl.ts b/packages/frontend/src/components/MkInviteCode.stories.impl.ts index 456d215288..ccdebf0a4d 100644 --- a/packages/frontend/src/components/MkInviteCode.stories.impl.ts +++ b/packages/frontend/src/components/MkInviteCode.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed, inviteCode } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkInviteCode.vue b/packages/frontend/src/components/MkInviteCode.vue index 1a71f6574f..ab797459cc 100644 --- a/packages/frontend/src/components/MkInviteCode.vue +++ b/packages/frontend/src/components/MkInviteCode.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.items"> <div> <div :class="$style.label">{{ i18n.ts.invitationCode }}</div> - <div>{{ invite.code }}</div> + <div class="_selectableAtomic">{{ invite.code }}</div> </div> <div v-if="moderator"> <div :class="$style.label">{{ i18n.ts.inviteCodeCreator }}</div> @@ -64,7 +64,7 @@ import { computed } from 'vue'; import * as Misskey from 'misskey-js'; import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; @@ -90,7 +90,6 @@ function deleteCode() { function copyInviteCode() { copyToClipboard(props.invite.code); - os.success(); } </script> diff --git a/packages/frontend/src/components/MkKeyValue.vue b/packages/frontend/src/components/MkKeyValue.vue index 50c9e16e5e..b4185d2d0a 100644 --- a/packages/frontend/src/components/MkKeyValue.vue +++ b/packages/frontend/src/components/MkKeyValue.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.key"> <slot name="key"></slot> </div> - <div :class="$style.value"> + <div :class="$style.value" class="_selectable"> <slot name="value"></slot> <button v-if="copy" v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copy_"><i class="ti ti-copy"></i></button> </div> @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { } from 'vue'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; @@ -31,7 +31,6 @@ const props = withDefaults(defineProps<{ const copy_ = () => { copyToClipboard(props.copy); - os.success(); }; </script> diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index c7af75e2e7..f33896b7da 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -27,11 +27,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef } from 'vue'; +import { useTemplateRef } from 'vue'; import MkModal from '@/components/MkModal.vue'; import { navbarItemDef } from '@/navbar.js'; -import { defaultStore } from '@/store.js'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { deviceKind } from '@/utility/device-kind.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ src?: HTMLElement; @@ -48,9 +48,9 @@ const preferedModalType = (deviceKind === 'desktop' && props.src != null) ? 'pop deviceKind === 'smartphone' ? 'drawer' : 'dialog'; -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); -const menu = defaultStore.state.menu; +const menu = prefer.s.menu; const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => navbarItemDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({ type: def.to ? 'link' : 'button', diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue index 263cd95eb1..4a431a64df 100644 --- a/packages/frontend/src/components/MkLink.vue +++ b/packages/frontend/src/components/MkLink.vue @@ -19,10 +19,10 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent, ref } from 'vue'; import { url as local } from '@@/js/config.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; +import { useTooltip } from '@/use/use-tooltip.js'; import * as os from '@/os.js'; import { isEnabledUrlPreview } from '@/instance.js'; -import { MkABehavior } from '@/components/global/MkA.vue'; +import type { MkABehavior } from '@/components/global/MkA.vue'; import { warningExternalWebsite } from '@/scripts/warning-external-website.js'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue index 10450fb621..8ede22db0d 100644 --- a/packages/frontend/src/components/MkMediaAudio.vue +++ b/packages/frontend/src/components/MkMediaAudio.vue @@ -10,20 +10,20 @@ SPDX-License-Identifier: AGPL-3.0-only tabindex="0" :class="[ $style.audioContainer, - (audio.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive, + (audio.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive, ]" @contextmenu.stop @keydown.stop > <button v-if="hide" :class="$style.hidden" @click="show"> <div :class="$style.hiddenTextWrapper"> - <b v-if="audio.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.audio}${audio.size ? ' ' + bytes(audio.size) : ''})` : '' }}</b> - <b v-else style="display: block;"><i class="ti ti-music"></i> {{ defaultStore.state.dataSaver.media && audio.size ? bytes(audio.size) : i18n.ts.audio }}</b> + <b v-if="audio.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media ? ` (${i18n.ts.audio}${audio.size ? ' ' + bytes(audio.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ti ti-music"></i> {{ prefer.s.dataSaver.media && audio.size ? bytes(audio.size) : i18n.ts.audio }}</b> <span style="display: block;">{{ i18n.ts.clickToShow }}</span> </div> </button> - <div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.nativeAudioContainer"> + <div v-else-if="prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.nativeAudioContainer"> <audio ref="audioEl" preload="metadata" @@ -91,17 +91,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef, watch, computed, ref, onDeactivated, onActivated, onMounted } from 'vue'; +import { useTemplateRef, watch, computed, ref, onDeactivated, onActivated, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import type { MenuItem } from '@/types/menu.js'; -import { defaultStore } from '@/store.js'; +import type { Keymap } from '@/utility/hotkey.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { type Keymap } from '@/scripts/hotkey.js'; import bytes from '@/filters/bytes.js'; import { hms } from '@/filters/hms.js'; import MkMediaRange from '@/components/MkMediaRange.vue'; -import { $i, iAmModerator } from '@/account.js'; +import { $i, iAmModerator } from '@/i.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ audio: Misskey.entities.DriveFile; @@ -150,17 +151,17 @@ const keymap = { // PlayerElもしくはその子要素にフォーカスがあるかどうか function hasFocus() { if (!playerEl.value) return false; - return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement); + return playerEl.value === window.document.activeElement || playerEl.value.contains(window.document.activeElement); } -const playerEl = shallowRef<HTMLDivElement>(); -const audioEl = shallowRef<HTMLAudioElement>(); +const playerEl = useTemplateRef('playerEl'); +const audioEl = useTemplateRef('audioEl'); // eslint-disable-next-line vue/no-setup-props-reactivity-loss -const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore')); +const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore')); async function show() { - if (props.audio.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) { + if (props.audio.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { const { canceled } = await os.confirm({ type: 'question', text: i18n.ts.sensitiveMediaRevealConfirm, @@ -219,10 +220,9 @@ function showMenu(ev: MouseEvent) { }); } + const details: MenuItem[] = []; if ($i?.id === props.audio.userId) { - menu.push({ - type: 'divider', - }, { + details.push({ type: 'link', text: i18n.ts._fileViewer.title, icon: 'ti ti-info-circle', @@ -230,6 +230,29 @@ function showMenu(ev: MouseEvent) { }); } + if (iAmModerator) { + details.push({ + type: 'link', + text: i18n.ts.moderation, + icon: 'ti ti-photo-exclamation', + to: `/admin/file/${props.audio.id}`, + }); + } + + if (details.length > 0) { + menu.push({ type: 'divider' }, ...details); + } + + if (prefer.s.devMode) { + menu.push({ type: 'divider' }, { + icon: 'ti ti-hash', + text: i18n.ts.copyFileId, + action: () => { + copyToClipboard(props.audio.id); + }, + }); + } + menuShowing.value = true; os.popupMenu(menu, ev.currentTarget ?? ev.target, { align: 'right', @@ -239,7 +262,14 @@ function showMenu(ev: MouseEvent) { }); } -function toggleSensitive(file: Misskey.entities.DriveFile) { +async function toggleSensitive(file: Misskey.entities.DriveFile) { + const { canceled } = await os.confirm({ + type: 'warning', + text: file.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm, + }); + + if (canceled) return; + os.apiWithDialog('drive/files/update', { fileId: file.id, isSensitive: !file.isSensitive, @@ -380,7 +410,7 @@ onDeactivated(() => { elapsedTimeMs.value = 0; durationMs.value = 0; bufferedEnd.value = 0; - hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore'); + hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore'); stopAudioElWatch(); onceInit = false; if (mediaTickFrameId) { diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index 56048a33d8..2aa971d43c 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -27,9 +27,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; import * as os from '@/os.js'; import MkMediaAudio from '@/components/MkMediaAudio.vue'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ media: Misskey.entities.DriveFile; @@ -38,7 +38,7 @@ const props = defineProps<{ const hide = ref(true); async function show() { - if (props.media.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) { + if (props.media.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { const { canceled } = await os.confirm({ type: 'question', text: i18n.ts.sensitiveMediaRevealConfirm, diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index 4aca7256a5..0f72da9ce9 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive]" @click="onclick"> +<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="onclick"> <component :is="disableImageLink ? 'div' : 'a'" v-bind="disableImageLink ? { @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only > <ImgWithBlurhash :hash="image.blurhash" - :src="(defaultStore.state.dataSaver.media && hide) ? null : url" + :src="(prefer.s.dataSaver.media && hide) ? null : url" :forceBlurhash="hide" :cover="hide || cover" :alt="image.comment" @@ -32,8 +32,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="hide"> <div :class="$style.hiddenText"> <div :class="$style.hiddenTextWrapper"> - <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b> - <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && image.size ? bytes(image.size) : i18n.ts.image }}</b> + <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ prefer.s.dataSaver.media && image.size ? bytes(image.size) : i18n.ts.image }}</b> <span v-if="controls" style="display: block;">{{ i18n.ts.clickToShow }}</span> </div> </div> @@ -55,13 +55,14 @@ SPDX-License-Identifier: AGPL-3.0-only import { watch, ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import type { MenuItem } from '@/types/menu.js'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; import bytes from '@/filters/bytes.js'; import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; -import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { $i, iAmModerator } from '@/account.js'; +import { $i, iAmModerator } from '@/i.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ image: Misskey.entities.DriveFile; @@ -77,9 +78,9 @@ const props = withDefaults(defineProps<{ const hide = ref(true); -const url = computed(() => (props.raw || defaultStore.state.loadRawImages) +const url = computed(() => (props.raw || prefer.s.loadRawImages) ? props.image.url - : defaultStore.state.disableShowingAnimatedImages + : prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(props.image.url) : props.image.thumbnailUrl, ); @@ -91,7 +92,7 @@ async function onclick(ev: MouseEvent) { if (hide.value) { ev.stopPropagation(); - if (props.image.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) { + if (props.image.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { const { canceled } = await os.confirm({ type: 'question', text: i18n.ts.sensitiveMediaRevealConfirm, @@ -105,7 +106,7 @@ async function onclick(ev: MouseEvent) { // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする watch(() => props.image, () => { - hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore'); + hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.image.isSensitive && prefer.s.nsfw !== 'ignore'); }, { deep: true, immediate: true, @@ -124,19 +125,28 @@ function showMenu(ev: MouseEvent) { if (iAmModerator) { menuItems.push({ - text: i18n.ts.markAsSensitive, + text: props.image.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, icon: 'ti ti-eye-exclamation', danger: true, - action: () => { - os.apiWithDialog('drive/files/update', { fileId: props.image.id, isSensitive: true }); + action: async () => { + const { canceled } = await os.confirm({ + type: 'warning', + text: props.image.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm, + }); + + if (canceled) return; + + os.apiWithDialog('drive/files/update', { + fileId: props.image.id, + isSensitive: !props.image.isSensitive, + }); }, }); } + const details: MenuItem[] = []; if ($i?.id === props.image.userId) { - menuItems.push({ - type: 'divider', - }, { + details.push({ type: 'link', text: i18n.ts._fileViewer.title, icon: 'ti ti-info-circle', @@ -144,6 +154,29 @@ function showMenu(ev: MouseEvent) { }); } + if (iAmModerator) { + details.push({ + type: 'link', + text: i18n.ts.moderation, + icon: 'ti ti-photo-exclamation', + to: `/admin/file/${props.image.id}`, + }); + } + + if (details.length > 0) { + menuItems.push({ type: 'divider' }, ...details); + } + + if (prefer.s.devMode) { + menuItems.push({ type: 'divider' }, { + icon: 'ti ti-hash', + text: i18n.ts.copyFileId, + action: () => { + copyToClipboard(props.image.id); + }, + }); + } + os.popupMenu(menuItems, ev.currentTarget ?? ev.target); } diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index 487cf509d6..fcc01d9197 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -12,9 +12,9 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[ $style.medias, count === 1 ? [$style.n1, { - [$style.n116_9]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '16_9', - [$style.n11_1]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '1_1', - [$style.n12_3]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '2_3', + [$style.n116_9]: prefer.s.mediaListWithOneImageAppearance === '16_9', + [$style.n11_1]: prefer.s.mediaListWithOneImageAppearance === '1_1', + [$style.n12_3]: prefer.s.mediaListWithOneImageAppearance === '2_3', }] : count === 2 ? $style.n2 : count === 3 ? $style.n3 : count === 4 ? $style.n4 : $style.nMany, ]" > @@ -30,29 +30,29 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, onUnmounted, shallowRef } from 'vue'; +import { computed, onMounted, onUnmounted, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import PhotoSwipeLightbox from 'photoswipe/lightbox'; import PhotoSwipe from 'photoswipe'; import 'photoswipe/style.css'; +import { FILE_TYPE_BROWSERSAFE, FILE_EXT_TRACKER_MODULES, FILE_TYPE_TRACKER_MODULES, FILE_TYPE_FLASH_CONTENT, FILE_EXT_FLASH_CONTENT } from '@@/js/const.js'; import XBanner from '@/components/MkMediaBanner.vue'; import XImage from '@/components/MkMediaImage.vue'; import XVideo from '@/components/MkMediaVideo.vue'; import XModPlayer from '@/components/SkModPlayer.vue'; import XFlashPlayer from '@/components/SkFlashPlayer.vue'; import * as os from '@/os.js'; -import { FILE_TYPE_BROWSERSAFE, FILE_EXT_TRACKER_MODULES, FILE_TYPE_TRACKER_MODULES, FILE_TYPE_FLASH_CONTENT, FILE_EXT_FLASH_CONTENT } from '@@/js/const.js'; -import { defaultStore } from '@/store.js'; -import { focusParent } from '@/scripts/focus.js'; +import { focusParent } from '@/utility/focus.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ mediaList: Misskey.entities.DriveFile[]; raw?: boolean; }>(); -const gallery = shallowRef<HTMLDivElement>(); +const gallery = useTemplateRef('gallery'); const pswpZIndex = os.claimZIndex('middle'); -document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString()); +window.document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString()); const count = computed(() => props.mediaList.filter(media => previewable(media)).length); let lightbox: PhotoSwipeLightbox | null = null; @@ -79,7 +79,7 @@ async function calcAspectRatio() { return `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`; }; - switch (defaultStore.state.mediaListWithOneImageAppearance) { + switch (prefer.s.mediaListWithOneImageAppearance) { case '16_9': gallery.value.style.aspectRatio = ratioMax(16 / 9); break; @@ -182,7 +182,7 @@ onMounted(() => { className: 'pswp__alt-text-container', appendTo: 'wrapper', onInit: (el, pswp) => { - const textBox = document.createElement('p'); + const textBox = window.document.createElement('p'); textBox.className = 'pswp__alt-text _acrylic'; el.appendChild(textBox); @@ -206,19 +206,19 @@ onMounted(() => { }); lightbox.on('afterInit', () => { - activeEl = document.activeElement instanceof HTMLElement ? document.activeElement : null; + activeEl = window.document.activeElement instanceof HTMLElement ? window.document.activeElement : null; focusParent(activeEl, true, true); lightbox?.pswp?.element?.focus({ preventScroll: true, }); - history.pushState(null, '', '#pswp'); + window.history.pushState(null, '', '#pswp'); }); lightbox.on('destroy', () => { focusParent(activeEl, true, false); activeEl = null; if (window.location.hash === '#pswp') { - history.back(); + window.history.back(); } }); @@ -261,7 +261,6 @@ defineExpose({ .container { position: relative; width: 100%; - margin-top: 4px; } .medias { diff --git a/packages/frontend/src/components/MkMediaRange.vue b/packages/frontend/src/components/MkMediaRange.vue index df7505b0c3..9689dc5cfa 100644 --- a/packages/frontend/src/components/MkMediaRange.vue +++ b/packages/frontend/src/components/MkMediaRange.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { computed, ModelRef } from 'vue'; +import { computed } from 'vue'; withDefaults(defineProps<{ buffer?: number; @@ -28,8 +28,7 @@ const emit = defineEmits<{ (ev: 'dragEnded', value: number): void; }>(); -// eslint-disable-next-line no-undef -const model = defineModel({ required: true }) as ModelRef<string | number>; +const model = defineModel<string | number>({ required: true }); const modelValue = computed({ get: () => typeof model.value === 'number' ? model.value : parseFloat(model.value), set: v => { model.value = v; }, diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index 04f8e9f8d8..a60e100969 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[ $style.videoContainer, controlsShowing && $style.active, - (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive, + (video.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive, ]" @mouseover="onMouseOver" @mouseleave="onMouseLeave" @@ -20,13 +20,13 @@ SPDX-License-Identifier: AGPL-3.0-only > <button v-if="hide" :class="$style.hidden" @click="show"> <div :class="$style.hiddenTextWrapper"> - <b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> - <b v-else style="display: block;"><i class="ti ti-movie"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b> + <b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ti ti-movie"></i> {{ prefer.s.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b> <span style="display: block;">{{ i18n.ts.clickToShow }}</span> </div> </button> - <div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.videoRoot"> + <div v-else-if="prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.videoRoot"> <video ref="videoEl" :class="$style.video" @@ -112,19 +112,20 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue'; +import { ref, useTemplateRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import type { MenuItem } from '@/types/menu.js'; -import { type Keymap } from '@/scripts/hotkey.js'; +import type { Keymap } from '@/utility/hotkey.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard'; import bytes from '@/filters/bytes.js'; import { hms } from '@/filters/hms.js'; -import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { exitFullscreen, requestFullscreen } from '@/scripts/fullscreen.js'; -import hasAudio from '@/scripts/media-has-audio.js'; +import { exitFullscreen, requestFullscreen } from '@/utility/fullscreen.js'; +import hasAudio from '@/utility/media-has-audio.js'; import MkMediaRange from '@/components/MkMediaRange.vue'; -import { $i, iAmModerator } from '@/account.js'; +import { $i, iAmModerator } from '@/i.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ video: Misskey.entities.DriveFile; @@ -173,14 +174,14 @@ const keymap = { // PlayerElもしくはその子要素にフォーカスがあるかどうか function hasFocus() { if (!playerEl.value) return false; - return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement); + return playerEl.value === window.document.activeElement || playerEl.value.contains(window.document.activeElement); } // eslint-disable-next-line vue/no-setup-props-reactivity-loss -const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore')); +const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.video.isSensitive && prefer.s.nsfw !== 'ignore')); async function show() { - if (props.video.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) { + if (props.video.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { const { canceled } = await os.confirm({ type: 'question', text: i18n.ts.sensitiveMediaRevealConfirm, @@ -218,7 +219,7 @@ function showMenu(ev: MouseEvent) { '2.0x': 2, }, }, - ...(document.pictureInPictureEnabled ? [{ + ...(window.document.pictureInPictureEnabled ? [{ text: i18n.ts._mediaControls.pip, icon: 'ti ti-picture-in-picture', action: togglePictureInPicture, @@ -244,10 +245,9 @@ function showMenu(ev: MouseEvent) { }); } + const details: MenuItem[] = []; if ($i?.id === props.video.userId) { - menu.push({ - type: 'divider', - }, { + details.push({ type: 'link', text: i18n.ts._fileViewer.title, icon: 'ti ti-info-circle', @@ -255,6 +255,29 @@ function showMenu(ev: MouseEvent) { }); } + if (iAmModerator) { + details.push({ + type: 'link', + text: i18n.ts.moderation, + icon: 'ti ti-photo-exclamation', + to: `/admin/file/${props.video.id}`, + }); + } + + if (details.length > 0) { + menu.push({ type: 'divider' }, ...details); + } + + if (prefer.s.devMode) { + menu.push({ type: 'divider' }, { + icon: 'ti ti-hash', + text: i18n.ts.copyFileId, + action: () => { + copyToClipboard(props.video.id); + }, + }); + } + menuShowing.value = true; os.popupMenu(menu, ev.currentTarget ?? ev.target, { align: 'right', @@ -264,7 +287,14 @@ function showMenu(ev: MouseEvent) { }); } -function toggleSensitive(file: Misskey.entities.DriveFile) { +async function toggleSensitive(file: Misskey.entities.DriveFile) { + const { canceled } = await os.confirm({ + type: 'warning', + text: file.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm, + }); + + if (canceled) return; + os.apiWithDialog('drive/files/update', { fileId: file.id, isSensitive: !file.isSensitive, @@ -272,8 +302,8 @@ function toggleSensitive(file: Misskey.entities.DriveFile) { } // MediaControl: Video State -const videoEl = shallowRef<HTMLVideoElement>(); -const playerEl = shallowRef<HTMLDivElement>(); +const videoEl = useTemplateRef('videoEl'); +const playerEl = useTemplateRef('playerEl'); const isHoverring = ref(false); const controlsShowing = computed(() => { if (!oncePlayed.value) return true; @@ -357,8 +387,8 @@ function toggleFullscreen() { function togglePictureInPicture() { if (videoEl.value) { - if (document.pictureInPictureElement) { - document.exitPictureInPicture(); + if (window.document.pictureInPictureElement) { + window.document.exitPictureInPicture(); } else { videoEl.value.requestPictureInPicture(); } @@ -475,7 +505,7 @@ onDeactivated(() => { elapsedTimeMs.value = 0; durationMs.value = 0; bufferedEnd.value = 0; - hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'); + hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.video.isSensitive && prefer.s.nsfw !== 'ignore'); stopVideoElWatch(); onceInit = false; if (mediaTickFrameId) { diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue index ac50d82a63..de8f39e7a8 100644 --- a/packages/frontend/src/components/MkMention.vue +++ b/packages/frontend/src/components/MkMention.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <img :class="$style.icon" :src="avatarUrl" alt=""> <span> <span>@{{ username }}</span> - <span v-if="(host != localHost) || defaultStore.state.showFullAcct" :class="$style.host">@{{ toUnicode(host) }}</span> + <span v-if="(host != localHost)" :class="$style.host">@{{ toUnicode(host) }}</span> </span> </MkA> </template> @@ -17,10 +17,10 @@ SPDX-License-Identifier: AGPL-3.0-only import { toUnicode } from 'punycode.js'; import { computed } from 'vue'; import { host as localHost } from '@@/js/config.js'; -import { $i } from '@/account.js'; -import { defaultStore } from '@/store.js'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; -import { MkABehavior } from '@/components/global/MkA.vue'; +import type { MkABehavior } from '@/components/global/MkA.vue'; +import { $i } from '@/i.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ username: string; @@ -36,7 +36,7 @@ const isMe = $i && ( `@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase() ); -const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar +const avatarUrl = computed(() => prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar ? getStaticImageUrl(`/avatar/@${props.username}@${props.host}`) : `/avatar/@${props.username}@${props.host}`, ); diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue index 086573ba6d..f7cd72b6c6 100644 --- a/packages/frontend/src/components/MkMenu.child.vue +++ b/packages/frontend/src/components/MkMenu.child.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted, onUnmounted, provide, shallowRef, watch } from 'vue'; +import { nextTick, onMounted, onUnmounted, provide, useTemplateRef, watch } from 'vue'; import MkMenu from './MkMenu.vue'; import type { MenuItem } from '@/types/menu.js'; @@ -28,7 +28,7 @@ const emit = defineEmits<{ provide('isNestingMenu', true); -const el = shallowRef<HTMLElement>(); +const el = useTemplateRef('el'); const align = 'left'; const SCROLLBAR_THICKNESS = 16; diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index b0a1b80210..c688cb4b1f 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.center]: align === 'center', [$style.big]: big, [$style.asDrawer]: asDrawer, + [$style.widthSpecified]: width != null, }" @focusin.passive.stop="() => {}" > @@ -29,12 +30,19 @@ SPDX-License-Identifier: AGPL-3.0-only > <template v-for="item in (items2 ?? [])"> <div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div> + <span v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label, $style.item]"> <span style="opacity: 0.7;">{{ item.text }}</span> </span> + <span v-else-if="item.type === 'pending'" role="menuitem" tabindex="0" :class="[$style.pending, $style.item]"> <span><MkEllipsis/></span> </span> + + <div v-else-if="item.type === 'component'" role="menuitem" tabindex="-1" :class="[$style.componentItem]"> + <component :is="item.component" v-bind="item.props"/> + </div> + <MkA v-else-if="item.type === 'link'" role="menuitem" @@ -48,10 +56,14 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/> <div :class="$style.item_content"> - <span :class="$style.item_content_text">{{ item.text }}</span> + <div :class="$style.item_content_text"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span> </div> </MkA> + <a v-else-if="item.type === 'a'" role="menuitem" @@ -67,10 +79,14 @@ SPDX-License-Identifier: AGPL-3.0-only > <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <div :class="$style.item_content"> - <span :class="$style.item_content_text">{{ item.text }}</span> + <div :class="$style.item_content_text"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span> </div> </a> + <button v-else-if="item.type === 'user'" role="menuitem" @@ -85,6 +101,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span> </div> </button> + <button v-else-if="item.type === 'switch'" role="menuitemcheckbox" @@ -98,10 +115,14 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <MkSwitchButton v-else :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/> <div :class="$style.item_content"> - <span :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]">{{ item.text }}</span> + <div :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <MkSwitchButton v-if="item.icon" :class="[$style.switchButton, $style.caret]" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/> </div> </button> + <button v-else-if="item.type === 'radio'" role="menuitem" @@ -114,10 +135,14 @@ SPDX-License-Identifier: AGPL-3.0-only > <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i> <div :class="$style.item_content"> - <span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span> + <div :class="$style.item_content_text" style="pointer-events: none;"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span> </div> </button> + <button v-else-if="item.type === 'radioOption'" role="menuitemradio" @@ -131,9 +156,13 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="[$style.radioIcon, { [$style.radioChecked]: unref(item.active) }]"></span> </div> <div :class="$style.item_content"> - <span :class="$style.item_content_text">{{ item.text }}</span> + <div :class="$style.item_content_text"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> </div> </button> + <button v-else-if="item.type === 'parent'" role="menuitem" @@ -145,12 +174,17 @@ SPDX-License-Identifier: AGPL-3.0-only > <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i> <div :class="$style.item_content"> - <span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span> + <div :class="$style.item_content_text" style="pointer-events: none;"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span> </div> </button> + <button - v-else role="menuitem" + v-else + role="menuitem" tabindex="0" :class="['_button', $style.item, { [$style.danger]: item.danger, [$style.active]: unref(item.active) }]" @click.prevent="unref(item.active) ? close(false) : clicked(item.action, $event)" @@ -160,11 +194,15 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/> <div :class="$style.item_content"> - <span :class="$style.item_content_text">{{ item.text }}</span> + <div :class="$style.item_content_text"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span> </div> </button> </template> + <span v-if="items2 == null || items2.length === 0" tabindex="-1" :class="[$style.none, $style.item]"> <span>{{ i18n.ts.none }}</span> </span> @@ -176,15 +214,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts"> -import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, unref, watch } from 'vue'; +import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, unref, watch, shallowRef } from 'vue'; +import type { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js'; +import type { Keymap } from '@/utility/hotkey.js'; import MkSwitchButton from '@/components/MkSwitch.button.vue'; -import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { isTouchUsing } from '@/scripts/touch.js'; -import { type Keymap } from '@/scripts/hotkey.js'; -import { isFocusable } from '@/scripts/focus.js'; -import { getNodeOrNull } from '@/scripts/get-dom-node-or-null.js'; +import { isTouchUsing } from '@/utility/touch.js'; +import { isFocusable } from '@/utility/focus.js'; +import { getNodeOrNull } from '@/utility/get-dom-node-or-null.js'; const childrenCache = new WeakMap<MenuParent, MenuItem[]>(); </script> @@ -209,11 +247,11 @@ const big = isTouchUsing; const isNestingMenu = inject<boolean>('isNestingMenu', false); -const itemsEl = shallowRef<HTMLElement>(); +const itemsEl = useTemplateRef('itemsEl'); const items2 = ref<InnerMenuItem[]>(); -const child = shallowRef<InstanceType<typeof XChild>>(); +const child = useTemplateRef('child'); const keymap = { 'up|k|shift+tab': { @@ -254,7 +292,7 @@ watch(() => props.items, () => { }); const childMenu = ref<MenuItem[] | null>(); -const childTarget = shallowRef<HTMLElement | null>(); +const childTarget = shallowRef<HTMLElement>(); function closeChild() { childMenu.value = null; @@ -355,10 +393,10 @@ function switchItem(item: MenuSwitch & { ref: any }) { function focusUp() { if (disposed) return; - if (!itemsEl.value?.contains(document.activeElement)) return; + if (!itemsEl.value?.contains(window.document.activeElement)) return; const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable); - const activeIndex = focusableElements.findIndex(el => el === document.activeElement); + const activeIndex = focusableElements.findIndex(el => el === window.document.activeElement); const targetIndex = (activeIndex !== -1 && activeIndex !== 0) ? (activeIndex - 1) : (focusableElements.length - 1); const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value; @@ -367,10 +405,10 @@ function focusUp() { function focusDown() { if (disposed) return; - if (!itemsEl.value?.contains(document.activeElement)) return; + if (!itemsEl.value?.contains(window.document.activeElement)) return; const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable); - const activeIndex = focusableElements.findIndex(el => el === document.activeElement); + const activeIndex = focusableElements.findIndex(el => el === window.document.activeElement); const targetIndex = (activeIndex !== -1 && activeIndex !== (focusableElements.length - 1)) ? (activeIndex + 1) : 0; const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value; @@ -397,9 +435,9 @@ const onGlobalMousedown = (ev: MouseEvent) => { const setupHandlers = () => { if (!isNestingMenu) { - document.addEventListener('focusin', onGlobalFocusin, { passive: true }); + window.document.addEventListener('focusin', onGlobalFocusin, { passive: true }); } - document.addEventListener('mousedown', onGlobalMousedown, { passive: true }); + window.document.addEventListener('mousedown', onGlobalMousedown, { passive: true }); }; let disposed = false; @@ -407,9 +445,9 @@ let disposed = false; const disposeHandlers = () => { disposed = true; if (!isNestingMenu) { - document.removeEventListener('focusin', onGlobalFocusin); + window.document.removeEventListener('focusin', onGlobalFocusin); } - document.removeEventListener('mousedown', onGlobalMousedown); + window.document.removeEventListener('mousedown', onGlobalMousedown); }; onMounted(() => { @@ -435,6 +473,12 @@ onBeforeUnmount(() => { } } + &:not(.widthSpecified) { + > .menu { + max-width: 400px; + } + } + &.big:not(.asDrawer) { > .menu { min-width: 230px; @@ -558,11 +602,11 @@ onBeforeUnmount(() => { } &.danger { - --menuFg: #ff2a2a; + --menuFg: var(--MI_THEME-error); --menuHoverFg: #fff; - --menuHoverBg: #ff4242; + --menuHoverBg: var(--MI_THEME-error); --menuActiveFg: #fff; - --menuActiveBg: #d42e2e; + --menuActiveBg: hsl(from var(--MI_THEME-error) h s calc(l - 10)); } &.radio { @@ -604,10 +648,19 @@ onBeforeUnmount(() => { .item_content_text { max-width: calc(100vw - 4rem); +} + +.item_content_text_title { text-overflow: ellipsis; overflow: hidden; } +.item_content_text_caption { + text-wrap: auto; + font-size: 85%; + opacity: 0.7; +} + .switchButton { margin-left: -2px; --height: 1.35em; diff --git a/packages/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue index 7ea585ecc2..98bd471438 100644 --- a/packages/frontend/src/components/MkMiniChart.vue +++ b/packages/frontend/src/components/MkMiniChart.vue @@ -48,7 +48,7 @@ const polygonPoints = ref(''); const headX = ref<number | null>(null); const headY = ref<number | null>(null); const clock = ref<number | null>(null); -const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-accent')); +const accent = tinycolor(getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-accent')); const color = accent.toRgbString(); function draw(): void { diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index a446dad0ab..b5c93df4ed 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -42,14 +42,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, normalizeClass, onMounted, onUnmounted, provide, watch, ref, shallowRef, computed } from 'vue'; +import { nextTick, normalizeClass, onMounted, onUnmounted, provide, watch, ref, useTemplateRef, computed } from 'vue'; +import type { Keymap } from '@/utility/hotkey.js'; import * as os from '@/os.js'; -import { isTouchUsing } from '@/scripts/touch.js'; -import { defaultStore } from '@/store.js'; -import { deviceKind } from '@/scripts/device-kind.js'; -import { type Keymap } from '@/scripts/hotkey.js'; -import { focusTrap } from '@/scripts/focus-trap.js'; -import { focusParent } from '@/scripts/focus.js'; +import { isTouchUsing } from '@/utility/touch.js'; +import { deviceKind } from '@/utility/device-kind.js'; +import { focusTrap } from '@/utility/focus-trap.js'; +import { focusParent } from '@/utility/focus.js'; +import { prefer } from '@/preferences.js'; function getFixedContainer(el: Element | null): Element | null { if (el == null || el.tagName === 'BODY') return null; @@ -100,13 +100,13 @@ const maxHeight = ref<number>(); const fixed = ref(false); const transformOrigin = ref('center'); const showing = ref(true); -const modalRootEl = shallowRef<HTMLElement>(); -const content = shallowRef<HTMLElement>(); +const modalRootEl = useTemplateRef('modalRootEl'); +const content = useTemplateRef('content'); const zIndex = os.claimZIndex(props.zPriority); const useSendAnime = ref(false); const type = computed<ModalTypes>(() => { if (props.preferType === 'auto') { - if ((defaultStore.state.menuStyle === 'drawer') || (defaultStore.state.menuStyle === 'auto' && isTouchUsing && deviceKind === 'smartphone')) { + if ((prefer.s.menuStyle === 'drawer') || (prefer.s.menuStyle === 'auto' && isTouchUsing && deviceKind === 'smartphone')) { return 'drawer'; } else { return props.src != null ? 'popup' : 'dialog'; @@ -117,7 +117,7 @@ const type = computed<ModalTypes>(() => { }); const isEnableBgTransparent = computed(() => props.transparentBg && (type.value === 'popup')); const transitionName = computed((() => - defaultStore.state.animation + prefer.s.animation ? useSendAnime.value ? 'send' : type.value === 'drawer' diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index f06cfffee4..19989e375b 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, shallowRef, ref } from 'vue'; +import { onMounted, onUnmounted, useTemplateRef, ref } from 'vue'; import MkModal from './MkModal.vue'; const props = withDefaults(defineProps<{ @@ -47,9 +47,9 @@ const emit = defineEmits<{ (event: 'esc'): void; }>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); -const rootEl = shallowRef<HTMLElement>(); -const headerEl = shallowRef<HTMLElement>(); +const modal = useTemplateRef('modal'); +const rootEl = useTemplateRef('rootEl'); +const headerEl = useTemplateRef('headerEl'); const bodyWidth = ref(0); const bodyHeight = ref(0); diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 2f0e39835c..fed3dafeea 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only v-show="!isDeleted" ref="rootEl" v-hotkey="keymap" - :class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover, [$style.skipRender]: defaultStore.state.skipNoteRender }]" + :class="[$style.root, { [$style.showActionsOnlyHover]: prefer.s.showNoteActionsOnlyHover, [$style.skipRender]: prefer.s.skipNoteRender }]" :tabindex="isDeleted ? '-1' : '0'" > <div v-if="appearNote.reply && inReplyToCollapsed" :class="$style.collapsedInReplyTo"> @@ -86,12 +86,13 @@ SPDX-License-Identifier: AGPL-3.0-only :enableEmojiMenuReaction="true" :isAnim="allowAnim" :isBlock="true" + class="_selectable" /> <div v-if="translating || translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> <div v-else-if="translation"> <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> - <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> + <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/> </div> </div> <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> @@ -159,9 +160,9 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i> <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> <i v-else class="ph-smiley ph-bold ph-lg"></i> - <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p> + <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p> </button> - <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()"> + <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()"> <i class="ti ti-paperclip"></i> </button> <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()"> @@ -206,14 +207,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, inject, onMounted, ref, shallowRef, Ref, watch, provide } from 'vue'; +import { computed, inject, onMounted, ref, useTemplateRef, watch, provide } from 'vue'; import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; import { shouldCollapsed } from '@@/js/collapsed.js'; import { host } from '@@/js/config.js'; +import type { Ref } from 'vue'; import { computeMergedCw } from '@@/js/compute-merged-cw.js'; import type { MenuItem } from '@/types/menu.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; +import type { Keymap } from '@/utility/hotkey.js'; import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; @@ -226,35 +230,36 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; import MkButton from '@/components/MkButton.vue'; -import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js'; -import { checkWordMute } from '@/scripts/check-word-mute.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import { checkWordMute } from '@/utility/check-word-mute.js'; import { notePage } from '@/filters/note.js'; import { userPage } from '@/filters/user.js'; import number from '@/filters/number.js'; import * as os from '@/os.js'; -import * as sound from '@/scripts/sound.js'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; -import { defaultStore, noteViewInterruptors } from '@/store.js'; -import { reactionPicker } from '@/scripts/reaction-picker.js'; -import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; -import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; -import { $i } from '@/account.js'; +import * as sound from '@/utility/sound.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; +import { reactionPicker } from '@/utility/reaction-picker.js'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; +import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu.js'; -import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js'; -import { useNoteCapture } from '@/scripts/use-note-capture.js'; -import { deepClone } from '@/scripts/clone.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { getNoteSummary } from '@/scripts/get-note-summary.js'; +import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js'; +import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js'; +import { useNoteCapture } from '@/use/use-note-capture.js'; +import { deepClone } from '@/utility/clone.js'; +import { useTooltip } from '@/use/use-tooltip.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { getNoteSummary } from '@/utility/get-note-summary.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; -import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; +import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import { useRouter } from '@/router/supplier.js'; -import { boostMenuItems, type Visibility, computeRenoteTooltip } from '@/scripts/boost-quote.js'; +import { boostMenuItems, type Visibility, computeRenoteTooltip } from '@/utility/boost-quote.js'; import { isEnabledUrlPreview } from '@/instance.js'; -import { type Keymap } from '@/scripts/hotkey.js'; -import { focusPrev, focusNext } from '@/scripts/focus.js'; -import { getAppearNote } from '@/scripts/get-appear-note.js'; +import { focusPrev, focusNext } from '@/utility/focus.js'; +import { getAppearNote } from '@/utility/get-appear-note.js'; +import { prefer } from '@/preferences.js'; +import { getPluginHandlers } from '@/plugin.js'; +import { DI } from '@/di.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -265,7 +270,7 @@ const props = withDefaults(defineProps<{ mock: false, }); -provide('mock', props.mock); +provide(DI.mock, props.mock); const emit = defineEmits<{ (ev: 'reaction', emoji: string): void; @@ -289,6 +294,7 @@ function noteclick(id: string) { } // plugin +const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); if (noteViewInterruptors.length > 0) { onMounted(async () => { let result: Misskey.entities.Note | null = deepClone(note.value); @@ -309,17 +315,17 @@ if (noteViewInterruptors.length > 0) { const isRenote = Misskey.note.isPureRenote(note.value); -const rootEl = shallowRef<HTMLElement>(); -const menuButton = shallowRef<HTMLElement>(); -const menuVersionsButton = shallowRef<HTMLElement>(); -const renoteButton = shallowRef<HTMLElement>(); -const renoteTime = shallowRef<HTMLElement>(); -const reactButton = shallowRef<HTMLElement>(); -const quoteButton = shallowRef<HTMLElement>(); -const clipButton = shallowRef<HTMLElement>(); -const likeButton = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); +const menuButton = useTemplateRef('menuButton'); +const renoteButton = useTemplateRef('renoteButton'); +const renoteTime = useTemplateRef('renoteTime'); +const reactButton = useTemplateRef('reactButton'); +const clipButton = useTemplateRef('clipButton'); +const menuVersionsButton = useTemplateRef('menuVersionsButton'); +const quoteButton = useTemplateRef('quoteButton'); +const likeButton = useTemplateRef('likeButton'); const appearNote = computed(() => getAppearNote(note.value)); -const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>(); +const galleryEl = useTemplateRef('galleryEl'); const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(defaultStore.state.uncollapseCW); const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); @@ -330,13 +336,13 @@ const isDeleted = ref(false); const renoted = ref(false); const muted = ref(checkMute(appearNote.value, $i?.mutedWords)); const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true)); -const showSoftWordMutedWord = computed(() => defaultStore.state.showSoftWordMutedWord); +const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord); const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translating = ref(false); -const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); +const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); const renoteCollapsed = ref( - defaultStore.state.collapseRenotes && isRenote && ( + prefer.s.collapseRenotes && isRenote && ( ($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131 (appearNote.value.myReaction != null) ), @@ -401,7 +407,7 @@ const keymap = { }, 'c': () => { if (renoteCollapsed.value) return; - if (!defaultStore.state.showClipButtonInNoteFooter) return; + if (!prefer.s.showClipButtonInNoteFooter) return; clip(); }, 'o': () => { @@ -699,7 +705,7 @@ function react(viaKeyboard = false): void { override: defaultLike.value, }); const el = reactButton.value; - if (el) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -709,7 +715,16 @@ function react(viaKeyboard = false): void { } } else { blur(); - reactionPicker.show(reactButton.value ?? null, note.value, reaction => { + reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => { + if (prefer.s.confirmOnReact) { + const confirm = await os.confirm({ + type: 'question', + text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }), + }); + + if (confirm.canceled) return; + } + sound.playMisskeySfx('reaction'); if (props.mock) { @@ -781,7 +796,7 @@ function onContextmenu(ev: MouseEvent): void { if (ev.target && isLink(ev.target as HTMLElement)) return; if (window.getSelection()?.toString() !== '') return; - if (defaultStore.state.useReactionPickerForContextMenu) { + if (prefer.s.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 1bbd3ba5d0..531232f86a 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -101,13 +101,14 @@ SPDX-License-Identifier: AGPL-3.0-only :enableEmojiMenuReaction="true" :isAnim="allowAnim" :isBlock="true" + class="_selectable" /> <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> <div v-if="translating || translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> <div v-else-if="translation"> <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> - <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> + <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/> </div> </div> <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> @@ -169,9 +170,9 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i> <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> <i v-else class="ph-smiley ph-bold ph-lg"></i> - <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p> + <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p> </button> - <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()"> + <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()"> <i class="ti ti-paperclip"></i> </button> <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()"> @@ -240,12 +241,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, inject, onMounted, provide, ref, shallowRef, watch } from 'vue'; +import { computed, inject, onMounted, provide, ref, useTemplateRef } from 'vue'; import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; import { host } from '@@/js/config.js'; import { computeMergedCw } from '@@/js/compute-merged-cw.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; +import type { Paging } from '@/components/MkPagination.vue'; +import type { Keymap } from '@/utility/hotkey.js'; import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; @@ -256,41 +260,41 @@ import MkPoll from '@/components/MkPoll.vue'; import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; -import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js'; -import { checkWordMute } from '@/scripts/check-word-mute.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import { checkWordMute } from '@/utility/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import { notePage } from '@/filters/note.js'; import number from '@/filters/number.js'; import * as os from '@/os.js'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; -import * as sound from '@/scripts/sound.js'; -import { defaultStore, noteViewInterruptors } from '@/store.js'; -import { reactionPicker } from '@/scripts/reaction-picker.js'; -import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; -import { $i } from '@/account.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; +import * as sound from '@/utility/sound.js'; +import { reactionPicker } from '@/utility/reaction-picker.js'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu.js'; -import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js'; -import { useNoteCapture } from '@/scripts/use-note-capture.js'; -import { deepClone } from '@/scripts/clone.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; +import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js'; +import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js'; +import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js'; +import { useNoteCapture } from '@/use/use-note-capture.js'; +import { deepClone } from '@/utility/clone.js'; +import { useTooltip } from '@/use/use-tooltip.js'; +import { claimAchievement } from '@/utility/achievements.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; -import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; +import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; -import MkPagination, { type Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; import { boostMenuItems, type Visibility, computeRenoteTooltip } from '@/scripts/boost-quote.js'; import { isEnabledUrlPreview } from '@/instance.js'; -import { getAppearNote } from '@/scripts/get-appear-note.js'; -import { type Keymap } from '@/scripts/hotkey.js'; +import { getAppearNote } from '@/utility/get-appear-note.js'; +import { prefer } from '@/preferences.js'; +import { getPluginHandlers } from '@/plugin.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; + initialTab?: string; expandAllCws?: boolean; - initialTab: string; }>(), { initialTab: 'replies', }); @@ -300,6 +304,7 @@ const inChannel = inject('inChannel', null); const note = ref(deepClone(props.note)); // plugin +const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); if (noteViewInterruptors.length > 0) { onMounted(async () => { let result: Misskey.entities.Note | null = deepClone(note.value); @@ -320,17 +325,17 @@ if (noteViewInterruptors.length > 0) { const isRenote = Misskey.note.isPureRenote(note.value); -const rootEl = shallowRef<HTMLElement>(); -const menuButton = shallowRef<HTMLElement>(); -const menuVersionsButton = shallowRef<HTMLElement>(); -const renoteButton = shallowRef<HTMLElement>(); -const renoteTime = shallowRef<HTMLElement>(); -const reactButton = shallowRef<HTMLElement>(); -const quoteButton = shallowRef<HTMLElement>(); -const clipButton = shallowRef<HTMLElement>(); -const likeButton = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); +const menuButton = useTemplateRef('menuButton'); +const renoteButton = useTemplateRef('renoteButton'); +const renoteTime = useTemplateRef('renoteTime'); +const reactButton = useTemplateRef('reactButton'); +const clipButton = useTemplateRef('clipButton'); +const menuVersionsButton = useTemplateRef('menuVersionsButton'); +const quoteButton = useTemplateRef('quoteButton'); +const likeButton = useTemplateRef('likeButton'); const appearNote = computed(() => getAppearNote(note.value)); -const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>(); +const galleryEl = useTemplateRef('galleryEl'); const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(defaultStore.state.uncollapseCW); const isDeleted = ref(false); @@ -341,8 +346,8 @@ const translating = ref(false); const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null; const animated = computed(() => parsed ? checkAnimationFromMfm(parsed) : null); -const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); -const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); +const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm); +const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); const conversation = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]); const quotes = ref<Misskey.entities.Note[]>([]); @@ -380,7 +385,7 @@ const keymap = { 'q': () => { if (canRenote.value && !renoted.value && !renoting) renote(defaultStore.state.visibilityOnBoost); }, 'm': () => showMenu(), 'c': () => { - if (!defaultStore.state.showClipButtonInNoteFooter) return; + if (!prefer.s.showClipButtonInNoteFooter) return; clip(); }, 'o': () => galleryEl.value?.openGallery(), @@ -644,7 +649,7 @@ function react(): void { override: defaultLike.value, }); const el = reactButton.value; - if (el) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -654,7 +659,16 @@ function react(): void { } } else { blur(); - reactionPicker.show(reactButton.value ?? null, note.value, reaction => { + reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => { + if (prefer.s.confirmOnReact) { + const confirm = await os.confirm({ + type: 'question', + text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }), + }); + + if (confirm.canceled) return; + } + sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { @@ -728,7 +742,7 @@ function onContextmenu(ev: MouseEvent): void { if (ev.target && isLink(ev.target as HTMLElement)) return; if (window.getSelection()?.toString() !== '') return; - if (defaultStore.state.useReactionPickerForContextMenu) { + if (prefer.s.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 3b15242685..42b61b841a 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -41,9 +41,9 @@ import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import { notePage } from '@/filters/note.js'; import { userPage } from '@/filters/user.js'; -import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js'; +import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js'; import { popupMenu } from '@/os.js'; -import { defaultStore } from '@/store.js'; +import { DI } from '@/di.js'; const props = defineProps<{ note: Misskey.entities.Note & { @@ -61,7 +61,7 @@ async function menuVersions(viaKeyboard = false): Promise<void> { }).then(focus).finally(cleanup); } -const mock = inject<boolean>('mock', false); +const mock = inject(DI.mock, false); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkNoteMediaGrid.vue b/packages/frontend/src/components/MkNoteMediaGrid.vue index bf105c3c27..764d9f6a32 100644 --- a/packages/frontend/src/components/MkNoteMediaGrid.vue +++ b/packages/frontend/src/components/MkNoteMediaGrid.vue @@ -4,51 +4,51 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> - <template v-for="file in note.files"> - <div - v-if="((( - (defaultStore.state.nsfw === 'force' || file.isSensitive) && - defaultStore.state.nsfw !== 'ignore' - ) || (defaultStore.state.dataSaver.media && file.type.startsWith('image/'))) && - !showingFiles.has(file.id) - )" - :class="[$style.filePreview, { [$style.square]: square }]" - @click="showingFiles.add(file.id)" - > - <MkDriveFileThumbnail - :file="file" - fit="cover" - :highlightWhenSensitive="defaultStore.state.highlightSensitiveMedia" - :forceBlurhash="true" - :large="true" - :class="$style.file" - /> - <div :class="$style.sensitive"> - <div> - <div v-if="file.isSensitive"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media && file.size ? ` (${bytes(file.size)})` : '' }}</div> - <div v-else><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && file.size ? bytes(file.size) : i18n.ts.image }}</div> - <div>{{ i18n.ts.clickToShow }}</div> - </div> +<template v-for="file in note.files"> + <div + v-if="((( + (prefer.s.nsfw === 'force' || file.isSensitive) && + prefer.s.nsfw !== 'ignore' + ) || (prefer.s.dataSaver.media && file.type.startsWith('image/'))) && + !showingFiles.has(file.id) + )" + :class="[$style.filePreview, { [$style.square]: square }]" + @click="showingFiles.add(file.id)" + > + <MkDriveFileThumbnail + :file="file" + fit="cover" + :highlightWhenSensitive="prefer.s.highlightSensitiveMedia" + :forceBlurhash="true" + :large="true" + :class="$style.file" + /> + <div :class="$style.sensitive"> + <div> + <div v-if="file.isSensitive"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media && file.size ? ` (${bytes(file.size)})` : '' }}</div> + <div v-else><i class="ti ti-photo"></i> {{ prefer.s.dataSaver.media && file.size ? bytes(file.size) : i18n.ts.image }}</div> + <div>{{ i18n.ts.clickToShow }}</div> </div> </div> - <MkA v-else :class="[$style.filePreview, { [$style.square]: square }]" :to="notePage(note)"> - <MkDriveFileThumbnail - :file="file" - fit="cover" - :highlightWhenSensitive="defaultStore.state.highlightSensitiveMedia" - :large="true" - :class="$style.file" - /> - </MkA> - </template> + </div> + <MkA v-else :class="[$style.filePreview, { [$style.square]: square }]" :to="notePage(note)"> + <MkDriveFileThumbnail + :file="file" + fit="cover" + :highlightWhenSensitive="prefer.s.highlightSensitiveMedia" + :large="true" + :class="$style.file" + /> + </MkA> +</template> </template> <script lang="ts" setup> import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; import { notePage } from '@/filters/note.js'; import { i18n } from '@/i18n.js'; -import * as Misskey from 'misskey-js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; import bytes from '@/filters/bytes.js'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index c121758387..3d1884b6e4 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -93,24 +93,22 @@ import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkCwButton from '@/components/MkCwButton.vue'; import { notePage } from '@/filters/note.js'; import * as os from '@/os.js'; -import * as sound from '@/scripts/sound.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import * as sound from '@/utility/sound.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { userPage } from '@/filters/user.js'; -import { checkWordMute } from '@/scripts/check-word-mute.js'; +import { checkWordMute } from '@/utility/check-word-mute.js'; import { defaultStore } from '@/store.js'; import { host } from '@@/js/config.js'; -import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js'; -import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; +import { pleaseLogin, type OpenOnRemoteOptions } from '@/utility/please-login.js'; +import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; -import { reactionPicker } from '@/scripts/reaction-picker.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { getNoteMenu } from '@/scripts/get-note-menu.js'; -import { useNoteCapture } from '@/scripts/use-note-capture.js'; -import { boostMenuItems, type Visibility, computeRenoteTooltip } from '@/scripts/boost-quote.js'; - -const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id); +import { reactionPicker } from '@/utility/reaction-picker.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { getNoteMenu } from '@/utility/get-note-menu.js'; +import { useNoteCapture } from '@/utility/use-note-capture.js'; +import { boostMenuItems, type Visibility, computeRenoteTooltip } from '@/utility/boost-quote.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -124,6 +122,8 @@ const props = withDefaults(defineProps<{ depth: 1, }); +const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id); + const el = shallowRef<HTMLElement>(); const muted = ref($i ? checkWordMute(props.note, $i, $i.mutedWords) : false); const translation = ref<any>(null); diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index bd157d0b14..928a143a72 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.noNotes }}</div> </div> </template> @@ -32,9 +32,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, shallowRef } from 'vue'; +import { defineAsyncComponent, useTemplateRef } from 'vue'; +import type { Paging } from '@/components/MkPagination.vue'; import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; -import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; import { defaultStore } from '@/store.js'; @@ -51,7 +52,7 @@ const props = defineProps<{ disableAutoLoad?: boolean; }>(); -const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); +const pagingComponent = useTemplateRef('pagingComponent'); defineExpose({ pagingComponent, diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index a910151e42..8e86d67ab9 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.root"> <div :class="$style.head"> <MkAvatar v-if="['pollEnded', 'note', 'edited', 'scheduledNotePosted'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/> - <MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'scheduledNoteFailed'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> + <MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'createToken', 'scheduledNoteFailed'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> <div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div> @@ -27,6 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_achievementEarned]: notification.type === 'achievementEarned', [$style.t_exportCompleted]: notification.type === 'exportCompleted', [$style.t_login]: notification.type === 'login', + [$style.t_createToken]: notification.type === 'createToken', [$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null, [$style.t_pollEnded]: notification.type === 'edited', [$style.t_roleAssigned]: notification.type === 'scheduledNoteFailed', @@ -44,6 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i> <i v-else-if="notification.type === 'exportCompleted'" class="ti ti-archive"></i> <i v-else-if="notification.type === 'login'" class="ti ti-login-2"></i> + <i v-else-if="notification.type === 'createToken'" class="ti ti-key"></i> <template v-else-if="notification.type === 'roleAssigned'"> <img v-if="notification.role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="notification.role.iconUrl" alt=""/> <i v-else class="ti ti-badges"></i> @@ -68,6 +70,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span> <span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span> <span v-else-if="notification.type === 'login'">{{ i18n.ts._notification.login }}</span> + <span v-else-if="notification.type === 'createToken'">{{ i18n.ts._notification.createToken }}</span> <span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span> <span v-else-if="notification.type === 'exportCompleted'">{{ i18n.tsx._notification.exportOfXCompleted({ x: exportEntityName[notification.exportedEntity] }) }}</span> <MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA> @@ -117,6 +120,9 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA v-else-if="notification.type === 'exportCompleted'" :class="$style.text" :to="`/my/drive/file/${notification.fileId}`"> {{ i18n.ts.showFile }} </MkA> + <MkA v-else-if="notification.type === 'createToken'" :class="$style.text" to="/settings/apps"> + <Mfm :text="i18n.tsx._notification.createTokenDescription({ text: i18n.ts.manageAccessTokens })"/> + </MkA> <div v-else-if="notification.type === 'scheduledNoteFailed'" :class="$style.text"> {{ notification.reason }} </div> @@ -188,16 +194,16 @@ import * as Misskey from 'misskey-js'; import { UserDetailed } from 'misskey-js/autogen/models.js'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; -import { getNoteSummary } from '@/scripts/get-note-summary.js'; +import { getNoteSummary } from '@/utility/get-note-summary.js'; import { notePage } from '@/filters/note.js'; import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { signinRequired } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { ensureSignin } from '@/i.js'; import { infoImageUrl } from '@/instance.js'; import MkFollowButton from '@/components/MkFollowButton.vue'; -const $i = signinRequired(); +const $i = ensureSignin(); const props = withDefaults(defineProps<{ notification: Misskey.entities.Notification; @@ -408,6 +414,12 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification) pointer-events: none; } +.t_createToken { + padding: 3px; + background: var(--eventOther); + pointer-events: none; +} + .tail { flex: 1; min-width: 0; diff --git a/packages/frontend/src/components/MkNotificationSelectWindow.vue b/packages/frontend/src/components/MkNotificationSelectWindow.vue index d07827d11a..d074dceb2f 100644 --- a/packages/frontend/src/components/MkNotificationSelectWindow.vue +++ b/packages/frontend/src/components/MkNotificationSelectWindow.vue @@ -30,15 +30,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, Ref, shallowRef } from 'vue'; +import { ref, useTemplateRef } from 'vue'; +import { notificationTypes } from '@@/js/const.js'; import MkSwitch from './MkSwitch.vue'; import MkInfo from './MkInfo.vue'; import MkButton from './MkButton.vue'; +import type { Ref } from 'vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; -import { notificationTypes } from '@@/js/const.js'; import { i18n } from '@/i18n.js'; -type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>> +type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>>; const emit = defineEmits<{ (ev: 'done', v: { excludeTypes: string[] }): void, @@ -51,7 +52,7 @@ const props = withDefaults(defineProps<{ excludeTypes: () => [], }); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const typesMap = notificationTypes.reduce((p, t) => ({ ...p, [t]: ref<boolean>(!props.excludeTypes.includes(t)) }), {} as TypesMap); diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index 51c4ea7ce4..4a1377655f 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination ref="pagingComponent" :pagination="pagination"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.noNotifications }}</div> </div> </template> @@ -24,17 +24,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, onUnmounted, onDeactivated, onMounted, computed, shallowRef, onActivated } from 'vue'; +import { defineAsyncComponent, onUnmounted, onDeactivated, onMounted, computed, useTemplateRef, onActivated } from 'vue'; +import * as Misskey from 'misskey-js'; +import type { notificationTypes } from '@@/js/const.js'; import MkPagination from '@/components/MkPagination.vue'; import XNotification from '@/components/MkNotification.vue'; import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; -import { notificationTypes } from '@@/js/const.js'; import { infoImageUrl } from '@/instance.js'; -import { defaultStore } from '@/store.js'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; -import * as Misskey from 'misskey-js'; +import { prefer } from '@/preferences.js'; const MkNote = defineAsyncComponent(() => (defaultStore.state.noteDesign === 'misskey') ? import('@/components/MkNote.vue') : @@ -46,9 +46,9 @@ const props = defineProps<{ excludeTypes?: typeof notificationTypes[number][]; }>(); -const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); +const pagingComponent = useTemplateRef('pagingComponent'); -const pagination = computed(() => defaultStore.reactiveState.useGroupedNotifications.value ? { +const pagination = computed(() => prefer.r.useGroupedNotifications.value ? { endpoint: 'i/notifications-grouped' as const, limit: 20, params: computed(() => ({ @@ -64,7 +64,7 @@ const pagination = computed(() => defaultStore.reactiveState.useGroupedNotificat function onNotification(notification) { const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false; - if (isMuted || document.visibilityState === 'visible') { + if (isMuted || window.document.visibilityState === 'visible') { useStream().send('readNotification'); } diff --git a/packages/frontend/src/components/MkOmit.vue b/packages/frontend/src/components/MkOmit.vue index b978a71c15..7292b28f25 100644 --- a/packages/frontend/src/components/MkOmit.vue +++ b/packages/frontend/src/components/MkOmit.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, shallowRef, ref } from 'vue'; +import { onMounted, onUnmounted, useTemplateRef, ref } from 'vue'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ @@ -22,7 +22,7 @@ const props = withDefaults(defineProps<{ maxHeight: 200, }); -const content = shallowRef<HTMLElement>(); +const content = useTemplateRef('content'); const omitted = ref(false); const ignoreOmit = ref(false); diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 3ff4cc215c..56592dcf5c 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -22,28 +22,30 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </template> - <div ref="contents" :class="$style.root" style="container-type: inline-size;"> - <RouterView :key="reloadCount" :router="windowRouter"/> + <div :class="$style.root"> + <StackingRouterView v-if="prefer.s['experimental.stackingRouterView']" :key="reloadCount" :router="windowRouter"/> + <RouterView v-else :key="reloadCount" :router="windowRouter"/> </div> </MkWindow> </template> <script lang="ts" setup> -import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue'; +import { computed, onMounted, onUnmounted, provide, ref, useTemplateRef } from 'vue'; import { url } from '@@/js/config.js'; -import { getScrollContainer } from '@@/js/scroll.js'; +import type { PageMetadata } from '@/page.js'; import MkUserName from './global/MkUserName.vue'; import RouterView from '@/components/global/RouterView.vue'; import MkWindow from '@/components/MkWindow.vue'; -import { popout as _popout } from '@/scripts/popout.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { useScrollPositionManager } from '@/nirax.js'; +import { popout as _popout } from '@/utility/popout.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; -import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; +import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; import { openingWindowsCount } from '@/os.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { useRouterFactory } from '@/router/supplier.js'; -import { mainRouter } from '@/router/main.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { createRouter, mainRouter } from '@/router.js'; +import { analytics } from '@/analytics.js'; +import { DI } from '@/di.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ initialPath: string; @@ -53,15 +55,12 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const routerFactory = useRouterFactory(); -const windowRouter = routerFactory(props.initialPath); +const windowRouter = createRouter(props.initialPath); -const contents = shallowRef<HTMLElement | null>(null); const pageMetadata = ref<null | PageMetadata>(null); -const windowEl = shallowRef<InstanceType<typeof MkWindow>>(); -const history = ref<{ path: string; key: string; }[]>([{ - path: windowRouter.getCurrentPath(), - key: windowRouter.getCurrentKey(), +const windowEl = useTemplateRef('windowEl'); +const history = ref<{ path: string; }[]>([{ + path: windowRouter.getCurrentFullPath(), }]); const buttonsLeft = computed(() => { const buttons: Record<string, unknown>[] = []; @@ -90,18 +89,36 @@ const buttonsRight = computed(() => { }); const reloadCount = ref(0); +function getSearchMarker(path: string) { + const hash = path.split('#')[1]; + if (hash == null) return null; + return hash; +} + +const searchMarkerId = ref<string | null>(getSearchMarker(props.initialPath)); + windowRouter.addListener('push', ctx => { - history.value.push({ path: ctx.path, key: ctx.key }); + history.value.push({ path: ctx.fullPath }); }); windowRouter.addListener('replace', ctx => { history.value.pop(); - history.value.push({ path: ctx.path, key: ctx.key }); + history.value.push({ path: ctx.fullPath }); +}); + +windowRouter.addListener('change', ctx => { + if (_DEV_) console.log('windowRouter: change', ctx.fullPath); + searchMarkerId.value = getSearchMarker(ctx.fullPath); + analytics.page({ + path: ctx.fullPath, + title: ctx.fullPath, + }); }); windowRouter.init(); -provide('router', windowRouter); +provide(DI.router, windowRouter); +provide('inAppSearchMarkerId', searchMarkerId); provideMetadataReceiver((metadataGetter) => { const info = metadataGetter(); pageMetadata.value = info; @@ -124,20 +141,20 @@ const contextmenu = computed(() => ([{ icon: 'ti ti-external-link', text: i18n.ts.openInNewTab, action: () => { - window.open(url + windowRouter.getCurrentPath(), '_blank', 'noopener'); + window.open(url + windowRouter.getCurrentFullPath(), '_blank', 'noopener'); windowEl.value?.close(); }, }, { icon: 'ti ti-link', text: i18n.ts.copyLink, action: () => { - copyToClipboard(url + windowRouter.getCurrentPath()); + copyToClipboard(url + windowRouter.getCurrentFullPath()); }, }])); function back() { history.value.pop(); - windowRouter.replace(history.value.at(-1)!.path, history.value.at(-1)!.key); + windowRouter.replace(history.value.at(-1)!.path); } function reload() { @@ -149,18 +166,21 @@ function close() { } function expand() { - mainRouter.push(windowRouter.getCurrentPath(), 'forcePage'); + mainRouter.push(windowRouter.getCurrentFullPath(), 'forcePage'); windowEl.value?.close(); } function popout() { - _popout(windowRouter.getCurrentPath(), windowEl.value?.$el); + _popout(windowRouter.getCurrentFullPath(), windowEl.value?.$el); windowEl.value?.close(); } -useScrollPositionManager(() => getScrollContainer(contents.value), windowRouter); - onMounted(() => { + analytics.page({ + path: props.initialPath, + title: props.initialPath, + }); + openingWindowsCount.value++; if (openingWindowsCount.value >= 3) { claimAchievement('open3windows'); @@ -178,9 +198,7 @@ defineExpose({ <style lang="scss" module> .root { - overscroll-behavior: contain; - - min-height: 100%; + height: 100%; background: var(--MI_THEME-bg); --MI-margin: var(--MI-marginHalf); diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index f37cb10f6d..71f7fc513b 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_fade_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''" mode="out-in" > <MkLoading v-if="fetching"/> @@ -18,22 +18,22 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-else-if="empty" key="_empty_" class="empty"> <slot name="empty"> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> </slot> </div> - <div v-else ref="rootEl"> - <div v-show="pagination.reversed && more" key="_more_" class="_margin"> - <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead"> + <div v-else ref="rootEl" class="_gaps"> + <div v-show="pagination.reversed && more" key="_more_"> + <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMoreAhead"> {{ i18n.ts.loadMore }} </MkButton> <MkLoading v-else class="loading"/> </div> <slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot> - <div v-show="!pagination.reversed && more" key="_more_" class="_margin"> - <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore"> + <div v-show="!pagination.reversed && more" key="_more_"> + <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore"> {{ i18n.ts.loadMore }} </MkButton> <MkLoading v-else class="loading"/> @@ -43,15 +43,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts"> -import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch, type Ref } from 'vue'; +import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, useTemplateRef, watch, type Ref } from 'vue'; import * as Misskey from 'misskey-js'; import { useDocumentVisibility } from '@@/js/use-document-visibility.js'; -import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js'; -import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { MisskeyEntity } from '@/types/date-separated-list.js'; +import { onScrollTop, isHeadVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isTailVisible } from '@@/js/scroll.js'; +import type { ComputedRef } from 'vue'; +import type { MisskeyEntity } from '@/types/date-separated-list.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; const SECOND_FETCH_LIMIT = 30; const TOLERANCE = 16; @@ -74,8 +74,6 @@ export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> reversed?: boolean; offsetMode?: boolean | ComputedRef<boolean>; - - pageEl?: HTMLElement; }; type MisskeyEntityMap = Map<string, MisskeyEntity>; @@ -107,7 +105,7 @@ const emit = defineEmits<{ (ev: 'init'): void; }>(); -const rootEl = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); // 遡り中かどうか const backed = ref(false); @@ -140,10 +138,9 @@ const empty = computed(() => items.value.size === 0); const error = ref(false); const { enableInfiniteScroll, -} = defaultStore.reactiveState; +} = prefer.r; -const contentEl = computed(() => props.pagination.pageEl ?? rootEl.value); -const scrollableElement = computed(() => contentEl.value ? getScrollContainer(contentEl.value) : document.body); +const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : window.document.body); const visibility = useDocumentVisibility(); @@ -174,13 +171,13 @@ watch(rootEl, () => { }); }); -watch([backed, contentEl], () => { +watch([backed, rootEl], () => { if (!backed.value) { - if (!contentEl.value) return; + if (!rootEl.value) return; scrollRemove.value = props.pagination.reversed - ? onScrollBottom(contentEl.value, executeQueue, TOLERANCE) - : onScrollTop(contentEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE); + ? onScrollBottom(rootEl.value, executeQueue, TOLERANCE) + : onScrollTop(rootEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE); } else { if (scrollRemove.value) scrollRemove.value(); scrollRemove.value = null; @@ -360,7 +357,7 @@ const appearFetchMoreAhead = async (): Promise<void> => { fetchMoreAppearTimeout(); }; -const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl.value!, TOLERANCE); +const isHead = (): boolean => isBackTop.value || (props.pagination.reversed ? isTailVisible : isHeadVisible)(rootEl.value!, TOLERANCE); watch(visibility, () => { if (visibility.value === 'hidden') { @@ -375,7 +372,7 @@ watch(visibility, () => { timerForSetPause = null; } else { isPausingUpdate = false; - if (isTop()) { + if (isHead()) { executeQueue(); } } @@ -387,16 +384,18 @@ watch(visibility, () => { * ストリーミングから降ってきたアイテムはこれで追加する * @param item アイテム */ -const prepend = (item: MisskeyEntity): void => { +function prepend(item: MisskeyEntity): void { if (items.value.size === 0) { items.value.set(item.id, item); fetching.value = false; return; } - if (isTop() && !isPausingUpdate) unshiftItems([item]); + if (_DEV_) console.log(isHead(), isPausingUpdate); + + if (isHead() && !isPausingUpdate) unshiftItems([item]); else prependQueue(item); -}; +} /** * 新着アイテムをitemsの先頭に追加し、displayLimitを適用する @@ -458,7 +457,7 @@ onDeactivated(() => { }); function toBottom() { - scrollToBottom(contentEl.value!); + scrollToBottom(rootEl.value!); } onBeforeMount(() => { diff --git a/packages/frontend/src/components/MkPasswordDialog.vue b/packages/frontend/src/components/MkPasswordDialog.vue index e749725fea..2abf8669ed 100644 --- a/packages/frontend/src/components/MkPasswordDialog.vue +++ b/packages/frontend/src/components/MkPasswordDialog.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer :marginMin="20" :marginMax="28"> <div style="padding: 0 0 16px 0; text-align: center;"> - <img src="/fluent-emoji/1f510.png" alt="🔐" style="display: block; margin: 0 auto; width: 48px;"> + <img src="/client-assets/locked_with_key_3d.png" alt="🔐" style="display: block; margin: 0 auto; width: 48px;"> <div style="margin-top: 16px;">{{ i18n.ts.authenticationRequiredToContinue }}</div> </div> @@ -39,14 +39,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; +import { onMounted, useTemplateRef, ref } from 'vue'; import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const emit = defineEmits<{ (ev: 'done', v: { password: string; token: string | null; }): void; @@ -54,8 +54,8 @@ const emit = defineEmits<{ (ev: 'cancelled'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); -const passwordInput = shallowRef<InstanceType<typeof MkInput>>(); +const dialog = useTemplateRef('dialog'); +const passwordInput = useTemplateRef('passwordInput'); const password = ref(''); const isBackupCode = ref(false); const token = ref<string | null>(null); diff --git a/packages/frontend/src/components/MkPolkadots.vue b/packages/frontend/src/components/MkPolkadots.vue new file mode 100644 index 0000000000..285c4d0b79 --- /dev/null +++ b/packages/frontend/src/components/MkPolkadots.vue @@ -0,0 +1,40 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[$style.root, accented ? $style.accented : null]"></div> +</template> + +<script lang="ts" setup> +const props = withDefaults(defineProps<{ + accented?: boolean; +}>(), { + accented: false, +}); +</script> + +<style lang="scss" module> +.root { + --c: var(--MI_THEME-divider); + + &.accented { + --c: var(--MI_THEME-accent); + opacity: 0.5; + } + + --dot-size: 2px; + --gap-size: 40px; + --offset: calc(var(--gap-size) / 2); + + height: 200px; + margin-bottom: -200px; + + background-image: linear-gradient(transparent 60%, transparent 100%), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size)), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size)); + background-position: 0 0, 0 0, var(--offset) var(--offset); + background-size: 100% 100%, var(--gap-size) var(--gap-size), var(--gap-size) var(--gap-size); + mask-image: linear-gradient(to bottom, black 0%, transparent 100%); + pointer-events: none; +} +</style> diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index f6218de4c8..ff83520acd 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -35,11 +35,11 @@ import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { host } from '@@/js/config.js'; import { useInterval } from '@@/js/use-interval.js'; -import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; -import { sum } from '@/scripts/array.js'; -import { pleaseLogin } from '@/scripts/please-login.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; +import { sum } from '@/utility/array.js'; +import { pleaseLogin } from '@/utility/please-login.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue index 3726ddf822..22fe189a63 100644 --- a/packages/frontend/src/components/MkPollEditor.vue +++ b/packages/frontend/src/components/MkPollEditor.vue @@ -58,8 +58,8 @@ import MkInput from './MkInput.vue'; import MkSelect from './MkSelect.vue'; import MkSwitch from './MkSwitch.vue'; import MkButton from './MkButton.vue'; -import { formatDateTimeString } from '@/scripts/format-time-string.js'; -import { addTime } from '@/scripts/time.js'; +import { formatDateTimeString } from '@/utility/format-time-string.js'; +import { addTime } from '@/utility/time.js'; import { i18n } from '@/i18n.js'; export type PollEditorModelValue = { diff --git a/packages/frontend/src/components/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue index f1b5ff4de0..be5927c536 100644 --- a/packages/frontend/src/components/MkPopupMenu.vue +++ b/packages/frontend/src/components/MkPopupMenu.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef } from 'vue'; +import { ref, useTemplateRef } from 'vue'; import MkModal from './MkModal.vue'; import MkMenu from './MkMenu.vue'; import type { MenuItem } from '@/types/menu.js'; @@ -28,7 +28,7 @@ const emit = defineEmits<{ (ev: 'closing'): void; }>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); const manualShowing = ref(true); const hiding = ref(false); diff --git a/packages/frontend/src/components/MkPostForm.TextCounter.vue b/packages/frontend/src/components/MkPostForm.TextCounter.vue new file mode 100644 index 0000000000..b1d39df5d3 --- /dev/null +++ b/packages/frontend/src/components/MkPostForm.TextCounter.vue @@ -0,0 +1,95 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[$style.textCountRoot]"> + <div :class="$style.textCountLabel">{{ i18n.ts.textCount }}</div> + <div + :class="[$style.textCount, + { [$style.danger]: textCountPercentage > 100 }, + { [$style.warning]: textCountPercentage > 90 && textCountPercentage <= 100 }, + ]" + > + <div :class="$style.textCountGraph"></div> + <div><span :class="$style.textCountCurrent">{{ number(textLength) }}</span> / {{ number(maxTextLength) }}</div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, useTemplateRef } from 'vue'; +import { instance } from '@/instance.js'; +import { i18n } from '@/i18n.js'; +import number from '@/filters/number.js'; + +const props = defineProps<{ + textLength: number; +}>(); + +const maxTextLength = computed(() => { + return instance ? instance.maxNoteTextLength : 1000; +}); + +const textCountPercentage = computed(() => { + return props.textLength / maxTextLength.value * 100; +}); +</script> + +<style lang="scss" module> +.textCountRoot { + padding: 4px 14px; +} + +.textCountLabel { + font-size: 11px; + opacity: 0.8; + margin-bottom: 4px; +} + +.textCount { + display: flex; + gap: var(--MI-marginHalf); + align-items: center; + font-size: 12px; + --countColor: var(--MI_THEME-accent); + + &.danger { + --countColor: var(--MI_THEME-error); + } + + &.warning { + --countColor: var(--MI_THEME-warn); + } + + .textCountGraph { + position: relative; + width: 24px; + height: 24px; + border-radius: 50%; + background-image: conic-gradient( + var(--countColor) 0% v-bind("Math.min(100, textCountPercentage) + '%'"), + rgba(0, 0, 0, .2) v-bind("Math.min(100, textCountPercentage) + '%'") 100% + ); + + &::after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--MI_THEME-popup); + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + } + + .textCountCurrent { + color: var(--countColor); + font-weight: 700; + font-size: 18px; + } +} +</style> diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index ca227d649a..921829c41e 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="$style.headerRight"> <template v-if="!(channel != null && fixed)"> - <button v-if="channel == null" ref="visibilityButton" v-click-anime v-tooltip="i18n.ts.visibility" :class="['_button', $style.headerRightItem, $style.visibility]" :disabled="editId != null" @click="setVisibility"> + <button v-if="channel == null" ref="visibilityButton" v-tooltip="i18n.ts.visibility" :class="['_button', $style.headerRightItem, $style.visibility]" :disabled="editId != null" @click="setVisibility"> <span v-if="visibility === 'public'"><i class="ti ti-world"></i></span> <span v-if="visibility === 'home'"><i class="ti ti-home"></i></span> <span v-if="visibility === 'followers'"><i class="ti ti-lock"></i></span> @@ -32,11 +32,12 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="$style.headerRightButtonText">{{ channel.name }}</span> </button> </template> - <button v-click-anime v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="channel != null || visibility === 'specified' || editId != null" @click="toggleLocalOnly"> + <button v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="channel != null || visibility === 'specified' || editId != null" @click="toggleLocalOnly"> <span v-if="!localOnly"><i class="ti ti-rocket"></i></span> <span v-else><i class="ti ti-rocket-off"></i></span> </button> - <button v-click-anime v-tooltip="i18n.ts.reactionAcceptance" class="_button" :class="[$style.headerRightItem, { [$style.danger]: reactionAcceptance === 'likeOnly' }]" @click="toggleReactionAcceptance"> + <button ref="otherSettingsButton" v-tooltip="i18n.ts.other" class="_button" :class="$style.headerRightItem" @click="showOtherSettings"><i class="ti ti-dots"></i></button> + <button v-tooltip="i18n.ts.reactionAcceptance" class="_button" :class="[$style.headerRightItem, { [$style.danger]: reactionAcceptance === 'likeOnly' }]" @click="toggleReactionAcceptance"> <span v-if="reactionAcceptance === 'likeOnly'"><i class="ti ti-heart"></i></span> <span v-else-if="reactionAcceptance === 'likeOnlyForRemote'"><i class="ti ti-heart-plus"></i></span> <span v-else><i class="ph-smiley ph-bold ph-lg"></i></span> @@ -65,9 +66,9 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo> - <div v-show="useCw" :class="$style.cwFrame"> + <div v-show="useCw" :class="$style.cwOuter"> <input ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown" @keyup="onKeyup" @compositionend="onCompositionEnd"> - <div v-if="maxCwLength - cwLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: cwLength > maxCwLength }]">{{ maxCwLength - cwLength }}</div> + <div v-if="maxCwTextLength - cwTextLength < 20" :class="['_acrylic', $style.cwTextCount, { [$style.cwTextOver]: cwTextLength > maxCwTextLength }]">{{ maxCwTextLength - cwTextLength }}</div> </div> <div :class="[$style.textOuter, { [$style.withCw]: useCw }]"> <div v-if="channel" :class="$style.colorBar" :style="{ background: channel.color }"></div> @@ -106,41 +107,48 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, toRaw, type ShallowRef } from 'vue'; +import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef, toRaw } from 'vue'; import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; import { toASCII } from 'punycode.js'; import { host, url } from '@@/js/config.js'; +import type { ShallowRef } from 'vue'; import { appendContentWarning } from '@@/js/append-content-warning.js'; import type { MenuItem } from '@/types/menu.js'; import type { PostFormProps } from '@/types/post-form.js'; -import MkNoteSimple from '@/components/MkNoteSimple.vue'; +import type { PollEditorModelValue } from '@/components/MkPollEditor.vue'; import MkNotePreview from '@/components/MkNotePreview.vue'; import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; -import MkPollEditor, { type PollEditorModelValue } from '@/components/MkPollEditor.vue'; -import { erase, unique } from '@/scripts/array.js'; -import { extractMentions } from '@/scripts/extract-mentions.js'; -import { formatTimeString } from '@/scripts/format-time-string.js'; -import { Autocomplete } from '@/scripts/autocomplete.js'; +import XTextCounter from '@/components/MkPostForm.TextCounter.vue'; +import MkPollEditor from '@/components/MkPollEditor.vue'; +import MkNoteSimple from '@/components/MkNoteSimple.vue'; +import { erase, unique } from '@/utility/array.js'; +import { extractMentions } from '@/utility/extract-mentions.js'; +import { formatTimeString } from '@/utility/format-time-string.js'; +import { Autocomplete } from '@/utility/autocomplete.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { selectFiles } from '@/scripts/select-file.js'; -import { defaultStore, notePostInterruptors, postFormActions } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { selectFiles } from '@/utility/select-file.js'; +import { store } from '@/store.js'; import MkInfo from '@/components/MkInfo.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { signinRequired, notesCount, incNotesCount, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account.js'; -import { uploadFile } from '@/scripts/upload.js'; -import { deepClone } from '@/scripts/clone.js'; +import { ensureSignin, notesCount, incNotesCount } from '@/i.js'; +import { getAccounts, openAccountMenu as openAccountMenu_ } from '@/accounts.js'; +import { uploadFile } from '@/utility/upload.js'; +import { deepClone } from '@/utility/clone.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; -import { miLocalStorage } from '@/local-storage.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { emojiPicker } from '@/scripts/emoji-picker.js'; -import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js'; import MkScheduleEditor from '@/components/MkScheduleEditor.vue'; +import { miLocalStorage } from '@/local-storage.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { emojiPicker } from '@/utility/emoji-picker.js'; +import { mfmFunctionPicker } from '@/utility/mfm-function-picker.js'; +import { prefer } from '@/preferences.js'; +import { getPluginHandlers } from '@/plugin.js'; +import { DI } from '@/di.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const modal = inject('modal'); @@ -157,7 +165,7 @@ const props = withDefaults(defineProps<PostFormProps & { initialLocalOnly: undefined, }); -provide('mock', props.mock); +provide(DI.mock, props.mock); const emit = defineEmits<{ (ev: 'posted'): void; @@ -168,10 +176,11 @@ const emit = defineEmits<{ (ev: 'fileChangeSensitive', fileId: string, to: boolean): void; }>(); -const textareaEl = shallowRef<HTMLTextAreaElement | null>(null); -const cwInputEl = shallowRef<HTMLInputElement | null>(null); -const hashtagsInputEl = shallowRef<HTMLInputElement | null>(null); -const visibilityButton = shallowRef<HTMLElement>(); +const textareaEl = useTemplateRef('textareaEl'); +const cwInputEl = useTemplateRef('cwInputEl'); +const hashtagsInputEl = useTemplateRef('hashtagsInputEl'); +const visibilityButton = useTemplateRef('visibilityButton'); +const otherSettingsButton = useTemplateRef('otherSettingsButton'); const posting = ref(false); const posted = ref(false); @@ -179,19 +188,18 @@ const text = ref(props.initialText ?? ''); const files = ref(props.initialFiles ?? []); const poll = ref<PollEditorModelValue | null>(null); const useCw = ref<boolean>(!!props.initialCw); -const showPreview = ref(defaultStore.state.showPreview); -watch(showPreview, () => defaultStore.set('showPreview', showPreview.value)); -const showAddMfmFunction = ref(defaultStore.state.enableQuickAddMfmFunction); -watch(showAddMfmFunction, () => defaultStore.set('enableQuickAddMfmFunction', showAddMfmFunction.value)); +const showPreview = ref(store.s.showPreview); +watch(showPreview, () => store.set('showPreview', showPreview.value)); +const showAddMfmFunction = ref(prefer.s.enableQuickAddMfmFunction); +watch(showAddMfmFunction, () => prefer.commit('enableQuickAddMfmFunction', showAddMfmFunction.value)); const cw = ref<string | null>(props.initialCw ?? null); -const localOnly = ref(props.initialLocalOnly ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly)); -const visibility = ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility)); +const localOnly = ref(props.initialLocalOnly ?? (prefer.s.rememberNoteVisibility ? store.s.localOnly : prefer.s.defaultNoteLocalOnly)); +const visibility = ref(props.initialVisibility ?? (prefer.s.rememberNoteVisibility ? store.s.visibility : prefer.s.defaultNoteVisibility)); const visibleUsers = ref<Misskey.entities.UserDetailed[]>([]); if (props.initialVisibleUsers) { props.initialVisibleUsers.forEach(u => pushVisibleUser(u)); } -const reactionAcceptance = ref(defaultStore.state.reactionAcceptance); -const autocomplete = ref(null); +const reactionAcceptance = ref(store.s.reactionAcceptance); const draghover = ref(false); const quoteId = ref<string | null>(null); const hasNotSpecifiedMentions = ref(false); @@ -204,6 +212,7 @@ const scheduleNote = ref<{ scheduledAt: number | null; } | null>(null); const renoteTargetNote: ShallowRef<PostFormProps['renote'] | null> = shallowRef(props.renote); +const postFormActions = getPluginHandlers('post_form_action'); const draftKey = computed((): string => { let key = props.channel ? `channel:${props.channel.id}` : ''; @@ -255,8 +264,11 @@ const maxTextLength = computed((): number => { return instance ? instance.maxNoteTextLength : 1000; }); -const cwLength = computed(() => cw.value?.length ?? 0); -const maxCwLength = computed(() => instance.maxCwLength); +const cwTextLength = computed((): number => { + return cw.value?.length ?? 0; +}); + +const maxCwTextLength = computed(() => instance.maxCwLength); const canPost = computed((): boolean => { return !props.mock && !posting.value && !posted.value && @@ -268,13 +280,19 @@ const canPost = computed((): boolean => { quoteId.value != null ) && (textLength.value <= maxTextLength.value) && - (cwLength.value <= maxCwLength.value) && + ( + useCw.value ? + ( + cw.value != null && cw.value.trim() !== '' && + cwTextLength.value <= maxCwTextLength.value + ) : true + ) && (files.value.length <= 16) && (!poll.value || poll.value.choices.length >= 2); }); -const withHashtags = computed(defaultStore.makeGetterSetter('postFormWithHashtags')); -const hashtags = computed(defaultStore.makeGetterSetter('postFormHashtags')); +const withHashtags = computed(store.makeGetterSetter('postFormWithHashtags')); +const hashtags = computed(store.makeGetterSetter('postFormHashtags')); watch(text, () => { checkMissingMention(); @@ -362,7 +380,7 @@ if (props.specified) { } // keep cw when reply -if (defaultStore.state.keepCw && props.reply && props.reply.cw) { +if (prefer.s.keepCw && props.reply && props.reply.cw) { useCw.value = true; cw.value = props.reply.cw; } @@ -484,7 +502,7 @@ function replaceFile(file: Misskey.entities.DriveFile, newFile: Misskey.entities function upload(file: File, name?: string): void { if (props.mock) return; - uploadFile(file, defaultStore.state.uploadFolder, name).then(res => { + uploadFile(file, prefer.s.uploadFolder, name).then(res => { files.value.push(res); }); } @@ -505,8 +523,8 @@ function setVisibility() { }, { changeVisibility: v => { visibility.value = v; - if (defaultStore.state.rememberNoteVisibility) { - defaultStore.set('visibility', visibility.value); + if (prefer.s.rememberNoteVisibility) { + store.set('visibility', visibility.value); } }, closed: () => dispose(), @@ -553,8 +571,8 @@ async function toggleLocalOnly() { } localOnly.value = !localOnly.value; - if (defaultStore.state.rememberNoteVisibility) { - defaultStore.set('localOnly', localOnly.value); + if (prefer.s.rememberNoteVisibility) { + store.set('localOnly', localOnly.value); } } @@ -574,6 +592,47 @@ async function toggleReactionAcceptance() { reactionAcceptance.value = select.result; } +//#region その他の設定メニューpopup +function showOtherSettings() { + let reactionAcceptanceIcon = 'ti ti-icons'; + + if (reactionAcceptance.value === 'likeOnly') { + reactionAcceptanceIcon = 'ti ti-heart _love'; + } else if (reactionAcceptance.value === 'likeOnlyForRemote') { + reactionAcceptanceIcon = 'ti ti-heart-plus'; + } + + const menuItems = [{ + type: 'component', + component: XTextCounter, + props: { + textLength: textLength, + }, + }, { type: 'divider' }, { + icon: reactionAcceptanceIcon, + text: i18n.ts.reactionAcceptance, + action: () => { + toggleReactionAcceptance(); + }, + }, { type: 'divider' }, { + icon: 'ti ti-trash', + text: i18n.ts.reset, + danger: true, + action: async () => { + if (props.mock) return; + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.resetAreYouSure, + }); + if (canceled) return; + clear(); + }, + }] satisfies MenuItem[]; + + os.popupMenu(menuItems, otherSettingsButton.value); +} +//#endregion + function pushVisibleUser(user: Misskey.entities.UserDetailed) { if (!visibleUsers.value.some(u => u.username === user.username && u.host === user.host)) { visibleUsers.value.push(user); @@ -623,6 +682,8 @@ function onCompositionEnd(ev: CompositionEvent) { justEndedComposition.value = true; } +const pastedFileName = 'yyyy-MM-dd HH-mm-ss [{{number}}]'; + async function onPaste(ev: ClipboardEvent) { if (props.mock) return; if (!ev.clipboardData) return; @@ -633,7 +694,7 @@ async function onPaste(ev: ClipboardEvent) { if (!file) continue; const lio = file.name.lastIndexOf('.'); const ext = lio >= 0 ? file.name.slice(lio) : ''; - const formatted = `${formatTimeString(new Date(file.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; + const formatted = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; upload(file, formatted); } } @@ -678,7 +739,7 @@ async function onPaste(ev: ClipboardEvent) { return; } - const fileName = formatTimeString(new Date(), defaultStore.state.pastedFileName).replace(/{{number}}/g, '0'); + const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0'); const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' }); upload(file, `${fileName}.txt`); }); @@ -772,19 +833,19 @@ function deleteDraft() { miLocalStorage.setItem('drafts', JSON.stringify(draftData)); } -async function post(ev?: MouseEvent) { - if (useCw.value && (cw.value == null || cw.value.trim() === '')) { - os.alert({ - type: 'error', - text: i18n.ts.cwNotationRequired, - }); - return; - } +function isAnnoying(text: string): boolean { + return text.includes('$[x2') || + text.includes('$[x3') || + text.includes('$[x4') || + text.includes('$[scale') || + text.includes('$[position'); +} +async function post(ev?: MouseEvent) { if (ev) { const el = (ev.currentTarget ?? ev.target) as HTMLElement | null; - if (el) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -796,14 +857,10 @@ async function post(ev?: MouseEvent) { if (props.mock) return; - const annoying = - text.value.includes('$[x2') || - text.value.includes('$[x3') || - text.value.includes('$[x4') || - text.value.includes('$[scale') || - text.value.includes('$[position'); - - if (annoying && visibility.value === 'public') { + if (visibility.value === 'public' && ( + (useCw.value && cw.value != null && cw.value.trim() !== '' && isAnnoying(cw.value)) || // CWが迷惑になる場合 + ((!useCw.value || cw.value == null || cw.value.trim() === '') && text.value != null && text.value.trim() !== '' && isAnnoying(text.value)) // CWが無い かつ 本文が迷惑になる場合 + )) { const { canceled, result } = await os.actions({ type: 'warning', text: i18n.ts.thisPostMayBeAnnoying, @@ -884,6 +941,7 @@ async function post(ev?: MouseEvent) { } // plugin + const notePostInterruptors = getPluginHandlers('note_post_interruptor'); if (notePostInterruptors.length > 0) { for (const interruptor of notePostInterruptors) { try { @@ -898,7 +956,7 @@ async function post(ev?: MouseEvent) { if (postAccount.value) { const storedAccounts = await getAccounts(); - token = storedAccounts.find(x => x.id === postAccount.value?.id)?.token; + token = storedAccounts.find(x => x.user.id === postAccount.value?.id)?.token; } posting.value = true; @@ -1182,6 +1240,8 @@ defineExpose({ &.modal { width: 100%; max-width: 520px; + overflow-x: clip; + overflow-y: auto; } } @@ -1292,7 +1352,7 @@ defineExpose({ border-radius: var(--MI-radius-sm); &:hover { - background: var(--MI_THEME-X5); + background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); } &:disabled { @@ -1356,7 +1416,7 @@ defineExpose({ margin-right: 14px; padding: 8px 0 8px 8px; border-radius: var(--MI-radius-sm); - background: var(--MI_THEME-X4); + background: light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1)); } .hasNotSpecifiedMentions { @@ -1387,7 +1447,12 @@ defineExpose({ } } -.cwFrame { +.cwOuter { + width: 100%; + position: relative; +} + +.cw { z-index: 1; padding-bottom: 8px; border-bottom: solid 0.5px var(--MI_THEME-divider); @@ -1396,6 +1461,23 @@ defineExpose({ position: relative; } +.cwTextCount { + position: absolute; + top: 0; + right: 2px; + padding: 2px 6px; + font-size: .9em; + color: var(--MI_THEME-warn); + border-radius: 6px; + max-width: 100%; + min-width: 1.6em; + text-align: center; + + &.cwTextOver { + color: #ff2a2a; + } +} + .hashtags { z-index: 1; padding-top: 8px; @@ -1470,7 +1552,7 @@ defineExpose({ border-radius: var(--MI-radius-sm); &:hover { - background: var(--MI_THEME-X5); + background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); } &.footerButtonActive { diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index bab7d22112..43348475e9 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -22,20 +22,27 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> </Sortable> - <p :class="[$style.remain, { - [$style.exceeded]: props.modelValue.length > 16, - }]">{{ 16 - props.modelValue.length }}/16</p> + <p + :class="[$style.remain, { + [$style.exceeded]: props.modelValue.length > 16, + }]" + > + {{ props.modelValue.length }}/16 + </p> </div> </template> <script lang="ts" setup> import { defineAsyncComponent, inject } from 'vue'; import * as Misskey from 'misskey-js'; +import type { MenuItem } from '@/types/menu'; +import { copyToClipboard } from '@/utility/copy-to-clipboard'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import type { MenuItem } from '@/types/menu.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -44,7 +51,7 @@ const props = defineProps<{ detachMediaFn?: (id: string) => void; }>(); -const mock = inject<boolean>('mock', false); +const mock = inject(DI.mock, false); const emit = defineEmits<{ (ev: 'update:modelValue', value: Misskey.entities.DriveFile[]): void; @@ -168,6 +175,14 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar text: i18n.ts.cropImage, icon: 'ti ti-crop', action: () : void => { crop(file); }, + }, { + text: i18n.ts.preview, + icon: 'ti ti-photo-search', + action: () => { + os.popup(defineAsyncComponent(() => import('@/components/MkImgPreviewDialog.vue')), { + file: file, + }); + }, }); } @@ -184,6 +199,16 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar action: () => { detachAndDeleteMedia(file); }, }); + if (prefer.s.devMode) { + menuItems.push({ type: 'divider' }, { + icon: 'ti ti-hash', + text: i18n.ts.copyFileId, + action: () => { + copyToClipboard(file.id); + }, + }); + } + os.popupMenu(menuItems, ev.currentTarget ?? ev.target).then(() => menuShowing = false); menuShowing = true; } diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index 0fd17e12c7..aa3eebb257 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -4,17 +4,32 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :preferType="'dialog'" @click="modal?.close()" @closed="onModalClosed()" @esc="modal?.close()"> - <MkPostForm ref="form" :class="$style.form" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="onCancel" @esc="onCancel"/> +<MkModal + ref="modal" + :preferType="'dialog'" + @click="modal?.close()" + @closed="onModalClosed()" + @esc="modal?.close()" +> + <MkPostForm + ref="form" + :class="$style.form" + v-bind="props" + autofocus + freezeAfterPosted + @posted="onPosted" + @cancel="onCancel" + @esc="onCancel" + /> </MkModal> </template> <script lang="ts" setup> -import { shallowRef } from 'vue'; +import { useTemplateRef } from 'vue'; +import type { PostFormProps } from '@/types/post-form.js'; import MkModal from '@/components/MkModal.vue'; import MkPostForm from '@/components/MkPostForm.vue'; import * as Misskey from 'misskey-js'; -import type { PostFormProps } from '@/types/post-form.js'; const props = withDefaults(defineProps<PostFormProps & { instant?: boolean; @@ -29,8 +44,7 @@ const emit = defineEmits<{ (ev: 'closed', cancelled: boolean): void; }>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); -const form = shallowRef<InstanceType<typeof MkPostForm>>(); +const modal = useTemplateRef('modal'); function onPosted() { modal.value?.close({ diff --git a/packages/frontend/src/components/MkPreferenceContainer.vue b/packages/frontend/src/components/MkPreferenceContainer.vue new file mode 100644 index 0000000000..70b111513c --- /dev/null +++ b/packages/frontend/src/components/MkPreferenceContainer.vue @@ -0,0 +1,103 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root" @contextmenu.prevent.stop="showMenu($event, true)"> + <div :class="$style.body"> + <slot></slot> + </div> + <div :class="$style.menu"> + <i v-if="isSyncEnabled" class="ti ti-cloud-cog" style="color: var(--MI_THEME-accent); opacity: 0.7;"></i> + <i v-if="isAccountOverrided" class="ti ti-user-cog" style="color: var(--MI_THEME-accent); opacity: 0.7;"></i> + <div :class="$style.buttons"> + <button class="_button" style="color: var(--MI_THEME-fg)" @click="showMenu($event)"><i class="ti ti-dots"></i></button> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import type { PREF_DEF } from '@/preferences/def.js'; +import * as os from '@/os.js'; +import { prefer } from '@/preferences.js'; + +const props = withDefaults(defineProps<{ + k: keyof typeof PREF_DEF; +}>(), { +}); + +const isAccountOverrided = ref(prefer.isAccountOverrided(props.k)); +const isSyncEnabled = ref(prefer.isSyncEnabled(props.k)); + +function showMenu(ev: MouseEvent, contextmenu?: boolean) { + const i = window.setInterval(() => { + isAccountOverrided.value = prefer.isAccountOverrided(props.k); + isSyncEnabled.value = prefer.isSyncEnabled(props.k); + }, 100); + if (contextmenu) { + os.contextMenu(prefer.getPerPrefMenu(props.k), ev).then(() => { + window.clearInterval(i); + }); + } else { + os.popupMenu(prefer.getPerPrefMenu(props.k), ev.currentTarget ?? ev.target, { + onClosing: () => { + window.clearInterval(i); + }, + }); + } +} +</script> + +<style lang="scss" module> +.root { + position: relative; + display: flex; + + &:hover { + &::before { + content: ''; + position: absolute; + top: -8px; + left: -8px; + width: calc(100% + 16px); + height: calc(100% + 16px); + border-radius: 8px; + background: light-dark(rgba(0, 0, 0, 0.02), rgba(255, 255, 255, 0.02)); + pointer-events: none; + } + + .menu { + .buttons { + opacity: 0.7; + } + } + } + + .body { + flex: 1; + } + + .menu { + display: flex; + gap: 8px; + align-items: center; + margin-left: 12px; + font-size: 12px; + padding-left: 8px; + border-left: solid 1px var(--MI_THEME-divider); + + &:hover { + .buttons { + opacity: 1; + } + } + + .buttons { + opacity: 0.3; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkPreview.vue b/packages/frontend/src/components/MkPreview.vue index 6efd99d14b..d8dfbd1655 100644 --- a/packages/frontend/src/components/MkPreview.vue +++ b/packages/frontend/src/components/MkPreview.vue @@ -43,7 +43,7 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkRadio from '@/components/MkRadio.vue'; import * as os from '@/os.js'; import * as config from '@@/js/config.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; const text = ref(''); const flag = ref(true); diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue index 4fb4c6fe56..1fbf00d212 100644 --- a/packages/frontend/src/components/MkPullToRefresh.vue +++ b/packages/frontend/src/components/MkPullToRefresh.vue @@ -23,10 +23,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, ref, shallowRef } from 'vue'; -import { i18n } from '@/i18n.js'; +import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; import { getScrollContainer } from '@@/js/scroll.js'; -import { isHorizontalSwipeSwiping } from '@/scripts/touch.js'; +import { i18n } from '@/i18n.js'; +import { isHorizontalSwipeSwiping } from '@/utility/touch.js'; const SCROLL_STOP = 10; const MAX_PULL_DISTANCE = Infinity; @@ -43,7 +43,7 @@ const pullDistance = ref(0); let supportPointerDesktop = false; let startScreenY: number | null = null; -const rootEl = shallowRef<HTMLDivElement>(); +const rootEl = useTemplateRef('rootEl'); let scrollEl: HTMLElement | null = null; let disabled = false; diff --git a/packages/frontend/src/components/MkPushNotificationAllowButton.vue b/packages/frontend/src/components/MkPushNotificationAllowButton.vue index 5e42df4795..9c37eb5e72 100644 --- a/packages/frontend/src/components/MkPushNotificationAllowButton.vue +++ b/packages/frontend/src/components/MkPushNotificationAllowButton.vue @@ -42,12 +42,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import { ref } from 'vue'; -import { $i, getAccounts } from '@/account.js'; +import { $i } from '@/i.js'; import MkButton from '@/components/MkButton.vue'; import { instance } from '@/instance.js'; import { apiWithDialog, promiseDialog } from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { getAccounts } from '@/accounts.js'; defineProps<{ primary?: boolean; diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue index af81eb814d..559399d1d4 100644 --- a/packages/frontend/src/components/MkRadios.vue +++ b/packages/frontend/src/components/MkRadios.vue @@ -4,7 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> <script lang="ts"> -import { VNode, defineComponent, h, ref, watch } from 'vue'; +import { defineComponent, h, ref, watch } from 'vue'; +import type { VNode } from 'vue'; import MkRadio from './MkRadio.vue'; export default defineComponent({ diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index d009f3858c..81cbd6b842 100644 --- a/packages/frontend/src/components/MkRange.vue +++ b/packages/frontend/src/components/MkRange.vue @@ -33,8 +33,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'; -import { isTouchUsing } from '@/scripts/touch.js'; +import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'; +import { isTouchUsing } from '@/utility/touch.js'; import * as os from '@/os.js'; const props = withDefaults(defineProps<{ @@ -58,8 +58,8 @@ const emit = defineEmits<{ (ev: 'dragEnded', value: number): void; }>(); -const containerEl = shallowRef<HTMLElement>(); -const thumbEl = shallowRef<HTMLElement>(); +const containerEl = useTemplateRef('containerEl'); +const thumbEl = useTemplateRef('thumbEl'); const rawValue = ref((props.modelValue - props.min) / (props.max - props.min)); const steppedRawValue = computed(() => { @@ -151,9 +151,9 @@ function onMousedown(ev: MouseEvent | TouchEvent) { closed: () => dispose(), }); - const style = document.createElement('style'); - style.appendChild(document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }')); - document.head.appendChild(style); + const style = window.document.createElement('style'); + style.appendChild(window.document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }')); + window.document.head.appendChild(style); const thumbWidth = getThumbWidth(); @@ -172,7 +172,7 @@ function onMousedown(ev: MouseEvent | TouchEvent) { let beforeValue = finalValue.value; const onMouseup = () => { - document.head.removeChild(style); + window.document.head.removeChild(style); tooltipForDragShowing.value = false; window.removeEventListener('mousemove', onDrag); window.removeEventListener('touchmove', onDrag); diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue index c0cbd8a65d..453253f0fc 100644 --- a/packages/frontend/src/components/MkReactionIcon.vue +++ b/packages/frontend/src/components/MkReactionIcon.vue @@ -9,8 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, shallowRef } from 'vue'; -import { useTooltip } from '@/scripts/use-tooltip.js'; +import { defineAsyncComponent, useTemplateRef } from 'vue'; +import { useTooltip } from '@/use/use-tooltip.js'; import * as os from '@/os.js'; const props = defineProps<{ @@ -20,7 +20,7 @@ const props = defineProps<{ withTooltip?: boolean; }>(); -const elRef = shallowRef(); +const elRef = useTemplateRef('elRef'); if (props.withTooltip) { useTooltip(elRef, (showing) => { diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 9cd972639f..e66a056a3f 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -8,33 +8,34 @@ SPDX-License-Identifier: AGPL-3.0-only ref="buttonEl" v-ripple="canToggle" class="_button" - :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: defaultStore.state.reactionsDisplaySize === 'small', [$style.large]: defaultStore.state.reactionsDisplaySize === 'large' }]" + :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: prefer.s.reactionsDisplaySize === 'small', [$style.large]: prefer.s.reactionsDisplaySize === 'large' }]" @click="toggleReaction()" @contextmenu.prevent.stop="menu" > - <MkReactionIcon :class="defaultStore.state.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]" @click="toggleReaction()" @click.stop/> + <MkReactionIcon :class="prefer.s.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]" @click="toggleReaction()" @click.stop/> <span :class="$style.count">{{ count }}</span> </button> </template> <script lang="ts" setup> -import { computed, inject, onMounted, shallowRef, watch } from 'vue'; +import { computed, inject, onMounted, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { getUnicodeEmoji } from '@@/js/emojilist.js'; import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue'; import XDetails from '@/components/MkReactionsViewer.details.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import * as os from '@/os.js'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; -import { $i } from '@/account.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; +import { useTooltip } from '@/use/use-tooltip.js'; +import { $i } from '@/i.js'; import MkReactionEffect from '@/components/MkReactionEffect.vue'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { defaultStore } from '@/store.js'; +import { claimAchievement } from '@/utility/achievements.js'; import { i18n } from '@/i18n.js'; -import * as sound from '@/scripts/sound.js'; -import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js'; +import * as sound from '@/utility/sound.js'; +import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js'; import { customEmojisMap } from '@/custom-emojis.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; const props = defineProps<{ reaction: string; @@ -43,13 +44,13 @@ const props = defineProps<{ note: Misskey.entities.Note; }>(); -const mock = inject<boolean>('mock', false); +const mock = inject(DI.mock, false); const emit = defineEmits<{ (ev: 'reactionToggled', emoji: string, newCount: number): void; }>(); -const buttonEl = shallowRef<HTMLElement>(); +const buttonEl = useTemplateRef('buttonEl'); const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, '')); const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction)); @@ -90,6 +91,15 @@ async function toggleReaction() { } }); } else { + if (prefer.s.confirmOnReact) { + const confirm = await os.confirm({ + type: 'question', + text: i18n.tsx.reactAreYouSure({ emoji: props.reaction.replace('@.', '') }), + }); + + if (confirm.canceled) return; + } + sound.playMisskeySfx('reaction'); if (mock) { @@ -126,7 +136,7 @@ async function menu(ev) { } function anime() { - if (document.hidden || !defaultStore.state.animation || buttonEl.value == null) return; + if (window.document.hidden || !prefer.s.animation || buttonEl.value == null) return; const rect = buttonEl.value.getBoundingClientRect(); const x = rect.left + 16; diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index a70ed18d18..cf0bb7b1f2 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -5,11 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <TransitionGroup - :enterActiveClass="defaultStore.state.animation ? $style.transition_x_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_x_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_x_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_x_leaveTo : ''" - :moveClass="defaultStore.state.animation ? $style.transition_x_move : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''" + :moveClass="prefer.s.animation ? $style.transition_x_move : ''" tag="div" :class="$style.root" > <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/> @@ -21,7 +21,8 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { inject, watch, ref } from 'vue'; import XReaction from '@/components/MkReactionsViewer.reaction.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -30,7 +31,7 @@ const props = withDefaults(defineProps<{ maxNumber: Infinity, }); -const mock = inject<boolean>('mock', false); +const mock = inject(DI.mock, false); const emit = defineEmits<{ (ev: 'mockUpdateMyReaction', emoji: string, delta: number): void; diff --git a/packages/frontend/src/components/MkRemoteCaution.vue b/packages/frontend/src/components/MkRemoteCaution.vue index 6391468204..0d1a2f0b76 100644 --- a/packages/frontend/src/components/MkRemoteCaution.vue +++ b/packages/frontend/src/components/MkRemoteCaution.vue @@ -4,14 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i>{{ i18n.ts.remoteUserCaution }}<a :class="$style.link" :href="href" rel="nofollow noopener" target="_blank">{{ i18n.ts.showOnRemote }}</a></div> +<div :class="$style.root"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i>{{ i18n.ts.remoteUserCaution }}<a v-if="href" :class="$style.link" :href="href" rel="nofollow noopener" target="_blank">{{ i18n.ts.showOnRemote }}</a></div> </template> <script lang="ts" setup> import { i18n } from '@/i18n.js'; defineProps<{ - href: string; + href?: string; }>(); </script> diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue index 64b573c4d3..1ab2397337 100644 --- a/packages/frontend/src/components/MkRetentionHeatmap.vue +++ b/packages/frontend/src/components/MkRetentionHeatmap.vue @@ -13,18 +13,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, nextTick, shallowRef, ref } from 'vue'; +import { onMounted, nextTick, useTemplateRef, ref } from 'vue'; import { Chart } from 'chart.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { alpha } from '@/scripts/color.js'; -import { initChart } from '@/scripts/init-chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { alpha } from '@/utility/color.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); -const rootEl = shallowRef<HTMLDivElement | null>(null); -const chartEl = shallowRef<HTMLCanvasElement | null>(null); +const rootEl = useTemplateRef('rootEl'); +const chartEl = useTemplateRef('chartEl'); let chartInstance: Chart | null = null; const fetching = ref(true); @@ -75,7 +75,7 @@ async function renderChart() { await nextTick(); - const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300'; + const color = store.s.darkMode ? '#b4e900' : '#86b300'; const getYYYYMMDD = (date: Date) => { const y = date.getFullYear().toString().padStart(2, '0'); diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue index d41793b0fa..ba66ffecc0 100644 --- a/packages/frontend/src/components/MkRetentionLineChart.vue +++ b/packages/frontend/src/components/MkRetentionLineChart.vue @@ -8,19 +8,19 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef } from 'vue'; +import { onMounted, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; import tinycolor from 'tinycolor2'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { alpha } from '@/scripts/color.js'; -import { initChart } from '@/scripts/init-chart.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { alpha } from '@/utility/color.js'; +import { initChart } from '@/utility/init-chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; initChart(); -const chartEl = shallowRef<HTMLCanvasElement | null>(null); +const chartEl = useTemplateRef('chartEl'); const { handler: externalTooltipHandler } = useChartTooltip(); @@ -42,9 +42,9 @@ const getDate = (ymd: string) => { onMounted(async () => { let raw = await misskeyApi('retention', { }); - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; - const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-accent')); + const accent = tinycolor(getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-accent')); const color = accent.toHex(); if (chartEl.value == null) return; diff --git a/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts b/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts index 411d62edf9..b090f0a0fa 100644 --- a/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { http, HttpResponse } from 'msw'; import { role } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue index 8d11bd855f..5f77dc6734 100644 --- a/packages/frontend/src/components/MkRoleSelectDialog.vue +++ b/packages/frontend/src/components/MkRoleSelectDialog.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only :width="400" :height="500" @close="onCloseModalWindow" - @closed="$emit('dispose')" + @closed="emit('closed')" > <template #header>{{ title }}</template> <MkSpacer :marginMin="20" :marginMax="28"> @@ -49,7 +49,7 @@ import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkRolePreview from '@/components/MkRolePreview.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import * as os from '@/os.js'; import MkSpacer from '@/components/global/MkSpacer.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; @@ -58,7 +58,7 @@ import MkLoading from '@/components/global/MkLoading.vue'; const emit = defineEmits<{ (ev: 'done', value: Misskey.entities.Role[]), (ev: 'close'), - (ev: 'dispose'), + (ev: 'closed'), }>(); const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index 79a56b68a8..361d80c68b 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -40,11 +40,29 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots, VNodeChild } from 'vue'; +import { onMounted, nextTick, ref, watch, computed, toRefs, useSlots } from 'vue'; import { useInterval } from '@@/js/use-interval.js'; +import type { VNode, VNodeChild } from 'vue'; import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; +type ItemOption = { + type?: 'option'; + value: string | number | null; + label: string; +}; + +type ItemGroup = { + type: 'group'; + label: string; + items: ItemOption[]; +}; + +export type MkSelectItem = ItemOption | ItemGroup; + +// TODO: itemsをslot内のoptionで指定する用法は廃止する(props.itemsを必須化する) +// see: https://github.com/misskey-dev/misskey/issues/15558 + const props = defineProps<{ modelValue: string | number | null; required?: boolean; @@ -55,6 +73,7 @@ const props = defineProps<{ inline?: boolean; small?: boolean; large?: boolean; + items?: MkSelectItem[]; }>(); const emit = defineEmits<{ @@ -106,7 +125,30 @@ onMounted(() => { }); }); -watch(modelValue, () => { +watch([modelValue, () => props.items], () => { + if (props.items) { + let found: ItemOption | null = null; + for (const item of props.items) { + if (item.type === 'group') { + for (const option of item.items) { + if (option.value === modelValue.value) { + found = option; + break; + } + } + } else { + if (item.value === modelValue.value) { + found = item; + break; + } + } + } + if (found) { + currentValueText.value = found.label; + } + return; + } + const scanOptions = (options: VNodeChild[]) => { for (const vnode of options) { if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue; @@ -129,7 +171,7 @@ watch(modelValue, () => { }; scanOptions(slots.default!()); -}, { immediate: true }); +}, { immediate: true, deep: true }); function show() { if (opening.value) return; @@ -138,41 +180,70 @@ function show() { opening.value = true; const menu: MenuItem[] = []; - let options = slots.default!(); - const pushOption = (option: VNode) => { - menu.push({ - text: option.children as string, - active: computed(() => modelValue.value === option.props?.value), - action: () => { - emit('update:modelValue', option.props?.value); - }, - }); - }; - - const scanOptions = (options: VNodeChild[]) => { - for (const vnode of options) { - if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue; - if (vnode.type === 'optgroup') { - const optgroup = vnode; + if (props.items) { + for (const item of props.items) { + if (item.type === 'group') { menu.push({ type: 'label', - text: optgroup.props?.label, + text: item.label, }); - if (Array.isArray(optgroup.children)) scanOptions(optgroup.children); - } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある - const fragment = vnode; - if (Array.isArray(fragment.children)) scanOptions(fragment.children); - } else if (vnode.props == null) { // v-if で条件が false のときにこうなる - // nop? + for (const option of item.items) { + menu.push({ + text: option.label, + active: computed(() => modelValue.value === option.value), + action: () => { + emit('update:modelValue', option.value); + }, + }); + } } else { - const option = vnode; - pushOption(option); + menu.push({ + text: item.label, + active: computed(() => modelValue.value === item.value), + action: () => { + emit('update:modelValue', item.value); + }, + }); } } - }; + } else { + let options = slots.default!(); + + const pushOption = (option: VNode) => { + menu.push({ + text: option.children as string, + active: computed(() => modelValue.value === option.props?.value), + action: () => { + emit('update:modelValue', option.props?.value); + }, + }); + }; - scanOptions(options); + const scanOptions = (options: VNodeChild[]) => { + for (const vnode of options) { + if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue; + if (vnode.type === 'optgroup') { + const optgroup = vnode; + menu.push({ + type: 'label', + text: optgroup.props?.label, + }); + if (Array.isArray(optgroup.children)) scanOptions(optgroup.children); + } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある + const fragment = vnode; + if (Array.isArray(fragment.children)) scanOptions(fragment.children); + } else if (vnode.props == null) { // v-if で条件が false のときにこうなる + // nop? + } else { + const option = vnode; + pushOption(option); + } + } + }; + + scanOptions(options); + } os.popupMenu(menu, container.value, { width: container.value?.offsetWidth, diff --git a/packages/frontend/src/components/MkSignin.input.vue b/packages/frontend/src/components/MkSignin.input.vue index e98ac9cfd2..aacd1eae2a 100644 --- a/packages/frontend/src/components/MkSignin.input.vue +++ b/packages/frontend/src/components/MkSignin.input.vue @@ -58,7 +58,7 @@ import { toUnicode } from 'punycode.js'; import { query, extractDomain } from '@@/js/url.js'; import { host as configHost } from '@@/js/config.js'; -import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index d6177762d2..3037a41657 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -67,20 +67,20 @@ SPDX-License-Identifier: AGPL-3.0-only import { nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import { supported as webAuthnSupported, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; - import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill'; -import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; -import { login } from '@/account.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; +import type { PwResponse } from '@/components/MkSignin.password.vue'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { showSuspendedDialog } from '@/utility/show-suspended-dialog.js'; import { i18n } from '@/i18n.js'; import { showSystemAccountDialog } from '@/scripts/show-system-account-dialog.js'; import * as os from '@/os.js'; import XInput from '@/components/MkSignin.input.vue'; -import XPassword, { type PwResponse } from '@/components/MkSignin.password.vue'; +import XPassword from '@/components/MkSignin.password.vue'; import XTotp from '@/components/MkSignin.totp.vue'; import XPasskey from '@/components/MkSignin.passkey.vue'; +import { login } from '@/accounts.js'; const emit = defineEmits<{ (ev: 'login', v: Misskey.entities.SigninFlowResponse & { finished: true }): void; diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue index 676a336ec7..f122da7468 100644 --- a/packages/frontend/src/components/MkSigninDialog.vue +++ b/packages/frontend/src/components/MkSigninDialog.vue @@ -24,8 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { shallowRef } from 'vue'; -import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; +import { useTemplateRef } from 'vue'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import MkSignin from '@/components/MkSignin.vue'; import MkModal from '@/components/MkModal.vue'; import { i18n } from '@/i18n.js'; @@ -46,7 +46,7 @@ const emit = defineEmits<{ (ev: 'cancelled'): void; }>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); function onClose() { emit('cancelled'); diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue index dd263ce642..b152ba81a6 100644 --- a/packages/frontend/src/components/MkSignupDialog.form.vue +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -90,12 +90,13 @@ import * as Misskey from 'misskey-js'; import * as config from '@@/js/config.js'; import MkButton from './MkButton.vue'; import MkInput from './MkInput.vue'; -import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue'; +import type { Captcha } from '@/components/MkCaptcha.vue'; +import MkCaptcha from '@/components/MkCaptcha.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { login } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; +import { login } from '@/accounts.js'; const props = withDefaults(defineProps<{ autoSet?: boolean; @@ -278,7 +279,7 @@ async function onSubmit(): Promise<void> { 'testcaptcha-response': testcaptchaResponse.value, }; - const res = await fetch(`${config.apiUrl}/signup`, { + const res = await window.fetch(`${config.apiUrl}/signup`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts index 9df3ec0c30..8d99bc44b7 100644 --- a/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts +++ b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect, userEvent, waitFor, within } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { onBeforeUnmount } from 'vue'; import MkSignupServerRules from './MkSignupDialog.rules.vue'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue index 291c3ecc2f..bf1b5fcf3e 100644 --- a/packages/frontend/src/components/MkSignupDialog.vue +++ b/packages/frontend/src/components/MkSignupDialog.vue @@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef, ref } from 'vue'; +import { useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import XSignup from '@/components/MkSignupDialog.form.vue'; import XServerRules from '@/components/MkSignupDialog.rules.vue'; @@ -52,7 +52,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const isAcceptedServerRule = ref(false); diff --git a/packages/frontend/src/components/MkSortOrderEditor.define.ts b/packages/frontend/src/components/MkSortOrderEditor.define.ts index f023b5d72b..e56b93f98a 100644 --- a/packages/frontend/src/components/MkSortOrderEditor.define.ts +++ b/packages/frontend/src/components/MkSortOrderEditor.define.ts @@ -3,9 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export type SortOrderDirection = '+' | '-' +export type SortOrderDirection = '+' | '-'; export type SortOrder<T extends string> = { key: T; direction: SortOrderDirection; -} +}; diff --git a/packages/frontend/src/components/MkSortOrderEditor.vue b/packages/frontend/src/components/MkSortOrderEditor.vue index 9decacc5f5..27ffc724ae 100644 --- a/packages/frontend/src/components/MkSortOrderEditor.vue +++ b/packages/frontend/src/components/MkSortOrderEditor.vue @@ -27,9 +27,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { toRefs } from 'vue'; import MkTagItem from '@/components/MkTagItem.vue'; import MkButton from '@/components/MkButton.vue'; -import { MenuItem } from '@/types/menu.js'; +import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; -import { SortOrder } from '@/components/MkSortOrderEditor.define.js'; +import type { SortOrder } from '@/components/MkSortOrderEditor.define.js'; const emit = defineEmits<{ (ev: 'update', sortOrders: SortOrder<T>[]): void; diff --git a/packages/frontend/src/components/MkSparkle.vue b/packages/frontend/src/components/MkSparkle.vue index b3fc67c0df..2400c5ec7f 100644 --- a/packages/frontend/src/components/MkSparkle.vue +++ b/packages/frontend/src/components/MkSparkle.vue @@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, ref, shallowRef } from 'vue'; +import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; const particles = ref<{ id: string, @@ -66,7 +66,7 @@ const particles = ref<{ dur: number, color: string }[]>([]); -const el = shallowRef<HTMLElement>(); +const el = useTemplateRef('el'); const width = ref(0); const height = ref(0); const colors = ['#FF1493', '#00FFFF', '#FFE202', '#FFE202', '#FFE202']; diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 56e8fcfa37..272646f338 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -4,27 +4,62 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="rrevdjwu" :class="{ grid }"> - <div v-for="group in def" class="group"> - <div v-if="group.title" class="title">{{ group.title }}</div> +<div ref="rootEl" class="rrevdjwu" :class="{ grid }"> + <MkInput + v-if="searchIndex && searchIndex.length > 0" + v-model="searchQuery" + :placeholder="i18n.ts.search" + type="search" + style="margin-bottom: 16px;" + @input.passive="searchOnInput" + @keydown="searchOnKeyDown" + > + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> - <div class="items"> - <template v-for="(item, i) in group.items"> - <a v-if="item.type === 'a'" :href="item.href" :target="item.target" class="_button item" :class="{ danger: item.danger, active: item.active }"> - <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> - <span class="text">{{ item.text }}</span> - </a> - <button v-else-if="item.type === 'button'" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="ev => item.action(ev)"> - <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> - <span class="text">{{ item.text }}</span> - </button> - <MkA v-else :to="item.to" class="_button item" :class="{ danger: item.danger, active: item.active }"> - <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> - <span class="text">{{ item.text }}</span> - </MkA> - </template> + <template v-if="rawSearchQuery == ''"> + <div v-for="group in def" class="group"> + <div v-if="group.title" class="title">{{ group.title }}</div> + + <div class="items"> + <template v-for="(item, i) in group.items"> + <a v-if="item.type === 'a'" :href="item.href" :target="item.target" class="_button item" :class="{ danger: item.danger, active: item.active }"> + <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> + <span class="text">{{ item.text }}</span> + </a> + <button v-else-if="item.type === 'button'" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="ev => item.action(ev)"> + <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> + <span class="text">{{ item.text }}</span> + </button> + <MkA v-else :to="item.to" class="_button item" :class="{ danger: item.danger, active: item.active }"> + <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> + <span class="text">{{ item.text }}</span> + </MkA> + </template> + </div> + </div> + </template> + <template v-else> + <div v-for="item, index in searchResult"> + <MkA + :to="item.path + '#' + item.id" + class="_button searchResultItem" + :class="{ selected: searchSelectedIndex !== null && searchSelectedIndex === index }" + > + <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> + <span class="text"> + <template v-if="item.isRoot"> + {{ item.label }} + </template> + <template v-else> + <span style="opacity: 0.7; font-size: 90%;">{{ item.parentLabels.join(' > ') }}</span> + <br> + <span>{{ item.label }}</span> + </template> + </span> + </MkA> </div> - </div> + </template> </div> </template> @@ -45,7 +80,7 @@ export type SuperMenuDef = { text: string; danger?: boolean; active?: boolean; - action: (ev: MouseEvent) => void; + action: (ev: MouseEvent) => void | Promise<void>; } | { type?: 'link'; to: string; @@ -58,10 +93,115 @@ export type SuperMenuDef = { </script> <script lang="ts" setup> -defineProps<{ +import { useTemplateRef, ref, watch, nextTick } from 'vue'; +import type { SearchIndexItem } from '@/utility/autogen/settings-search-index.js'; +import MkInput from '@/components/MkInput.vue'; +import { i18n } from '@/i18n.js'; +import { getScrollContainer } from '@@/js/scroll.js'; +import { useRouter } from '@/router.js'; +import { initIntlString, compareStringIncludes } from '@/utility/intl-string.js'; + +const props = defineProps<{ def: SuperMenuDef[]; grid?: boolean; + searchIndex?: SearchIndexItem[]; }>(); + +initIntlString(); + +const router = useRouter(); +const rootEl = useTemplateRef('rootEl'); + +const searchQuery = ref(''); +const rawSearchQuery = ref(''); + +const searchSelectedIndex = ref<null | number>(null); +const searchResult = ref<{ + id: string; + path: string; + label: string; + icon?: string; + isRoot: boolean; + parentLabels: string[]; +}[]>([]); + +watch(searchQuery, (value) => { + rawSearchQuery.value = value; +}); + +watch(rawSearchQuery, (value) => { + searchResult.value = []; + searchSelectedIndex.value = null; + + if (value === '') { + return; + } + + const dive = (items: SearchIndexItem[], parents: SearchIndexItem[] = []) => { + for (const item of items) { + const matched = ( + compareStringIncludes(item.label, value) || + item.keywords.some((x) => compareStringIncludes(x, value)) + ); + + if (matched) { + searchResult.value.push({ + id: item.id, + path: item.path ?? parents.find((x) => x.path != null)?.path ?? '/', // never gets `/` + label: item.label, + parentLabels: parents.map((x) => x.label).toReversed(), + icon: item.icon ?? parents.find((x) => x.icon != null)?.icon, + isRoot: parents.length === 0, + }); + } + + if (item.children) { + dive(item.children, [item, ...parents]); + } + } + }; + + if (props.searchIndex) { + dive(props.searchIndex); + } +}); + +function searchOnInput(ev: InputEvent) { + searchSelectedIndex.value = null; + rawSearchQuery.value = (ev.target as HTMLInputElement).value; +} + +function searchOnKeyDown(ev: KeyboardEvent) { + if (ev.isComposing) return; + + if (ev.key === 'Enter' && searchSelectedIndex.value != null) { + ev.preventDefault(); + router.push(searchResult.value[searchSelectedIndex.value].path + '#' + searchResult.value[searchSelectedIndex.value].id); + } else if (ev.key === 'ArrowDown') { + ev.preventDefault(); + const current = searchSelectedIndex.value ?? -1; + searchSelectedIndex.value = current + 1 >= searchResult.value.length ? 0 : current + 1; + } else if (ev.key === 'ArrowUp') { + ev.preventDefault(); + const current = searchSelectedIndex.value ?? 0; + searchSelectedIndex.value = current - 1 < 0 ? searchResult.value.length - 1 : current - 1; + } + + if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') { + nextTick(() => { + if (!rootEl.value) return; + const selectedEl = rootEl.value.querySelector<HTMLElement>('.searchResultItem.selected'); + if (selectedEl != null) { + const scrollContainer = getScrollContainer(selectedEl); + if (!scrollContainer) return; + scrollContainer.scrollTo({ + top: selectedEl.offsetTop - scrollContainer.clientHeight / 2 + selectedEl.clientHeight / 2, + behavior: 'instant', + }); + } + }); + } +} </script> <style lang="scss" scoped> @@ -184,5 +324,52 @@ defineProps<{ } } } + + .searchResultItem { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + padding: 9px 16px 9px 8px; + border-radius: 9px; + font-size: 0.9em; + + &:hover { + text-decoration: none; + background: var(--MI_THEME-panelHighlight); + } + + &.selected { + outline: 2px solid var(--MI_THEME-focus); + } + + &:focus-visible, + &.selected { + outline-offset: -2px; + } + + &.active { + color: var(--MI_THEME-accent); + background: var(--MI_THEME-accentedBg); + } + + &.danger { + color: var(--MI_THEME-error); + } + + > .icon { + width: 32px; + margin-right: 2px; + flex-shrink: 0; + text-align: center; + opacity: 0.8; + } + + > .text { + white-space: normal; + padding-right: 12px; + flex-shrink: 1; + } + } } </style> diff --git a/packages/frontend/src/components/MkSwitch.button.vue b/packages/frontend/src/components/MkSwitch.button.vue index 581aa4e644..e9a029c993 100644 --- a/packages/frontend/src/components/MkSwitch.button.vue +++ b/packages/frontend/src/components/MkSwitch.button.vue @@ -19,7 +19,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { toRefs, Ref } from 'vue'; +import { toRefs } from 'vue'; +import type { Ref } from 'vue'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index 5e6029ee40..797e577fa4 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -27,7 +27,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { toRefs, Ref } from 'vue'; +import { toRefs } from 'vue'; +import type { Ref } from 'vue'; import XButton from '@/components/MkSwitch.button.vue'; const props = defineProps<{ diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.vue b/packages/frontend/src/components/MkSystemWebhookEditor.vue index 485d003f93..f819f82923 100644 --- a/packages/frontend/src/components/MkSystemWebhookEditor.vue +++ b/packages/frontend/src/components/MkSystemWebhookEditor.vue @@ -92,18 +92,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { computed, onMounted, ref, shallowRef, toRefs } from 'vue'; +import { computed, onMounted, ref, useTemplateRef, toRefs } from 'vue'; import * as Misskey from 'misskey-js'; -import MkInput from '@/components/MkInput.vue'; -import MkSwitch from '@/components/MkSwitch.vue'; -import { +import type { MkSystemWebhookEditorProps, MkSystemWebhookResult, SystemWebhookEventType, } from '@/components/MkSystemWebhookEditor.impl.js'; +import MkInput from '@/components/MkInput.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; @@ -114,7 +114,7 @@ type EventType = { userCreated: boolean; inactiveModeratorsWarning: boolean; inactiveModeratorsInvitationOnlyChanged: boolean; -} +}; const emit = defineEmits<{ (ev: 'submitted', result: MkSystemWebhookResult): void; @@ -122,7 +122,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialogEl = useTemplateRef('dialogEl'); const props = defineProps<MkSystemWebhookEditorProps>(); diff --git a/packages/frontend/src/components/MkTagCloud.vue b/packages/frontend/src/components/MkTagCloud.vue index 87aa046963..9d541c8acb 100644 --- a/packages/frontend/src/components/MkTagCloud.vue +++ b/packages/frontend/src/components/MkTagCloud.vue @@ -15,18 +15,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, watch, onBeforeUnmount, ref, shallowRef } from 'vue'; +import { onMounted, watch, onBeforeUnmount, ref, useTemplateRef } from 'vue'; import tinycolor from 'tinycolor2'; const loaded = !!window.TagCanvas; const SAFE_FOR_HTML_ID = 'abcdefghijklmnopqrstuvwxyz'; -const computedStyle = getComputedStyle(document.documentElement); +const computedStyle = getComputedStyle(window.document.documentElement); const idForCanvas = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join(''); const idForTags = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join(''); const available = ref(false); -const rootEl = shallowRef<HTMLElement | null>(null); -const canvasEl = shallowRef<HTMLCanvasElement | null>(null); -const tagsEl = shallowRef<HTMLElement | null>(null); +const rootEl = useTemplateRef('rootEl'); +const canvasEl = useTemplateRef('canvasEl'); +const tagsEl = useTemplateRef('tagsEl'); const width = ref(300); watch(available, () => { @@ -57,7 +57,7 @@ onMounted(() => { if (loaded) { available.value = true; } else { - document.head.appendChild(Object.assign(document.createElement('script'), { + window.document.head.appendChild(Object.assign(window.document.createElement('script'), { async: true, src: '/client-assets/tagcanvas.min.js', })).addEventListener('load', () => available.value = true); diff --git a/packages/frontend/src/components/MkTagItem.stories.impl.ts b/packages/frontend/src/components/MkTagItem.stories.impl.ts index 3f243ff651..ac932c8342 100644 --- a/packages/frontend/src/components/MkTagItem.stories.impl.ts +++ b/packages/frontend/src/components/MkTagItem.stories.impl.ts @@ -6,7 +6,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkTagItem from './MkTagItem.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue index 9deb6528d1..ca42865f96 100644 --- a/packages/frontend/src/components/MkTextarea.vue +++ b/packages/frontend/src/components/MkTextarea.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> +<div class="_selectable"> <div :class="$style.label" @click="focus"><slot name="label"></slot></div> <div :class="{ [$style.disabled]: disabled, [$style.focused]: focused, [$style.tall]: tall, [$style.pre]: pre }" style="position: relative;"> <textarea @@ -36,11 +36,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, shallowRef } from 'vue'; +import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, useTemplateRef } from 'vue'; import { debounce } from 'throttle-debounce'; +import type { SuggestionType } from '@/utility/autocomplete.js'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js'; +import { Autocomplete } from '@/utility/autocomplete.js'; const props = defineProps<{ modelValue: string | null; @@ -74,7 +75,7 @@ const focused = ref(false); const changed = ref(false); const invalid = ref(false); const filled = computed(() => v.value !== '' && v.value != null); -const inputEl = shallowRef<HTMLTextAreaElement>(); +const inputEl = useTemplateRef('inputEl'); const preview = ref(false); let autocompleteWorker: Autocomplete | null = null; diff --git a/packages/frontend/src/components/MkThemePreview.vue b/packages/frontend/src/components/MkThemePreview.vue new file mode 100644 index 0000000000..5b180b3680 --- /dev/null +++ b/packages/frontend/src/components/MkThemePreview.vue @@ -0,0 +1,96 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<svg + version="1.1" + viewBox="0 0 203.2 152.4" + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" +> + <g fill-rule="evenodd"> + <rect width="203.2" height="152.4" :fill="themeVariables.bg" stroke-width=".26458" /> + <rect width="65.498" height="152.4" :fill="themeVariables.panel" stroke-width=".26458" /> + <rect x="65.498" width="137.7" height="40.892" :fill="themeVariables.acrylicBg" stroke-width=".265" /> + <path transform="scale(.26458)" d="m439.77 247.19c-43.673 0-78.832 35.157-78.832 78.83v249.98h407.06v-328.81z" :fill="themeVariables.panel" /> + </g> + <circle cx="32.749" cy="83.054" r="21.132" :fill="themeVariables.accentedBg" stroke-dasharray="0.319256, 0.319256" stroke-width=".15963" style="paint-order:stroke fill markers" /> + <circle cx="136.67" cy="106.76" r="23.876" :fill="themeVariables.fg" fill-opacity="0.5" stroke-dasharray="0.352425, 0.352425" stroke-width=".17621" style="paint-order:stroke fill markers" /> + <g :fill="themeVariables.fg" fill-rule="evenodd" stroke-width=".26458"> + <rect x="171.27" y="87.815" width="48.576" height="6.8747" ry="3.4373"/> + <rect x="171.27" y="105.09" width="48.576" height="6.875" ry="3.4375"/> + <rect x="171.27" y="121.28" width="48.576" height="6.875" ry="3.4375"/> + <rect x="171.27" y="137.47" width="48.576" height="6.875" ry="3.4375"/> + </g> + <path d="m65.498 40.892h137.7" :stroke="themeVariables.divider" stroke-width="0.75" /> + <g transform="matrix(.60823 0 0 .60823 25.45 75.755)" fill="none" :stroke="themeVariables.accent" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"> + <path d="m0 0h24v24h-24z" fill="none" stroke="none" /> + <path d="m5 12h-2l9-9 9 9h-2" /> + <path d="m5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7" /> + <path d="m9 21v-6a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v6" /> + </g> + <g transform="matrix(.61621 0 0 .61621 25.354 117.92)" fill="none" :stroke="themeVariables.fg" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"> + <path d="m0 0h24v24h-24z" fill="none" stroke="none" /> + <path d="m10 5a2 2 0 1 1 4 0 7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2-3v-3a7 7 0 0 1 4-6" /> + <path d="m9 17v1a3 3 0 0 0 6 0v-1" /> + </g> + <image x="20.948" y="18.388" width="23.602" height="23.602" image-rendering="optimizeSpeed" preserveAspectRatio="xMidYMid meet" v-bind="{ 'xlink:href': instance.iconUrl || '/favicon.ico' }" /> +</svg> +</template> + +<script setup lang="ts"> +import { ref, watch } from 'vue'; +import { instance } from '@/instance.js'; +import { compile } from '@/theme.js'; +import type { Theme } from '@/theme.js'; +import { deepClone } from '@/utility/clone.js'; +import lightTheme from '@@/themes/_light.json5'; +import darkTheme from '@@/themes/_dark.json5'; + +const props = defineProps<{ + theme: Theme; +}>(); + +const themeVariables = ref<{ + bg: string; + acrylicBg: string; + panel: string; + fg: string; + divider: string; + accent: string; + accentedBg: string; +}>({ + bg: 'var(--MI_THEME-bg)', + acrylicBg: 'var(--MI_THEME-acrylicBg)', + panel: 'var(--MI_THEME-panel)', + fg: 'var(--MI_THEME-fg)', + divider: 'var(--MI_THEME-divider)', + accent: 'var(--MI_THEME-accent)', + accentedBg: 'var(--MI_THEME-accentedBg)', +}); + +watch(() => props.theme, (theme) => { + if (theme == null) return; + + const _theme = deepClone(theme); + + if (_theme?.base != null) { + const base = [lightTheme, darkTheme].find(x => x.id === _theme.base); + if (base) _theme.props = Object.assign({}, base.props, _theme.props); + } + + const compiled = compile(_theme); + + themeVariables.value = { + bg: compiled.bg ?? 'var(--MI_THEME-bg)', + acrylicBg: compiled.acrylicBg ?? 'var(--MI_THEME-acrylicBg)', + panel: compiled.panel ?? 'var(--MI_THEME-panel)', + fg: compiled.fg ?? 'var(--MI_THEME-fg)', + divider: compiled.divider ?? 'var(--MI_THEME-divider)', + accent: compiled.accent ?? 'var(--MI_THEME-accent)', + accentedBg: compiled.accentedBg ?? 'var(--MI_THEME-accentedBg)', + }; +}, { immediate: true }); +</script> diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 7a9abab62e..15b9da7655 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only v-if="paginationQuery" ref="tlComponent" :pagination="paginationQuery" - :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" + :noGap="!prefer.s.showGapBetweenNotesInTimeline" @queue="emit('queue', $event)" @status="prComponent?.setDisabled($event)" /> @@ -17,17 +17,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, watch, onUnmounted, provide, ref, shallowRef } from 'vue'; +import { computed, watch, onUnmounted, provide, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import type { BasicTimelineType } from '@/timelines.js'; +import type { Paging } from '@/components/MkPagination.vue'; import MkNotes from '@/components/MkNotes.vue'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import { useStream } from '@/stream.js'; -import * as sound from '@/scripts/sound.js'; -import { $i } from '@/account.js'; +import * as sound from '@/utility/sound.js'; +import { $i } from '@/i.js'; import { instance } from '@/instance.js'; -import { defaultStore } from '@/store.js'; -import { Paging } from '@/components/MkPagination.vue'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; @@ -59,19 +59,19 @@ provide('tl_withSensitive', computed(() => props.withSensitive)); provide('inChannel', computed(() => props.src === 'channel')); type TimelineQueryType = { - antennaId?: string, - withRenotes?: boolean, - withReplies?: boolean, - withFiles?: boolean, - withBots?: boolean, - visibility?: string, - listId?: string, - channelId?: string, - roleId?: string -} + antennaId?: string, + withRenotes?: boolean, + withReplies?: boolean, + withFiles?: boolean, + withBots?: boolean, + visibility?: string, + listId?: string, + channelId?: string, + roleId?: string +}; -const prComponent = shallowRef<InstanceType<typeof MkPullToRefresh>>(); -const tlComponent = shallowRef<InstanceType<typeof MkNotes>>(); +const prComponent = useTemplateRef('prComponent'); +const tlComponent = useTemplateRef('tlComponent'); let tlNotesCount = 0; @@ -266,7 +266,7 @@ function updatePaginationQuery() { } function refreshEndpointAndChannel() { - if (!defaultStore.state.disableStreamingTimeline) { + if (!prefer.s.disableStreamingTimeline) { disconnectChannel(); connectChannel(); } diff --git a/packages/frontend/src/components/MkToast.vue b/packages/frontend/src/components/MkToast.vue index 38b537cbc9..571835432e 100644 --- a/packages/frontend/src/components/MkToast.vue +++ b/packages/frontend/src/components/MkToast.vue @@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_toast_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_toast_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_toast_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_toast_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_toast_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_toast_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_toast_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_toast_leaveTo : ''" appear @afterLeave="emit('closed')" > <div v-if="showing" class="_acrylic" :class="$style.root" :style="{ zIndex }"> @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as os from '@/os.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; withDefaults(defineProps<{ message: string; diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index 73aef68964..b449155edb 100644 --- a/packages/frontend/src/components/MkTokenGenerateWindow.vue +++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue @@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef, ref } from 'vue'; +import { useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkInput from './MkInput.vue'; import MkSwitch from './MkSwitch.vue'; @@ -55,7 +55,7 @@ import MkButton from './MkButton.vue'; import MkInfo from './MkInfo.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; -import { iAmAdmin } from '@/account.js'; +import { iAmAdmin } from '@/i.js'; const props = withDefaults(defineProps<{ title?: string | null; @@ -77,10 +77,10 @@ const emit = defineEmits<{ const defaultPermissions = Misskey.permissions.filter(p => !p.startsWith('read:admin') && !p.startsWith('write:admin')); const adminPermissions = Misskey.permissions.filter(p => p.startsWith('read:admin') || p.startsWith('write:admin')); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const name = ref(props.initialName); -const permissionSwitches = ref(<Record<(typeof Misskey.permissions)[number], boolean>>{}); -const permissionSwitchesForAdmin = ref(<Record<(typeof Misskey.permissions)[number], boolean>>{}); +const permissionSwitches = ref({} as Record<(typeof Misskey.permissions)[number], boolean>); +const permissionSwitchesForAdmin = ref({} as Record<(typeof Misskey.permissions)[number], boolean>); if (props.initialPermissions) { for (const kind of props.initialPermissions) { diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue index 22e74aa6d1..955c24b6ef 100644 --- a/packages/frontend/src/components/MkTooltip.vue +++ b/packages/frontend/src/components/MkTooltip.vue @@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_tooltip_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_tooltip_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_tooltip_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_tooltip_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_tooltip_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_tooltip_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_tooltip_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_tooltip_leaveTo : ''" appear @afterLeave="emit('closed')" > <div v-show="showing" ref="el" :class="$style.root" class="_acrylic _shadow" :style="{ zIndex, maxWidth: maxWidth + 'px' }"> @@ -23,10 +23,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted, onUnmounted, shallowRef } from 'vue'; +import { nextTick, onMounted, onUnmounted, useTemplateRef } from 'vue'; import * as os from '@/os.js'; -import { calcPopupPosition } from '@/scripts/popup-position.js'; -import { defaultStore } from '@/store.js'; +import { calcPopupPosition } from '@/utility/popup-position.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ showing: boolean; @@ -51,7 +51,7 @@ const emit = defineEmits<{ // タイミングによっては最初から showing = false な場合があり、その場合に closed 扱いにしないと永久にDOMに残ることになる if (!props.showing) emit('closed'); -const el = shallowRef<HTMLElement>(); +const el = useTemplateRef('el'); const zIndex = os.claimZIndex('high'); function setPosition() { diff --git a/packages/frontend/src/components/MkTutorialDialog.Note.vue b/packages/frontend/src/components/MkTutorialDialog.Note.vue index 53b8db38b2..ddb22f7d8c 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Note.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Note.vue @@ -38,7 +38,7 @@ import * as Misskey from 'misskey-js'; import { ref, reactive } from 'vue'; import { i18n } from '@/i18n.js'; import { globalEvents } from '@/events.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import MkNote from '@/components/MkNote.vue'; const props = defineProps<{ diff --git a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue index e1fc3e4f26..df184ec315 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue @@ -31,7 +31,7 @@ import MkPostForm from '@/components/MkPostForm.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkNote from '@/components/MkNote.vue'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; const emit = defineEmits<{ (ev: 'succeeded'): void; diff --git a/packages/frontend/src/components/MkTutorialDialog.vue b/packages/frontend/src/components/MkTutorialDialog.vue index 11d7c8dc4d..3e91baada4 100644 --- a/packages/frontend/src/components/MkTutorialDialog.vue +++ b/packages/frontend/src/components/MkTutorialDialog.vue @@ -148,7 +148,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, watch } from 'vue'; +import { ref, useTemplateRef, watch } from 'vue'; +import { host } from '@@/js/config.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import XNote from '@/components/MkTutorialDialog.Note.vue'; @@ -158,8 +159,7 @@ import XSensitive from '@/components/MkTutorialDialog.Sensitive.vue'; import MkAnimBg from '@/components/MkAnimBg.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { host } from '@@/js/config.js'; -import { claimAchievement } from '@/scripts/achievements.js'; +import { claimAchievement } from '@/utility/achievements.js'; import * as os from '@/os.js'; const props = defineProps<{ @@ -170,7 +170,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); // eslint-disable-next-line vue/no-setup-props-reactivity-loss const page = ref(props.initialPage ?? 0); diff --git a/packages/frontend/src/components/MkUpdated.vue b/packages/frontend/src/components/MkUpdated.vue index 7cafb1b0af..3685be6359 100644 --- a/packages/frontend/src/components/MkUpdated.vue +++ b/packages/frontend/src/components/MkUpdated.vue @@ -15,15 +15,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef } from 'vue'; +import { onMounted, useTemplateRef } from 'vue'; +import { version } from '@@/js/config.js'; import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import MkSparkle from '@/components/MkSparkle.vue'; -import { version } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; -import { confetti } from '@/scripts/confetti.js'; +import { confetti } from '@/utility/confetti.js'; -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); const whatIsNew = () => { modal.value?.close(); diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index 922fa86072..11071f2f60 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only > <iframe v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" - sandbox="allow-popups allow-scripts allow-storage-access-by-user-activation allow-same-origin" + sandbox="allow-popups allow-popups-to-escape-sandbox allow-scripts allow-storage-access-by-user-activation allow-same-origin" scrolling="no" :allow="player.allow == null ? 'autoplay;encrypted-media;fullscreen' : player.allow.filter(x => ['autoplay', 'clipboard-write', 'fullscreen', 'encrypted-media', 'picture-in-picture', 'web-share'].includes(x)).join(';')" :class="$style.playerIframe" @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only sandbox="allow-popups allow-popups-to-escape-sandbox allow-scripts allow-same-origin" scrolling="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px`, border: 0 }" - :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${defaultStore.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`" + :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${store.s.darkMode ? 'dark' : 'light'}&id=${tweetId}`" ></iframe> </div> <div :class="$style.action"> @@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-else-if="theNote" :class="[$style.link, { [$style.compact]: compact }]"><XNoteSimple :note="theNote" :class="$style.body"/></div> <div v-else-if="!hidePreview"> <component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substring(local.length) : url" rel="nofollow noopener" :target="target" :title="url" @click.prevent="self ? true : warningExternalWebsite(url)" @click.stop> - <div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="defaultStore.state.dataSaver.urlPreview ? '' : `background-image: url('${thumbnail}')`"> + <div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="prefer.s.dataSaver.urlPreview ? '' : `background-image: url('${thumbnail}')`"> </div> <article :class="$style.body"> <header :class="$style.header"> @@ -89,7 +89,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, onDeactivated, onUnmounted, ref, watch } from 'vue'; +import { defineAsyncComponent, onDeactivated, onUnmounted, ref } from 'vue'; import { url as local } from '@@/js/config.js'; import { versatileLang } from '@@/js/intl-const.js'; import * as Misskey from 'misskey-js'; @@ -98,12 +98,13 @@ import type MkNoteSimple from '@/components/MkNoteSimple.vue'; import type SkNoteSimple from '@/components/SkNoteSimple.vue'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { deviceKind } from '@/utility/device-kind.js'; import MkButton from '@/components/MkButton.vue'; -import { transformPlayerUrl } from '@/scripts/player-url-transform.js'; -import { defaultStore } from '@/store.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { warningExternalWebsite } from '@/scripts/warning-external-website.js'; +import { transformPlayerUrl } from '@/utility/player-url-transform.js'; +import { store } from '@/store.js'; +import { prefer } from '@/preferences.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { warningExternalWebsite } from '@/utility/warning-external-website.js'; const XNoteSimple = defineAsyncComponent<typeof MkNoteSimple | typeof SkNoteSimple>(() => defaultStore.state.noteDesign === 'misskey' @@ -301,6 +302,7 @@ onUnmounted(() => { box-shadow: 0 0 0 1px var(--MI_THEME-divider); border-radius: var(--MI-radius-sm); overflow: clip; + text-align: left; &:hover { text-decoration: none; diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue index e972973dba..fd36d6a82b 100644 --- a/packages/frontend/src/components/MkUrlPreviewPopup.vue +++ b/packages/frontend/src/components/MkUrlPreviewPopup.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root" :style="{ zIndex, top: top + 'px', left: left + 'px' }"> - <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" @afterLeave="emit('closed')"> + <Transition :name="prefer.s.animation ? '_transition_zoom' : ''" @afterLeave="emit('closed')"> <MkUrlPreview v-if="showing" class="_popup _shadow" :url="url" :showActions="false"/> </Transition> </div> @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, ref } from 'vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import * as os from '@/os.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ showing: boolean; diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue index fe499fabbf..34e86444ad 100644 --- a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue +++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue @@ -56,13 +56,13 @@ import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkRadios from '@/components/MkRadios.vue'; -type AdminAnnouncementType = Misskey.entities.AdminAnnouncementsCreateRequest & { id: string; } +type AdminAnnouncementType = Misskey.entities.AdminAnnouncementsCreateRequest & { id: string; }; const props = defineProps<{ user: Misskey.entities.User, diff --git a/packages/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue index ce28f6ec5e..e2e42e78f3 100644 --- a/packages/frontend/src/components/MkUserCardMini.vue +++ b/packages/frontend/src/components/MkUserCardMini.vue @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { onMounted, ref } from 'vue'; import MkMiniChart from '@/components/MkMiniChart.vue'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; import { acct } from '@/filters/user.js'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue index a6bbacacee..0c59c7748a 100644 --- a/packages/frontend/src/components/MkUserInfo.vue +++ b/packages/frontend/src/components/MkUserInfo.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_panel" :class="$style.root"> - <div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` : ''"></div> + <div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` : ''"></div> <MkAvatar :class="$style.avatar" :user="user" indicator/> <div :class="$style.title"> <MkA :class="$style.name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA> @@ -47,10 +47,10 @@ import MkFollowButton from '@/components/MkFollowButton.vue'; import number from '@/filters/number.js'; import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; -import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; -import { defaultStore } from '@/store.js'; +import { $i } from '@/i.js'; +import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/utility/isFfVisibleForMe.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; +import { prefer } from '@/preferences.js'; defineProps<{ user: Misskey.entities.UserDetailed; diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue index 8dc01a08ab..785efe6e33 100644 --- a/packages/frontend/src/components/MkUserList.vue +++ b/packages/frontend/src/components/MkUserList.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination :pagination="pagination" :displayLimit="50"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.noUsers }}</div> </div> </template> @@ -21,8 +21,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import type { Paging } from '@/components/MkPagination.vue'; import MkUserInfo from '@/components/MkUserInfo.vue'; -import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue index 73e38bef09..29d6736ffc 100644 --- a/packages/frontend/src/components/MkUserPopup.vue +++ b/packages/frontend/src/components/MkUserPopup.vue @@ -5,15 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_popup_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_popup_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_popup_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_popup_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_popup_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_popup_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_popup_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_popup_leaveTo : ''" appear @afterLeave="emit('closed')" > <div v-if="showing" :class="$style.root" class="_popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { emit('mouseover'); }" @mouseleave="() => { emit('mouseleave'); }"> <div v-if="user != null"> - <div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` : ''"> + <div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` : ''"> <span v-if="$i && $i.id != user.id && user.isFollowed && user.isFollowing" :class="$style.followed">{{ i18n.ts.mutuals }}</span> <span v-else-if="$i && $i.id != user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span> <span v-else-if="$i && $i.id != user.id && user.isFollowing" :class="$style.followed">{{ i18n.ts.following }}</span> @@ -74,14 +74,14 @@ import * as Misskey from 'misskey-js'; import MkFollowButton from '@/components/MkFollowButton.vue'; import { userPage } from '@/filters/user.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { getUserMenu } from '@/scripts/get-user-menu.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { getUserMenu } from '@/utility/get-user-menu.js'; import number from '@/filters/number.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; -import { $i } from '@/account.js'; -import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; +import { prefer } from '@/preferences.js'; +import { $i } from '@/i.js'; +import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/utility/isFfVisibleForMe.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; const props = defineProps<{ showing: boolean; diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue index 120f19cb7f..34ed1f8fc2 100644 --- a/packages/frontend/src/components/MkUserSelectDialog.vue +++ b/packages/frontend/src/components/MkUserSelectDialog.vue @@ -61,17 +61,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, computed, shallowRef } from 'vue'; +import { onMounted, ref, computed, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; +import { host as currentHost, hostname } from '@@/js/config.js'; import MkInput from '@/components/MkInput.vue'; import FormSplit from '@/components/form/split.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { instance } from '@/instance.js'; -import { host as currentHost, hostname } from '@@/js/config.js'; const emit = defineEmits<{ (ev: 'ok', selected: Misskey.entities.UserDetailed): void; @@ -94,7 +94,7 @@ const host = ref(''); const users = ref<Misskey.entities.UserLite[]>([]); const recentUsers = ref<Misskey.entities.UserDetailed[]>([]); const selected = ref<Misskey.entities.UserLite | null>(null); -const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialogEl = useTemplateRef('dialogEl'); function search() { if (username.value === '' && host.value === '') { @@ -128,10 +128,10 @@ async function ok() { dialogEl.value?.close(); // 最近使ったユーザー更新 - let recents = defaultStore.state.recentlyUsedUsers; + let recents = store.s.recentlyUsedUsers; recents = recents.filter(x => x !== selected.value?.id); recents.unshift(selected.value.id); - defaultStore.set('recentlyUsedUsers', recents.splice(0, 16)); + store.set('recentlyUsedUsers', recents.splice(0, 16)); } function cancel() { @@ -141,7 +141,7 @@ function cancel() { onMounted(() => { misskeyApi('users/show', { - userIds: defaultStore.state.recentlyUsedUsers, + userIds: store.s.recentlyUsedUsers, }).then(foundUsers => { let _users = foundUsers; _users = _users.filter((u) => { @@ -198,7 +198,7 @@ onMounted(() => { font-size: 14px; &:hover { - background: var(--MI_THEME-X7); + background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); } &.selected { diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts index 638bfb4372..52467893a0 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; import { userDetailed } from '../../.storybook/fakes.js'; diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue index 5153c06139..67a06c70db 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue @@ -38,7 +38,8 @@ import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; import XUser from '@/components/MkUserSetupDialog.User.vue'; -import MkPagination, { type Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import type { Paging } from '@/components/MkPagination.vue'; const pinnedUsers: Paging = { endpoint: 'pinned-users', diff --git a/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts index 2a7947c6f8..0ada259d3f 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkUserSetupDialog_Privacy from './MkUserSetupDialog.Privacy.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue index fb4a2b1c78..5e68bfeff3 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue @@ -41,7 +41,7 @@ import { i18n } from '@/i18n.js'; import MkSwitch from '@/components/MkSwitch.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkFolder from '@/components/MkFolder.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const isLocked = ref(false); const hideOnlineStatus = ref(true); diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts index c6088a5ae3..cefd48cb01 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkUserSetupDialog_Profile from './MkUserSetupDialog.Profile.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue index 7cb48f6afb..30925b854c 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -37,11 +37,11 @@ import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import FormSlot from '@/components/form/slot.vue'; import MkInfo from '@/components/MkInfo.vue'; -import { chooseFileFromPc } from '@/scripts/select-file.js'; +import { chooseFileFromPc } from '@/utility/select-file.js'; import * as os from '@/os.js'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const name = ref($i.name ?? ''); const description = ref($i.description ?? ''); diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts index f0206e0cb4..b424632bdc 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { userDetailed } from '../../.storybook/fakes.js'; import MkUserSetupDialog_User from './MkUserSetupDialog.User.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.vue b/packages/frontend/src/components/MkUserSetupDialog.User.vue index 4c4f4989c5..dfaf21a6b8 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.User.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.User.vue @@ -29,7 +29,7 @@ import * as Misskey from 'misskey-js'; import { ref } from 'vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const props = defineProps<{ user: Misskey.entities.UserDetailed; diff --git a/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts index 3f5ae734bd..751391c2d8 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; import { userDetailed } from '../../.storybook/fakes.js'; diff --git a/packages/frontend/src/components/MkUserSetupDialog.vue b/packages/frontend/src/components/MkUserSetupDialog.vue index b7261129ef..767f5c591a 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.vue @@ -128,7 +128,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, watch, nextTick, defineAsyncComponent } from 'vue'; +import { ref, useTemplateRef, watch, nextTick, defineAsyncComponent } from 'vue'; +import { host } from '@@/js/config.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import XProfile from '@/components/MkUserSetupDialog.Profile.vue'; @@ -137,22 +138,20 @@ import XPrivacy from '@/components/MkUserSetupDialog.Privacy.vue'; import MkAnimBg from '@/components/MkAnimBg.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { host } from '@@/js/config.js'; import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import * as os from '@/os.js'; const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); - -// eslint-disable-next-line vue/no-setup-props-reactivity-loss -const page = ref(defaultStore.state.accountSetupWizard); +const dialog = useTemplateRef('dialog'); + +const page = ref(store.s.accountSetupWizard); watch(page, () => { - defaultStore.set('accountSetupWizard', page.value); + store.set('accountSetupWizard', page.value); }); async function close(skip: boolean) { @@ -165,11 +164,11 @@ async function close(skip: boolean) { } dialog.value?.close(); - defaultStore.set('accountSetupWizard', -1); + store.set('accountSetupWizard', -1); } function setupComplete() { - defaultStore.set('accountSetupWizard', -1); + store.set('accountSetupWizard', -1); dialog.value?.close(); } @@ -194,7 +193,7 @@ async function later(later: boolean) { } dialog.value?.close(); - defaultStore.set('accountSetupWizard', 0); + store.set('accountSetupWizard', 0); } </script> diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index 5624abbd33..659a82d8f2 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -42,12 +42,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, shallowRef, ref } from 'vue'; +import { nextTick, useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkModal from '@/components/MkModal.vue'; import { i18n } from '@/i18n.js'; -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); const props = withDefaults(defineProps<{ currentVisibility: typeof Misskey.noteVisibilities[number]; diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue index d098dad9a1..79c9e739c4 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue @@ -13,19 +13,19 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref, nextTick } from 'vue'; +import { onMounted, useTemplateRef, ref, nextTick } from 'vue'; import { Chart } from 'chart.js'; import gradient from 'chartjs-plugin-gradient'; import tinycolor from 'tinycolor2'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { initChart } from '@/scripts/init-chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); -const chartEl = shallowRef<HTMLCanvasElement | null>(null); +const chartEl = useTemplateRef('chartEl'); const now = new Date(); let chartInstance: Chart | null = null; const chartLimit = 30; @@ -59,9 +59,9 @@ async function renderChart() { await nextTick(); - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; - const computedStyle = getComputedStyle(document.documentElement); + const computedStyle = getComputedStyle(window.document.documentElement); const accent = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(); const colorRead = accent; diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index a1c860c473..9a87d480e3 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -66,7 +66,7 @@ import MkTimeline from '@/components/MkTimeline.vue'; import MkInfo from '@/components/MkInfo.vue'; import { instanceName } from '@@/js/config.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import MkNumber from '@/components/MkNumber.vue'; diff --git a/packages/frontend/src/components/MkWaitingDialog.vue b/packages/frontend/src/components/MkWaitingDialog.vue index 34fa6b0723..282da00ee1 100644 --- a/packages/frontend/src/components/MkWaitingDialog.vue +++ b/packages/frontend/src/components/MkWaitingDialog.vue @@ -14,10 +14,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { watch, shallowRef } from 'vue'; +import { watch, useTemplateRef } from 'vue'; import MkModal from '@/components/MkModal.vue'; -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); const props = defineProps<{ success: boolean; diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue index 2953f656d4..e5ac791d0b 100644 --- a/packages/frontend/src/components/MkWindow.vue +++ b/packages/frontend/src/components/MkWindow.vue @@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_window_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_window_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_window_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_window_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_window_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_window_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_window_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_window_leaveTo : ''" appear @afterLeave="emit('closed')" > @@ -53,12 +53,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onBeforeUnmount, onMounted, provide, shallowRef, ref } from 'vue'; +import { onBeforeUnmount, onMounted, provide, useTemplateRef, ref } from 'vue'; import type { MenuItem } from '@/types/menu.js'; -import contains from '@/scripts/contains.js'; +import contains from '@/utility/contains.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; type WindowButton = { title: string; @@ -114,7 +114,7 @@ const emit = defineEmits<{ provide('inWindow', true); -const rootEl = shallowRef<HTMLElement | null>(); +const rootEl = useTemplateRef('rootEl'); const showing = ref(true); let beforeClickedAt = 0; const maximized = ref(false); @@ -240,7 +240,7 @@ function onHeaderMousedown(evt: MouseEvent | TouchEvent) { const main = rootEl.value; if (main == null) return; - if (!contains(main, document.activeElement)) main.focus(); + if (!contains(main, window.document.activeElement)) main.focus(); const position = main.getBoundingClientRect(); diff --git a/packages/frontend/src/components/MkYouTubePlayer.vue b/packages/frontend/src/components/MkYouTubePlayer.vue index 1122976436..ab62a5113d 100644 --- a/packages/frontend/src/components/MkYouTubePlayer.vue +++ b/packages/frontend/src/components/MkYouTubePlayer.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <div class="poamfof"> - <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in"> <div v-if="player.url && (player.url.startsWith('http://') || player.url.startsWith('https://'))" class="player"> <iframe v-if="!fetching" :src="transformPlayerUrl(player.url)" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe> </div> @@ -25,10 +25,10 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; -import MkWindow from '@/components/MkWindow.vue'; import { versatileLang } from '@@/js/intl-const.js'; -import { transformPlayerUrl } from '@/scripts/player-url-transform.js'; -import { defaultStore } from '@/store.js'; +import MkWindow from '@/components/MkWindow.vue'; +import { transformPlayerUrl } from '@/utility/player-url-transform.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ url: string; diff --git a/packages/frontend/src/components/global/MkA.stories.impl.ts b/packages/frontend/src/components/global/MkA.stories.impl.ts index 02e5a7f98c..deb2b8a52b 100644 --- a/packages/frontend/src/components/global/MkA.stories.impl.ts +++ b/packages/frontend/src/components/global/MkA.stories.impl.ts @@ -5,9 +5,9 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect, userEvent, within } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkA from './MkA.vue'; -import { tick } from '@/scripts/test-utils.js'; +import { tick } from '@/utility/test-utils.js'; export const Default = { render(args) { return { diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue index 23f049ebb4..9a51acc9dc 100644 --- a/packages/frontend/src/components/global/MkA.vue +++ b/packages/frontend/src/components/global/MkA.vue @@ -14,12 +14,12 @@ export type MkABehavior = 'window' | 'browser' | null; </script> <script lang="ts" setup> -import { computed, inject, shallowRef } from 'vue'; -import * as os from '@/os.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { computed, inject, useTemplateRef } from 'vue'; import { url } from '@@/js/config.js'; +import * as os from '@/os.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const props = withDefaults(defineProps<{ to: string; @@ -32,7 +32,7 @@ const props = withDefaults(defineProps<{ const behavior = props.behavior ?? inject<MkABehavior>('linkNavigationBehavior', null); -const el = shallowRef<HTMLElement>(); +const el = useTemplateRef('el'); defineExpose({ $el: el }); @@ -87,7 +87,7 @@ function openWindow() { function nav(ev: MouseEvent) { if (behavior === 'browser') { - location.href = props.to; + window.location.href = props.to; return; } diff --git a/packages/frontend/src/components/global/MkAcct.stories.impl.ts b/packages/frontend/src/components/global/MkAcct.stories.impl.ts index 04960ec60c..02fc835709 100644 --- a/packages/frontend/src/components/global/MkAcct.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAcct.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { userDetailed } from '../../../.storybook/fakes.js'; import MkAcct from './MkAcct.vue'; export const Default = { diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue index 2f4141b901..ff794d9b6e 100644 --- a/packages/frontend/src/components/global/MkAcct.vue +++ b/packages/frontend/src/components/global/MkAcct.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <span> <span>@{{ user.username }}</span> - <span v-if="user.host || detail || defaultStore.state.showFullAcct" style="opacity: 0.5;">@{{ user.host || host }}</span> + <span v-if="user.host || detail" style="opacity: 0.5;">@{{ user.host || host }}</span> </span> </template> @@ -14,7 +14,6 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { toUnicode } from 'punycode.js'; import { host as hostRaw } from '@@/js/config.js'; -import { defaultStore } from '@/store.js'; defineProps<{ user: Misskey.entities.UserLite; diff --git a/packages/frontend/src/components/global/MkAd.stories.impl.ts b/packages/frontend/src/components/global/MkAd.stories.impl.ts index 8c0b7ef52f..c5a928b5cf 100644 --- a/packages/frontend/src/components/global/MkAd.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAd.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect, userEvent, waitFor, within } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkAd from './MkAd.vue'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue index fc6c64d2aa..b29bbb6392 100644 --- a/packages/frontend/src/components/global/MkAd.vue +++ b/packages/frontend/src/components/global/MkAd.vue @@ -45,9 +45,10 @@ import { url as local, host } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import MkButton from '@/components/MkButton.vue'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import * as os from '@/os.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; +import { prefer } from '@/preferences.js'; type Ad = (typeof instance)['ads'][number]; @@ -66,7 +67,7 @@ const choseAd = (): Ad | null => { return props.specify; } - const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? { + const allAds = instance.ads.map(ad => store.s.mutedAds.includes(ad.id) ? { ...ad, ratio: 0, } : ad); @@ -107,12 +108,12 @@ const chosen = ref(choseAd()); const self = computed(() => chosen.value?.url.startsWith(local)); -const shouldHide = ref(!defaultStore.state.forceShowAds && $i && $i.policies.canHideAds && (props.specify == null)); +const shouldHide = ref(!prefer.s.forceShowAds && $i && $i.policies.canHideAds && (props.specify == null)); function reduceFrequency(): void { if (chosen.value == null) return; - if (defaultStore.state.mutedAds.includes(chosen.value.id)) return; - defaultStore.push('mutedAds', chosen.value.id); + if (store.s.mutedAds.includes(chosen.value.id)) return; + store.push('mutedAds', chosen.value.id); os.success(); chosen.value = choseAd(); showMenu.value = false; diff --git a/packages/frontend/src/components/global/MkAvatar.stories.impl.ts b/packages/frontend/src/components/global/MkAvatar.stories.impl.ts index 9d2de9f0be..84221842e9 100644 --- a/packages/frontend/src/components/global/MkAvatar.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAvatar.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { userDetailed } from '../../../.storybook/fakes.js'; import MkAvatar from './MkAvatar.vue'; const common = { diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index 1f78b068a2..603b6f12a1 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -35,6 +35,8 @@ SPDX-License-Identifier: AGPL-3.0-only zIndex: getDecorationZIndex(decoration), }" alt="" + draggable="false" + style="-webkit-user-drag: none;" > </template> </component> @@ -46,14 +48,13 @@ import * as Misskey from 'misskey-js'; import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js'; import MkImgWithBlurhash from '../MkImgWithBlurhash.vue'; import MkA from './MkA.vue'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; import { acct, userPage } from '@/filters/user.js'; import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; -const animation = ref(defaultStore.state.animation); -const squareAvatars = ref(defaultStore.state.squareAvatars); -const useBlurEffect = ref(defaultStore.state.useBlurEffect); +const animation = ref(prefer.s.animation); +const squareAvatars = ref(prefer.s.squareAvatars); const props = withDefaults(defineProps<{ user: Misskey.entities.User; @@ -76,7 +77,7 @@ const emit = defineEmits<{ (ev: 'click', v: MouseEvent): void; }>(); -const showDecoration = props.forceShowDecoration || defaultStore.state.showAvatarDecorations; +const showDecoration = props.forceShowDecoration || prefer.s.showAvatarDecorations; const bound = computed(() => props.link ? { to: userPage(props.user), target: props.target } @@ -84,7 +85,7 @@ const bound = computed(() => props.link const url = computed(() => { if (props.user.avatarUrl == null) return null; - if (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar) return getStaticImageUrl(props.user.avatarUrl); + if (prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar) return getStaticImageUrl(props.user.avatarUrl); return props.user.avatarUrl; }); @@ -94,7 +95,7 @@ function onClick(ev: MouseEvent): void { } function getDecorationUrl(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) { - if (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar) return getStaticImageUrl(decoration.url); + if (prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar) return getStaticImageUrl(decoration.url); return decoration.url; } diff --git a/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts b/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts index e15dcba760..15ae489ff8 100644 --- a/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts +++ b/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkCondensedLine from './MkCondensedLine.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts b/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts index 9e6177045d..eded13b686 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts +++ b/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkCustomEmoji from './MkCustomEmoji.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index 90fa522f3d..70af2c7962 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -9,6 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" src="/client-assets/dummy.png" :title="alt" + draggable="false" + style="-webkit-user-drag: none;" /> <span v-else-if="errored">:{{ customEmojiName }}:</span> <img @@ -18,6 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only :alt="alt" :title="alt" decoding="async" + draggable="false" @error="errored = true" @load="errored = false" @click="onClick" @@ -27,16 +30,16 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, defineAsyncComponent, inject, ref } from 'vue'; import type { MenuItem } from '@/types/menu.js'; -import { getProxiedImageUrl, getStaticImageUrl } from '@/scripts/media-proxy.js'; -import { defaultStore } from '@/store.js'; +import { getProxiedImageUrl, getStaticImageUrl } from '@/utility/media-proxy.js'; import { customEmojisMap } from '@/custom-emojis.js'; import * as os from '@/os.js'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import * as sound from '@/scripts/sound.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import * as sound from '@/utility/sound.js'; import { i18n } from '@/i18n.js'; import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ name: string; @@ -77,7 +80,7 @@ const url = computed(() => { false, true, ); - return defaultStore.reactiveState.disableShowingAnimatedImages.value + return prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(proxied) : proxied; }); @@ -99,7 +102,6 @@ function onClick(ev: MouseEvent) { icon: 'ti ti-copy', action: () => { copyToClipboard(`:${props.name}:`); - os.success(); }, }); @@ -159,6 +161,7 @@ async function edit(name: string) { .root { height: 2em; vertical-align: middle; + -webkit-user-drag: none; transition: transform 0.2s ease; &:hover { diff --git a/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts b/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts index 6a8fcf4fe3..dafdcbd13f 100644 --- a/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts +++ b/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import isChromatic from 'chromatic/isChromatic'; import MkEllipsis from './MkEllipsis.vue'; export const Default = { diff --git a/packages/frontend/src/components/global/MkEmoji.stories.impl.ts b/packages/frontend/src/components/global/MkEmoji.stories.impl.ts index 309c015757..1a394ca6bb 100644 --- a/packages/frontend/src/components/global/MkEmoji.stories.impl.ts +++ b/packages/frontend/src/components/global/MkEmoji.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkEmoji from './MkEmoji.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue index bd9b1d665a..432de24478 100644 --- a/packages/frontend/src/components/global/MkEmoji.vue +++ b/packages/frontend/src/components/global/MkEmoji.vue @@ -12,12 +12,12 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, inject } from 'vue'; import { colorizeEmoji, getEmojiName } from '@@/js/emojilist.js'; import { char2fluentEmojiFilePath, char2twemojiFilePath, char2tossfaceFilePath } from '@@/js/emoji-base.js'; -import { defaultStore } from '@/store.js'; +import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import * as sound from '@/scripts/sound.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import * as sound from '@/utility/sound.js'; import { i18n } from '@/i18n.js'; -import type { MenuItem } from '@/types/menu.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ emoji: string; @@ -27,9 +27,9 @@ const props = defineProps<{ const react = inject<((name: string) => void) | null>('react', null); -const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : defaultStore.state.emojiStyle === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath; +const char2path = prefer.s.emojiStyle === 'twemoji' ? char2twemojiFilePath : prefer.s.emojiStyle === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath; -const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native'); +const useOsNativeEmojis = computed(() => prefer.s.emojiStyle === 'native'); const url = computed(() => char2path(props.emoji)); const colorizedNativeEmoji = computed(() => colorizeEmoji(props.emoji)); @@ -52,7 +52,6 @@ function onClick(ev: MouseEvent) { icon: 'ti ti-copy', action: () => { copyToClipboard(props.emoji); - os.success(); }, }); diff --git a/packages/frontend/src/components/global/MkError.stories.impl.ts b/packages/frontend/src/components/global/MkError.stories.impl.ts index daef04cd87..e150493a18 100644 --- a/packages/frontend/src/components/global/MkError.stories.impl.ts +++ b/packages/frontend/src/components/global/MkError.stories.impl.ts @@ -6,7 +6,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; import { expect, waitFor } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkError from './MkError.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/global/MkError.stories.meta.ts b/packages/frontend/src/components/global/MkError.stories.meta.ts index cd7fada189..940b445e90 100644 --- a/packages/frontend/src/components/global/MkError.stories.meta.ts +++ b/packages/frontend/src/components/global/MkError.stories.meta.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Meta } from '@storybook/vue3'; +import type { Meta } from '@storybook/vue3'; import MkError from './MkError.vue'; export const argTypes = { diff --git a/packages/frontend/src/components/global/MkError.vue b/packages/frontend/src/components/global/MkError.vue index 77dddaff89..fadfbda7a6 100644 --- a/packages/frontend/src/components/global/MkError.vue +++ b/packages/frontend/src/components/global/MkError.vue @@ -4,9 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" appear> +<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear> <div :class="$style.root"> - <img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/> + <img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/> <p :class="$style.text"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</p> <MkButton :class="$style.button" @click="() => emit('retry')">{{ i18n.ts.retry }}</MkButton> </div> @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; import { serverErrorImageUrl } from '@/instance.js'; const emit = defineEmits<{ diff --git a/packages/frontend/src/components/global/MkFooterSpacer.vue b/packages/frontend/src/components/global/MkFooterSpacer.vue deleted file mode 100644 index 1a75855fa1..0000000000 --- a/packages/frontend/src/components/global/MkFooterSpacer.vue +++ /dev/null @@ -1,32 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div :class="[$style.spacer, defaultStore.reactiveState.darkMode.value ? $style.dark : $style.light]"></div> -</template> - -<script lang="ts" setup> -import { defaultStore } from '@/store.js'; -</script> - -<style lang="scss" module> -.spacer { - box-sizing: border-box; - padding: 32px; - margin: 0 auto; - height: 300px; - background-clip: content-box; - background-size: auto auto; - background-color: rgba(255, 255, 255, 0); - - &.light { - background-image: repeating-linear-gradient(135deg, transparent, transparent 16px, #00000010 16px, #00000010 20px ); - } - - &.dark { - background-image: repeating-linear-gradient(135deg, transparent, transparent 16px, #FFFFFF16 16px, #FFFFFF16 20px ); - } -} -</style> diff --git a/packages/frontend/src/components/global/MkLazy.vue b/packages/frontend/src/components/global/MkLazy.vue index 29908f303d..c2be975c8c 100644 --- a/packages/frontend/src/components/global/MkLazy.vue +++ b/packages/frontend/src/components/global/MkLazy.vue @@ -11,9 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted, onActivated, onBeforeUnmount, ref, shallowRef } from 'vue'; +import { nextTick, onMounted, onActivated, onBeforeUnmount, ref, useTemplateRef } from 'vue'; -const rootEl = shallowRef<HTMLDivElement>(); +const rootEl = useTemplateRef('rootEl'); const showing = ref(false); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/global/MkLoading.stories.impl.ts b/packages/frontend/src/components/global/MkLoading.stories.impl.ts index c781ad0479..8313f73e4b 100644 --- a/packages/frontend/src/components/global/MkLoading.stories.impl.ts +++ b/packages/frontend/src/components/global/MkLoading.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import isChromatic from 'chromatic/isChromatic'; import MkLoading from './MkLoading.vue'; export const Default = { diff --git a/packages/frontend/src/components/global/MkMfm.stories.impl.ts b/packages/frontend/src/components/global/MkMfm.stories.impl.ts index 1daf7a29cb..98da531ed4 100644 --- a/packages/frontend/src/components/global/MkMfm.stories.impl.ts +++ b/packages/frontend/src/components/global/MkMfm.stories.impl.ts @@ -2,8 +2,8 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - -import { StoryObj } from '@storybook/vue3'; + +import type { StoryObj } from '@storybook/vue3'; import { expect, within } from '@storybook/test'; import MkMfm from './MkMfm.js'; export const Default = { diff --git a/packages/frontend/src/components/global/MkMfm.ts b/packages/frontend/src/components/global/MkMfm.ts index 9785bc0f07..424150e9a3 100644 --- a/packages/frontend/src/components/global/MkMfm.ts +++ b/packages/frontend/src/components/global/MkMfm.ts @@ -3,10 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { VNode, h, defineAsyncComponent, SetupContext } from 'vue'; -import * as mfm from '@transfem-org/sfm-js'; +import { h, defineAsyncComponent } from 'vue'; +import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { host } from '@@/js/config.js'; +import type { VNode, SetupContext } from 'vue'; +import type { MkABehavior } from '@/components/global/MkA.vue'; import CkFollowMouse from '../CkFollowMouse.vue'; import MkUrl from '@/components/global/MkUrl.vue'; import MkTime from '@/components/global/MkTime.vue'; @@ -18,8 +20,8 @@ import MkCode from '@/components/MkCode.vue'; import MkCodeInline from '@/components/MkCodeInline.vue'; import MkGoogle from '@/components/MkGoogle.vue'; import MkSparkle from '@/components/MkSparkle.vue'; -import MkA, { MkABehavior } from '@/components/global/MkA.vue'; -import { defaultStore } from '@/store.js'; +import MkA from '@/components/global/MkA.vue'; +import { prefer } from '@/preferences.js'; function safeParseFloat(str: unknown): number | null { if (typeof str !== 'string' || str === '') return null; @@ -76,13 +78,12 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven return t.match(/^\-?[0-9.]+s$/) ? t : null; }; - const useAnim = defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : props.isAnim ? true : false; - const validColor = (c: unknown): string | null => { if (typeof c !== 'string') return null; return c.match(/^[0-9a-f]{3,6}$/i) ? c : null; }; + const useAnim = prefer.s.advancedMfm && prefer.s.animatedMfm; const isBlock = props.isBlock ?? false; const SkFormula = defineAsyncComponent(() => import('@/components/SkFormula.vue')); @@ -192,17 +193,17 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven } case 'x2': { return h('span', { - class: defaultStore.state.advancedMfm ? 'mfm-x2' : '', + class: prefer.s.advancedMfm ? 'mfm-x2' : '', }, genEl(token.children, scale * 2)); } case 'x3': { return h('span', { - class: defaultStore.state.advancedMfm ? 'mfm-x3' : '', + class: prefer.s.advancedMfm ? 'mfm-x3' : '', }, genEl(token.children, scale * 3)); } case 'x4': { return h('span', { - class: defaultStore.state.advancedMfm ? 'mfm-x4' : '', + class: prefer.s.advancedMfm ? 'mfm-x4' : '', }, genEl(token.children, scale * 4)); } case 'font': { @@ -282,7 +283,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven }, genEl(token.children, scale)); } case 'position': { - if (!defaultStore.state.advancedMfm) break; + if (!prefer.s.advancedMfm) break; const x = safeParseFloat(token.props.args.x) ?? 0; const y = safeParseFloat(token.props.args.y) ?? 0; style = `transform: translateX(${x}em) translateY(${y}em);`; @@ -305,7 +306,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven break; } case 'scale': { - if (!defaultStore.state.advancedMfm) { + if (!prefer.s.advancedMfm) { style = ''; break; } diff --git a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts index 1d079edd2c..c9af5f4ea4 100644 --- a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts +++ b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { waitFor } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkPageHeader from './MkPageHeader.vue'; export const Empty = { render(args) { diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue index ffa6f13ff6..832c7887cb 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -8,13 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.tabsInner"> <button v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" - class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: defaultStore.reactiveState.animation.value }]" + class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: prefer.s.animation }]" @mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)" > <div :class="$style.tabInner"> <i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i> <div - v-if="!t.iconOnly || (!defaultStore.reactiveState.animation.value && t.key === tab)" + v-if="!t.iconOnly || (!prefer.s.animation && t.key === tab)" :class="$style.tabTitle" > {{ t.title }} @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div ref="tabHighlightEl" - :class="[$style.tabHighlight, { [$style.animate]: defaultStore.reactiveState.animation.value }]" + :class="[$style.tabHighlight, { [$style.animate]: prefer.s.animation }]" ></div> </div> </template> @@ -41,20 +41,20 @@ export type Tab = { onClick?: (ev: MouseEvent) => void; } & ( | { - iconOnly?: false; - title: string; - icon?: string; - } + iconOnly?: false; + title: string; + icon?: string; + } | { - iconOnly: true; - icon: string; - } + iconOnly: true; + icon: string; + } ); </script> <script lang="ts" setup> -import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue'; -import { defaultStore } from '@/store.js'; +import { nextTick, onMounted, onUnmounted, useTemplateRef, watch } from 'vue'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ tabs?: Tab[]; @@ -69,9 +69,9 @@ const emit = defineEmits<{ (ev: 'tabClick', key: string); }>(); -const el = shallowRef<HTMLElement | null>(null); +const el = useTemplateRef('el'); +const tabHighlightEl = useTemplateRef('tabHighlightEl'); const tabRefs: Record<string, HTMLElement | null> = {}; -const tabHighlightEl = shallowRef<HTMLElement | null>(null); function onTabMousedown(tab: Tab, ev: MouseEvent): void { // ユーザビリティの観点からmousedown時にはonClickは呼ばない @@ -170,7 +170,7 @@ onMounted(() => { if (props.rootEl) { ro2 = new ResizeObserver((entries, observer) => { - if (document.body.contains(el.value as HTMLElement)) { + if (window.document.body.contains(el.value as HTMLElement)) { nextTick(() => renderTab()); } }); diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index 1a424f349f..54ce0f85eb 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -50,15 +50,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, ref, inject, shallowRef, computed } from 'vue'; +import { onMounted, onUnmounted, ref, inject, useTemplateRef, computed } from 'vue'; import tinycolor from 'tinycolor2'; -import XTabs, { Tab } from './MkPageHeader.tabs.vue'; import { scrollToTop } from '@@/js/scroll.js'; -import { globalEvents } from '@/events.js'; -import { injectReactiveMetadata } from '@/scripts/page-metadata.js'; -import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js'; +import XTabs from './MkPageHeader.tabs.vue'; +import type { Tab } from './MkPageHeader.tabs.vue'; import type { PageHeaderItem } from '@/types/page-header.js'; -import type { PageMetadata } from '@/scripts/page-metadata.js'; +import type { PageMetadata } from '@/page.js'; +import { globalEvents } from '@/events.js'; +import { openAccountMenu as openAccountMenu_ } from '@/accounts.js'; +import { $i } from '@/i.js'; +import { DI } from '@/di.js'; const props = withDefaults(defineProps<{ overridePageMetadata?: PageMetadata; @@ -79,13 +81,15 @@ const emit = defineEmits<{ const displayBackButton = props.displayBackButton && history.state.key !== 'index' && history.length > 1 && inject('shouldBackButton', true); -const injectedPageMetadata = injectReactiveMetadata(); +const viewId = inject(DI.viewId); +const viewTransitionName = computed(() => `${viewId}---pageHeader`); +const injectedPageMetadata = inject(DI.pageMetadata); const pageMetadata = computed(() => props.overridePageMetadata ?? injectedPageMetadata.value); const hideTitle = computed(() => inject('shouldOmitHeaderTitle', false) || props.hideTitle); const thin_ = props.thin || inject('shouldHeaderThin', false); -const el = shallowRef<HTMLElement | undefined>(undefined); +const el = useTemplateRef('el'); const bg = ref<string | undefined>(undefined); const narrow = ref(false); const hasTabs = computed(() => props.tabs.length > 0); @@ -120,7 +124,7 @@ function goBack(): void { const calcBg = () => { const rawBg = 'var(--MI_THEME-bg)'; - const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); + const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(window.document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); tinyBg.setAlpha(0.85); bg.value = tinyBg.toRgbString(); }; @@ -129,12 +133,12 @@ let ro: ResizeObserver | null; onMounted(() => { calcBg(); - globalEvents.on('themeChanged', calcBg); + globalEvents.on('themeChanging', calcBg); if (el.value && el.value.parentElement) { narrow.value = el.value.parentElement.offsetWidth < 500; ro = new ResizeObserver((entries, observer) => { - if (el.value && el.value.parentElement && document.body.contains(el.value as HTMLElement)) { + if (el.value && el.value.parentElement && window.document.body.contains(el.value as HTMLElement)) { narrow.value = el.value.parentElement.offsetWidth < 500; } }); @@ -143,7 +147,7 @@ onMounted(() => { }); onUnmounted(() => { - globalEvents.off('themeChanged', calcBg); + globalEvents.off('themeChanging', calcBg); if (ro) ro.disconnect(); }); </script> @@ -154,6 +158,7 @@ onUnmounted(() => { backdrop-filter: var(--MI-blur, blur(15px)); border-bottom: solid 0.5px var(--MI_THEME-divider); width: 100%; + view-transition-name: v-bind(viewTransitionName); } .upper, diff --git a/packages/frontend/src/components/global/MkSpacer.vue b/packages/frontend/src/components/global/MkSpacer.vue index db01c10eb0..6080bad9cd 100644 --- a/packages/frontend/src/components/global/MkSpacer.vue +++ b/packages/frontend/src/components/global/MkSpacer.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { inject } from 'vue'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { deviceKind } from '@/utility/device-kind.js'; const props = withDefaults(defineProps<{ contentMax?: number | null; diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue index 1aebf487bb..05245716c2 100644 --- a/packages/frontend/src/components/global/MkStickyContainer.vue +++ b/packages/frontend/src/components/global/MkStickyContainer.vue @@ -22,9 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, provide, inject, Ref, ref, watch, useTemplateRef } from 'vue'; - -import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@@/js/const.js'; +import { onMounted, onUnmounted, provide, inject, ref, watch, useTemplateRef } from 'vue'; +import { DI } from '@/di.js'; const rootEl = useTemplateRef('rootEl'); const headerEl = useTemplateRef('headerEl'); @@ -32,13 +31,13 @@ const footerEl = useTemplateRef('footerEl'); const headerHeight = ref<string | undefined>(); const childStickyTop = ref(0); -const parentStickyTop = inject<Ref<number>>(CURRENT_STICKY_TOP, ref(0)); -provide(CURRENT_STICKY_TOP, childStickyTop); +const parentStickyTop = inject(DI.currentStickyTop, ref(0)); +provide(DI.currentStickyTop, childStickyTop); const footerHeight = ref<string | undefined>(); const childStickyBottom = ref(0); -const parentStickyBottom = inject<Ref<number>>(CURRENT_STICKY_BOTTOM, ref(0)); -provide(CURRENT_STICKY_BOTTOM, childStickyBottom); +const parentStickyBottom = inject(DI.currentStickyBottom, ref(0)); +provide(DI.currentStickyBottom, childStickyBottom); const calc = () => { // コンポーネントが表示されてないけどKeepAliveで残ってる場合などは null になる diff --git a/packages/frontend/src/components/global/MkTime.stories.impl.ts b/packages/frontend/src/components/global/MkTime.stories.impl.ts index ccf7f200b5..5e62c3fbab 100644 --- a/packages/frontend/src/components/global/MkTime.stories.impl.ts +++ b/packages/frontend/src/components/global/MkTime.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkTime from './MkTime.vue'; import { i18n } from '@/i18n.js'; import { dateTimeFormat } from '@@/js/intl-const.js'; diff --git a/packages/frontend/src/components/global/MkUrl.stories.impl.ts b/packages/frontend/src/components/global/MkUrl.stories.impl.ts index 34a4adfe49..ea02fdfdd0 100644 --- a/packages/frontend/src/components/global/MkUrl.stories.impl.ts +++ b/packages/frontend/src/components/global/MkUrl.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect, userEvent, waitFor, within } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../../.storybook/mocks.js'; import MkUrl from './MkUrl.vue'; diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index 5196a63635..3a3e73978b 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -31,9 +31,9 @@ import { defineAsyncComponent, ref } from 'vue'; import { toUnicode as decodePunycode } from 'punycode.js'; import { url as local } from '@@/js/config.js'; import * as os from '@/os.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; +import { useTooltip } from '@/use/use-tooltip.js'; import { isEnabledUrlPreview } from '@/instance.js'; -import { MkABehavior } from '@/components/global/MkA.vue'; +import type { MkABehavior } from '@/components/global/MkA.vue'; import { warningExternalWebsite } from '@/scripts/warning-external-website.js'; function safeURIDecode(str: string): string { diff --git a/packages/frontend/src/components/global/MkUserName.stories.impl.ts b/packages/frontend/src/components/global/MkUserName.stories.impl.ts index e39061c291..b46c91c903 100644 --- a/packages/frontend/src/components/global/MkUserName.stories.impl.ts +++ b/packages/frontend/src/components/global/MkUserName.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { userDetailed } from '../../../.storybook/fakes.js'; import MkUserName from './MkUserName.vue'; export const Default = { diff --git a/packages/frontend/src/components/global/NestedRouterView.vue b/packages/frontend/src/components/global/NestedRouterView.vue new file mode 100644 index 0000000000..af00347db8 --- /dev/null +++ b/packages/frontend/src/components/global/NestedRouterView.vue @@ -0,0 +1,60 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<Suspense :timeout="0"> + <component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/> + + <template #fallback> + <MkLoading/> + </template> +</Suspense> +</template> + +<script lang="ts" setup> +import { inject, provide, ref, shallowRef } from 'vue'; +import type { Router } from '@/router.js'; +import type { PathResolvedResult } from '@/lib/nirax.js'; +import MkLoadingPage from '@/pages/_loading_.vue'; +import { DI } from '@/di.js'; + +const props = defineProps<{ + router?: Router; +}>(); + +const router = props.router ?? inject(DI.router); + +if (router == null) { + throw new Error('no router provided'); +} + +const currentDepth = inject(DI.routerCurrentDepth, 0); +provide(DI.routerCurrentDepth, currentDepth + 1); + +function resolveNested(current: PathResolvedResult, d = 0): PathResolvedResult | null { + if (d === currentDepth) { + return current; + } else { + if (current.child) { + return resolveNested(current.child, d + 1); + } else { + return null; + } + } +} + +const current = resolveNested(router.current)!; +const currentPageComponent = shallowRef('component' in current.route ? current.route.component : MkLoadingPage); +const currentPageProps = ref(current.props); +const key = ref(router.getCurrentFullPath()); + +router.useListener('change', ({ resolved }) => { + const current = resolveNested(resolved); + if (current == null || 'redirect' in current.route) return; + currentPageComponent.value = current.route.component; + currentPageProps.value = current.props; + key.value = router.getCurrentFullPath(); +}); +</script> diff --git a/packages/frontend/src/components/global/PageWithAnimBg.vue b/packages/frontend/src/components/global/PageWithAnimBg.vue new file mode 100644 index 0000000000..a00b196a04 --- /dev/null +++ b/packages/frontend/src/components/global/PageWithAnimBg.vue @@ -0,0 +1,21 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div> + <MkAnimBg style="position: absolute;"/> + <div class="_pageScrollable" style="position: absolute; top: 0; width: 100%; height: 100%;"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts" setup> +import MkAnimBg from '@/components/MkAnimBg.vue'; +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue new file mode 100644 index 0000000000..fb813689ba --- /dev/null +++ b/packages/frontend/src/components/global/PageWithHeader.vue @@ -0,0 +1,44 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[$style.root, reversed ? '_pageScrollableReversed' : '_pageScrollable']"> + <MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="actions" :tabs="tabs"/></template> + <div :class="$style.body"> + <slot></slot> + </div> + <template #footer><slot name="footer"></slot></template> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import type { PageHeaderItem } from '@/types/page-header.js'; +import type { Tab } from './MkPageHeader.tabs.vue'; + +const props = withDefaults(defineProps<{ + tabs?: Tab[]; + actions?: PageHeaderItem[] | null; + thin?: boolean; + hideTitle?: boolean; + displayMyAvatar?: boolean; + reversed?: boolean; +}>(), { + tabs: () => ([] as Tab[]), +}); + +const tab = defineModel<string>('tab'); +</script> + +<style lang="scss" module> +.root { + +} + +.body { + min-height: calc(100cqh - (var(--MI-stickyTop, 0px) + var(--MI-stickyBottom, 0px))); +} +</style> diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue index 38bdfc52d4..1c0c35f34e 100644 --- a/packages/frontend/src/components/global/RouterView.vue +++ b/packages/frontend/src/components/global/RouterView.vue @@ -4,98 +4,108 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<KeepAlive - :max="defaultStore.state.numberOfPageCache" - :exclude="pageCacheController" -> - <Suspense :timeout="0"> - <component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/> +<div ref="rootEl" class="_pageContainer" :class="$style.root"> + <KeepAlive :max="prefer.s.numberOfPageCache"> + <Suspense :timeout="0"> + <component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/> - <template #fallback> - <MkLoading/> - </template> - </Suspense> -</KeepAlive> + <template #fallback> + <MkLoading/> + </template> + </Suspense> + </KeepAlive> +</div> </template> <script lang="ts" setup> -import { inject, onBeforeUnmount, provide, ref, shallowRef, computed, nextTick } from 'vue'; -import { IRouter, Resolved, RouteDef } from '@/nirax.js'; -import { defaultStore } from '@/store.js'; -import { globalEvents } from '@/events.js'; +import { inject, nextTick, onMounted, provide, ref, shallowRef, useTemplateRef } from 'vue'; +import type { Router } from '@/router.js'; +import { prefer } from '@/preferences.js'; import MkLoadingPage from '@/pages/_loading_.vue'; +import { DI } from '@/di.js'; +import { randomId } from '@/utility/random-id.js'; +import { deepEqual } from '@/utility/deep-equal.js'; const props = defineProps<{ - router?: IRouter; - nested?: boolean; + router?: Router; }>(); -const router = props.router ?? inject('router'); +const router = props.router ?? inject(DI.router); if (router == null) { throw new Error('no router provided'); } -const currentDepth = inject('routerCurrentDepth', 0); -provide('routerCurrentDepth', currentDepth + 1); +const viewId = randomId(); +provide(DI.viewId, viewId); -function resolveNested(current: Resolved, d = 0): Resolved | null { - if (!props.nested) return current; +const currentDepth = inject(DI.routerCurrentDepth, 0); +provide(DI.routerCurrentDepth, currentDepth + 1); - if (d === currentDepth) { - return current; - } else { - if (current.child) { - return resolveNested(current.child, d + 1); - } else { - return null; - } - } +const rootEl = useTemplateRef('rootEl'); +onMounted(() => { + rootEl.value.style.viewTransitionName = viewId; // view-transition-nameにcss varが使えないっぽいため直接代入 +}); + +// view-transition-newなどの<pt-name-selector>にはcss varが使えず、v-bindできないため直接スタイルを生成 +const viewTransitionStylesTag = window.document.createElement('style'); +viewTransitionStylesTag.textContent = ` +@keyframes ${viewId}-old { + to { transform: scale(0.95); opacity: 0; } } -const current = resolveNested(router.current)!; -const currentPageComponent = shallowRef('component' in current.route ? current.route.component : MkLoadingPage); -const currentPageProps = ref(current.props); -const key = ref(router.getCurrentKey() + JSON.stringify(Object.fromEntries(current.props))); +@keyframes ${viewId}-new { + from { transform: scale(0.95); opacity: 0; } +} -function onChange({ resolved, key: newKey }) { - const current = resolveNested(resolved); - if (current == null || 'redirect' in current.route) return; - currentPageComponent.value = current.route.component; - currentPageProps.value = current.props; - key.value = newKey + JSON.stringify(Object.fromEntries(current.props)); +::view-transition-old(${viewId}) { + animation-duration: 0.2s; + animation-name: ${viewId}-old; +} - nextTick(() => { - // ページ遷移完了後に再びキャッシュを有効化 - if (clearCacheRequested.value) { - clearCacheRequested.value = false; - } - }); +::view-transition-new(${viewId}) { + animation-duration: 0.2s; + animation-name: ${viewId}-new; } +`; -router.addListener('change', onChange); +window.document.head.appendChild(viewTransitionStylesTag); -// #region キャッシュ制御 +const current = router.current!; +const currentPageComponent = shallowRef('component' in current.route ? current.route.component : MkLoadingPage); +const currentPageProps = ref(current.props); +let currentRoutePath = current.route.path; +const key = ref(router.getCurrentFullPath()); -/** - * キャッシュクリアが有効になったら、全キャッシュをクリアする - * - * keepAlive側にwatcherがあるのですぐ消えるとはおもうけど、念のためページ遷移完了まではキャッシュを無効化しておく。 - * キャッシュ有効時向けにexcludeを使いたい場合は、pageCacheControllerに並列に突っ込むのではなく、下に追記すること - */ -const pageCacheController = computed(() => clearCacheRequested.value ? /.*/ : undefined); -const clearCacheRequested = ref(false); +router.useListener('change', ({ resolved }) => { + if (resolved == null || 'redirect' in resolved.route) return; + if (resolved.route.path === currentRoutePath && deepEqual(resolved.props, currentPageProps.value)) return; -globalEvents.on('requestClearPageCache', () => { - if (_DEV_) console.log('clear page cache requested'); - if (!clearCacheRequested.value) { - clearCacheRequested.value = true; + function _() { + currentPageComponent.value = resolved.route.component; + currentPageProps.value = resolved.props; + key.value = router.getCurrentFullPath(); + currentRoutePath = resolved.route.path; } -}); - -// #endregion -onBeforeUnmount(() => { - router.removeListener('change', onChange); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (prefer.s.animation && window.document.startViewTransition) { + window.document.startViewTransition(() => new Promise((res) => { + _(); + nextTick(() => { + res(); + //setTimeout(res, 100); + }); + })); + } else { + _(); + } }); </script> + +<style lang="scss" module> +.root { + height: 100%; + background-color: var(--MI_THEME-bg); +} +</style> diff --git a/packages/frontend/src/components/global/SearchKeyword.vue b/packages/frontend/src/components/global/SearchKeyword.vue new file mode 100644 index 0000000000..27a284faf0 --- /dev/null +++ b/packages/frontend/src/components/global/SearchKeyword.vue @@ -0,0 +1,14 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<slot></slot> +</template> + +<script lang="ts" setup> +</script> + +<style lang="scss" module> +</style> diff --git a/packages/frontend/src/components/global/SearchLabel.vue b/packages/frontend/src/components/global/SearchLabel.vue new file mode 100644 index 0000000000..27a284faf0 --- /dev/null +++ b/packages/frontend/src/components/global/SearchLabel.vue @@ -0,0 +1,14 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<slot></slot> +</template> + +<script lang="ts" setup> +</script> + +<style lang="scss" module> +</style> diff --git a/packages/frontend/src/components/global/SearchMarker.vue b/packages/frontend/src/components/global/SearchMarker.vue new file mode 100644 index 0000000000..061ce3f47d --- /dev/null +++ b/packages/frontend/src/components/global/SearchMarker.vue @@ -0,0 +1,116 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div ref="root" :class="[$style.root, { [$style.highlighted]: highlighted }]"> + <slot></slot> +</div> +</template> + +<script lang="ts" setup> +import { + onActivated, + onDeactivated, + onMounted, + onBeforeUnmount, + watch, + computed, + ref, + useTemplateRef, + inject, +} from 'vue'; +import type { Ref } from 'vue'; + +const props = defineProps<{ + markerId?: string; + label?: string; + icon?: string; + keywords?: string[]; + children?: string[]; + inlining?: string[]; +}>(); + +const rootEl = useTemplateRef('root'); +const rootElMutationObserver = new MutationObserver(() => { + checkChildren(); +}); +const injectedSearchMarkerId = inject<Ref<string | null> | null>('inAppSearchMarkerId', null); +const searchMarkerId = computed(() => injectedSearchMarkerId?.value ?? window.location.hash.slice(1)); +const highlighted = ref(props.markerId === searchMarkerId.value); + +function checkChildren() { + if (props.children?.includes(searchMarkerId.value)) { + const el = window.document.querySelector(`[data-in-app-search-marker-id="${searchMarkerId.value}"]`); + highlighted.value = el == null; + } +} + +watch([ + searchMarkerId, + () => props.children, +], () => { + if (props.children != null && props.children.length > 0) { + checkChildren(); + } +}, { flush: 'post' }); + +function init() { + checkChildren(); + + if (highlighted.value) { + rootEl.value?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } + + if (rootEl.value != null) { + rootElMutationObserver.observe(rootEl.value, { + childList: true, + subtree: true, + }); + } +} + +function dispose() { + rootElMutationObserver.disconnect(); +} + +onMounted(init); +onActivated(init); +onDeactivated(dispose); +onBeforeUnmount(dispose); +</script> + +<style lang="scss" module> +.root { + position: relative; +} + +.highlighted { + &::after { + content: ''; + position: absolute; + top: -8px; + left: -8px; + width: calc(100% + 16px); + height: calc(100% + 16px); + border-radius: 6px; + animation: blink 1s 3.5; + pointer-events: none; + } +} + +@keyframes blink { + 0%, 100% { + background: color(from var(--MI_THEME-accent) srgb r g b / 0.05); + border: 1px solid color(from var(--MI_THEME-accent) srgb r g b / 0.7); + } + 50% { + background: transparent; + border: 1px solid transparent; + } +} +</style> diff --git a/packages/frontend/src/components/global/StackingRouterView.vue b/packages/frontend/src/components/global/StackingRouterView.vue new file mode 100644 index 0000000000..c95c74aef3 --- /dev/null +++ b/packages/frontend/src/components/global/StackingRouterView.vue @@ -0,0 +1,243 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<TransitionGroup + :enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''" + :moveClass="prefer.s.animation ? $style.transition_x_move : ''" + :duration="200" + tag="div" :class="$style.tabs" +> + <div v-for="(tab, i) in tabs" :key="tab.fullPath" :class="$style.tab" :style="{ '--i': i - 1 }"> + <div v-if="i > 0" :class="$style.tabBg" @click="back()"></div> + <div :class="$style.tabFg" @click.stop="back()"> + <div v-if="i > 0" :class="$style.tabMenu"> + <svg :class="$style.tabMenuShape" viewBox="0 0 24 16"> + <g transform="matrix(2.04108e-17,-0.333333,0.222222,1.36072e-17,21.3333,15.9989)"> + <path d="M23.997,-42C47.903,-23.457 47.997,12 47.997,12L-0.003,12L-0.003,-96C-0.003,-96 0.091,-60.543 23.997,-42Z" style="fill:var(--MI_THEME-panel);"/> + </g> + </svg> + <button :class="$style.tabMenuButton" class="_button" @click.stop="mount"><i class="ti ti-rectangle"></i></button> + <button :class="$style.tabMenuButton" class="_button" @click.stop="back"><i class="ti ti-x"></i></button> + </div> + <div v-if="i > 0" :class="$style.tabBorder"></div> + <div :class="$style.tabContent" class="_pageContainer" @click.stop=""> + <Suspense :timeout="0"> + <component :is="tab.component" v-bind="Object.fromEntries(tab.props)"/> + + <template #fallback> + <MkLoading/> + </template> + </Suspense> + </div> + </div> + </div> +</TransitionGroup> +</template> + +<script lang="ts" setup> +import { inject, provide, shallowRef } from 'vue'; +import type { Router } from '@/router.js'; +import { prefer } from '@/preferences.js'; +import MkLoadingPage from '@/pages/_loading_.vue'; +import { DI } from '@/di.js'; +import { deepEqual } from '@/utility/deep-equal.js'; + +const props = defineProps<{ + router?: Router; +}>(); + +const router = props.router ?? inject(DI.router); + +if (router == null) { + throw new Error('no router provided'); +} + +const currentDepth = inject(DI.routerCurrentDepth, 0); +provide(DI.routerCurrentDepth, currentDepth + 1); + +const tabs = shallowRef([{ + fullPath: router.getCurrentFullPath(), + routePath: router.current.route.path, + component: 'component' in router.current.route ? router.current.route.component : MkLoadingPage, + props: router.current.props, +}]); + +function mount() { + const currentTab = tabs.value[tabs.value.length - 1]; + tabs.value = [currentTab]; +} + +function back() { + const prev = tabs.value[tabs.value.length - 2]; + tabs.value = [...tabs.value.slice(0, tabs.value.length - 1)]; + router.replace(prev.fullPath); +} + +router.useListener('change', ({ resolved }) => { + const currentTab = tabs.value[tabs.value.length - 1]; + const routePath = resolved.route.path; + if (resolved == null || 'redirect' in resolved.route) return; + if (resolved.route.path === currentTab.routePath && deepEqual(resolved.props, currentTab.props)) return; + const fullPath = router.getCurrentFullPath(); + + if (tabs.value.some(tab => tab.routePath === routePath && deepEqual(resolved.props, tab.props))) { + const newTabs = []; + for (const tab of tabs.value) { + newTabs.push(tab); + + if (tab.routePath === routePath && deepEqual(resolved.props, tab.props)) { + break; + } + } + tabs.value = newTabs; + return; + } + + tabs.value = tabs.value.length >= prefer.s.numberOfPageCache ? [ + ...tabs.value.slice(1), + { + fullPath: fullPath, + routePath, + component: resolved.route.component, + props: resolved.props, + }, + ] : [...tabs.value, { + fullPath: fullPath, + routePath, + component: resolved.route.component, + props: resolved.props, + }]; +}); + +router.useListener('replace', ({ fullPath }) => { + const currentTab = tabs.value[tabs.value.length - 1]; + currentTab.fullPath = fullPath; + tabs.value = [...tabs.value.slice(0, tabs.value.length - 1), currentTab]; +}); +</script> + +<style lang="scss" module> +.transition_x_move, +.transition_x_enterActive, +.transition_x_leaveActive { + .tabBg { + transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important; + } + + .tabFg { + transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important; + } +} +.transition_x_enterFrom, +.transition_x_leaveTo { + .tabBg { + opacity: 0; + } + + .tabFg { + opacity: 0; + transform: translateY(100px); + } +} +.transition_x_leaveActive { + .tabFg { + //position: absolute; + } +} + +.tabs { + position: relative; + width: 100%; + height: 100%; +} + +.tab { + overflow: clip; + + &:first-child { + position: relative; + width: 100%; + height: 100%; + + .tabFg { + position: relative; + width: 100%; + height: 100%; + } + + .tabContent { + position: relative; + width: 100%; + height: 100%; + } + } + + &:not(:first-child) { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + + .tabBg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #0003; + -webkit-backdrop-filter: var(--MI-blur, blur(3px)); + backdrop-filter: var(--MI-blur, blur(3px)); + } + + .tabFg { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: calc(100% - (10px + (20px * var(--i)))); + display: flex; + flex-direction: column; + } + + .tabContent { + flex: 1; + width: 100%; + height: 100%; + background: var(--MI_THEME-bg); + } + } +} + +.tabMenu { + position: relative; + margin-left: auto; + padding: 0 4px; + background: var(--MI_THEME-panel); +} + +.tabMenuShape { + position: absolute; + bottom: -1px; + left: -100%; + height: calc(100% + 1px); + width: 129%; + pointer-events: none; +} + +.tabBorder { + height: 6px; + background: var(--MI_THEME-panel); +} + +.tabMenuButton { + padding: 8px; + font-size: 13px; +} +</style> diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue index e473b7c1af..7c8a5d64d7 100644 --- a/packages/frontend/src/components/grid/MkDataCell.vue +++ b/packages/frontend/src/components/grid/MkDataCell.vue @@ -39,10 +39,12 @@ SPDX-License-Identifier: AGPL-3.0-only {{ cell.value }} </div> <div v-else-if="cellType === 'boolean'"> - <div :class="[$style.bool, { - [$style.boolTrue]: cell.value === true, - 'ti ti-check': cell.value === true, - }]"></div> + <div + :class="[$style.bool, { + [$style.boolTrue]: cell.value === true, + 'ti ti-check': cell.value === true, + }]" + ></div> </div> <div v-else-if="cellType === 'image'"> <img @@ -88,13 +90,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, shallowRef, toRefs, watch } from 'vue'; -import { GridEventEmitter, Size } from '@/components/grid/grid.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; +import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, useTemplateRef, toRefs, watch } from 'vue'; +import type { Size } from '@/components/grid/grid.js'; +import type { CellValue, GridCell } from '@/components/grid/cell.js'; +import type { GridRowSetting } from '@/components/grid/row.js'; +import { GridEventEmitter } from '@/components/grid/grid.js'; +import { useTooltip } from '@/use/use-tooltip.js'; import * as os from '@/os.js'; -import { CellValue, GridCell } from '@/components/grid/cell.js'; import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js'; -import { GridRowSetting } from '@/components/grid/row.js'; const emit = defineEmits<{ (ev: 'operation:beginEdit', sender: GridCell): void; @@ -110,9 +113,9 @@ const props = defineProps<{ const { cell, bus } = toRefs(props); -const rootEl = shallowRef<InstanceType<typeof HTMLTableCellElement>>(); -const contentAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>(); -const inputAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>(); +const rootEl = useTemplateRef('rootEl'); +const contentAreaEl = useTemplateRef('contentAreaEl'); +const inputAreaEl = useTemplateRef('inputAreaEl'); /** 値が編集中かどうか */ const editing = ref<boolean>(false); diff --git a/packages/frontend/src/components/grid/MkDataRow.vue b/packages/frontend/src/components/grid/MkDataRow.vue index 280a14bc4a..a35f93b435 100644 --- a/packages/frontend/src/components/grid/MkDataRow.vue +++ b/packages/frontend/src/components/grid/MkDataRow.vue @@ -37,11 +37,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { GridEventEmitter, Size } from '@/components/grid/grid.js'; +import { GridEventEmitter } from '@/components/grid/grid.js'; import MkDataCell from '@/components/grid/MkDataCell.vue'; import MkNumberCell from '@/components/grid/MkNumberCell.vue'; -import { CellValue, GridCell } from '@/components/grid/cell.js'; -import { GridRow, GridRowSetting } from '@/components/grid/row.js'; +import type { Size } from '@/components/grid/grid.js'; +import type { CellValue, GridCell } from '@/components/grid/cell.js'; +import type { GridRow, GridRowSetting } from '@/components/grid/row.js'; const emit = defineEmits<{ (ev: 'operation:beginEdit', sender: GridCell): void; diff --git a/packages/frontend/src/components/grid/MkGrid.stories.impl.ts b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts index 5801012f15..f85bf146e8 100644 --- a/packages/frontend/src/components/grid/MkGrid.stories.impl.ts +++ b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts @@ -5,14 +5,14 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { ref } from 'vue'; import { commonHandlers } from '../../../.storybook/mocks.js'; import { boolean, choose, country, date, firstName, integer, lastName, text } from '../../../.storybook/fake-utils.js'; import MkGrid from './MkGrid.vue'; -import { GridContext, GridEvent } from '@/components/grid/grid-event.js'; -import { DataSource, GridSetting } from '@/components/grid/grid.js'; -import { GridColumnSetting } from '@/components/grid/column.js'; +import type { GridContext, GridEvent } from '@/components/grid/grid-event.js'; +import type { DataSource, GridSetting } from '@/components/grid/grid.js'; +import type { GridColumnSetting } from '@/components/grid/column.js'; function d(p: { check?: boolean, diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue index 4dbd4ebcae..94f4f3dab1 100644 --- a/packages/frontend/src/components/grid/MkGrid.vue +++ b/packages/frontend/src/components/grid/MkGrid.vue @@ -50,11 +50,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import { computed, onMounted, ref, toRefs, watch } from 'vue'; -import { DataSource, GridEventEmitter, GridSetting, GridState, Size } from '@/components/grid/grid.js'; +import { GridEventEmitter } from '@/components/grid/grid.js'; import MkDataRow from '@/components/grid/MkDataRow.vue'; import MkHeaderRow from '@/components/grid/MkHeaderRow.vue'; import { cellValidation } from '@/components/grid/cell-validators.js'; -import { CELL_ADDRESS_NONE, CellAddress, CellValue, createCell, GridCell, resetCell } from '@/components/grid/cell.js'; +import { CELL_ADDRESS_NONE, createCell, resetCell } from '@/components/grid/cell.js'; import { copyGridDataToClipboard, equalCellAddress, @@ -63,18 +63,23 @@ import { pasteToGridFromClipboard, removeDataFromGrid, } from '@/components/grid/grid-utils.js'; -import { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; -import { GridContext, GridEvent } from '@/components/grid/grid-event.js'; -import { createColumn, GridColumn } from '@/components/grid/column.js'; -import { createRow, defaultGridRowSetting, GridRow, GridRowSetting, resetRow } from '@/components/grid/row.js'; -import { handleKeyEvent } from '@/scripts/key-event.js'; +import { createColumn } from '@/components/grid/column.js'; +import { createRow, defaultGridRowSetting, resetRow } from '@/components/grid/row.js'; +import { handleKeyEvent } from '@/utility/key-event.js'; + +import type { DataSource, GridSetting, GridState, Size } from '@/components/grid/grid.js'; +import type { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; +import type { GridContext, GridEvent } from '@/components/grid/grid-event.js'; +import type { GridColumn } from '@/components/grid/column.js'; +import type { GridRow, GridRowSetting } from '@/components/grid/row.js'; +import type { MenuItem } from '@/types/menu.js'; type RowHolder = { row: GridRow, cells: GridCell[], origin: DataSource, -} +}; const emit = defineEmits<{ (ev: 'event', event: GridEvent, context: GridContext): void; diff --git a/packages/frontend/src/components/grid/MkHeaderCell.vue b/packages/frontend/src/components/grid/MkHeaderCell.vue index aecfe7eaa3..69a68b6f2c 100644 --- a/packages/frontend/src/components/grid/MkHeaderCell.vue +++ b/packages/frontend/src/components/grid/MkHeaderCell.vue @@ -32,8 +32,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, watch } from 'vue'; -import { GridEventEmitter, Size } from '@/components/grid/grid.js'; -import { GridColumn } from '@/components/grid/column.js'; +import { GridEventEmitter } from '@/components/grid/grid.js'; +import type { Size } from '@/components/grid/grid.js'; +import type { GridColumn } from '@/components/grid/column.js'; const emit = defineEmits<{ (ev: 'operation:beginWidthChange', sender: GridColumn): void; diff --git a/packages/frontend/src/components/grid/MkHeaderRow.vue b/packages/frontend/src/components/grid/MkHeaderRow.vue index 8affa08fd5..225f623b84 100644 --- a/packages/frontend/src/components/grid/MkHeaderRow.vue +++ b/packages/frontend/src/components/grid/MkHeaderRow.vue @@ -29,11 +29,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { GridEventEmitter, Size } from '@/components/grid/grid.js'; +import { GridEventEmitter } from '@/components/grid/grid.js'; import MkHeaderCell from '@/components/grid/MkHeaderCell.vue'; import MkNumberCell from '@/components/grid/MkNumberCell.vue'; -import { GridColumn } from '@/components/grid/column.js'; -import { GridRowSetting } from '@/components/grid/row.js'; +import type { Size } from '@/components/grid/grid.js'; +import type { GridColumn } from '@/components/grid/column.js'; +import type { GridRowSetting } from '@/components/grid/row.js'; const emit = defineEmits<{ (ev: 'operation:beginWidthChange', sender: GridColumn): void; diff --git a/packages/frontend/src/components/grid/MkNumberCell.vue b/packages/frontend/src/components/grid/MkNumberCell.vue index 674bba96bc..d3b5956ddd 100644 --- a/packages/frontend/src/components/grid/MkNumberCell.vue +++ b/packages/frontend/src/components/grid/MkNumberCell.vue @@ -19,8 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> - -import { GridRow } from '@/components/grid/row.js'; +import type { GridRow } from '@/components/grid/row.js'; defineProps<{ content: string, diff --git a/packages/frontend/src/components/grid/cell-validators.ts b/packages/frontend/src/components/grid/cell-validators.ts index 949cab2ec6..7310a82c9e 100644 --- a/packages/frontend/src/components/grid/cell-validators.ts +++ b/packages/frontend/src/components/grid/cell-validators.ts @@ -3,9 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { CellValue, GridCell } from '@/components/grid/cell.js'; -import { GridColumn } from '@/components/grid/column.js'; -import { GridRow } from '@/components/grid/row.js'; +import type { CellValue, GridCell } from '@/components/grid/cell.js'; +import type { GridColumn } from '@/components/grid/column.js'; +import type { GridRow } from '@/components/grid/row.js'; import { i18n } from '@/i18n.js'; export type ValidatorParams = { @@ -18,25 +18,25 @@ export type ValidatorParams = { export type ValidatorResult = { valid: boolean; message?: string; -} +}; export type GridCellValidator = { name?: string; ignoreViolation?: boolean; validate: (params: ValidatorParams) => ValidatorResult; -} +}; export type ValidateViolation = { valid: boolean; params: ValidatorParams; violations: ValidateViolationItem[]; -} +}; export type ValidateViolationItem = { valid: boolean; validator: GridCellValidator; result: ValidatorResult; -} +}; export function cellValidation(allCells: GridCell[], cell: GridCell, newValue: CellValue): ValidateViolation { const { column, row } = cell; diff --git a/packages/frontend/src/components/grid/cell.ts b/packages/frontend/src/components/grid/cell.ts index 71b7a3e3f1..d347d05bdb 100644 --- a/packages/frontend/src/components/grid/cell.ts +++ b/packages/frontend/src/components/grid/cell.ts @@ -3,19 +3,19 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ValidateViolation } from '@/components/grid/cell-validators.js'; -import { Size } from '@/components/grid/grid.js'; -import { GridColumn } from '@/components/grid/column.js'; -import { GridRow } from '@/components/grid/row.js'; -import { MenuItem } from '@/types/menu.js'; -import { GridContext } from '@/components/grid/grid-event.js'; +import type { ValidateViolation } from '@/components/grid/cell-validators.js'; +import type { Size } from '@/components/grid/grid.js'; +import type { GridColumn } from '@/components/grid/column.js'; +import type { GridRow } from '@/components/grid/row.js'; +import type { MenuItem } from '@/types/menu.js'; +import type { GridContext } from '@/components/grid/grid-event.js'; export type CellValue = string | boolean | number | undefined | null | Array<unknown> | NonNullable<unknown>; export type CellAddress = { row: number; col: number; -} +}; export const CELL_ADDRESS_NONE: CellAddress = { row: -1, @@ -32,13 +32,13 @@ export type GridCell = { contentSize: Size; setting: GridCellSetting; violation: ValidateViolation; -} +}; export type GridCellContextMenuFactory = (col: GridColumn, row: GridRow, value: CellValue, context: GridContext) => MenuItem[]; export type GridCellSetting = { contextMenuFactory?: GridCellContextMenuFactory; -} +}; export function createCell( column: GridColumn, diff --git a/packages/frontend/src/components/grid/column.ts b/packages/frontend/src/components/grid/column.ts index 2f505756fe..6a694b39ec 100644 --- a/packages/frontend/src/components/grid/column.ts +++ b/packages/frontend/src/components/grid/column.ts @@ -3,13 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { GridCellValidator } from '@/components/grid/cell-validators.js'; -import { Size, SizeStyle } from '@/components/grid/grid.js'; import { calcCellWidth } from '@/components/grid/grid-utils.js'; -import { CellValue, GridCell } from '@/components/grid/cell.js'; -import { GridRow } from '@/components/grid/row.js'; -import { MenuItem } from '@/types/menu.js'; -import { GridContext } from '@/components/grid/grid-event.js'; +import type { GridCellValidator } from '@/components/grid/cell-validators.js'; +import type { Size, SizeStyle } from '@/components/grid/grid.js'; +import type { CellValue, GridCell } from '@/components/grid/cell.js'; +import type { GridRow } from '@/components/grid/row.js'; +import type { MenuItem } from '@/types/menu.js'; +import type { GridContext } from '@/components/grid/grid-event.js'; export type ColumnType = 'text' | 'number' | 'date' | 'boolean' | 'image' | 'hidden'; @@ -40,7 +40,7 @@ export type GridColumn = { setting: GridColumnSetting; width: string; contentSize: Size; -} +}; export function createColumn(setting: GridColumnSetting, index: number): GridColumn { return { diff --git a/packages/frontend/src/components/grid/grid-event.ts b/packages/frontend/src/components/grid/grid-event.ts index 074b72b956..e2f1e44055 100644 --- a/packages/frontend/src/components/grid/grid-event.ts +++ b/packages/frontend/src/components/grid/grid-event.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; -import { GridState } from '@/components/grid/grid.js'; -import { ValidateViolation } from '@/components/grid/cell-validators.js'; -import { GridColumn } from '@/components/grid/column.js'; -import { GridRow } from '@/components/grid/row.js'; +import type { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; +import type { GridState } from '@/components/grid/grid.js'; +import type { ValidateViolation } from '@/components/grid/cell-validators.js'; +import type { GridColumn } from '@/components/grid/column.js'; +import type { GridRow } from '@/components/grid/row.js'; export type GridContext = { selectedCell?: GridCell; diff --git a/packages/frontend/src/components/grid/grid-utils.ts b/packages/frontend/src/components/grid/grid-utils.ts index a45bc88926..9e5402354e 100644 --- a/packages/frontend/src/components/grid/grid-utils.ts +++ b/packages/frontend/src/components/grid/grid-utils.ts @@ -3,13 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { isRef, Ref } from 'vue'; -import { DataSource, SizeStyle } from '@/components/grid/grid.js'; -import { CELL_ADDRESS_NONE, CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; -import { GridRow } from '@/components/grid/row.js'; -import { GridContext } from '@/components/grid/grid-event.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { GridColumn, GridColumnSetting } from '@/components/grid/column.js'; +import { isRef } from 'vue'; +import type { Ref } from 'vue'; +import type { DataSource, SizeStyle } from '@/components/grid/grid.js'; +import { CELL_ADDRESS_NONE } from '@/components/grid/cell.js'; +import type { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; +import type { GridRow } from '@/components/grid/row.js'; +import type { GridContext } from '@/components/grid/grid-event.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import type { GridColumn, GridColumnSetting } from '@/components/grid/column.js'; export function isCellElement(elem: HTMLElement): boolean { return elem.hasAttribute('data-grid-cell'); diff --git a/packages/frontend/src/components/grid/grid.ts b/packages/frontend/src/components/grid/grid.ts index b82e12b304..0428e6493c 100644 --- a/packages/frontend/src/components/grid/grid.ts +++ b/packages/frontend/src/components/grid/grid.ts @@ -4,9 +4,9 @@ */ import { EventEmitter } from 'eventemitter3'; -import { CellValue, GridCellSetting } from '@/components/grid/cell.js'; -import { GridColumnSetting } from '@/components/grid/column.js'; -import { GridRowSetting } from '@/components/grid/row.js'; +import type { CellValue, GridCellSetting } from '@/components/grid/cell.js'; +import type { GridColumnSetting } from '@/components/grid/column.js'; +import type { GridRowSetting } from '@/components/grid/row.js'; export type GridSetting = { root?: { @@ -21,7 +21,7 @@ export type GridSetting = { export type DataSource = Record<string, CellValue>; -export type GridState = +export type GridState = ( 'normal' | 'cellSelecting' | 'cellEditing' | @@ -29,19 +29,19 @@ export type GridState = 'colSelecting' | 'rowSelecting' | 'hidden' - ; +); export type Size = { width: number; height: number; -} +}; export type SizeStyle = number | 'auto' | undefined; export type AdditionalStyle = { className?: string; style?: Record<string, string | number>; -} +}; export class GridEventEmitter extends EventEmitter<{ 'forceRefreshContentSize': void; diff --git a/packages/frontend/src/components/grid/row.ts b/packages/frontend/src/components/grid/row.ts index e0a317c9d3..42da22193f 100644 --- a/packages/frontend/src/components/grid/row.ts +++ b/packages/frontend/src/components/grid/row.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { AdditionalStyle } from '@/components/grid/grid.js'; -import { GridCell } from '@/components/grid/cell.js'; -import { GridColumn } from '@/components/grid/column.js'; -import { MenuItem } from '@/types/menu.js'; -import { GridContext } from '@/components/grid/grid-event.js'; +import type { AdditionalStyle } from '@/components/grid/grid.js'; +import type { GridCell } from '@/components/grid/cell.js'; +import type { GridColumn } from '@/components/grid/column.js'; +import type { MenuItem } from '@/types/menu.js'; +import type { GridContext } from '@/components/grid/grid-event.js'; export const defaultGridRowSetting: Required<GridRowSetting> = { showNumber: true, @@ -27,7 +27,7 @@ export type GridRowStyleRuleConditionParams = { export type GridRowStyleRule = { condition: (params: GridRowStyleRuleConditionParams) => boolean; applyStyle: AdditionalStyle; -} +}; export type GridRowContextMenuFactory = (row: GridRow, context: GridContext) => MenuItem[]; @@ -40,7 +40,7 @@ export type GridRowSetting = { events?: { delete?: (rows: GridRow[]) => void; } -} +}; export type GridRow = { index: number; @@ -48,7 +48,7 @@ export type GridRow = { using: boolean; setting: GridRowSetting; additionalStyles: AdditionalStyle[]; -} +}; export function createRow(index: number, using: boolean, setting: GridRowSetting): GridRow { return { diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index b36625ed1b..6c6903c3a4 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -3,8 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { App } from 'vue'; - import Mfm from './global/MkMfm.js'; import MkA from './global/MkA.vue'; import MkAcct from './global/MkAcct.vue'; @@ -18,14 +16,22 @@ import MkTime from './global/MkTime.vue'; import MkUrl from './global/MkUrl.vue'; import I18n from './global/I18n.vue'; import RouterView from './global/RouterView.vue'; +import NestedRouterView from './global/NestedRouterView.vue'; +import StackingRouterView from './global/StackingRouterView.vue'; import MkLoading from './global/MkLoading.vue'; import MkError from './global/MkError.vue'; import MkAd from './global/MkAd.vue'; import MkPageHeader from './global/MkPageHeader.vue'; import MkSpacer from './global/MkSpacer.vue'; -import MkFooterSpacer from './global/MkFooterSpacer.vue'; import MkStickyContainer from './global/MkStickyContainer.vue'; import MkLazy from './global/MkLazy.vue'; +import PageWithHeader from './global/PageWithHeader.vue'; +import PageWithAnimBg from './global/PageWithAnimBg.vue'; +import SearchMarker from './global/SearchMarker.vue'; +import SearchLabel from './global/SearchLabel.vue'; +import SearchKeyword from './global/SearchKeyword.vue'; + +import type { App } from 'vue'; export default function(app: App) { for (const [key, value] of Object.entries(components)) { @@ -36,6 +42,8 @@ export default function(app: App) { export const components = { I18n: I18n, RouterView: RouterView, + NestedRouterView: NestedRouterView, + StackingRouterView: StackingRouterView, Mfm: Mfm, MkA: MkA, MkAcct: MkAcct, @@ -52,15 +60,21 @@ export const components = { MkAd: MkAd, MkPageHeader: MkPageHeader, MkSpacer: MkSpacer, - MkFooterSpacer: MkFooterSpacer, MkStickyContainer: MkStickyContainer, MkLazy: MkLazy, + PageWithHeader: PageWithHeader, + PageWithAnimBg: PageWithAnimBg, + SearchMarker: SearchMarker, + SearchLabel: SearchLabel, + SearchKeyword: SearchKeyword, }; declare module '@vue/runtime-core' { export interface GlobalComponents { I18n: typeof I18n; RouterView: typeof RouterView; + NestedRouterView: typeof NestedRouterView; + StackingRouterView: typeof StackingRouterView; Mfm: typeof Mfm; MkA: typeof MkA; MkAcct: typeof MkAcct; @@ -77,8 +91,12 @@ declare module '@vue/runtime-core' { MkAd: typeof MkAd; MkPageHeader: typeof MkPageHeader; MkSpacer: typeof MkSpacer; - MkFooterSpacer: typeof MkFooterSpacer; MkStickyContainer: typeof MkStickyContainer; MkLazy: typeof MkLazy; + PageWithHeader: typeof PageWithHeader; + PageWithAnimBg: typeof PageWithAnimBg; + SearchMarker: typeof SearchMarker; + SearchLabel: typeof SearchLabel; + SearchKeyword: typeof SearchKeyword; } } diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue index 84436e7adb..df26874c17 100644 --- a/packages/frontend/src/components/page/page.note.vue +++ b/packages/frontend/src/components/page/page.note.vue @@ -15,7 +15,7 @@ import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkNote from '@/components/MkNote.vue'; import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const props = defineProps<{ block: Misskey.entities.PageBlock, diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue index e5b1eff294..d5c80aa5f8 100644 --- a/packages/frontend/src/components/page/page.text.vue +++ b/packages/frontend/src/components/page/page.text.vue @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { defineAsyncComponent } from 'vue'; import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; -import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; import { isEnabledUrlPreview } from '@/instance.js'; const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue')); diff --git a/packages/frontend/src/custom-emojis.ts b/packages/frontend/src/custom-emojis.ts index 0d03282cee..45d4b40fd7 100644 --- a/packages/frontend/src/custom-emojis.ts +++ b/packages/frontend/src/custom-emojis.ts @@ -5,8 +5,8 @@ import { shallowRef, computed, markRaw, watch } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; -import { get, set } from '@/scripts/idb-proxy.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; +import { get, set } from '@/utility/idb-proxy.js'; const storageCache = await get('emojis'); export const customEmojis = shallowRef<Misskey.entities.EmojiSimple[]>(Array.isArray(storageCache) ? storageCache : []); diff --git a/packages/frontend/src/debug.ts b/packages/frontend/src/debug.ts index 8bb8012ae3..3a8f6289d1 100644 --- a/packages/frontend/src/debug.ts +++ b/packages/frontend/src/debug.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { type ComponentInternalInstance, getCurrentInstance } from 'vue'; +import { getCurrentInstance } from 'vue'; +import type { ComponentInternalInstance } from 'vue'; export function isDebuggerEnabled(id: number): boolean { try { diff --git a/packages/frontend/src/deck.ts b/packages/frontend/src/deck.ts new file mode 100644 index 0000000000..9df56c52df --- /dev/null +++ b/packages/frontend/src/deck.ts @@ -0,0 +1,353 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { notificationTypes } from 'misskey-js'; +import { ref } from 'vue'; +import { v4 as uuid } from 'uuid'; +import { i18n } from './i18n.js'; +import type { BasicTimelineType } from '@/timelines.js'; +import type { SoundStore } from '@/preferences/def.js'; +import type { MenuItem } from '@/types/menu.js'; +import { deepClone } from '@/utility/clone.js'; +import { prefer } from '@/preferences.js'; +import * as os from '@/os.js'; + +export type DeckProfile = { + name: string; + id: string; + columns: Column[]; + layout: Column['id'][][]; +}; + +type ColumnWidget = { + name: string; + id: string; + data: Record<string, any>; +}; + +export const columnTypes = [ + 'main', + 'widgets', + 'notifications', + 'tl', + 'antenna', + 'list', + 'channel', + 'mentions', + 'direct', + 'roleTimeline', +] as const; + +export type ColumnType = typeof columnTypes[number]; + +export type Column = { + id: string; + type: ColumnType; + name: string | null; + width: number; + widgets?: ColumnWidget[]; + active?: boolean; + flexible?: boolean; + antennaId?: string; + listId?: string; + channelId?: string; + roleId?: string; + excludeTypes?: typeof notificationTypes[number][]; + tl?: BasicTimelineType; + withRenotes?: boolean; + withReplies?: boolean; + withSensitive?: boolean; + onlyFiles?: boolean; + soundSetting?: SoundStore; +}; + +const _currentProfile = prefer.s['deck.profiles'].find(p => p.name === prefer.s['deck.profile']); +const __currentProfile = _currentProfile ? deepClone(_currentProfile) : null; +export const columns = ref(__currentProfile ? __currentProfile.columns : []); +export const layout = ref(__currentProfile ? __currentProfile.layout : []); + +if (prefer.s['deck.profile'] == null) { + addProfile('Main'); +} + +export function forceSaveCurrentDeckProfile() { + const currentProfile = prefer.s['deck.profiles'].find(p => p.name === prefer.s['deck.profile']); + if (currentProfile == null) return; + + const newProfile = deepClone(currentProfile); + newProfile.columns = columns.value; + newProfile.layout = layout.value; + + const newProfiles = prefer.s['deck.profiles'].filter(p => p.name !== prefer.s['deck.profile']); + newProfiles.push(newProfile); + prefer.commit('deck.profiles', newProfiles); +} + +export const saveCurrentDeckProfile = () => { + forceSaveCurrentDeckProfile(); +}; + +function switchProfile(profile: DeckProfile) { + prefer.commit('deck.profile', profile.name); + const currentProfile = deepClone(profile); + columns.value = currentProfile.columns; + layout.value = currentProfile.layout; + forceSaveCurrentDeckProfile(); +} + +function addProfile(name: string) { + if (name.trim() === '') return; + if (prefer.s['deck.profiles'].find(p => p.name === name)) return; + + const newProfile: DeckProfile = { + id: uuid(), + name, + columns: [], + layout: [], + }; + prefer.commit('deck.profiles', [...prefer.s['deck.profiles'], newProfile]); + switchProfile(newProfile); +} + +function createFirstProfile() { + addProfile('Main'); +} + +export function deleteProfile(name: string): void { + const newProfiles = prefer.s['deck.profiles'].filter(p => p.name !== name); + prefer.commit('deck.profiles', newProfiles); + + if (prefer.s['deck.profiles'].length === 0) { + createFirstProfile(); + } else { + switchProfile(prefer.s['deck.profiles'][0]); + } +} + +export function addColumn(column: Column) { + if (column.name === undefined) column.name = null; + columns.value.push(column); + layout.value.push([column.id]); + saveCurrentDeckProfile(); +} + +export function removeColumn(id: Column['id']) { + columns.value = columns.value.filter(c => c.id !== id); + layout.value = layout.value.map(ids => ids.filter(_id => _id !== id)).filter(ids => ids.length > 0); + saveCurrentDeckProfile(); +} + +export function swapColumn(a: Column['id'], b: Column['id']) { + const aX = layout.value.findIndex(ids => ids.indexOf(a) !== -1); + const aY = layout.value[aX].findIndex(id => id === a); + const bX = layout.value.findIndex(ids => ids.indexOf(b) !== -1); + const bY = layout.value[bX].findIndex(id => id === b); + const newLayout = deepClone(layout.value); + newLayout[aX][aY] = b; + newLayout[bX][bY] = a; + layout.value = newLayout; + saveCurrentDeckProfile(); +} + +export function swapLeftColumn(id: Column['id']) { + const newLayout = deepClone(layout.value); + layout.value.some((ids, i) => { + if (ids.includes(id)) { + const left = layout.value[i - 1]; + if (left) { + newLayout[i - 1] = layout.value[i]; + newLayout[i] = left; + layout.value = newLayout; + } + return true; + } + return false; + }); + saveCurrentDeckProfile(); +} + +export function swapRightColumn(id: Column['id']) { + const newLayout = deepClone(layout.value); + layout.value.some((ids, i) => { + if (ids.includes(id)) { + const right = layout.value[i + 1]; + if (right) { + newLayout[i + 1] = layout.value[i]; + newLayout[i] = right; + layout.value = newLayout; + } + return true; + } + return false; + }); + saveCurrentDeckProfile(); +} + +export function swapUpColumn(id: Column['id']) { + const newLayout = deepClone(layout.value); + const idsIndex = layout.value.findIndex(ids => ids.includes(id)); + const ids = deepClone(layout.value[idsIndex]); + ids.some((x, i) => { + if (x === id) { + const up = ids[i - 1]; + if (up) { + ids[i - 1] = id; + ids[i] = up; + + newLayout[idsIndex] = ids; + layout.value = newLayout; + } + return true; + } + return false; + }); + saveCurrentDeckProfile(); +} + +export function swapDownColumn(id: Column['id']) { + const newLayout = deepClone(layout.value); + const idsIndex = layout.value.findIndex(ids => ids.includes(id)); + const ids = deepClone(layout.value[idsIndex]); + ids.some((x, i) => { + if (x === id) { + const down = ids[i + 1]; + if (down) { + ids[i + 1] = id; + ids[i] = down; + + newLayout[idsIndex] = ids; + layout.value = newLayout; + } + return true; + } + return false; + }); + saveCurrentDeckProfile(); +} + +export function stackLeftColumn(id: Column['id']) { + let newLayout = deepClone(layout.value); + const i = layout.value.findIndex(ids => ids.includes(id)); + newLayout = newLayout.map(ids => ids.filter(_id => _id !== id)); + newLayout[i - 1].push(id); + newLayout = newLayout.filter(ids => ids.length > 0); + layout.value = newLayout; + saveCurrentDeckProfile(); +} + +export function popRightColumn(id: Column['id']) { + let newLayout = deepClone(layout.value); + const i = layout.value.findIndex(ids => ids.includes(id)); + const affected = newLayout[i]; + newLayout = newLayout.map(ids => ids.filter(_id => _id !== id)); + newLayout.splice(i + 1, 0, [id]); + newLayout = newLayout.filter(ids => ids.length > 0); + layout.value = newLayout; + + const newColumns = deepClone(columns.value); + for (const column of newColumns) { + if (affected.includes(column.id)) { + column.active = true; + } + } + columns.value = newColumns; + + saveCurrentDeckProfile(); +} + +export function addColumnWidget(id: Column['id'], widget: ColumnWidget) { + const newColumns = deepClone(columns.value); + const columnIndex = columns.value.findIndex(c => c.id === id); + const column = deepClone(columns.value[columnIndex]); + if (column == null) return; + if (column.widgets == null) column.widgets = []; + column.widgets.unshift(widget); + newColumns[columnIndex] = column; + columns.value = newColumns; + saveCurrentDeckProfile(); +} + +export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) { + const newColumns = deepClone(columns.value); + const columnIndex = columns.value.findIndex(c => c.id === id); + const column = deepClone(columns.value[columnIndex]); + if (column == null) return; + if (column.widgets == null) column.widgets = []; + column.widgets = column.widgets.filter(w => w.id !== widget.id); + newColumns[columnIndex] = column; + columns.value = newColumns; + saveCurrentDeckProfile(); +} + +export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) { + const newColumns = deepClone(columns.value); + const columnIndex = columns.value.findIndex(c => c.id === id); + const column = deepClone(columns.value[columnIndex]); + if (column == null) return; + column.widgets = widgets; + newColumns[columnIndex] = column; + columns.value = newColumns; + saveCurrentDeckProfile(); +} + +export function updateColumnWidget(id: Column['id'], widgetId: string, widgetData: any) { + const newColumns = deepClone(columns.value); + const columnIndex = columns.value.findIndex(c => c.id === id); + const column = deepClone(columns.value[columnIndex]); + if (column == null) return; + if (column.widgets == null) column.widgets = []; + column.widgets = column.widgets.map(w => w.id === widgetId ? { + ...w, + data: widgetData, + } : w); + newColumns[columnIndex] = column; + columns.value = newColumns; + saveCurrentDeckProfile(); +} + +export function updateColumn(id: Column['id'], column: Partial<Column>) { + const newColumns = deepClone(columns.value); + const columnIndex = columns.value.findIndex(c => c.id === id); + const currentColumn = deepClone(columns.value[columnIndex]); + if (currentColumn == null) return; + for (const [k, v] of Object.entries(column)) { + currentColumn[k] = v; + } + newColumns[columnIndex] = currentColumn; + columns.value = newColumns; + saveCurrentDeckProfile(); +} + +export function switchProfileMenu(ev: MouseEvent) { + const items: MenuItem[] = prefer.s['deck.profile'] ? [{ + text: prefer.s['deck.profile'], + active: true, + action: () => {}, + }] : []; + + const profiles = prefer.s['deck.profiles']; + + items.push(...(profiles.filter(p => p.name !== prefer.s['deck.profile']).map(p => ({ + text: p.name, + action: () => { + switchProfile(p); + }, + }))), { type: 'divider' as const }, { + text: i18n.ts._deck.newProfile, + icon: 'ti ti-plus', + action: async () => { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts._deck.profile, + minLength: 1, + }); + + if (canceled || name == null || name.trim() === '') return; + + addProfile(name); + }, + }); + + os.popupMenu(items, ev.currentTarget ?? ev.target); +} diff --git a/packages/frontend/src/di.ts b/packages/frontend/src/di.ts new file mode 100644 index 0000000000..f9fc282315 --- /dev/null +++ b/packages/frontend/src/di.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { InjectionKey, Ref } from 'vue'; +import type { Router } from '@/router.js'; + +export const DI = { + routerCurrentDepth: Symbol() as InjectionKey<number>, + router: Symbol() as InjectionKey<Router>, + mock: Symbol() as InjectionKey<boolean>, + pageMetadata: Symbol() as InjectionKey<Ref<Record<string, any>>>, + viewId: Symbol() as InjectionKey<string>, + currentStickyTop: Symbol() as InjectionKey<Ref<number>>, + currentStickyBottom: Symbol() as InjectionKey<Ref<number>>, +}; diff --git a/packages/frontend/src/directives/adaptive-bg.ts b/packages/frontend/src/directives/adaptive-bg.ts index f88996019f..a68cd1b18b 100644 --- a/packages/frontend/src/directives/adaptive-bg.ts +++ b/packages/frontend/src/directives/adaptive-bg.ts @@ -3,8 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Directive } from 'vue'; -import { getBgColor } from '@/scripts/get-bg-color.js'; +import type { Directive } from 'vue'; +import { getBgColor } from '@/utility/get-bg-color.js'; export default { mounted(src, binding, vn) { diff --git a/packages/frontend/src/directives/adaptive-border.ts b/packages/frontend/src/directives/adaptive-border.ts index 1305f312bd..8072a1ffd9 100644 --- a/packages/frontend/src/directives/adaptive-border.ts +++ b/packages/frontend/src/directives/adaptive-border.ts @@ -3,19 +3,34 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Directive } from 'vue'; -import { getBgColor } from '@/scripts/get-bg-color.js'; +import type { Directive } from 'vue'; +import { getBgColor } from '@/utility/get-bg-color.js'; +import { globalEvents } from '@/events.js'; + +const handlerMap = new WeakMap<any, any>(); export default { mounted(src, binding, vn) { - const parentBg = getBgColor(src.parentElement) ?? 'transparent'; + function calc() { + const parentBg = getBgColor(src.parentElement) ?? 'transparent'; - const myBg = window.getComputedStyle(src).backgroundColor; + const myBg = window.getComputedStyle(src).backgroundColor; - if (parentBg === myBg) { - src.style.borderColor = 'var(--MI_THEME-divider)'; - } else { - src.style.borderColor = myBg; + if (parentBg === myBg) { + src.style.borderColor = 'var(--MI_THEME-divider)'; + } else { + src.style.borderColor = myBg; + } } + + handlerMap.set(src, calc); + + calc(); + + globalEvents.on('themeChanged', calc); + }, + + unmounted(src, binding, vn) { + globalEvents.off('themeChanged', handlerMap.get(src)); }, } as Directive; diff --git a/packages/frontend/src/directives/anim.ts b/packages/frontend/src/directives/anim.ts index d5b6ae4287..ad0cb5ed81 100644 --- a/packages/frontend/src/directives/anim.ts +++ b/packages/frontend/src/directives/anim.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Directive } from 'vue'; +import type { Directive } from 'vue'; export default { beforeMount(src, binding, vn) { diff --git a/packages/frontend/src/directives/appear.ts b/packages/frontend/src/directives/appear.ts index 706d4a9ee4..802477e00b 100644 --- a/packages/frontend/src/directives/appear.ts +++ b/packages/frontend/src/directives/appear.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Directive } from 'vue'; +import type { Directive } from 'vue'; export default { mounted(src, binding, vn) { diff --git a/packages/frontend/src/directives/click-anime.ts b/packages/frontend/src/directives/click-anime.ts index 5bb48bbcdd..c34f351fb3 100644 --- a/packages/frontend/src/directives/click-anime.ts +++ b/packages/frontend/src/directives/click-anime.ts @@ -3,12 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Directive } from 'vue'; -import { defaultStore } from '@/store.js'; +import type { Directive } from 'vue'; +import { prefer } from '@/preferences.js'; export default { mounted(el: HTMLElement, binding, vn) { - if (!defaultStore.state.animation) return; + if (!prefer.s.animation) return; const target = el.children[0]; diff --git a/packages/frontend/src/directives/follow-append.ts b/packages/frontend/src/directives/follow-append.ts index 615dd99fa8..f3eaac10e3 100644 --- a/packages/frontend/src/directives/follow-append.ts +++ b/packages/frontend/src/directives/follow-append.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Directive } from 'vue'; +import type { Directive } from 'vue'; import { getScrollContainer, getScrollPosition } from '@@/js/scroll.js'; export default { diff --git a/packages/frontend/src/directives/get-size.ts b/packages/frontend/src/directives/get-size.ts index 2655c76c48..488f201a0d 100644 --- a/packages/frontend/src/directives/get-size.ts +++ b/packages/frontend/src/directives/get-size.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Directive } from 'vue'; +import type { Directive } from 'vue'; const mountings = new Map<Element, { resize: ResizeObserver; diff --git a/packages/frontend/src/directives/hotkey.ts b/packages/frontend/src/directives/hotkey.ts index 0e5c7ede24..63637ab2ba 100644 --- a/packages/frontend/src/directives/hotkey.ts +++ b/packages/frontend/src/directives/hotkey.ts @@ -3,8 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Directive } from 'vue'; -import { makeHotkey } from '@/scripts/hotkey.js'; +import type { Directive } from 'vue'; +import { makeHotkey } from '@/utility/hotkey.js'; export default { mounted(el, binding) { @@ -13,7 +13,7 @@ export default { el._keyHandler = makeHotkey(binding.value); if (el._hotkey_global) { - document.addEventListener('keydown', el._keyHandler, { passive: false }); + window.document.addEventListener('keydown', el._keyHandler, { passive: false }); } else { el.addEventListener('keydown', el._keyHandler, { passive: false }); } @@ -21,7 +21,7 @@ export default { unmounted(el) { if (el._hotkey_global) { - document.removeEventListener('keydown', el._keyHandler); + window.document.removeEventListener('keydown', el._keyHandler); } else { el.removeEventListener('keydown', el._keyHandler); } diff --git a/packages/frontend/src/directives/index.ts b/packages/frontend/src/directives/index.ts index bda7738ccd..9555045afe 100644 --- a/packages/frontend/src/directives/index.ts +++ b/packages/frontend/src/directives/index.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { App } from 'vue'; +import type { App } from 'vue'; import userPreview from './user-preview.js'; import getSize from './get-size.js'; diff --git a/packages/frontend/src/directives/panel.ts b/packages/frontend/src/directives/panel.ts index aa26b94d0b..0af19e6ca3 100644 --- a/packages/frontend/src/directives/panel.ts +++ b/packages/frontend/src/directives/panel.ts @@ -3,14 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Directive } from 'vue'; -import { getBgColor } from '@/scripts/get-bg-color.js'; +import type { Directive } from 'vue'; +import { getBgColor } from '@/utility/get-bg-color.js'; export default { mounted(src, binding, vn) { const parentBg = getBgColor(src.parentElement) ?? 'transparent'; - const myBg = getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-panel'); + const myBg = getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-panel'); if (parentBg === myBg) { src.style.backgroundColor = 'var(--MI_THEME-bg)'; diff --git a/packages/frontend/src/directives/ripple.ts b/packages/frontend/src/directives/ripple.ts index a043ff212d..614cd37011 100644 --- a/packages/frontend/src/directives/ripple.ts +++ b/packages/frontend/src/directives/ripple.ts @@ -4,12 +4,14 @@ */ import MkRippleEffect from '@/components/MkRippleEffect.vue'; +import { prefer } from '@/preferences.js'; import { popup } from '@/os.js'; export default { mounted(el, binding, vn) { // 明示的に false であればバインドしない if (binding.value === false) return; + if (!prefer.s.animation) return; el.addEventListener('click', () => { const rect = el.getBoundingClientRect(); diff --git a/packages/frontend/src/directives/tooltip.ts b/packages/frontend/src/directives/tooltip.ts index 251ce5675f..750acd0588 100644 --- a/packages/frontend/src/directives/tooltip.ts +++ b/packages/frontend/src/directives/tooltip.ts @@ -6,8 +6,9 @@ // TODO: useTooltip関数使うようにしたい // ただディレクティブ内でonUnmountedなどのcomposition api使えるのか不明 -import { defineAsyncComponent, Directive, ref } from 'vue'; -import { isTouchUsing } from '@/scripts/touch.js'; +import { defineAsyncComponent, ref } from 'vue'; +import type { Directive } from 'vue'; +import { isTouchUsing } from '@/utility/touch.js'; import { popup, alert } from '@/os.js'; const start = isTouchUsing ? 'touchstart' : 'mouseenter'; @@ -46,7 +47,7 @@ export default { } self.show = () => { - if (!document.body.contains(el)) return; + if (!window.document.body.contains(el)) return; if (self._close) return; if (self.text == null) return; diff --git a/packages/frontend/src/directives/user-preview.ts b/packages/frontend/src/directives/user-preview.ts index 278d842d09..94deea82c7 100644 --- a/packages/frontend/src/directives/user-preview.ts +++ b/packages/frontend/src/directives/user-preview.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { defineAsyncComponent, Directive, ref } from 'vue'; +import { defineAsyncComponent, ref } from 'vue'; +import type { Directive } from 'vue'; import { popup } from '@/os.js'; export class UserPreview { @@ -30,7 +31,7 @@ export class UserPreview { } private show() { - if (!document.body.contains(this.el)) return; + if (!window.document.body.contains(this.el)) return; if (this.promise) return; const showing = ref(true); @@ -57,7 +58,7 @@ export class UserPreview { }; this.checkTimer = window.setInterval(() => { - if (!document.body.contains(this.el)) { + if (!window.document.body.contains(this.el)) { window.clearTimeout(this.showTimer); window.clearTimeout(this.hideTimer); this.close(); diff --git a/packages/frontend/src/events.ts b/packages/frontend/src/events.ts index d476aec04a..dfd3d4120c 100644 --- a/packages/frontend/src/events.ts +++ b/packages/frontend/src/events.ts @@ -7,7 +7,7 @@ import { EventEmitter } from 'eventemitter3'; import * as Misskey from 'misskey-js'; export const globalEvents = new EventEmitter<{ + themeChanging: () => void; themeChanged: () => void; clientNotification: (notification: Misskey.entities.Notification) => void; - requestClearPageCache: () => void; }>(); diff --git a/packages/frontend/src/filters/number.ts b/packages/frontend/src/filters/number.ts index 10fb64deb4..479afd58d4 100644 --- a/packages/frontend/src/filters/number.ts +++ b/packages/frontend/src/filters/number.ts @@ -5,4 +5,4 @@ import { numberFormat } from '@@/js/intl-const.js'; -export default n => n == null ? 'N/A' : numberFormat.format(n); +export default (n?: number) => n == null ? 'N/A' : numberFormat.format(n); diff --git a/packages/frontend/src/i.ts b/packages/frontend/src/i.ts new file mode 100644 index 0000000000..a71ed1671f --- /dev/null +++ b/packages/frontend/src/i.ts @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { reactive } from 'vue'; +import * as Misskey from 'misskey-js'; +import { miLocalStorage } from '@/local-storage.js'; + +// TODO: 他のタブと永続化されたstateを同期 + +type AccountWithToken = Misskey.entities.MeDetailed & { token: string }; + +const accountData = miLocalStorage.getItem('account'); + +// TODO: 外部からはreadonlyに +export const $i = accountData ? reactive(JSON.parse(accountData) as AccountWithToken) : null; + +export const iAmModerator = $i != null && ($i.isAdmin === true || $i.isModerator === true); +export const iAmAdmin = $i != null && $i.isAdmin; + +export function ensureSignin() { + if ($i == null) throw new Error('signin required'); + return $i; +} + +export let notesCount = $i == null ? 0 : $i.notesCount; +export function incNotesCount() { + notesCount++; +} + +if (_DEV_) { + (window as any).$i = $i; +} diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts index 71cb42b30c..e75e3dfd34 100644 --- a/packages/frontend/src/instance.ts +++ b/packages/frontend/src/instance.ts @@ -5,14 +5,14 @@ import { computed, reactive } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { miLocalStorage } from '@/local-storage.js'; import { DEFAULT_INFO_IMAGE_URL, DEFAULT_NOT_FOUND_IMAGE_URL, DEFAULT_SERVER_ERROR_IMAGE_URL } from '@@/js/const.js'; // TODO: 他のタブと永続化されたstateを同期 //#region loader -const providedMetaEl = document.getElementById('misskey_meta'); +const providedMetaEl = window.document.getElementById('misskey_meta'); let cachedMeta = miLocalStorage.getItem('instance') ? JSON.parse(miLocalStorage.getItem('instance')!) : null; let cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0; diff --git a/packages/frontend/src/nirax.ts b/packages/frontend/src/lib/nirax.ts index 965bd6f0bc..a97803e879 100644 --- a/packages/frontend/src/nirax.ts +++ b/packages/frontend/src/lib/nirax.ts @@ -5,8 +5,9 @@ // NIRAX --- A lightweight router -import { Component, onMounted, shallowRef, ShallowRef } from 'vue'; +import { onBeforeUnmount, onMounted, shallowRef } from 'vue'; import { EventEmitter } from 'eventemitter3'; +import type { Component, ShallowRef } from 'vue'; function safeURIDecode(str: string): string { try { @@ -22,7 +23,6 @@ interface RouteDefBase { loginRequired?: boolean; name?: string; hash?: string; - globalCacheKey?: string; children?: RouteDef[]; } @@ -45,31 +45,28 @@ type ParsedPath = (string | { optional?: boolean; })[]; -export type RouterEvent = { +export type RouterEvents = { change: (ctx: { - beforePath: string; - path: string; - resolved: Resolved; - key: string; + beforeFullPath: string; + fullPath: string; + resolved: PathResolvedResult; }) => void; replace: (ctx: { - path: string; - key: string; + fullPath: string; }) => void; push: (ctx: { - beforePath: string; - path: string; + beforeFullPath: string; + fullPath: string; route: RouteDef | null; props: Map<string, string> | null; - key: string; }) => void; same: () => void; -} +}; -export type Resolved = { +export type PathResolvedResult = { route: RouteDef; props: Map<string, string | boolean>; - child?: Resolved; + child?: PathResolvedResult; redirected?: boolean; /** @internal */ @@ -105,124 +102,39 @@ function parsePath(path: string): ParsedPath { return res; } -export interface IRouter extends EventEmitter<RouterEvent> { - current: Resolved; - currentRef: ShallowRef<Resolved>; - currentRoute: ShallowRef<RouteDef>; - navHook: ((path: string, flag?: RouterFlag) => boolean) | null; - - /** - * ルートの初期化(eventListenerの定義後に必ず呼び出すこと) - */ - init(): void; - - resolve(path: string): Resolved | null; - - getCurrentPath(): string; - - getCurrentKey(): string; - - push(path: string, flag?: RouterFlag): void; - - replace(path: string, key?: string | null): void; - - /** @see EventEmitter */ - eventNames(): Array<EventEmitter.EventNames<RouterEvent>>; - - /** @see EventEmitter */ - listeners<T extends EventEmitter.EventNames<RouterEvent>>( - event: T - ): Array<EventEmitter.EventListener<RouterEvent, T>>; - - /** @see EventEmitter */ - listenerCount( - event: EventEmitter.EventNames<RouterEvent> - ): number; - - /** @see EventEmitter */ - emit<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - ...args: EventEmitter.EventArgs<RouterEvent, T> - ): boolean; - - /** @see EventEmitter */ - on<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn: EventEmitter.EventListener<RouterEvent, T>, - context?: any - ): this; - - /** @see EventEmitter */ - addListener<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn: EventEmitter.EventListener<RouterEvent, T>, - context?: any - ): this; - - /** @see EventEmitter */ - once<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn: EventEmitter.EventListener<RouterEvent, T>, - context?: any - ): this; - - /** @see EventEmitter */ - removeListener<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn?: EventEmitter.EventListener<RouterEvent, T>, - context?: any, - once?: boolean | undefined - ): this; - - /** @see EventEmitter */ - off<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn?: EventEmitter.EventListener<RouterEvent, T>, - context?: any, - once?: boolean | undefined - ): this; - - /** @see EventEmitter */ - removeAllListeners( - event?: EventEmitter.EventNames<RouterEvent> - ): this; -} - -export class Router extends EventEmitter<RouterEvent> implements IRouter { - private routes: RouteDef[]; - public current: Resolved; - public currentRef: ShallowRef<Resolved>; +export class Nirax<DEF extends RouteDef[]> extends EventEmitter<RouterEvents> { + private routes: DEF; + public current: PathResolvedResult; + public currentRef: ShallowRef<PathResolvedResult>; public currentRoute: ShallowRef<RouteDef>; - private currentPath: string; + private currentFullPath: string; // /foo/bar?baz=qux#hash private isLoggedIn: boolean; private notFoundPageComponent: Component; - private currentKey = Date.now().toString(); private redirectCount = 0; - public navHook: ((path: string, flag?: RouterFlag) => boolean) | null = null; + public navHook: ((fullPath: string, flag?: RouterFlag) => boolean) | null = null; - constructor(routes: Router['routes'], currentPath: Router['currentPath'], isLoggedIn: boolean, notFoundPageComponent: Component) { + constructor(routes: DEF, currentFullPath: Nirax<DEF>['currentFullPath'], isLoggedIn: boolean, notFoundPageComponent: Component) { super(); this.routes = routes; - this.current = this.resolve(currentPath)!; + this.current = this.resolve(currentFullPath)!; this.currentRef = shallowRef(this.current); this.currentRoute = shallowRef(this.current.route); - this.currentPath = currentPath; + this.currentFullPath = currentFullPath; this.isLoggedIn = isLoggedIn; this.notFoundPageComponent = notFoundPageComponent; } public init() { - const res = this.navigate(this.currentPath, null, false); + const res = this.navigate(this.currentFullPath, false); this.emit('replace', { - path: res._parsedRoute.fullPath, - key: this.currentKey, + fullPath: res._parsedRoute.fullPath, }); } - public resolve(path: string): Resolved | null { - const fullPath = path; + public resolve(fullPath: string): PathResolvedResult | null { + let path = fullPath; let queryString: string | null = null; let hash: string | null = null; if (path[0] === '/') path = path.substring(1); @@ -241,9 +153,7 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter { hash, }; - if (_DEV_) console.log('Routing: ', path, queryString); - - function check(routes: RouteDef[], _parts: string[]): Resolved | null { + function check(routes: RouteDef[], _parts: string[]): PathResolvedResult | null { forEachRouteLoop: for (const route of routes) { let parts = [..._parts]; @@ -346,14 +256,14 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter { return check(this.routes, _parts); } - private navigate(path: string, key: string | null | undefined, emitChange = true, _redirected = false): Resolved { - const beforePath = this.currentPath; - this.currentPath = path; + private navigate(fullPath: string, emitChange = true, _redirected = false): PathResolvedResult { + const beforeFullPath = this.currentFullPath; + this.currentFullPath = fullPath; - const res = this.resolve(this.currentPath); + const res = this.resolve(this.currentFullPath); if (res == null) { - throw new Error('no route found for: ' + path); + throw new Error('no route found for: ' + fullPath); } if ('redirect' in res.route) { @@ -367,7 +277,7 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter { if (_redirected && this.redirectCount++ > 10) { throw new Error('redirect loop detected'); } - return this.navigate(redirectPath, null, emitChange, true); + return this.navigate(redirectPath, emitChange, true); } if (res.route.loginRequired && !this.isLoggedIn) { @@ -375,19 +285,15 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter { res.props.set('showLoginPopup', true); } - const isSamePath = beforePath === path; - if (isSamePath && key == null) key = this.currentKey; this.current = res; this.currentRef.value = res; this.currentRoute.value = res.route; - this.currentKey = res.route.globalCacheKey ?? key ?? path; if (emitChange && res.route.path !== '/:(*)') { this.emit('change', { - beforePath, - path, + beforeFullPath, + fullPath, resolved: res, - key: this.currentKey, }); } @@ -398,70 +304,45 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter { }; } - public getCurrentPath() { - return this.currentPath; - } - - public getCurrentKey() { - return this.currentKey; + public getCurrentFullPath() { + return this.currentFullPath; } - public push(path: string, flag?: RouterFlag) { - const beforePath = this.currentPath; - if (path === beforePath) { + public push(fullPath: string, flag?: RouterFlag) { + const beforeFullPath = this.currentFullPath; + if (fullPath === beforeFullPath) { this.emit('same'); return; } if (this.navHook) { - const cancel = this.navHook(path, flag); + const cancel = this.navHook(fullPath, flag); if (cancel) return; } - const res = this.navigate(path, null); + const res = this.navigate(fullPath); if (res.route.path === '/:(*)') { - location.href = path; + window.location.href = fullPath; } else { this.emit('push', { - beforePath, - path: res._parsedRoute.fullPath, + beforeFullPath, + fullPath: res._parsedRoute.fullPath, route: res.route, props: res.props, - key: this.currentKey, }); } } - public replace(path: string, key?: string | null) { - const res = this.navigate(path, key); + public replace(fullPath: string) { + const res = this.navigate(fullPath); this.emit('replace', { - path: res._parsedRoute.fullPath, - key: this.currentKey, + fullPath: res._parsedRoute.fullPath, }); } -} - -export function useScrollPositionManager(getScrollContainer: () => HTMLElement | null, router: IRouter) { - const scrollPosStore = new Map<string, number>(); - - onMounted(() => { - const scrollContainer = getScrollContainer(); - if (scrollContainer == null) return; - scrollContainer.addEventListener('scroll', () => { - scrollPosStore.set(router.getCurrentKey(), scrollContainer.scrollTop); - }, { passive: true }); + public useListener<E extends keyof RouterEvents, L = RouterEvents[E]>(event: E, listener: L) { + this.addListener(event, listener); - router.addListener('change', ctx => { - const scrollPos = scrollPosStore.get(ctx.key) ?? 0; - scrollContainer.scroll({ top: scrollPos, behavior: 'instant' }); - if (scrollPos !== 0) { - window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール - scrollContainer.scroll({ top: scrollPos, behavior: 'instant' }); - }, 100); - } - }); - - router.addListener('same', () => { - scrollContainer.scroll({ top: 0, behavior: 'smooth' }); + onBeforeUnmount(() => { + this.removeListener(event, listener); }); - }); + } } diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/lib/pizzax.ts index 7740fe0d39..a232ced75e 100644 --- a/packages/frontend/src/pizzax.ts +++ b/packages/frontend/src/lib/pizzax.ts @@ -5,15 +5,16 @@ // PIZZAX --- A lightweight store -import { onUnmounted, Ref, ref, watch } from 'vue'; +import { onUnmounted, ref, watch } from 'vue'; import { BroadcastChannel } from 'broadcast-channel'; -import { $i } from '@/account.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { get, set } from '@/scripts/idb-proxy.js'; -import { defaultStore } from '@/store.js'; +import type { Ref } from 'vue'; +import { $i } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { get, set } from '@/utility/idb-proxy.js'; +import { store } from '@/store.js'; import { useStream } from '@/stream.js'; -import { deepClone } from '@/scripts/clone.js'; -import { deepMerge } from '@/scripts/merge.js'; +import { deepClone } from '@/utility/clone.js'; +import { deepMerge } from '@/utility/merge.js'; type StateDef = Record<string, { where: 'account' | 'device' | 'deviceAccount'; @@ -32,7 +33,7 @@ type PizzaxChannelMessage<T extends StateDef> = { userId?: string; }; -export class Storage<T extends StateDef> { +export class Pizzax<T extends StateDef> { public readonly ready: Promise<void>; public readonly loaded: Promise<void>; @@ -44,8 +45,15 @@ export class Storage<T extends StateDef> { public readonly def: T; // TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487 - public readonly state: State<T>; - public readonly reactiveState: ReactiveState<T>; + /** + * static / state の略 (static が予約語のため) + */ + public readonly s: State<T>; + + /** + * reactive の略 + */ + public readonly r: ReactiveState<T>; private pizzaxChannel: BroadcastChannel<PizzaxChannelMessage<T>>; @@ -69,12 +77,12 @@ export class Storage<T extends StateDef> { this.pizzaxChannel = new BroadcastChannel(`pizzax::${key}`); - this.state = {} as State<T>; - this.reactiveState = {} as ReactiveState<T>; + this.s = {} as State<T>; + this.r = {} as ReactiveState<T>; for (const [k, v] of Object.entries(def) as [keyof T, T[keyof T]['default']][]) { - this.state[k] = v.default; - this.reactiveState[k] = ref(v.default); + this.s[k] = v.default; + this.r[k] = ref(v.default); } this.ready = this.init(); @@ -105,14 +113,13 @@ export class Storage<T extends StateDef> { for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) { - this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceState[k], v.default); + this.r[k].value = this.s[k] = this.mergeState<T[keyof T]['default']>(deviceState[k], v.default); } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { - this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(registryCache[k], v.default); + this.r[k].value = this.s[k] = this.mergeState<T[keyof T]['default']>(registryCache[k], v.default); } else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { - this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceAccountState[k], v.default); + this.r[k].value = this.s[k] = this.mergeState<T[keyof T]['default']>(deviceAccountState[k], v.default); } else { - this.reactiveState[k].value = this.state[k] = v.default; - if (_DEV_) console.log('Use default value', k, v.default); + this.r[k].value = this.s[k] = v.default; } } @@ -120,7 +127,7 @@ export class Storage<T extends StateDef> { // アカウント変更すればunisonReloadが効くため、このreturnが発火することは // まずないと思うけど一応弾いておく if (where === 'deviceAccount' && !($i && userId !== $i.id)) return; - this.reactiveState[key].value = this.state[key] = value; + this.r[key].value = this.s[key] = value; }); if ($i) { @@ -128,9 +135,9 @@ export class Storage<T extends StateDef> { // streamingのuser storage updateイベントを監視して更新 connection.on('registryUpdated', ({ scope, key, value }: { scope?: string[], key: keyof T, value: T[typeof key]['default'] }) => { - if (!scope || scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.state[key] === value) return; + if (!scope || scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.s[key] === value) return; - this.reactiveState[key].value = this.state[key] = value; + this.r[key].value = this.s[key] = value; this.addIdbSetJob(async () => { const cache = await get(this.registryCacheKeyName); @@ -148,7 +155,7 @@ export class Storage<T extends StateDef> { if ($i) { // api関数と循環参照なので一応setTimeoutしておく window.setTimeout(async () => { - await defaultStore.ready; + await store.ready; misskeyApi('i/registry/get-all', { scope: ['client', this.key] }) .then(kvs => { @@ -156,10 +163,10 @@ export class Storage<T extends StateDef> { for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { if (v.where === 'account') { if (Object.prototype.hasOwnProperty.call(kvs, k)) { - this.reactiveState[k].value = this.state[k] = (kvs as Partial<T>)[k]; + this.r[k].value = this.s[k] = (kvs as Partial<T>)[k]; cache[k] = (kvs as Partial<T>)[k]; } else { - this.reactiveState[k].value = this.state[k] = v.default; + this.r[k].value = this.s[k] = v.default; } } } @@ -179,12 +186,9 @@ export class Storage<T extends StateDef> { // (JSON.parse(JSON.stringify(value))の代わり) const rawValue = deepClone(value); - if (_DEV_) console.log('set', key, rawValue, value); - - this.reactiveState[key].value = this.state[key] = rawValue; + this.r[key].value = this.s[key] = rawValue; return this.addIdbSetJob(async () => { - if (_DEV_) console.log(`set ${String(key)} start`); switch (this.def[key].where) { case 'device': { this.pizzaxChannel.postMessage({ @@ -223,12 +227,11 @@ export class Storage<T extends StateDef> { break; } } - if (_DEV_) console.log(`set ${String(key)} complete`); }); } public push<K extends keyof T>(key: K, value: ArrayElement<T[K]['default']>): void { - const currentState = this.state[key]; + const currentState = this.s[key]; this.set(key, [...currentState, value]); } @@ -241,17 +244,18 @@ export class Storage<T extends StateDef> { * 特定のキーの、簡易的なgetter/setterを作ります * 主にvue上で設定コントロールのmodelとして使う用 */ + // TODO: 廃止 public makeGetterSetter<K extends keyof T, R = T[K]['default']>( key: K, getter?: (v: T[K]['default']) => R, setter?: (v: R) => T[K]['default'], ): { - get: () => R; - set: (value: R) => void; - } { - const valueRef = ref(this.state[key]); + get: () => R; + set: (value: R) => void; + } { + const valueRef = ref(this.s[key]); - const stop = watch(this.reactiveState[key], val => { + const stop = watch(this.r[key], val => { valueRef.value = val; }); diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts index 89c0a4b849..c43bd7cd9a 100644 --- a/packages/frontend/src/local-storage.ts +++ b/packages/frontend/src/local-storage.ts @@ -3,13 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export type Keys = +export type Keys = ( 'v' | 'lastVersion' | 'instance' | 'instanceCachedAt' | 'account' | - 'accounts' | 'latestDonationInfoShownAt' | 'neverShowDonationInfo' | 'neverShowLocalOnlyInfo' | @@ -19,7 +18,6 @@ export type Keys = 'drafts' | 'hashtags' | 'wallpaper' | - 'theme' | 'colorScheme' | 'useSystemFont' | 'fontSize' | @@ -29,18 +27,23 @@ export type Keys = 'locale' | 'localeVersion' | 'theme' | + 'themeId' | 'customCss' | - 'message_drafts' | + 'chatMessageDrafts' | 'scratchpad' | 'debug' | + 'preferences' | + 'latestPreferencesUpdate' | + 'hidePreferencesRestoreSuggestion' | `miux:${string}` | `ui:folder:${string}` | - `themes:${string}` | + `themes:${string}` | // DEPRECATED `aiscript:${string}` | 'lastEmojisFetchedAt' | // DEPRECATED, stored in indexeddb (13.9.0~) 'emojis' | // DEPRECATED, stored in indexeddb (13.9.0~); `channelLastReadedAt:${string}` | `idbfallback::${string}` +); // セッション毎に廃棄されるLocalStorage代替(セーフモードなどで使用できそう) //const safeSessionStorage = new Map<Keys, string>(); diff --git a/packages/frontend/src/memory-storage.ts b/packages/frontend/src/memory-storage.ts new file mode 100644 index 0000000000..df0dc1308f --- /dev/null +++ b/packages/frontend/src/memory-storage.ts @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type MemoryStorage = { + has: (key: string) => boolean; + getItem: <T>(key: string) => T | null; + setItem: (key: string, value: unknown) => void; + removeItem: (key: string) => void; + clear: () => void; + size: number; +}; + +class MemoryStorageImpl implements MemoryStorage { + private readonly storage: Map<string, unknown>; + + constructor() { + this.storage = new Map(); + } + + has(key: string): boolean { + return this.storage.has(key); + } + + getItem<T>(key: string): T | null { + return this.storage.has(key) ? this.storage.get(key) as T : null; + } + + setItem(key: string, value: unknown): void { + this.storage.set(key, value); + } + + removeItem(key: string): void { + this.storage.delete(key); + } + + clear(): void { + this.storage.clear(); + } + + get size(): number { + return this.storage.size; + } +} + +export function createMemoryStorage(): MemoryStorage { + return new MemoryStorageImpl(); +} + +/** + * SessionStorageよりも更に短い期間でクリアされるストレージです + * - ブラウザの再読み込みやタブの閉じると内容が揮発します + * - このストレージは他のタブと共有されません + * - アカウント切り替えやログアウトを行うと内容が揮発します + */ +export const defaultMemoryStorage: MemoryStorage = createMemoryStorage(); diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index 2bcb69b145..9d4988338a 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -4,16 +4,16 @@ */ import { computed, defineAsyncComponent, reactive } from 'vue'; -import { clearCache } from './scripts/clear-cache.js'; +import { ui } from '@@/js/config.js'; +import { clearCache } from './utility/clear-cache.js'; import { instance } from './instance.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { miLocalStorage } from '@/local-storage.js'; -import { openInstanceMenu } from '@/ui/_common_/common.js'; -import { lookup } from '@/scripts/lookup.js'; +import { openInstanceMenu, openToolsMenu } from '@/ui/_common_/common.js'; +import { lookup } from '@/utility/lookup.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { ui } from '@@/js/config.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; +import { unisonReload } from '@/utility/unison-reload.js'; export const navbarItemDef = reactive({ notifications: { @@ -130,6 +130,12 @@ export const navbarItemDef = reactive({ icon: 'ti ti-device-tv', to: '/channels', }, + chat: { + title: i18n.ts.chat, + icon: 'ti ti-message', + to: '/chat', + indicated: computed(() => $i != null && $i.hasUnreadChatMessages), + }, achievements: { title: i18n.ts.achievements, icon: 'ti ti-medal', @@ -180,7 +186,7 @@ export const navbarItemDef = reactive({ title: i18n.ts.reload, icon: 'ti ti-refresh', action: (ev) => { - location.reload(); + window.location.reload(); }, }, profile: { diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 59af5ad2b3..dce2a8e910 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -5,30 +5,31 @@ // TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する -import { Component, markRaw, Ref, ref, defineAsyncComponent, nextTick } from 'vue'; +import { markRaw, ref, defineAsyncComponent, nextTick } from 'vue'; import { EventEmitter } from 'eventemitter3'; import * as Misskey from 'misskey-js'; +import type { Component, Ref } from 'vue'; import type { ComponentProps as CP } from 'vue-component-type-helpers'; -import type { Form, GetFormResultType } from '@/scripts/form.js'; +import type { Form, GetFormResultType } from '@/utility/form.js'; import type { MenuItem } from '@/types/menu.js'; import type { PostFormProps } from '@/types/post-form.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { prefer } from '@/preferences.js'; import { i18n } from '@/i18n.js'; import MkPostFormDialog from '@/components/MkPostFormDialog.vue'; import MkWaitingDialog from '@/components/MkWaitingDialog.vue'; import MkPageWindow from '@/components/MkPageWindow.vue'; import MkToast from '@/components/MkToast.vue'; import MkDialog from '@/components/MkDialog.vue'; -import MkPasswordDialog from '@/components/MkPasswordDialog.vue'; -import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue'; import MkPopupMenu from '@/components/MkPopupMenu.vue'; import MkContextMenu from '@/components/MkContextMenu.vue'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { pleaseLogin } from '@/scripts/please-login.js'; -import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; -import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js'; -import { focusParent } from '@/scripts/focus.js'; +import type MkRoleSelectDialog_TypeReferenceOnly from '@/components/MkRoleSelectDialog.vue'; +import type MkEmojiPickerDialog_TypeReferenceOnly from '@/components/MkEmojiPickerDialog.vue'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import { showMovedDialog } from '@/utility/show-moved-dialog.js'; +import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js'; +import { focusParent } from '@/utility/focus.js'; export const openingWindowsCount = ref(0); @@ -62,7 +63,6 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints, P extends Miss }); if (result === 'copy') { copyToClipboard(`Endpoint: ${endpoint}\nInfo: ${JSON.stringify(err.info)}\nDate: ${date}`); - success(); } return; } else if (err.code === 'RATE_LIMIT_EXCEEDED') { @@ -188,7 +188,7 @@ type EmitsExtractor<T> = { export function popup<T extends Component>( component: T, props: ComponentProps<T>, - events: ComponentEmit<T> = {} as ComponentEmit<T>, + events: Partial<ComponentEmit<T>> = {}, ): { dispose: () => void } { markRaw(component); @@ -318,6 +318,21 @@ export function inputText(props: { } | { canceled: false; result: string; }>; +// min lengthが指定されてたら result は null になり得ないことを保証する overload function +export function inputText(props: { + type?: 'text' | 'email' | 'password' | 'url'; + title?: string; + text?: string; + placeholder?: string | null; + autocomplete?: string; + default?: string; + minLength: number; + maxLength?: number; +}): Promise<{ + canceled: true; result: undefined; +} | { + canceled: false; result: string; +}>; export function inputText(props: { type?: 'text' | 'email' | 'password' | 'url'; title?: string; @@ -454,7 +469,7 @@ export function authenticateDialog(): Promise<{ canceled: false; result: { password: string; token: string | null; }; }> { return new Promise(resolve => { - const { dispose } = popup(MkPasswordDialog, {}, { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkPasswordDialog.vue')), {}, { done: result => { resolve(result ? { canceled: false, result } : { canceled: true, result: undefined }); }, @@ -611,30 +626,26 @@ export async function selectDriveFolder(multiple: boolean): Promise<Misskey.enti }); } -export async function selectRole(params: { - initialRoleIds?: string[], - title?: string, - infoMessage?: string, - publicOnly?: boolean, -}): Promise< +export async function selectRole(params: ComponentProps<typeof MkRoleSelectDialog_TypeReferenceOnly>): Promise< { canceled: true; result: undefined; } | { canceled: false; result: Misskey.entities.Role[] } > { return new Promise((resolve) => { - popup(defineAsyncComponent(() => import('@/components/MkRoleSelectDialog.vue')), params, { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkRoleSelectDialog.vue')), params, { done: roles => { resolve({ canceled: false, result: roles }); }, close: () => { resolve({ canceled: true, result: undefined }); }, - }, 'dispose'); + closed: () => dispose(), + }); }); } -export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerDialog>): Promise<string> { +export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerDialog_TypeReferenceOnly>): Promise<string> { return new Promise(resolve => { - const { dispose } = popup(MkEmojiPickerDialog, { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { src, ...opts, }, { @@ -669,7 +680,11 @@ export function popupMenu(items: MenuItem[], src?: HTMLElement | EventTarget | n width?: number; onClosing?: () => void; }): Promise<void> { - let returnFocusTo = getHTMLElementOrNull(src) ?? getHTMLElementOrNull(document.activeElement); + if (!(src instanceof HTMLElement)) { + src = null; + } + + let returnFocusTo = getHTMLElementOrNull(src) ?? getHTMLElementOrNull(window.document.activeElement); return new Promise(resolve => nextTick(() => { const { dispose } = popup(MkPopupMenu, { items, @@ -692,13 +707,13 @@ export function popupMenu(items: MenuItem[], src?: HTMLElement | EventTarget | n export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> { if ( - defaultStore.state.contextMenu === 'native' || - (defaultStore.state.contextMenu === 'appWithShift' && !ev.shiftKey) + prefer.s.contextMenu === 'native' || + (prefer.s.contextMenu === 'appWithShift' && !ev.shiftKey) ) { return Promise.resolve(); } - let returnFocusTo = getHTMLElementOrNull(ev.currentTarget ?? ev.target) ?? getHTMLElementOrNull(document.activeElement); + let returnFocusTo = getHTMLElementOrNull(ev.currentTarget ?? ev.target) ?? getHTMLElementOrNull(window.document.activeElement); ev.preventDefault(); return new Promise(resolve => nextTick(() => { const { dispose } = popup(MkContextMenu, { diff --git a/packages/frontend/src/scripts/page-metadata.ts b/packages/frontend/src/page.ts index 0e3b093ecf..0107f17be4 100644 --- a/packages/frontend/src/scripts/page-metadata.ts +++ b/packages/frontend/src/page.ts @@ -4,7 +4,9 @@ */ import * as Misskey from 'misskey-js'; -import { MaybeRefOrGetter, Ref, inject, isRef, onActivated, onBeforeUnmount, provide, ref, toValue, watch } from 'vue'; +import { inject, isRef, onActivated, onBeforeUnmount, provide, ref, toValue, watch } from 'vue'; +import { DI } from './di.js'; +import type { MaybeRefOrGetter, Ref } from 'vue'; export type PageMetadata = { title: string; @@ -30,11 +32,8 @@ const METADATA_KEY = Symbol('MetadataKey'); const setMetadata = (v: Ref<PageMetadata | null>): void => { provide<Ref<PageMetadata | null>>(METADATA_KEY, v); }; -const getMetadata = (): Ref<PageMetadata | null> | undefined => { - return inject<Ref<PageMetadata | null>>(METADATA_KEY); -}; -export const definePageMetadata = (maybeRefOrGetterMetadata: MaybeRefOrGetter<PageMetadata>): void => { +export const definePage = (maybeRefOrGetterMetadata: MaybeRefOrGetter<PageMetadata>): void => { const metadataRef = ref(toValue(maybeRefOrGetterMetadata)); const metadataGetter = () => metadataRef.value; const receiver = getReceiver(); @@ -54,6 +53,8 @@ export const definePageMetadata = (maybeRefOrGetterMetadata: MaybeRefOrGetter<Pa onActivated(() => { receiver?.(metadataGetter); }); + + provide(DI.pageMetadata, metadataRef); }; export const provideMetadataReceiver = (receiver: PageMetadataReceiver): void => { @@ -63,8 +64,3 @@ export const provideMetadataReceiver = (receiver: PageMetadataReceiver): void => export const provideReactiveMetadata = (metadataRef: Ref<PageMetadata | null>): void => { setMetadata(metadataRef); }; - -export const injectReactiveMetadata = (): Ref<PageMetadata | null> => { - const metadataRef = getMetadata(); - return isRef(metadataRef) ? metadataRef : ref(null); -}; diff --git a/packages/frontend/src/pages/_error_.vue b/packages/frontend/src/pages/_error_.vue index 0bafc385a6..22ed979282 100644 --- a/packages/frontend/src/pages/_error_.vue +++ b/packages/frontend/src/pages/_error_.vue @@ -5,9 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkLoading v-if="!loaded"/> -<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" appear> +<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear> <div v-show="loaded" :class="$style.root"> - <img :src="serverErrorImageUrl" class="_ghost" :class="$style.img"/> + <img :src="serverErrorImageUrl" draggable="false" :class="$style.img"/> <div class="_gaps"> <div><b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.pageLoadError }}</b></div> <div v-if="meta && (version === meta.version)">{{ i18n.ts.pageLoadErrorDescription }}</div> @@ -27,15 +27,15 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; +import { version } from '@@/js/config.js'; import MkButton from '@/components/MkButton.vue'; import MkLink from '@/components/MkLink.vue'; -import { version } from '@@/js/config.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { unisonReload } from '@/utility/unison-reload.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { miLocalStorage } from '@/local-storage.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; import { serverErrorImageUrl } from '@/instance.js'; const props = withDefaults(defineProps<{ @@ -67,7 +67,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.error, icon: 'ti ti-alert-triangle', })); diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue index d7d526f3ba..b166dfd940 100644 --- a/packages/frontend/src/pages/about.emojis.vue +++ b/packages/frontend/src/pages/about.emojis.vue @@ -44,7 +44,7 @@ import MkInput from '@/components/MkInput.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import { customEmojis, customEmojiCategories, getCustomEmojiTags } from '@/custom-emojis.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; const customEmojiTags = getCustomEmojiTags(); const q = ref(''); diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue index 2190be8bec..ea27f7d90a 100644 --- a/packages/frontend/src/pages/about.federation.vue +++ b/packages/frontend/src/pages/about.federation.vue @@ -56,7 +56,8 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ref } from 'vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; -import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import type { Paging } from '@/components/MkPagination.vue'; import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; import FormSplit from '@/components/form/split.vue'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/about.overview.vue b/packages/frontend/src/pages/about.overview.vue index 347214c8fa..ebd440ac8a 100644 --- a/packages/frontend/src/pages/about.overview.vue +++ b/packages/frontend/src/pages/about.overview.vue @@ -152,7 +152,7 @@ import { host, version } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import number from '@/filters/number.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; import FormSplit from '@/components/form/split.vue'; diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue index 1f36589a49..1dee8c30c7 100644 --- a/packages/frontend/src/pages/about.vue +++ b/packages/frontend/src/pages/about.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSpacer v-if="tab === 'overview'" :contentMax="600" :marginMin="20"> <XOverview/> @@ -20,15 +19,15 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInstanceStats/> </MkSpacer> </MkHorizontalSwipe> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, defineAsyncComponent, ref, watch } from 'vue'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { definePage } from '@/page.js'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; const XOverview = defineAsyncComponent(() => import('@/pages/about.overview.vue')); @@ -81,7 +80,7 @@ const headerTabs = computed(() => { return items; }); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.instanceInfo, icon: 'ti ti-info-circle', })); diff --git a/packages/frontend/src/pages/achievements.vue b/packages/frontend/src/pages/achievements.vue index 77ab473ea2..423e709da4 100644 --- a/packages/frontend/src/pages/achievements.vue +++ b/packages/frontend/src/pages/achievements.vue @@ -4,21 +4,20 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> +<PageWithHeader> <MkSpacer :contentMax="1200"> <MkAchievements :user="$i"/> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'; import MkAchievements from '@/components/MkAchievements.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { $i } from '@/account.js'; -import { claimAchievement } from '@/scripts/achievements.js'; +import { definePage } from '@/page.js'; +import { $i } from '@/i.js'; +import { claimAchievement } from '@/utility/achievements.js'; let timer: number | null; @@ -48,7 +47,7 @@ onDeactivated(() => { } }); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.achievements, icon: 'ti ti-medal', })); diff --git a/packages/frontend/src/pages/admin-file.vue b/packages/frontend/src/pages/admin-file.vue index 60f6be51d4..1e3e106842 100644 --- a/packages/frontend/src/pages/admin-file.vue +++ b/packages/frontend/src/pages/admin-file.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer v-if="file" :contentMax="600" :marginMin="16" :marginMax="32"> <div v-if="tab === 'overview'" class="cxqhhsmd _gaps_m"> <a class="thumbnail" :href="file.url" target="_blank"> @@ -36,8 +35,9 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA v-if="file.user" class="user" :to="`/admin/user/${file.user.id}`"> <MkUserCardMini :user="file.user"/> </MkA> + <div> - <MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">{{ i18n.ts.sensitive }}</MkSwitch> + <MkSwitch :modelValue="isSensitive" @update:modelValue="toggleSensitive">{{ i18n.ts.sensitive }}</MkSwitch> </div> <div> @@ -66,7 +66,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkObjectView> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -82,10 +82,10 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkInfo from '@/components/MkInfo.vue'; import bytes from '@/filters/bytes.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { iAmAdmin, iAmModerator } from '@/account.js'; +import { definePage } from '@/page.js'; +import { iAmAdmin, iAmModerator } from '@/i.js'; const tab = ref('overview'); const file = ref<Misskey.entities.DriveFile | null>(null); @@ -117,9 +117,21 @@ async function del() { }); } -async function toggleIsSensitive(v) { - await misskeyApi('drive/files/update', { fileId: props.fileId, isSensitive: v }); - isSensitive.value = v; +async function toggleSensitive() { + if (!file.value) return; + + const { canceled } = await os.confirm({ + type: 'warning', + text: isSensitive.value ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm, + }); + + if (canceled) return; + isSensitive.value = !isSensitive.value; + + os.apiWithDialog('drive/files/update', { + fileId: file.value.id, + isSensitive: !file.value.isSensitive, + }); } const headerActions = computed(() => [{ @@ -148,7 +160,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-code', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: file.value ? `${i18n.ts.file}: ${file.value.name}` : i18n.ts.file, icon: 'ti ti-file', })); diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index ce1fbc46a1..d5f9f0073b 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="600" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> <div v-if="tab === 'overview'" class="_gaps_m"> @@ -39,18 +38,20 @@ SPDX-License-Identifier: AGPL-3.0-only <template #value><span class="_monospace">{{ ips[0].ip }}</span></template> </MkKeyValue> --> - <MkKeyValue oneline> - <template #key>{{ i18n.ts.createdAt }}</template> - <template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template> - </MkKeyValue> - <MkKeyValue v-if="info" oneline> - <template #key>{{ i18n.ts.lastActiveDate }}</template> - <template #value><span class="_monospace"><MkTime :time="info.lastActiveDate" :mode="'detail'"/></span></template> - </MkKeyValue> - <MkKeyValue v-if="info" oneline> - <template #key>{{ i18n.ts.email }}</template> - <template #value><span class="_monospace">{{ info.email }}</span></template> - </MkKeyValue> + <template v-if="!isSystem"> + <MkKeyValue oneline> + <template #key>{{ i18n.ts.createdAt }}</template> + <template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template> + </MkKeyValue> + <MkKeyValue v-if="info" oneline> + <template #key>{{ i18n.ts.lastActiveDate }}</template> + <template #value><span class="_monospace"><MkTime :time="info.lastActiveDate" :mode="'detail'"/></span></template> + </MkKeyValue> + <MkKeyValue v-if="info" oneline> + <template #key>{{ i18n.ts.email }}</template> + <template #value><span class="_monospace">{{ info.email }}</span></template> + </MkKeyValue> + </template> </div> <MkTextarea v-model="moderationNote" manualSave @update:modelValue="onModerationNoteChanged"> @@ -77,7 +78,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </FormSection> - <FormSection> + <FormSection v-if="!isSystem"> <div class="_gaps"> <MkSwitch v-model="silenced" @update:modelValue="toggleSilence">{{ i18n.ts.silence }}</MkSwitch> <MkSwitch v-if="!isSystem" v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch> @@ -200,7 +201,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </FormSuspense> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -221,11 +222,11 @@ import FormSuspense from '@/components/form/suspense.vue'; import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { acct } from '@/filters/user.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { iAmAdmin, $i, iAmModerator } from '@/account.js'; +import { iAmAdmin, $i, iAmModerator } from '@/i.js'; import MkRolePreview from '@/components/MkRolePreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkInput from '@/components/MkInput.vue'; @@ -469,7 +470,7 @@ async function deleteAccount() { } async function assignRole() { - const roles = await misskeyApi('admin/roles/list'); + const roles = await misskeyApi('admin/roles/list').then(it => it.filter(r => r.target === 'manual')); const { canceled, result: roleId } = await os.select({ title: i18n.ts._role.chooseRoleToAssign, @@ -558,7 +559,15 @@ watch(user, () => { const headerActions = computed(() => []); -const headerTabs = computed(() => [{ +const headerTabs = computed(() => isSystem.value ? [{ + key: 'overview', + title: i18n.ts.overview, + icon: 'ti ti-info-circle', +}, { + key: 'raw', + title: 'Raw', + icon: 'ti ti-code', +}] : [{ key: 'overview', title: i18n.ts.overview, icon: 'ti ti-info-circle', @@ -584,7 +593,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-code', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: user.value ? acct(user.value) : i18n.ts.userInfo, icon: 'ti ti-user-exclamation', })); diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue index 4762ef3f97..6c47e6397f 100644 --- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue +++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue @@ -71,7 +71,7 @@ import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { deepClone } from '@/scripts/clone.js'; +import { deepClone } from '@/utility/clone.js'; import { rolesCache } from '@/cache.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue index b0651150a6..03f70bc22b 100644 --- a/packages/frontend/src/pages/admin/_header_.vue +++ b/packages/frontend/src/pages/admin/_header_.vue @@ -33,13 +33,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, onUnmounted, ref, shallowRef, watch, nextTick } from 'vue'; +import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch, nextTick, inject } from 'vue'; import tinycolor from 'tinycolor2'; -import { popupMenu } from '@/os.js'; import { scrollToTop } from '@@/js/scroll.js'; +import { popupMenu } from '@/os.js'; import MkButton from '@/components/MkButton.vue'; import { globalEvents } from '@/events.js'; -import { injectReactiveMetadata } from '@/scripts/page-metadata.js'; +import { DI } from '@/di.js'; type Tab = { key?: string | null; @@ -66,11 +66,11 @@ const emit = defineEmits<{ (ev: 'update:tab', key: string); }>(); -const pageMetadata = injectReactiveMetadata(); +const pageMetadata = inject(DI.pageMetadata); -const el = shallowRef<HTMLElement>(null); +const el = useTemplateRef('el'); +const tabHighlightEl = useTemplateRef('tabHighlightEl'); const tabRefs = {}; -const tabHighlightEl = shallowRef<HTMLElement | null>(null); const bg = ref<string | null>(null); const height = ref(0); const hasTabs = computed(() => { @@ -119,15 +119,15 @@ function onTabClick(tab: Tab, ev: MouseEvent): void { } const calcBg = () => { - const rawBg = pageMetadata.value?.bg ?? 'var(--MI_THEME-bg)'; - const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); + const rawBg = pageMetadata.value.bg ?? 'var(--MI_THEME-bg)'; + const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(window.document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); tinyBg.setAlpha(0.85); bg.value = tinyBg.toRgbString(); }; onMounted(() => { calcBg(); - globalEvents.on('themeChanged', calcBg); + globalEvents.on('themeChanging', calcBg); watch(() => [props.tab, props.tabs], () => { nextTick(() => { @@ -147,7 +147,7 @@ onMounted(() => { }); onUnmounted(() => { - globalEvents.off('themeChanged', calcBg); + globalEvents.off('themeChanging', calcBg); }); </script> diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue index eef24afd32..a56a24ff7d 100644 --- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue @@ -71,15 +71,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, ref, shallowRef, toRefs } from 'vue'; +import { computed, onMounted, ref, useTemplateRef, toRefs } from 'vue'; import { entities } from 'misskey-js'; +import type { MkSystemWebhookResult } from '@/components/MkSystemWebhookEditor.impl.js'; import MkButton from '@/components/MkButton.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; import MkInput from '@/components/MkInput.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkSelect from '@/components/MkSelect.vue'; -import { MkSystemWebhookResult, showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js'; +import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js'; import MkSwitch from '@/components/MkSwitch.vue'; import MkDivider from '@/components/MkDivider.vue'; import * as os from '@/os.js'; @@ -99,7 +100,7 @@ const props = defineProps<{ const { mode, id } = toRefs(props); -const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialogEl = useTemplateRef('dialogEl'); const loading = ref<number>(0); diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue index f5249261be..ee87fae606 100644 --- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue @@ -49,7 +49,7 @@ import { entities } from 'misskey-js'; import { computed, defineAsyncComponent, onMounted, ref } from 'vue'; import XRecipient from './notification-recipient.item.vue'; import XHeader from '@/pages/admin/_header_.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue index a164ecb1fe..67d54a85ad 100644 --- a/packages/frontend/src/pages/admin/abuses.vue +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton link to="/admin/abuse-report-notification-recipient" primary>{{ i18n.ts.notificationSetting }}</MkButton> </div> - <MkInfo v-if="!defaultStore.reactiveState.abusesTutorial.value" closable @close="closeTutorial()"> + <MkInfo v-if="!store.r.abusesTutorial.value" closable @close="closeTutorial()"> {{ i18n.ts._abuseUserReport.resolveTutorial }} </MkInfo> @@ -59,18 +59,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, shallowRef, ref } from 'vue'; +import { computed, useTemplateRef, ref } from 'vue'; import XHeader from './_header_.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkPagination from '@/components/MkPagination.vue'; import XAbuseReport from '@/components/MkAbuseReport.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; -const reports = shallowRef<InstanceType<typeof MkPagination>>(); +const reports = useTemplateRef('reports'); const state = ref('unresolved'); const reporterOrigin = ref('combined'); @@ -93,14 +93,14 @@ function resolved(reportId) { } function closeTutorial() { - defaultStore.set('abusesTutorial', false); + store.set('abusesTutorial', false); } const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.abuseReports, icon: 'ti ti-exclamation-circle', })); diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue index 0d67359e47..ebc3d23296 100644 --- a/packages/frontend/src/pages/admin/ads.vue +++ b/packages/frontend/src/pages/admin/ads.vue @@ -96,9 +96,9 @@ import MkFolder from '@/components/MkFolder.vue'; import MkSelect from '@/components/MkSelect.vue'; import FormSplit from '@/components/form/split.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const ads = ref<Misskey.entities.Ad[]>([]); @@ -255,7 +255,7 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.ads, icon: 'ti ti-ad', })); diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index e420586017..f6b331455f 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -94,9 +94,9 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkFolder from '@/components/MkFolder.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -199,7 +199,7 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.announcements, icon: 'ti ti-speakerphone', })); diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue index e37df40f2f..c8853c5ae4 100644 --- a/packages/frontend/src/pages/admin/bot-protection.vue +++ b/packages/frontend/src/pages/admin/bot-protection.vue @@ -196,14 +196,14 @@ import MkRadios from '@/components/MkRadios.vue'; import MkInput from '@/components/MkInput.vue'; import FormSlot from '@/components/form/slot.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { useForm } from '@/scripts/use-form.js'; +import { useForm } from '@/use/use-form.js'; import MkFormFooter from '@/components/MkFormFooter.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkInfo from '@/components/MkInfo.vue'; -import { ApiWithDialogCustomErrors } from '@/os.js'; +import type { ApiWithDialogCustomErrors } from '@/os.js'; const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue')); diff --git a/packages/frontend/src/pages/admin/branding.vue b/packages/frontend/src/pages/admin/branding.vue index cc05466832..84d8ea85da 100644 --- a/packages/frontend/src/pages/admin/branding.vue +++ b/packages/frontend/src/pages/admin/branding.vue @@ -128,10 +128,10 @@ import MkTextarea from '@/components/MkTextarea.vue'; import FromSlot from '@/components/form/slot.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { instance, fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import MkColorInput from '@/components/MkColorInput.vue'; import { host } from '@@/js/config.js'; @@ -210,7 +210,7 @@ function chooseNewLike(ev: MouseEvent) { const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.branding, icon: 'ti ti-paint', })); diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue index c4ea3b93e3..260177c894 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue @@ -71,25 +71,25 @@ export type EmojiSearchQuery = { <script setup lang="ts"> import { computed, defineAsyncComponent, onMounted, ref, nextTick, useCssModule } from 'vue'; import * as Misskey from 'misskey-js'; +import type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js'; +import type { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; +import type { GridSetting } from '@/components/grid/grid.js'; import * as os from '@/os.js'; import { emptyStrToEmptyArray, emptyStrToNull, emptyStrToUndefined, - RequestLogItem, roleIdsParser, } from '@/pages/admin/custom-emojis-manager.impl.js'; import MkGrid from '@/components/grid/MkGrid.vue'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; import { validators } from '@/components/grid/cell-validators.js'; -import { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkPagingButtons from '@/components/MkPagingButtons.vue'; -import { GridSetting } from '@/components/grid/grid.js'; -import { selectFile } from '@/scripts/select-file.js'; +import { selectFile } from '@/utility/select-file.js'; import { copyGridDataToClipboard, removeDataFromGrid } from '@/components/grid/grid-utils.js'; -import { useLoading } from "@/components/hook/useLoading.js"; +import { useLoading } from '@/components/hook/useLoading.js'; type GridItem = { checked: boolean; @@ -108,7 +108,7 @@ type GridItem = { publicUrl?: string | null; originalUrl?: string | null; type: string | null; -} +}; function setupGrid(): GridSetting { const $style = useCssModule(); @@ -464,8 +464,8 @@ async function refreshCustomEmojis() { aliases: emptyStrToUndefined(searchQuery.value.aliases), category: emptyStrToUndefined(searchQuery.value.category), license: emptyStrToUndefined(searchQuery.value.license), - isSensitive: searchQuery.value.sensitive ? Boolean(searchQuery.value.sensitive).valueOf() : undefined, - localOnly: searchQuery.value.localOnly ? Boolean(searchQuery.value.localOnly).valueOf() : undefined, + isSensitive: searchQuery.value.sensitive != null ? Boolean(searchQuery.value.sensitive).valueOf() : undefined, + localOnly: searchQuery.value.localOnly != null ? Boolean(searchQuery.value.localOnly).valueOf() : undefined, updatedAtFrom: emptyStrToUndefined(searchQuery.value.updatedAtFrom), updatedAtTo: emptyStrToUndefined(searchQuery.value.updatedAtTo), roleIds: searchQuery.value.roles.map(it => it.id), @@ -592,7 +592,7 @@ const headerActions = computed(() => [{ dispose(); }, }); - } + }, }]); </script> diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue index cc8b625cd5..666e3c95ac 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue @@ -78,30 +78,32 @@ SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable @typescript-eslint/no-non-null-assertion */ import * as Misskey from 'misskey-js'; import { onMounted, ref, useCssModule } from 'vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js'; +import type { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; +import type { DroppedFile } from '@/utility/file-drop.js'; +import type { GridSetting } from '@/components/grid/grid.js'; +import type { GridRow } from '@/components/grid/row.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { emptyStrToEmptyArray, emptyStrToNull, - RequestLogItem, roleIdsParser, } from '@/pages/admin/custom-emojis-manager.impl.js'; import MkGrid from '@/components/grid/MkGrid.vue'; import { i18n } from '@/i18n.js'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import { defaultStore } from '@/store.js'; import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { validators } from '@/components/grid/cell-validators.js'; -import { chooseFileFromDrive, chooseFileFromPc } from '@/scripts/select-file.js'; -import { uploadFile } from '@/scripts/upload.js'; -import { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; -import { DroppedFile, extractDroppedItems, flattenDroppedFiles } from '@/scripts/file-drop.js'; +import { chooseFileFromDrive, chooseFileFromPc } from '@/utility/select-file.js'; +import { uploadFile } from '@/utility/upload.js'; +import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js'; import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue'; -import { GridSetting } from '@/components/grid/grid.js'; import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js'; -import { GridRow } from '@/components/grid/row.js'; + +import { prefer } from '@/preferences.js'; const MAXIMUM_EMOJI_REGISTER_COUNT = 100; @@ -122,7 +124,7 @@ type GridItem = { localOnly: boolean; roleIdsThatCanBeUsedThisEmojiAsReaction: { id: string, name: string }[]; type: string | null; -} +}; function setupGrid(): GridSetting { const $style = useCssModule(); @@ -242,8 +244,8 @@ function setupGrid(): GridSetting { const uploadFolders = ref<FolderItem[]>([]); const gridItems = ref<GridItem[]>([]); -const selectedFolderId = ref(defaultStore.state.uploadFolder); -const keepOriginalUploading = ref(defaultStore.state.keepOriginalUploading); +const selectedFolderId = ref(prefer.s.uploadFolder); +const keepOriginalUploading = ref(prefer.s.keepOriginalUploading); const directoryToCategory = ref<boolean>(false); const registerButtonDisabled = ref<boolean>(false); const requestLogs = ref<RequestLogItem[]>([]); diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue index eecf8d7390..c868a700f1 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue @@ -143,35 +143,32 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, onMounted, ref, useCssModule } from 'vue'; import * as Misskey from 'misskey-js'; import MkRemoteEmojiEditDialog from '@/components/MkRemoteEmojiEditDialog.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkGrid from '@/components/grid/MkGrid.vue'; -import { - emptyStrToUndefined, - GridSortOrderKey, - gridSortOrderKeys, - RequestLogItem, -} from '@/pages/admin/custom-emojis-manager.impl.js'; -import { GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; +import { emptyStrToUndefined, gridSortOrderKeys } from '@/pages/admin/custom-emojis-manager.impl.js'; import MkFolder from '@/components/MkFolder.vue'; import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue'; import * as os from '@/os.js'; -import { GridSetting } from '@/components/grid/grid.js'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { deviceKind } from '@/utility/device-kind.js'; import MkPagingButtons from '@/components/MkPagingButtons.vue'; import MkSortOrderEditor from '@/components/MkSortOrderEditor.vue'; -import { SortOrder } from '@/components/MkSortOrderEditor.define.js'; import { useLoading } from '@/components/hook/useLoading.js'; +import type { GridSortOrderKey, RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js'; +import type { GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; +import type { GridSetting } from '@/components/grid/grid.js'; +import type { SortOrder } from '@/components/MkSortOrderEditor.define.js'; + type GridItem = { checked: boolean; id: string; url: string; name: string; host: string; -} +}; function setupGrid(): GridSetting { const $style = useCssModule(); diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts b/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts index f62304277a..3384a71f0f 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts +++ b/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts @@ -4,7 +4,7 @@ */ import { delay, http, HttpResponse } from 'msw'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { entities } from 'misskey-js'; import { commonHandlers } from '../../../.storybook/mocks.js'; import { emoji } from '../../../.storybook/fakes.js'; diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager2.vue b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue index fb930064ff..7667206fa8 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager2.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import { computed, ref } from 'vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import XGridLocalComponent from '@/pages/admin/custom-emojis-manager.local.vue'; import XGridRemoteComponent from '@/pages/admin/custom-emojis-manager.remote.vue'; import MkPageHeader from '@/components/global/MkPageHeader.vue'; @@ -36,7 +36,7 @@ const headerTabs = computed(() => [{ title: i18n.ts.remote, }]); -definePageMetadata(computed(() => ({ +definePage(computed(() => ({ title: i18n.ts.customEmojis, icon: 'ti ti-icons', needWideArea: true, diff --git a/packages/frontend/src/pages/admin/database.vue b/packages/frontend/src/pages/admin/database.vue index e092efd92c..6691142a64 100644 --- a/packages/frontend/src/pages/admin/database.vue +++ b/packages/frontend/src/pages/admin/database.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800" :marginMin="16" :marginMax="32"> <FormSuspense v-slot="{ result: database }" :p="databasePromiseFactory"> <MkKeyValue v-for="table in database" :key="table[0]" oneline style="margin: 1em 0;"> @@ -14,18 +13,18 @@ SPDX-License-Identifier: AGPL-3.0-only </MkKeyValue> </FormSuspense> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed } from 'vue'; import FormSuspense from '@/components/form/suspense.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import bytes from '@/filters/bytes.js'; import number from '@/filters/number.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const databasePromiseFactory = () => misskeyApi('admin/get-table-stats').then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size)); @@ -33,7 +32,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.database, icon: 'ti ti-database', })); diff --git a/packages/frontend/src/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue index 2f1d12ebb5..d018977bda 100644 --- a/packages/frontend/src/pages/admin/email-settings.vue +++ b/packages/frontend/src/pages/admin/email-settings.vue @@ -73,10 +73,10 @@ import FormSuspense from '@/components/form/suspense.vue'; import FormSplit from '@/components/form/split.vue'; import FormSection from '@/components/form/section.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance, instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; const enableEmail = ref<boolean>(false); @@ -130,7 +130,7 @@ function save() { const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.emailServer, icon: 'ti ti-mail', })); diff --git a/packages/frontend/src/pages/admin/external-services.vue b/packages/frontend/src/pages/admin/external-services.vue index 8cff014104..ab168c138d 100644 --- a/packages/frontend/src/pages/admin/external-services.vue +++ b/packages/frontend/src/pages/admin/external-services.vue @@ -10,6 +10,18 @@ SPDX-License-Identifier: AGPL-3.0-only <FormSuspense :p="init"> <div class="_gaps_m"> <MkFolder> + <template #label>Google Analytics<span class="_beta">{{ i18n.ts.beta }}</span></template> + + <div class="_gaps_m"> + <MkInput v-model="googleAnalyticsMeasurementId"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Measurement ID</template> + </MkInput> + <MkButton primary @click="save_googleAnalytics">Save</MkButton> + </div> + </MkFolder> + + <MkFolder> <template #label>DeepL Translation</template> <div class="_gaps_m"> @@ -65,10 +77,10 @@ import MkButton from '@/components/MkButton.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkFolder from '@/components/MkFolder.vue'; const deeplAuthKey = ref<string | null>(''); @@ -78,14 +90,17 @@ const deeplFreeInstance = ref<string | null>(''); const libreTranslateURL = ref<string | null>(''); const libreTranslateKey = ref<string | null>(''); +const googleAnalyticsMeasurementId = ref<string>(''); + async function init() { const meta = await misskeyApi('admin/meta'); - deeplAuthKey.value = meta.deeplAuthKey; + deeplAuthKey.value = meta.deeplAuthKey ?? ''; deeplIsPro.value = meta.deeplIsPro; deeplFreeMode.value = meta.deeplFreeMode; deeplFreeInstance.value = meta.deeplFreeInstance; libreTranslateURL.value = meta.libreTranslateURL; libreTranslateKey.value = meta.libreTranslateKey; + googleAnalyticsMeasurementId.value = meta.googleAnalyticsMeasurementId ?? ''; } function save_deepl() { @@ -108,11 +123,19 @@ function save_libre() { }); } +function save_googleAnalytics() { + os.apiWithDialog('admin/update-meta', { + googleAnalyticsMeasurementId: googleAnalyticsMeasurementId.value, + }).then(() => { + fetchInstance(true); + }); +} + const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.externalServices, icon: 'ph-arrow-square-out ph-bold ph-lg', })); diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue index 188678c183..6c8979c5b5 100644 --- a/packages/frontend/src/pages/admin/federation.vue +++ b/packages/frontend/src/pages/admin/federation.vue @@ -69,7 +69,7 @@ import MkPagination from '@/components/MkPagination.vue'; import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; import FormSplit from '@/components/form/split.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const host = ref(''); const state = ref('federating'); @@ -116,7 +116,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.federation, icon: 'ti ti-whirl', })); diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue index 4cc859227f..e15724c2a7 100644 --- a/packages/frontend/src/pages/admin/files.vue +++ b/packages/frontend/src/pages/admin/files.vue @@ -42,9 +42,9 @@ import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; import * as os from '@/os.js'; -import { lookupFile } from '@/scripts/admin-lookup.js'; +import { lookupFile } from '@/utility/admin-lookup.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const origin = ref('local'); const type = ref<string | null>(null); @@ -85,7 +85,7 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.files, icon: 'ti ti-cloud', })); diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 3a95e0a5a6..3142c5a45d 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -27,24 +27,25 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSpacer> </div> <div v-if="!(narrow && currentPage?.route.name == null)" class="main"> - <RouterView nested/> + <NestedRouterView/> </div> </div> </template> <script lang="ts" setup> import { onActivated, onMounted, onUnmounted, provide, watch, ref, computed } from 'vue'; +import type { SuperMenuDef } from '@/components/MkSuperMenu.vue'; +import type { PageMetadata } from '@/page.js'; import { i18n } from '@/i18n.js'; import MkSuperMenu from '@/components/MkSuperMenu.vue'; -import type { SuperMenuDef } from '@/components/MkSuperMenu.vue'; import MkInfo from '@/components/MkInfo.vue'; import { instance } from '@/instance.js'; -import { lookup } from '@/scripts/lookup.js'; +import { lookup } from '@/utility/lookup.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { lookupUser, lookupUserByEmail, lookupFile } from '@/scripts/admin-lookup.js'; -import { PageMetadata, definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; -import { useRouter } from '@/router/supplier.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { lookupUser, lookupUserByEmail, lookupFile } from '@/utility/admin-lookup.js'; +import { definePage, provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; +import { useRouter } from '@/router.js'; const isEmpty = (x: string | null) => x == null || x === ''; @@ -339,7 +340,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => INFO.value); +definePage(() => INFO.value); defineExpose({ header: { diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue index c4f2c292e0..7dae1db839 100644 --- a/packages/frontend/src/pages/admin/invites.vue +++ b/packages/frontend/src/pages/admin/invites.vue @@ -55,21 +55,22 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, ref, shallowRef } from 'vue'; +import { computed, ref, useTemplateRef } from 'vue'; import XHeader from './_header_.vue'; +import type { Paging } from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import MkInviteCode from '@/components/MkInviteCode.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; -const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); +const pagingComponent = useTemplateRef('pagingComponent'); const type = ref('all'); const sort = ref('+createdAt'); @@ -113,7 +114,7 @@ function deleted(id: string) { const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.invite, icon: 'ti ti-user-plus', })); diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 27cbfda078..d6be7a5cf4 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -162,10 +162,10 @@ import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import FormLink from '@/components/form/link.vue'; import MkFolder from '@/components/MkFolder.vue'; @@ -319,7 +319,7 @@ function save_mediaSilencedHosts() { const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.moderation, icon: 'ti ti-shield', })); diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index 9ce6499e2d..67db9ed6d3 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -274,6 +274,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-else-if="log.type === 'removeRelay'"> <div>{{ i18n.ts.inboxUrl }}: {{ log.info.inbox }}</div> </template> + <template v-else-if="log.type === 'updateProxyAccountDescription'"> + <div :class="$style.diff"> + <CodeDiff :context="5" :hideHeader="true" :oldString="log.info.before ?? ''" :newString="log.info.after ?? ''" maxHeight="300px"/> + </div> + </template> <details> <summary>raw</summary> diff --git a/packages/frontend/src/pages/admin/modlog.vue b/packages/frontend/src/pages/admin/modlog.vue index 35f939f1be..32a20e6dd4 100644 --- a/packages/frontend/src/pages/admin/modlog.vue +++ b/packages/frontend/src/pages/admin/modlog.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, shallowRef, ref } from 'vue'; +import { computed, useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import XHeader from './_header_.vue'; import XModLog from './modlog.ModLog.vue'; @@ -38,10 +38,10 @@ import MkSelect from '@/components/MkSelect.vue'; import MkInput from '@/components/MkInput.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; -const logs = shallowRef<InstanceType<typeof MkPagination>>(); +const logs = useTemplateRef('logs'); const type = ref<string | null>(null); const moderatorId = ref(''); @@ -59,7 +59,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.moderationLogs, icon: 'ti ti-list-search', })); diff --git a/packages/frontend/src/pages/admin/object-storage.vue b/packages/frontend/src/pages/admin/object-storage.vue index d5a664934c..da96eb4881 100644 --- a/packages/frontend/src/pages/admin/object-storage.vue +++ b/packages/frontend/src/pages/admin/object-storage.vue @@ -90,10 +90,10 @@ import MkInput from '@/components/MkInput.vue'; import FormSuspense from '@/components/form/suspense.vue'; import FormSplit from '@/components/form/split.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; const useObjectStorage = ref<boolean>(false); @@ -149,7 +149,7 @@ function save() { const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.objectStorage, icon: 'ti ti-cloud', })); diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue index 79dd6fd5fd..5b7f669f6b 100644 --- a/packages/frontend/src/pages/admin/overview.active-users.vue +++ b/packages/frontend/src/pages/admin/overview.active-users.vue @@ -13,18 +13,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; +import { onMounted, useTemplateRef, ref } from 'vue'; import { Chart } from 'chart.js'; import gradient from 'chartjs-plugin-gradient'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { initChart } from '@/scripts/init-chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); -const chartEl = shallowRef<HTMLCanvasElement>(null); +const chartEl = useTemplateRef('chartEl'); const now = new Date(); let chartInstance: Chart = null; const chartLimit = 7; @@ -54,7 +54,7 @@ async function renderChart() { const raw = await misskeyApi('charts/active-users', { limit: chartLimit, span: 'day' }); - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const colorRead = '#3498db'; const colorWrite = '#2ecc71'; diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.stories.impl.ts b/packages/frontend/src/pages/admin/overview.ap-requests.stories.impl.ts index 584cd3e4d9..88747ef4ed 100644 --- a/packages/frontend/src/pages/admin/overview.ap-requests.stories.impl.ts +++ b/packages/frontend/src/pages/admin/overview.ap-requests.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { http, HttpResponse } from 'msw'; import { action } from '@storybook/addon-actions'; import { commonHandlers } from '../../../.storybook/mocks.js'; diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue index 570fcddc07..4c06d94d6d 100644 --- a/packages/frontend/src/pages/admin/overview.ap-requests.vue +++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue @@ -20,22 +20,22 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; +import { onMounted, useTemplateRef, ref } from 'vue'; import { Chart } from 'chart.js'; import gradient from 'chartjs-plugin-gradient'; import isChromatic from 'chromatic'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { defaultStore } from '@/store.js'; -import { alpha } from '@/scripts/color.js'; -import { initChart } from '@/scripts/init-chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { store } from '@/store.js'; +import { alpha } from '@/utility/color.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); const chartLimit = 50; -const chartEl = shallowRef<HTMLCanvasElement>(); -const chartEl2 = shallowRef<HTMLCanvasElement>(); +const chartEl = useTemplateRef('chartEl'); +const chartEl2 = useTemplateRef('chartEl2'); const fetching = ref(true); const { handler: externalTooltipHandler } = useChartTooltip(); @@ -68,7 +68,7 @@ onMounted(async () => { const raw = await misskeyApi('charts/ap-request', { limit: chartLimit, span: 'day' }); - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const succColor = '#87e000'; const failColor = '#ff4400'; diff --git a/packages/frontend/src/pages/admin/overview.federation.vue b/packages/frontend/src/pages/admin/overview.federation.vue index 9062c73ff6..34007d309d 100644 --- a/packages/frontend/src/pages/admin/overview.federation.vue +++ b/packages/frontend/src/pages/admin/overview.federation.vue @@ -47,13 +47,14 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; -import XPie, { type InstanceForPie } from './overview.pie.vue'; +import XPie from './overview.pie.vue'; +import type { InstanceForPie } from './overview.pie.vue'; import * as os from '@/os.js'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; import number from '@/filters/number.js'; import MkNumberDiff from '@/components/MkNumberDiff.vue'; import { i18n } from '@/i18n.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; const topSubInstancesForPie = ref<InstanceForPie[] | null>(null); const topPubInstancesForPie = ref<InstanceForPie[] | null>(null); diff --git a/packages/frontend/src/pages/admin/overview.instances.vue b/packages/frontend/src/pages/admin/overview.instances.vue index 292e2e1dbc..c8291459fe 100644 --- a/packages/frontend/src/pages/admin/overview.instances.vue +++ b/packages/frontend/src/pages/admin/overview.instances.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> - <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? '_transition_zoom' : ''" mode="out-in"> <MkLoading v-if="fetching"/> <div v-else :class="$style.instances"> <MkA v-for="(instance, i) in instances" :key="instance.id" v-tooltip.mfm.noDelay="`${instance.name}\n${instance.host}\n${instance.softwareName} ${instance.softwareVersion}`" :to="`/instance-info/${instance.host}`" :class="$style.instance"> @@ -18,11 +18,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; import * as Misskey from 'misskey-js'; import { useInterval } from '@@/js/use-interval.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const instances = ref<Misskey.entities.FederationInstance[]>([]); const fetching = ref(true); diff --git a/packages/frontend/src/pages/admin/overview.moderators.vue b/packages/frontend/src/pages/admin/overview.moderators.vue index f0691534c8..fb2c5ea13c 100644 --- a/packages/frontend/src/pages/admin/overview.moderators.vue +++ b/packages/frontend/src/pages/admin/overview.moderators.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> - <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? '_transition_zoom' : ''" mode="out-in"> <MkLoading v-if="fetching"/> <div v-else :class="$style.root" class="_panel"> <MkA v-for="user in moderators" :key="user.id" class="user" :to="`/admin/user/${user.id}`"> @@ -18,9 +18,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; import * as Misskey from 'misskey-js'; -import { defaultStore } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { prefer } from '@/preferences.js'; const moderators = ref<Misskey.entities.UserDetailed[] | null>(null); const fetching = ref(true); diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue index a21ec6c464..86c5eff4da 100644 --- a/packages/frontend/src/pages/admin/overview.pie.vue +++ b/packages/frontend/src/pages/admin/overview.pie.vue @@ -8,10 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef } from 'vue'; +import { onMounted, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { initChart } from '@/scripts/init-chart.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { initChart } from '@/utility/init-chart.js'; export type InstanceForPie = { name: string, @@ -26,7 +26,7 @@ const props = defineProps<{ data: InstanceForPie[]; }>(); -const chartEl = shallowRef<HTMLCanvasElement>(null); +const chartEl = useTemplateRef('chartEl'); const { handler: externalTooltipHandler } = useChartTooltip({ position: 'middle', @@ -41,7 +41,7 @@ onMounted(() => { labels: props.data.map(x => x.name), datasets: [{ backgroundColor: props.data.map(x => x.color), - borderColor: getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-panel'), + borderColor: getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-panel'), borderWidth: 2, hoverOffset: 0, data: props.data.map(x => x.value), diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue index 2efc17c888..6fc941a848 100644 --- a/packages/frontend/src/pages/admin/overview.queue.chart.vue +++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue @@ -8,13 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef } from 'vue'; +import { onMounted, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { alpha } from '@/scripts/color.js'; -import { initChart } from '@/scripts/init-chart.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { alpha } from '@/utility/color.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); @@ -22,7 +22,7 @@ const props = defineProps<{ type: string; }>(); -const chartEl = shallowRef<HTMLCanvasElement>(null); +const chartEl = useTemplateRef('chartEl'); const { handler: externalTooltipHandler } = useChartTooltip(); @@ -67,7 +67,7 @@ const color = '?' as never; onMounted(() => { - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; chartInstance = new Chart(chartEl.value, { type: 'line', diff --git a/packages/frontend/src/pages/admin/overview.queue.vue b/packages/frontend/src/pages/admin/overview.queue.vue index de6b254412..cf07cddced 100644 --- a/packages/frontend/src/pages/admin/overview.queue.vue +++ b/packages/frontend/src/pages/admin/overview.queue.vue @@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue'; +import { markRaw, onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import XChart from './overview.queue.chart.vue'; import type { ApQueueDomain } from '@/pages/admin/queue.vue'; @@ -48,10 +48,10 @@ const activeSincePrevTick = ref(0); const active = ref(0); const delayed = ref(0); const waiting = ref(0); -const chartProcess = shallowRef<InstanceType<typeof XChart>>(); -const chartActive = shallowRef<InstanceType<typeof XChart>>(); -const chartDelayed = shallowRef<InstanceType<typeof XChart>>(); -const chartWaiting = shallowRef<InstanceType<typeof XChart>>(); +const chartProcess = useTemplateRef('chartProcess'); +const chartActive = useTemplateRef('chartActive'); +const chartDelayed = useTemplateRef('chartDelayed'); +const chartWaiting = useTemplateRef('chartWaiting'); const props = defineProps<{ domain: ApQueueDomain; diff --git a/packages/frontend/src/pages/admin/overview.stats.vue b/packages/frontend/src/pages/admin/overview.stats.vue index a967b305f7..9284d886d9 100644 --- a/packages/frontend/src/pages/admin/overview.stats.vue +++ b/packages/frontend/src/pages/admin/overview.stats.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> - <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? '_transition_zoom' : ''" mode="out-in"> <MkLoading v-if="fetching"/> <div v-else :class="$style.root"> <div class="item _panel users"> @@ -63,12 +63,12 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; import MkNumberDiff from '@/components/MkNumberDiff.vue'; import MkNumber from '@/components/MkNumber.vue'; import { i18n } from '@/i18n.js'; import { customEmojis } from '@/custom-emojis.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const stats = ref<Misskey.entities.StatsResponse | null>(null); const usersComparedToThePrevDay = ref<number>(); diff --git a/packages/frontend/src/pages/admin/overview.users.vue b/packages/frontend/src/pages/admin/overview.users.vue index 8c9d7a8197..6a39f4561f 100644 --- a/packages/frontend/src/pages/admin/overview.users.vue +++ b/packages/frontend/src/pages/admin/overview.users.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> - <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? '_transition_zoom' : ''" mode="out-in"> <MkLoading v-if="fetching"/> <div v-else class="users"> <MkA v-for="(user, i) in newUsers" :key="user.id" :to="`/admin/user/${user.id}`" class="user"> @@ -18,11 +18,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; import * as Misskey from 'misskey-js'; import { useInterval } from '@@/js/use-interval.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const newUsers = ref<Misskey.entities.UserDetailed[] | null>(null); const fetching = ref(true); diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue index 1de4dc0dc8..616815a6a6 100644 --- a/packages/frontend/src/pages/admin/overview.vue +++ b/packages/frontend/src/pages/admin/overview.vue @@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { markRaw, onMounted, onBeforeUnmount, nextTick, shallowRef, ref, computed } from 'vue'; +import { markRaw, onMounted, onBeforeUnmount, nextTick, shallowRef, ref, computed, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import XFederation from './overview.federation.vue'; import XInstances from './overview.instances.vue'; @@ -79,13 +79,13 @@ import XModerators from './overview.moderators.vue'; import XHeatmap from './overview.heatmap.vue'; import type { InstanceForPie } from './overview.pie.vue'; import * as os from '@/os.js'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -const rootEl = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); const serverInfo = ref<Misskey.entities.ServerInfoResponse | null>(null); const topSubInstancesForPie = ref<InstanceForPie[] | null>(null); const topPubInstancesForPie = ref<InstanceForPie[] | null>(null); @@ -184,7 +184,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.dashboard, icon: 'ti ti-dashboard', })); diff --git a/packages/frontend/src/pages/admin/performance.vue b/packages/frontend/src/pages/admin/performance.vue index 12338f0bf9..6bb0918fea 100644 --- a/packages/frontend/src/pages/admin/performance.vue +++ b/packages/frontend/src/pages/admin/performance.vue @@ -111,15 +111,15 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed } from 'vue'; import XHeader from './_header_.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkSwitch from '@/components/MkSwitch.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkInput from '@/components/MkInput.vue'; import MkLink from '@/components/MkLink.vue'; -import { useForm } from '@/scripts/use-form.js'; +import { useForm } from '@/use/use-form.js'; import MkFormFooter from '@/components/MkFormFooter.vue'; const meta = await misskeyApi('admin/meta'); @@ -202,7 +202,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.other, icon: 'ti ti-adjustments', })); diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/queue.chart.chart.vue index cc18898172..5dd2887024 100644 --- a/packages/frontend/src/pages/admin/queue.chart.chart.vue +++ b/packages/frontend/src/pages/admin/queue.chart.chart.vue @@ -8,13 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef } from 'vue'; +import { onMounted, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { alpha } from '@/scripts/color.js'; -import { initChart } from '@/scripts/init-chart.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { alpha } from '@/utility/color.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); @@ -22,7 +22,7 @@ const props = defineProps<{ type: string; }>(); -const chartEl = shallowRef<HTMLCanvasElement>(null); +const chartEl = useTemplateRef('chartEl'); const { handler: externalTooltipHandler } = useChartTooltip(); @@ -67,7 +67,7 @@ const color = '?' as never; onMounted(() => { - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; chartInstance = new Chart(chartEl.value, { type: 'line', diff --git a/packages/frontend/src/pages/admin/queue.chart.vue b/packages/frontend/src/pages/admin/queue.chart.vue index 7c171ba0e1..1ba02d6e0e 100644 --- a/packages/frontend/src/pages/admin/queue.chart.vue +++ b/packages/frontend/src/pages/admin/queue.chart.vue @@ -48,12 +48,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue'; +import { markRaw, onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import XChart from './queue.chart.chart.vue'; import type { ApQueueDomain } from '@/pages/admin/queue.vue'; import number from '@/filters/number.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; @@ -65,10 +65,10 @@ const active = ref(0); const delayed = ref(0); const waiting = ref(0); const jobs = ref<Misskey.Endpoints[`admin/queue/${ApQueueDomain}-delayed`]['res']>([]); -const chartProcess = shallowRef<InstanceType<typeof XChart>>(); -const chartActive = shallowRef<InstanceType<typeof XChart>>(); -const chartDelayed = shallowRef<InstanceType<typeof XChart>>(); -const chartWaiting = shallowRef<InstanceType<typeof XChart>>(); +const chartProcess = useTemplateRef('chartProcess'); +const chartActive = useTemplateRef('chartActive'); +const chartDelayed = useTemplateRef('chartDelayed'); +const chartWaiting = useTemplateRef('chartWaiting'); const props = defineProps<{ domain: ApQueueDomain; diff --git a/packages/frontend/src/pages/admin/queue.vue b/packages/frontend/src/pages/admin/queue.vue index 512039242e..b5aee1e51e 100644 --- a/packages/frontend/src/pages/admin/queue.vue +++ b/packages/frontend/src/pages/admin/queue.vue @@ -16,13 +16,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, computed, type Ref } from 'vue'; +import { ref, computed } from 'vue'; +import * as config from '@@/js/config.js'; import XQueue from './queue.chart.vue'; import XHeader from './_header_.vue'; +import type { Ref } from 'vue'; import * as os from '@/os.js'; -import * as config from '@@/js/config.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; export type ApQueueDomain = 'deliver' | 'inbox'; @@ -53,14 +54,7 @@ function promoteAllQueues() { }); } -const headerActions = computed(() => [{ - asFullButton: true, - icon: 'ti ti-external-link', - text: i18n.ts.dashboard, - handler: () => { - window.open(config.url + '/queue', '_blank', 'noopener'); - }, -}]); +const headerActions = computed(() => []); const headerTabs = computed(() => [{ key: 'deliver', @@ -70,7 +64,7 @@ const headerTabs = computed(() => [{ title: 'Inbox', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.jobQueue, icon: 'ti ti-clock-play', })); diff --git a/packages/frontend/src/pages/admin/relays.vue b/packages/frontend/src/pages/admin/relays.vue index 17e99e6593..a6280e7075 100644 --- a/packages/frontend/src/pages/admin/relays.vue +++ b/packages/frontend/src/pages/admin/relays.vue @@ -29,9 +29,9 @@ import * as Misskey from 'misskey-js'; import XHeader from './_header_.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const relays = ref<Misskey.entities.AdminRelaysListResponse>([]); @@ -84,7 +84,7 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.relays, icon: 'ti ti-planet', })); diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue index 2b4006c3f7..7741064685 100644 --- a/packages/frontend/src/pages/admin/roles.edit.vue +++ b/packages/frontend/src/pages/admin/roles.edit.vue @@ -28,12 +28,12 @@ import { v4 as uuid } from 'uuid'; import XHeader from './_header_.vue'; import XEditor from './roles.editor.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import { rolesCache } from '@/cache.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -87,7 +87,7 @@ async function save() { const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: role.value ? `${i18n.ts._role.edit}: ${role.value.name}` : i18n.ts._role.new, icon: 'ti ti-badge', })); diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 6bab594d36..f6c24c13da 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -219,6 +219,26 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canChat, 'canChat'])"> + <template #label>{{ i18n.ts._role._options.canChat }}</template> + <template #suffix> + <span v-if="role.policies.canChat.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.canChat.value ? i18n.ts.yes : i18n.ts.no }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canChat)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.canChat.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkSwitch v-model="role.policies.canChat.value" :disabled="role.policies.canChat.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + <MkRange v-model="role.policies.canChat.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <template #label>{{ i18n.ts._role.priority }}</template> + </MkRange> + </div> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])"> <template #label>{{ i18n.ts._role._options.mentionMax }}</template> <template #suffix> @@ -769,7 +789,7 @@ import MkRange from '@/components/MkRange.vue'; import FormSlot from '@/components/form/slot.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { deepClone } from '@/scripts/clone.js'; +import { deepClone } from '@/utility/clone.js'; const emit = defineEmits<{ (ev: 'update:modelValue', v: any): void; diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue index d1c7be39d6..9e21aba8a0 100644 --- a/packages/frontend/src/pages/admin/roles.role.vue +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination :pagination="usersPagination" :displayLimit="50"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.noUsers }}</div> </div> </template> @@ -67,15 +67,15 @@ import XHeader from './_header_.vue'; import XEditor from './roles.editor.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkPagination from '@/components/MkPagination.vue'; import { infoImageUrl } from '@/instance.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -170,7 +170,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: `${i18n.ts.role}: ${role.name}`, icon: 'ti ti-badge', })); diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index f67b1cd582..6038a1237f 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -77,6 +77,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canChat, 'canChat'])"> + <template #label>{{ i18n.ts._role._options.canChat }}</template> + <template #suffix>{{ policies.canChat ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.canChat"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])"> <template #label>{{ i18n.ts._role._options.mentionMax }}</template> <template #suffix>{{ policies.mentionLimit }}</template> @@ -317,12 +325,12 @@ import MkRange from '@/components/MkRange.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkRolePreview from '@/components/MkRolePreview.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { instance, fetchInstance } from '@/instance.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); const baseRoleQ = ref(''); @@ -365,7 +373,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.roles, icon: 'ti ti-badges', })); diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue index 38986dc977..84827b799f 100644 --- a/packages/frontend/src/pages/admin/security.vue +++ b/packages/frontend/src/pages/admin/security.vue @@ -103,11 +103,11 @@ import MkRange from '@/components/MkRange.vue'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { useForm } from '@/scripts/use-form.js'; +import { definePage } from '@/page.js'; +import { useForm } from '@/use/use-form.js'; import MkFormFooter from '@/components/MkFormFooter.vue'; const meta = await misskeyApi('admin/meta'); @@ -162,7 +162,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.security, icon: 'ti ti-lock', })); diff --git a/packages/frontend/src/pages/admin/server-rules.vue b/packages/frontend/src/pages/admin/server-rules.vue index 6c1227b3cf..3b0f1066f5 100644 --- a/packages/frontend/src/pages/admin/server-rules.vue +++ b/packages/frontend/src/pages/admin/server-rules.vue @@ -46,7 +46,7 @@ import XHeader from './_header_.vue'; import * as os from '@/os.js'; import { fetchInstance, instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -67,7 +67,7 @@ const remove = (index: number): void => { const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.serverRules, icon: 'ti ti-checkbox', })); @@ -122,7 +122,7 @@ definePageMetadata(() => ({ border-radius: var(--MI-radius-sm); &:hover { - background: var(--MI_THEME-X5); + background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); } } diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 0e9c0a7d38..1f5fcc2d33 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -270,15 +270,17 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFolder> <template #icon><i class="ti ti-ghost"></i></template> <template #label>{{ i18n.ts.proxyAccount }}</template> + <template v-if="proxyAccountForm.modified.value" #footer> + <MkFormFooter :form="proxyAccountForm"/> + </template> <div class="_gaps"> <MkInfo>{{ i18n.ts.proxyAccountDescription }}</MkInfo> - <MkKeyValue> - <template #key>{{ i18n.ts.proxyAccount }}</template> - <template #value>{{ proxyAccount ? `@${proxyAccount.username}` : i18n.ts.none }}</template> - </MkKeyValue> - <MkButton primary @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</MkButton> + <MkTextarea v-model="proxyAccountForm.state.description" :max="500" tall mfmAutocomplete :mfmPreview="true"> + <template #label>{{ i18n.ts._profile.description }}</template> + <template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template> + </MkTextarea> </div> </MkFolder> </div> @@ -288,7 +290,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, computed } from 'vue'; +import { ref, computed, reactive } from 'vue'; import XHeader from './_header_.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkInput from '@/components/MkInput.vue'; @@ -296,20 +298,20 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkInfo from '@/components/MkInfo.vue'; import FormSplit from '@/components/form/split.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance, instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; -import { useForm } from '@/scripts/use-form.js'; +import { useForm } from '@/use/use-form.js'; import MkFormFooter from '@/components/MkFormFooter.vue'; import MkRadios from '@/components/MkRadios.vue'; const meta = await misskeyApi('admin/meta'); -const proxyAccount = ref(meta.proxyAccountId ? await misskeyApi('users/show', { userId: meta.proxyAccountId }) : null); +const proxyAccount = await misskeyApi('users/show', { userId: meta.proxyAccountId }); const infoForm = useForm({ name: meta.name ?? '', @@ -425,16 +427,14 @@ const federationForm = useForm({ fetchInstance(true); }); -function chooseProxyAccount() { - os.selectUser({ localOnly: true }).then(user => { - proxyAccount.value = user; - os.apiWithDialog('admin/update-meta', { - proxyAccountId: user.id, - }).then(() => { - fetchInstance(true); - }); +const proxyAccountForm = useForm({ + description: proxyAccount.description, +}, async (state) => { + await os.apiWithDialog('admin/update-proxy-account', { + description: state.description, }); -} + fetchInstance(true); +}); async function genKeys() { if (serviceWorkerForm.savedState.swPrivateKey) { @@ -450,7 +450,7 @@ async function genKeys() { const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.general, icon: 'ti ti-settings', })); diff --git a/packages/frontend/src/pages/admin/system-webhook.vue b/packages/frontend/src/pages/admin/system-webhook.vue index c59abda24a..d8eb9b92ee 100644 --- a/packages/frontend/src/pages/admin/system-webhook.vue +++ b/packages/frontend/src/pages/admin/system-webhook.vue @@ -30,11 +30,11 @@ import { computed, onMounted, ref } from 'vue'; import { entities } from 'misskey-js'; import XItem from './system-webhook.item.vue'; import FormSection from '@/components/form/section.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import XHeader from '@/pages/admin/_header_.vue'; import MkButton from '@/components/MkButton.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js'; import * as os from '@/os.js'; @@ -82,7 +82,7 @@ onMounted(async () => { await fetchWebhooks(); }); -definePageMetadata(() => ({ +definePage(() => ({ title: 'SystemWebhook', icon: 'ti ti-webhook', })); diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue index c693eed850..e33ec8a1e6 100644 --- a/packages/frontend/src/pages/admin/users.vue +++ b/packages/frontend/src/pages/admin/users.vue @@ -10,6 +10,9 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer :contentMax="900"> <div class="_gaps"> <div :class="$style.inputs"> + <MkButton style="margin-left: auto" @click="resetQuery">{{ i18n.ts.reset }}</MkButton> + </div> + <div :class="$style.inputs"> <MkSelect v-model="sort" style="flex: 1;"> <template #label>{{ i18n.ts.sort }}</template> <option value="-createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.ascendingOrder }})</option> @@ -58,25 +61,36 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, shallowRef, ref } from 'vue'; +import { computed, useTemplateRef, ref, watchEffect } from 'vue'; import XHeader from './_header_.vue'; +import { defaultMemoryStorage } from '@/memory-storage'; +import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkPagination from '@/components/MkPagination.vue'; import * as os from '@/os.js'; -import { lookupUser } from '@/scripts/admin-lookup.js'; +import { lookupUser } from '@/utility/admin-lookup.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import { dateString } from '@/filters/date.js'; -const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>(); +type SearchQuery = { + sort?: string; + state?: string; + origin?: string; + username?: string; + hostname?: string; +}; + +const paginationComponent = useTemplateRef('paginationComponent'); +const storedQuery = JSON.parse(defaultMemoryStorage.getItem('admin-users-query') ?? '{}') as SearchQuery; -const sort = ref('+createdAt'); -const state = ref('all'); -const origin = ref('local'); -const searchUsername = ref(''); -const searchHost = ref(''); +const sort = ref(storedQuery.sort ?? '+createdAt'); +const state = ref(storedQuery.state ?? 'all'); +const origin = ref(storedQuery.origin ?? 'local'); +const searchUsername = ref(storedQuery.username ?? ''); +const searchHost = ref(storedQuery.hostname ?? ''); const pagination = { endpoint: 'admin/show-users' as const, limit: 10, @@ -120,6 +134,14 @@ function show(user) { os.pageWindow(`/admin/user/${user.id}`); } +function resetQuery() { + sort.value = '+createdAt'; + state.value = 'all'; + origin.value = 'local'; + searchUsername.value = ''; + searchHost.value = ''; +} + const headerActions = computed(() => [{ icon: 'ti ti-search', text: i18n.ts.search, @@ -138,7 +160,17 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +watchEffect(() => { + defaultMemoryStorage.setItem('admin-users-query', JSON.stringify({ + sort: sort.value, + state: state.value, + origin: origin.value, + username: searchUsername.value, + hostname: searchHost.value, + })); +}); + +definePage(() => ({ title: i18n.ts.users, icon: 'ti ti-users', })); diff --git a/packages/frontend/src/pages/ads.vue b/packages/frontend/src/pages/ads.vue index b31807f9f5..700ac0bd1a 100644 --- a/packages/frontend/src/pages/ads.vue +++ b/packages/frontend/src/pages/ads.vue @@ -4,23 +4,21 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> - +<PageWithHeader> <MkSpacer :contentMax="500"> <div class="_gaps"> <MkAd v-for="ad in instance.ads" :key="ad.id" :specify="ad"/> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.ads, icon: 'ti ti-ad', })); diff --git a/packages/frontend/src/pages/announcement.vue b/packages/frontend/src/pages/announcement.vue index 56c10fb292..2e0c7d2f42 100644 --- a/packages/frontend/src/pages/announcement.vue +++ b/packages/frontend/src/pages/announcement.vue @@ -4,14 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.fadeEnterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.fadeLeaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.fadeEnterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.fadeLeaveTo : ''" mode="out-in" > <div v-if="announcement" :key="announcement.id" class="_panel" :class="$style.announcement"> @@ -44,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLoading v-else/> </Transition> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -52,11 +51,12 @@ import { ref, computed, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { $i, updateAccountPartial } from '@/account.js'; -import { defaultStore } from '@/store.js'; +import { definePage } from '@/page.js'; +import { $i } from '@/i.js'; +import { prefer } from '@/preferences.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; const props = defineProps<{ announcementId: string; @@ -90,7 +90,7 @@ async function read(target: Misskey.entities.Announcement): Promise<void> { target.isRead = true; await misskeyApi('i/read-announcement', { announcementId: target.id }); if ($i) { - updateAccountPartial({ + updateCurrentAccountPartial({ unreadAnnouncements: $i.unreadAnnouncements.filter((a: { id: string; }) => a.id !== target.id), }); } @@ -102,7 +102,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: announcement.value ? announcement.value.title : i18n.ts.announcements, icon: 'ti ti-speakerphone', })); diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index 79b9e7607d..36e9e4342a 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div :key="tab" class="_gaps"> + <div class="_gaps"> <MkInfo v-if="$i && $i.hasUnreadAnnouncement && tab === 'current'" warn>{{ i18n.ts.youHaveUnreadAnnouncements }}</MkInfo> <MkPagination ref="paginationEl" :key="tab" v-slot="{items}" :pagination="tab === 'current' ? paginationCurrent : paginationPast" class="_gaps"> <section v-for="announcement in items" :key="announcement.id" class="_panel" :class="$style.announcement"> @@ -43,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkHorizontalSwipe> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -53,10 +52,11 @@ import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { $i, updateAccountPartial } from '@/account.js'; +import { definePage } from '@/page.js'; +import { $i } from '@/i.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; const paginationCurrent = { endpoint: 'announcements' as const, @@ -94,7 +94,7 @@ async function read(target) { return a; }); misskeyApi('i/read-announcement', { announcementId: target.id }); - updateAccountPartial({ + updateCurrentAccountPartial({ unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id), }); } @@ -111,7 +111,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-point', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.announcements, icon: 'ti ti-speakerphone', })); diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue index 350f772d65..400c19ce93 100644 --- a/packages/frontend/src/pages/antenna-timeline.vue +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :displayBackButton="true" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :displayBackButton="true" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <div ref="rootEl"> <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> @@ -20,19 +19,19 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> -import { computed, watch, ref, shallowRef } from 'vue'; +import { computed, watch, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; -import MkTimeline from '@/components/MkTimeline.vue'; import { scroll } from '@@/js/scroll.js'; +import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -42,8 +41,8 @@ const props = defineProps<{ const antenna = ref<Misskey.entities.Antenna | null>(null); const queue = ref(0); -const rootEl = shallowRef<HTMLElement>(); -const tlEl = shallowRef<InstanceType<typeof MkTimeline>>(); +const rootEl = useTemplateRef('rootEl'); +const tlEl = useTemplateRef('tlEl'); function queueUpdated(q) { queue.value = q; @@ -88,7 +87,7 @@ const headerActions = computed(() => antenna.value ? [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: antenna.value ? antenna.value.name : i18n.ts.antennas, icon: 'ti ti-antenna', })); diff --git a/packages/frontend/src/pages/api-console.vue b/packages/frontend/src/pages/api-console.vue index 1e3bb0de6b..53020bfb08 100644 --- a/packages/frontend/src/pages/api-console.vue +++ b/packages/frontend/src/pages/api-console.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> <div class="_gaps_m"> <div class="_gaps_m"> @@ -30,19 +29,19 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; import JSON5 from 'json5'; -import { Endpoints } from 'misskey-js'; +import type { Endpoints } from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; const body = ref('{}'); const endpoint = ref(''); @@ -87,7 +86,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: 'API console', icon: 'ti ti-terminal-2', })); diff --git a/packages/frontend/src/pages/auth.form.vue b/packages/frontend/src/pages/auth.form.vue index f4fb2ef4d5..5b1fd1a386 100644 --- a/packages/frontend/src/pages/auth.form.vue +++ b/packages/frontend/src/pages/auth.form.vue @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ @@ -38,7 +38,7 @@ const emit = defineEmits<{ const app = computed(() => props.session.app); const name = computed(() => { - const el = document.createElement('div'); + const el = window.document.createElement('div'); el.textContent = app.value.name; return el.innerHTML; }); diff --git a/packages/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue index 30e0e99326..898fa7996e 100644 --- a/packages/frontend/src/pages/auth.vue +++ b/packages/frontend/src/pages/auth.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="500"> <div v-if="state == 'fetch-session-error'"> <p>{{ i18n.ts.somethingHappened }}</p> @@ -38,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSignin @login="onLogin"/> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -46,10 +45,11 @@ import { onMounted, ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import XForm from './auth.form.vue'; import MkSignin from '@/components/MkSignin.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { $i, login } from '@/account.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { $i } from '@/i.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; +import { login } from '@/accounts.js'; const props = defineProps<{ token: string; @@ -84,7 +84,7 @@ function accepted() { } else if (session.value && session.value.app.callbackUrl) { const url = new URL(session.value.app.callbackUrl); if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:', 'vbscript:'].includes(url.protocol)) throw new Error('invalid url'); - location.href = `${session.value.app.callbackUrl}?token=${session.value.token}`; + window.location.href = `${session.value.app.callbackUrl}?token=${session.value.token}`; } } @@ -118,7 +118,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts._auth.shareAccessTitle, icon: 'ti ti-apps', })); diff --git a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue index a834f1c5fd..12fd867407 100644 --- a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue +++ b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue @@ -68,14 +68,14 @@ import MkInput from '@/components/MkInput.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import MkSwitch from '@/components/MkSwitch.vue'; import MkRolePreview from '@/components/MkRolePreview.vue'; import MkTextarea from '@/components/MkTextarea.vue'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const props = defineProps<{ avatarDecoration?: any, diff --git a/packages/frontend/src/pages/avatar-decorations.vue b/packages/frontend/src/pages/avatar-decorations.vue index a5cafb1678..2bab449089 100644 --- a/packages/frontend/src/pages/avatar-decorations.vue +++ b/packages/frontend/src/pages/avatar-decorations.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="900"> <div class="_gaps"> <div :class="$style.decorations"> @@ -22,19 +21,19 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { ref, computed, defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const avatarDecorations = ref<Misskey.entities.AdminAvatarDecorationsListResponse>([]); @@ -86,7 +85,7 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.avatarDecorations, icon: 'ti ti-sparkles', })); diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue index 6d8274a55c..084fee15cf 100644 --- a/packages/frontend/src/pages/channel-editor.vue +++ b/packages/frontend/src/pages/channel-editor.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> <div v-if="channelId == null || channel != null" class="_gaps_m"> <MkInput v-model="name"> @@ -65,7 +64,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -74,15 +73,15 @@ import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkColorInput from '@/components/MkColorInput.vue'; -import { selectFile } from '@/scripts/select-file.js'; +import { selectFile } from '@/utility/select-file.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkTextarea from '@/components/MkTextarea.vue'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -202,7 +201,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: props.channelId ? i18n.ts._channel.edit : i18n.ts._channel.create, icon: 'ti ti-device-tv', })); diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index fb99379a0a..303558a621 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :contentMax="700" :class="$style.main"> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> + <MkSpacer :contentMax="700"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="channel && tab === 'overview'" key="overview" class="_gaps"> + <div v-if="channel && tab === 'overview'" class="_gaps"> <div class="_panel" :class="$style.bannerContainer"> <XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/> <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike class="button" rounded primary :class="$style.favorite" @click="unfavorite()"><i class="ti ti-star"></i></MkButton> @@ -33,18 +32,18 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFoldableSection> </div> - <div v-if="channel && tab === 'timeline'" key="timeline" class="_gaps"> + <div v-if="channel && tab === 'timeline'" class="_gaps"> <MkInfo v-if="channel.isArchived" warn>{{ i18n.ts.thisChannelArchived }}</MkInfo> <!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる --> - <MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/> + <MkPostForm v-if="$i && prefer.r.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/> <MkTimeline :key="channelId + withRenotes + onlyFiles" src="channel" :channel="channelId" :withRenotes="withRenotes" :onlyFiles="onlyFiles" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/> </div> - <div v-else-if="tab === 'featured'" key="featured"> + <div v-else-if="tab === 'featured'"> <MkNotes :pagination="featuredPagination"/> </div> - <div v-else-if="tab === 'search'" key="search"> + <div v-else-if="tab === 'search'"> <div v-if="notesSearchAvailable" class="_gaps"> <div> <MkInput v-model="searchQuery" @enter="search()"> @@ -69,38 +68,38 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSpacer> </div> </template> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { url } from '@@/js/config.js'; +import type { PageHeaderItem } from '@/types/page-header.js'; import MkPostForm from '@/components/MkPostForm.vue'; import MkTimeline from '@/components/MkTimeline.vue'; import XChannelFollowButton from '@/components/MkChannelFollowButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { $i, iAmModerator } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { $i, iAmModerator } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { definePage } from '@/page.js'; +import { deviceKind } from '@/utility/device-kind.js'; import MkNotes from '@/components/MkNotes.vue'; -import { url } from '@@/js/config.js'; import { favoritedChannelsCache } from '@/cache.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; import MkNote from '@/components/MkNote.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; -import { PageHeaderItem } from '@/types/page-header.js'; -import { isSupportShare } from '@/scripts/navigator.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { notesSearchAvailable } from '@/scripts/check-permissions.js'; +import { isSupportShare } from '@/utility/navigator.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { notesSearchAvailable } from '@/utility/check-permissions.js'; import { miLocalStorage } from '@/local-storage.js'; -import { useRouter } from '@/router/supplier.js'; -import { deepMerge } from '@/scripts/merge.js'; +import { useRouter } from '@/router.js'; +import { deepMerge } from '@/utility/merge.js'; const router = useRouter(); @@ -241,7 +240,6 @@ const headerActions = computed(() => { return; } copyToClipboard(`${url}/channels/${channel.value.id}`); - os.success(); }, }); @@ -296,17 +294,13 @@ const headerTabs = computed(() => [{ icon: 'ti ti-search', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: channel.value ? channel.value.name : i18n.ts.channel, icon: 'ti ti-device-tv', })); </script> <style lang="scss" module> -.main { - min-height: calc(100cqh - (var(--MI-stickyTop, 0px) + var(--MI-stickyBottom, 0px))); -} - .footer { -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue index 6830c1ace4..76800aaf70 100644 --- a/packages/frontend/src/pages/channels.vue +++ b/packages/frontend/src/pages/channels.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="1200"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'search'" key="search" :class="$style.searchRoot"> + <div v-if="tab === 'search'" :class="$style.searchRoot"> <div class="_gaps"> <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search"> <template #prefix><i class="ti ti-search"></i></template> @@ -25,28 +24,28 @@ SPDX-License-Identifier: AGPL-3.0-only <MkChannelList :key="key" :pagination="channelPagination"/> </MkFoldableSection> </div> - <div v-if="tab === 'featured'" key="featured"> + <div v-if="tab === 'featured'"> <MkPagination v-slot="{items}" :pagination="featuredPagination"> <div :class="$style.root"> <MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/> </div> </MkPagination> </div> - <div v-else-if="tab === 'favorites'" key="favorites"> + <div v-else-if="tab === 'favorites'"> <MkPagination v-slot="{items}" :pagination="favoritesPagination"> <div :class="$style.root"> <MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/> </div> </MkPagination> </div> - <div v-else-if="tab === 'following'" key="following"> + <div v-else-if="tab === 'following'"> <MkPagination v-slot="{items}" :pagination="followingPagination"> <div :class="$style.root"> <MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/> </div> </MkPagination> </div> - <div v-else-if="tab === 'owned'" key="owned"> + <div v-else-if="tab === 'owned'"> <MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton> <MkPagination v-slot="{items}" :pagination="ownedPagination"> <div :class="$style.root"> @@ -56,7 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkHorizontalSwipe> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -69,9 +68,9 @@ import MkRadios from '@/components/MkRadios.vue'; import MkButton from '@/components/MkButton.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -161,7 +160,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-edit', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.channel, icon: 'ti ti-device-tv', })); diff --git a/packages/frontend/src/pages/chat/XMessage.vue b/packages/frontend/src/pages/chat/XMessage.vue new file mode 100644 index 0000000000..1e7f8e20ea --- /dev/null +++ b/packages/frontend/src/pages/chat/XMessage.vue @@ -0,0 +1,245 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[$style.root, { [$style.isMe]: isMe }]"> + <MkAvatar :class="$style.avatar" :user="message.fromUser" :link="!isMe" :preview="false"/> + <div :class="$style.body"> + <MkFukidashi :class="$style.fukidashi" :tail="isMe ? 'right' : 'left'" :accented="isMe"> + <div v-if="!message.isDeleted" :class="$style.content"> + <Mfm v-if="message.text" ref="text" class="_selectable" :text="message.text" :i="$i"/> + <MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/> + </div> + <div v-else :class="$style.content"> + <p>{{ i18n.ts.deleted }}</p> + </div> + </MkFukidashi> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/> + <div :class="$style.footer"> + <button class="_textButton" style="color: currentColor;" @click="showMenu"><i class="ti ti-dots-circle-horizontal"></i></button> + <MkTime :class="$style.time" :time="message.createdAt"/> + <MkA v-if="isSearchResult && message.toRoomId" :to="`/chat/room/${message.toRoomId}`">{{ message.toRoom.name }}</MkA> + <MkA v-if="isSearchResult && message.toUserId && isMe" :to="`/chat/user/${message.toUserId}`">@{{ message.toUser.username }}</MkA> + </div> + <TransitionGroup + :enterActiveClass="prefer.s.animation ? $style.transition_reaction_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_reaction_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_reaction_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_reaction_leaveTo : ''" + :moveClass="prefer.s.animation ? $style.transition_reaction_move : ''" + tag="div" :class="$style.reactions" + > + <div v-for="record in message.reactions" :key="record.reaction + record.user.id" :class="$style.reaction"> + <MkAvatar :user="record.user" :link="false" :class="$style.reactionAvatar"/> + <MkReactionIcon + :withTooltip="true" + :reaction="record.reaction.replace(/^:(\w+):$/, ':$1@.:')" + :noStyle="true" + :class="$style.reactionIcon" + /> + </div> + </TransitionGroup> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent } from 'vue'; +import * as mfm from 'mfm-js'; +import * as Misskey from 'misskey-js'; +import { url } from '@@/js/config.js'; +import type { MenuItem } from '@/types/menu.js'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; +import MkUrlPreview from '@/components/MkUrlPreview.vue'; +import { ensureSignin } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import MkFukidashi from '@/components/MkFukidashi.vue'; +import * as os from '@/os.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import MkMediaList from '@/components/MkMediaList.vue'; +import { reactionPicker } from '@/utility/reaction-picker.js'; +import * as sound from '@/utility/sound.js'; +import MkReactionIcon from '@/components/MkReactionIcon.vue'; +import { prefer } from '@/preferences.js'; + +const $i = ensureSignin(); + +const props = defineProps<{ + message: Misskey.entities.ChatMessageLite | Misskey.entities.ChatMessage; + isSearchResult?: boolean; +}>(); + +const isMe = computed(() => props.message.fromUserId === $i.id); +const urls = computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []); + +function react(ev: MouseEvent) { + reactionPicker.show(ev.currentTarget ?? ev.target, null, async (reaction) => { + sound.playMisskeySfx('reaction'); + + misskeyApi('chat/messages/react', { + messageId: props.message.id, + reaction: reaction, + }); + }); +} + +function showMenu(ev: MouseEvent) { + const menu: MenuItem[] = []; + + if (!isMe.value) { + menu.push({ + text: i18n.ts.reaction, + icon: 'ti ti-mood-plus', + action: (ev) => { + react(ev); + }, + }); + + menu.push({ + type: 'divider', + }); + } + + menu.push({ + text: i18n.ts.copyContent, + icon: 'ti ti-copy', + action: () => { + copyToClipboard(props.message.text); + }, + }); + + menu.push({ + type: 'divider', + }); + + if (isMe.value) { + menu.push({ + text: i18n.ts.delete, + icon: 'ti ti-trash', + danger: true, + action: () => { + misskeyApi('chat/messages/delete', { + messageId: props.message.id, + }); + }, + }); + } else { + menu.push({ + text: i18n.ts.reportAbuse, + icon: 'ti ti-exclamation-circle', + action: () => { + const localUrl = `${url}/chat/messages/${props.message.id}`; + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { + user: props.message.fromUser, + initialComment: `${localUrl}\n-----\n`, + }, { + closed: () => dispose(), + }); + }, + }); + } + + os.popupMenu(menu, ev.currentTarget ?? ev.target); +} +</script> + +<style lang="scss" module> +.transition_reaction_move, +.transition_reaction_enterActive, +.transition_reaction_leaveActive { + transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important; +} +.transition_reaction_enterFrom, +.transition_reaction_leaveTo { + opacity: 0; + transform: scale(0.7); +} +.transition_reaction_leaveActive { + position: absolute; +} + +.root { + position: relative; + display: flex; + + &.isMe { + flex-direction: row-reverse; + text-align: right; + + .content { + color: var(--MI_THEME-fgOnAccent); + } + + .footer { + flex-direction: row-reverse; + } + } +} + +.avatar { + position: sticky; + top: calc(16px + var(--MI-stickyTop, 0px)); + display: block; + width: 52px; + height: 52px; +} + +.body { + margin: 0 12px; +} + +.content { + overflow: clip; + overflow-wrap: break-word; + word-break: break-word; +} + +.file { +} + +.footer { + display: flex; + flex-direction: row; + gap: 0.5em; + margin-top: 4px; + font-size: 75%; +} + +.time { + opacity: 0.5; +} + +.reactions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 8px; + + &:empty { + display: none; + } +} + +.reaction { + display: flex; + align-items: center; + border: solid 1px var(--MI_THEME-divider); + border-radius: 999px; + padding: 8px; +} + +.reactionAvatar { + width: 24px; + height: 24px; + margin-right: 8px; +} + +.reactionIcon { + width: 24px; + height: 24px; +} +</style> diff --git a/packages/frontend/src/pages/chat/XRoom.vue b/packages/frontend/src/pages/chat/XRoom.vue new file mode 100644 index 0000000000..b063a0cdd1 --- /dev/null +++ b/packages/frontend/src/pages/chat/XRoom.vue @@ -0,0 +1,41 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkA :to="`/chat/room/${room.id}`" class="_panel _gaps_s" :class="$style.root"> + <div :class="$style.header"> + <div style="font-weight: bold;">{{ room.name }}</div> + <MkAvatar :user="room.owner" :link="false" :class="$style.headerAvatar"/> + </div> + <hr> + <div>{{ room.description }}</div> +</MkA> +</template> + +<script lang="ts" setup> +import * as Misskey from 'misskey-js'; + +const props = defineProps<{ + room: Misskey.entities.ChatRoom; +}>(); + +</script> + +<style lang="scss" module> +.root { + padding: 16px; +} + +.header { + display: flex; + align-items: center; +} + +.headerAvatar { + width: 30px; + height: 30px; + margin-left: auto; +} +</style> diff --git a/packages/frontend/src/pages/chat/home.home.vue b/packages/frontend/src/pages/chat/home.home.vue new file mode 100644 index 0000000000..1d0605136c --- /dev/null +++ b/packages/frontend/src/pages/chat/home.home.vue @@ -0,0 +1,252 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <MkButton primary gradate rounded :class="$style.start" @click="start"><i class="ti ti-plus"></i> {{ i18n.ts.startChat }}</MkButton> + + <MkAd :prefer="['horizontal', 'horizontal-big']"/> + + <MkInput + v-model="searchQuery" + :placeholder="i18n.ts._chat.searchMessages" + type="search" + > + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + + <MkButton v-if="searchQuery.length > 0" primary rounded @click="search">{{ i18n.ts.search }}</MkButton> + + <MkFoldableSection v-if="searched"> + <template #header>{{ i18n.ts.searchResult }}</template> + + <div class="_gaps_s"> + <div v-for="message in searchResults" :key="message.id" :class="$style.searchResultItem"> + <XMessage :message="message" :isSearchResult="true"/> + </div> + </div> + </MkFoldableSection> + + <MkFoldableSection> + <template #header>{{ i18n.ts._chat.history }}</template> + + <div v-if="history.length > 0" class="_gaps_s"> + <MkA + v-for="item in history" + :key="item.id" + :class="[$style.message, { [$style.isMe]: item.isMe, [$style.isRead]: item.message.isRead }]" + class="_panel" + :to="item.message.toRoomId ? `/chat/room/${item.message.toRoomId}` : `/chat/user/${item.other!.id}`" + > + <MkAvatar v-if="item.other" :class="$style.messageAvatar" :user="item.other" indicator :preview="false"/> + <div :class="$style.messageBody"> + <header v-if="item.message.toRoom" :class="$style.messageHeader"> + <span :class="$style.messageHeaderName">{{ item.message.toRoom.name }}</span> + <MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/> + </header> + <header v-else :class="$style.messageHeader"> + <MkUserName :class="$style.messageHeaderName" :user="item.other!"/> + <MkAcct :class="$style.messageHeaderUsername" :user="item.other!"/> + <MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/> + </header> + <div :class="$style.messageBodyText"><span v-if="item.isMe" :class="$style.youSaid">{{ i18n.ts.you }}:</span>{{ item.message.text }}</div> + </div> + </MkA> + </div> + <div v-if="!fetching && history.length == 0" class="_fullinfo"> + <div>{{ i18n.ts._chat.noHistory }}</div> + </div> + <MkLoading v-if="fetching"/> + </MkFoldableSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import XMessage from './XMessage.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { ensureSignin } from '@/i.js'; +import { useRouter } from '@/router.js'; +import * as os from '@/os.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; +import MkInput from '@/components/MkInput.vue'; +import MkFoldableSection from '@/components/MkFoldableSection.vue'; + +const $i = ensureSignin(); + +const router = useRouter(); + +const fetching = ref(true); +const history = ref<{ + id: string; + message: Misskey.entities.ChatMessage; + other: Misskey.entities.ChatMessage['fromUser'] | Misskey.entities.ChatMessage['toUser'] | null; + isMe: boolean; +}[]>([]); + +const searchQuery = ref(''); +const searched = ref(false); +const searchResults = ref<Misskey.entities.ChatMessage[]>([]); + +function start(ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts._chat.individualChat, + caption: i18n.ts._chat.individualChat_description, + icon: 'ti ti-user', + action: () => { startUser(); }, + }, { type: 'divider' }, { + type: 'parent', + text: i18n.ts._chat.roomChat, + caption: i18n.ts._chat.roomChat_description, + icon: 'ti ti-users-group', + children: [{ + text: i18n.ts._chat.createRoom, + icon: 'ti ti-plus', + action: () => { createRoom(); }, + }], + }], ev.currentTarget ?? ev.target); +} + +async function startUser() { + os.selectUser().then(user => { + router.push(`/chat/user/${user.id}`); + }); +} + +async function createRoom() { + const { canceled, result } = await os.inputText({ + title: i18n.ts.name, + minLength: 1, + }); + if (canceled) return; + + const room = await misskeyApi('chat/rooms/create', { + name: result, + }); + + router.push(`/chat/room/${room.id}`); +} + +async function search() { + const res = await misskeyApi('chat/messages/search', { + query: searchQuery.value, + }); + + searchResults.value = res; + searched.value = true; +} + +async function fetchHistory() { + fetching.value = true; + + const [userMessages, roomMessages] = await Promise.all([ + misskeyApi('chat/history', { room: false }), + misskeyApi('chat/history', { room: true }), + ]); + + history.value = [...userMessages, ...roomMessages] + .toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .map(m => ({ + id: m.id, + message: m, + other: m.room == null ? (m.fromUserId === $i.id ? m.toUser : m.fromUser) : null, + isMe: m.fromUserId === $i.id, + })); + + fetching.value = false; + + updateCurrentAccountPartial({ hasUnreadChatMessages: false }); +} + +onMounted(() => { + fetchHistory(); +}); +</script> + +<style lang="scss" module> +.start { + margin: 0 auto; +} + +.message { + position: relative; + display: flex; + padding: 16px 24px; + + &.isRead, + &.isMe { + opacity: 0.8; + } + + &:not(.isMe):not(.isRead) { + &::before { + content: ''; + position: absolute; + top: 8px; + right: 8px; + width: 8px; + height: 8px; + border-radius: 100%; + background-color: var(--MI_THEME-accent); + } + } +} + +.messageAvatar { + width: 50px; + height: 50px; + margin: 0 16px 0 0; +} + +.messageBody { + flex: 1; + min-width: 0; +} + +.messageHeader { + display: flex; + align-items: center; + margin-bottom: 2px; + white-space: nowrap; + overflow: clip; +} + +.messageHeaderName { + margin: 0; + padding: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: 1em; + font-weight: bold; +} + +.messageHeaderUsername { + margin: 0 8px; +} + +.messageHeaderTime { + margin-left: auto; +} + +.messageBodyText { + overflow: hidden; + overflow-wrap: break-word; + font-size: 1.1em; +} + +.youSaid { + font-weight: bold; + margin-right: 0.5em; +} + +.searchResultItem { + padding: 12px; + border: solid 1px var(--MI_THEME-divider); + border-radius: 12px; +} +</style> diff --git a/packages/frontend/src/pages/chat/home.invitations.vue b/packages/frontend/src/pages/chat/home.invitations.vue new file mode 100644 index 0000000000..4c3c0b282e --- /dev/null +++ b/packages/frontend/src/pages/chat/home.invitations.vue @@ -0,0 +1,98 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <div v-if="invitations.length > 0" class="_gaps_s"> + <MkFolder v-for="invitation in invitations" :key="invitation.id" :defaultOpen="true"> + <template #icon><i class="ti ti-users-group"></i></template> + <template #label>{{ invitation.room.name }}</template> + <template #suffix><MkTime :time="invitation.createdAt"/></template> + <template #footer> + <div class="_buttons"> + <MkButton primary @click="join(invitation)"><i class="ti ti-plus"></i> {{ i18n.ts._chat.join }}</MkButton> + <MkButton danger @click="ignore(invitation)"><i class="ti ti-x"></i> {{ i18n.ts._chat.ignore }}</MkButton> + </div> + </template> + + <div :class="$style.invitationBody"> + <MkAvatar :user="invitation.room.owner" :class="$style.invitationBodyAvatar" link/> + <div style="flex: 1;" class="_gaps_s"> + <MkUserName :user="invitation.room.owner"/> + <hr> + <div>{{ invitation.room.description === '' ? i18n.ts.noDescription : invitation.room.description }}</div> + </div> + </div> + </MkFolder> + </div> + <div v-if="!fetching && invitations.length == 0" class="_fullinfo"> + <div>{{ i18n.ts._chat.noInvitations }}</div> + </div> + <MkLoading v-if="fetching"/> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { ensureSignin } from '@/i.js'; +import { useRouter } from '@/router.js'; +import * as os from '@/os.js'; +import MkFolder from '@/components/MkFolder.vue'; + +const $i = ensureSignin(); + +const router = useRouter(); + +const fetching = ref(true); +const invitations = ref<Misskey.entities.ChatRoomInvitation[]>([]); + +async function fetchInvitations() { + fetching.value = true; + + const res = await misskeyApi('chat/rooms/invitations/inbox', { + }); + + invitations.value = res; + + fetching.value = false; +} + +async function join(invitation: Misskey.entities.ChatRoomInvitation) { + await misskeyApi('chat/rooms/join', { + roomId: invitation.room.id, + }); + + router.push(`/chat/room/${invitation.room.id}`); +} + +async function ignore(invitation: Misskey.entities.ChatRoomInvitation) { + await misskeyApi('chat/rooms/invitations/ignore', { + roomId: invitation.room.id, + }); + + invitations.value = invitations.value.filter(i => i.id !== invitation.id); +} + +onMounted(() => { + fetchInvitations(); +}); +</script> + +<style lang="scss" module> +.invitationBody { + display: flex; + align-items: center; +} + +.invitationBodyAvatar { + margin-right: 12px; + width: 45px; + height: 45px; +} +</style> diff --git a/packages/frontend/src/pages/chat/home.joiningRooms.vue b/packages/frontend/src/pages/chat/home.joiningRooms.vue new file mode 100644 index 0000000000..63e4d2adf8 --- /dev/null +++ b/packages/frontend/src/pages/chat/home.joiningRooms.vue @@ -0,0 +1,54 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <div v-if="memberships.length > 0" class="_gaps_s"> + <XRoom v-for="membership in memberships" :key="membership.id" :room="membership.room"/> + </div> + <div v-if="!fetching && memberships.length == 0" class="_fullinfo"> + <div>{{ i18n.ts._chat.noRooms }}</div> + </div> + <MkLoading v-if="fetching"/> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import XRoom from './XRoom.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { ensureSignin } from '@/i.js'; +import { useRouter } from '@/router.js'; +import * as os from '@/os.js'; + +const $i = ensureSignin(); + +const router = useRouter(); + +const fetching = ref(true); +const memberships = ref<Misskey.entities.ChatRoomMembership[]>([]); + +async function fetchRooms() { + fetching.value = true; + + const res = await misskeyApi('chat/rooms/joining', { + }); + + memberships.value = res; + + fetching.value = false; +} + +onMounted(() => { + fetchRooms(); +}); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/pages/chat/home.ownedRooms.vue b/packages/frontend/src/pages/chat/home.ownedRooms.vue new file mode 100644 index 0000000000..b0449fb373 --- /dev/null +++ b/packages/frontend/src/pages/chat/home.ownedRooms.vue @@ -0,0 +1,54 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <div v-if="rooms.length > 0" class="_gaps_s"> + <XRoom v-for="room in rooms" :key="room.id" :room="room"/> + </div> + <div v-if="!fetching && rooms.length == 0" class="_fullinfo"> + <div>{{ i18n.ts._chat.noRooms }}</div> + </div> + <MkLoading v-if="fetching"/> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import XRoom from './XRoom.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { ensureSignin } from '@/i.js'; +import { useRouter } from '@/router.js'; +import * as os from '@/os.js'; + +const $i = ensureSignin(); + +const router = useRouter(); + +const fetching = ref(true); +const rooms = ref<Misskey.entities.ChatRoom[]>([]); + +async function fetchRooms() { + fetching.value = true; + + const res = await misskeyApi('chat/rooms/owned', { + }); + + rooms.value = res; + + fetching.value = false; +} + +onMounted(() => { + fetchRooms(); +}); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/pages/chat/home.vue b/packages/frontend/src/pages/chat/home.vue new file mode 100644 index 0000000000..c2b272a42d --- /dev/null +++ b/packages/frontend/src/pages/chat/home.vue @@ -0,0 +1,60 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> + <MkPolkadots v-if="tab === 'home'" accented/> + <MkSpacer :contentMax="700"> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <XHome v-if="tab === 'home'"/> + <XInvitations v-else-if="tab === 'invitations'"/> + <XJoiningRooms v-else-if="tab === 'joiningRooms'"/> + <XOwnedRooms v-else-if="tab === 'ownedRooms'"/> + </MkHorizontalSwipe> + </MkSpacer> +</PageWithHeader> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref } from 'vue'; +import XHome from './home.home.vue'; +import XInvitations from './home.invitations.vue'; +import XJoiningRooms from './home.joiningRooms.vue'; +import XOwnedRooms from './home.ownedRooms.vue'; +import { i18n } from '@/i18n.js'; +import { definePage } from '@/page.js'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import MkPolkadots from '@/components/MkPolkadots.vue'; + +const tab = ref('home'); + +const headerActions = computed(() => []); + +const headerTabs = computed(() => [{ + key: 'home', + title: i18n.ts._chat.home, + icon: 'ti ti-home', +}, { + key: 'invitations', + title: i18n.ts._chat.invitations, + icon: 'ti ti-ticket', +}, { + key: 'joiningRooms', + title: i18n.ts._chat.joiningRooms, + icon: 'ti ti-users-group', +}, { + key: 'ownedRooms', + title: i18n.ts._chat.yourRooms, + icon: 'ti ti-settings', +}]); + +definePage(() => ({ + title: i18n.ts.chat + ' (beta)', + icon: 'ti ti-message', +})); +</script> + +<style lang="scss" module> +</style> diff --git a/packages/frontend/src/pages/chat/message.vue b/packages/frontend/src/pages/chat/message.vue new file mode 100644 index 0000000000..be8be7e5d1 --- /dev/null +++ b/packages/frontend/src/pages/chat/message.vue @@ -0,0 +1,55 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<PageWithHeader> + <MkSpacer :contentMax="700"> + <div v-if="initializing"> + <MkLoading/> + </div> + <div v-else> + <XMessage :message="message"/> + </div> + </MkSpacer> +</PageWithHeader> +</template> + +<script lang="ts" setup> +import { ref, useTemplateRef, computed, watch, onMounted, nextTick, onBeforeUnmount, onDeactivated, onActivated } from 'vue'; +import * as Misskey from 'misskey-js'; +import XMessage from './XMessage.vue'; +import * as os from '@/os.js'; +import { useStream } from '@/stream.js'; +import { i18n } from '@/i18n.js'; +import { ensureSignin } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; +import MkButton from '@/components/MkButton.vue'; + +const props = defineProps<{ + messageId?: string; +}>(); + +const initializing = ref(true); +const message = ref<Misskey.entities.ChatMessage>(); + +async function initialize() { + initializing.value = true; + + message.value = await misskeyApi('chat/messages/show', { + messageId: props.messageId, + }); + + initializing.value = false; +} + +onMounted(() => { + initialize(); +}); + +definePage({ + title: i18n.ts.chat, +}); +</script> diff --git a/packages/frontend/src/pages/chat/room.form.vue b/packages/frontend/src/pages/chat/room.form.vue new file mode 100644 index 0000000000..aba9d6061f --- /dev/null +++ b/packages/frontend/src/pages/chat/room.form.vue @@ -0,0 +1,333 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + :class="$style.root" + @dragover.stop="onDragover" + @drop.stop="onDrop" +> + <textarea + ref="textareaEl" + v-model="text" + :class="$style.textarea" + class="_acrylic" + :placeholder="i18n.ts.inputMessageHere" + :readonly="textareaReadOnly" + @keydown="onKeydown" + @paste="onPaste" + ></textarea> + <footer :class="$style.footer"> + <div v-if="file" :class="$style.file" @click="file = null">{{ file.name }}</div> + <div :class="$style.buttons"> + <button class="_button" :class="$style.button" @click="chooseFile"><i class="ti ti-photo-plus"></i></button> + <button class="_button" :class="$style.button" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> + <button class="_button" :class="[$style.button, $style.send]" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send"> + <template v-if="!sending"><i class="ti ti-send"></i></template><template v-if="sending"><MkLoading :em="true"/></template> + </button> + </div> + </footer> + <input ref="fileEl" style="display: none;" type="file" @change="onChangeFile"/> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, watch, ref, shallowRef, computed, nextTick, readonly } from 'vue'; +import * as Misskey from 'misskey-js'; +//import insertTextAtCursor from 'insert-text-at-cursor'; +import { throttle } from 'throttle-debounce'; +import { formatTimeString } from '@/utility/format-time-string.js'; +import { selectFile } from '@/utility/select-file.js'; +import * as os from '@/os.js'; +import { useStream } from '@/stream.js'; +import { i18n } from '@/i18n.js'; +import { uploadFile } from '@/utility/upload.js'; +import { miLocalStorage } from '@/local-storage.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { prefer } from '@/preferences.js'; +import { Autocomplete } from '@/utility/autocomplete.js'; +import { emojiPicker } from '@/utility/emoji-picker.js'; + +const props = defineProps<{ + user?: Misskey.entities.UserDetailed | null; + room?: Misskey.entities.ChatRoom | null; +}>(); + +const textareaEl = shallowRef<HTMLTextAreaElement>(); +const fileEl = shallowRef<HTMLInputElement>(); + +const text = ref<string>(''); +const file = ref<Misskey.entities.DriveFile | null>(null); +const sending = ref(false); +const textareaReadOnly = ref(false); + +const canSend = computed(() => (text.value != null && text.value !== '') || file.value != null); + +function getDraftKey() { + return props.user ? 'user:' + props.user.id : 'room:' + props.room?.id; +} + +watch([text, file], saveDraft); + +async function onPaste(ev: ClipboardEvent) { + if (!ev.clipboardData) return; + + const pastedFileName = 'yyyy-MM-dd HH-mm-ss [{{number}}]'; + + const clipboardData = ev.clipboardData; + const items = clipboardData.items; + + if (items.length === 1) { + if (items[0].kind === 'file') { + const pastedFile = items[0].getAsFile(); + if (!pastedFile) return; + const lio = pastedFile.name.lastIndexOf('.'); + const ext = lio >= 0 ? pastedFile.name.slice(lio) : ''; + const formatted = formatTimeString(new Date(pastedFile.lastModified), pastedFileName).replace(/{{number}}/g, '1') + ext; + if (formatted) upload(pastedFile, formatted); + } + } else { + if (items[0].kind === 'file') { + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); + } + } +} + +function onDragover(ev: DragEvent) { + if (!ev.dataTransfer) return; + + const isFile = ev.dataTransfer.items[0].kind === 'file'; + const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; + if (isFile || isDriveFile) { + ev.preventDefault(); + switch (ev.dataTransfer.effectAllowed) { + case 'all': + case 'uninitialized': + case 'copy': + case 'copyLink': + case 'copyMove': + ev.dataTransfer.dropEffect = 'copy'; + break; + case 'linkMove': + case 'move': + ev.dataTransfer.dropEffect = 'move'; + break; + default: + ev.dataTransfer.dropEffect = 'none'; + break; + } + } +} + +function onDrop(ev: DragEvent): void { + if (!ev.dataTransfer) return; + + // ファイルだったら + if (ev.dataTransfer.files.length === 1) { + ev.preventDefault(); + upload(ev.dataTransfer.files[0]); + return; + } else if (ev.dataTransfer.files.length > 1) { + ev.preventDefault(); + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); + return; + } + + //#region ドライブのファイル + const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile !== '') { + file.value = JSON.parse(driveFile); + ev.preventDefault(); + } + //#endregion +} + +function onKeydown(ev: KeyboardEvent) { + if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey)) { + send(); + } +} + +function chooseFile(ev: MouseEvent) { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => { + file.value = selectedFile; + }); +} + +function onChangeFile() { + if (fileEl.value.files![0]) upload(fileEl.value.files[0]); +} + +function upload(fileToUpload: File, name?: string) { + uploadFile(fileToUpload, prefer.s.uploadFolder, name).then(res => { + file.value = res; + }); +} + +function send() { + if (!canSend.value) return; + + sending.value = true; + + if (props.user) { + misskeyApi('chat/messages/create-to-user', { + toUserId: props.user.id, + text: text.value ? text.value : undefined, + fileId: file.value ? file.value.id : undefined, + }).then(message => { + clear(); + }).catch(err => { + console.error(err); + }).then(() => { + sending.value = false; + }); + } else if (props.room) { + misskeyApi('chat/messages/create-to-room', { + toRoomId: props.room.id, + text: text.value ? text.value : undefined, + fileId: file.value ? file.value.id : undefined, + }).then(message => { + clear(); + }).catch(err => { + console.error(err); + }).then(() => { + sending.value = false; + }); + } +} + +function clear() { + text.value = ''; + file.value = null; + deleteDraft(); +} + +function saveDraft() { + const drafts = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}'); + + drafts[getDraftKey()] = { + updatedAt: new Date(), + data: { + text: text.value, + file: file.value, + }, + }; + + miLocalStorage.setItem('chatMessageDrafts', JSON.stringify(drafts)); +} + +function deleteDraft() { + const drafts = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}'); + + delete drafts[getDraftKey()]; + + miLocalStorage.setItem('chatMessageDrafts', JSON.stringify(drafts)); +} + +async function insertEmoji(ev: MouseEvent) { + textareaReadOnly.value = true; + const target = ev.currentTarget ?? ev.target; + if (target == null) return; + + // emojiPickerはダイアログが閉じずにtextareaとやりとりするので、 + // focustrapをかけているとinsertTextAtCursorが効かない + // そのため、投稿フォームのテキストに直接注入する + // See: https://github.com/misskey-dev/misskey/pull/14282 + // https://github.com/misskey-dev/misskey/issues/14274 + + let pos = textareaEl.value?.selectionStart ?? 0; + let posEnd = textareaEl.value?.selectionEnd ?? text.value.length; + emojiPicker.show( + target as HTMLElement, + emoji => { + const textBefore = text.value.substring(0, pos); + const textAfter = text.value.substring(posEnd); + text.value = textBefore + emoji + textAfter; + pos += emoji.length; + posEnd += emoji.length; + }, + () => { + textareaReadOnly.value = false; + nextTick(() => focus()); + }, + ); +} + +onMounted(() => { + // TODO: detach when unmount + new Autocomplete(textareaEl.value, text); + + // 書きかけの投稿を復元 + const draft = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}')[getDraftKey()]; + if (draft) { + text.value = draft.data.text; + file.value = draft.data.file; + } +}); +</script> + +<style lang="scss" module> +.root { + position: relative; + border-bottom: none; + border-radius: 14px 14px 0 0; + overflow: clip; +} + +.textarea { + cursor: auto; + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 80px; + margin: 0; + padding: 16px 16px 0 16px; + resize: none; + font-size: 1em; + font-family: inherit; + outline: none; + border: none; + border-radius: 0; + box-shadow: none; + box-sizing: border-box; + color: var(--MI_THEME-fg); + field-sizing: content; +} + +.footer { + position: sticky; + bottom: 0; + background: var(--MI_THEME-panel); +} + +.file { + padding: 8px; + cursor: pointer; +} + +.buttons { + display: flex; +} + +.button { + height: 50px; + aspect-ratio: 1; + + &:hover { + color: var(--MI_THEME-accent); + } +} +.send { + margin-left: auto; + color: var(--MI_THEME-accent); +} +</style> diff --git a/packages/frontend/src/pages/chat/room.info.vue b/packages/frontend/src/pages/chat/room.info.vue new file mode 100644 index 0000000000..7d38d07b3a --- /dev/null +++ b/packages/frontend/src/pages/chat/room.info.vue @@ -0,0 +1,87 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <MkInput v-model="name_" :disabled="!isOwner"> + <template #label>{{ i18n.ts.name }}</template> + </MkInput> + + <MkTextarea v-model="description_" :disabled="!isOwner"> + <template #label>{{ i18n.ts.description }}</template> + </MkTextarea> + + <MkButton v-if="isOwner" primary @click="save">{{ i18n.ts.save }}</MkButton> + + <hr> + + <MkSwitch v-if="!isOwner" v-model="isMuted"> + <template #label>{{ i18n.ts._chat.muteThisRoom }}</template> + </MkSwitch> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import * as os from '@/os.js'; +import { ensureSignin } from '@/i.js'; +import MkInput from '@/components/MkInput.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; + +const $i = ensureSignin(); + +const props = defineProps<{ + room: Misskey.entities.ChatRoom; +}>(); + +const isOwner = computed(() => { + return props.room.ownerId === $i.id; +}); + +const name_ = ref(props.room.name); +const description_ = ref(props.room.description); + +function save() { + os.apiWithDialog('chat/rooms/update', { + roomId: props.room.id, + name: name_.value, + description: description_.value, + }); +} + +const isMuted = ref(props.room.isMuted); + +watch(isMuted, async () => { + await os.apiWithDialog('chat/rooms/mute', { + roomId: props.room.id, + mute: isMuted.value, + }); +}); + +onMounted(async () => { + +}); +</script> + +<style lang="scss" module> +.membership { + display: flex; +} + +.membershipBody { + flex: 1; + min-width: 0; + margin-right: 8px; + + &:hover { + text-decoration: none; + } +} +</style> diff --git a/packages/frontend/src/pages/chat/room.members.vue b/packages/frontend/src/pages/chat/room.members.vue new file mode 100644 index 0000000000..d20216a81c --- /dev/null +++ b/packages/frontend/src/pages/chat/room.members.vue @@ -0,0 +1,73 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <MkButton v-if="isOwner" primary rounded style="margin: 0 auto;" @click="emit('inviteUser')"><i class="ti ti-plus"></i> {{ i18n.ts._chat.inviteUser }}</MkButton> + + <MkA :class="$style.membershipBody" :to="`${userPage(room.owner)}`"> + <MkUserCardMini :user="room.owner"/> + </MkA> + + <hr> + + <div v-for="membership in memberships" :key="membership.id" :class="$style.membership"> + <MkA :class="$style.membershipBody" :to="`${userPage(membership.user)}`"> + <MkUserCardMini :user="membership.user"/> + </MkA> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import * as os from '@/os.js'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import { userPage } from '@/filters/user.js'; +import { ensureSignin } from '@/i.js'; + +const $i = ensureSignin(); + +const props = defineProps<{ + room: Misskey.entities.ChatRoom; +}>(); + +const emit = defineEmits<{ + (ev: 'inviteUser'): void, +}>(); + +const isOwner = computed(() => { + return props.room.ownerId === $i.id; +}); + +const memberships = ref<Misskey.entities.ChatRoomMembership[]>([]); + +onMounted(async () => { + memberships.value = await misskeyApi('chat/rooms/members', { + roomId: props.room.id, + limit: 50, + }); +}); +</script> + +<style lang="scss" module> +.membership { + display: flex; +} + +.membershipBody { + flex: 1; + min-width: 0; + margin-right: 8px; + + &:hover { + text-decoration: none; + } +} +</style> diff --git a/packages/frontend/src/pages/chat/room.search.vue b/packages/frontend/src/pages/chat/room.search.vue new file mode 100644 index 0000000000..de5e7156ca --- /dev/null +++ b/packages/frontend/src/pages/chat/room.search.vue @@ -0,0 +1,68 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <MkInput + v-model="searchQuery" + :placeholder="i18n.ts._chat.searchMessages" + type="search" + > + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + + <MkButton v-if="searchQuery.length > 0" primary rounded @click="search">{{ i18n.ts.search }}</MkButton> + + <MkFoldableSection v-if="searched"> + <template #header>{{ i18n.ts.searchResult }}</template> + + <div class="_gaps_s"> + <div v-for="message in searchResults" :key="message.id" :class="$style.searchResultItem"> + <XMessage :message="message" :user="message.fromUser" :isSearchResult="true"/> + </div> + </div> + </MkFoldableSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import XMessage from './XMessage.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import * as os from '@/os.js'; +import MkInput from '@/components/MkInput.vue'; +import MkFoldableSection from '@/components/MkFoldableSection.vue'; + +const props = defineProps<{ + userId?: string; + roomId?: string; +}>(); + +const searchQuery = ref(''); +const searched = ref(false); +const searchResults = ref<Misskey.entities.ChatMessage[]>([]); + +async function search() { + const res = await misskeyApi('chat/messages/search', { + query: searchQuery.value, + roomId: props.roomId, + userId: props.userId, + }); + + searchResults.value = res; + searched.value = true; +} +</script> + +<style lang="scss" module> +.searchResultItem { + padding: 12px; + border: solid 1px var(--MI_THEME-divider); + border-radius: 12px; +} +</style> diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue new file mode 100644 index 0000000000..15e9f43db2 --- /dev/null +++ b/packages/frontend/src/pages/chat/room.vue @@ -0,0 +1,426 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<PageWithHeader v-model:tab="tab" :reversed="tab === 'chat'" :tabs="headerTabs" :actions="headerActions"> + <MkSpacer v-if="tab === 'chat'" :contentMax="700"> + <div v-if="initializing"> + <MkLoading/> + </div> + + <div v-else-if="messages.length === 0"> + <div class="_gaps" style="text-align: center;"> + <div>{{ i18n.ts._chat.noMessagesYet }}</div> + <template v-if="user"> + <div v-if="user.chatScope === 'followers'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowers }}</div> + <div v-else-if="user.chatScope === 'following'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowing }}</div> + <div v-else-if="user.chatScope === 'mutual'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromMutualFollowing }}</div> + <div v-else>{{ i18n.ts._chat.thisUserNotAllowedChatAnyone }}</div> + </template> + <template v-else-if="room"> + <div>{{ i18n.ts._chat.inviteUserToChat }}</div> + </template> + </div> + </div> + + <div v-else class="_gaps"> + <div v-if="canFetchMore"> + <MkButton :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">{{ i18n.ts.loadMore }}</MkButton> + </div> + + <TransitionGroup + :enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''" + :moveClass="prefer.s.animation ? $style.transition_x_move : ''" + tag="div" class="_gaps" + > + <XMessage v-for="message in messages.toReversed()" :key="message.id" :message="message"/> + </TransitionGroup> + </div> + </MkSpacer> + + <MkSpacer v-else-if="tab === 'search'" :contentMax="700"> + <XSearch :userId="userId" :roomId="roomId"/> + </MkSpacer> + + <MkSpacer v-else-if="tab === 'members'" :contentMax="700"> + <XMembers v-if="room != null" :room="room" @inviteUser="inviteUser"/> + </MkSpacer> + + <MkSpacer v-else-if="tab === 'info'" :contentMax="700"> + <XInfo v-if="room != null" :room="room"/> + </MkSpacer> + + <template #footer> + <div v-if="tab === 'chat'" :class="$style.footer"> + <div class="_gaps"> + <Transition name="fade"> + <div v-show="showIndicator" :class="$style.new"> + <button class="_buttonPrimary" :class="$style.newButton" @click="onIndicatorClick"> + <i class="fas ti-fw fa-arrow-circle-down" :class="$style.newIcon"></i>{{ i18n.ts.newMessageExists }} + </button> + </div> + </Transition> + <XForm v-if="!initializing" :user="user" :room="room" :class="$style.form"/> + </div> + </div> + </template> +</PageWithHeader> +</template> + +<script lang="ts" setup> +import { ref, useTemplateRef, computed, watch, onMounted, nextTick, onBeforeUnmount, onDeactivated, onActivated } from 'vue'; +import * as Misskey from 'misskey-js'; +import { isTailVisible } from '@@/js/scroll.js'; +import XMessage from './XMessage.vue'; +import XForm from './room.form.vue'; +import XSearch from './room.search.vue'; +import XMembers from './room.members.vue'; +import XInfo from './room.info.vue'; +import type { MenuItem } from '@/types/menu.js'; +import * as os from '@/os.js'; +import { useStream } from '@/stream.js'; +import * as sound from '@/utility/sound.js'; +import { i18n } from '@/i18n.js'; +import { ensureSignin } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; +import { prefer } from '@/preferences.js'; +import MkButton from '@/components/MkButton.vue'; +import { useRouter } from '@/router.js'; + +const $i = ensureSignin(); +const router = useRouter(); + +const props = defineProps<{ + userId?: string; + roomId?: string; +}>(); + +const initializing = ref(true); +const moreFetching = ref(false); +const messages = ref<Misskey.entities.ChatMessage[]>([]); +const canFetchMore = ref(false); +const user = ref<Misskey.entities.UserDetailed | null>(null); +const room = ref<Misskey.entities.ChatRoom | null>(null); +const connection = ref<Misskey.ChannelConnection<Misskey.Channels['chatUser'] | Misskey.Channels['chatRoom']> | null>(null); +const showIndicator = ref(false); + +function normalizeMessage(message: Misskey.entities.ChatMessageLite | Misskey.entities.ChatMessage) { + const reactions = [...message.reactions]; + for (const record of reactions) { + if (room.value == null && record.user == null) { // 1on1の時はuserは省略される + record.user = message.fromUserId === $i.id ? user.value : $i; + } + } + + return { + ...message, + fromUser: message.fromUser ?? (message.fromUserId === $i.id ? $i : user), + reactions, + }; +} + +async function initialize() { + const LIMIT = 20; + + initializing.value = true; + + if (props.userId) { + const [u, m] = await Promise.all([ + misskeyApi('users/show', { userId: props.userId }), + misskeyApi('chat/messages/user-timeline', { userId: props.userId, limit: LIMIT }), + ]); + + user.value = u; + messages.value = m.map(x => normalizeMessage(x)); + + if (messages.value.length === LIMIT) { + canFetchMore.value = true; + } + + connection.value = useStream().useChannel('chatUser', { + otherId: user.value.id, + }); + connection.value.on('message', onMessage); + connection.value.on('deleted', onDeleted); + connection.value.on('react', onReact); + } else { + const [r, m] = await Promise.all([ + misskeyApi('chat/rooms/show', { roomId: props.roomId }), + misskeyApi('chat/messages/room-timeline', { roomId: props.roomId, limit: LIMIT }), + ]); + + room.value = r; + messages.value = m.map(x => normalizeMessage(x)); + + if (messages.value.length === LIMIT) { + canFetchMore.value = true; + } + + connection.value = useStream().useChannel('chatRoom', { + roomId: room.value.id, + }); + connection.value.on('message', onMessage); + connection.value.on('deleted', onDeleted); + connection.value.on('react', onReact); + } + + window.document.addEventListener('visibilitychange', onVisibilitychange); + + initializing.value = false; +} + +let isActivated = true; + +onActivated(() => { + isActivated = true; +}); + +onDeactivated(() => { + isActivated = false; +}); + +async function fetchMore() { + const LIMIT = 30; + + moreFetching.value = true; + + const newMessages = props.userId ? await misskeyApi('chat/messages/user-timeline', { + userId: user.value.id, + limit: LIMIT, + untilId: messages.value[messages.value.length - 1].id, + }) : await misskeyApi('chat/messages/room-timeline', { + roomId: room.value.id, + limit: LIMIT, + untilId: messages.value[messages.value.length - 1].id, + }); + + messages.value.push(...newMessages.map(x => normalizeMessage(x))); + + canFetchMore.value = newMessages.length === LIMIT; + moreFetching.value = false; +} + +function onMessage(message: Misskey.entities.ChatMessage) { + sound.playMisskeySfx('chatMessage'); + + messages.value.unshift(normalizeMessage(message)); + + // TODO: DOM的にバックグラウンドになっていないかどうかも考慮する + if (message.fromUserId !== $i.id && !window.document.hidden && isActivated) { + connection.value?.send('read', { + id: message.id, + }); + } + + if (message.fromUserId !== $i.id) { + //notifyNewMessage(); + } +} + +function onDeleted(id) { + const index = messages.value.findIndex(m => m.id === id); + if (index !== -1) { + messages.value.splice(index, 1); + } +} + +function onReact(ctx) { + const message = messages.value.find(m => m.id === ctx.messageId); + if (message) { + if (room.value == null) { // 1on1の時はuserは省略される + message.reactions.push({ + reaction: ctx.reaction, + user: message.fromUserId === $i.id ? user : $i, + }); + } else { + message.reactions.push({ + reaction: ctx.reaction, + user: ctx.user, + }); + } + } +} + +function onIndicatorClick() { + showIndicator.value = false; +} + +function notifyNewMessage() { + showIndicator.value = true; +} + +function onVisibilitychange() { + if (window.document.hidden) return; + // TODO +} + +onMounted(() => { + initialize(); +}); + +onBeforeUnmount(() => { + connection.value?.dispose(); + window.document.removeEventListener('visibilitychange', onVisibilitychange); +}); + +async function inviteUser() { + const invitee = await os.selectUser({ includeSelf: false, localOnly: true }); + os.apiWithDialog('chat/rooms/invitations/create', { + roomId: room.value?.id, + userId: invitee.id, + }); +} + +async function leaveRoom() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.areYouSure, + }); + if (canceled) return; + + misskeyApi('chat/rooms/leave', { + roomId: room.value?.id, + }); + router.push('/chat'); +} + +function showMenu(ev: MouseEvent) { + const menuItems: MenuItem[] = []; + + if (room.value) { + if (room.value.ownerId === $i.id) { + menuItems.push({ + text: i18n.ts._chat.inviteUser, + icon: 'ti ti-user-plus', + action: () => { + inviteUser(); + }, + }); + } else { + menuItems.push({ + text: i18n.ts._chat.leave, + icon: 'ti ti-x', + action: () => { + leaveRoom(); + }, + }); + } + } + + os.popupMenu(menuItems, ev.currentTarget ?? ev.target); +} + +const tab = ref('chat'); + +const headerTabs = computed(() => room.value ? [{ + key: 'chat', + title: i18n.ts.chat, + icon: 'ti ti-messages', +}, { + key: 'members', + title: i18n.ts._chat.members, + icon: 'ti ti-users', +}, { + key: 'search', + title: i18n.ts.search, + icon: 'ti ti-search', +}, { + key: 'info', + title: i18n.ts.info, + icon: 'ti ti-info-circle', +}] : [{ + key: 'chat', + title: i18n.ts.chat, + icon: 'ti ti-messages', +}, { + key: 'search', + title: i18n.ts.search, + icon: 'ti ti-search', +}]); + +const headerActions = computed(() => [{ + icon: 'ti ti-dots', + handler: showMenu, +}]); + +definePage(computed(() => !initializing.value ? user.value ? { + userName: user, + avatar: user, +} : { + title: room.value?.name, + icon: 'ti ti-users', +} : null)); +</script> + +<style lang="scss" module> +.transition_x_move, +.transition_x_enterActive, +.transition_x_leaveActive { + transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important; +} +.transition_x_enterFrom, +.transition_x_leaveTo { + opacity: 0; + transform: translateY(80px); +} +.transition_x_leaveActive { + position: absolute; +} + +.root { +} + +.more { + margin: 0 auto; +} + +.footer { + width: 100%; + padding-top: 8px; +} + +.new { + width: 100%; + padding-bottom: 8px; + text-align: center; +} + +.newButton { + display: inline-block; + margin: 0; + padding: 0 12px; + line-height: 32px; + font-size: 12px; + border-radius: 16px; +} + +.newIcon { + display: inline-block; + margin-right: 8px; +} + +.footer { + +} + +.form { + margin: 0 auto; + width: 100%; + max-width: 700px; +} + +.fade-enter-active, .fade-leave-active { + transition: opacity 0.1s; +} + +.fade-enter-from, .fade-leave-to { + transition: opacity 0.5s; + opacity: 0; +} +</style> diff --git a/packages/frontend/src/pages/clicker.vue b/packages/frontend/src/pages/clicker.vue index 9e9b5e8688..479204f39b 100644 --- a/packages/frontend/src/pages/clicker.vue +++ b/packages/frontend/src/pages/clicker.vue @@ -4,19 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> +<PageWithHeader> <MkSpacer :contentMax="800"> <MkClickerGame/> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import MkClickerGame from '@/components/MkClickerGame.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; -definePageMetadata(() => ({ +definePage(() => ({ title: '🍪👈', icon: 'ti ti-cookie', })); diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index 240f395e04..041364d4fc 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions"/></template> +<PageWithHeader :actions="headerActions"> <MkSpacer :contentMax="800"> <div v-if="clip" class="_gaps"> <div class="_panel"> @@ -27,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkNotes :pagination="pagination" :detail="true"/> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -36,16 +35,16 @@ import * as Misskey from 'misskey-js'; import { url } from '@@/js/config.js'; import type { MenuItem } from '@/types/menu.js'; import MkNotes from '@/components/MkNotes.vue'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import { clipsCache } from '@/cache.js'; -import { isSupportShare } from '@/scripts/navigator.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { genEmbedCode } from '@/scripts/get-embed-code.js'; +import { isSupportShare } from '@/utility/navigator.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { genEmbedCode } from '@/utility/get-embed-code.js'; import { assertServerContext, serverContext } from '@/server-context.js'; // contextは非ログイン状態の情報しかないためログイン時は利用できない @@ -148,7 +147,6 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{ text: i18n.ts.copyUrl, action: () => { copyToClipboard(`${url}/clips/${clip.value!.id}`); - os.success(); }, }, { icon: 'ti ti-code', @@ -193,7 +191,7 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{ }, }] : null); -definePageMetadata(() => ({ +definePage(() => ({ title: clip.value ? clip.value.name : i18n.ts.clip, icon: 'ti ti-paperclip', })); diff --git a/packages/frontend/src/pages/contact.vue b/packages/frontend/src/pages/contact.vue index 1f2bee5a77..39d70cafc7 100644 --- a/packages/frontend/src/pages/contact.vue +++ b/packages/frontend/src/pages/contact.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> +<PageWithHeader> <MkSpacer :contentMax="600" :marginMin="20"> <div class="_gaps_m"> <MkKeyValue :copy="instance.maintainerName"> @@ -31,17 +30,17 @@ SPDX-License-Identifier: AGPL-3.0-only </MkKeyValue> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkLink from '@/components/MkLink.vue'; -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.inquiry, icon: 'ti ti-help-circle', })); diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index 107a0d760c..7205cca42f 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -4,91 +4,88 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :contentMax="900"> - <div class="ogwlenmc"> - <div v-if="tab === 'local'" class="local"> - <MkInput v-model="query" :debounce="true" type="search" autocapitalize="off"> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> + <MkSpacer :contentMax="900"> + <div class="ogwlenmc"> + <div v-if="tab === 'local'" class="local"> + <MkInput v-model="query" :debounce="true" type="search" autocapitalize="off"> + <template #prefix><i class="ti ti-search"></i></template> + <template #label>{{ i18n.ts.search }}</template> + </MkInput> + <MkSwitch v-model="selectMode" style="margin: 8px 0;"> + <template #label>Select mode</template> + </MkSwitch> + <div v-if="selectMode" class="_buttons"> + <MkButton inline @click="selectAll">Select all</MkButton> + <MkButton inline @click="setCategoryBulk">Set category</MkButton> + <MkButton inline @click="setTagBulk">Set tag</MkButton> + <MkButton inline @click="addTagBulk">Add tag</MkButton> + <MkButton inline @click="removeTagBulk">Remove tag</MkButton> + <MkButton inline @click="setLicenseBulk">Set License</MkButton> + <MkButton inline danger @click="delBulk">Delete</MkButton> + </div> + <MkPagination ref="emojisPaginationComponent" :pagination="pagination" :displayLimit="50"> + <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.category }}</div> + </div> + </button> + </div> + </template> + </MkPagination> + </div> + + <div v-else-if="tab === 'remote'" class="remote"> + <FormSplit> + <MkInput v-model="queryRemote" :debounce="true" type="search" autocapitalize="off"> <template #prefix><i class="ti ti-search"></i></template> <template #label>{{ i18n.ts.search }}</template> </MkInput> - <MkSwitch v-model="selectMode" style="margin: 8px 0;"> - <template #label>Select mode</template> - </MkSwitch> - <div v-if="selectMode" class="_buttons"> - <MkButton inline @click="selectAll">Select all</MkButton> - <MkButton inline @click="setCategoryBulk">Set category</MkButton> - <MkButton inline @click="setTagBulk">Set tag</MkButton> - <MkButton inline @click="addTagBulk">Add tag</MkButton> - <MkButton inline @click="removeTagBulk">Remove tag</MkButton> - <MkButton inline @click="setLicenseBulk">Set License</MkButton> - <MkButton inline danger @click="delBulk">Delete</MkButton> - </div> - <MkPagination ref="emojisPaginationComponent" :pagination="pagination" :displayLimit="50"> - <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> - <template #default="{items}"> - <div class="ldhfsamy"> - <button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)"> - <img :src="emoji.url" class="img" :alt="emoji.name"/> - <div class="body"> - <div class="name _monospace">{{ emoji.name }}</div> - <div class="info">{{ emoji.category }}</div> - </div> - </button> - </div> - </template> - </MkPagination> - </div> - - <div v-else-if="tab === 'remote'" class="remote"> - <FormSplit> - <MkInput v-model="queryRemote" :debounce="true" type="search" autocapitalize="off"> - <template #prefix><i class="ti ti-search"></i></template> - <template #label>{{ i18n.ts.search }}</template> - </MkInput> - <MkInput v-model="host" :debounce="true"> - <template #label>{{ i18n.ts.host }}</template> - </MkInput> - </FormSplit> - <MkPagination :pagination="remotePagination" :displayLimit="50"> - <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> - <template #default="{items}"> - <div class="ldhfsamy"> - <div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)"> - <img :src="getProxiedImageUrl(emoji.url, 'emoji')" class="img" :alt="emoji.name"/> - <div class="body"> - <div class="name _monospace">{{ emoji.name }}</div> - <div class="info">{{ emoji.host }}</div> - </div> + <MkInput v-model="host" :debounce="true"> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> + </FormSplit> + <MkPagination :pagination="remotePagination" :displayLimit="50"> + <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)"> + <img :src="getProxiedImageUrl(emoji.url, 'emoji')" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.host }}</div> </div> </div> - </template> - </MkPagination> - </div> + </div> + </template> + </MkPagination> </div> - </MkSpacer> - </MkStickyContainer> -</div> + </div> + </MkSpacer> +</PageWithHeader> </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, ref, shallowRef } from 'vue'; +import { computed, defineAsyncComponent, ref, useTemplateRef } from 'vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkRemoteEmojiEditDialog from '@/components/MkRemoteEmojiEditDialog.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import FormSplit from '@/components/form/split.vue'; -import { selectFile } from '@/scripts/select-file.js'; +import { selectFile } from '@/utility/select-file.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { getProxiedImageUrl } from '@/scripts/media-proxy.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { getProxiedImageUrl } from '@/utility/media-proxy.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; -const emojisPaginationComponent = shallowRef<InstanceType<typeof MkPagination>>(); +const emojisPaginationComponent = useTemplateRef('emojisPaginationComponent'); const tab = ref('local'); const query = ref<string | null>(null); @@ -337,7 +334,7 @@ const headerTabs = computed(() => [{ title: i18n.ts.remote, }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.customEmojis, icon: 'ph-smiley ph-bold ph-lg', })); diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue index 8706dc7047..c079b9030d 100644 --- a/packages/frontend/src/pages/drive.file.info.vue +++ b/packages/frontend/src/pages/drive.file.info.vue @@ -69,7 +69,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div v-else class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> </div> @@ -85,8 +85,8 @@ import bytes from '@/filters/bytes.js'; import { infoImageUrl } from '@/instance.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useRouter } from '@/router/supplier.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); diff --git a/packages/frontend/src/pages/drive.file.notes.vue b/packages/frontend/src/pages/drive.file.notes.vue index ca63d43747..d7519896cc 100644 --- a/packages/frontend/src/pages/drive.file.notes.vue +++ b/packages/frontend/src/pages/drive.file.notes.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed } from 'vue'; import { i18n } from '@/i18n.js'; -import { Paging } from '@/components/MkPagination.vue'; +import type { Paging } from '@/components/MkPagination.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkNotes from '@/components/MkNotes.vue'; diff --git a/packages/frontend/src/pages/drive.file.vue b/packages/frontend/src/pages/drive.file.vue index 5711ec8b3a..3063d5a4d6 100644 --- a/packages/frontend/src/pages/drive.file.vue +++ b/packages/frontend/src/pages/drive.file.vue @@ -10,11 +10,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <MkSpacer v-if="tab === 'info'" key="info" :contentMax="800"> + <MkSpacer v-if="tab === 'info'" :contentMax="800"> <XFileInfo :fileId="fileId"/> </MkSpacer> - <MkSpacer v-else-if="tab === 'notes'" key="notes" :contentMax="800"> + <MkSpacer v-else-if="tab === 'notes'" :contentMax="800"> <XNotes :fileId="fileId"/> </MkSpacer> </MkHorizontalSwipe> @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref, defineAsyncComponent } from 'vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; const props = defineProps<{ @@ -48,7 +48,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-pencil', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts._fileViewer.title, icon: 'ti ti-file', })); diff --git a/packages/frontend/src/pages/drive.vue b/packages/frontend/src/pages/drive.vue index 25e140f67f..c5813a4523 100644 --- a/packages/frontend/src/pages/drive.vue +++ b/packages/frontend/src/pages/drive.vue @@ -14,7 +14,7 @@ import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; import XDrive from '@/components/MkDrive.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const folder = ref<Misskey.entities.DriveFolder | null>(null); @@ -22,7 +22,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: folder.value ? folder.value.name : i18n.ts.drive, icon: 'ti ti-cloud', hideHeader: true, diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue index 8d369101af..eee174a6af 100644 --- a/packages/frontend/src/pages/drop-and-fusion.game.vue +++ b/packages/frontend/src/pages/drop-and-fusion.game.vue @@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div ref="containerEl" :class="[$style.gameContainer, { [$style.gameOver]: isGameOver && !replaying }]" @contextmenu.stop.prevent @click.stop.prevent="onClick" @touchmove.stop.prevent="onTouchmove" @touchend="onTouchend" @mousemove="onMousemove"> - <img v-if="defaultStore.state.darkMode" src="/client-assets/drop-and-fusion/frame-dark.svg" :class="$style.mainFrameImg"/> + <img v-if="store.s.darkMode" src="/client-assets/drop-and-fusion/frame-dark.svg" :class="$style.mainFrameImg"/> <img v-else src="/client-assets/drop-and-fusion/frame-light.svg" :class="$style.mainFrameImg"/> <canvas ref="canvasEl" :class="$style.canvas"/> <Transition @@ -191,26 +191,28 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onDeactivated, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'; +import { computed, onDeactivated, onMounted, onUnmounted, ref, shallowRef, watch, useTemplateRef } from 'vue'; import * as Matter from 'matter-js'; import * as Misskey from 'misskey-js'; -import { DropAndFusionGame, Mono } from 'misskey-bubble-game'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { DropAndFusionGame } from 'misskey-bubble-game'; +import { useInterval } from '@@/js/use-interval.js'; +import { apiUrl } from '@@/js/config.js'; +import type { Mono } from 'misskey-bubble-game'; +import { definePage } from '@/page.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import * as os from '@/os.js'; import MkNumber from '@/components/MkNumber.vue'; import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue'; import MkButton from '@/components/MkButton.vue'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { defaultStore } from '@/store.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { store } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { useInterval } from '@@/js/use-interval.js'; -import { apiUrl } from '@@/js/config.js'; -import { $i } from '@/account.js'; -import * as sound from '@/scripts/sound.js'; +import { $i } from '@/i.js'; +import * as sound from '@/utility/sound.js'; import MkRange from '@/components/MkRange.vue'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { prefer } from '@/preferences.js'; type FrontendMonoDefinition = { id: string; @@ -565,8 +567,8 @@ let game = new DropAndFusionGame({ }); attachGameEvents(); -const containerEl = shallowRef<HTMLElement>(); -const canvasEl = shallowRef<HTMLCanvasElement>(); +const containerEl = useTemplateRef('containerEl'); +const canvasEl = useTemplateRef('canvasEl'); const dropperX = ref(0); const currentPick = shallowRef<{ id: string; mono: Mono } | null>(null); const stock = shallowRef<{ id: string; mono: Mono }[]>([]); @@ -585,8 +587,8 @@ const showConfig = ref(false); const replaying = ref(false); const replayPlaybackRate = ref(1); const currentFrame = ref(0); -const bgmVolume = ref(defaultStore.state.dropAndFusion.bgmVolume); -const sfxVolume = ref(defaultStore.state.dropAndFusion.sfxVolume); +const bgmVolume = ref(prefer.s['game.dropAndFusion'].bgmVolume); +const sfxVolume = ref(prefer.s['game.dropAndFusion'].sfxVolume); watch(replayPlaybackRate, (newValue) => { game.replayPlaybackRate = newValue; @@ -622,7 +624,7 @@ function loadMonoTextures() { if (renderer.textures[mono.img]) return; let src = mono.img; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (monoTextureUrls[mono.img]) { src = monoTextureUrls[mono.img]; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -630,7 +632,7 @@ function loadMonoTextures() { src = URL.createObjectURL(monoTextures[mono.img]); monoTextureUrls[mono.img] = src; } else { - const res = await fetch(mono.img); + const res = await window.fetch(mono.img); const blob = await res.blob(); monoTextures[mono.img] = blob; src = URL.createObjectURL(blob); @@ -648,7 +650,6 @@ function loadMonoTextures() { function getTextureImageUrl(mono: Mono) { const def = monoDefinitions.value.find(x => x.id === mono.id)!; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (monoTextureUrls[def.img]) { return monoTextureUrls[def.img]; @@ -849,17 +850,16 @@ function exportLog() { l: DropAndFusionGame.serializeLogs(logs), }); copyToClipboard(data); - os.success(); } function updateSettings< - K extends keyof typeof defaultStore.state.dropAndFusion, - V extends typeof defaultStore.state.dropAndFusion[K], + K extends keyof typeof prefer.s['game.dropAndFusion'], + V extends typeof prefer.s['game.dropAndFusion'][K], >(key: K, value: V) { const changes: { [P in K]?: V } = {}; changes[key] = value; - defaultStore.set('dropAndFusion', { - ...defaultStore.state.dropAndFusion, + prefer.commit('game.dropAndFusion', { + ...prefer.s['game.dropAndFusion'], ...changes, }); } @@ -876,7 +876,7 @@ function loadImage(url: string) { function getGameImageDriveFile() { return new Promise<Misskey.entities.DriveFile | null>(res => { - const dcanvas = document.createElement('canvas'); + const dcanvas = window.document.createElement('canvas'); dcanvas.width = game.GAME_WIDTH; dcanvas.height = game.GAME_HEIGHT; const ctx = dcanvas.getContext('2d'); @@ -909,8 +909,8 @@ function getGameImageDriveFile() { formData.append('name', `bubble-game-${Date.now()}.png`); formData.append('isSensitive', 'false'); formData.append('i', $i.token); - if (defaultStore.state.uploadFolder) { - formData.append('folderId', defaultStore.state.uploadFolder); + if (prefer.s.uploadFolder) { + formData.append('folderId', prefer.s.uploadFolder); } window.fetch(apiUrl + '/drive/files/create', { @@ -1229,7 +1229,7 @@ onDeactivated(() => { bgmNodes?.soundSource.stop(); }); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.bubbleGame, icon: 'ti ti-apple', })); diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue index 54352c9b0d..7f571a7c36 100644 --- a/packages/frontend/src/pages/drop-and-fusion.vue +++ b/packages/frontend/src/pages/drop-and-fusion.vue @@ -88,12 +88,12 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref, watch } from 'vue'; import XGame from './drop-and-fusion.game.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; const gameMode = ref<'normal' | 'square' | 'yen' | 'sweets' | 'space'>('normal'); const gameStarted = ref(false); @@ -121,7 +121,7 @@ function onGameEnd() { gameStarted.value = false; } -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.bubbleGame, icon: 'ti ti-device-gamepad', })); diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index c8e6dfb05a..f9d3197ece 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -87,11 +87,11 @@ import MkInput from '@/components/MkInput.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { customEmojiCategories } from '@/custom-emojis.js'; import MkSwitch from '@/components/MkSwitch.vue'; -import { selectFile } from '@/scripts/select-file.js'; +import { selectFile } from '@/utility/select-file.js'; import MkRolePreview from '@/components/MkRolePreview.vue'; const props = defineProps<{ diff --git a/packages/frontend/src/pages/emojis.emoji.vue b/packages/frontend/src/pages/emojis.emoji.vue index 594a8eda0e..773d65d270 100644 --- a/packages/frontend/src/pages/emojis.emoji.vue +++ b/packages/frontend/src/pages/emojis.emoji.vue @@ -18,14 +18,14 @@ import * as Misskey from 'misskey-js'; import { defineAsyncComponent } from 'vue'; import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; const props = defineProps<{ - emoji: Misskey.entities.EmojiSimple; + emoji: Misskey.entities.EmojiSimple; }>(); function menu(ev) { @@ -38,7 +38,6 @@ function menu(ev) { icon: 'ti ti-copy', action: () => { copyToClipboard(`:${props.emoji.name}:`); - os.success(); }, }, { text: i18n.ts.info, diff --git a/packages/frontend/src/pages/explore.roles.vue b/packages/frontend/src/pages/explore.roles.vue index 389cd23ad2..ffefeb9618 100644 --- a/packages/frontend/src/pages/explore.roles.vue +++ b/packages/frontend/src/pages/explore.roles.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkRolePreview from '@/components/MkRolePreview.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const roles = ref<Misskey.entities.Role[] | null>(null); diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue index 0d2c6217d4..4db26e799c 100644 --- a/packages/frontend/src/pages/explore.users.vue +++ b/packages/frontend/src/pages/explore.users.vue @@ -63,12 +63,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { watch, ref, shallowRef, computed } from 'vue'; +import { watch, ref, useTemplateRef, computed } from 'vue'; import * as Misskey from 'misskey-js'; import MkUserList from '@/components/MkUserList.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkTab from '@/components/MkTab.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; @@ -77,7 +77,7 @@ const props = defineProps<{ }>(); const origin = ref('local'); -const tagsEl = shallowRef<InstanceType<typeof MkFoldableSection>>(); +const tagsEl = useTemplateRef('tagsEl'); const tagsLocal = ref<Misskey.entities.Hashtag[]>([]); const tagsRemote = ref<Misskey.entities.Hashtag[]>([]); diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue index b1a8183d9b..85b9fe4932 100644 --- a/packages/frontend/src/pages/explore.vue +++ b/packages/frontend/src/pages/explore.vue @@ -4,30 +4,29 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'featured'" key="featured"> + <div v-if="tab === 'featured'"> <XFeatured/> </div> - <div v-else-if="tab === 'users'" key="users"> + <div v-else-if="tab === 'users'"> <XUsers/> </div> - <div v-else-if="tab === 'roles'" key="roles"> + <div v-else-if="tab === 'roles'"> <XRoles/> </div> </MkHorizontalSwipe> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> -import { computed, watch, ref, shallowRef } from 'vue'; +import { computed, watch, ref, useTemplateRef } from 'vue'; import XFeatured from './explore.featured.vue'; import XUsers from './explore.users.vue'; import XRoles from './explore.roles.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ @@ -38,7 +37,7 @@ const props = withDefaults(defineProps<{ }); const tab = ref(props.initialTab); -const tagsEl = shallowRef<InstanceType<typeof MkFoldableSection>>(); +const tagsEl = useTemplateRef('tagsEl'); watch(() => props.tag, () => { if (tagsEl.value) tagsEl.value.toggleContent(props.tag == null); @@ -60,7 +59,7 @@ const headerTabs = computed(() => [{ title: i18n.ts.roles, }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.explore, icon: 'ti ti-hash', })); diff --git a/packages/frontend/src/pages/favorites.vue b/packages/frontend/src/pages/favorites.vue index ab26573b19..456f7800f7 100644 --- a/packages/frontend/src/pages/favorites.vue +++ b/packages/frontend/src/pages/favorites.vue @@ -4,13 +4,12 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> +<PageWithHeader> <MkSpacer :contentMax="800"> <MkPagination :pagination="pagination"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.noNotes }}</div> </div> </template> @@ -22,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </MkPagination> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -30,7 +29,7 @@ import MkPagination from '@/components/MkPagination.vue'; import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; import { defineAsyncComponent } from 'vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { infoImageUrl } from '@/instance.js'; import { defaultStore } from '@/store.js'; @@ -45,7 +44,7 @@ const pagination = { limit: 10, }; -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.favorites, icon: 'ti ti-star', })); diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index d84ec4873b..c2f66c0e4d 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> <div class="_gaps"> <MkInput v-model="title"> @@ -37,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSpacer> </div> </template> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -46,14 +45,14 @@ import * as Misskey from 'misskey-js'; import { AISCRIPT_VERSION } from '@syuilo/aiscript'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkTextarea from '@/components/MkTextarea.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const PRESET_DEFAULT = `/// @ ${AISCRIPT_VERSION} @@ -461,7 +460,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: flash.value ? `${i18n.ts._play.edit}: ${flash.value.title}` : i18n.ts._play.new, })); </script> diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue index 2b85489706..98ab587b55 100644 --- a/packages/frontend/src/pages/flash/flash-index.vue +++ b/packages/frontend/src/pages/flash/flash-index.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'featured'" key="featured"> + <div v-if="tab === 'featured'"> <MkPagination v-slot="{items}" :pagination="featuredFlashsPagination"> <div class="_gaps_s"> <MkFlashPreview v-for="flash in items" :key="flash.id" :flash="flash"/> @@ -16,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPagination> </div> - <div v-else-if="tab === 'my'" key="my"> + <div v-else-if="tab === 'my'"> <div class="_gaps"> <MkButton gradate rounded style="margin: 0 auto;" @click="create()"><i class="ti ti-plus"></i></MkButton> <MkPagination v-slot="{items}" :pagination="myFlashsPagination"> @@ -27,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> - <div v-else-if="tab === 'liked'" key="liked"> + <div v-else-if="tab === 'liked'"> <MkPagination v-slot="{items}" :pagination="likedFlashsPagination"> <div class="_gaps_s"> <MkFlashPreview v-for="like in items" :key="like.flash.id" :flash="like.flash"/> @@ -36,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkHorizontalSwipe> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -46,8 +45,8 @@ import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { useRouter } from '@/router/supplier.js'; +import { definePage } from '@/page.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -91,7 +90,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-heart', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: 'Play', icon: 'ti ti-player-play', })); diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue index 7a7d52691c..a644c83620 100644 --- a/packages/frontend/src/pages/flash/flash.vue +++ b/packages/frontend/src/pages/flash/flash.vue @@ -4,12 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> - <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in"> <div v-if="flash" :key="flash.id"> - <Transition :name="defaultStore.state.animation ? 'zoom' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? 'zoom' : ''" mode="out-in"> <div v-if="started" :class="$style.started"> <div class="main _panel"> <MkAsUi v-if="root" :component="root" :components="components"/> @@ -58,30 +57,32 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLoading v-else/> </Transition> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> -import { computed, onDeactivated, onUnmounted, Ref, ref, watch, shallowRef, defineAsyncComponent } from 'vue'; +import { computed, onDeactivated, onUnmounted, ref, watch, shallowRef, defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; import { Interpreter, Parser, values } from '@syuilo/aiscript'; +import { url } from '@@/js/config.js'; +import type { Ref } from 'vue'; +import type { AsUiComponent, AsUiRoot } from '@/aiscript/ui.js'; +import type { MenuItem } from '@/types/menu.js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { url } from '@@/js/config.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkAsUi from '@/components/MkAsUi.vue'; -import { AsUiComponent, AsUiRoot, registerAsUiLib } from '@/scripts/aiscript/ui.js'; -import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; +import { registerAsUiLib } from '@/aiscript/ui.js'; +import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js'; import MkFolder from '@/components/MkFolder.vue'; import MkCode from '@/components/MkCode.vue'; -import { defaultStore } from '@/store.js'; -import { $i } from '@/account.js'; -import { isSupportShare } from '@/scripts/navigator.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import type { MenuItem } from '@/types/menu.js'; -import { pleaseLogin } from '@/scripts/please-login.js'; +import { prefer } from '@/preferences.js'; +import { $i } from '@/i.js'; +import { isSupportShare } from '@/utility/navigator.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { pleaseLogin } from '@/utility/please-login.js'; const props = defineProps<{ id: string; @@ -127,7 +128,6 @@ function copyLink() { if (!flash.value) return; copyToClipboard(`${url}/play/${flash.value.id}`); - os.success(); } function shareWithNavigator() { @@ -196,6 +196,8 @@ async function run() { if (aiscript.value) aiscript.value.abort(); if (!flash.value) return; + components.value = []; + aiscript.value = new Interpreter({ ...createAiScriptEnv({ storageKey: 'flash:' + flash.value.id, @@ -300,7 +302,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: flash.value ? flash.value.title : 'Play', ...flash.value ? { avatar: flash.value.user, diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue index 2f9cacbde9..744fa215cd 100644 --- a/packages/frontend/src/pages/follow-requests.vue +++ b/packages/frontend/src/pages/follow-requests.vue @@ -4,59 +4,57 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div :key="tab" class="_gaps"> - <MkPagination ref="paginationComponent" :pagination="pagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> - <div>{{ i18n.ts.noFollowRequests }}</div> - </div> - </template> - <template #default="{items}"> - <div class="mk-follow-requests _gaps"> - <div v-for="req in items" :key="req.id" class="user _panel"> - <MkAvatar class="avatar" :user="displayUser(req)" indicator link preview/> - <div class="body"> - <div class="name"> - <MkA v-user-preview="displayUser(req).id" class="name" :to="userPage(displayUser(req))"><MkUserName :user="displayUser(req)"/></MkA> - <p class="acct">@{{ acct(displayUser(req)) }}</p> - </div> - <div v-if="tab === 'list'" class="commands"> - <MkButton class="command" rounded primary @click="accept(displayUser(req))"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton> - <MkButton class="command" rounded danger @click="reject(displayUser(req))"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton> - </div> - <div v-else class="commands"> - <MkButton class="command" rounded danger @click="cancel(displayUser(req))"><i class="ti ti-x"/> {{ i18n.ts.cancel }}</MkButton> - </div> + <MkPagination ref="paginationComponent" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img :src="infoImageUrl" draggable="false"/> + <div>{{ i18n.ts.noFollowRequests }}</div> + </div> + </template> + <template #default="{items}"> + <div class="mk-follow-requests _gaps"> + <div v-for="req in items" :key="req.id" class="user _panel"> + <MkAvatar class="avatar" :user="displayUser(req)" indicator link preview/> + <div class="body"> + <div class="name"> + <MkA v-user-preview="displayUser(req).id" class="name" :to="userPage(displayUser(req))"><MkUserName :user="displayUser(req)"/></MkA> + <p class="acct">@{{ acct(displayUser(req)) }}</p> + </div> + <div v-if="tab === 'list'" class="commands"> + <MkButton class="command" rounded primary @click="accept(displayUser(req))"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton> + <MkButton class="command" rounded danger @click="reject(displayUser(req))"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton> + </div> + <div v-else class="commands"> + <MkButton class="command" rounded danger @click="cancel(displayUser(req))"><i class="ti ti-x"/> {{ i18n.ts.cancel }}</MkButton> </div> </div> </div> - </template> - </MkPagination> - </div> + </div> + </template> + </MkPagination> </MkHorizontalSwipe> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { shallowRef, computed, ref } from 'vue'; -import MkPagination, { type Paging } from '@/components/MkPagination.vue'; +import { useTemplateRef, computed, ref } from 'vue'; +import type { Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import { userPage, acct } from '@/filters/user.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { infoImageUrl } from '@/instance.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; -const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>(); +const paginationComponent = useTemplateRef('paginationComponent'); const pagination = computed<Paging>(() => tab.value === 'list' ? { endpoint: 'following/requests/list', @@ -104,7 +102,7 @@ const headerTabs = computed(() => [ const tab = ref($i?.isLocked || !$i.hasPendingSentFollowRequest ? 'list' : 'sent'); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.followRequests, icon: 'ti ti-user-plus', })); @@ -145,12 +143,10 @@ definePageMetadata(() => ({ } > .name { - font-size: 16px; line-height: 24px; } > .acct { - font-size: 15px; line-height: 16px; opacity: 0.7; } @@ -163,7 +159,6 @@ definePageMetadata(() => ({ overflow: hidden; text-overflow: ellipsis; opacity: 0.7; - font-size: 14px; padding-right: 40px; padding-left: 8px; box-sizing: border-box; diff --git a/packages/frontend/src/pages/gallery/edit.vue b/packages/frontend/src/pages/gallery/edit.vue index 70f8b2c31d..7831e084a2 100644 --- a/packages/frontend/src/pages/gallery/edit.vue +++ b/packages/frontend/src/pages/gallery/edit.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800" :marginMin="16" :marginMax="32"> <FormSuspense :p="init" class="_gaps"> <MkInput v-model="title"> @@ -34,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </FormSuspense> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -45,12 +44,12 @@ import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import FormSuspense from '@/components/form/suspense.vue'; -import { selectFiles } from '@/scripts/select-file.js'; +import { selectFiles } from '@/utility/select-file.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -122,7 +121,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: props.postId ? i18n.ts.edit : i18n.ts.postToGallery, icon: 'ti ti-pencil', })); diff --git a/packages/frontend/src/pages/gallery/index.vue b/packages/frontend/src/pages/gallery/index.vue index 2162e269bf..806b6df671 100644 --- a/packages/frontend/src/pages/gallery/index.vue +++ b/packages/frontend/src/pages/gallery/index.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="1400"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'explore'" key="explore"> + <div v-if="tab === 'explore'"> <MkFoldableSection class="_margin"> <template #header><i class="ti ti-clock"></i>{{ i18n.ts.recentPosts }}</template> <MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disableAutoLoad="true"> @@ -26,14 +25,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPagination> </MkFoldableSection> </div> - <div v-else-if="tab === 'liked'" key="liked"> + <div v-else-if="tab === 'liked'"> <MkPagination v-slot="{items}" :pagination="likedPostsPagination"> <div :class="$style.items"> <MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/> </div> </MkPagination> </div> - <div v-else-if="tab === 'my'" key="my"> + <div v-else-if="tab === 'my'"> <MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="ti ti-plus"></i> {{ i18n.ts.postToGallery }}</MkA> <MkPagination v-slot="{items}" :pagination="myPostsPagination"> <div :class="$style.items"> @@ -43,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkHorizontalSwipe> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -52,9 +51,9 @@ import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -119,7 +118,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-edit', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.gallery, icon: 'ph-images-square ph-bold ph-lg', })); diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue index 539a6a9a7b..77c4cf1f08 100644 --- a/packages/frontend/src/pages/gallery/post.vue +++ b/packages/frontend/src/pages/gallery/post.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="1000" :marginMin="16" :marginMax="32"> <div class="_root"> - <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in"> <div v-if="post" class="rkxwuolj"> <div class="files"> <div v-for="file in post.files" :key="file.id" class="file"> @@ -59,28 +58,28 @@ SPDX-License-Identifier: AGPL-3.0-only </Transition> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, watch, ref, defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; +import { url } from '@@/js/config.js'; +import type { MenuItem } from '@/types/menu.js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; -import { url } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { defaultStore } from '@/store.js'; -import { $i } from '@/account.js'; -import { isSupportShare } from '@/scripts/navigator.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { useRouter } from '@/router/supplier.js'; -import type { MenuItem } from '@/types/menu.js'; +import { definePage } from '@/page.js'; +import { prefer } from '@/preferences.js'; +import { $i } from '@/i.js'; +import { isSupportShare } from '@/utility/navigator.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -111,7 +110,6 @@ function fetchPost() { function copyLink() { copyToClipboard(`${url}/gallery/${post.value.id}`); - os.success(); } function share() { @@ -208,7 +206,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: post.value ? post.value.title : i18n.ts.gallery, ...post.value ? { avatar: post.value.user, diff --git a/packages/frontend/src/pages/games.vue b/packages/frontend/src/pages/games.vue index 998b8be0f3..7436c13332 100644 --- a/packages/frontend/src/pages/games.vue +++ b/packages/frontend/src/pages/games.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> +<PageWithHeader> <MkSpacer :contentMax="800"> <div class="_gaps"> <div class="_panel" :class="$style.link"> @@ -20,14 +19,14 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; -definePageMetadata(() => ({ +definePage(() => ({ title: 'Misskey Games', icon: 'ti ti-device-gamepad', })); diff --git a/packages/frontend/src/pages/install-extensions.vue b/packages/frontend/src/pages/install-extensions.vue index 6d68ed83b4..bf57b0c231 100644 --- a/packages/frontend/src/pages/install-extensions.vue +++ b/packages/frontend/src/pages/install-extensions.vue @@ -4,14 +4,12 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :contentMax="500"> +<PageWithAnimBg> + <MkSpacer :contentMax="550" :marginMax="50"> <MkLoading v-if="uiPhase === 'fetching'"/> - <MkExtensionInstaller v-else-if="uiPhase === 'confirm' && data" :extension="data" @confirm="install()"> + <MkExtensionInstaller v-else-if="uiPhase === 'confirm' && data" :extension="data" @confirm="install()" @cancel="close_()"> <template #additionalInfo> <FormSection> - <template #label>{{ i18n.ts._externalResourceInstaller._vendorInfo.title }}</template> <div class="_gaps_s"> <MkKeyValue> <template #key>{{ i18n.ts._externalResourceInstaller._vendorInfo.endpoint }}</template> @@ -35,29 +33,30 @@ SPDX-License-Identifier: AGPL-3.0-only <h2 :class="$style.extInstallerTitle">{{ errorKV?.title }}</h2> <div :class="$style.extInstallerNormDesc">{{ errorKV?.description }}</div> <div class="_buttonsCenter"> - <MkButton @click="goBack()">{{ i18n.ts.goBack }}</MkButton> - <MkButton @click="goToMisskey()">{{ i18n.ts.goToMisskey }}</MkButton> + <MkButton @click="close_()">{{ i18n.ts.close }}</MkButton> </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithAnimBg> </template> <script lang="ts" setup> -import { ref, computed, onActivated, onDeactivated, nextTick } from 'vue'; +import { ref, computed, nextTick } from 'vue'; +import type { Extension } from '@/components/MkExtensionInstaller.vue'; +import type { AiScriptPluginMeta } from '@/plugin.js'; import MkLoading from '@/components/global/MkLoading.vue'; -import MkExtensionInstaller, { type Extension } from '@/components/MkExtensionInstaller.vue'; +import MkExtensionInstaller from '@/components/MkExtensionInstaller.vue'; import MkButton from '@/components/MkButton.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkUrl from '@/components/global/MkUrl.vue'; import FormSection from '@/components/form/section.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { AiScriptPluginMeta, parsePluginMeta, installPlugin } from '@/scripts/install-plugin.js'; -import { parseThemeCode, installTheme } from '@/scripts/install-theme.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { parsePluginMeta, installPlugin } from '@/plugin.js'; +import { parseThemeCode, installTheme } from '@/theme.js'; +import { unisonReload } from '@/utility/unison-reload.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const uiPhase = ref<'fetching' | 'confirm' | 'error'>('fetching'); const errorKV = ref<{ @@ -73,12 +72,12 @@ const hash = ref<string | null>(null); const data = ref<Extension | null>(null); -function goBack(): void { - history.back(); -} - -function goToMisskey(): void { - location.href = '/'; +function close_(): void { + if (window.history.length === 1) { + window.close(); + } else { + window.history.back(); + } } async function fetch() { @@ -205,9 +204,9 @@ async function install() { try { await installPlugin(data.value.raw, data.value.meta as AiScriptPluginMeta); os.success(); - nextTick(() => { - unisonReload('/'); - }); + window.setTimeout(() => { + close_(); + }, 3000); } catch (err) { errorKV.value = { title: i18n.ts._externalResourceInstaller._errors._pluginInstallFailed.title, @@ -221,28 +220,18 @@ async function install() { if (!data.value.meta) return; await installTheme(data.value.raw); os.success(); - nextTick(() => { - location.href = '/settings/theme'; - }); + window.setTimeout(() => { + close_(); + }, 3000); } } -onActivated(() => { - const urlParams = new URLSearchParams(window.location.search); - url.value = urlParams.get('url'); - hash.value = urlParams.get('hash'); - fetch(); -}); - -onDeactivated(() => { - uiPhase.value = 'fetching'; -}); - -const headerActions = computed(() => []); - -const headerTabs = computed(() => []); +const urlParams = new URLSearchParams(window.location.search); +url.value = urlParams.get('url'); +hash.value = urlParams.get('hash'); +fetch(); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts._externalResourceInstaller.title, icon: 'ti ti-download', })); diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index 32a9d30264..963cc98c7c 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer v-if="instance" :contentMax="600" :marginMin="16" :marginMax="32"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'overview'" key="overview" class="_gaps_m"> + <div v-if="tab === 'overview'" class="_gaps_m"> <div class="fnfelxur"> <img :src="faviconUrl" alt="" class="icon"/> <span class="name">{{ instance.name || `(${i18n.ts.unknown})` }}</span> @@ -100,7 +99,7 @@ SPDX-License-Identifier: AGPL-3.0-only <FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink> </FormSection> </div> - <div v-else-if="tab === 'chart'" key="chart" class="_gaps_m"> + <div v-else-if="tab === 'chart'" class="_gaps_m"> <div class="cmhjzshl"> <div class="selects"> <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> @@ -125,14 +124,14 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> - <div v-else-if="tab === 'users'" key="users" class="_gaps_m"> + <div v-else-if="tab === 'users'" class="_gaps_m"> <MkPagination v-slot="{items}" :pagination="usersPagination" style="display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); grid-gap: 12px;"> <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${dateString(user.updatedAt)}`" class="user" :to="`/admin/user/${user.id}`"> <MkUserCardMini :user="user"/> </MkA> </MkPagination> </div> - <div v-else-if="tab === 'following'" key="following" class="_gaps_m"> + <div v-else-if="tab === 'following'" class="_gaps_m"> <MkPagination v-slot="{items}" :pagination="followingPagination"> <div class="follow-relations-list"> <div v-for="followRelationship in items" :key="followRelationship.id" class="follow-relation"> @@ -147,7 +146,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkPagination> </div> - <div v-else-if="tab === 'followers'" key="followers" class="_gaps_m"> + <div v-else-if="tab === 'followers'" class="_gaps_m"> <MkPagination v-slot="{items}" :pagination="followersPagination"> <div class="follow-relations-list"> <div v-for="followRelationship in items" :key="followRelationship.id" class="follow-relation"> @@ -162,19 +161,21 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkPagination> </div> - <div v-else-if="tab === 'raw'" key="raw" class="_gaps_m"> + <div v-else-if="tab === 'raw'" class="_gaps_m"> <MkObjectView tall :value="instance"> </MkObjectView> </div> </MkHorizontalSwipe> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { ref, computed, watch } from 'vue'; import * as Misskey from 'misskey-js'; -import MkChart, { type ChartSrc } from '@/components/MkChart.vue'; +import type { ChartSrc } from '@/components/MkChart.vue'; +import type { Paging } from '@/components/MkPagination.vue'; +import MkChart from '@/components/MkChart.vue'; import MkObjectView from '@/components/MkObjectView.vue'; import FormLink from '@/components/form/link.vue'; import MkLink from '@/components/MkLink.vue'; @@ -184,15 +185,15 @@ import MkKeyValue from '@/components/MkKeyValue.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import number from '@/filters/number.js'; -import { iAmModerator, iAmAdmin } from '@/account.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { iAmModerator, iAmAdmin } from '@/i.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; -import MkPagination, { type Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; -import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; +import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; import { dateString } from '@/filters/date.js'; import MkTextarea from '@/components/MkTextarea.vue'; import MkInfo from '@/components/MkInfo.vue'; @@ -458,7 +459,7 @@ function getFollowingTabs() { ]; } -definePageMetadata(() => ({ +definePage(() => ({ title: props.host, icon: 'ti ti-server', })); diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue index 3416ee6cfc..77ad1cdd96 100644 --- a/packages/frontend/src/pages/invite.vue +++ b/packages/frontend/src/pages/invite.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> +<PageWithHeader> <MkSpacer v-if="!instance.disableRegistration || !($i && ($i.isAdmin || $i.policies.canInvite))" :contentMax="1200"> <div :class="$style.root"> - <img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/> + <img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/> <div :class="$style.text"> <i class="ti ti-alert-triangle"></i> {{ i18n.ts.nothing }} @@ -30,23 +29,24 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPagination> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> -import { computed, ref, shallowRef } from 'vue'; -import type * as Misskey from 'misskey-js'; +import { computed, ref, useTemplateRef } from 'vue'; +import * as Misskey from 'misskey-js'; +import type { Paging } from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkButton from '@/components/MkButton.vue'; -import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import MkInviteCode from '@/components/MkInviteCode.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { serverErrorImageUrl, instance } from '@/instance.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; -const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); +const pagingComponent = useTemplateRef('pagingComponent'); const currentInviteLimit = ref<null | number>(null); const inviteLimit = (($i != null && $i.policies.inviteLimit) || (($i == null && instance.policies.inviteLimit))) as number; const inviteLimitCycle = (($i != null && $i.policies.inviteLimitCycle) || ($i == null && instance.policies.inviteLimitCycle)) as number; @@ -91,7 +91,7 @@ async function update() { update(); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.invite, icon: 'ti ti-user-plus', })); diff --git a/packages/frontend/src/pages/list.vue b/packages/frontend/src/pages/list.vue index e1ba424afc..3a1f98db74 100644 --- a/packages/frontend/src/pages/list.vue +++ b/packages/frontend/src/pages/list.vue @@ -4,18 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer v-if="error != null" :contentMax="1200"> <div :class="$style.root"> - <img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/> + <img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/> <p :class="$style.text"> <i class="ti ti-alert-triangle"></i> {{ i18n.ts.nothing }} </p> </div> </MkSpacer> - <MkSpacer v-else-if="list" :contentMax="700" :class="$style.main"> + <MkSpacer v-else-if="list" :contentMax="700"> <div v-if="list" class="members _margin"> <div :class="$style.member_text">{{ i18n.ts.members }}</div> <div class="_gaps_s"> @@ -30,19 +29,19 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton v-if="!list.isLiked" v-tooltip="i18n.ts.like" inline :class="$style.button" asLike @click="like()"><i class="ti ti-heart"></i><span v-if="1 > 0" class="count">{{ list.likedCount }}</span></MkButton> <MkButton inline @click="create()"><i class="ti ti-download" :class="$style.import"></i>{{ i18n.ts.import }}</MkButton> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { watch, computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkButton from '@/components/MkButton.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { serverErrorImageUrl } from '@/instance.js'; const props = defineProps<{ @@ -101,16 +100,12 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: list.value ? list.value.name : i18n.ts.lists, icon: 'ti ti-list', })); </script> <style lang="scss" module> -.main { - min-height: calc(100cqh - (var(--MI-stickyTop, 0px) + var(--MI-stickyBottom, 0px))); -} - .userItem { display: flex; } diff --git a/packages/frontend/src/pages/lookup.vue b/packages/frontend/src/pages/lookup.vue index 6f10c69640..623c2a6779 100644 --- a/packages/frontend/src/pages/lookup.vue +++ b/packages/frontend/src/pages/lookup.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <div v-if="state === 'done'" class="_buttonsCenter"> <MkButton @click="close">{{ i18n.ts.close }}</MkButton> @@ -15,23 +14,23 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLoading/> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { mainRouter } from '@/router/main.js'; +import { definePage } from '@/page.js'; +import { mainRouter } from '@/router.js'; import MkButton from '@/components/MkButton.vue'; const state = ref<'fetching' | 'done'>('fetching'); function fetch() { - const params = new URL(location.href).searchParams; + const params = new URL(window.location.href).searchParams; // acctのほうはdeprecated let uri = params.get('uri') ?? params.get('acct'); @@ -76,12 +75,12 @@ function close(): void { // 閉じなければ100ms後タイムラインに window.setTimeout(() => { - location.href = '/'; + window.location.href = '/'; }, 100); } function goToMisskey(): void { - location.href = '/'; + window.location.href = '/'; } fetch(); @@ -90,7 +89,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePage({ title: i18n.ts.lookup, icon: 'ti ti-world-search', }); diff --git a/packages/frontend/src/pages/miauth.vue b/packages/frontend/src/pages/miauth.vue index ab060587c5..d4296d428b 100644 --- a/packages/frontend/src/pages/miauth.vue +++ b/packages/frontend/src/pages/miauth.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkAnimBg style="position: fixed; top: 0;"/> +<PageWithAnimBg> <div :class="$style.formContainer"> <div :class="$style.form"> <MkAuthConfirm @@ -25,19 +24,16 @@ SPDX-License-Identifier: AGPL-3.0-only </MkAuthConfirm> </div> </div> -</div> +</PageWithAnimBg> </template> <script lang="ts" setup> import { computed, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; - -import MkAnimBg from '@/components/MkAnimBg.vue'; import MkAuthConfirm from '@/components/MkAuthConfirm.vue'; - import { i18n } from '@/i18n.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; const props = defineProps<{ session: string; @@ -64,7 +60,7 @@ async function onAccept(token: string) { const cbUrl = new URL(props.callback); if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:', 'vbscript:'].includes(cbUrl.protocol)) throw new Error('invalid url'); cbUrl.searchParams.set('session', props.session); - location.href = cbUrl.toString(); + window.location.href = cbUrl.toString(); } else { authRoot.value?.showUI('success'); } @@ -77,7 +73,7 @@ function onDeny() { authRoot.value?.showUI('denied'); } -definePageMetadata(() => ({ +definePage(() => ({ title: 'MiAuth', icon: 'ti ti-apps', })); diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue index 2b8518747f..8f331f1333 100644 --- a/packages/frontend/src/pages/my-antennas/create.vue +++ b/packages/frontend/src/pages/my-antennas/create.vue @@ -4,19 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkAntennaEditor @created="onAntennaCreated"/> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed } from 'vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { antennasCache } from '@/cache.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; import MkAntennaEditor from '@/components/MkAntennaEditor.vue'; const router = useRouter(); @@ -29,7 +27,7 @@ function onAntennaCreated() { const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.createAntenna, icon: 'ti ti-antenna', })); diff --git a/packages/frontend/src/pages/my-antennas/edit.vue b/packages/frontend/src/pages/my-antennas/edit.vue index 9f927cd1a0..f449e83c1f 100644 --- a/packages/frontend/src/pages/my-antennas/edit.vue +++ b/packages/frontend/src/pages/my-antennas/edit.vue @@ -4,22 +4,20 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkAntennaEditor v-if="antenna" :antenna="antenna" @updated="onAntennaUpdated"/> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import MkAntennaEditor from '@/components/MkAntennaEditor.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { antennasCache } from '@/cache.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -41,7 +39,7 @@ misskeyApi('antennas/show', { antennaId: props.antennaId }).then((antennaRespons const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.editAntenna, icon: 'ti ti-antenna', })); diff --git a/packages/frontend/src/pages/my-antennas/index.vue b/packages/frontend/src/pages/my-antennas/index.vue index 540cb34904..297436ad61 100644 --- a/packages/frontend/src/pages/my-antennas/index.vue +++ b/packages/frontend/src/pages/my-antennas/index.vue @@ -4,13 +4,12 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> <div> <div v-if="antennas.length === 0" class="empty"> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> </div> @@ -24,14 +23,14 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { onActivated, computed } from 'vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { antennasCache } from '@/cache.js'; import { infoImageUrl } from '@/instance.js'; @@ -55,7 +54,7 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.manageAntennas, icon: 'ti ti-antenna', })); diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue index acf37a9a2f..1525bbef9b 100644 --- a/packages/frontend/src/pages/my-clips/index.vue +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -4,35 +4,34 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'my'" key="my" class="_gaps"> + <div v-if="tab === 'my'" class="_gaps"> <MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> <MkPagination v-slot="{ items }" ref="pagingComponent" :pagination="pagination" class="_gaps"> <MkClipPreview v-for="item in items" :key="item.id" :clip="item" :noUserInfo="true"/> </MkPagination> </div> - <div v-else-if="tab === 'favorites'" key="favorites" class="_gaps"> + <div v-else-if="tab === 'favorites'" class="_gaps"> <MkClipPreview v-for="item in favorites" :key="item.id" :clip="item"/> </div> </MkHorizontalSwipe> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> -import { watch, ref, shallowRef, computed } from 'vue'; +import { watch, ref, useTemplateRef, computed } from 'vue'; import * as Misskey from 'misskey-js'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import MkClipPreview from '@/components/MkClipPreview.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { clipsCache } from '@/cache.js'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; @@ -46,7 +45,7 @@ const tab = ref('my'); const favorites = ref<Misskey.entities.Clip[] | null>(null); -const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); +const pagingComponent = useTemplateRef('pagingComponent'); watch(tab, async () => { favorites.value = await misskeyApi('clips/my-favorites'); @@ -100,7 +99,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-heart', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.clip, icon: 'ti ti-paperclip', })); diff --git a/packages/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue index d08f6fec5a..6e23769083 100644 --- a/packages/frontend/src/pages/my-lists/index.vue +++ b/packages/frontend/src/pages/my-lists/index.vue @@ -4,13 +4,12 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> <div class="_gaps"> <div v-if="items.length === 0" class="empty"> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> </div> @@ -25,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -34,12 +33,12 @@ import MkButton from '@/components/MkButton.vue'; import MkAvatars from '@/components/MkAvatars.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { userListsCache } from '@/cache.js'; import { infoImageUrl } from '@/instance.js'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const items = computed(() => userListsCache.value.value ?? []); @@ -71,7 +70,7 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.manageLists, icon: 'ti ti-list', })); diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue index 69e404bd85..c187435af9 100644 --- a/packages/frontend/src/pages/my-lists/list.vue +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -4,9 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :contentMax="700" :class="$style.main"> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> + <MkSpacer :contentMax="700"> <div v-if="list" class="_gaps"> <MkFolder> <template #label>{{ i18n.ts.settings }}</template> @@ -49,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -57,8 +56,8 @@ import { computed, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import { userPage } from '@/filters/user.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; @@ -66,16 +65,16 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkInput from '@/components/MkInput.vue'; import { userListsCache } from '@/cache.js'; -import { signinRequired } from '@/account.js'; -import { defaultStore } from '@/store.js'; +import { ensureSignin } from '@/i.js'; import MkPagination from '@/components/MkPagination.vue'; -import { mainRouter } from '@/router/main.js'; +import { mainRouter } from '@/router.js'; +import { prefer } from '@/preferences.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const { enableInfiniteScroll, -} = defaultStore.reactiveState; +} = prefer.r; const props = defineProps<{ listId: string; @@ -191,17 +190,13 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: list.value ? list.value.name : i18n.ts.lists, icon: 'ti ti-list', })); </script> <style lang="scss" module> -.main { - min-height: calc(100cqh - (var(--MI-stickyTop, 0px) + var(--MI-stickyBottom, 0px))); -} - .userItem { display: flex; } diff --git a/packages/frontend/src/pages/not-found.vue b/packages/frontend/src/pages/not-found.vue index 6a2d01b6fa..684a3bb5bd 100644 --- a/packages/frontend/src/pages/not-found.vue +++ b/packages/frontend/src/pages/not-found.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> <div class="_fullinfo"> - <img :src="notFoundImageUrl" class="_ghost"/> + <img :src="notFoundImageUrl" draggable="false"/> <div>{{ i18n.ts.notFoundDescription }}</div> </div> </div> @@ -15,8 +15,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed } from 'vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { pleaseLogin } from '@/scripts/please-login.js'; +import { definePage } from '@/page.js'; +import { pleaseLogin } from '@/utility/please-login.js'; import { notFoundImageUrl } from '@/instance.js'; const props = defineProps<{ @@ -31,7 +31,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.notFound, icon: 'ti ti-alert-triangle', })); diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue index 5214ca4849..dece69bfc8 100644 --- a/packages/frontend/src/pages/note.vue +++ b/packages/frontend/src/pages/note.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :displayBackButton="true" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :displayBackButton="true" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <div> - <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in"> <div v-if="note"> <div v-if="showNext" class="_margin"> <MkNotes class="" :pagination="showNext === 'channel' ? nextChannelPagination : nextUserPagination" :noGap="true" :disableAutoLoad="true"/> @@ -45,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only </Transition> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -56,16 +55,17 @@ import type { Paging } from '@/components/MkPagination.vue'; import MkNotes from '@/components/MkNotes.vue'; import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; import MkButton from '@/components/MkButton.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import { dateString } from '@/filters/date.js'; import MkClipPreview from '@/components/MkClipPreview.vue'; import SkErrorList from '@/components/SkErrorList.vue'; -import { defaultStore } from '@/store.js'; -import { pleaseLogin } from '@/scripts/please-login.js'; +import { prefer } from '@/preferences.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import { getAppearNote } from '@/utility/get-appear-note.js'; import { serverContext, assertServerContext } from '@/server-context.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; // contextは非ログイン状態の情報しかないためログイン時は利用できない const CTX_NOTE = !$i && assertServerContext(serverContext, 'note') ? serverContext.note : null; @@ -140,10 +140,11 @@ function fetchNote() { noteId: props.noteId, }).then(res => { note.value = res; + const appearNote = getAppearNote(res); // 古いノートは被クリップ数をカウントしていないので、2023-10-01以前のものは強制的にnotes/clipsを叩く - if (note.value.clippedCount > 0 || new Date(note.value.createdAt).getTime() < new Date('2023-10-01').getTime()) { + if ((appearNote.clippedCount ?? 0) > 0 || new Date(appearNote.createdAt).getTime() < new Date('2023-10-01').getTime()) { misskeyApi('notes/clips', { - noteId: note.value.id, + noteId: appearNote.id, }).then((_clips) => { clips.value = _clips; }); @@ -177,14 +178,14 @@ const headerActions = computed(() => note.value ? [ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.note, ...note.value ? { subtitle: dateString(note.value.createdAt), avatar: note.value.user, path: `/notes/${note.value.id}`, share: { - title: i18n.tsx.noteOf({ user: note.value.user.name }), + title: i18n.tsx.noteOf({ user: note.value.user.name ?? note.value.user.username }), text: note.value.text, }, } : {}, diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue index 46ee501c76..a7ff519a34 100644 --- a/packages/frontend/src/pages/notifications.vue +++ b/packages/frontend/src/pages/notifications.vue @@ -4,33 +4,32 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'all'" key="all"> + <div v-if="tab === 'all'"> <XNotifications :class="$style.notifications" :excludeTypes="excludeTypes"/> </div> - <div v-else-if="tab === 'mentions'" key="mention"> + <div v-else-if="tab === 'mentions'"> <MkNotes :pagination="mentionsPagination"/> </div> - <div v-else-if="tab === 'directNotes'" key="directNotes"> + <div v-else-if="tab === 'directNotes'"> <MkNotes :pagination="directNotesPagination"/> </div> </MkHorizontalSwipe> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, ref } from 'vue'; +import { notificationTypes } from '@@/js/const.js'; import XNotifications from '@/components/MkNotifications.vue'; import MkNotes from '@/components/MkNotes.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { notificationTypes } from '@@/js/const.js'; +import { definePage } from '@/page.js'; const tab = ref('all'); const includeTypes = ref<string[] | null>(null); @@ -94,7 +93,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-mail', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.notifications, icon: 'ti ti-bell', })); diff --git a/packages/frontend/src/pages/oauth.vue b/packages/frontend/src/pages/oauth.vue index 8719a769e5..49fdd25ff3 100644 --- a/packages/frontend/src/pages/oauth.vue +++ b/packages/frontend/src/pages/oauth.vue @@ -4,13 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkAnimBg style="position: fixed; top: 0;"/> +<PageWithAnimBg> <div :class="$style.formContainer"> <div :class="$style.form"> <MkAuthConfirm ref="authRoot" :name="name" + :icon="logo" :permissions="permissions" :waitOnDeny="true" @accept="onAccept" @@ -18,50 +18,50 @@ SPDX-License-Identifier: AGPL-3.0-only /> </div> </div> -</div> +</PageWithAnimBg> </template> <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import MkAnimBg from '@/components/MkAnimBg.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkAuthConfirm from '@/components/MkAuthConfirm.vue'; -const transactionIdMeta = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]'); +const transactionIdMeta = window.document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]'); if (transactionIdMeta) { transactionIdMeta.remove(); } -const name = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content; -const permissions = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:scope"]')?.content.split(' ').filter((p): p is typeof Misskey.permissions[number] => (Misskey.permissions as readonly string[]).includes(p)) ?? []; +const name = window.document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content; +const logo = window.document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-logo"]')?.content; +const permissions = window.document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:scope"]')?.content.split(' ').filter((p): p is typeof Misskey.permissions[number] => (Misskey.permissions as readonly string[]).includes(p)) ?? []; function doPost(token: string, decision: 'accept' | 'deny') { - const form = document.createElement('form'); + const form = window.document.createElement('form'); form.action = '/oauth/decision'; form.method = 'post'; form.acceptCharset = 'utf-8'; - const loginToken = document.createElement('input'); + const loginToken = window.document.createElement('input'); loginToken.type = 'hidden'; loginToken.name = 'login_token'; loginToken.value = token; form.appendChild(loginToken); - const transactionId = document.createElement('input'); + const transactionId = window.document.createElement('input'); transactionId.type = 'hidden'; transactionId.name = 'transaction_id'; transactionId.value = transactionIdMeta?.content ?? ''; form.appendChild(transactionId); if (decision === 'deny') { - const cancel = document.createElement('input'); + const cancel = window.document.createElement('input'); cancel.type = 'hidden'; cancel.name = 'cancel'; cancel.value = 'cancel'; form.appendChild(cancel); } - document.body.appendChild(form); + window.document.body.appendChild(form); form.submit(); } @@ -73,7 +73,7 @@ function onDeny(token: string) { doPost(token, 'deny'); } -definePageMetadata(() => ({ +definePage(() => ({ title: 'OAuth', icon: 'ti ti-apps', })); diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue index c3ad6657b0..1b98425719 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue @@ -26,7 +26,7 @@ import * as Misskey from 'misskey-js'; import XContainer from '../page-editor.container.vue'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue index 36e03b4790..f275ec9517 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue @@ -30,7 +30,7 @@ import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkNote from '@/components/MkNote.vue'; import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue index 3fed07f7e8..4d1a3716e7 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue @@ -21,14 +21,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> - + import { defineAsyncComponent, inject, onMounted, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { v4 as uuid } from 'uuid'; import XContainer from '../page-editor.container.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { deepClone } from '@/scripts/clone.js'; +import { deepClone } from '@/utility/clone.js'; import MkButton from '@/components/MkButton.vue'; import { getPageBlockList } from '@/pages/page-editor/common.js'; diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue index 5795b46c00..4a980ce472 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue @@ -15,12 +15,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> - -import { watch, ref, shallowRef, onMounted, onUnmounted } from 'vue'; +import { watch, ref, useTemplateRef, onMounted, onUnmounted } from 'vue'; import * as Misskey from 'misskey-js'; import XContainer from '../page-editor.container.vue'; import { i18n } from '@/i18n.js'; -import { Autocomplete } from '@/scripts/autocomplete.js'; +import { Autocomplete } from '@/utility/autocomplete.js'; const props = defineProps<{ modelValue: Misskey.entities.PageBlock & { type: 'text' } @@ -33,7 +32,7 @@ const emit = defineEmits<{ let autocomplete: Autocomplete; const text = ref(props.modelValue.text ?? ''); -const inputEl = shallowRef<HTMLTextAreaElement | null>(null); +const inputEl = useTemplateRef('inputEl'); watch(text, () => { emit('update:modelValue', { diff --git a/packages/frontend/src/pages/page-editor/page-editor.container.vue b/packages/frontend/src/pages/page-editor/page-editor.container.vue index 1739c2fc00..6b10c641d7 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.container.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.container.vue @@ -61,11 +61,11 @@ function remove() { position: relative; overflow: hidden; background: var(--MI_THEME-panel); - border: solid 2px var(--MI_THEME-X12); + border: solid 2px light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1)); border-radius: var(--MI-radius-sm); &:hover { - border: solid 2px var(--MI_THEME-X13); + border: solid 2px light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15)); } &.warn { diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue index 8597654375..67134f0976 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> <div class="jqqmcavi"> <MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="ti ti-external-link"></i> {{ i18n.ts._pages.viewPage }}</MkButton> @@ -57,26 +56,26 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, provide, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { v4 as uuid } from 'uuid'; +import { url } from '@@/js/config.js'; import XBlocks from './page-editor.blocks.vue'; import MkButton from '@/components/MkButton.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkInput from '@/components/MkInput.vue'; -import { url } from '@@/js/config.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { selectFile } from '@/scripts/select-file.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { selectFile } from '@/utility/select-file.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { $i } from '@/account.js'; -import { mainRouter } from '@/router/main.js'; +import { definePage } from '@/page.js'; +import { $i } from '@/i.js'; +import { mainRouter } from '@/router.js'; import { getPageBlockList } from '@/pages/page-editor/common.js'; const props = defineProps<{ @@ -264,10 +263,10 @@ const headerTabs = computed(() => [{ icon: 'ti ti-note', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: props.initPageId ? i18n.ts._pages.editPage - : props.initPageName && props.initUser ? i18n.ts._pages.readPage - : i18n.ts._pages.newPage, + : props.initPageName && props.initUser ? i18n.ts._pages.readPage + : i18n.ts._pages.newPage, icon: 'ti ti-pencil', })); </script> diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index d27a4f121d..637821e74f 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -4,14 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :displayBackButton="true" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :displayBackButton="true" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.fadeEnterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.fadeLeaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.fadeEnterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.fadeLeaveTo : ''" mode="out-in" > <div v-if="page" :key="page.id" class="_gaps"> @@ -94,17 +93,18 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLoading v-else/> </Transition> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, watch, ref, defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; +import { url } from '@@/js/config.js'; +import type { MenuItem } from '@/types/menu.js'; import XPage from '@/components/page/page.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { url } from '@@/js/config.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkMediaImage from '@/components/MkMediaImage.vue'; import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; @@ -112,16 +112,16 @@ import MkContainer from '@/components/MkContainer.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkPagePreview from '@/components/MkPagePreview.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { pageViewInterruptors, defaultStore } from '@/store.js'; -import { deepClone } from '@/scripts/clone.js'; -import { $i } from '@/account.js'; -import { isSupportShare } from '@/scripts/navigator.js'; +import { definePage } from '@/page.js'; +import { deepClone } from '@/utility/clone.js'; +import { $i } from '@/i.js'; +import { isSupportShare } from '@/utility/navigator.js'; import { instance } from '@/instance.js'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { useRouter } from '@/router/supplier.js'; -import type { MenuItem } from '@/types/menu.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { useRouter } from '@/router.js'; +import { prefer } from '@/preferences.js'; +import { getPluginHandlers } from '@/plugin.js'; const router = useRouter(); @@ -150,6 +150,7 @@ function fetchPage() { page.value = _page; // plugin + const pageViewInterruptors = getPluginHandlers('page_view_interruptor'); if (pageViewInterruptors.length > 0) { let result = deepClone(_page); for (const interruptor of pageViewInterruptors) { @@ -188,7 +189,6 @@ function copyLink() { if (!page.value) return; copyToClipboard(`${url}/@${page.value.user.username}/pages/${page.value.name}`); - os.success(); } function shareWithNote() { @@ -318,7 +318,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: page.value ? page.value.title || page.value.name : i18n.ts.pages, ...page.value ? { avatar: page.value.user, diff --git a/packages/frontend/src/pages/pages.vue b/packages/frontend/src/pages/pages.vue index 4ef9d3b091..c99d7f1a0f 100644 --- a/packages/frontend/src/pages/pages.vue +++ b/packages/frontend/src/pages/pages.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'featured'" key="featured"> + <div v-if="tab === 'featured'"> <MkPagination v-slot="{items}" :pagination="featuredPagesPagination"> <div class="_gaps"> <MkPagePreview v-for="page in items" :key="page.id" :page="page"/> @@ -16,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPagination> </div> - <div v-else-if="tab === 'my'" key="my" class="_gaps"> + <div v-else-if="tab === 'my'" class="_gaps"> <MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton> <MkPagination v-slot="{items}" :pagination="myPagesPagination"> <div class="_gaps"> @@ -25,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPagination> </div> - <div v-else-if="tab === 'liked'" key="liked"> + <div v-else-if="tab === 'liked'"> <MkPagination v-slot="{items}" :pagination="likedPagesPagination"> <div class="_gaps"> <MkPagePreview v-for="like in items" :key="like.page.id" :page="like.page"/> @@ -34,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkHorizontalSwipe> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -44,8 +43,8 @@ import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { useRouter } from '@/router/supplier.js'; +import { definePage } from '@/page.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -88,7 +87,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-heart', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.pages, icon: 'ti ti-note', })); diff --git a/packages/frontend/src/pages/preview.vue b/packages/frontend/src/pages/preview.vue index 8e07b190aa..78167500f4 100644 --- a/packages/frontend/src/pages/preview.vue +++ b/packages/frontend/src/pages/preview.vue @@ -13,13 +13,13 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed } from 'vue'; import MkSample from '@/components/MkPreview.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => ({ +definePage(computed(() => ({ title: i18n.ts.preview, icon: 'ti ti-eye', }))); diff --git a/packages/frontend/src/pages/registry.keys.vue b/packages/frontend/src/pages/registry.keys.vue index a13a8f1f4b..9140555f86 100644 --- a/packages/frontend/src/pages/registry.keys.vue +++ b/packages/frontend/src/pages/registry.keys.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="600" :marginMin="16"> <div class="_gaps_m"> <FormSplit> @@ -29,16 +28,16 @@ SPDX-License-Identifier: AGPL-3.0-only </FormSection> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { watch, computed, ref } from 'vue'; import JSON5 from 'json5'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; import MkButton from '@/components/MkButton.vue'; @@ -96,7 +95,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.registry, icon: 'ti ti-adjustments', })); diff --git a/packages/frontend/src/pages/registry.value.vue b/packages/frontend/src/pages/registry.value.vue index c40d13f664..7c0a7f20bb 100644 --- a/packages/frontend/src/pages/registry.value.vue +++ b/packages/frontend/src/pages/registry.value.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="600" :marginMin="16"> <div class="_gaps_m"> <FormInfo warn>{{ i18n.ts.editTheseSettingsMayBreakAccount }}</FormInfo> @@ -41,16 +40,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { watch, computed, ref } from 'vue'; import JSON5 from 'json5'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; @@ -123,7 +122,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.registry, icon: 'ti ti-adjustments', })); diff --git a/packages/frontend/src/pages/registry.vue b/packages/frontend/src/pages/registry.vue index c641874b17..c60833920b 100644 --- a/packages/frontend/src/pages/registry.vue +++ b/packages/frontend/src/pages/registry.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="600" :marginMin="16"> <MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton> @@ -18,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only </FormSection> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -26,9 +25,9 @@ import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import JSON5 from 'json5'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; import MkButton from '@/components/MkButton.vue'; @@ -73,7 +72,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.registry, icon: 'ti ti-adjustments', })); diff --git a/packages/frontend/src/pages/reset-password.vue b/packages/frontend/src/pages/reset-password.vue index 6d24029535..0a7726a7f8 100644 --- a/packages/frontend/src/pages/reset-password.vue +++ b/packages/frontend/src/pages/reset-password.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer v-if="token" :contentMax="700" :marginMin="16" :marginMax="32"> <div class="_gaps_m"> <MkInput v-model="password" type="password"> @@ -16,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton primary @click="save">{{ i18n.ts.save }}</MkButton> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -25,8 +24,8 @@ import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { mainRouter } from '@/router/main.js'; +import { definePage } from '@/page.js'; +import { mainRouter } from '@/router.js'; const props = defineProps<{ token?: string; @@ -55,7 +54,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.resetPassword, icon: 'ti ti-lock', })); diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index 429f502133..403a760521 100644 --- a/packages/frontend/src/pages/reversi/game.board.vue +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -148,18 +148,18 @@ import * as Reversi from 'misskey-reversi'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import { deepClone } from '@/scripts/clone.js'; +import { deepClone } from '@/utility/clone.js'; import { useInterval } from '@@/js/use-interval.js'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; import { url } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { userPage } from '@/filters/user.js'; -import * as sound from '@/scripts/sound.js'; +import * as sound from '@/utility/sound.js'; import * as os from '@/os.js'; -import { confetti } from '@/scripts/confetti.js'; +import { confetti } from '@/utility/confetti.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const props = defineProps<{ game: Misskey.entities.ReversiGameDetailed; diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue index 437a1a2294..d2720a79fc 100644 --- a/packages/frontend/src/pages/reversi/game.setting.vue +++ b/packages/frontend/src/pages/reversi/game.setting.vue @@ -114,17 +114,17 @@ import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue'; import * as Misskey from 'misskey-js'; import * as Reversi from 'misskey-reversi'; import { i18n } from '@/i18n.js'; -import { signinRequired } from '@/account.js'; -import { deepClone } from '@/scripts/clone.js'; +import { ensureSignin } from '@/i.js'; +import { deepClone } from '@/utility/clone.js'; import MkButton from '@/components/MkButton.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; import type { MenuItem } from '@/types/menu.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const router = useRouter(); diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue index 10ea3717ab..a447572cc0 100644 --- a/packages/frontend/src/pages/reversi/game.vue +++ b/packages/frontend/src/pages/reversi/game.vue @@ -14,17 +14,17 @@ import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue'; import * as Misskey from 'misskey-js'; import GameSetting from './game.setting.vue'; import GameBoard from './game.board.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { useStream } from '@/stream.js'; -import { signinRequired } from '@/account.js'; -import { useRouter } from '@/router/supplier.js'; +import { ensureSignin } from '@/i.js'; +import { useRouter } from '@/router.js'; import * as os from '@/os.js'; import { url } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; import { useInterval } from '@@/js/use-interval.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const router = useRouter(); @@ -114,7 +114,7 @@ onUnmounted(() => { } }); -definePageMetadata(() => ({ +definePage(() => ({ title: 'Reversi', icon: 'ti ti-device-gamepad', })); diff --git a/packages/frontend/src/pages/reversi/index.vue b/packages/frontend/src/pages/reversi/index.vue index d608a2411c..e3f01d9938 100644 --- a/packages/frontend/src/pages/reversi/index.vue +++ b/packages/frontend/src/pages/reversi/index.vue @@ -107,19 +107,19 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onDeactivated, onMounted, onUnmounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { useStream } from '@/stream.js'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import MkPagination from '@/components/MkPagination.vue'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; import * as os from '@/os.js'; import { useInterval } from '@@/js/use-interval.js'; -import { pleaseLogin } from '@/scripts/please-login.js'; -import * as sound from '@/scripts/sound.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import * as sound from '@/utility/sound.js'; const myGamesPagination = { endpoint: 'reversi/games' as const, @@ -261,7 +261,7 @@ onUnmounted(() => { cancelMatching(); }); -definePageMetadata(() => ({ +definePage(() => ({ title: 'Reversi', icon: 'ti ti-device-gamepad', })); diff --git a/packages/frontend/src/pages/role.vue b/packages/frontend/src/pages/role.vue index d985349bc5..5a1ed70e2f 100644 --- a/packages/frontend/src/pages/role.vue +++ b/packages/frontend/src/pages/role.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :displayBackButton="true" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :displayBackButton="true" :tabs="headerTabs"> <MkSpacer v-if="error != null" :contentMax="1200"> <div :class="$style.root"> - <img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/> + <img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/> <p :class="$style.text"> <i class="ti ti-alert-triangle"></i> {{ error }} @@ -20,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="role">{{ role.description }}</div> <MkUserList v-if="visible" :pagination="users" :extractor="(item) => item.user"/> <div v-else-if="!visible" class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> </div> @@ -28,22 +27,22 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer v-else-if="tab === 'timeline'" :contentMax="700"> <MkTimeline v-if="visible" ref="timeline" src="role" :role="props.roleId"/> <div v-else-if="!visible" class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { instanceName } from '@@/js/config.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkUserList from '@/components/MkUserList.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import MkTimeline from '@/components/MkTimeline.vue'; -import { instanceName } from '@@/js/config.js'; import { serverErrorImageUrl, infoImageUrl } from '@/instance.js'; const props = withDefaults(defineProps<{ @@ -93,7 +92,7 @@ const headerTabs = computed(() => [{ title: i18n.ts.timeline, }]); -definePageMetadata(() => ({ +definePage(() => ({ title: role.value ? role.value.name : (error.value ?? i18n.ts.role), icon: 'ti ti-badge', })); diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue index 983cba1746..5bf9d52221 100644 --- a/packages/frontend/src/pages/scratchpad.vue +++ b/packages/frontend/src/pages/scratchpad.vue @@ -4,9 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> - +<PageWithHeader> <MkSpacer :contentMax="800"> <div :class="$style.root"> <div class="_gaps_s"> @@ -53,25 +51,28 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> -import { onDeactivated, onUnmounted, Ref, ref, watch, computed } from 'vue'; +import { onDeactivated, onUnmounted, ref, watch, computed } from 'vue'; import { Interpreter, Parser, utils } from '@syuilo/aiscript'; +import type { Ref } from 'vue'; +import type { AsUiComponent } from '@/aiscript/ui.js'; +import type { AsUiRoot } from '@/aiscript/ui.js'; import MkContainer from '@/components/MkContainer.vue'; import MkButton from '@/components/MkButton.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; -import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; +import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js'; import * as os from '@/os.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { AsUiComponent, AsUiRoot, registerAsUiLib } from '@/scripts/aiscript/ui.js'; +import { definePage } from '@/page.js'; +import { registerAsUiLib } from '@/aiscript/ui.js'; import MkAsUi from '@/components/MkAsUi.vue'; import { miLocalStorage } from '@/local-storage.js'; -import { claimAchievement } from '@/scripts/achievements.js'; +import { claimAchievement } from '@/utility/achievements.js'; const parser = new Parser(); let aiscript: Interpreter; @@ -99,7 +100,7 @@ function stringifyUiProps(uiProps) { return JSON.stringify( { ...uiProps, type: undefined, id: undefined }, (k, v) => typeof v === 'function' ? '<function>' : v, - 2 + 2, ); } @@ -198,7 +199,7 @@ const showns = computed(() => { return result; }); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.scratchpad, icon: 'ti ti-terminal-2', })); diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue index d5f96efb8e..cf991efa07 100644 --- a/packages/frontend/src/pages/search.note.vue +++ b/packages/frontend/src/pages/search.note.vue @@ -6,24 +6,36 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps"> <div class="_gaps"> - <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter.prevent="search"> + <MkInput + v-model="searchQuery" + large + autofocus + type="search" + @enter.prevent="search" + > <template #prefix><i class="ti ti-search"></i></template> </MkInput> - <MkFoldableSection :expanded="true"> + <MkFoldableSection expanded> <template #header>{{ i18n.ts.options }}</template> <div class="_gaps_m"> - <template v-if="instance.federation !== 'none'"> - <MkRadios v-model="hostSelect"> - <template #label>{{ i18n.ts.host }}</template> - <option value="all" default>{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.local }}</option> - <option v-if="noteSearchableScope === 'global'" value="specified">{{ i18n.ts.specifyHost }}</option> - </MkRadios> - <MkInput v-if="noteSearchableScope === 'global'" v-model="hostInput" :disabled="hostSelect !== 'specified'" :large="true" type="search"> + <MkRadios v-model="searchScope"> + <option v-if="instance.federation !== 'none' && noteSearchableScope === 'global'" value="all">{{ i18n.ts._search.searchScopeAll }}</option> + <option value="local">{{ instance.federation === 'none' ? i18n.ts._search.searchScopeAll : i18n.ts._search.searchScopeLocal }}</option> + <option v-if="instance.federation !== 'none' && noteSearchableScope === 'global'" value="server">{{ i18n.ts._search.searchScopeServer }}</option> + <option value="user">{{ i18n.ts._search.searchScopeUser }}</option> + </MkRadios> + + <div v-if="instance.federation !== 'none' && searchScope === 'server'" :class="$style.subOptionRoot"> + <MkInput + v-model="hostInput" + :placeholder="i18n.ts._search.serverHostPlaceholder" + @enter.prevent="search" + > + <template #label>{{ i18n.ts._search.pleaseEnterServerHost }}</template> <template #prefix><i class="ti ti-server"></i></template> </MkInput> - </template> + </div> <MkSwitch v-model="order">{{ i18n.ts._noteSearch.newestToOldest }}</MkSwitch> @@ -37,52 +49,99 @@ SPDX-License-Identifier: AGPL-3.0-only <option value="flash">{{ i18n.ts._noteSearch._fileType.flash }}</option> </MkSelect> - <MkFolder :defaultOpen="true"> - <template #label>{{ i18n.ts.specifyUser }}</template> - <template v-if="user" #suffix>@{{ user.username }}{{ user.host ? `@${user.host}` : "" }}</template> - + <div v-if="searchScope === 'user'" :class="$style.subOptionRoot"> + <div :class="$style.userSelectLabel">{{ i18n.ts._search.pleaseSelectUser }}</div> <div class="_gaps"> - <div :class="$style.userItem"> - <MkUserCardMini v-if="user" :class="$style.userCard" :user="user" :withChart="false"/> - <MkButton v-if="user == null && $i != null" transparent :class="$style.addMeButton" @click="selectSelf"><div :class="$style.addUserButtonInner"><span><i class="ti ti-plus"></i><i class="ti ti-user"></i></span><span>{{ i18n.ts.selectSelf }}</span></div></MkButton> - <MkButton v-if="user == null" transparent :class="$style.addUserButton" @click="selectUser"><div :class="$style.addUserButtonInner"><i class="ti ti-plus"></i><span>{{ i18n.ts.selectUser }}</span></div></MkButton> - <button class="_button" :class="$style.remove" :disabled="user == null" @click="removeUser"><i class="ti ti-x"></i></button> + <div v-if="user == null" :class="$style.userSelectButtons"> + <div v-if="$i != null"> + <MkButton + transparent + :class="$style.userSelectButton" + @click="selectSelf" + > + <div :class="$style.userSelectButtonInner"> + <span><i class="ti ti-plus"></i><i class="ti ti-user"></i></span> + <span>{{ i18n.ts.selectSelf }}</span> + </div> + </MkButton> + </div> + <div :style="$i == null ? 'grid-column: span 2;' : undefined"> + <MkButton + transparent + :class="$style.userSelectButton" + @click="selectUser" + > + <div :class="$style.userSelectButtonInner"> + <span><i class="ti ti-plus"></i></span> + <span>{{ i18n.ts.selectUser }}</span> + </div> + </MkButton> + </div> + </div> + <div v-else :class="$style.userSelectedButtons"> + <div style="overflow: hidden;"> + <MkUserCardMini + :user="user" + :withChart="false" + :class="$style.userSelectedCard" + /> + </div> + <div> + <button + class="_button" + :class="$style.userSelectedRemoveButton" + @click="removeUser" + > + <i class="ti ti-x"></i> + </button> + </div> </div> </div> - </MkFolder> + </div> </div> </MkFoldableSection> <div> - <MkButton large primary gradate rounded style="margin: 0 auto;" @click="search">{{ i18n.ts.search }}</MkButton> + <MkButton + large + primary + gradate + rounded + :disabled="searchParams == null" + style="margin: 0 auto;" + @click="search" + > + {{ i18n.ts.search }} + </MkButton> </div> </div> <MkFoldableSection v-if="notePagination"> <template #header>{{ i18n.ts.searchResult }}</template> - <MkNotes :key="key" :pagination="notePagination"/> + <MkNotes :key="`searchNotes:${key}`" :pagination="notePagination"/> </MkFoldableSection> </div> </template> <script lang="ts" setup> -import { computed, ref, toRef, watch } from 'vue'; -import type { UserDetailed } from 'misskey-js/entities.js'; +import { computed, ref, shallowRef, toRef } from 'vue'; +import type * as Misskey from 'misskey-js'; import type { Paging } from '@/components/MkPagination.vue'; -import MkNotes from '@/components/MkNotes.vue'; -import MkInput from '@/components/MkInput.vue'; -import MkButton from '@/components/MkButton.vue'; -import MkSwitch from '@/components/MkSwitch.vue'; -import MkSelect from '@/components/MkSelect.vue'; +import { $i } from '@/i.js'; +import { host as localHost } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; +import { instance } from '@/instance.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { apLookup } from '@/utility/lookup.js'; +import { useRouter } from '@/router.js'; +import MkButton from '@/components/MkButton.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -import MkFolder from '@/components/MkFolder.vue'; -import { useRouter } from '@/router/supplier.js'; -import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkNotes from '@/components/MkNotes.vue'; import MkRadios from '@/components/MkRadios.vue'; -import { $i } from '@/account.js'; -import { instance } from '@/instance.js'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkSelect from '@/components/MkSelect.vue'; const props = withDefaults(defineProps<{ query?: string; @@ -97,86 +156,132 @@ const props = withDefaults(defineProps<{ }); const router = useRouter(); + const key = ref(0); +const notePagination = ref<Paging<'notes/search'>>(); + const searchQuery = ref(toRef(props, 'query').value); -const notePagination = ref<Paging>(); -const user = ref<UserDetailed | null>(null); const hostInput = ref(toRef(props, 'host').value); const order = ref(false); const filetype = ref<'image' | 'video' | 'audio' | 'module' | 'flash' | null>(null); +const user = shallowRef<Misskey.entities.UserDetailed | null>(null); + +// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition const noteSearchableScope = instance.noteSearchableScope ?? 'local'; -const hostSelect = ref<'all' | 'local' | 'specified'>('all'); +//#region set user +let fetchedUser: Misskey.entities.UserDetailed | null = null; + +if (props.userId) { + fetchedUser = await misskeyApi('users/show', { + userId: props.userId, + }).catch(() => null); +} + +if (props.username && fetchedUser == null) { + fetchedUser = await misskeyApi('users/show', { + username: props.username, + ...(props.host ? { host: props.host } : {}), + }).catch(() => null); +} + +if (fetchedUser != null) { + if (!(noteSearchableScope === 'local' && fetchedUser.host != null)) { + user.value = fetchedUser; + } +} +//#endregion + +const searchScope = ref<'all' | 'local' | 'server' | 'user'>((() => { + if (user.value != null) return 'user'; + if (noteSearchableScope === 'local') return 'local'; + if (hostInput.value) return 'server'; + return 'all'; +})()); -const setHostSelectWithInput = (after:string|undefined|null, before:string|undefined|null) => { - if (before === after) return; - if (after === '') hostSelect.value = 'all'; - else hostSelect.value = 'specified'; +type SearchParams = { + readonly query: string; + readonly host?: string; + readonly userId?: string; }; -setHostSelectWithInput(hostInput.value, undefined); +const fixHostIfLocal = (target: string | null | undefined) => { + if (!target || target === localHost) return '.'; + return target; +}; -watch(hostInput, setHostSelectWithInput); +const searchParams = computed<SearchParams | null>(() => { + const trimmedQuery = searchQuery.value.trim(); + if (!trimmedQuery) return null; -const searchHost = computed(() => { - if (hostSelect.value === 'local' || instance.federation === 'none') return '.'; - if (hostSelect.value === 'specified') return hostInput.value; - return null; -}); + if (searchScope.value === 'user') { + if (user.value == null) return null; + return { + query: trimmedQuery, + host: fixHostIfLocal(user.value.host), + userId: user.value.id, + }; + } -if (props.userId != null) { - misskeyApi('users/show', { userId: props.userId }).then(_user => { - user.value = _user; - }); -} else if (props.username != null) { - misskeyApi('users/show', { - username: props.username, - ...(props.host != null && props.host !== '') ? { host: props.host } : {}, - }).then(_user => { - user.value = _user; - }); -} + if (instance.federation !== 'none' && searchScope.value === 'server') { + let trimmedHost = hostInput.value?.trim(); + if (!trimmedHost) return null; + if (trimmedHost.startsWith('https://') || trimmedHost.startsWith('http://')) { + try { + trimmedHost = new URL(trimmedHost).host; + } catch (err) { /* empty */ } + } + return { + query: trimmedQuery, + host: fixHostIfLocal(trimmedHost), + }; + } + + if (instance.federation === 'none' || searchScope.value === 'local') { + return { + query: trimmedQuery, + host: '.', + }; + } + + return { + query: trimmedQuery, + }; +}); function selectUser() { - os.selectUser({ includeSelf: true, localOnly: instance.noteSearchableScope === 'local' }).then(_user => { + os.selectUser({ + includeSelf: true, + localOnly: instance.noteSearchableScope === 'local', + }).then(_user => { user.value = _user; - hostInput.value = _user.host ?? ''; }); } function selectSelf() { - user.value = $i as UserDetailed | null; - hostInput.value = null; + user.value = $i; } function removeUser() { user.value = null; - hostInput.value = ''; } async function search() { - const query = searchQuery.value.toString().trim(); - - if (query == null || query === '') return; + if (searchParams.value == null) return; //#region AP lookup - if (query.startsWith('http://') || query.startsWith('https://') && !query.includes(' ')) { + if ((searchParams.value.query.startsWith('http://') || searchParams.value.query.startsWith('https://')) && !searchParams.value.query.includes(' ')) { const confirm = await os.confirm({ type: 'info', text: i18n.ts.lookupConfirm, }); if (!confirm.canceled) { - const promise = misskeyApi('ap/show', { - uri: query, - }); - - os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); - - const res = await promise; + const res = await apLookup(searchParams.value.query); if (res.type === 'User') { router.push(`/@${res.object.username}@${res.object.host}`); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition } else if (res.type === 'Note') { router.push(`/notes/${res.object.id}`); } @@ -186,25 +291,25 @@ async function search() { } //#endregion - if (query.length > 1 && !query.includes(' ')) { - if (query.startsWith('@')) { + if (searchParams.value.query.length > 1 && !searchParams.value.query.includes(' ')) { + if (searchParams.value.query.startsWith('@')) { const confirm = await os.confirm({ type: 'info', text: i18n.ts.lookupConfirm, }); if (!confirm.canceled) { - router.push(`/${query}`); + router.push(`/${searchParams.value.query}`); return; } } - if (query.startsWith('#')) { + if (searchParams.value.query.startsWith('#')) { const confirm = await os.confirm({ type: 'info', text: i18n.ts.openTagPageConfirm, }); if (!confirm.canceled) { - router.push(`/tags/${encodeURIComponent(query.substring(1))}`); + router.push(`/tags/${encodeURIComponent(searchParams.value.query.substring(1))}`); return; } } @@ -214,9 +319,7 @@ async function search() { endpoint: 'notes/search', limit: 10, params: { - query: searchQuery.value, - userId: user.value ? user.value.id : null, - ...(searchHost.value ? { host: searchHost.value } : {}), + ...searchParams.value, order: order.value ? 'desc' : 'asc', filetype: filetype.value, }, @@ -226,41 +329,48 @@ async function search() { } </script> <style lang="scss" module> -.userItem { - display: flex; - justify-content: center; +.subOptionRoot { + background: var(--MI_THEME-panel); + border-radius: var(--MI-radius); + padding: var(--MI-margin); } -.addMeButton { - border: 2px dashed var(--MI_THEME-fgTransparent); - padding: 12px; - margin-right: 16px; + +.userSelectLabel { + font-size: 0.85em; + padding: 0 0 8px; + user-select: none; +} + +.userSelectButtons { + display: grid; + grid-template-columns: auto 1fr; + gap: 16px; } -.addUserButton { - border: 2px dashed var(--MI_THEME-fgTransparent); + +.userSelectButton { + width: 100%; + height: 100%; padding: 12px; - flex-grow: 1; + border: 2px dashed var(--MI_THEME-fgTransparent); } -.addUserButtonInner { + +.userSelectButtonInner { display: flex; flex-direction: column; align-items: center; justify-content: space-between; min-height: 38px; } -.userCard { - flex-grow: 1; + +.userSelectedButtons { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; } -.remove { + +.userSelectedRemoveButton { width: 32px; height: 32px; - align-self: center; - - & > i:before { - color: #ff2a2a; - } - - &:disabled { - opacity: 0; - } + color: #ff2a2a; } </style> diff --git a/packages/frontend/src/pages/search.stories.impl.ts b/packages/frontend/src/pages/search.stories.impl.ts index 0110a7ab8e..27271615c2 100644 --- a/packages/frontend/src/pages/search.stories.impl.ts +++ b/packages/frontend/src/pages/search.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import search_ from './search.vue'; import { userDetailed } from '@/../.storybook/fakes.js'; diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue index 8d0899a30c..a10b93f18e 100644 --- a/packages/frontend/src/pages/search.user.vue +++ b/packages/frontend/src/pages/search.user.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFoldableSection v-if="userPagination"> <template #header>{{ i18n.ts.searchResult }}</template> - <MkUserList :key="key" :pagination="userPagination"/> + <MkUserList :key="`searchUsers:${key}`" :pagination="userPagination"/> </MkFoldableSection> </div> </template> @@ -36,8 +36,8 @@ import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import * as os from '@/os.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useRouter } from '@/router/supplier.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { useRouter } from '@/router.js'; const props = withDefaults(defineProps<{ query?: string, @@ -49,14 +49,16 @@ const props = withDefaults(defineProps<{ const router = useRouter(); -const key = ref(''); +const key = ref(0); +const userPagination = ref<Paging<'users/search'>>(); + const searchQuery = ref(toRef(props, 'query').value); const searchOrigin = ref(toRef(props, 'origin').value); -const userPagination = ref<Paging>(); async function search() { const query = searchQuery.value.toString().trim(); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (query == null || query === '') return; //#region AP lookup @@ -76,6 +78,7 @@ async function search() { if (res.type === 'User') { router.push(`/@${res.object.username}@${res.object.host}`); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition } else if (res.type === 'Note') { router.push(`/notes/${res.object.id}`); } @@ -116,6 +119,6 @@ async function search() { }, }; - key.value = query; + key.value++; } </script> diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue index 38d7548fa8..e0cb2dcbab 100644 --- a/packages/frontend/src/pages/search.vue +++ b/packages/frontend/src/pages/search.vue @@ -4,11 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <MkSpacer v-if="tab === 'note'" key="note" :contentMax="800"> + <MkSpacer v-if="tab === 'note'" :contentMax="800"> <div v-if="notesSearchAvailable || ignoreNotesSearchAvailable"> <XNote v-bind="props"/> </div> @@ -17,18 +15,18 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkSpacer> - <MkSpacer v-else-if="tab === 'user'" key="user" :contentMax="800"> + <MkSpacer v-else-if="tab === 'user'" :contentMax="800"> <XUser v-bind="props"/> </MkSpacer> </MkHorizontalSwipe> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, defineAsyncComponent, ref, toRef } from 'vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { notesSearchAvailable } from '@/scripts/check-permissions.js'; +import { definePage } from '@/page.js'; +import { notesSearchAvailable } from '@/utility/check-permissions.js'; import MkInfo from '@/components/MkInfo.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; @@ -68,7 +66,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-users', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.search, icon: 'ti ti-search', })); diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue index 18c82ffdf6..03f973a33e 100644 --- a/packages/frontend/src/pages/settings/2fa.qrdialog.vue +++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue @@ -106,7 +106,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef, ref } from 'vue'; +import { hostname, port } from '@@/js/config'; +import { useTemplateRef, ref } from 'vue'; import MkButton from '@/components/MkButton.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; @@ -116,10 +117,10 @@ import * as os from '@/os.js'; import MkFolder from '@/components/MkFolder.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkLink from '@/components/MkLink.vue'; -import { confetti } from '@/scripts/confetti.js'; -import { signinRequired } from '@/account.js'; +import { confetti } from '@/utility/confetti.js'; +import { ensureSignin } from '@/i.js'; -const $i = signinRequired(); +const $i = ensureSignin(); defineProps<{ twoFactorData: { @@ -132,7 +133,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const page = ref(0); const token = ref<string | number | null>(null); const backupCodes = ref<string[]>(); @@ -159,9 +160,9 @@ async function tokenDone() { function downloadBackupCodes() { if (backupCodes.value !== undefined) { const txtBlob = new Blob([backupCodes.value.join('\n')], { type: 'text/plain' }); - const dummya = document.createElement('a'); + const dummya = window.document.createElement('a'); dummya.href = URL.createObjectURL(txtBlob); - dummya.download = `${$i.username}-2fa-backup-codes.txt`; + dummya.download = `${$i.username}@${hostname}` + (port !== '' ? `_${port}` : '') + '-2fa-backup-codes.txt'; dummya.click(); } } diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue index 776f59dda3..f47ffc984e 100644 --- a/packages/frontend/src/pages/settings/2fa.vue +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -4,74 +4,82 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<FormSection :first="first"> - <template #label>{{ i18n.ts['2fa'] }}</template> +<SearchMarker markerId="2fa" :keywords="['2fa']"> + <FormSection :first="first"> + <template #label><SearchLabel>{{ i18n.ts['2fa'] }}</SearchLabel></template> - <div v-if="$i" class="_gaps_s"> - <MkInfo v-if="$i.twoFactorEnabled && $i.twoFactorBackupCodesStock === 'partial'" warn> - {{ i18n.ts._2fa.backupCodeUsedWarning }} - </MkInfo> - <MkInfo v-if="$i.twoFactorEnabled && $i.twoFactorBackupCodesStock === 'none'" warn> - {{ i18n.ts._2fa.backupCodesExhaustedWarning }} - </MkInfo> + <div v-if="$i" class="_gaps_s"> + <MkInfo v-if="$i.twoFactorEnabled && $i.twoFactorBackupCodesStock === 'partial'" warn> + {{ i18n.ts._2fa.backupCodeUsedWarning }} + </MkInfo> + <MkInfo v-if="$i.twoFactorEnabled && $i.twoFactorBackupCodesStock === 'none'" warn> + {{ i18n.ts._2fa.backupCodesExhaustedWarning }} + </MkInfo> - <MkFolder :defaultOpen="true"> - <template #icon><i class="ti ti-shield-lock"></i></template> - <template #label>{{ i18n.ts.totp }}</template> - <template #caption>{{ i18n.ts.totpDescription }}</template> - <template #suffix><i v-if="$i.twoFactorEnabled" class="ti ti-check" style="color: var(--MI_THEME-success)"></i></template> + <SearchMarker :keywords="['totp', 'app']"> + <MkFolder :defaultOpen="true"> + <template #icon><i class="ti ti-shield-lock"></i></template> + <template #label><SearchLabel>{{ i18n.ts.totp }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.totpDescription }}</SearchKeyword></template> + <template #suffix><i v-if="$i.twoFactorEnabled" class="ti ti-check" style="color: var(--MI_THEME-success)"></i></template> - <div v-if="$i.twoFactorEnabled" class="_gaps_s"> - <div v-text="i18n.ts._2fa.alreadyRegistered"/> - <template v-if="$i.securityKeysList.length > 0"> - <MkButton @click="renewTOTP">{{ i18n.ts._2fa.renewTOTP }}</MkButton> - <MkInfo>{{ i18n.ts._2fa.whyTOTPOnlyRenew }}</MkInfo> - </template> - <MkButton v-else danger @click="unregisterTOTP">{{ i18n.ts.unregister }}</MkButton> - </div> + <div v-if="$i.twoFactorEnabled" class="_gaps_s"> + <div v-text="i18n.ts._2fa.alreadyRegistered"/> + <template v-if="$i.securityKeysList.length > 0"> + <MkButton @click="renewTOTP">{{ i18n.ts._2fa.renewTOTP }}</MkButton> + <MkInfo>{{ i18n.ts._2fa.whyTOTPOnlyRenew }}</MkInfo> + </template> + <MkButton v-else danger @click="unregisterTOTP">{{ i18n.ts.unregister }}</MkButton> + </div> - <div v-else-if="!$i.twoFactorEnabled" class="_gaps_s"> - <MkButton primary gradate @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton> - <MkLink url="https://misskey-hub.net/docs/for-users/stepped-guides/how-to-enable-2fa/" target="_blank"><i class="ti ti-help-circle"></i> {{ i18n.ts.learnMore }}</MkLink> - </div> - </MkFolder> + <div v-else-if="!$i.twoFactorEnabled" class="_gaps_s"> + <MkButton primary gradate @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton> + <MkLink url="https://misskey-hub.net/docs/for-users/stepped-guides/how-to-enable-2fa/" target="_blank"><i class="ti ti-help-circle"></i> {{ i18n.ts.learnMore }}</MkLink> + </div> + </MkFolder> + </SearchMarker> - <MkFolder> - <template #icon><i class="ti ti-key"></i></template> - <template #label>{{ i18n.ts.securityKeyAndPasskey }}</template> - <div class="_gaps_s"> - <MkInfo> - {{ i18n.ts._2fa.securityKeyInfo }} - </MkInfo> + <SearchMarker :keywords="['security', 'key', 'passkey']"> + <MkFolder> + <template #icon><i class="ti ti-key"></i></template> + <template #label><SearchLabel>{{ i18n.ts.securityKeyAndPasskey }}</SearchLabel></template> + <div class="_gaps_s"> + <MkInfo> + {{ i18n.ts._2fa.securityKeyInfo }} + </MkInfo> - <MkInfo v-if="!webAuthnSupported()" warn> - {{ i18n.ts._2fa.securityKeyNotSupported }} - </MkInfo> + <MkInfo v-if="!webAuthnSupported()" warn> + {{ i18n.ts._2fa.securityKeyNotSupported }} + </MkInfo> - <MkInfo v-else-if="webAuthnSupported() && !$i.twoFactorEnabled" warn> - {{ i18n.ts._2fa.registerTOTPBeforeKey }} - </MkInfo> + <MkInfo v-else-if="webAuthnSupported() && !$i.twoFactorEnabled" warn> + {{ i18n.ts._2fa.registerTOTPBeforeKey }} + </MkInfo> - <template v-else> - <MkButton primary @click="addSecurityKey">{{ i18n.ts._2fa.registerSecurityKey }}</MkButton> - <MkFolder v-for="key in $i.securityKeysList" :key="key.id"> - <template #label>{{ key.name }}</template> - <template #suffix><I18n :src="i18n.ts.lastUsedAt"><template #t><MkTime :time="key.lastUsed"/></template></I18n></template> - <div class="_buttons"> - <MkButton @click="renameKey(key)"><i class="ti ti-forms"></i> {{ i18n.ts.rename }}</MkButton> - <MkButton danger @click="unregisterKey(key)"><i class="ti ti-trash"></i> {{ i18n.ts.unregister }}</MkButton> - </div> - </MkFolder> - </template> - </div> - </MkFolder> + <template v-else> + <MkButton primary @click="addSecurityKey">{{ i18n.ts._2fa.registerSecurityKey }}</MkButton> + <MkFolder v-for="key in $i.securityKeysList" :key="key.id"> + <template #label>{{ key.name }}</template> + <template #suffix><I18n :src="i18n.ts.lastUsedAt"><template #t><MkTime :time="key.lastUsed"/></template></I18n></template> + <div class="_buttons"> + <MkButton @click="renameKey(key)"><i class="ti ti-forms"></i> {{ i18n.ts.rename }}</MkButton> + <MkButton danger @click="unregisterKey(key)"><i class="ti ti-trash"></i> {{ i18n.ts.unregister }}</MkButton> + </div> + </MkFolder> + </template> + </div> + </MkFolder> + </SearchMarker> - <MkSwitch :disabled="!$i.twoFactorEnabled || $i.securityKeysList.length === 0" :modelValue="usePasswordLessLogin" @update:modelValue="v => updatePasswordLessLogin(v)"> - <template #label>{{ i18n.ts.passwordLessLogin }}</template> - <template #caption>{{ i18n.ts.passwordLessLoginDescription }}</template> - </MkSwitch> - </div> -</FormSection> + <SearchMarker :keywords="['password', 'less', 'key', 'passkey', 'login', 'signin']"> + <MkSwitch :disabled="!$i.twoFactorEnabled || $i.securityKeysList.length === 0" :modelValue="usePasswordLessLogin" @update:modelValue="v => updatePasswordLessLogin(v)"> + <template #label><SearchLabel>{{ i18n.ts.passwordLessLogin }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.passwordLessLoginDescription }}</SearchKeyword></template> + </MkSwitch> + </SearchMarker> + </div> + </FormSection> +</SearchMarker> </template> <script lang="ts" setup> @@ -84,10 +92,11 @@ import FormSection from '@/components/form/section.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkLink from '@/components/MkLink.vue'; import * as os from '@/os.js'; -import { signinRequired, updateAccountPartial } from '@/account.js'; +import { ensureSignin } from '@/i.js'; import { i18n } from '@/i18n.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; -const $i = signinRequired(); +const $i = ensureSignin(); // メモ: 各エンドポイントはmeUpdatedを発行するため、refreshAccountは不要 @@ -123,7 +132,7 @@ async function unregisterTOTP(): Promise<void> { password: auth.result.password, token: auth.result.token, }).then(res => { - updateAccountPartial({ + updateCurrentAccountPartial({ twoFactorEnabled: false, }); }).catch(error => { diff --git a/packages/frontend/src/pages/settings/accessibility.vue b/packages/frontend/src/pages/settings/accessibility.vue new file mode 100644 index 0000000000..e8268719f5 --- /dev/null +++ b/packages/frontend/src/pages/settings/accessibility.vue @@ -0,0 +1,173 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<SearchMarker path="/settings/accessibility" :label="i18n.ts.accessibility" :keywords="['accessibility']" icon="ti ti-accessible"> + <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/mens_room_3d.png" color="#0011ff"> + <SearchKeyword>{{ i18n.ts._settings.accessibilityBanner }}</SearchKeyword> + </MkFeatureBanner> + + <div class="_gaps_s"> + <SearchMarker :keywords="['animation', 'motion', 'reduce']"> + <MkPreferenceContainer k="animation"> + <MkSwitch v-model="reduceAnimation"> + <template #label><SearchLabel>{{ i18n.ts.reduceUiAnimation }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['disable', 'animation', 'image', 'photo', 'picture', 'media', 'thumbnail', 'gif']"> + <MkPreferenceContainer k="disableShowingAnimatedImages"> + <MkSwitch v-model="disableShowingAnimatedImages"> + <template #label><SearchLabel>{{ i18n.ts.disableShowingAnimatedImages }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['mfm', 'enable', 'show', 'animated']"> + <MkPreferenceContainer k="animatedMfm"> + <MkSwitch v-model="animatedMfm"> + <template #label><SearchLabel>{{ i18n.ts.enableAnimatedMfm }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['swipe', 'horizontal', 'tab']"> + <MkPreferenceContainer k="enableHorizontalSwipe"> + <MkSwitch v-model="enableHorizontalSwipe"> + <template #label><SearchLabel>{{ i18n.ts.enableHorizontalSwipe }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['keep', 'screen', 'display', 'on']"> + <MkPreferenceContainer k="keepScreenOn"> + <MkSwitch v-model="keepScreenOn"> + <template #label><SearchLabel>{{ i18n.ts.keepScreenOn }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['native', 'system', 'video', 'audio', 'player', 'media']"> + <MkPreferenceContainer k="useNativeUiForVideoAudioPlayer"> + <MkSwitch v-model="useNativeUiForVideoAudioPlayer"> + <template #label><SearchLabel>{{ i18n.ts.useNativeUIForVideoAudioPlayer }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['text', 'selectable']"> + <MkPreferenceContainer k="makeEveryTextElementsSelectable"> + <MkSwitch v-model="makeEveryTextElementsSelectable"> + <template #label><SearchLabel>{{ i18n.ts._settings.makeEveryTextElementsSelectable }}</SearchLabel></template> + <template #caption>{{ i18n.ts._settings.makeEveryTextElementsSelectable_description }}</template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + </div> + + <SearchMarker :keywords="['menu', 'style', 'popup', 'drawer']"> + <MkPreferenceContainer k="menuStyle"> + <MkSelect v-model="menuStyle"> + <template #label><SearchLabel>{{ i18n.ts.menuStyle }}</SearchLabel></template> + <option value="auto">{{ i18n.ts.auto }}</option> + <option value="popup">{{ i18n.ts.popup }}</option> + <option value="drawer">{{ i18n.ts.drawer }}</option> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['contextmenu', 'system', 'native']"> + <MkPreferenceContainer k="contextMenu"> + <MkSelect v-model="contextMenu"> + <template #label><SearchLabel>{{ i18n.ts._contextMenu.title }}</SearchLabel></template> + <option value="app">{{ i18n.ts._contextMenu.app }}</option> + <option value="appWithShift">{{ i18n.ts._contextMenu.appWithShift }}</option> + <option value="native">{{ i18n.ts._contextMenu.native }}</option> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['font', 'size']"> + <MkRadios v-model="fontSize"> + <template #label><SearchLabel>{{ i18n.ts.fontSize }}</SearchLabel></template> + <option :value="null"><span style="font-size: 14px;">Aa</span></option> + <option value="1"><span style="font-size: 15px;">Aa</span></option> + <option value="2"><span style="font-size: 16px;">Aa</span></option> + <option value="3"><span style="font-size: 17px;">Aa</span></option> + </MkRadios> + </SearchMarker> + + <SearchMarker :keywords="['font', 'system', 'native']"> + <MkSwitch v-model="useSystemFont"> + <template #label><SearchLabel>{{ i18n.ts.useSystemFont }}</SearchLabel></template> + </MkSwitch> + </SearchMarker> + </div> +</SearchMarker> +</template> + +<script lang="ts" setup> +import { computed, ref, watch } from 'vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import { prefer } from '@/preferences.js'; +import { reloadAsk } from '@/utility/reload-ask.js'; +import { i18n } from '@/i18n.js'; +import { definePage } from '@/page.js'; +import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; +import { miLocalStorage } from '@/local-storage.js'; +import MkRadios from '@/components/MkRadios.vue'; + +const reduceAnimation = prefer.model('animation', v => !v, v => !v); +const animatedMfm = prefer.model('animatedMfm'); +const disableShowingAnimatedImages = prefer.model('disableShowingAnimatedImages'); +const keepScreenOn = prefer.model('keepScreenOn'); +const enableHorizontalSwipe = prefer.model('enableHorizontalSwipe'); +const useNativeUiForVideoAudioPlayer = prefer.model('useNativeUiForVideoAudioPlayer'); +const contextMenu = prefer.model('contextMenu'); +const menuStyle = prefer.model('menuStyle'); +const makeEveryTextElementsSelectable = prefer.model('makeEveryTextElementsSelectable'); + +const fontSize = ref(miLocalStorage.getItem('fontSize')); +const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null); + +watch(fontSize, () => { + if (fontSize.value == null) { + miLocalStorage.removeItem('fontSize'); + } else { + miLocalStorage.setItem('fontSize', fontSize.value); + } +}); + +watch(useSystemFont, () => { + if (useSystemFont.value) { + miLocalStorage.setItem('useSystemFont', 't'); + } else { + miLocalStorage.removeItem('useSystemFont'); + } +}); + +watch([ + keepScreenOn, + contextMenu, + fontSize, + useSystemFont, + makeEveryTextElementsSelectable, +], async () => { + await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); +}); + +const headerActions = computed(() => []); + +const headerTabs = computed(() => []); + +definePage(() => ({ + title: i18n.ts.accessibility, + icon: 'ti ti-accessible', +})); +</script> diff --git a/packages/frontend/src/pages/settings/account-data.vue b/packages/frontend/src/pages/settings/account-data.vue new file mode 100644 index 0000000000..14bea577a3 --- /dev/null +++ b/packages/frontend/src/pages/settings/account-data.vue @@ -0,0 +1,277 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<SearchMarker path="/settings/account-data" :label="i18n.ts._settings.accountData" :keywords="['import', 'export', 'data', 'archive']" icon="ti ti-package"> + <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/package_3d.png" color="#ff9100"> + <SearchKeyword>{{ i18n.ts._settings.accountDataBanner }}</SearchKeyword> + </MkFeatureBanner> + + <div class="_gaps_s"> + <SearchMarker :keywords="['notes']"> + <MkFolder> + <template #icon><i class="ti ti-pencil"></i></template> + <template #label><SearchLabel>{{ i18n.ts._exportOrImport.allNotes }}</SearchLabel></template> + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportNotes()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </MkFolder> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['favorite', 'notes']"> + <MkFolder> + <template #icon><i class="ti ti-star"></i></template> + <template #label><SearchLabel>{{ i18n.ts._exportOrImport.favoritedNotes }}</SearchLabel></template> + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportFavorites()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </MkFolder> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['clip', 'notes']"> + <MkFolder> + <template #icon><i class="ti ti-star"></i></template> + <template #label><SearchLabel>{{ i18n.ts._exportOrImport.clips }}</SearchLabel></template> + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportClips()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </MkFolder> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['following', 'users']"> + <MkFolder> + <template #icon><i class="ti ti-users"></i></template> + <template #label><SearchLabel>{{ i18n.ts._exportOrImport.followingList }}</SearchLabel></template> + <div class="_gaps_s"> + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <div class="_gaps_s"> + <MkSwitch v-model="excludeMutingUsers"> + {{ i18n.ts._exportOrImport.excludeMutingUsers }} + </MkSwitch> + <MkSwitch v-model="excludeInactiveUsers"> + {{ i18n.ts._exportOrImport.excludeInactiveUsers }} + </MkSwitch> + <MkButton primary :class="$style.button" inline @click="exportFollowing()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </div> + </MkFolder> + <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportFollowing" :defaultOpen="true"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkSwitch v-model="withReplies"> + {{ i18n.ts._exportOrImport.withReplies }} + </MkSwitch> + <MkButton primary :class="$style.button" inline @click="importFollowing($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </MkFolder> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['user', 'lists']"> + <MkFolder> + <template #icon><i class="ti ti-users"></i></template> + <template #label><SearchLabel>{{ i18n.ts._exportOrImport.userLists }}</SearchLabel></template> + <div class="_gaps_s"> + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportUserLists()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </MkFolder> + <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportUserLists" :defaultOpen="true"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importUserLists($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </MkFolder> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['mute', 'users']"> + <MkFolder> + <template #icon><i class="ti ti-user-off"></i></template> + <template #label><SearchLabel>{{ i18n.ts._exportOrImport.muteList }}</SearchLabel></template> + <div class="_gaps_s"> + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportMuting()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </MkFolder> + <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportMuting" :defaultOpen="true"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importMuting($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </MkFolder> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['block', 'users']"> + <MkFolder> + <template #icon><i class="ti ti-user-off"></i></template> + <template #label><SearchLabel>{{ i18n.ts._exportOrImport.blockingList }}</SearchLabel></template> + <div class="_gaps_s"> + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportBlocking()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </MkFolder> + <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportBlocking" :defaultOpen="true"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importBlocking($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </MkFolder> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['antennas']"> + <MkFolder> + <template #icon><i class="ti ti-antenna"></i></template> + <template #label><SearchLabel>{{ i18n.ts.antennas }}</SearchLabel></template> + <div class="_gaps_s"> + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportAntennas()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </MkFolder> + <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportAntennas" :defaultOpen="true"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importAntennas($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </MkFolder> + </div> + </MkFolder> + </SearchMarker> + </div> + </div> +</SearchMarker> +</template> + +<script lang="ts" setup> +import { ref, computed } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { selectFile } from '@/utility/select-file.js'; +import { i18n } from '@/i18n.js'; +import { definePage } from '@/page.js'; +import { $i } from '@/i.js'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; +import { prefer } from '@/preferences.js'; + +const excludeMutingUsers = ref(false); +const excludeInactiveUsers = ref(false); +const withReplies = ref(prefer.s.defaultFollowWithReplies); + +const onExportSuccess = () => { + os.alert({ + type: 'info', + text: i18n.ts.exportRequested, + }); +}; + +const onImportSuccess = () => { + os.alert({ + type: 'info', + text: i18n.ts.importRequested, + }); +}; + +const onError = (ev) => { + os.alert({ + type: 'error', + text: ev.message, + }); +}; + +const exportNotes = () => { + misskeyApi('i/export-notes', {}).then(onExportSuccess).catch(onError); +}; + +const exportFavorites = () => { + misskeyApi('i/export-favorites', {}).then(onExportSuccess).catch(onError); +}; + +const exportClips = () => { + misskeyApi('i/export-clips', {}).then(onExportSuccess).catch(onError); +}; + +const exportFollowing = () => { + misskeyApi('i/export-following', { + excludeMuting: excludeMutingUsers.value, + excludeInactive: excludeInactiveUsers.value, + }) + .then(onExportSuccess).catch(onError); +}; + +const exportBlocking = () => { + misskeyApi('i/export-blocking', {}).then(onExportSuccess).catch(onError); +}; + +const exportUserLists = () => { + misskeyApi('i/export-user-lists', {}).then(onExportSuccess).catch(onError); +}; + +const exportMuting = () => { + misskeyApi('i/export-mute', {}).then(onExportSuccess).catch(onError); +}; + +const exportAntennas = () => { + misskeyApi('i/export-antennas', {}).then(onExportSuccess).catch(onError); +}; + +const importFollowing = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + misskeyApi('i/import-following', { + fileId: file.id, + withReplies: withReplies.value, + }).then(onImportSuccess).catch(onError); +}; + +const importUserLists = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + misskeyApi('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const importMuting = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + misskeyApi('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const importBlocking = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + misskeyApi('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const importAntennas = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + misskeyApi('i/import-antennas', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const headerActions = computed(() => []); + +const headerTabs = computed(() => []); + +definePage(() => ({ + title: i18n.ts._settings.accountData, + icon: 'ti ti-package', +})); +</script> + +<style module> +.button { + margin-right: 16px; +} +</style> diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue index c2588736b3..2fd0a021da 100644 --- a/packages/frontend/src/pages/settings/accounts.vue +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -4,80 +4,50 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class=""> - <FormSuspense :p="init"> - <div class="_gaps"> - <div class="_buttons"> - <MkButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</MkButton> - <MkButton @click="init"><i class="ti ti-refresh"></i> {{ i18n.ts.reloadAccountsList }}</MkButton> - </div> - - <template v-for="[id, user] in accounts"> - <MkUserCardMini v-if="user != null" :key="user.id" :user="user" :class="$style.user" @click.prevent="menu(user, $event)"/> - <button v-else v-panel class="_button" :class="$style.unknownUser" @click="menu(id, $event)"> - <div :class="$style.unknownUserAvatarMock"><i class="ti ti-user-question"></i></div> - <div> - <div :class="$style.unknownUserTitle">{{ i18n.ts.unknown }}</div> - <div :class="$style.unknownUserSub">ID: <span class="_monospace">{{ id }}</span></div> - </div> - </button> - </template> +<SearchMarker path="/settings/accounts" :label="i18n.ts.accounts" :keywords="['accounts']" icon="ti ti-users"> + <div class="_gaps"> + <div class="_buttons"> + <MkButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</MkButton> + <!--<MkButton @click="refreshAllAccounts"><i class="ti ti-refresh"></i></MkButton>--> </div> - </FormSuspense> -</div> + + <MkUserCardMini v-for="x in accounts" :key="x[0] + x[1].id" :user="x[1]" :class="$style.user" @click.prevent="menu(x[0], x[1], $event)"/> + </div> +</SearchMarker> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; -import type * as Misskey from 'misskey-js'; -import FormSuspense from '@/components/form/suspense.vue'; +import * as Misskey from 'misskey-js'; +import type { MenuItem } from '@/types/menu.js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { getAccounts, removeAccount as _removeAccount, login, $i, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { $i } from '@/i.js'; +import { switchAccount, removeAccount, login, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/accounts.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; -import { MenuItem } from '@/types/menu'; - -const storedAccounts = ref<{ id: string, token: string }[] | null>(null); -const accounts = ref(new Map<string, Misskey.entities.UserDetailed | null>()); +import { prefer } from '@/preferences.js'; -const init = async () => { - getAccounts().then(accounts => { - storedAccounts.value = accounts.filter(x => x.id !== $i!.id); +const accounts = prefer.r.accounts; - return misskeyApi('users/show', { - userIds: storedAccounts.value.map(x => x.id), - }); - }).then(response => { - if (storedAccounts.value == null) return; - accounts.value = new Map(storedAccounts.value.map(x => [x.id, response.find((y: Misskey.entities.UserDetailed) => y.id === x.id) ?? null])); - }); -}; +function refreshAllAccounts() { + // TODO +} -function menu(account: Misskey.entities.UserDetailed | string, ev: MouseEvent) { +function menu(host: string, account: Misskey.entities.UserDetailed, ev: MouseEvent) { let menu: MenuItem[]; - if (typeof account === 'string') { - menu = [{ - text: i18n.ts.logout, - icon: 'ti ti-trash', - danger: true, - action: () => removeAccount(account), - }]; - } else { - menu = [{ - text: i18n.ts.switch, - icon: 'ti ti-switch-horizontal', - action: () => switchAccount(account.id), - }, { - text: i18n.ts.logout, - icon: 'ti ti-trash', - danger: true, - action: () => removeAccount(account.id), - }]; - } + menu = [{ + text: i18n.ts.switch, + icon: 'ti ti-switch-horizontal', + action: () => switchAccount(host, account.id), + }, { + text: i18n.ts.remove, + icon: 'ti ti-trash', + action: () => removeAccount(host, account.id), + }]; os.popupMenu(menu, ev.currentTarget ?? ev.target); } @@ -92,16 +62,10 @@ function addAccount(ev: MouseEvent) { }], ev.currentTarget ?? ev.target); } -async function removeAccount(id: string) { - await _removeAccount(id); - accounts.value.delete(id); -} - function addExistingAccount() { getAccountWithSigninDialog().then((res) => { if (res != null) { os.success(); - init(); } }); } @@ -109,26 +73,16 @@ function addExistingAccount() { function createAccount() { getAccountWithSignupDialog().then((res) => { if (res != null) { - switchAccountWithToken(res.token); + login(res.token); } }); } -async function switchAccount(id: string) { - const fetchedAccounts = await getAccounts(); - const token = fetchedAccounts.find(x => x.id === id)!.token; - switchAccountWithToken(token); -} - -function switchAccountWithToken(token: string) { - login(token); -} - const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.accounts, icon: 'ti ti-users', })); diff --git a/packages/frontend/src/pages/settings/api.vue b/packages/frontend/src/pages/settings/api.vue deleted file mode 100644 index b35d406a98..0000000000 --- a/packages/frontend/src/pages/settings/api.vue +++ /dev/null @@ -1,53 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div class="_gaps_m"> - <MkButton primary @click="generateToken">{{ i18n.ts.generateAccessToken }}</MkButton> - <FormLink to="/settings/apps">{{ i18n.ts.manageAccessTokens }}</FormLink> - <FormLink to="/api-console" :behavior="isDesktop ? 'window' : null">API console</FormLink> -</div> -</template> - -<script lang="ts" setup> -import { defineAsyncComponent, ref, computed } from 'vue'; -import FormLink from '@/components/form/link.vue'; -import MkButton from '@/components/MkButton.vue'; -import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; - -const isDesktop = ref(window.innerWidth >= 1100); - -function generateToken() { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, { - done: async result => { - const { name, permissions } = result; - const { token } = await misskeyApi('miauth/gen-token', { - session: null, - name: name, - permission: permissions, - }); - - os.alert({ - type: 'success', - title: i18n.ts.token, - text: token, - }); - }, - closed: () => dispose(), - }); -} - -const headerActions = computed(() => []); - -const headerTabs = computed(() => []); - -definePageMetadata(() => ({ - title: 'API', - icon: 'ti ti-api', -})); -</script> diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue index 6515503505..c72179b9a1 100644 --- a/packages/frontend/src/pages/settings/apps.vue +++ b/packages/frontend/src/pages/settings/apps.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <FormPagination ref="list" :pagination="pagination"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> </template> @@ -57,9 +57,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import FormPagination from '@/components/MkPagination.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; @@ -86,7 +86,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.installedApps, icon: 'ti ti-plug', })); diff --git a/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue b/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue index c71595380e..d3bec4a19e 100644 --- a/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue +++ b/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue @@ -16,9 +16,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed } from 'vue'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const props = defineProps<{ active?: boolean; diff --git a/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue b/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue index 77f9d4af20..1fd977cbd4 100644 --- a/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue +++ b/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue @@ -48,15 +48,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef, ref, computed } from 'vue'; +import { useTemplateRef, ref, computed } from 'vue'; import MkButton from '@/components/MkButton.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import { i18n } from '@/i18n.js'; import MkRange from '@/components/MkRange.vue'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const props = defineProps<{ usingIndex: number | null; @@ -87,7 +87,7 @@ const emit = defineEmits<{ (ev: 'detach'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const exceeded = computed(() => ($i.policies.avatarDecorationLimit - $i.avatarDecorations.length) <= 0); const locked = computed(() => props.decoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => props.decoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id))); const angle = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].angle : null) ?? 0); diff --git a/packages/frontend/src/pages/settings/avatar-decoration.vue b/packages/frontend/src/pages/settings/avatar-decoration.vue index efcd2a5f3f..e0f964a23b 100644 --- a/packages/frontend/src/pages/settings/avatar-decoration.vue +++ b/packages/frontend/src/pages/settings/avatar-decoration.vue @@ -4,45 +4,47 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <div v-if="!loading" class="_gaps"> - <MkInfo>{{ i18n.tsx._profile.avatarDecorationMax({ max: $i.policies.avatarDecorationLimit }) }} ({{ i18n.tsx.remainingN({ n: $i.policies.avatarDecorationLimit - $i.avatarDecorations.length }) }})</MkInfo> +<SearchMarker path="/settings/avatar-decoration" :label="i18n.ts.avatarDecorations" :keywords="['avatar', 'icon', 'decoration']" icon="ti ti-sparkles"> + <div> + <div v-if="!loading" class="_gaps"> + <MkInfo>{{ i18n.tsx._profile.avatarDecorationMax({ max: $i.policies.avatarDecorationLimit }) }} ({{ i18n.tsx.remainingN({ n: $i.policies.avatarDecorationLimit - $i.avatarDecorations.length }) }})</MkInfo> - <MkAvatar :class="$style.avatar" :user="$i" forceShowDecoration/> + <MkAvatar :class="$style.avatar" :user="$i" forceShowDecoration/> - <div v-if="$i.avatarDecorations.length > 0" v-panel :class="$style.current" class="_gaps_s"> - <div>{{ i18n.ts.inUse }}</div> + <div v-if="$i.avatarDecorations.length > 0" v-panel :class="$style.current" class="_gaps_s"> + <div>{{ i18n.ts.inUse }}</div> + + <div :class="$style.decorations"> + <XDecoration + v-for="(avatarDecoration, i) in $i.avatarDecorations" + :decoration="avatarDecorations.find(d => d.id === avatarDecoration.id)" + :angle="avatarDecoration.angle" + :flipH="avatarDecoration.flipH" + :offsetX="avatarDecoration.offsetX" + :offsetY="avatarDecoration.offsetY" + :showBelow="avatarDecoration.showBelow" + :active="true" + @click="openDecoration(avatarDecoration, i)" + /> + </div> + + <MkButton danger @click="detachAllDecorations">{{ i18n.ts.detachAll }}</MkButton> + </div> <div :class="$style.decorations"> <XDecoration - v-for="(avatarDecoration, i) in $i.avatarDecorations" - :decoration="avatarDecorations.find(d => d.id === avatarDecoration.id)" - :angle="avatarDecoration.angle" - :flipH="avatarDecoration.flipH" - :offsetX="avatarDecoration.offsetX" - :offsetY="avatarDecoration.offsetY" - :showBelow="avatarDecoration.showBelow" - :active="true" - @click="openDecoration(avatarDecoration, i)" + v-for="avatarDecoration in avatarDecorations" + :key="avatarDecoration.id" + :decoration="avatarDecoration" + @click="openDecoration(avatarDecoration)" /> </div> - - <MkButton danger @click="detachAllDecorations">{{ i18n.ts.detachAll }}</MkButton> </div> - - <div :class="$style.decorations"> - <XDecoration - v-for="avatarDecoration in avatarDecorations" - :key="avatarDecoration.id" - :decoration="avatarDecoration" - @click="openDecoration(avatarDecoration)" - /> + <div v-else> + <MkLoading/> </div> </div> - <div v-else> - <MkLoading/> - </div> -</div> +</SearchMarker> </template> <script lang="ts" setup> @@ -51,13 +53,13 @@ import * as Misskey from 'misskey-js'; import XDecoration from './avatar-decoration.decoration.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; import MkInfo from '@/components/MkInfo.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const loading = ref(true); const avatarDecorations = ref<Misskey.entities.GetAvatarDecorationsResponse>([]); @@ -132,7 +134,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.avatarDecorations, icon: 'ti ti-sparkles', })); diff --git a/packages/frontend/src/pages/settings/connect.vue b/packages/frontend/src/pages/settings/connect.vue new file mode 100644 index 0000000000..280ee546dc --- /dev/null +++ b/packages/frontend/src/pages/settings/connect.vue @@ -0,0 +1,112 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<SearchMarker path="/settings/connect" :label="i18n.ts._settings.serviceConnection" :keywords="['app', 'service', 'connect', 'webhook', 'api', 'token']" icon="ti ti-link"> + <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/link_3d.png" color="#ff0088"> + <SearchKeyword>{{ i18n.ts._settings.serviceConnectionBanner }}</SearchKeyword> + </MkFeatureBanner> + + <SearchMarker :keywords="['api', 'app', 'token', 'accessToken']"> + <FormSection> + <template #label><i class="ti ti-api"></i> <SearchLabel>{{ i18n.ts._settings.api }}</SearchLabel></template> + + <div class="_gaps_m"> + <MkButton primary @click="generateToken">{{ i18n.ts.generateAccessToken }}</MkButton> + <FormLink to="/settings/apps">{{ i18n.ts.manageAccessTokens }}</FormLink> + <FormLink to="/api-console" :behavior="isDesktop ? 'window' : null">API console</FormLink> + </div> + </FormSection> + </SearchMarker> + + <SearchMarker :keywords="['webhook']"> + <FormSection> + <template #label><i class="ti ti-webhook"></i> <SearchLabel>{{ i18n.ts._settings.webhook }}</SearchLabel></template> + + <div class="_gaps_m"> + <FormLink :to="`/settings/webhook/new`"> + {{ i18n.ts._webhookSettings.createWebhook }} + </FormLink> + + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts.manage }}</template> + + <MkPagination :pagination="pagination"> + <template #default="{items}"> + <div class="_gaps"> + <FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit/${webhook.id}`"> + <template #icon> + <i v-if="webhook.active === false" class="ti ti-player-pause"></i> + <i v-else-if="webhook.latestStatus === null" class="ti ti-circle"></i> + <i v-else-if="[200, 201, 204].includes(webhook.latestStatus)" class="ti ti-check" :style="{ color: 'var(--MI_THEME-success)' }"></i> + <i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--MI_THEME-error)' }"></i> + </template> + {{ webhook.name || webhook.url }} + <template #suffix> + <MkTime v-if="webhook.latestSentAt" :time="webhook.latestSentAt"></MkTime> + </template> + </FormLink> + </div> + </template> + </MkPagination> + </MkFolder> + </div> + </FormSection> + </SearchMarker> + </div> +</SearchMarker> +</template> + +<script lang="ts" setup> +import { computed, ref, defineAsyncComponent } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import FormSection from '@/components/form/section.vue'; +import FormLink from '@/components/form/link.vue'; +import { definePage } from '@/page.js'; +import { i18n } from '@/i18n.js'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; + +const isDesktop = ref(window.innerWidth >= 1100); + +const pagination = { + endpoint: 'i/webhooks/list' as const, + limit: 100, + noPaging: true, +}; + +function generateToken() { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, { + done: async result => { + const { name, permissions } = result; + const { token } = await misskeyApi('miauth/gen-token', { + session: null, + name: name, + permission: permissions, + }); + + os.alert({ + type: 'success', + title: i18n.ts.token, + text: token, + }); + }, + closed: () => dispose(), + }); +} + +const headerActions = computed(() => []); + +const headerTabs = computed(() => []); + +definePage(() => ({ + title: i18n.ts._settings.serviceConnection, + icon: 'ti ti-link', +})); +</script> diff --git a/packages/frontend/src/pages/settings/custom-css.vue b/packages/frontend/src/pages/settings/custom-css.vue index cf05e75acc..9b0e04860e 100644 --- a/packages/frontend/src/pages/settings/custom-css.vue +++ b/packages/frontend/src/pages/settings/custom-css.vue @@ -18,9 +18,9 @@ import { ref, watch, computed } from 'vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import FormInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; +import { unisonReload } from '@/utility/unison-reload.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { miLocalStorage } from '@/local-storage.js'; const localCustomCss = ref(miLocalStorage.getItem('customCss') ?? ''); @@ -45,7 +45,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.customCss, icon: 'ti ti-code', })); diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue index e574ec7dc0..9b2b40374e 100644 --- a/packages/frontend/src/pages/settings/deck.vue +++ b/packages/frontend/src/pages/settings/deck.vue @@ -4,39 +4,84 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_gaps_m"> - <MkSwitch v-model="useSimpleUiForNonRootPages">{{ i18n.ts._deck.useSimpleUiForNonRootPages }}</MkSwitch> +<SearchMarker path="/settings/deck" :label="i18n.ts.deck" :keywords="['deck', 'ui']" icon="ti ti-columns"> + <div class="_gaps_m"> + <SearchMarker :keywords="['sync', 'profiles', 'devices']"> + <MkSwitch :modelValue="profilesSyncEnabled" @update:modelValue="changeProfilesSyncEnabled"> + <template #label><SearchLabel>{{ i18n.ts._deck.enableSyncBetweenDevicesForProfiles }}</SearchLabel></template> + </MkSwitch> + </SearchMarker> - <MkSwitch v-model="navWindow">{{ i18n.ts.defaultNavigationBehaviour }}: {{ i18n.ts.openInWindow }}</MkSwitch> + <SearchMarker :keywords="['ui', 'root', 'page']"> + <MkPreferenceContainer k="deck.useSimpleUiForNonRootPages"> + <MkSwitch v-model="useSimpleUiForNonRootPages"> + <template #label><SearchLabel>{{ i18n.ts._deck.useSimpleUiForNonRootPages }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> - <MkSwitch v-model="alwaysShowMainColumn">{{ i18n.ts._deck.alwaysShowMainColumn }}</MkSwitch> + <SearchMarker :keywords="['default', 'navigation', 'behaviour', 'window']"> + <MkPreferenceContainer k="deck.navWindow"> + <MkSwitch v-model="navWindow"> + <template #label><SearchLabel>{{ i18n.ts.defaultNavigationBehaviour }}</SearchLabel>: {{ i18n.ts.openInWindow }}</template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> - <MkRadios v-model="columnAlign"> - <template #label>{{ i18n.ts._deck.columnAlign }}</template> - <option value="left">{{ i18n.ts.left }}</option> - <option value="center">{{ i18n.ts.center }}</option> - </MkRadios> -</div> + <SearchMarker :keywords="['always', 'show', 'main', 'column']"> + <MkPreferenceContainer k="deck.alwaysShowMainColumn"> + <MkSwitch v-model="alwaysShowMainColumn"> + <template #label><SearchLabel>{{ i18n.ts._deck.alwaysShowMainColumn }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['column', 'align']"> + <MkPreferenceContainer k="deck.columnAlign"> + <MkRadios v-model="columnAlign"> + <template #label><SearchLabel>{{ i18n.ts._deck.columnAlign }}</SearchLabel></template> + <option value="left">{{ i18n.ts.left }}</option> + <option value="center">{{ i18n.ts.center }}</option> + </MkRadios> + </MkPreferenceContainer> + </SearchMarker> + </div> +</SearchMarker> </template> <script lang="ts" setup> -import { computed } from 'vue'; +import { computed, ref } from 'vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkRadios from '@/components/MkRadios.vue'; -import { deckStore } from '@/ui/deck/deck-store.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; +import { prefer } from '@/preferences.js'; +import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; + +const navWindow = prefer.model('deck.navWindow'); +const useSimpleUiForNonRootPages = prefer.model('deck.useSimpleUiForNonRootPages'); +const alwaysShowMainColumn = prefer.model('deck.alwaysShowMainColumn'); +const columnAlign = prefer.model('deck.columnAlign'); + +const profilesSyncEnabled = ref(prefer.isSyncEnabled('deck.profiles')); -const navWindow = computed(deckStore.makeGetterSetter('navWindow')); -const useSimpleUiForNonRootPages = computed(deckStore.makeGetterSetter('useSimpleUiForNonRootPages')); -const alwaysShowMainColumn = computed(deckStore.makeGetterSetter('alwaysShowMainColumn')); -const columnAlign = computed(deckStore.makeGetterSetter('columnAlign')); +function changeProfilesSyncEnabled(value: boolean) { + if (value) { + prefer.enableSync('deck.profiles').then((res) => { + if (res == null) return; + if (res.enabled) profilesSyncEnabled.value = true; + }); + } else { + prefer.disableSync('deck.profiles'); + profilesSyncEnabled.value = false; + } +} const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.deck, icon: 'ti ti-columns', })); diff --git a/packages/frontend/src/pages/settings/drive-cleaner.vue b/packages/frontend/src/pages/settings/drive-cleaner.vue index 1ad3613e4b..a65a1e6e71 100644 --- a/packages/frontend/src/pages/settings/drive-cleaner.vue +++ b/packages/frontend/src/pages/settings/drive-cleaner.vue @@ -48,19 +48,21 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { computed, ref, shallowRef, watch, type StyleValue } from 'vue'; +import { computed, ref, shallowRef, watch } from 'vue'; +import type { StyleValue } from 'vue'; import tinycolor from 'tinycolor2'; import * as Misskey from 'misskey-js'; import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkPagination from '@/components/MkPagination.vue'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import { i18n } from '@/i18n.js'; import bytes from '@/filters/bytes.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkSelect from '@/components/MkSelect.vue'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>(); @@ -162,7 +164,7 @@ function onContextMenu(ev: MouseEvent, file): void { os.contextMenu(getDriveFileMenu(file), ev); } -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.drivecleaner, icon: 'ti ti-trash', })); diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index eef2e5b505..57161aa666 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -4,56 +4,82 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_gaps_m"> - <FormSection v-if="!fetching" first> - <template #label>{{ i18n.ts.usageAmount }}</template> +<SearchMarker path="/settings/drive" :label="i18n.ts.drive" :keywords="['drive']" icon="ti ti-cloud"> + <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/cloud_3d.png" color="#0059ff"> + <SearchKeyword>{{ i18n.ts._settings.driveBanner }}</SearchKeyword> + </MkFeatureBanner> - <div class="_gaps_m"> - <div> - <div :class="$style.meter"><div :class="$style.meterValue" :style="meterStyle"></div></div> - </div> - <FormSplit> - <MkKeyValue> - <template #key>{{ i18n.ts.capacity }}</template> - <template #value>{{ bytes(capacity, 1) }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.inUse }}</template> - <template #value>{{ bytes(usage, 1) }}</template> - </MkKeyValue> - </FormSplit> - </div> - </FormSection> + <SearchMarker :keywords="['capacity', 'usage']"> + <FormSection first> + <template #label><SearchLabel>{{ i18n.ts.usageAmount }}</SearchLabel></template> + + <div v-if="!fetching" class="_gaps_m"> + <div> + <div :class="$style.meter"><div :class="$style.meterValue" :style="meterStyle"></div></div> + </div> + <FormSplit> + <MkKeyValue> + <template #key>{{ i18n.ts.capacity }}</template> + <template #value>{{ bytes(capacity, 1) }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.inUse }}</template> + <template #value>{{ bytes(usage, 1) }}</template> + </MkKeyValue> + </FormSplit> + </div> + </FormSection> + </SearchMarker> + + <SearchMarker :keywords="['statistics', 'usage']"> + <FormSection> + <template #label><SearchLabel>{{ i18n.ts.statistics }}</SearchLabel></template> + <MkChart src="per-user-drive" :args="{ user: $i }" span="day" :limit="7 * 5" :bar="true" :stacked="true" :detailed="false" :aspectRatio="6"/> + </FormSection> + </SearchMarker> + + <FormSection> + <div class="_gaps_m"> + <SearchMarker :keywords="['default', 'upload', 'folder']"> + <FormLink to="" @click="chooseUploadFolder()"> + <SearchLabel>{{ i18n.ts.uploadFolder }}</SearchLabel> + <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> + <template #suffixIcon><i class="ti ti-folder"></i></template> + </FormLink> + </SearchMarker> - <FormSection> - <template #label>{{ i18n.ts.statistics }}</template> - <MkChart src="per-user-drive" :args="{ user: $i }" span="day" :limit="7 * 5" :bar="true" :stacked="true" :detailed="false" :aspectRatio="6"/> - </FormSection> + <FormLink to="/settings/drive/cleaner"> + {{ i18n.ts.drivecleaner }} + </FormLink> - <FormSection> - <div class="_gaps_m"> - <FormLink to="" @click="chooseUploadFolder()"> - {{ i18n.ts.uploadFolder }} - <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> - <template #suffixIcon><i class="ti ti-folder"></i></template> - </FormLink> - <FormLink to="/settings/drive/cleaner"> - {{ i18n.ts.drivecleaner }} - </FormLink> - <MkSwitch v-model="keepOriginalUploading"> - <template #label>{{ i18n.ts.keepOriginalUploading }}</template> - <template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template> - </MkSwitch> - <MkSwitch v-model="keepOriginalFilename"> - <template #label>{{ i18n.ts.keepOriginalFilename }}</template> - <template #caption>{{ i18n.ts.keepOriginalFilenameDescription }}</template> - </MkSwitch> - <MkSwitch v-model="defaultSensitive" @update:modelValue="saveProfile()"> - <template #label>{{ i18n.ts.alwaysMarkSensitive }}</template> - </MkSwitch> - </div> - </FormSection> -</div> + <SearchMarker :keywords="['keep', 'original', 'raw', 'upload']"> + <MkPreferenceContainer k="keepOriginalUploading"> + <MkSwitch v-model="keepOriginalUploading"> + <template #label><SearchLabel>{{ i18n.ts.keepOriginalUploading }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.keepOriginalUploadingDescription }}</SearchKeyword></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['keep', 'original', 'filename']"> + <MkPreferenceContainer k="keepOriginalFilename"> + <MkSwitch v-model="keepOriginalFilename"> + <template #label><SearchLabel>{{ i18n.ts.keepOriginalFilename }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.keepOriginalFilenameDescription }}</SearchKeyword></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file']"> + <MkSwitch v-model="defaultSensitive" @update:modelValue="saveProfile()"> + <template #label><SearchLabel>{{ i18n.ts.alwaysMarkSensitive }}</SearchLabel></template> + </MkSwitch> + </SearchMarker> + </div> + </FormSection> + </div> +</SearchMarker> </template> <script lang="ts" setup> @@ -66,15 +92,17 @@ import FormSection from '@/components/form/section.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import FormSplit from '@/components/form/split.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import bytes from '@/filters/bytes.js'; -import { defaultStore } from '@/store.js'; import MkChart from '@/components/MkChart.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { signinRequired } from '@/account.js'; +import { definePage } from '@/page.js'; +import { ensureSignin } from '@/i.js'; +import { prefer } from '@/preferences.js'; +import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; -const $i = signinRequired(); +const $i = ensureSignin(); const fetching = ref(true); const usage = ref<number | null>(null); @@ -94,8 +122,8 @@ const meterStyle = computed(() => { }; }); -const keepOriginalUploading = computed(defaultStore.makeGetterSetter('keepOriginalUploading')); -const keepOriginalFilename = computed(defaultStore.makeGetterSetter('keepOriginalFilename')); +const keepOriginalUploading = prefer.model('keepOriginalUploading'); +const keepOriginalFilename = prefer.model('keepOriginalFilename'); misskeyApi('drive').then(info => { capacity.value = info.capacity; @@ -103,9 +131,9 @@ misskeyApi('drive').then(info => { fetching.value = false; }); -if (defaultStore.state.uploadFolder) { +if (prefer.s.uploadFolder) { misskeyApi('drive/folders/show', { - folderId: defaultStore.state.uploadFolder, + folderId: prefer.s.uploadFolder, }).then(response => { uploadFolder.value = response; }); @@ -113,11 +141,11 @@ if (defaultStore.state.uploadFolder) { function chooseUploadFolder() { os.selectDriveFolder(false).then(async folder => { - defaultStore.set('uploadFolder', folder[0] ? folder[0].id : null); + prefer.commit('uploadFolder', folder[0] ? folder[0].id : null); os.success(); - if (defaultStore.state.uploadFolder) { + if (prefer.s.uploadFolder) { uploadFolder.value = await misskeyApi('drive/folders/show', { - folderId: defaultStore.state.uploadFolder, + folderId: prefer.s.uploadFolder, }); } else { uploadFolder.value = null; @@ -142,7 +170,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.drive, icon: 'ti ti-cloud', })); diff --git a/packages/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue index d90c86a4ec..e9acd59778 100644 --- a/packages/frontend/src/pages/settings/email.vue +++ b/packages/frontend/src/pages/settings/email.vue @@ -4,25 +4,37 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-if="instance.enableEmail" class="_gaps_m"> - <FormSection first> - <template #label>{{ i18n.ts.emailAddress }}</template> - <MkInput v-model="emailAddress" type="email" manualSave> - <template #prefix><i class="ti ti-mail"></i></template> - <template v-if="$i.email && !$i.emailVerified" #caption>{{ i18n.ts.verificationEmailSent }}</template> - <template v-else-if="emailAddress === $i.email && $i.emailVerified" #caption><i class="ti ti-check" style="color: var(--MI_THEME-success);"></i> {{ i18n.ts.emailVerified }}</template> - </MkInput> - </FormSection> +<SearchMarker path="/settings/email" :label="i18n.ts.email" :keywords="['email']" icon="ti ti-mail"> + <div class="_gaps_m"> + <MkInfo v-if="!instance.enableEmail">{{ i18n.ts.emailNotSupported }}</MkInfo> - <FormSection> - <MkSwitch :modelValue="$i.receiveAnnouncementEmail" @update:modelValue="onChangeReceiveAnnouncementEmail"> - {{ i18n.ts.receiveAnnouncementFromInstance }} - </MkSwitch> - </FormSection> -</div> -<div v-if="!instance.enableEmail" class="_gaps_m"> - <MkInfo>{{ i18n.ts.emailNotSupported }}</MkInfo> -</div> + <MkDisableSection :disabled="!instance.enableEmail"> + <div class="_gaps_m"> + <SearchMarker :keywords="['email', 'address']"> + <FormSection first> + <template #label><SearchLabel>{{ i18n.ts.emailAddress }}</SearchLabel></template> + <MkInput v-model="emailAddress" type="email" manualSave> + <template #prefix><i class="ti ti-mail"></i></template> + <template v-if="$i.email && !$i.emailVerified" #caption>{{ i18n.ts.verificationEmailSent }}</template> + <template v-else-if="emailAddress === $i.email && $i.emailVerified" #caption><i class="ti ti-check" style="color: var(--MI_THEME-success);"></i> {{ i18n.ts.emailVerified }}</template> + </MkInput> + </FormSection> + </SearchMarker> + + <FormSection> + <SearchMarker :keywords="['announcement', 'email']"> + <MkSwitch :modelValue="$i.receiveAnnouncementEmail" @update:modelValue="onChangeReceiveAnnouncementEmail"> + <template #label><SearchLabel>{{ i18n.ts.receiveAnnouncementFromInstance }}</SearchLabel></template> + </MkSwitch> + </SearchMarker> + </FormSection> + </div> + </MkDisableSection> + </div> + <div v-if="!instance.enableEmail" class="_gaps_m"> + <MkInfo>{{ i18n.ts.emailNotSupported }}</MkInfo> + </div> +</SearchMarker> </template> <script lang="ts" setup> @@ -31,14 +43,15 @@ import FormSection from '@/components/form/section.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; +import MkDisableSection from '@/components/MkDisableSection.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { signinRequired } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { ensureSignin } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { instance } from '@/instance.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const emailAddress = ref($i.email); @@ -69,7 +82,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.email, icon: 'ti ti-mail', })); diff --git a/packages/frontend/src/pages/settings/emoji-palette.palette.vue b/packages/frontend/src/pages/settings/emoji-palette.palette.vue new file mode 100644 index 0000000000..33d1d7c9fa --- /dev/null +++ b/packages/frontend/src/pages/settings/emoji-palette.palette.vue @@ -0,0 +1,166 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkFolder :defaultOpen="true"> + <template #icon><i class="ti ti-palette"></i></template> + <template #label>{{ palette.name === '' ? '(' + i18n.ts.noName + ')' : palette.name }}</template> + <template #footer> + <div class="_buttons"> + <MkButton @click="rename"><i class="ti ti-pencil"></i> {{ i18n.ts.rename }}</MkButton> + <MkButton @click="copy"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton> + <MkButton danger @click="paste"><i class="ti ti-clipboard"></i> {{ i18n.ts.paste }}</MkButton> + <MkButton danger iconOnly style="margin-left: auto;" @click="del"><i class="ti ti-trash"></i></MkButton> + </div> + </template> + + <div> + <div v-panel style="border-radius: 6px;"> + <Sortable + v-model="emojis" + :class="$style.emojis" + :itemKey="item => item" + :animation="150" + :delay="100" + :delayOnTouchOnly="true" + :group="{ name: 'SortableEmojiPalettes' }" + > + <template #item="{element}"> + <button class="_button" :class="$style.emojisItem" @click="remove(element, $event)"> + <MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/> + <MkEmoji v-else :emoji="element" :normal="true"/> + </button> + </template> + <template #footer> + <button class="_button" :class="$style.emojisAdd" @click="pick"> + <i class="ti ti-plus"></i> + </button> + </template> + </Sortable> + </div> + <div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div> + </div> +</MkFolder> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import Sortable from 'vuedraggable'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import { deepClone } from '@/utility/clone.js'; +import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue'; +import MkEmoji from '@/components/global/MkEmoji.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; + +const props = defineProps<{ + palette: { + id: string; + name: string; + emojis: string[]; + }; +}>(); + +const emit = defineEmits<{ + (ev: 'updateEmojis', emojis: string[]): void, + (ev: 'updateName', name: string): void, + (ev: 'del'): void, +}>(); + +const emojis = ref<string[]>(deepClone(props.palette.emojis)); + +watch(emojis, () => { + emit('updateEmojis', emojis.value); +}, { deep: true }); + +function remove(reaction: string, ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts.remove, + action: () => { + emojis.value = emojis.value.filter(x => x !== reaction); + }, + }], getHTMLElement(ev)); +} + +function pick(ev: MouseEvent) { + os.pickEmoji(getHTMLElement(ev), { + showPinned: false, + }).then(it => { + const emoji = it; + if (!emojis.value.includes(emoji)) { + emojis.value.push(emoji); + } + }); +} + +function getHTMLElement(ev: MouseEvent): HTMLElement { + const target = ev.currentTarget ?? ev.target; + return target as HTMLElement; +} + +function rename() { + os.inputText({ + title: i18n.ts.rename, + default: props.palette.name, + }).then(({ canceled, result: name }) => { + if (canceled) return; + if (name != null) { + emit('updateName', name); + } + }); +} + +function copy() { + copyToClipboard(emojis.value.join(' ')); +} + +function paste() { + // TODO: validate + navigator.clipboard.readText().then(text => { + emojis.value = text.split(' '); + }); +} + +function del(ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts.delete, + action: () => { + emit('del'); + }, + }], ev.currentTarget ?? ev.target); +} +</script> + +<style lang="scss" module> +.tab { + margin: calc(var(--MI-margin) / 2) 0; + padding: calc(var(--MI-margin) / 2) 0; + background: var(--MI_THEME-bg); +} + +.emojis { + padding: 12px; + font-size: 1.1em; +} + +.emojisItem { + display: inline-block; + padding: 8px; + cursor: move; +} + +.emojisAdd { + display: inline-block; + padding: 8px; +} + +.editorCaption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: var(--MI_THEME-fgTransparentWeak); +} +</style> diff --git a/packages/frontend/src/pages/settings/emoji-palette.vue b/packages/frontend/src/pages/settings/emoji-palette.vue new file mode 100644 index 0000000000..398228e226 --- /dev/null +++ b/packages/frontend/src/pages/settings/emoji-palette.vue @@ -0,0 +1,251 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<SearchMarker path="/settings/emoji-palette" :label="i18n.ts.emojiPalette" :keywords="['emoji', 'palette']" icon="ti ti-mood-happy"> + <div class="_gaps_m"> + <FormSection first> + <template #label>{{ i18n.ts._emojiPalette.palettes }}</template> + + <div class="_gaps_s"> + <XPalette + v-for="palette in prefer.r.emojiPalettes.value" + :key="palette.id" + :palette="palette" + @updateEmojis="emojis => updatePaletteEmojis(palette.id, emojis)" + @updateName="name => updatePaletteName(palette.id, name)" + @del="delPalette(palette.id)" + /> + <MkButton primary rounded style="margin: auto;" @click="addPalette"><i class="ti ti-plus"></i></MkButton> + </div> + </FormSection> + + <FormSection> + <div class="_gaps_m"> + <SearchMarker :keywords="['sync', 'palettes', 'devices']"> + <MkSwitch :modelValue="palettesSyncEnabled" @update:modelValue="changePalettesSyncEnabled"> + <template #label><SearchLabel>{{ i18n.ts._emojiPalette.enableSyncBetweenDevicesForPalettes }}</SearchLabel></template> + </MkSwitch> + </SearchMarker> + </div> + </FormSection> + + <FormSection> + <div class="_gaps_m"> + <SearchMarker :keywords="['main', 'palette']"> + <MkPreferenceContainer k="emojiPaletteForMain"> + <MkSelect v-model="emojiPaletteForMain"> + <template #label><SearchLabel>{{ i18n.ts._emojiPalette.paletteForMain }}</SearchLabel></template> + <option key="-" :value="null">({{ i18n.ts.auto }})</option> + <option v-for="palette in prefer.r.emojiPalettes.value" :key="palette.id" :value="palette.id">{{ palette.name === '' ? '(' + i18n.ts.noName + ')' : palette.name }}</option> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['reaction', 'palette']"> + <MkPreferenceContainer k="emojiPaletteForReaction"> + <MkSelect v-model="emojiPaletteForReaction"> + <template #label><SearchLabel>{{ i18n.ts._emojiPalette.paletteForReaction }}</SearchLabel></template> + <option key="-" :value="null">({{ i18n.ts.auto }})</option> + <option v-for="palette in prefer.r.emojiPalettes.value" :key="palette.id" :value="palette.id">{{ palette.name === '' ? '(' + i18n.ts.noName + ')' : palette.name }}</option> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + </div> + </FormSection> + + <SearchMarker :keywords="['emoji', 'picker', 'display']"> + <FormSection> + <template #label><SearchLabel>{{ i18n.ts.emojiPickerDisplay }}</SearchLabel></template> + + <div class="_gaps_m"> + <SearchMarker :keywords="['emoji', 'picker', 'scale', 'size']"> + <MkPreferenceContainer k="emojiPickerScale"> + <MkRadios v-model="emojiPickerScale"> + <template #label><SearchLabel>{{ i18n.ts.size }}</SearchLabel></template> + <option :value="1">{{ i18n.ts.small }}</option> + <option :value="2">{{ i18n.ts.medium }}</option> + <option :value="3">{{ i18n.ts.large }}</option> + </MkRadios> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['emoji', 'picker', 'width', 'column', 'size']"> + <MkPreferenceContainer k="emojiPickerWidth"> + <MkRadios v-model="emojiPickerWidth"> + <template #label><SearchLabel>{{ i18n.ts.numberOfColumn }}</SearchLabel></template> + <option :value="1">5</option> + <option :value="2">6</option> + <option :value="3">7</option> + <option :value="4">8</option> + <option :value="5">9</option> + </MkRadios> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['emoji', 'picker', 'height', 'size']"> + <MkPreferenceContainer k="emojiPickerHeight"> + <MkRadios v-model="emojiPickerHeight"> + <template #label><SearchLabel>{{ i18n.ts.height }}</SearchLabel></template> + <option :value="1">{{ i18n.ts.small }}</option> + <option :value="2">{{ i18n.ts.medium }}</option> + <option :value="3">{{ i18n.ts.large }}</option> + <option :value="4">{{ i18n.ts.large }}+</option> + </MkRadios> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['emoji', 'picker', 'style']"> + <MkPreferenceContainer k="emojiPickerStyle"> + <MkSelect v-model="emojiPickerStyle"> + <template #label><SearchLabel>{{ i18n.ts.style }}</SearchLabel></template> + <template #caption>{{ i18n.ts.needReloadToApply }}</template> + <option value="auto">{{ i18n.ts.auto }}</option> + <option value="popup">{{ i18n.ts.popup }}</option> + <option value="drawer">{{ i18n.ts.drawer }}</option> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + + <MkButton @click="previewPicker"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton> + </div> + </FormSection> + </SearchMarker> + </div> +</SearchMarker> +</template> + +<script lang="ts" setup> +import { computed, ref, watch } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XPalette from './emoji-palette.palette.vue'; +import MkRadios from '@/components/MkRadios.vue'; +import MkButton from '@/components/MkButton.vue'; +import FormSection from '@/components/form/section.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import { definePage } from '@/page.js'; +import MkFolder from '@/components/MkFolder.vue'; +import { prefer } from '@/preferences.js'; +import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import { emojiPicker } from '@/utility/emoji-picker.js'; + +const emojiPaletteForReaction = prefer.model('emojiPaletteForReaction'); +const emojiPaletteForMain = prefer.model('emojiPaletteForMain'); +const emojiPickerScale = prefer.model('emojiPickerScale'); +const emojiPickerWidth = prefer.model('emojiPickerWidth'); +const emojiPickerHeight = prefer.model('emojiPickerHeight'); +const emojiPickerStyle = prefer.model('emojiPickerStyle'); + +const palettesSyncEnabled = ref(prefer.isSyncEnabled('emojiPalettes')); + +function changePalettesSyncEnabled(value: boolean) { + if (value) { + prefer.enableSync('emojiPalettes').then((res) => { + if (res == null) return; + if (res.enabled) palettesSyncEnabled.value = true; + }); + } else { + prefer.disableSync('emojiPalettes'); + palettesSyncEnabled.value = false; + } +} + +function addPalette() { + prefer.commit('emojiPalettes', [ + ...prefer.s.emojiPalettes, + { + id: uuid(), + name: '', + emojis: [], + }, + ]); +} + +function updatePaletteEmojis(id: string, emojis: string[]) { + prefer.commit('emojiPalettes', prefer.s.emojiPalettes.map(palette => { + if (palette.id === id) { + return { + ...palette, + emojis, + }; + } else { + return palette; + } + })); +} + +function updatePaletteName(id: string, name: string) { + prefer.commit('emojiPalettes', prefer.s.emojiPalettes.map(palette => { + if (palette.id === id) { + return { + ...palette, + name, + }; + } else { + return palette; + } + })); +} + +function delPalette(id: string) { + if (prefer.s.emojiPalettes.length === 1) { + addPalette(); + } + prefer.commit('emojiPalettes', prefer.s.emojiPalettes.filter(palette => palette.id !== id)); + if (prefer.s.emojiPaletteForMain === id) { + prefer.commit('emojiPaletteForMain', null); + } + if (prefer.s.emojiPaletteForReaction === id) { + prefer.commit('emojiPaletteForReaction', null); + } +} + +function getHTMLElement(ev: MouseEvent): HTMLElement { + const target = ev.currentTarget ?? ev.target; + return target as HTMLElement; +} + +function previewPicker(ev: MouseEvent) { + emojiPicker.show(getHTMLElement(ev)); +} + +definePage(() => ({ + title: i18n.ts.emojiPalette, + icon: 'ti ti-mood-happy', +})); +</script> + +<style lang="scss" module> +.tab { + margin: calc(var(--MI-margin) / 2) 0; + padding: calc(var(--MI-margin) / 2) 0; + background: var(--MI_THEME-bg); +} + +.emojis { + padding: 12px; + font-size: 1.1em; +} + +.emojisItem { + display: inline-block; + padding: 8px; + cursor: move; +} + +.emojisAdd { + display: inline-block; + padding: 8px; +} + +.editorCaption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: var(--MI_THEME-fgTransparentWeak); +} +</style> diff --git a/packages/frontend/src/pages/settings/emoji-picker.vue b/packages/frontend/src/pages/settings/emoji-picker.vue deleted file mode 100644 index 7ddc1eab71..0000000000 --- a/packages/frontend/src/pages/settings/emoji-picker.vue +++ /dev/null @@ -1,316 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div class="_gaps_m"> - <MkFolder :defaultOpen="true"> - <template #icon><i class="ti ti-pin"></i></template> - <template #label>{{ i18n.ts.pinned }} ({{ i18n.ts.reaction }})</template> - <template #caption>{{ i18n.ts.pinnedEmojisForReactionSettingDescription }}</template> - - <div class="_gaps"> - <div> - <div v-panel style="border-radius: var(--MI-radius-sm);"> - <Sortable - v-model="pinnedEmojisForReaction" - :class="$style.emojis" - :itemKey="item => item" - :animation="150" - :delay="100" - :delayOnTouchOnly="true" - > - <template #item="{element}"> - <button class="_button" :class="$style.emojisItem" @click="removeReaction(element, $event)"> - <MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/> - <MkEmoji v-else :emoji="element" :normal="true"/> - </button> - </template> - <template #footer> - <button class="_button" :class="$style.emojisAdd" @click="chooseReaction"> - <i class="ti ti-plus"></i> - </button> - </template> - </Sortable> - </div> - <div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div> - </div> - - <div class="_buttons"> - <MkButton inline @click="previewReaction"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton> - <MkButton inline danger @click="setDefaultReaction"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton> - <MkButton inline danger @click="overwriteFromPinnedEmojis"><i class="ti ti-copy"></i> {{ i18n.ts.overwriteFromPinnedEmojis }}</MkButton> - </div> - </div> - </MkFolder> - - <MkFolder> - <template #icon><i class="ti ti-pin"></i></template> - <template #label>{{ i18n.ts.pinned }} ({{ i18n.ts.general }})</template> - <template #caption>{{ i18n.ts.pinnedEmojisSettingDescription }}</template> - - <div class="_gaps"> - <div> - <div v-panel style="border-radius: var(--MI-radius-sm);"> - <Sortable - v-model="pinnedEmojis" - :class="$style.emojis" - :itemKey="item => item" - :animation="150" - :delay="100" - :delayOnTouchOnly="true" - > - <template #item="{element}"> - <button class="_button" :class="$style.emojisItem" @click="removeEmoji(element, $event)"> - <MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/> - <MkEmoji v-else :emoji="element" :normal="true"/> - </button> - </template> - <template #footer> - <button class="_button" :class="$style.emojisAdd" @click="chooseEmoji"> - <i class="ti ti-plus"></i> - </button> - </template> - </Sortable> - </div> - <div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div> - </div> - - <div class="_buttons"> - <MkButton inline @click="previewEmoji"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton> - <MkButton inline danger @click="setDefaultEmoji"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton> - <MkButton inline danger @click="overwriteFromPinnedEmojisForReaction"><i class="ti ti-copy"></i> {{ i18n.ts.overwriteFromPinnedEmojisForReaction }}</MkButton> - </div> - </div> - </MkFolder> - - <FromSlot> - <template #label>{{ i18n.ts.defaultLike }}</template> - <MkCustomEmoji v-if="like && like.startsWith(':')" style="max-height: 3em; font-size: 1.1em;" :useOriginalSize="false" :name="like" :normal="true" :noStyle="true"/> - <MkEmoji v-else-if="like && !like.startsWith(':')" :emoji="like" style="max-height: 3em; font-size: 1.1em;" :normal="true" :noStyle="true"/> - <span v-else-if="!like">{{ i18n.ts.notSet }}</span> - <div class="_buttons" style="padding-top: 8px;"> - <MkButton rounded :small="true" inline @click="chooseNewLike"><i class="ph-smiley ph-bold ph-lg"></i> Change</MkButton> - <MkButton rounded :small="true" inline @click="resetLike"><i class="ph-arrow-clockwise ph-bold ph-lg"></i> Reset</MkButton> - </div> - </FromSlot> - - <FormSection> - <template #label>{{ i18n.ts.emojiPickerDisplay }}</template> - - <div class="_gaps_m"> - <MkRadios v-model="emojiPickerScale"> - <template #label>{{ i18n.ts.size }}</template> - <option :value="1">{{ i18n.ts.small }}</option> - <option :value="2">{{ i18n.ts.medium }}</option> - <option :value="3">{{ i18n.ts.large }}</option> - </MkRadios> - - <MkRadios v-model="emojiPickerWidth"> - <template #label>{{ i18n.ts.numberOfColumn }}</template> - <option :value="1">5</option> - <option :value="2">6</option> - <option :value="3">7</option> - <option :value="4">8</option> - <option :value="5">9</option> - </MkRadios> - - <MkRadios v-model="emojiPickerHeight"> - <template #label>{{ i18n.ts.height }}</template> - <option :value="1">{{ i18n.ts.small }}</option> - <option :value="2">{{ i18n.ts.medium }}</option> - <option :value="3">{{ i18n.ts.large }}</option> - <option :value="4">{{ i18n.ts.large }}+</option> - </MkRadios> - - <MkSelect v-model="emojiPickerStyle"> - <template #label>{{ i18n.ts.style }}</template> - <template #caption>{{ i18n.ts.needReloadToApply }}</template> - <option value="auto">{{ i18n.ts.auto }}</option> - <option value="popup">{{ i18n.ts.popup }}</option> - <option value="drawer">{{ i18n.ts.drawer }}</option> - </MkSelect> - </div> - </FormSection> -</div> -</template> - -<script lang="ts" setup> -import { computed, ref, Ref, watch } from 'vue'; -import Sortable from 'vuedraggable'; -import MkRadios from '@/components/MkRadios.vue'; -import MkButton from '@/components/MkButton.vue'; -import FormSection from '@/components/form/section.vue'; -import FromSlot from '@/components/form/slot.vue'; -import MkSelect from '@/components/MkSelect.vue'; -import * as os from '@/os.js'; -import { defaultStore } from '@/store.js'; -import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { deepClone } from '@/scripts/clone.js'; -import { reactionPicker } from '@/scripts/reaction-picker.js'; -import { emojiPicker } from '@/scripts/emoji-picker.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; -import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue'; -import MkEmoji from '@/components/global/MkEmoji.vue'; -import MkFolder from '@/components/MkFolder.vue'; - -const pinnedEmojisForReaction: Ref<string[]> = ref(deepClone(defaultStore.state.reactions)); -const pinnedEmojis: Ref<string[]> = ref(deepClone(defaultStore.state.pinnedEmojis)); - -const emojiPickerScale = computed(defaultStore.makeGetterSetter('emojiPickerScale')); -const emojiPickerWidth = computed(defaultStore.makeGetterSetter('emojiPickerWidth')); -const emojiPickerHeight = computed(defaultStore.makeGetterSetter('emojiPickerHeight')); -const emojiPickerStyle = computed(defaultStore.makeGetterSetter('emojiPickerStyle')); - -const removeReaction = (reaction: string, ev: MouseEvent) => remove(pinnedEmojisForReaction, reaction, ev); -const chooseReaction = (ev: MouseEvent) => pickEmoji(pinnedEmojisForReaction, ev); -const setDefaultReaction = () => setDefault(pinnedEmojisForReaction); - -const like = computed(defaultStore.makeGetterSetter('like')); - -const removeEmoji = (reaction: string, ev: MouseEvent) => remove(pinnedEmojis, reaction, ev); -const chooseEmoji = (ev: MouseEvent) => pickEmoji(pinnedEmojis, ev); -const setDefaultEmoji = () => setDefault(pinnedEmojis); - -function previewReaction(ev: MouseEvent) { - reactionPicker.show(getHTMLElement(ev), null); -} - -function previewEmoji(ev: MouseEvent) { - emojiPicker.show(getHTMLElement(ev)); -} - -async function overwriteFromPinnedEmojis() { - const { canceled } = await os.confirm({ - type: 'warning', - text: i18n.ts.overwriteContentConfirm, - }); - - if (canceled) { - return; - } - - pinnedEmojisForReaction.value = [...pinnedEmojis.value]; -} - -async function overwriteFromPinnedEmojisForReaction() { - const { canceled } = await os.confirm({ - type: 'warning', - text: i18n.ts.overwriteContentConfirm, - }); - - if (canceled) { - return; - } - - pinnedEmojis.value = [...pinnedEmojisForReaction.value]; -} - -function remove(itemsRef: Ref<string[]>, reaction: string, ev: MouseEvent) { - os.popupMenu([{ - text: i18n.ts.remove, - action: () => { - itemsRef.value = itemsRef.value.filter(x => x !== reaction); - }, - }], getHTMLElement(ev)); -} - -async function setDefault(itemsRef: Ref<string[]>) { - const { canceled } = await os.confirm({ - type: 'warning', - text: i18n.ts.resetAreYouSure, - }); - if (canceled) return; - - itemsRef.value = deepClone(defaultStore.def.reactions.default); -} - -async function pickEmoji(itemsRef: Ref<string[]>, ev: MouseEvent) { - os.pickEmoji(getHTMLElement(ev), { - showPinned: false, - }).then(it => { - const emoji = it; - if (!itemsRef.value.includes(emoji)) { - itemsRef.value.push(emoji); - } - }); -} - -async function reloadAsk() { - const { canceled } = await os.confirm({ - type: 'info', - text: i18n.ts.reloadToApplySetting, - }); - if (canceled) return; - - unisonReload(); -} - -function chooseNewLike(ev: MouseEvent) { - os.pickEmoji(getHTMLElement(ev), { - showPinned: false, - }).then(async emoji => { - defaultStore.set('like', emoji as string); - await reloadAsk(); - }); -} - -async function resetLike() { - defaultStore.set('like', null); - await reloadAsk(); -} - -function getHTMLElement(ev: MouseEvent): HTMLElement { - const target = ev.currentTarget ?? ev.target; - return target as HTMLElement; -} - -watch(pinnedEmojisForReaction, () => { - defaultStore.set('reactions', pinnedEmojisForReaction.value); -}, { - deep: true, -}); - -watch(pinnedEmojis, () => { - defaultStore.set('pinnedEmojis', pinnedEmojis.value); -}, { - deep: true, -}); - -definePageMetadata(() => ({ - title: i18n.ts.emojiPicker, - icon: 'ti ti-mood-happy', -})); -</script> - -<style lang="scss" module> -.tab { - margin: calc(var(--MI-margin) / 2) 0; - padding: calc(var(--MI-margin) / 2) 0; - background: var(--MI_THEME-bg); -} - -.emojis { - padding: 12px; - font-size: 1.1em; -} - -.emojisItem { - display: inline-block; - padding: 8px; - cursor: move; -} - -.emojisAdd { - display: inline-block; - padding: 8px; -} - -.editorCaption { - font-size: 0.85em; - padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); -} -</style> diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue deleted file mode 100644 index fc9c6aa669..0000000000 --- a/packages/frontend/src/pages/settings/general.vue +++ /dev/null @@ -1,623 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div class="_gaps_m"> - <MkSelect v-model="lang"> - <template #label>{{ i18n.ts.uiLanguage }}</template> - <option v-for="x in langs" :key="x[0]" :value="x[0]">{{ x[1] }}</option> - <template #caption> - <I18n :src="i18n.ts.i18nInfo" tag="span"> - <template #link> - <MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink> - </template> - </I18n> - </template> - </MkSelect> - - <MkRadios v-model="overridedDeviceKind"> - <template #label>{{ i18n.ts.overridedDeviceKind }}</template> - <option :value="null">{{ i18n.ts.auto }}</option> - <option value="smartphone"><i class="ti ti-device-mobile"/> {{ i18n.ts.smartphone }}</option> - <option value="tablet"><i class="ti ti-device-tablet"/> {{ i18n.ts.tablet }}</option> - <option value="desktop"><i class="ti ti-device-desktop"/> {{ i18n.ts.desktop }}</option> - </MkRadios> - - <FormSection> - <div class="_gaps_s"> - <MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch> - <MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch> - <MkFolder> - <template #label>{{ i18n.ts.pinnedList }}</template> - <!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ --> - <MkButton v-if="defaultStore.reactiveState.pinnedUserLists.value.length === 0" @click="setPinnedList()">{{ i18n.ts.add }}</MkButton> - <MkButton v-else danger @click="removePinnedList()"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> - </MkFolder> - </div> - </FormSection> - - <FormSection> - <template #label>{{ i18n.ts.displayOfNote }}</template> - - <div class="_gaps_m"> - <div class="_gaps_s"> - <MkSwitch v-model="collapseRenotes"> - <template #label>{{ i18n.ts.collapseRenotes }}</template> - <template #caption>{{ i18n.ts.collapseRenotesDescription }}</template> - </MkSwitch> - <MkSwitch v-model="collapseNotesRepliedTo">{{ i18n.ts.collapseNotesRepliedTo }}</MkSwitch> - <MkSwitch v-model="collapseFiles">{{ i18n.ts.collapseFiles }}</MkSwitch> - <MkSwitch v-model="uncollapseCW">{{ i18n.ts.uncollapseCW }}</MkSwitch> - <MkSwitch v-model="expandLongNote">{{ i18n.ts.expandLongNote }}</MkSwitch> - <MkSwitch v-model="showNoteActionsOnlyHover">{{ i18n.ts.showNoteActionsOnlyHover }}</MkSwitch> - <MkSwitch v-model="showClipButtonInNoteFooter">{{ i18n.ts.showClipButtonInNoteFooter }}</MkSwitch> - <MkSwitch v-model="autoloadConversation">{{ i18n.ts.autoloadConversation }}</MkSwitch> - <MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch> - <MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch> - <MkSwitch v-if="advancedMfm" v-model="enableQuickAddMfmFunction">{{ i18n.ts.enableQuickAddMfmFunction }}</MkSwitch> - <MkSwitch v-model="showReactionsCount">{{ i18n.ts.showReactionsCount }}</MkSwitch> - <MkSwitch v-model="showGapBetweenNotesInTimeline">{{ i18n.ts.showGapBetweenNotesInTimeline }}</MkSwitch> - <MkSwitch v-model="loadRawImages">{{ i18n.ts.loadRawImages }}</MkSwitch> - <MkSwitch v-model="showTickerOnReplies">{{ i18n.ts.showTickerOnReplies }}</MkSwitch> - <MkSwitch v-model="disableCatSpeak">{{ i18n.ts.disableCatSpeak }}</MkSwitch> - <MkSelect v-model="searchEngine" placeholder="Other"> - <template #label>{{ i18n.ts.searchEngine }}</template> - <option - v-for="[key, value] in Object.entries(searchEngineMap)" :key="key" :value="key" - > - {{ value }} - </option> - <!-- If the user is on Other and enters a domain add this one so that the dropdown doesnt go blank --> - <option v-if="useCustomSearchEngine" :value="searchEngine"> - {{ i18n.ts.searchEngineOther }} - </option> - <!-- If one of the other options is selected show this as a blank other --> - <option v-if="!useCustomSearchEngine" value="">{{ i18n.ts.searchEngineOther }}</option> - </MkSelect> - - <div v-if="useCustomSearchEngine"> - <MkInput v-model="searchEngine" :max="300" :manualSave="true"> - <template #label>{{ i18n.ts.searchEngineCusomURI }}</template> - <template #caption>{{ i18n.ts.searchEngineCustomURIDescription }}</template> - </MkInput> - </div> - - <MkRadios v-model="reactionsDisplaySize"> - <template #label>{{ i18n.ts.reactionsDisplaySize }}</template> - <option value="small">{{ i18n.ts.small }}</option> - <option value="medium">{{ i18n.ts.medium }}</option> - <option value="large">{{ i18n.ts.large }}</option> - </MkRadios> - <MkRadios v-model="noteDesign"> - <template #label>Note Design</template> - <option value="sharkey"><i class="sk-icons sk-shark sk-icons-lg" style="top: 2px;position: relative;"></i> Sharkey</option> - <option value="misskey"><i class="sk-icons sk-misskey sk-icons-lg" style="top: 2px;position: relative;"></i> Misskey</option> - </MkRadios> - <MkSwitch v-model="limitWidthOfReaction">{{ i18n.ts.limitWidthOfReaction }}</MkSwitch> - </div> - - <MkSelect v-if="instance.federation !== 'none'" v-model="instanceTicker"> - <template #label>{{ i18n.ts.instanceTicker }}</template> - <option value="none">{{ i18n.ts._instanceTicker.none }}</option> - <option value="remote">{{ i18n.ts._instanceTicker.remote }}</option> - <option value="always">{{ i18n.ts._instanceTicker.always }}</option> - </MkSelect> - - <MkSelect v-model="nsfw"> - <template #label>{{ i18n.ts.displayOfSensitiveMedia }}</template> - <option value="respect">{{ i18n.ts._displayOfSensitiveMedia.respect }}</option> - <option value="ignore">{{ i18n.ts._displayOfSensitiveMedia.ignore }}</option> - <option value="force">{{ i18n.ts._displayOfSensitiveMedia.force }}</option> - </MkSelect> - - <MkRadios v-model="mediaListWithOneImageAppearance"> - <template #label>{{ i18n.ts.mediaListWithOneImageAppearance }}</template> - <option value="expand">{{ i18n.ts.default }}</option> - <option value="16_9">{{ i18n.tsx.limitTo({ x: '16:9' }) }}</option> - <option value="1_1">{{ i18n.tsx.limitTo({ x: '1:1' }) }}</option> - <option value="2_3">{{ i18n.tsx.limitTo({ x: '2:3' }) }}</option> - </MkRadios> - - <MkRange v-model="numberOfReplies" :min="2" :max="20" :step="1" easing> - <template #label>{{ i18n.ts.numberOfReplies }}</template> - <template #caption>{{ i18n.ts.numberOfRepliesDescription }}</template> - </MkRange> - </div> - </FormSection> - - <FormSection> - <template #label>{{ i18n.ts.notificationDisplay }}</template> - - <div class="_gaps_m"> - <MkSwitch v-model="useGroupedNotifications">{{ i18n.ts.useGroupedNotifications }}</MkSwitch> - - <MkSwitch v-model="enableFaviconNotificationDot"> - {{ i18n.ts.enableFaviconNotificationDot }} - <template #caption> - <I18n :src="i18n.ts.notificationDotNotWorkingAdvice" tag="span"> - <template #link> - <MkLink url="https://docs.joinsharkey.org/docs/install/faqs/#ive-enabled-the-notification-dot-but-it-doesnt-show">{{ i18n.ts._mfm.link }}</MkLink> - </template> - </I18n> - </template> - </MkSwitch> - - <MkButton @click="testNotificationDot">{{ i18n.ts.verifyNotificationDotWorkingButton }}</MkButton> - <MkRadios v-model="notificationPosition"> - <template #label>{{ i18n.ts.position }}</template> - <option value="leftTop"><i class="ti ti-align-box-left-top"></i> {{ i18n.ts.leftTop }}</option> - <option value="rightTop"><i class="ti ti-align-box-right-top"></i> {{ i18n.ts.rightTop }}</option> - <option value="leftBottom"><i class="ti ti-align-box-left-bottom"></i> {{ i18n.ts.leftBottom }}</option> - <option value="rightBottom"><i class="ti ti-align-box-right-bottom"></i> {{ i18n.ts.rightBottom }}</option> - </MkRadios> - - <MkRadios v-model="notificationStackAxis"> - <template #label>{{ i18n.ts.stackAxis }}</template> - <option value="vertical"><i class="ti ti-carousel-vertical"></i> {{ i18n.ts.vertical }}</option> - <option value="horizontal"><i class="ti ti-carousel-horizontal"></i> {{ i18n.ts.horizontal }}</option> - </MkRadios> - - <MkSwitch v-model="notificationClickable">{{ i18n.ts.allowClickingNotifications }}</MkSwitch> - - <MkButton @click="testNotification">{{ i18n.ts._notification.checkNotificationBehavior }}</MkButton> - </div> - </FormSection> - - <FormSection> - <template #label>{{ i18n.ts.appearance }}</template> - - <div class="_gaps_m"> - <div class="_gaps_s"> - <MkSwitch v-model="reduceAnimation">{{ i18n.ts.reduceUiAnimation }}</MkSwitch> - <MkSwitch v-model="useBlurEffect">{{ i18n.ts.useBlurEffect }}</MkSwitch> - <MkSwitch v-model="useBlurEffectForModal">{{ i18n.ts.useBlurEffectForModal }}</MkSwitch> - <MkSwitch v-model="disableShowingAnimatedImages">{{ i18n.ts.disableShowingAnimatedImages }}</MkSwitch> - <MkSwitch v-model="highlightSensitiveMedia">{{ i18n.ts.highlightSensitiveMedia }}</MkSwitch> - <MkSwitch v-model="squareAvatars">{{ i18n.ts.squareAvatars }}</MkSwitch> - <MkSwitch v-model="showAvatarDecorations">{{ i18n.ts.showAvatarDecorations }}</MkSwitch> - <MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch> - <MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch> - <MkSwitch v-model="oneko">{{ i18n.ts.oneko }}</MkSwitch> - <MkSwitch v-model="enableSeasonalScreenEffect">{{ i18n.ts.seasonalScreenEffect }}</MkSwitch> - <MkSwitch v-model="useNativeUIForVideoAudioPlayer">{{ i18n.ts.useNativeUIForVideoAudioPlayer }}</MkSwitch> - </div> - - <MkSelect v-model="menuStyle"> - <template #label>{{ i18n.ts.menuStyle }}</template> - <option value="auto">{{ i18n.ts.auto }}</option> - <option value="popup">{{ i18n.ts.popup }}</option> - <option value="drawer">{{ i18n.ts.drawer }}</option> - </MkSelect> - - <div> - <MkRadios v-model="emojiStyle"> - <template #label>{{ i18n.ts.emojiStyle }}</template> - <option value="native">{{ i18n.ts.native }}</option> - <option value="fluentEmoji">Fluent Emoji</option> - <option value="twemoji">Twemoji</option> - <option value="tossface">Tossface</option> - </MkRadios> - <div style="margin: 8px 0 0 0; font-size: 1.5em;"><Mfm :key="emojiStyle" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div> - </div> - - <MkRadios v-model="fontSize"> - <template #label>{{ i18n.ts.fontSize }}</template> - <option :value="null"><span style="font-size: 14px;">Aa</span></option> - <option value="1"><span style="font-size: 15px;">Aa</span></option> - <option value="2"><span style="font-size: 16px;">Aa</span></option> - <option value="3"><span style="font-size: 17px;">Aa</span></option> - </MkRadios> - - <MkRadios v-model="cornerRadius"> - <template #label>{{ i18n.ts.cornerRadius }}</template> - <option :value="null"><i class="sk-icons sk-shark sk-icons-lg" style="top: 2px;position: relative;"></i> Sharkey</option> - <option value="misskey"><i class="sk-icons sk-misskey sk-icons-lg" style="top: 2px;position: relative;"></i> Misskey</option> - </MkRadios> - </div> - </FormSection> - - <FormSection> - <template #label>{{ i18n.ts.behavior }}</template> - - <div class="_gaps_m"> - <div class="_gaps_s"> - <MkSwitch v-model="warnMissingAltText">{{ i18n.ts.warnForMissingAltText }}</MkSwitch> - <MkSwitch v-model="imageNewTab">{{ i18n.ts.openImageInNewTab }}</MkSwitch> - <MkSwitch v-model="useReactionPickerForContextMenu">{{ i18n.ts.useReactionPickerForContextMenu }}</MkSwitch> - <MkSwitch v-model="enableInfiniteScroll">{{ i18n.ts.enableInfiniteScroll }}</MkSwitch> - <MkSwitch v-model="keepScreenOn">{{ i18n.ts.keepScreenOn }}</MkSwitch> - <MkSwitch v-model="clickToOpen">{{ i18n.ts.clickToOpen }}</MkSwitch> - <MkSwitch v-model="disableStreamingTimeline">{{ i18n.ts.disableStreamingTimeline }}</MkSwitch> - <MkSwitch v-model="enableHorizontalSwipe">{{ i18n.ts.enableHorizontalSwipe }}</MkSwitch> - <MkSwitch v-model="alwaysConfirmFollow">{{ i18n.ts.alwaysConfirmFollow }}</MkSwitch> - <MkSwitch v-model="confirmWhenRevealingSensitiveMedia">{{ i18n.ts.confirmWhenRevealingSensitiveMedia }}</MkSwitch> - <MkSwitch v-model="warnExternalUrl">{{ i18n.ts.warnExternalUrl }}</MkSwitch> - </div> - <MkSelect v-model="serverDisconnectedBehavior"> - <template #label>{{ i18n.ts.whenServerDisconnected }}</template> - <option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option> - <option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option> - <option value="disabled">{{ i18n.ts._serverDisconnectedBehavior.disabled }}</option> - </MkSelect> - <MkSelect v-model="contextMenu"> - <template #label>{{ i18n.ts._contextMenu.title }}</template> - <option value="app">{{ i18n.ts._contextMenu.app }}</option> - <option value="appWithShift">{{ i18n.ts._contextMenu.appWithShift }}</option> - <option value="native">{{ i18n.ts._contextMenu.native }}</option> - </MkSelect> - <MkRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing> - <template #label>{{ i18n.ts.numberOfPageCache }}</template> - <template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template> - </MkRange> - - <MkFolder> - <template #label>{{ i18n.ts.boostSettings }}</template> - <div class="_gaps_m"> - <MkSwitch v-model="showVisibilitySelectorOnBoost"> - {{ i18n.ts.showVisibilitySelectorOnBoost }} - <template #caption>{{ i18n.ts.showVisibilitySelectorOnBoostDescription }}</template> - </MkSwitch> - <MkSelect v-model="visibilityOnBoost"> - <template #label>{{ i18n.ts.visibilityOnBoost }}</template> - <option value="public">{{ i18n.ts._visibility['public'] }}</option> - <option value="home">{{ i18n.ts._visibility['home'] }}</option> - <option value="followers">{{ i18n.ts._visibility['followers'] }}</option> - </MkSelect> - </div> - </MkFolder> - - <MkFolder> - <template #label>{{ i18n.ts.dataSaver }}</template> - - <div class="_gaps_m"> - <MkInfo>{{ i18n.ts.reloadRequiredToApplySettings }}</MkInfo> - - <div class="_buttons"> - <MkButton inline @click="enableAllDataSaver">{{ i18n.ts.enableAll }}</MkButton> - <MkButton inline @click="disableAllDataSaver">{{ i18n.ts.disableAll }}</MkButton> - </div> - <div class="_gaps_m"> - <MkSwitch v-model="dataSaver.media"> - {{ i18n.ts._dataSaver._media.title }} - <template #caption>{{ i18n.ts._dataSaver._media.description }}</template> - </MkSwitch> - <MkSwitch v-model="dataSaver.avatar"> - {{ i18n.ts._dataSaver._avatar.title }} - <template #caption>{{ i18n.ts._dataSaver._avatar.description }}</template> - </MkSwitch> - <MkSwitch v-model="dataSaver.urlPreview"> - {{ i18n.ts._dataSaver._urlPreview.title }} - <template #caption>{{ i18n.ts._dataSaver._urlPreview.description }}</template> - </MkSwitch> - <MkSwitch v-model="dataSaver.code"> - {{ i18n.ts._dataSaver._code.title }} - <template #caption>{{ i18n.ts._dataSaver._code.description }}</template> - </MkSwitch> - </div> - </div> - </MkFolder> - </div> - </FormSection> - - <FormSection> - <template #label>{{ i18n.ts.other }}</template> - - <div class="_gaps"> - <MkRadios v-model="hemisphere"> - <template #label>{{ i18n.ts.hemisphere }}</template> - <option value="N">{{ i18n.ts._hemisphere.N }}</option> - <option value="S">{{ i18n.ts._hemisphere.S }}</option> - <template #caption>{{ i18n.ts._hemisphere.caption }}</template> - </MkRadios> - <MkFolder> - <template #label>{{ i18n.ts.additionalEmojiDictionary }}</template> - <div class="_buttons"> - <template v-for="lang in emojiIndexLangs" :key="lang"> - <MkButton v-if="defaultStore.reactiveState.additionalUnicodeEmojiIndexes.value[lang]" danger @click="removeEmojiIndex(lang)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }} ({{ getEmojiIndexLangName(lang) }})</MkButton> - <MkButton v-else @click="downloadEmojiIndex(lang)"><i class="ti ti-download"></i> {{ getEmojiIndexLangName(lang) }}{{ defaultStore.reactiveState.additionalUnicodeEmojiIndexes.value[lang] ? ` (${ i18n.ts.installed })` : '' }}</MkButton> - </template> - </div> - </MkFolder> - <FormLink to="/settings/deck">{{ i18n.ts.deck }}</FormLink> - <FormLink to="/settings/custom-css"><template #icon><i class="ti ti-code"></i></template>{{ i18n.ts.customCss }}</FormLink> - </div> - </FormSection> -</div> -</template> - -<script lang="ts" setup> -import { computed, ref, watch } from 'vue'; -import * as Misskey from 'misskey-js'; -import { langs } from '@@/js/config.js'; -import MkSwitch from '@/components/MkSwitch.vue'; -import MkSelect from '@/components/MkSelect.vue'; -import MkRadios from '@/components/MkRadios.vue'; -import MkInput from '@/components/MkInput.vue'; -import MkRange from '@/components/MkRange.vue'; -import MkFolder from '@/components/MkFolder.vue'; -import MkButton from '@/components/MkButton.vue'; -import FormSection from '@/components/form/section.vue'; -import FormLink from '@/components/form/link.vue'; -import MkLink from '@/components/MkLink.vue'; -import MkInfo from '@/components/MkInfo.vue'; -import { searchEngineMap } from '@/scripts/search-engine-map.js'; -import { defaultStore } from '@/store.js'; -import * as os from '@/os.js'; -import { instance } from '@/instance.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { reloadAsk } from '@/scripts/reload-ask.js'; -import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { miLocalStorage } from '@/local-storage.js'; -import { globalEvents } from '@/events.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { worksOnInstance } from '@/scripts/favicon-dot.js'; - -const lang = ref(miLocalStorage.getItem('lang')); -const fontSize = ref(miLocalStorage.getItem('fontSize')); -const cornerRadius = ref(miLocalStorage.getItem('cornerRadius')); -const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null); -const dataSaver = ref(defaultStore.state.dataSaver); - -const hemisphere = computed(defaultStore.makeGetterSetter('hemisphere')); -const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind')); -const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior')); -const showNoteActionsOnlyHover = computed(defaultStore.makeGetterSetter('showNoteActionsOnlyHover')); -const showClipButtonInNoteFooter = computed(defaultStore.makeGetterSetter('showClipButtonInNoteFooter')); -const reactionsDisplaySize = computed(defaultStore.makeGetterSetter('reactionsDisplaySize')); -const limitWidthOfReaction = computed(defaultStore.makeGetterSetter('limitWidthOfReaction')); -const collapseRenotes = computed(defaultStore.makeGetterSetter('collapseRenotes')); -const collapseNotesRepliedTo = computed(defaultStore.makeGetterSetter('collapseNotesRepliedTo')); -const clickToOpen = computed(defaultStore.makeGetterSetter('clickToOpen')); -const collapseFiles = computed(defaultStore.makeGetterSetter('collapseFiles')); -const autoloadConversation = computed(defaultStore.makeGetterSetter('autoloadConversation')); -const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v)); -const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal')); -const useBlurEffect = computed(defaultStore.makeGetterSetter('useBlurEffect')); -const showGapBetweenNotesInTimeline = computed(defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline')); -const animatedMfm = computed(defaultStore.makeGetterSetter('animatedMfm')); -const advancedMfm = computed(defaultStore.makeGetterSetter('advancedMfm')); -const showReactionsCount = computed(defaultStore.makeGetterSetter('showReactionsCount')); -const enableQuickAddMfmFunction = computed(defaultStore.makeGetterSetter('enableQuickAddMfmFunction')); -const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle')); -const menuStyle = computed(defaultStore.makeGetterSetter('menuStyle')); -const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages')); -const forceShowAds = computed(defaultStore.makeGetterSetter('forceShowAds')); -const oneko = computed(defaultStore.makeGetterSetter('oneko')); -const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages')); -const disableCatSpeak = computed(defaultStore.makeGetterSetter('disableCatSpeak')); -const highlightSensitiveMedia = computed(defaultStore.makeGetterSetter('highlightSensitiveMedia')); -const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab')); -const enableFaviconNotificationDot = computed(defaultStore.makeGetterSetter('enableFaviconNotificationDot')); -const warnMissingAltText = computed(defaultStore.makeGetterSetter('warnMissingAltText')); -const nsfw = computed(defaultStore.makeGetterSetter('nsfw')); -const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm')); -const showFixedPostFormInChannel = computed(defaultStore.makeGetterSetter('showFixedPostFormInChannel')); -const numberOfPageCache = computed(defaultStore.makeGetterSetter('numberOfPageCache')); -const numberOfReplies = computed(defaultStore.makeGetterSetter('numberOfReplies')); -const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker')); -const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll')); -const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu')); -const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars')); -const showAvatarDecorations = computed(defaultStore.makeGetterSetter('showAvatarDecorations')); -const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter('mediaListWithOneImageAppearance')); -const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition')); -const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis')); -const notificationClickable = computed(defaultStore.makeGetterSetter('notificationClickable')); -const keepScreenOn = computed(defaultStore.makeGetterSetter('keepScreenOn')); -const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline')); -const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications')); -const showTickerOnReplies = computed(defaultStore.makeGetterSetter('showTickerOnReplies')); -const searchEngine = computed(defaultStore.makeGetterSetter('searchEngine')); - -const noteDesign = computed(defaultStore.makeGetterSetter('noteDesign')); -const uncollapseCW = computed(defaultStore.makeGetterSetter('uncollapseCW')); -const expandLongNote = computed(defaultStore.makeGetterSetter('expandLongNote')); -const enableSeasonalScreenEffect = computed(defaultStore.makeGetterSetter('enableSeasonalScreenEffect')); -const showVisibilitySelectorOnBoost = computed(defaultStore.makeGetterSetter('showVisibilitySelectorOnBoost')); -const visibilityOnBoost = computed(defaultStore.makeGetterSetter('visibilityOnBoost')); -const enableHorizontalSwipe = computed(defaultStore.makeGetterSetter('enableHorizontalSwipe')); -const useNativeUIForVideoAudioPlayer = computed(defaultStore.makeGetterSetter('useNativeUIForVideoAudioPlayer')); -const alwaysConfirmFollow = computed(defaultStore.makeGetterSetter('alwaysConfirmFollow')); -const confirmWhenRevealingSensitiveMedia = computed(defaultStore.makeGetterSetter('confirmWhenRevealingSensitiveMedia')); -const contextMenu = computed(defaultStore.makeGetterSetter('contextMenu')); -const warnExternalUrl = computed(defaultStore.makeGetterSetter('warnExternalUrl')); - -watch(lang, () => { - miLocalStorage.setItem('lang', lang.value as string); - miLocalStorage.removeItem('locale'); - miLocalStorage.removeItem('localeVersion'); -}); - -watch(fontSize, () => { - if (fontSize.value == null) { - miLocalStorage.removeItem('fontSize'); - } else { - miLocalStorage.setItem('fontSize', fontSize.value); - } -}); - -watch(cornerRadius, () => { - if (cornerRadius.value == null) { - miLocalStorage.removeItem('cornerRadius'); - } else { - miLocalStorage.setItem('cornerRadius', cornerRadius.value); - } -}); - -watch(useSystemFont, () => { - if (useSystemFont.value) { - miLocalStorage.setItem('useSystemFont', 't'); - } else { - miLocalStorage.removeItem('useSystemFont'); - } -}); - -watch(noteDesign, async (newval) => { - if (noteDesign.value === newval) { - await reloadAsk({}); - } -}); - -watch([ - hemisphere, - lang, - fontSize, - cornerRadius, - useSystemFont, - enableInfiniteScroll, - squareAvatars, - showNoteActionsOnlyHover, - showGapBetweenNotesInTimeline, - instanceTicker, - overridedDeviceKind, - mediaListWithOneImageAppearance, - reactionsDisplaySize, - limitWidthOfReaction, - highlightSensitiveMedia, - keepScreenOn, - disableStreamingTimeline, - enableSeasonalScreenEffect, - showVisibilitySelectorOnBoost, - visibilityOnBoost, - alwaysConfirmFollow, - confirmWhenRevealingSensitiveMedia, - contextMenu, - warnExternalUrl, -], async () => { - await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); -}); - -const emojiIndexLangs = ['en-US', 'ja-JP', 'ja-JP_hira'] as const; - -function getEmojiIndexLangName(targetLang: typeof emojiIndexLangs[number]) { - if (langs.find(x => x[0] === targetLang)) { - return langs.find(x => x[0] === targetLang)![1]; - } else { - // 絵文字辞書限定の言語定義 - switch (targetLang) { - case 'ja-JP_hira': return 'ひらがな'; - default: return targetLang; - } - } -} - -function downloadEmojiIndex(lang: typeof emojiIndexLangs[number]) { - async function main() { - const currentIndexes = defaultStore.state.additionalUnicodeEmojiIndexes; - - function download() { - switch (lang) { - case 'en-US': return import('../../unicode-emoji-indexes/en-US.json').then(x => x.default); - case 'ja-JP': return import('../../unicode-emoji-indexes/ja-JP.json').then(x => x.default); - case 'ja-JP_hira': return import('../../unicode-emoji-indexes/ja-JP_hira.json').then(x => x.default); - default: throw new Error('unrecognized lang: ' + lang); - } - } - - currentIndexes[lang] = await download(); - await defaultStore.set('additionalUnicodeEmojiIndexes', currentIndexes); - } - - os.promiseDialog(main()); -} - -function removeEmojiIndex(lang: string) { - async function main() { - const currentIndexes = defaultStore.state.additionalUnicodeEmojiIndexes; - delete currentIndexes[lang]; - await defaultStore.set('additionalUnicodeEmojiIndexes', currentIndexes); - } - - os.promiseDialog(main()); -} - -async function setPinnedList() { - const lists = await misskeyApi('users/lists/list'); - const { canceled, result: list } = await os.select({ - title: i18n.ts.selectList, - items: lists.map(x => ({ - value: x, text: x.name, - })), - }); - if (canceled) return; - - defaultStore.set('pinnedUserLists', [list]); -} - -function removePinnedList() { - defaultStore.set('pinnedUserLists', []); -} - -let smashCount = 0; -let smashTimer: number | null = null; - -function testNotification(): void { - const notification: Misskey.entities.Notification = { - id: Math.random().toString(), - createdAt: new Date().toUTCString(), - isRead: false, - type: 'test', - }; - - globalEvents.emit('clientNotification', notification); - - // セルフ通知破壊 実績関連 - smashCount++; - if (smashCount >= 10) { - claimAchievement('smashTestNotificationButton'); - smashCount = 0; - } - if (smashTimer) { - clearTimeout(smashTimer); - } - smashTimer = window.setTimeout(() => { - smashCount = 0; - }, 300); -} - -async function testNotificationDot() { - const success = await worksOnInstance(); - - if (success) { - os.toast(i18n.ts.notificationDotWorking); - } else { - os.toast(i18n.ts.notificationDotNotWorking); - } -} - -function enableAllDataSaver() { - const g = { ...defaultStore.state.dataSaver }; - - Object.keys(g).forEach((key) => { g[key] = true; }); - - dataSaver.value = g; -} - -function disableAllDataSaver() { - const g = { ...defaultStore.state.dataSaver }; - - Object.keys(g).forEach((key) => { g[key] = false; }); - - dataSaver.value = g; -} - -watch(dataSaver, (to) => { - defaultStore.set('dataSaver', to); -}, { - deep: true, -}); - -const headerActions = computed(() => []); - -const headerTabs = computed(() => []); - -definePageMetadata(() => ({ - title: i18n.ts.general, - icon: 'ti ti-adjustments', -})); - -const useCustomSearchEngine = computed(() => !Object.keys(searchEngineMap).includes(searchEngine.value)); -</script> diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue deleted file mode 100644 index e000c608fe..0000000000 --- a/packages/frontend/src/pages/settings/import-export.vue +++ /dev/null @@ -1,263 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div class="_gaps_m"> - <FormSection first> - <template #label><i class="ti ti-pencil"></i> {{ i18n.ts._exportOrImport.allNotes }}</template> - <div class="_gaps_s"> - <MkFolder> - <template #label>{{ i18n.ts.export }}</template> - <template #icon><i class="ti ti-download"></i></template> - <MkButton primary :class="$style.button" inline @click="exportNotes()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> - </MkFolder> - <MkFolder v-if="$i && $i.policies.canImportNotes"> - <template #label>{{ i18n.ts.import }}</template> - <template #icon><i class="ph-upload ph-bold ph-lg"></i></template> - <MkRadios v-model="noteType" style="padding-bottom: 8px;" small> - <template #label>Origin</template> - <option value="Misskey">Misskey/Firefish</option> - <option value="Mastodon">Mastodon/Pleroma/Akkoma</option> - <option value="Twitter">Twitter</option> - <option value="Instagram">Instagram</option> - <option value="Facebook">Facebook</option> - </MkRadios> - <MkButton primary :class="$style.button" inline @click="importNotes($event)"><i class="ph-upload ph-bold ph-lg"></i> {{ i18n.ts.import }}</MkButton> - </MkFolder> - </div> - </FormSection> - <FormSection> - <template #label><i class="ti ti-star"></i> {{ i18n.ts._exportOrImport.favoritedNotes }}</template> - <MkFolder> - <template #label>{{ i18n.ts.export }}</template> - <template #icon><i class="ti ti-download"></i></template> - <MkButton primary :class="$style.button" inline @click="exportFavorites()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> - </MkFolder> - </FormSection> - <FormSection> - <template #label><i class="ph-paperclip ph-bold ph-lg"></i> {{ i18n.ts._exportOrImport.clips }}</template> - <MkFolder> - <template #label>{{ i18n.ts.export }}</template> - <template #icon><i class="ti ti-download"></i></template> - <MkButton primary :class="$style.button" inline @click="exportClips()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> - </MkFolder> - </FormSection> - <FormSection> - <template #label><i class="ti ti-users"></i> {{ i18n.ts._exportOrImport.followingList }}</template> - <div class="_gaps_s"> - <MkFolder> - <template #label>{{ i18n.ts.export }}</template> - <template #icon><i class="ti ti-download"></i></template> - <div class="_gaps_s"> - <MkSwitch v-model="excludeMutingUsers"> - {{ i18n.ts._exportOrImport.excludeMutingUsers }} - </MkSwitch> - <MkSwitch v-model="excludeInactiveUsers"> - {{ i18n.ts._exportOrImport.excludeInactiveUsers }} - </MkSwitch> - <MkButton primary :class="$style.button" inline @click="exportFollowing()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> - </div> - </MkFolder> - <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportFollowing"> - <template #label>{{ i18n.ts.import }}</template> - <template #icon><i class="ti ti-upload"></i></template> - <MkSwitch v-model="withReplies"> - {{ i18n.ts._exportOrImport.withReplies }} - </MkSwitch> - <MkButton primary :class="$style.button" inline @click="importFollowing($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> - </MkFolder> - </div> - </FormSection> - <FormSection> - <template #label><i class="ti ti-users"></i> {{ i18n.ts._exportOrImport.userLists }}</template> - <div class="_gaps_s"> - <MkFolder> - <template #label>{{ i18n.ts.export }}</template> - <template #icon><i class="ti ti-download"></i></template> - <MkButton primary :class="$style.button" inline @click="exportUserLists()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> - </MkFolder> - <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportUserLists"> - <template #label>{{ i18n.ts.import }}</template> - <template #icon><i class="ti ti-upload"></i></template> - <MkButton primary :class="$style.button" inline @click="importUserLists($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> - </MkFolder> - </div> - </FormSection> - <FormSection> - <template #label><i class="ti ti-user-off"></i> {{ i18n.ts._exportOrImport.muteList }}</template> - <div class="_gaps_s"> - <MkFolder> - <template #label>{{ i18n.ts.export }}</template> - <template #icon><i class="ti ti-download"></i></template> - <MkButton primary :class="$style.button" inline @click="exportMuting()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> - </MkFolder> - <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportMuting"> - <template #label>{{ i18n.ts.import }}</template> - <template #icon><i class="ti ti-upload"></i></template> - <MkButton primary :class="$style.button" inline @click="importMuting($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> - </MkFolder> - </div> - </FormSection> - <FormSection> - <template #label><i class="ti ti-user-off"></i> {{ i18n.ts._exportOrImport.blockingList }}</template> - <div class="_gaps_s"> - <MkFolder> - <template #label>{{ i18n.ts.export }}</template> - <template #icon><i class="ti ti-download"></i></template> - <MkButton primary :class="$style.button" inline @click="exportBlocking()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> - </MkFolder> - <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportBlocking"> - <template #label>{{ i18n.ts.import }}</template> - <template #icon><i class="ti ti-upload"></i></template> - <MkButton primary :class="$style.button" inline @click="importBlocking($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> - </MkFolder> - </div> - </FormSection> - <FormSection> - <template #label><i class="ti ti-antenna"></i> {{ i18n.ts.antennas }}</template> - <div class="_gaps_s"> - <MkFolder> - <template #label>{{ i18n.ts.export }}</template> - <template #icon><i class="ti ti-download"></i></template> - <MkButton primary :class="$style.button" inline @click="exportAntennas()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> - </MkFolder> - <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportAntennas"> - <template #label>{{ i18n.ts.import }}</template> - <template #icon><i class="ti ti-upload"></i></template> - <MkButton primary :class="$style.button" inline @click="importAntennas($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> - </MkFolder> - </div> - </FormSection> -</div> -</template> - -<script lang="ts" setup> -import { ref, computed } from 'vue'; -import MkButton from '@/components/MkButton.vue'; -import FormSection from '@/components/form/section.vue'; -import MkFolder from '@/components/MkFolder.vue'; -import MkSwitch from '@/components/MkSwitch.vue'; -import MkRadios from '@/components/MkRadios.vue'; -import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { selectFile } from '@/scripts/select-file.js'; -import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { $i } from '@/account.js'; -import { defaultStore } from '@/store.js'; - -const excludeMutingUsers = ref(false); -const excludeInactiveUsers = ref(false); -const noteType = ref(null); -const withReplies = ref(defaultStore.state.defaultWithReplies); - -const onExportSuccess = () => { - os.alert({ - type: 'info', - text: i18n.ts.exportRequested, - }); -}; - -const onImportSuccess = () => { - os.alert({ - type: 'info', - text: i18n.ts.importRequested, - }); -}; - -const onError = (ev) => { - os.alert({ - type: 'error', - text: ev.message, - }); -}; - -const exportNotes = () => { - misskeyApi('i/export-notes', {}).then(onExportSuccess).catch(onError); -}; - -const exportFavorites = () => { - misskeyApi('i/export-favorites', {}).then(onExportSuccess).catch(onError); -}; - -const exportClips = () => { - misskeyApi('i/export-clips', {}).then(onExportSuccess).catch(onError); -}; - -const exportFollowing = () => { - misskeyApi('i/export-following', { - excludeMuting: excludeMutingUsers.value, - excludeInactive: excludeInactiveUsers.value, - }) - .then(onExportSuccess).catch(onError); -}; - -const exportBlocking = () => { - misskeyApi('i/export-blocking', {}).then(onExportSuccess).catch(onError); -}; - -const exportUserLists = () => { - misskeyApi('i/export-user-lists', {}).then(onExportSuccess).catch(onError); -}; - -const exportMuting = () => { - misskeyApi('i/export-mute', {}).then(onExportSuccess).catch(onError); -}; - -const exportAntennas = () => { - misskeyApi('i/export-antennas', {}).then(onExportSuccess).catch(onError); -}; - -const importFollowing = async (ev) => { - const file = await selectFile(ev.currentTarget ?? ev.target); - misskeyApi('i/import-following', { - fileId: file.id, - withReplies: withReplies.value, - }).then(onImportSuccess).catch(onError); -}; - -const importNotes = async (ev) => { - const file = await selectFile(ev.currentTarget ?? ev.target); - misskeyApi('i/import-notes', { - fileId: file.id, - type: noteType.value, - }).then(onImportSuccess).catch(onError); -}; - -const importUserLists = async (ev) => { - const file = await selectFile(ev.currentTarget ?? ev.target); - misskeyApi('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError); -}; - -const importMuting = async (ev) => { - const file = await selectFile(ev.currentTarget ?? ev.target); - misskeyApi('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError); -}; - -const importBlocking = async (ev) => { - const file = await selectFile(ev.currentTarget ?? ev.target); - misskeyApi('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError); -}; - -const importAntennas = async (ev) => { - const file = await selectFile(ev.currentTarget ?? ev.target); - misskeyApi('i/import-antennas', { fileId: file.id }).then(onImportSuccess).catch(onError); -}; - -const headerActions = computed(() => []); - -const headerTabs = computed(() => []); - -definePageMetadata(() => ({ - title: i18n.ts.importAndExport, - icon: 'ti ti-package', -})); -</script> - -<style module> -.button { - margin-right: 16px; -} -</style> diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index b7bf8c5dc1..a1e1460da1 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -4,39 +4,50 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :tabs="headerTabs" :actions="headerActions"> <MkSpacer :contentMax="900" :marginMin="20" :marginMax="32"> <div ref="el" class="vvcocwet" :class="{ wide: !narrow }"> <div class="body"> <div v-if="!narrow || currentPage?.route.name == null" class="nav"> - <div class="baaadecd"> + <div class="_gaps_s"> <MkInfo v-if="emailNotConfigured" warn class="info">{{ i18n.ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> - <MkSuperMenu :def="menuDef" :grid="narrow"></MkSuperMenu> + <MkInfo v-if="!store.r.enablePreferencesAutoCloudBackup.value && store.r.showPreferencesAutoCloudBackupSuggestion.value" class="info"> + <div>{{ i18n.ts._preferencesBackup.autoPreferencesBackupIsNotEnabledForThisDevice }}</div> + <div><button class="_textButton" @click="enableAutoBackup">{{ i18n.ts.enable }}</button> | <button class="_textButton" @click="skipAutoBackup">{{ i18n.ts.skip }}</button></div> + </MkInfo> + <MkSuperMenu :def="menuDef" :grid="narrow" :searchIndex="SETTING_INDEX"></MkSuperMenu> </div> </div> <div v-if="!(narrow && currentPage?.route.name == null)" class="main"> - <div class="bkzroven" style="container-type: inline-size;"> - <RouterView nested/> + <div style="container-type: inline-size;"> + <NestedRouterView/> </div> </div> </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script setup lang="ts"> -import { computed, onActivated, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'; +import { computed, onActivated, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'; +import type { PageMetadata } from '@/page.js'; +import type { SuperMenuDef } from '@/components/MkSuperMenu.vue'; import { i18n } from '@/i18n.js'; import MkInfo from '@/components/MkInfo.vue'; import MkSuperMenu from '@/components/MkSuperMenu.vue'; -import { signout, $i } from '@/account.js'; -import { clearCache } from '@/scripts/clear-cache.js'; +import { $i } from '@/i.js'; +import { clearCache } from '@/utility/clear-cache.js'; import { instance } from '@/instance.js'; -import { PageMetadata, definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; +import { definePage, provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; import * as os from '@/os.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; +import { searchIndexes } from '@/utility/autogen/settings-search-index.js'; +import { enableAutoBackup, getPreferencesProfileMenu } from '@/preferences/utility.js'; +import { store } from '@/store.js'; +import { signout } from '@/signout.js'; + +const SETTING_INDEX = searchIndexes; // TODO: lazy load const indexInfo = { title: i18n.ts.settings, @@ -44,7 +55,7 @@ const indexInfo = { hideHeader: true, }; const INFO = ref<PageMetadata>(indexInfo); -const el = shallowRef<HTMLElement | null>(null); +const el = useTemplateRef('el'); const childInfo = ref<null | PageMetadata>(null); const router = useRouter(); @@ -59,8 +70,11 @@ const ro = new ResizeObserver((entries, observer) => { narrow.value = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD; }); -const menuDef = computed(() => [{ - title: i18n.ts.basicSettings, +function skipAutoBackup() { + store.set('showPreferencesAutoCloudBackupSuggestion', false); +} + +const menuDef = computed<SuperMenuDef[]>(() => [{ items: [{ icon: 'ti ti-user', text: i18n.ts.profile, @@ -72,16 +86,6 @@ const menuDef = computed(() => [{ to: '/settings/privacy', active: currentPage.value?.route.name === 'privacy', }, { - icon: 'ti ti-mood-happy', - text: i18n.ts.emojiPicker, - to: '/settings/emoji-picker', - active: currentPage.value?.route.name === 'emojiPicker', - }, { - icon: 'ti ti-cloud', - text: i18n.ts.drive, - to: '/settings/drive', - active: currentPage.value?.route.name === 'drive', - }, { icon: 'ti ti-bell', text: i18n.ts.notifications, to: '/settings/notifications', @@ -98,70 +102,58 @@ const menuDef = computed(() => [{ active: currentPage.value?.route.name === 'security', }], }, { - title: i18n.ts.clientSettings, items: [{ icon: 'ti ti-adjustments', - text: i18n.ts.general, - to: '/settings/general', - active: currentPage.value?.route.name === 'general', + text: i18n.ts.preferences, + to: '/settings/preferences', + active: currentPage.value?.route.name === 'preferences', }, { icon: 'ti ti-palette', text: i18n.ts.theme, to: '/settings/theme', active: currentPage.value?.route.name === 'theme', }, { - icon: 'ti ti-menu-2', - text: i18n.ts.navbar, - to: '/settings/navbar', - active: currentPage.value?.route.name === 'navbar', - }, { - icon: 'ti ti-equal-double', - text: i18n.ts.statusbar, - to: '/settings/statusbar', - active: currentPage.value?.route.name === 'statusbar', + icon: 'ti ti-mood-happy', + text: i18n.ts.emojiPalette, + to: '/settings/emoji-palette', + active: currentPage.value?.route.name === 'emoji-palette', }, { icon: 'ti ti-music', text: i18n.ts.sounds, to: '/settings/sounds', active: currentPage.value?.route.name === 'sounds', }, { + icon: 'ti ti-accessible', + text: i18n.ts.accessibility, + to: '/settings/accessibility', + active: currentPage.value?.route.name === 'accessibility', + }, { icon: 'ti ti-plug', text: i18n.ts.plugins, to: '/settings/plugin', active: currentPage.value?.route.name === 'plugin', }], }, { - title: i18n.ts.otherSettings, items: [{ - icon: 'ti ti-badges', - text: i18n.ts.roles, - to: '/settings/roles', - active: currentPage.value?.route.name === 'roles', + icon: 'ti ti-cloud', + text: i18n.ts.drive, + to: '/settings/drive', + active: currentPage.value?.route.name === 'drive', }, { icon: 'ti ti-ban', text: i18n.ts.muteAndBlock, to: '/settings/mute-block', active: currentPage.value?.route.name === 'mute-block', }, { - icon: 'ti ti-api', - text: 'API', - to: '/settings/api', - active: currentPage.value?.route.name === 'api', - }, { - icon: 'ti ti-webhook', - text: 'Webhook', - to: '/settings/webhook', - active: currentPage.value?.route.name === 'webhook', + icon: 'ti ti-link', + text: i18n.ts._settings.serviceConnection, + to: '/settings/connect', + active: currentPage.value?.route.name === 'connect', }, { icon: 'ti ti-package', - text: i18n.ts.importAndExport, - to: '/settings/import-export', - active: currentPage.value?.route.name === 'import-export', - }, { - icon: 'ti ti-plane', - text: `${i18n.ts.accountMigration}`, - to: '/settings/migration', - active: currentPage.value?.route.name === 'migration', + text: i18n.ts._settings.accountData, + to: '/settings/account-data', + active: currentPage.value?.route.name === 'account-data', }, { icon: 'ti ti-dots', text: i18n.ts.other, @@ -170,10 +162,12 @@ const menuDef = computed(() => [{ }], }, { items: [{ - icon: 'ti ti-device-floppy', - text: i18n.ts.preferencesBackups, - to: '/settings/preferences-backups', - active: currentPage.value?.route.name === 'preferences-backups', + type: 'button', + icon: 'ti ti-settings-2', + text: i18n.ts.preferencesProfile, + action: async (ev: MouseEvent) => { + os.popupMenu(getPreferencesProfileMenu(), ev.currentTarget ?? ev.target); + }, }, { type: 'button', icon: 'ti ti-trash', @@ -242,37 +236,13 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => INFO.value); +definePage(() => INFO.value); // w 890 // h 700 </script> <style lang="scss" scoped> .vvcocwet { - > .body { - > .nav { - .baaadecd { - > .info { - margin: 16px 0; - } - - > .accounts { - > .avatar { - display: block; - width: 50px; - height: 50px; - margin: 8px auto 16px auto; - } - } - } - } - - > .main { - .bkzroven { - } - } - } - &.wide { > .body { display: flex; diff --git a/packages/frontend/src/pages/settings/migration.vue b/packages/frontend/src/pages/settings/migration.vue index ddc23945dd..902db9116c 100644 --- a/packages/frontend/src/pages/settings/migration.vue +++ b/packages/frontend/src/pages/settings/migration.vue @@ -66,13 +66,12 @@ import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkUserInfo from '@/components/MkUserInfo.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { signinRequired } from '@/account.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; +import { ensureSignin } from '@/i.js'; +import { unisonReload } from '@/utility/unison-reload.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const moveToAccount = ref(''); const movedTo = ref<Misskey.entities.UserDetailed>(); @@ -120,11 +119,6 @@ async function save(): Promise<void> { } init(); - -definePageMetadata(() => ({ - title: i18n.ts.accountMigration, - icon: 'ti ti-plane', -})); </script> <style lang="scss"> diff --git a/packages/frontend/src/pages/settings/mute-block.instance-mute.vue b/packages/frontend/src/pages/settings/mute-block.instance-mute.vue index d1fde2fc1c..a0a40e4c72 100644 --- a/packages/frontend/src/pages/settings/mute-block.instance-mute.vue +++ b/packages/frontend/src/pages/settings/mute-block.instance-mute.vue @@ -19,11 +19,11 @@ import { ref, watch } from 'vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkButton from '@/components/MkButton.vue'; -import { signinRequired } from '@/account.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { ensureSignin } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const instanceMutes = ref($i.mutedInstances.join('\n')); const changed = ref(false); diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index d6ee45e074..4e8e99bd9a 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -4,132 +4,177 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_gaps_m"> - <MkFolder> - <template #icon><i class="ph-envelope ph-bold ph-lg"></i></template> - <template #label>{{ i18n.ts.wordMute }}</template> +<SearchMarker path="/settings/mute-block" :label="i18n.ts.muteAndBlock" icon="ti ti-ban" :keywords="['mute', 'block']"> + <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/prohibited_3d.png" color="#ff2600"> + <SearchKeyword>{{ i18n.ts._settings.muteAndBlockBanner }}</SearchKeyword> + </MkFeatureBanner> - <div class="_gaps_m"> - <MkInfo>{{ i18n.ts.wordMuteDescription }}</MkInfo> - <MkSwitch v-model="showSoftWordMutedWord">{{ i18n.ts.showMutedWord }}</MkSwitch> - <XWordMute :muted="$i.mutedWords" @save="saveMutedWords"/> - </div> - </MkFolder> + <div class="_gaps_s"> + <SearchMarker + :label="i18n.ts.wordMute" + :keywords="['note', 'word', 'soft', 'mute', 'hide']" + > + <MkFolder> + <template #icon><i class="ph-envelope ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts.wordMute }}</template> - <MkFolder> - <template #icon><i class="ph-x-square ph-bold ph-lg"></i></template> - <template #label>{{ i18n.ts.hardWordMute }}</template> + <div class="_gaps_m"> + <MkInfo>{{ i18n.ts.wordMuteDescription }}</MkInfo> - <div class="_gaps_m"> - <MkInfo>{{ i18n.ts.hardWordMuteDescription }}</MkInfo> - <XWordMute :muted="$i.hardMutedWords" @save="saveHardMutedWords"/> - </div> - </MkFolder> + <SearchMarker + :label="i18n.ts.showMutedWord" + :keywords="['show']" + > + <MkSwitch v-model="showSoftWordMutedWord">{{ i18n.ts.showMutedWord }}</MkSwitch> + </SearchMarker> - <MkFolder v-if="instance.federation !== 'none'"> - <template #icon><i class="ti ti-planet-off"></i></template> - <template #label>{{ i18n.ts.instanceMute }}</template> + <XWordMute :muted="$i.mutedWords" @save="saveMutedWords"/> + </div> + </MkFolder> + </SearchMarker> - <XInstanceMute/> - </MkFolder> + <SearchMarker + :label="i18n.ts.hardWordMute" + :keywords="['note', 'word', 'hard', 'mute', 'hide']" + > + <MkFolder> + <template #icon><i class="ph-x-square ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts.hardWordMute }}</template> - <MkFolder> - <template #icon><i class="ti ti-repeat-off"></i></template> - <template #label>{{ i18n.ts.mutedUsers }} ({{ i18n.ts.renote }})</template> + <div class="_gaps_m"> + <MkInfo>{{ i18n.ts.hardWordMuteDescription }}</MkInfo> + <XWordMute :muted="$i.hardMutedWords" @save="saveHardMutedWords"/> + </div> + </MkFolder> + </SearchMarker> - <MkPagination :pagination="renoteMutingPagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> - <div>{{ i18n.ts.noUsers }}</div> - </div> - </template> + <SearchMarker + :label="i18n.ts.instanceMute" + :keywords="['note', 'server', 'instance', 'host', 'federation', 'mute', 'hide']" + > + <MkFolder v-if="instance.federation !== 'none'"> + <template #icon><i class="ti ti-planet-off"></i></template> + <template #label>{{ i18n.ts.instanceMute }}</template> - <template #default="{ items }"> - <div class="_gaps_s"> - <div v-for="item in items" :key="item.mutee.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedRenoteMuteItems.includes(item.id) }]"> - <div :class="$style.userItemMain"> - <MkA :class="$style.userItemMainBody" :to="userPage(item.mutee)"> - <MkUserCardMini :user="item.mutee"/> - </MkA> - <button class="_button" :class="$style.userToggle" @click="toggleRenoteMuteItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> - <button class="_button" :class="$style.remove" @click="unrenoteMute(item.mutee, $event)"><i class="ti ti-x"></i></button> - </div> - <div v-if="expandedRenoteMuteItems.includes(item.id)" :class="$style.userItemSub"> - <div>Muted at: <MkTime :time="item.createdAt" mode="detail"/></div> - </div> - </div> - </div> - </template> - </MkPagination> - </MkFolder> + <XInstanceMute/> + </MkFolder> + </SearchMarker> - <MkFolder> - <template #icon><i class="ti ti-eye-off"></i></template> - <template #label>{{ i18n.ts.mutedUsers }}</template> + <SearchMarker + :label="`${i18n.ts.mutedUsers} (${ i18n.ts.renote })`" + :keywords="['renote', 'mute', 'hide', 'user']" + > + <MkFolder> + <template #icon><i class="ti ti-repeat-off"></i></template> + <template #label>{{ i18n.ts.mutedUsers }} ({{ i18n.ts.renote }})</template> - <MkPagination :pagination="mutingPagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> - <div>{{ i18n.ts.noUsers }}</div> - </div> - </template> + <MkPagination :pagination="renoteMutingPagination"> + <template #empty> + <div class="_fullinfo"> + <img :src="infoImageUrl" draggable="false"/> + <div>{{ i18n.ts.noUsers }}</div> + </div> + </template> - <template #default="{ items }"> - <div class="_gaps_s"> - <div v-for="item in items" :key="item.mutee.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedMuteItems.includes(item.id) }]"> - <div :class="$style.userItemMain"> - <MkA :class="$style.userItemMainBody" :to="userPage(item.mutee)"> - <MkUserCardMini :user="item.mutee"/> - </MkA> - <button class="_button" :class="$style.userToggle" @click="toggleMuteItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> - <button class="_button" :class="$style.remove" @click="unmute(item.mutee, $event)"><i class="ti ti-x"></i></button> - </div> - <div v-if="expandedMuteItems.includes(item.id)" :class="$style.userItemSub"> - <div>Muted at: <MkTime :time="item.createdAt" mode="detail"/></div> - <div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div> - <div v-else>Period: {{ i18n.ts.indefinitely }}</div> - </div> - </div> - </div> - </template> - </MkPagination> - </MkFolder> + <template #default="{ items }"> + <div class="_gaps_s"> + <div v-for="item in items" :key="item.mutee.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedRenoteMuteItems.includes(item.id) }]"> + <div :class="$style.userItemMain"> + <MkA :class="$style.userItemMainBody" :to="userPage(item.mutee)"> + <MkUserCardMini :user="item.mutee"/> + </MkA> + <button class="_button" :class="$style.userToggle" @click="toggleRenoteMuteItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> + <button class="_button" :class="$style.remove" @click="unrenoteMute(item.mutee, $event)"><i class="ti ti-x"></i></button> + </div> + <div v-if="expandedRenoteMuteItems.includes(item.id)" :class="$style.userItemSub"> + <div>Muted at: <MkTime :time="item.createdAt" mode="detail"/></div> + </div> + </div> + </div> + </template> + </MkPagination> + </MkFolder> + </SearchMarker> - <MkFolder> - <template #icon><i class="ti ti-ban"></i></template> - <template #label>{{ i18n.ts.blockedUsers }}</template> + <SearchMarker + :label="i18n.ts.mutedUsers" + :keywords="['note', 'mute', 'hide', 'user']" + > + <MkFolder> + <template #icon><i class="ti ti-eye-off"></i></template> + <template #label>{{ i18n.ts.mutedUsers }}</template> - <MkPagination :pagination="blockingPagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> - <div>{{ i18n.ts.noUsers }}</div> - </div> - </template> + <MkPagination :pagination="mutingPagination"> + <template #empty> + <div class="_fullinfo"> + <img :src="infoImageUrl" draggable="false"/> + <div>{{ i18n.ts.noUsers }}</div> + </div> + </template> - <template #default="{ items }"> - <div class="_gaps_s"> - <div v-for="item in items" :key="item.blockee.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedBlockItems.includes(item.id) }]"> - <div :class="$style.userItemMain"> - <MkA :class="$style.userItemMainBody" :to="userPage(item.blockee)"> - <MkUserCardMini :user="item.blockee"/> - </MkA> - <button class="_button" :class="$style.userToggle" @click="toggleBlockItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> - <button class="_button" :class="$style.remove" @click="unblock(item.blockee, $event)"><i class="ti ti-x"></i></button> - </div> - <div v-if="expandedBlockItems.includes(item.id)" :class="$style.userItemSub"> - <div>Blocked at: <MkTime :time="item.createdAt" mode="detail"/></div> - <div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div> - <div v-else>Period: {{ i18n.ts.indefinitely }}</div> - </div> - </div> - </div> - </template> - </MkPagination> - </MkFolder> -</div> + <template #default="{ items }"> + <div class="_gaps_s"> + <div v-for="item in items" :key="item.mutee.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedMuteItems.includes(item.id) }]"> + <div :class="$style.userItemMain"> + <MkA :class="$style.userItemMainBody" :to="userPage(item.mutee)"> + <MkUserCardMini :user="item.mutee"/> + </MkA> + <button class="_button" :class="$style.userToggle" @click="toggleMuteItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> + <button class="_button" :class="$style.remove" @click="unmute(item.mutee, $event)"><i class="ti ti-x"></i></button> + </div> + <div v-if="expandedMuteItems.includes(item.id)" :class="$style.userItemSub"> + <div>Muted at: <MkTime :time="item.createdAt" mode="detail"/></div> + <div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div> + <div v-else>Period: {{ i18n.ts.indefinitely }}</div> + </div> + </div> + </div> + </template> + </MkPagination> + </MkFolder> + </SearchMarker> + + <SearchMarker + :label="i18n.ts.blockedUsers" + :keywords="['block', 'user']" + > + <MkFolder> + <template #icon><i class="ti ti-ban"></i></template> + <template #label>{{ i18n.ts.blockedUsers }}</template> + + <MkPagination :pagination="blockingPagination"> + <template #empty> + <div class="_fullinfo"> + <img :src="infoImageUrl" draggable="false"/> + <div>{{ i18n.ts.noUsers }}</div> + </div> + </template> + + <template #default="{ items }"> + <div class="_gaps_s"> + <div v-for="item in items" :key="item.blockee.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedBlockItems.includes(item.id) }]"> + <div :class="$style.userItemMain"> + <MkA :class="$style.userItemMainBody" :to="userPage(item.blockee)"> + <MkUserCardMini :user="item.blockee"/> + </MkA> + <button class="_button" :class="$style.userToggle" @click="toggleBlockItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> + <button class="_button" :class="$style.remove" @click="unblock(item.blockee, $event)"><i class="ti ti-x"></i></button> + </div> + <div v-if="expandedBlockItems.includes(item.id)" :class="$style.userItemSub"> + <div>Blocked at: <MkTime :time="item.createdAt" mode="detail"/></div> + <div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div> + <div v-else>Period: {{ i18n.ts.indefinitely }}</div> + </div> + </div> + </div> + </template> + </MkPagination> + </MkFolder> + </SearchMarker> + </div> + </div> +</SearchMarker> </template> <script lang="ts" setup> @@ -139,18 +184,19 @@ import XWordMute from './mute-block.word-mute.vue'; import MkPagination from '@/components/MkPagination.vue'; import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import * as os from '@/os.js'; import { instance, infoImageUrl } from '@/instance.js'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; import MkInfo from '@/components/MkInfo.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import { defaultStore } from '@/store'; -import { reloadAsk } from '@/scripts/reload-ask.js'; +import { reloadAsk } from '@/utility/reload-ask.js'; +import { prefer } from '@/preferences.js'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; -const $i = signinRequired(); +const $i = ensureSignin(); const renoteMutingPagination = { endpoint: 'renote-mute/list' as const, @@ -171,7 +217,7 @@ const expandedRenoteMuteItems = ref([]); const expandedMuteItems = ref([]); const expandedBlockItems = ref([]); -const showSoftWordMutedWord = computed(defaultStore.makeGetterSetter('showSoftWordMutedWord')); +const showSoftWordMutedWord = prefer.model('showSoftWordMutedWord'); watch([ showSoftWordMutedWord, @@ -248,7 +294,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.muteAndBlock, icon: 'ti ti-ban', })); diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue index c38cdc4fc2..706cb731eb 100644 --- a/packages/frontend/src/pages/settings/navbar.vue +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -53,22 +53,24 @@ import FormSlot from '@/components/form/slot.vue'; import MkContainer from '@/components/MkContainer.vue'; import * as os from '@/os.js'; import { navbarItemDef } from '@/navbar.js'; -import { defaultStore } from '@/store.js'; -import { reloadAsk } from '@/scripts/reload-ask.js'; +import { store } from '@/store.js'; +import { reloadAsk } from '@/utility/reload-ask.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; +import { prefer } from '@/preferences.js'; +import { PREF_DEF } from '@/preferences/def.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); -const items = ref(defaultStore.state.menu.map(x => ({ +const items = ref(prefer.s.menu.map(x => ({ id: Math.random().toString(), type: x, }))); -const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay')); +const menuDisplay = computed(store.makeGetterSetter('menuDisplay')); async function addItem() { - const menu = Object.keys(navbarItemDef).filter(k => !defaultStore.state.menu.includes(k)); + const menu = Object.keys(navbarItemDef).filter(k => !prefer.s.menu.includes(k)); const { canceled, result: item } = await os.select({ title: i18n.ts.addItem, items: [...menu.map(k => ({ @@ -89,12 +91,12 @@ function removeItem(index: number) { } async function save() { - defaultStore.set('menu', items.value.map(x => x.type)); + prefer.commit('menu', items.value.map(x => x.type)); await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); } function reset() { - items.value = defaultStore.def.menu.default.map(x => ({ + items.value = PREF_DEF.menu.default.map(x => ({ id: Math.random().toString(), type: x, })); @@ -104,7 +106,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.navbar, icon: 'ti ti-list', })); diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index 8573fbf2b3..3c1b6c8032 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -5,6 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/bell_3d.png" color="#ffff00"> + <SearchKeyword>{{ i18n.ts._settings.notificationsBanner }}</SearchKeyword> + </MkFeatureBanner> + <FormSection first> <template #label>{{ i18n.ts.notificationRecieveConfig }}</template> <div class="_gaps_s"> @@ -34,7 +38,6 @@ SPDX-License-Identifier: AGPL-3.0-only <FormSection> <div class="_gaps_m"> <FormLink @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink> - <FormLink @click="readAllUnreadNotes">{{ i18n.ts.markAsReadAllUnreadNotes }}</FormLink> </div> </FormSection> <FormSection> @@ -62,35 +65,33 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef, computed } from 'vue'; -import XNotificationConfig, { type NotificationConfig } from './notifications.notification-config.vue'; +import { useTemplateRef, computed } from 'vue'; +import { notificationTypes } from '@@/js/const.js'; +import XNotificationConfig from './notifications.notification-config.vue'; +import type { NotificationConfig } from './notifications.notification-config.vue'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import * as os from '@/os.js'; -import { signinRequired } from '@/account.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { ensureSignin } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; -import { notificationTypes } from '@@/js/const.js'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; -const $i = signinRequired(); +const $i = ensureSignin(); const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'test', 'exportCompleted'] satisfies (typeof notificationTypes[number])[] as string[]; -const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login', 'scheduledNoteFailed', 'scheduledNotePosted'] satisfies (typeof notificationTypes[number])[] as string[]; +const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login', 'createToken', 'scheduledNoteFailed', 'scheduledNotePosted'] satisfies (typeof notificationTypes[number])[] as string[]; -const allowButton = shallowRef<InstanceType<typeof MkPushNotificationAllowButton>>(); +const allowButton = useTemplateRef('allowButton'); const pushRegistrationInServer = computed(() => allowButton.value?.pushRegistrationInServer); const sendReadMessage = computed(() => pushRegistrationInServer.value?.sendReadMessage || false); const userLists = await misskeyApi('users/lists/list'); -async function readAllUnreadNotes() { - await os.apiWithDialog('i/read-all-unread-notes'); -} - async function readAllNotifications() { await os.apiWithDialog('notifications/mark-all-as-read'); } @@ -137,7 +138,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.notifications, icon: 'ti ti-bell', })); diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index abe4524126..e6405954e8 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -4,124 +4,180 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_gaps_m"> - <!-- - <MkSwitch v-model="$i.injectFeaturedNote" @update:model-value="onChangeInjectFeaturedNote"> - <template #label>{{ i18n.ts.showFeaturedNotesInTimeline }}</template> - </MkSwitch> - --> +<SearchMarker path="/settings/other" :label="i18n.ts.other" :keywords="['other']" icon="ti ti-dots"> + <div class="_gaps_m"> + <!-- + <MkSwitch v-model="$i.injectFeaturedNote" @update:model-value="onChangeInjectFeaturedNote"> + <template #label>{{ i18n.ts.showFeaturedNotesInTimeline }}</template> + </MkSwitch> + --> - <!-- - <MkSwitch v-model="reportError">{{ i18n.ts.sendErrorReports }}<template #caption>{{ i18n.ts.sendErrorReportsDescription }}</template></MkSwitch> - --> + <!-- + <MkSwitch v-model="reportError">{{ i18n.ts.sendErrorReports }}<template #caption>{{ i18n.ts.sendErrorReportsDescription }}</template></MkSwitch> + --> - <FormSection first> <div class="_gaps_s"> - <MkFolder> - <template #icon><i class="ti ti-info-circle"></i></template> - <template #label>{{ i18n.ts.accountInfo }}</template> + <SearchMarker :keywords="['account', 'info']"> + <MkFolder> + <template #icon><i class="ti ti-info-circle"></i></template> + <template #label><SearchLabel>{{ i18n.ts.accountInfo }}</SearchLabel></template> - <div class="_gaps_m"> - <MkKeyValue> - <template #key>ID</template> - <template #value><span class="_monospace">{{ $i.id }}</span></template> - </MkKeyValue> + <div class="_gaps_m"> + <MkKeyValue> + <template #key>ID</template> + <template #value><span class="_monospace">{{ $i.id }}</span></template> + </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.registeredDate }}</template> - <template #value><MkTime :time="$i.createdAt" mode="detail"/></template> - </MkKeyValue> - </div> - </MkFolder> + <MkKeyValue> + <template #key>{{ i18n.ts.registeredDate }}</template> + <template #value><MkTime :time="$i.createdAt" mode="detail"/></template> + </MkKeyValue> - <MkFolder> - <template #icon><i class="ph-database ph-bold ph-lg"></i></template> - <template #label>{{ i18n.ts._dataRequest.title }}</template> + <MkFolder> + <template #icon><i class="ti ti-badges"></i></template> + <template #label><SearchLabel>{{ i18n.ts._role.policies }}</SearchLabel></template> - <div class="_gaps_m"> - <FormInfo warn>{{ i18n.ts._dataRequest.warn }}</FormInfo> - <FormInfo>{{ i18n.ts._dataRequest.text }}</FormInfo> - <MkButton primary @click="exportData">{{ i18n.ts._dataRequest.button }}</MkButton> - </div> - </MkFolder> + <div class="_gaps_s"> + <div v-for="policy in Object.keys($i.policies)" :key="policy"> + {{ policy }} ... {{ $i.policies[policy] }} + </div> + </div> + </MkFolder> + </div> + </MkFolder> + </SearchMarker> - <MkFolder> - <template #icon><i class="ti ti-alert-triangle"></i></template> - <template #label>{{ i18n.ts.closeAccount }}</template> + <SearchMarker :keywords="['roles']"> + <MkFolder> + <template #icon><i class="ti ti-badges"></i></template> + <template #label><SearchLabel>{{ i18n.ts.rolesAssignedToMe }}</SearchLabel></template> - <div class="_gaps_m"> - <FormInfo warn>{{ i18n.ts._accountDelete.mayTakeTime }}</FormInfo> - <FormInfo>{{ i18n.ts._accountDelete.sendEmail }}</FormInfo> - <MkButton v-if="!$i.isDeleted" danger @click="deleteAccount">{{ i18n.ts._accountDelete.requestAccountDelete }}</MkButton> - <MkButton v-else disabled>{{ i18n.ts._accountDelete.inProgress }}</MkButton> - </div> - </MkFolder> + <MkRolePreview v-for="role in $i.roles" :key="role.id" :role="role" :forModeration="false"/> + </MkFolder> + </SearchMarker> - <MkFolder> - <template #icon><i class="ti ti-flask"></i></template> - <template #label>{{ i18n.ts.experimentalFeatures }}</template> + <SearchMarker :keywords="['account', 'move', 'migration']"> + <MkFolder> + <template #icon><i class="ti ti-plane"></i></template> + <template #label><SearchLabel>{{ i18n.ts.accountMigration }}</SearchLabel></template> - <div class="_gaps_m"> - <MkSwitch v-model="enableCondensedLine"> - <template #label>Enable condensed line</template> - </MkSwitch> - <MkSwitch v-model="skipNoteRender"> - <template #label>Enable note render skipping</template> - </MkSwitch> - </div> - </MkFolder> + <XMigration/> + </MkFolder> + </SearchMarker> - <MkFolder> - <template #icon><i class="ti ti-code"></i></template> - <template #label>{{ i18n.ts.developer }}</template> + <SearchMarker :keywords="['account', 'export', 'data']"> + <MkFolder> + <template #icon><i class="ph-database ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts._dataRequest.title }}</template> - <div class="_gaps_m"> - <MkSwitch v-model="devMode"> - <template #label>{{ i18n.ts.devMode }}</template> - </MkSwitch> - </div> - </MkFolder> + <div class="_gaps_m"> + <FormInfo warn>{{ i18n.ts._dataRequest.warn }}</FormInfo> + <FormInfo>{{ i18n.ts._dataRequest.text }}</FormInfo> + <MkButton primary @click="exportData">{{ i18n.ts._dataRequest.button }}</MkButton> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['account', 'close', 'delete']"> + <MkFolder> + <template #icon><i class="ti ti-alert-triangle"></i></template> + <template #label><SearchLabel>{{ i18n.ts.closeAccount }}</SearchLabel></template> + + <div class="_gaps_m"> + <FormInfo warn>{{ i18n.ts._accountDelete.mayTakeTime }}</FormInfo> + <FormInfo>{{ i18n.ts._accountDelete.sendEmail }}</FormInfo> + <MkButton v-if="!$i.isDeleted" danger @click="deleteAccount"><SearchKeyword>{{ i18n.ts._accountDelete.requestAccountDelete }}</SearchKeyword></MkButton> + <MkButton v-else disabled>{{ i18n.ts._accountDelete.inProgress }}</MkButton> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['experimental', 'feature', 'flags']"> + <MkFolder> + <template #icon><i class="ti ti-flask"></i></template> + <template #label><SearchLabel>{{ i18n.ts.experimentalFeatures }}</SearchLabel></template> + + <div class="_gaps_m"> + <MkSwitch v-model="enableCondensedLine"> + <template #label>Enable condensed line</template> + </MkSwitch> + <MkSwitch v-model="skipNoteRender"> + <template #label>Enable note render skipping</template> + </MkSwitch> + <MkSwitch v-model="stackingRouterView"> + <template #label>Enable stacking router view</template> + </MkSwitch> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['developer', 'mode', 'debug']"> + <MkFolder> + <template #icon><i class="ti ti-code"></i></template> + <template #label><SearchLabel>{{ i18n.ts.developer }}</SearchLabel></template> + + <div class="_gaps_m"> + <MkSwitch v-model="devMode"> + <template #label>{{ i18n.ts.devMode }}</template> + </MkSwitch> + </div> + </MkFolder> + </SearchMarker> </div> - </FormSection> - <FormSection> + <hr> + <FormLink to="/registry"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink> - </FormSection> - <FormSection> - <div class="_gaps_s"> - <MkSwitch v-model="defaultWithReplies">{{ i18n.ts.withRepliesByDefaultForNewlyFollowed }}</MkSwitch> - <MkButton danger @click="updateRepliesAll(true)"><i class="ph-chats ph-bold ph-lg"></i> {{ i18n.ts.showRepliesToOthersInTimelineAll }}</MkButton> - <MkButton danger @click="updateRepliesAll(false)"><i class="ph-chat ph-bold ph-lg"></i> {{ i18n.ts.hideRepliesToOthersInTimelineAll }}</MkButton> - </div> - </FormSection> -</div> + <hr> + + <FormSection> + <div class="_gaps_s"> + <MkSwitch v-model="defaultWithReplies">{{ i18n.ts.withRepliesByDefaultForNewlyFollowed }}</MkSwitch> + <MkButton danger @click="updateRepliesAll(true)"><i class="ph-chats ph-bold ph-lg"></i> {{ i18n.ts.showRepliesToOthersInTimelineAll }}</MkButton> + <MkButton danger @click="updateRepliesAll(false)"><i class="ph-chat ph-bold ph-lg"></i> {{ i18n.ts.hideRepliesToOthersInTimelineAll }}</MkButton> + </div> + </FormSection> + + <hr> + + <FormSlot> + <MkButton danger @click="migrate"><i class="ti ti-refresh"></i> {{ i18n.ts.migrateOldSettings }}</MkButton> + <template #caption>{{ i18n.ts.migrateOldSettings_description }}</template> + </FormSlot> + </div> +</SearchMarker> </template> <script lang="ts" setup> import { computed, watch } from 'vue'; +import XMigration from './migration.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import FormLink from '@/components/form/link.vue'; import MkFolder from '@/components/MkFolder.vue'; import FormInfo from '@/components/MkInfo.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkButton from '@/components/MkButton.vue'; +import FormSlot from '@/components/form/slot.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { signout, signinRequired } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { ensureSignin } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { reloadAsk } from '@/scripts/reload-ask.js'; +import { definePage } from '@/page.js'; +import { reloadAsk } from '@/utility/reload-ask.js'; import FormSection from '@/components/form/section.vue'; +import { prefer } from '@/preferences.js'; +import MkRolePreview from '@/components/MkRolePreview.vue'; +import { signout } from '@/signout.js'; +import { migrateOldSettings } from '@/pref-migrate.js'; -const $i = signinRequired(); +const $i = ensureSignin(); -const reportError = computed(defaultStore.makeGetterSetter('reportError')); -const enableCondensedLine = computed(defaultStore.makeGetterSetter('enableCondensedLine')); -const skipNoteRender = computed(defaultStore.makeGetterSetter('skipNoteRender')); -const devMode = computed(defaultStore.makeGetterSetter('devMode')); -const defaultWithReplies = computed(defaultStore.makeGetterSetter('defaultWithReplies')); +const reportError = prefer.model('reportError'); +const enableCondensedLine = prefer.model('enableCondensedLine'); +const skipNoteRender = prefer.model('skipNoteRender'); +const devMode = prefer.model('devMode'); +const stackingRouterView = prefer.model('experimental.stackingRouterView'); watch(skipNoteRender, async () => { await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); @@ -151,14 +207,9 @@ async function deleteAccount() { await signout(); } -async function updateRepliesAll(withReplies: boolean) { - const { canceled } = await os.confirm({ - type: 'warning', - text: withReplies ? i18n.ts.confirmShowRepliesAll : i18n.ts.confirmHideRepliesAll, - }); - if (canceled) return; - - misskeyApi('following/update-all', { withReplies }); +function migrate() { + os.waiting(); + migrateOldSettings(); } const exportData = () => { @@ -185,7 +236,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.other, icon: 'ti ti-dots', })); diff --git a/packages/frontend/src/pages/settings/plugin.install.vue b/packages/frontend/src/pages/settings/plugin.install.vue index 3ab26e80d9..22b53b4b96 100644 --- a/packages/frontend/src/pages/settings/plugin.install.vue +++ b/packages/frontend/src/pages/settings/plugin.install.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkCodeEditor> <div> - <MkButton :disabled="code == null" primary inline @click="install"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton> + <MkButton :disabled="code == null || code.trim() === ''" primary inline @click="install"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton> </div> </div> </template> @@ -23,11 +23,12 @@ import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkButton from '@/components/MkButton.vue'; import FormInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; -import { installPlugin } from '@/scripts/install-plugin.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; +import { installPlugin } from '@/plugin.js'; +import { useRouter } from '@/router.js'; +const router = useRouter(); const code = ref<string | null>(null); async function install() { @@ -36,10 +37,9 @@ async function install() { try { await installPlugin(code.value); os.success(); + code.value = null; - nextTick(() => { - unisonReload(); - }); + router.push('/settings/plugin'); } catch (err) { os.alert({ type: 'error', @@ -53,7 +53,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts._plugin.install, icon: 'ti ti-download', })); diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue index 3c3dcfe41e..16d5947ad2 100644 --- a/packages/frontend/src/pages/settings/plugin.vue +++ b/packages/frontend/src/pages/settings/plugin.vue @@ -4,76 +4,97 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_gaps_m"> - <FormLink to="/settings/plugin/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._plugin.install }}</FormLink> +<SearchMarker path="/settings/plugin" :label="i18n.ts.plugins" :keywords="['plugin', 'addon', 'extension']" icon="ti ti-plug"> + <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/electric_plug_3d.png" color="#ffbb00"> + <SearchKeyword>{{ i18n.ts._settings.pluginBanner }}</SearchKeyword> + </MkFeatureBanner> - <FormSection> - <template #label>{{ i18n.ts.manage }}</template> - <div class="_gaps_s"> - <div v-for="plugin in plugins" :key="plugin.id" class="_panel _gaps_m" style="padding: 20px;"> - <div class="_gaps_s"> - <span style="display: flex; align-items: center;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span> - <MkSwitch :modelValue="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</MkSwitch> - </div> + <FormLink to="/settings/plugin/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._plugin.install }}</FormLink> - <div class="_gaps_s"> - <MkKeyValue> - <template #key>{{ i18n.ts.author }}</template> - <template #value>{{ plugin.author }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.description }}</template> - <template #value>{{ plugin.description }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.permission }}</template> - <template #value> - <ul style="margin-top: 0; margin-bottom: 0;"> - <li v-for="permission in plugin.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li> - <li v-if="!plugin.permissions || plugin.permissions.length === 0">{{ i18n.ts.none }}</li> - </ul> - </template> - </MkKeyValue> - </div> - - <div class="_buttons"> - <MkButton v-if="plugin.config" inline @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton> - <MkButton inline danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton> - </div> + <FormSection> + <template #label>{{ i18n.ts.manage }}</template> + <div class="_gaps_s"> + <MkFolder v-for="plugin in plugins" :key="plugin.installId"> + <template #icon><i class="ti ti-plug"></i></template> + <template #suffix> + <i v-if="plugin.active" class="ti ti-player-play" style="color: var(--MI_THEME-success);"></i> + <i v-else class="ti ti-player-pause" style="opacity: 0.7;"></i> + </template> + <template #label> + <div :style="plugin.active ? '' : 'opacity: 0.7;'"> + {{ plugin.name }} + <span style="margin-left: 1em; opacity: 0.7;">v{{ plugin.version }}</span> + </div> + </template> + <template #caption> + {{ plugin.description }} + </template> + <template #footer> + <div class="_buttons"> + <MkButton :disabled="!plugin.active" @click="reload(plugin)"><i class="ti ti-refresh"></i> {{ i18n.ts.reload }}</MkButton> + <MkButton danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton> + <MkButton v-if="plugin.config" style="margin-left: auto;" @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton> + </div> + </template> - <MkFolder> - <template #icon><i class="ti ti-terminal-2"></i></template> - <template #label>{{ i18n.ts._plugin.viewLog }}</template> + <div class="_gaps_m"> + <div class="_gaps_s"> + <MkSwitch :modelValue="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</MkSwitch> + </div> - <div class="_gaps_s"> - <div class="_buttons"> - <MkButton inline @click="copy(pluginLogs.get(plugin.id)?.join('\n'))"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton> + <div class="_gaps_s"> + <MkKeyValue> + <template #key>{{ i18n.ts.author }}</template> + <template #value>{{ plugin.author }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.description }}</template> + <template #value>{{ plugin.description }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.permission }}</template> + <template #value> + <ul style="margin-top: 0; margin-bottom: 0;"> + <li v-for="permission in plugin.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li> + <li v-if="!plugin.permissions || plugin.permissions.length === 0">{{ i18n.ts.none }}</li> + </ul> + </template> + </MkKeyValue> </div> - <MkCode :code="pluginLogs.get(plugin.id)?.join('\n') ?? ''"/> - </div> - </MkFolder> + <div class="_gaps_s"> + <MkFolder> + <template #icon><i class="ti ti-terminal-2"></i></template> + <template #label>{{ i18n.ts.logs }}</template> - <MkFolder> - <template #icon><i class="ti ti-code"></i></template> - <template #label>{{ i18n.ts._plugin.viewSource }}</template> + <div> + <div v-for="log in pluginLogs.get(plugin.installId)" :class="[$style.log, { [$style.isSystemLog]: log.isSystem }]"> + <div class="_monospace">{{ timeToHhMmSs(log.at) }} {{ log.message }}</div> + </div> + </div> + </MkFolder> - <div class="_gaps_s"> - <div class="_buttons"> - <MkButton inline @click="copy(plugin.src)"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton> - </div> + <MkFolder :withSpacer="false"> + <template #icon><i class="ti ti-code"></i></template> + <template #label>{{ i18n.ts._plugin.viewSource }}</template> - <MkCode :code="plugin.src ?? ''" lang="is"/> + <div class="_gaps_s"> + <MkCode :code="plugin.src ?? ''" lang="ais"/> + </div> + </MkFolder> + </div> </div> </MkFolder> </div> - </div> - </FormSection> -</div> + </FormSection> + </div> +</SearchMarker> </template> <script lang="ts" setup> import { nextTick, ref, computed } from 'vue'; +import type { Plugin } from '@/plugin.js'; import FormLink from '@/components/form/link.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import FormSection from '@/components/form/section.vue'; @@ -81,66 +102,58 @@ import MkButton from '@/components/MkButton.vue'; import MkCode from '@/components/MkCode.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; -import * as os from '@/os.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { ColdDeviceStorage } from '@/store.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { pluginLogs } from '@/plugin.js'; +import { definePage } from '@/page.js'; +import { changePluginActive, configPlugin, pluginLogs, uninstallPlugin, reloadPlugin } from '@/plugin.js'; +import { prefer } from '@/preferences.js'; +import * as os from '@/os.js'; -const plugins = ref(ColdDeviceStorage.get('plugins')); +const plugins = prefer.r.plugins; -async function uninstall(plugin) { - ColdDeviceStorage.set('plugins', plugins.value.filter(x => x.id !== plugin.id)); - await os.apiWithDialog('i/revoke-token', { - token: plugin.token, - }); - nextTick(() => { - unisonReload(); +async function uninstall(plugin: Plugin) { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.tsx.removeAreYouSure({ x: plugin.name }), }); -} + if (canceled) return; + + await uninstallPlugin(plugin); -function copy(text) { - copyToClipboard(text ?? ''); os.success(); } -// TODO: この処理をstore側にactionとして移動し、設定画面を開くAiScriptAPIを実装できるようにする -async function config(plugin) { - const config = plugin.config; - for (const key in plugin.configData) { - config[key].default = plugin.configData[key]; - } - - const { canceled, result } = await os.form(plugin.name, config); - if (canceled) return; - - const coldPlugins = ColdDeviceStorage.get('plugins'); - coldPlugins.find(p => p.id === plugin.id)!.configData = result; - ColdDeviceStorage.set('plugins', coldPlugins); +function reload(plugin: Plugin) { + reloadPlugin(plugin); +} - nextTick(() => { - location.reload(); - }); +async function config(plugin: Plugin) { + await configPlugin(plugin); } -function changeActive(plugin, active) { - const coldPlugins = ColdDeviceStorage.get('plugins'); - coldPlugins.find(p => p.id === plugin.id)!.active = active; - ColdDeviceStorage.set('plugins', coldPlugins); +function changeActive(plugin: Plugin, active: boolean) { + changePluginActive(plugin, active); +} - nextTick(() => { - location.reload(); - }); +function timeToHhMmSs(unixtime: number) { + return new Date(unixtime).toTimeString().split(' ')[0]; } const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.plugins, icon: 'ti ti-plug', })); </script> + +<style module> +.log { +} + +.isSystemLog { + opacity: 0.5; +} +</style> diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue deleted file mode 100644 index f7dcc7b139..0000000000 --- a/packages/frontend/src/pages/settings/preferences-backups.vue +++ /dev/null @@ -1,489 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div class="_gaps_m"> - <div :class="$style.buttons"> - <MkButton inline primary @click="saveNew">{{ i18n.ts._preferencesBackups.saveNew }}</MkButton> - <MkButton inline @click="loadFile">{{ i18n.ts._preferencesBackups.loadFile }}</MkButton> - </div> - - <FormSection> - <template #label>{{ i18n.ts._preferencesBackups.list }}</template> - <template v-if="profiles && Object.keys(profiles).length > 0"> - <div class="_gaps_s"> - <div - v-for="(profile, id) in profiles" - :key="id" - class="_panel" - :class="$style.profile" - @click="$event => menu($event, id)" - @contextmenu.prevent.stop="$event => menu($event, id)" - > - <div :class="$style.profileName">{{ profile.name }}</div> - <div :class="$style.profileTime">{{ i18n.tsx._preferencesBackups.createdAt({ date: (new Date(profile.createdAt)).toLocaleDateString(), time: (new Date(profile.createdAt)).toLocaleTimeString() }) }}</div> - <div v-if="profile.updatedAt" :class="$style.profileTime">{{ i18n.tsx._preferencesBackups.updatedAt({ date: (new Date(profile.updatedAt)).toLocaleDateString(), time: (new Date(profile.updatedAt)).toLocaleTimeString() }) }}</div> - </div> - </div> - </template> - <div v-else-if="profiles"> - <MkInfo>{{ i18n.ts._preferencesBackups.noBackups }}</MkInfo> - </div> - <MkLoading v-else/> - </FormSection> -</div> -</template> - -<script lang="ts" setup> -import { onMounted, onUnmounted, ref } from 'vue'; -import { v4 as uuid } from 'uuid'; -import { version, host } from '@@/js/config.js'; -import FormSection from '@/components/form/section.vue'; -import MkButton from '@/components/MkButton.vue'; -import MkInfo from '@/components/MkInfo.vue'; -import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { ColdDeviceStorage, defaultStore } from '@/store.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; -import { useStream } from '@/stream.js'; -import { $i } from '@/account.js'; -import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { miLocalStorage } from '@/local-storage.js'; - -const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ - 'collapseRenotes', - 'collapseNotesRepliedTo', - 'menu', - 'visibility', - 'localOnly', - 'statusbars', - 'widgets', - 'tl', - 'pinnedUserLists', - 'overridedDeviceKind', - 'serverDisconnectedBehavior', - 'nsfw', - 'highlightSensitiveMedia', - 'animation', - 'animatedMfm', - 'advancedMfm', - 'showReactionsCount', - 'loadRawImages', - 'warnMissingAltText', - 'enableFaviconNotificationDot', - 'imageNewTab', - 'dataSaver', - 'disableCatSpeak', - 'disableShowingAnimatedImages', - 'emojiStyle', - 'menuStyle', - 'useBlurEffectForModal', - 'useBlurEffect', - 'showFixedPostForm', - 'showFixedPostFormInChannel', - 'enableInfiniteScroll', - 'useReactionPickerForContextMenu', - 'showGapBetweenNotesInTimeline', - 'instanceTicker', - 'emojiPickerScale', - 'emojiPickerWidth', - 'emojiPickerHeight', - 'emojiPickerStyle', - 'defaultSideView', - 'menuDisplay', - 'reportError', - 'squareAvatars', - 'showAvatarDecorations', - 'numberOfPageCache', - 'showNoteActionsOnlyHover', - 'showClipButtonInNoteFooter', - 'reactionsDisplaySize', - 'forceShowAds', - 'oneko', - 'numberOfReplies', - 'aiChanMode', - 'devMode', - 'mediaListWithOneImageAppearance', - 'notificationPosition', - 'notificationStackAxis', - 'keepScreenOn', - 'defaultWithReplies', - 'disableStreamingTimeline', - 'useGroupedNotifications', - 'sound_masterVolume', - 'sound_note', - 'sound_noteMy', - 'sound_notification', -]; -const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [ - 'lightTheme', - 'darkTheme', - 'syncDeviceDarkMode', - 'plugins', -]; - -const scope = ['clientPreferencesProfiles']; - -const profileProps = ['name', 'createdAt', 'updatedAt', 'misskeyVersion', 'settings', 'host']; - -type Profile = { - name: string; - createdAt: string; - updatedAt: string | null; - misskeyVersion: string; - host: string; - settings: { - hot: Record<keyof typeof defaultStoreSaveKeys, unknown>; - cold: Record<keyof typeof coldDeviceStorageSaveKeys, unknown>; - fontSize: string | null; - lang: string | null; - cornerRadius: string | null; - useSystemFont: 't' | null; - wallpaper: string | null; - }; -}; - -const connection = $i && useStream().useChannel('main'); - -const profiles = ref<Record<string, Profile> | null>(null); - -misskeyApi('i/registry/get-all', { scope }) - .then(res => { - profiles.value = res || {}; - }); - -function isObject(value: unknown): value is Record<string, unknown> { - return value != null && typeof value === 'object' && !Array.isArray(value); -} - -function validate(profile: any): void { - if (!isObject(profile)) throw new Error('not an object'); - - // Check if unnecessary properties exist - if (Object.keys(profile).some(key => !profileProps.includes(key))) throw new Error('Unnecessary properties exist'); - - if (!profile.name) throw new Error('Missing required prop: name'); - if (!profile.misskeyVersion) throw new Error('Missing required prop: misskeyVersion'); - - // Check if createdAt and updatedAt is Date - // https://zenn.dev/lollipop_onl/articles/eoz-judge-js-invalid-date - if (!profile.createdAt || Number.isNaN(new Date(profile.createdAt as any).getTime())) throw new Error('createdAt is falsy or not Date'); - if (profile.updatedAt) { - if (Number.isNaN(new Date(profile.updatedAt as any).getTime())) { - throw new Error('updatedAt is not Date'); - } - } else if (profile.updatedAt !== null) { - throw new Error('updatedAt is not null'); - } - - if (!profile.settings) throw new Error('Missing required prop: settings'); - if (!isObject(profile.settings)) throw new Error('Invalid prop: settings'); -} - -function getSettings(): Profile['settings'] { - const hot = {} as Record<keyof typeof defaultStoreSaveKeys, unknown>; - for (const key of defaultStoreSaveKeys) { - hot[key] = defaultStore.state[key]; - } - - const cold = {} as Record<keyof typeof coldDeviceStorageSaveKeys, unknown>; - for (const key of coldDeviceStorageSaveKeys) { - cold[key] = ColdDeviceStorage.get(key); - } - - return { - hot, - cold, - fontSize: miLocalStorage.getItem('fontSize'), - lang: miLocalStorage.getItem('lang'), - cornerRadius: miLocalStorage.getItem('cornerRadius'), - useSystemFont: miLocalStorage.getItem('useSystemFont') as 't' | null, - wallpaper: miLocalStorage.getItem('wallpaper'), - }; -} - -async function saveNew(): Promise<void> { - if (!profiles.value) return; - - const { canceled, result: name } = await os.inputText({ - title: i18n.ts._preferencesBackups.inputName, - default: '', - }); - if (canceled) return; - - if (Object.values(profiles.value).some(x => x.name === name)) { - return os.alert({ - title: i18n.ts._preferencesBackups.cannotSave, - text: i18n.tsx._preferencesBackups.nameAlreadyExists({ name }), - }); - } - - const id = uuid(); - const profile: Profile = { - name, - createdAt: (new Date()).toISOString(), - updatedAt: null, - misskeyVersion: version, - host, - settings: getSettings(), - }; - await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile }); -} - -function loadFile(): void { - const input = document.createElement('input'); - input.type = 'file'; - input.multiple = false; - input.onchange = async () => { - if (!profiles.value) return; - if (!input.files || input.files.length === 0) return; - - const file = input.files[0]; - - if (file.type !== 'application/json') { - return os.alert({ - type: 'error', - title: i18n.ts._preferencesBackups.cannotLoad, - text: i18n.ts._preferencesBackups.invalidFile, - }); - } - - let profile: Profile; - try { - profile = JSON.parse(await file.text()) as unknown as Profile; - validate(profile); - } catch (err) { - return os.alert({ - type: 'error', - title: i18n.ts._preferencesBackups.cannotLoad, - text: (err as any)?.message ?? '', - }); - } - - const id = uuid(); - await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile }); - - // 一応廃棄 - (window as any).__misskey_input_ref__ = null; - }; - - // https://qiita.com/fukasawah/items/b9dc732d95d99551013d - // iOS Safari で正常に動かす為のおまじない - (window as any).__misskey_input_ref__ = input; - - input.click(); -} - -async function applyProfile(id: string): Promise<void> { - if (!profiles.value) return; - - const profile = profiles.value[id]; - - const { canceled: cancel1 } = await os.confirm({ - type: 'warning', - title: i18n.ts._preferencesBackups.apply, - text: i18n.tsx._preferencesBackups.applyConfirm({ name: profile.name }), - }); - if (cancel1) return; - - // TODO: バージョン or ホストが違ったらさらに警告を表示 - - const settings = profile.settings; - - // defaultStore - for (const key of defaultStoreSaveKeys) { - if (settings.hot[key] !== undefined) { - defaultStore.set(key, settings.hot[key]); - } - } - - // coldDeviceStorage - for (const key of coldDeviceStorageSaveKeys) { - if (settings.cold[key] !== undefined) { - ColdDeviceStorage.set(key, settings.cold[key]); - } - } - - // fontSize - if (settings.fontSize) { - miLocalStorage.setItem('fontSize', settings.fontSize); - } else { - miLocalStorage.removeItem('fontSize'); - } - - // lang - if (settings.lang) { - miLocalStorage.setItem('lang', settings.lang); - } else { - miLocalStorage.removeItem('lang'); - } - - // cornerRadius - if (settings.cornerRadius) { - miLocalStorage.setItem('cornerRadius', settings.cornerRadius); - } else { - miLocalStorage.removeItem('cornerRadius'); - } - - // useSystemFont - if (settings.useSystemFont) { - miLocalStorage.setItem('useSystemFont', settings.useSystemFont); - } else { - miLocalStorage.removeItem('useSystemFont'); - } - - // wallpaper - if (settings.wallpaper != null) { - miLocalStorage.setItem('wallpaper', settings.wallpaper); - } else { - miLocalStorage.removeItem('wallpaper'); - } - - const { canceled: cancel2 } = await os.confirm({ - type: 'info', - text: i18n.ts.reloadToApplySetting, - }); - if (cancel2) return; - - unisonReload(); -} - -async function deleteProfile(id: string): Promise<void> { - if (!profiles.value) return; - - const { canceled } = await os.confirm({ - type: 'info', - title: i18n.ts.delete, - text: i18n.tsx.deleteAreYouSure({ x: profiles.value[id].name }), - }); - if (canceled) return; - - await os.apiWithDialog('i/registry/remove', { scope, key: id }); - delete profiles.value[id]; -} - -async function save(id: string): Promise<void> { - if (!profiles.value) return; - - const { name, createdAt } = profiles.value[id]; - - const { canceled } = await os.confirm({ - type: 'info', - title: i18n.ts._preferencesBackups.save, - text: i18n.tsx._preferencesBackups.saveConfirm({ name }), - }); - if (canceled) return; - - const profile: Profile = { - name, - createdAt, - updatedAt: (new Date()).toISOString(), - misskeyVersion: version, - host, - settings: getSettings(), - }; - await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile }); -} - -async function rename(id: string): Promise<void> { - if (!profiles.value) return; - - const { canceled: cancel1, result: name } = await os.inputText({ - title: i18n.ts._preferencesBackups.inputName, - default: '', - }); - if (cancel1 || profiles.value[id].name === name) return; - - if (Object.values(profiles.value).some(x => x.name === name)) { - return os.alert({ - title: i18n.ts._preferencesBackups.cannotSave, - text: i18n.tsx._preferencesBackups.nameAlreadyExists({ name }), - }); - } - - const registry = Object.assign({}, { ...profiles.value[id] }); - - const { canceled: cancel2 } = await os.confirm({ - type: 'info', - title: i18n.ts.rename, - text: i18n.tsx._preferencesBackups.renameConfirm({ old: registry.name, new: name }), - }); - if (cancel2) return; - - registry.name = name; - await os.apiWithDialog('i/registry/set', { scope, key: id, value: registry }); -} - -function menu(ev: MouseEvent, profileId: string) { - if (!profiles.value) return; - - return os.popupMenu([{ - text: i18n.ts._preferencesBackups.apply, - icon: 'ti ti-check', - action: () => applyProfile(profileId), - }, { - type: 'a', - text: i18n.ts.download, - icon: 'ti ti-download', - href: URL.createObjectURL(new Blob([JSON.stringify(profiles.value[profileId], null, 2)], { type: 'application/json' })), - download: `${profiles.value[profileId].name}.json`, - }, { type: 'divider' }, { - text: i18n.ts.rename, - icon: 'ti ti-forms', - action: () => rename(profileId), - }, { - text: i18n.ts._preferencesBackups.save, - icon: 'ti ti-device-floppy', - action: () => save(profileId), - }, { type: 'divider' }, { - text: i18n.ts.delete, - icon: 'ti ti-trash', - action: () => deleteProfile(profileId), - danger: true, - }], (ev.currentTarget ?? ev.target ?? undefined) as unknown as HTMLElement | undefined); -} - -onMounted(() => { - // streamingのuser storage updateイベントを監視して更新 - connection?.on('registryUpdated', ({ scope: recievedScope, key, value }) => { - if (!recievedScope || recievedScope.length !== scope.length || recievedScope[0] !== scope[0]) return; - if (!profiles.value) return; - - profiles.value[key] = value; - }); -}); - -onUnmounted(() => { - connection?.off('registryUpdated'); -}); - -definePageMetadata(() => ({ - title: i18n.ts.preferencesBackups, - icon: 'ti ti-device-floppy', -})); -</script> - -<style lang="scss" module> -.buttons { - display: flex; - gap: var(--MI-margin); - flex-wrap: wrap; -} - -.profile { - padding: 20px; - cursor: pointer; - - &Name { - font-weight: 700; - } - - &Time { - font-size: .85em; - opacity: .7; - } -} -</style> diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue new file mode 100644 index 0000000000..9256a565c4 --- /dev/null +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -0,0 +1,756 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<SearchMarker path="/settings/preferences" :label="i18n.ts.preferences" :keywords="['general', 'preferences']" icon="ti ti-adjustments"> + <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/gear_3d.png" color="#00ff9d"> + <SearchKeyword>{{ i18n.ts._settings.preferencesBanner }}</SearchKeyword> + </MkFeatureBanner> + + <div class="_gaps_s"> + <SearchMarker :keywords="['general']"> + <MkFolder> + <template #label><SearchLabel>{{ i18n.ts.general }}</SearchLabel></template> + + <div class="_gaps_m"> + <SearchMarker :keywords="['language']"> + <MkSelect v-model="lang"> + <template #label><SearchLabel>{{ i18n.ts.uiLanguage }}</SearchLabel></template> + <option v-for="x in langs" :key="x[0]" :value="x[0]">{{ x[1] }}</option> + <template #caption> + <I18n :src="i18n.ts.i18nInfo" tag="span"> + <template #link> + <MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink> + </template> + </I18n> + </template> + </MkSelect> + </SearchMarker> + + <SearchMarker :keywords="['device', 'type', 'kind', 'smartphone', 'tablet', 'desktop']"> + <MkRadios v-model="overridedDeviceKind"> + <template #label><SearchLabel>{{ i18n.ts.overridedDeviceKind }}</SearchLabel></template> + <option :value="null">{{ i18n.ts.auto }}</option> + <option value="smartphone"><i class="ti ti-device-mobile"/> {{ i18n.ts.smartphone }}</option> + <option value="tablet"><i class="ti ti-device-tablet"/> {{ i18n.ts.tablet }}</option> + <option value="desktop"><i class="ti ti-device-desktop"/> {{ i18n.ts.desktop }}</option> + </MkRadios> + </SearchMarker> + + <div class="_gaps_s"> + <SearchMarker :keywords="['blur']"> + <MkPreferenceContainer k="useBlurEffect"> + <MkSwitch v-model="useBlurEffect"> + <template #label><SearchLabel>{{ i18n.ts.useBlurEffect }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['blur', 'modal']"> + <MkPreferenceContainer k="useBlurEffectForModal"> + <MkSwitch v-model="useBlurEffectForModal"> + <template #label><SearchLabel>{{ i18n.ts.useBlurEffectForModal }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['avatar', 'icon', 'decoration', 'show']"> + <MkPreferenceContainer k="showAvatarDecorations"> + <MkSwitch v-model="showAvatarDecorations"> + <template #label><SearchLabel>{{ i18n.ts.showAvatarDecorations }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['follow', 'confirm', 'always']"> + <MkPreferenceContainer k="alwaysConfirmFollow"> + <MkSwitch v-model="alwaysConfirmFollow"> + <template #label><SearchLabel>{{ i18n.ts.alwaysConfirmFollow }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['highlight', 'sensitive', 'nsfw', 'image', 'photo', 'picture', 'media', 'thumbnail']"> + <MkPreferenceContainer k="highlightSensitiveMedia"> + <MkSwitch v-model="highlightSensitiveMedia"> + <template #label><SearchLabel>{{ i18n.ts.highlightSensitiveMedia }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['sensitive', 'nsfw', 'media', 'image', 'photo', 'picture', 'attachment', 'confirm']"> + <MkPreferenceContainer k="confirmWhenRevealingSensitiveMedia"> + <MkSwitch v-model="confirmWhenRevealingSensitiveMedia"> + <template #label><SearchLabel>{{ i18n.ts.confirmWhenRevealingSensitiveMedia }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['mfm', 'enable', 'show', 'advanced']"> + <MkPreferenceContainer k="advancedMfm"> + <MkSwitch v-model="advancedMfm"> + <template #label><SearchLabel>{{ i18n.ts.enableAdvancedMfm }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['auto', 'load', 'auto', 'more', 'scroll']"> + <MkPreferenceContainer k="enableInfiniteScroll"> + <MkSwitch v-model="enableInfiniteScroll"> + <template #label><SearchLabel>{{ i18n.ts.enableInfiniteScroll }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + </div> + + <SearchMarker :keywords="['emoji', 'style', 'native', 'system', 'fluent', 'twemoji']"> + <MkPreferenceContainer k="emojiStyle"> + <div> + <MkRadios v-model="emojiStyle"> + <template #label><SearchLabel>{{ i18n.ts.emojiStyle }}</SearchLabel></template> + <option value="native">{{ i18n.ts.native }}</option> + <option value="fluentEmoji">Fluent Emoji</option> + <option value="twemoji">Twemoji</option> + </MkRadios> + <div style="margin: 8px 0 0 0; font-size: 1.5em;"><Mfm :key="emojiStyle" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div> + </div> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['pinned', 'list']"> + <MkFolder> + <template #label><SearchLabel>{{ i18n.ts.pinnedList }}</SearchLabel></template> + <!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ --> + <MkButton v-if="prefer.r.pinnedUserLists.value.length === 0" @click="setPinnedList()">{{ i18n.ts.add }}</MkButton> + <MkButton v-else danger @click="removePinnedList()"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> + </MkFolder> + </SearchMarker> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['timeline', 'note']"> + <MkFolder> + <template #label><SearchLabel>{{ i18n.ts._settings.timelineAndNote }}</SearchLabel></template> + + <div class="_gaps_m"> + <div class="_gaps_s"> + <SearchMarker :keywords="['post', 'form', 'timeline']"> + <MkPreferenceContainer k="showFixedPostForm"> + <MkSwitch v-model="showFixedPostForm"> + <template #label><SearchLabel>{{ i18n.ts.showFixedPostForm }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['post', 'form', 'timeline', 'channel']"> + <MkPreferenceContainer k="showFixedPostFormInChannel"> + <MkSwitch v-model="showFixedPostFormInChannel"> + <template #label><SearchLabel>{{ i18n.ts.showFixedPostFormInChannel }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['renote']"> + <MkPreferenceContainer k="collapseRenotes"> + <MkSwitch v-model="collapseRenotes"> + <template #label><SearchLabel>{{ i18n.ts.collapseRenotes }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.collapseRenotesDescription }}</SearchKeyword></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['note', 'timeline', 'gap']"> + <MkPreferenceContainer k="showGapBetweenNotesInTimeline"> + <MkSwitch v-model="showGapBetweenNotesInTimeline"> + <template #label><SearchLabel>{{ i18n.ts.showGapBetweenNotesInTimeline }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['disable', 'streaming', 'timeline']"> + <MkPreferenceContainer k="disableStreamingTimeline"> + <MkSwitch v-model="disableStreamingTimeline"> + <template #label><SearchLabel>{{ i18n.ts.disableStreamingTimeline }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + </div> + + <hr> + + <div class="_gaps_m"> + <div class="_gaps_s"> + <SearchMarker :keywords="['hover', 'show', 'footer', 'action']"> + <MkPreferenceContainer k="showNoteActionsOnlyHover"> + <MkSwitch v-model="showNoteActionsOnlyHover"> + <template #label><SearchLabel>{{ i18n.ts.showNoteActionsOnlyHover }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['footer', 'action', 'clip', 'show']"> + <MkPreferenceContainer k="showClipButtonInNoteFooter"> + <MkSwitch v-model="showClipButtonInNoteFooter"> + <template #label><SearchLabel>{{ i18n.ts.showClipButtonInNoteFooter }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['reaction', 'count', 'show']"> + <MkPreferenceContainer k="showReactionsCount"> + <MkSwitch v-model="showReactionsCount"> + <template #label><SearchLabel>{{ i18n.ts.showReactionsCount }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['reaction', 'confirm']"> + <MkPreferenceContainer k="confirmOnReact"> + <MkSwitch v-model="confirmOnReact"> + <template #label><SearchLabel>{{ i18n.ts.confirmOnReact }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['image', 'photo', 'picture', 'media', 'thumbnail', 'quality', 'raw', 'attachment']"> + <MkPreferenceContainer k="loadRawImages"> + <MkSwitch v-model="loadRawImages"> + <template #label><SearchLabel>{{ i18n.ts.loadRawImages }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['reaction', 'picker', 'contextmenu', 'open']"> + <MkPreferenceContainer k="useReactionPickerForContextMenu"> + <MkSwitch v-model="useReactionPickerForContextMenu"> + <template #label><SearchLabel>{{ i18n.ts.useReactionPickerForContextMenu }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + </div> + + <SearchMarker :keywords="['reaction', 'size', 'scale', 'display']"> + <MkPreferenceContainer k="reactionsDisplaySize"> + <MkRadios v-model="reactionsDisplaySize"> + <template #label><SearchLabel>{{ i18n.ts.reactionsDisplaySize }}</SearchLabel></template> + <option value="small">{{ i18n.ts.small }}</option> + <option value="medium">{{ i18n.ts.medium }}</option> + <option value="large">{{ i18n.ts.large }}</option> + </MkRadios> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['reaction', 'size', 'scale', 'display', 'width', 'limit']"> + <MkPreferenceContainer k="limitWidthOfReaction"> + <MkSwitch v-model="limitWidthOfReaction"> + <template #label><SearchLabel>{{ i18n.ts.limitWidthOfReaction }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'list', 'size', 'height']"> + <MkPreferenceContainer k="mediaListWithOneImageAppearance"> + <MkRadios v-model="mediaListWithOneImageAppearance"> + <template #label><SearchLabel>{{ i18n.ts.mediaListWithOneImageAppearance }}</SearchLabel></template> + <option value="expand">{{ i18n.ts.default }}</option> + <option value="16_9">{{ i18n.tsx.limitTo({ x: '16:9' }) }}</option> + <option value="1_1">{{ i18n.tsx.limitTo({ x: '1:1' }) }}</option> + <option value="2_3">{{ i18n.tsx.limitTo({ x: '2:3' }) }}</option> + </MkRadios> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation']"> + <MkPreferenceContainer k="instanceTicker"> + <MkSelect v-if="instance.federation !== 'none'" v-model="instanceTicker"> + <template #label><SearchLabel>{{ i18n.ts.instanceTicker }}</SearchLabel></template> + <option value="none">{{ i18n.ts._instanceTicker.none }}</option> + <option value="remote">{{ i18n.ts._instanceTicker.remote }}</option> + <option value="always">{{ i18n.ts._instanceTicker.always }}</option> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'nsfw', 'sensitive', 'display', 'show', 'hide', 'visibility']"> + <MkPreferenceContainer k="nsfw"> + <MkSelect v-model="nsfw"> + <template #label><SearchLabel>{{ i18n.ts.displayOfSensitiveMedia }}</SearchLabel></template> + <option value="respect">{{ i18n.ts._displayOfSensitiveMedia.respect }}</option> + <option value="ignore">{{ i18n.ts._displayOfSensitiveMedia.ignore }}</option> + <option value="force">{{ i18n.ts._displayOfSensitiveMedia.force }}</option> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + </div> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['post', 'form']"> + <MkFolder> + <template #label><SearchLabel>{{ i18n.ts.postForm }}</SearchLabel></template> + + <div class="_gaps_m"> + <div class="_gaps_s"> + <SearchMarker :keywords="['remember', 'keep', 'note', 'cw']"> + <MkPreferenceContainer k="keepCw"> + <MkSwitch v-model="keepCw"> + <template #label><SearchLabel>{{ i18n.ts.keepCw }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['remember', 'keep', 'note', 'visibility']"> + <MkPreferenceContainer k="rememberNoteVisibility"> + <MkSwitch v-model="rememberNoteVisibility"> + <template #label><SearchLabel>{{ i18n.ts.rememberNoteVisibility }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['mfm', 'enable', 'show', 'advanced', 'picker', 'form', 'function', 'fn']"> + <MkPreferenceContainer k="enableQuickAddMfmFunction"> + <MkSwitch v-model="enableQuickAddMfmFunction"> + <template #label><SearchLabel>{{ i18n.ts.enableQuickAddMfmFunction }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + </div> + + <SearchMarker :keywords="['default', 'note', 'visibility']"> + <MkDisableSection :disabled="rememberNoteVisibility"> + <MkFolder> + <template #label><SearchLabel>{{ i18n.ts.defaultNoteVisibility }}</SearchLabel></template> + <template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template> + <template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</template> + <template v-else-if="defaultNoteVisibility === 'followers'" #suffix>{{ i18n.ts._visibility.followers }}</template> + <template v-else-if="defaultNoteVisibility === 'specified'" #suffix>{{ i18n.ts._visibility.specified }}</template> + + <div class="_gaps_m"> + <MkPreferenceContainer k="defaultNoteVisibility"> + <MkSelect v-model="defaultNoteVisibility"> + <option value="public">{{ i18n.ts._visibility.public }}</option> + <option value="home">{{ i18n.ts._visibility.home }}</option> + <option value="followers">{{ i18n.ts._visibility.followers }}</option> + <option value="specified">{{ i18n.ts._visibility.specified }}</option> + </MkSelect> + </MkPreferenceContainer> + + <MkPreferenceContainer k="defaultNoteLocalOnly"> + <MkSwitch v-model="defaultNoteLocalOnly">{{ i18n.ts._visibility.disableFederation }}</MkSwitch> + </MkPreferenceContainer> + </div> + </MkFolder> + </MkDisableSection> + </SearchMarker> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['notification']"> + <MkFolder> + <template #label><SearchLabel>{{ i18n.ts.notifications }}</SearchLabel></template> + + <div class="_gaps_m"> + <SearchMarker :keywords="['group']"> + <MkPreferenceContainer k="useGroupedNotifications"> + <MkSwitch v-model="useGroupedNotifications"> + <template #label><SearchLabel>{{ i18n.ts.useGroupedNotifications }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['position']"> + <MkPreferenceContainer k="notificationPosition"> + <MkRadios v-model="notificationPosition"> + <template #label><SearchLabel>{{ i18n.ts.position }}</SearchLabel></template> + <option value="leftTop"><i class="ti ti-align-box-left-top"></i> {{ i18n.ts.leftTop }}</option> + <option value="rightTop"><i class="ti ti-align-box-right-top"></i> {{ i18n.ts.rightTop }}</option> + <option value="leftBottom"><i class="ti ti-align-box-left-bottom"></i> {{ i18n.ts.leftBottom }}</option> + <option value="rightBottom"><i class="ti ti-align-box-right-bottom"></i> {{ i18n.ts.rightBottom }}</option> + </MkRadios> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['stack', 'axis', 'direction']"> + <MkPreferenceContainer k="notificationStackAxis"> + <MkRadios v-model="notificationStackAxis"> + <template #label><SearchLabel>{{ i18n.ts.stackAxis }}</SearchLabel></template> + <option value="vertical"><i class="ti ti-carousel-vertical"></i> {{ i18n.ts.vertical }}</option> + <option value="horizontal"><i class="ti ti-carousel-horizontal"></i> {{ i18n.ts.horizontal }}</option> + </MkRadios> + </MkPreferenceContainer> + </SearchMarker> + + <MkButton @click="testNotification">{{ i18n.ts._notification.checkNotificationBehavior }}</MkButton> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['datasaver']"> + <MkFolder> + <template #label><SearchLabel>{{ i18n.ts.dataSaver }}</SearchLabel></template> + + <div class="_gaps_m"> + <MkInfo>{{ i18n.ts.reloadRequiredToApplySettings }}</MkInfo> + + <div class="_buttons"> + <MkButton inline @click="enableAllDataSaver">{{ i18n.ts.enableAll }}</MkButton> + <MkButton inline @click="disableAllDataSaver">{{ i18n.ts.disableAll }}</MkButton> + </div> + <div class="_gaps_m"> + <MkSwitch v-model="dataSaver.media"> + {{ i18n.ts._dataSaver._media.title }} + <template #caption>{{ i18n.ts._dataSaver._media.description }}</template> + </MkSwitch> + <MkSwitch v-model="dataSaver.avatar"> + {{ i18n.ts._dataSaver._avatar.title }} + <template #caption>{{ i18n.ts._dataSaver._avatar.description }}</template> + </MkSwitch> + <MkSwitch v-model="dataSaver.urlPreview"> + {{ i18n.ts._dataSaver._urlPreview.title }} + <template #caption>{{ i18n.ts._dataSaver._urlPreview.description }}</template> + </MkSwitch> + <MkSwitch v-model="dataSaver.code"> + {{ i18n.ts._dataSaver._code.title }} + <template #caption>{{ i18n.ts._dataSaver._code.description }}</template> + </MkSwitch> + </div> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['other']"> + <MkFolder> + <template #label><SearchLabel>{{ i18n.ts.other }}</SearchLabel></template> + + <div class="_gaps_m"> + <div class="_gaps_s"> + <SearchMarker :keywords="['avatar', 'icon', 'square']"> + <MkPreferenceContainer k="squareAvatars"> + <MkSwitch v-model="squareAvatars"> + <template #label><SearchLabel>{{ i18n.ts.squareAvatars }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['effect', 'show']"> + <MkPreferenceContainer k="enableSeasonalScreenEffect"> + <MkSwitch v-model="enableSeasonalScreenEffect"> + <template #label><SearchLabel>{{ i18n.ts.seasonalScreenEffect }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['image', 'photo', 'picture', 'media', 'thumbnail', 'new', 'tab']"> + <MkPreferenceContainer k="imageNewTab"> + <MkSwitch v-model="imageNewTab"> + <template #label><SearchLabel>{{ i18n.ts.openImageInNewTab }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['follow', 'replies']"> + <MkPreferenceContainer k="defaultFollowWithReplies"> + <MkSwitch v-model="defaultFollowWithReplies"> + <template #label><SearchLabel>{{ i18n.ts.withRepliesByDefaultForNewlyFollowed }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + </div> + + <SearchMarker :keywords="['server', 'disconnect', 'reconnect', 'reload', 'streaming']"> + <MkPreferenceContainer k="serverDisconnectedBehavior"> + <MkSelect v-model="serverDisconnectedBehavior"> + <template #label><SearchLabel>{{ i18n.ts.whenServerDisconnected }}</SearchLabel></template> + <option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option> + <option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option> + <option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['cache', 'page']"> + <MkPreferenceContainer k="numberOfPageCache"> + <MkRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing> + <template #label><SearchLabel>{{ i18n.ts.numberOfPageCache }}</SearchLabel></template> + <template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template> + </MkRange> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['ad', 'show']"> + <MkPreferenceContainer k="forceShowAds"> + <MkSwitch v-model="forceShowAds"> + <template #label><SearchLabel>{{ i18n.ts.forceShowAds }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker> + <MkPreferenceContainer k="hemisphere"> + <MkRadios v-model="hemisphere"> + <template #label><SearchLabel>{{ i18n.ts.hemisphere }}</SearchLabel></template> + <option value="N">{{ i18n.ts._hemisphere.N }}</option> + <option value="S">{{ i18n.ts._hemisphere.S }}</option> + <template #caption>{{ i18n.ts._hemisphere.caption }}</template> + </MkRadios> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['emoji', 'dictionary', 'additional', 'extra']"> + <MkFolder> + <template #label><SearchLabel>{{ i18n.ts.additionalEmojiDictionary }}</SearchLabel></template> + <div class="_buttons"> + <template v-for="lang in emojiIndexLangs" :key="lang"> + <MkButton v-if="store.r.additionalUnicodeEmojiIndexes.value[lang]" danger @click="removeEmojiIndex(lang)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }} ({{ getEmojiIndexLangName(lang) }})</MkButton> + <MkButton v-else @click="downloadEmojiIndex(lang)"><i class="ti ti-download"></i> {{ getEmojiIndexLangName(lang) }}{{ store.r.additionalUnicodeEmojiIndexes.value[lang] ? ` (${ i18n.ts.installed })` : '' }}</MkButton> + </template> + </div> + </MkFolder> + </SearchMarker> + </div> + </MkFolder> + </SearchMarker> + </div> + + <hr> + + <div class="_gaps_s"> + <FormLink to="/settings/navbar"><template #icon><i class="ti ti-list"></i></template>{{ i18n.ts.navbar }}</FormLink> + <FormLink to="/settings/statusbar"><template #icon><i class="ti ti-list"></i></template>{{ i18n.ts.statusbar }}</FormLink> + <FormLink to="/settings/deck"><template #icon><i class="ti ti-columns"></i></template>{{ i18n.ts.deck }}</FormLink> + <FormLink to="/settings/custom-css"><template #icon><i class="ti ti-code"></i></template>{{ i18n.ts.customCss }}</FormLink> + </div> + </div> +</SearchMarker> +</template> + +<script lang="ts" setup> +import { computed, ref, watch } from 'vue'; +import { langs } from '@@/js/config.js'; +import * as Misskey from 'misskey-js'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkRadios from '@/components/MkRadios.vue'; +import MkRange from '@/components/MkRange.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkButton from '@/components/MkButton.vue'; +import FormSection from '@/components/form/section.vue'; +import FormLink from '@/components/form/link.vue'; +import MkLink from '@/components/MkLink.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import { store } from '@/store.js'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { reloadAsk } from '@/utility/reload-ask.js'; +import { i18n } from '@/i18n.js'; +import { definePage } from '@/page.js'; +import { miLocalStorage } from '@/local-storage.js'; +import { prefer } from '@/preferences.js'; +import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; +import { globalEvents } from '@/events.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { instance } from '@/instance.js'; + +const lang = ref(miLocalStorage.getItem('lang')); +const dataSaver = ref(prefer.s.dataSaver); + +const overridedDeviceKind = prefer.model('overridedDeviceKind'); +const keepCw = prefer.model('keepCw'); +const serverDisconnectedBehavior = prefer.model('serverDisconnectedBehavior'); +const hemisphere = prefer.model('hemisphere'); +const showNoteActionsOnlyHover = prefer.model('showNoteActionsOnlyHover'); +const showClipButtonInNoteFooter = prefer.model('showClipButtonInNoteFooter'); +const collapseRenotes = prefer.model('collapseRenotes'); +const advancedMfm = prefer.model('advancedMfm'); +const showReactionsCount = prefer.model('showReactionsCount'); +const enableQuickAddMfmFunction = prefer.model('enableQuickAddMfmFunction'); +const forceShowAds = prefer.model('forceShowAds'); +const loadRawImages = prefer.model('loadRawImages'); +const imageNewTab = prefer.model('imageNewTab'); +const showFixedPostForm = prefer.model('showFixedPostForm'); +const showFixedPostFormInChannel = prefer.model('showFixedPostFormInChannel'); +const numberOfPageCache = prefer.model('numberOfPageCache'); +const enableInfiniteScroll = prefer.model('enableInfiniteScroll'); +const useReactionPickerForContextMenu = prefer.model('useReactionPickerForContextMenu'); +const disableStreamingTimeline = prefer.model('disableStreamingTimeline'); +const useGroupedNotifications = prefer.model('useGroupedNotifications'); +const alwaysConfirmFollow = prefer.model('alwaysConfirmFollow'); +const confirmWhenRevealingSensitiveMedia = prefer.model('confirmWhenRevealingSensitiveMedia'); +const confirmOnReact = prefer.model('confirmOnReact'); +const defaultNoteVisibility = prefer.model('defaultNoteVisibility'); +const defaultNoteLocalOnly = prefer.model('defaultNoteLocalOnly'); +const rememberNoteVisibility = prefer.model('rememberNoteVisibility'); +const showGapBetweenNotesInTimeline = prefer.model('showGapBetweenNotesInTimeline'); +const notificationPosition = prefer.model('notificationPosition'); +const notificationStackAxis = prefer.model('notificationStackAxis'); +const instanceTicker = prefer.model('instanceTicker'); +const highlightSensitiveMedia = prefer.model('highlightSensitiveMedia'); +const mediaListWithOneImageAppearance = prefer.model('mediaListWithOneImageAppearance'); +const reactionsDisplaySize = prefer.model('reactionsDisplaySize'); +const limitWidthOfReaction = prefer.model('limitWidthOfReaction'); +const squareAvatars = prefer.model('squareAvatars'); +const enableSeasonalScreenEffect = prefer.model('enableSeasonalScreenEffect'); +const showAvatarDecorations = prefer.model('showAvatarDecorations'); +const nsfw = prefer.model('nsfw'); +const emojiStyle = prefer.model('emojiStyle'); +const useBlurEffectForModal = prefer.model('useBlurEffectForModal'); +const useBlurEffect = prefer.model('useBlurEffect'); +const defaultFollowWithReplies = prefer.model('defaultFollowWithReplies'); + +watch(lang, () => { + miLocalStorage.setItem('lang', lang.value as string); + miLocalStorage.removeItem('locale'); + miLocalStorage.removeItem('localeVersion'); +}); + +watch([ + hemisphere, + lang, + enableInfiniteScroll, + showNoteActionsOnlyHover, + overridedDeviceKind, + disableStreamingTimeline, + alwaysConfirmFollow, + confirmWhenRevealingSensitiveMedia, + showGapBetweenNotesInTimeline, + mediaListWithOneImageAppearance, + reactionsDisplaySize, + limitWidthOfReaction, + mediaListWithOneImageAppearance, + reactionsDisplaySize, + limitWidthOfReaction, + instanceTicker, + squareAvatars, + highlightSensitiveMedia, + enableSeasonalScreenEffect, +], async () => { + await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); +}); + +const emojiIndexLangs = ['en-US', 'ja-JP', 'ja-JP_hira'] as const; + +function getEmojiIndexLangName(targetLang: typeof emojiIndexLangs[number]) { + if (langs.find(x => x[0] === targetLang)) { + return langs.find(x => x[0] === targetLang)![1]; + } else { + // 絵文字辞書限定の言語定義 + switch (targetLang) { + case 'ja-JP_hira': return 'ひらがな'; + default: return targetLang; + } + } +} + +function downloadEmojiIndex(lang: typeof emojiIndexLangs[number]) { + async function main() { + const currentIndexes = store.s.additionalUnicodeEmojiIndexes; + + function download() { + switch (lang) { + case 'en-US': return import('../../unicode-emoji-indexes/en-US.json').then(x => x.default); + case 'ja-JP': return import('../../unicode-emoji-indexes/ja-JP.json').then(x => x.default); + case 'ja-JP_hira': return import('../../unicode-emoji-indexes/ja-JP_hira.json').then(x => x.default); + default: throw new Error('unrecognized lang: ' + lang); + } + } + + currentIndexes[lang] = await download(); + await store.set('additionalUnicodeEmojiIndexes', currentIndexes); + } + + os.promiseDialog(main()); +} + +function removeEmojiIndex(lang: string) { + async function main() { + const currentIndexes = store.s.additionalUnicodeEmojiIndexes; + delete currentIndexes[lang]; + await store.set('additionalUnicodeEmojiIndexes', currentIndexes); + } + + os.promiseDialog(main()); +} + +async function setPinnedList() { + const lists = await misskeyApi('users/lists/list'); + const { canceled, result: list } = await os.select({ + title: i18n.ts.selectList, + items: lists.map(x => ({ + value: x, text: x.name, + })), + }); + if (canceled) return; + if (list == null) return; + + prefer.commit('pinnedUserLists', [list]); +} + +function removePinnedList() { + prefer.commit('pinnedUserLists', []); +} + +function enableAllDataSaver() { + const g = { ...prefer.s.dataSaver }; + + Object.keys(g).forEach((key) => { g[key] = true; }); + + dataSaver.value = g; +} + +function disableAllDataSaver() { + const g = { ...prefer.s.dataSaver }; + + Object.keys(g).forEach((key) => { g[key] = false; }); + + dataSaver.value = g; +} + +watch(dataSaver, (to) => { + prefer.commit('dataSaver', to); +}, { + deep: true, +}); + +let smashCount = 0; +let smashTimer: number | null = null; + +function testNotification(): void { + const notification: Misskey.entities.Notification = { + id: Math.random().toString(), + createdAt: new Date().toUTCString(), + isRead: false, + type: 'test', + }; + + globalEvents.emit('clientNotification', notification); + + // セルフ通知破壊 実績関連 + smashCount++; + if (smashCount >= 10) { + claimAchievement('smashTestNotificationButton'); + smashCount = 0; + } + if (smashTimer) { + clearTimeout(smashTimer); + } + smashTimer = window.setTimeout(() => { + smashCount = 0; + }, 300); +} + +const headerActions = computed(() => []); + +const headerTabs = computed(() => []); + +definePage(() => ({ + title: i18n.ts.general, + icon: 'ti ti-adjustments', +})); +</script> diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index bcedb8b139..f672d9d44f 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -4,190 +4,263 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_gaps_m"> - <MkSwitch v-model="isLocked" @update:modelValue="save()">{{ i18n.ts.makeFollowManuallyApprove }}<template #caption>{{ i18n.ts.lockedAccountInfo }}</template></MkSwitch> - <MkSwitch v-if="isLocked" v-model="autoAcceptFollowed" @update:modelValue="save()">{{ i18n.ts.autoAcceptFollowed }}</MkSwitch> +<SearchMarker path="/settings/privacy" :label="i18n.ts.privacy" :keywords="['privacy']" icon="ti ti-lock-open"> + <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/unlocked_3d.png" color="#aeff00"> + <SearchKeyword>{{ i18n.ts._settings.privacyBanner }}</SearchKeyword> + </MkFeatureBanner> - <MkSwitch v-model="publicReactions" @update:modelValue="save()"> - {{ i18n.ts.makeReactionsPublic }} - <template #caption>{{ i18n.ts.makeReactionsPublicDescription }}</template> - </MkSwitch> + <SearchMarker :keywords="['follow', 'lock']"> + <MkSwitch v-model="isLocked" @update:modelValue="save()"> + <template #label><SearchLabel>{{ i18n.ts.makeFollowManuallyApprove }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.lockedAccountInfo }}</SearchKeyword></template> + </MkSwitch> + </SearchMarker> - <MkSelect v-model="followingVisibility" @update:modelValue="save()"> - <template #label>{{ i18n.ts.followingVisibility }}</template> - <option value="public">{{ i18n.ts._ffVisibility.public }}</option> - <option value="followers">{{ i18n.ts._ffVisibility.followers }}</option> - <option value="private">{{ i18n.ts._ffVisibility.private }}</option> - </MkSelect> + <MkDisableSection :disabled="!isLocked"> + <SearchMarker :keywords="['follow', 'auto', 'accept']"> + <MkSwitch v-model="autoAcceptFollowed" @update:modelValue="save()"> + <template #label><SearchLabel>{{ i18n.ts.autoAcceptFollowed }}</SearchLabel></template> + </MkSwitch> + </SearchMarker> + </MkDisableSection> - <MkSelect v-model="followersVisibility" @update:modelValue="save()"> - <template #label>{{ i18n.ts.followersVisibility }}</template> - <option value="public">{{ i18n.ts._ffVisibility.public }}</option> - <option value="followers">{{ i18n.ts._ffVisibility.followers }}</option> - <option value="private">{{ i18n.ts._ffVisibility.private }}</option> - </MkSelect> + <SearchMarker :keywords="['reaction', 'public']"> + <MkSwitch v-model="publicReactions" @update:modelValue="save()"> + <template #label><SearchLabel>{{ i18n.ts.makeReactionsPublic }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.makeReactionsPublicDescription }}</SearchKeyword></template> + </MkSwitch> + </SearchMarker> - <MkSwitch v-model="hideOnlineStatus" @update:modelValue="save()"> - {{ i18n.ts.hideOnlineStatus }} - <template #caption>{{ i18n.ts.hideOnlineStatusDescription }}</template> - </MkSwitch> - <MkSwitch v-model="noCrawle" @update:modelValue="save()"> - {{ i18n.ts.noCrawle }} - <template #caption>{{ i18n.ts.noCrawleDescription }}</template> - </MkSwitch> - <MkSwitch v-model="noindex" @update:modelValue="save()"> - {{ i18n.ts.makeIndexable }} - <template #caption>{{ i18n.ts.makeIndexableDescription }}</template> - </MkSwitch> - <MkSwitch v-model="isExplorable" @update:modelValue="save()"> - {{ i18n.ts.makeExplorable }} - <template #caption>{{ i18n.ts.makeExplorableDescription }}</template> - </MkSwitch> - <MkSwitch v-model="enableRss" @update:modelValue="save()"> - {{ i18n.ts.enableRss }} - <template #caption>{{ i18n.ts.enableRssDescription }}</template> - </MkSwitch> + <SearchMarker :keywords="['following', 'visibility']"> + <MkSelect v-model="followingVisibility" @update:modelValue="save()"> + <template #label><SearchLabel>{{ i18n.ts.followingVisibility }}</SearchLabel></template> + <option value="public">{{ i18n.ts._ffVisibility.public }}</option> + <option value="followers">{{ i18n.ts._ffVisibility.followers }}</option> + <option value="private">{{ i18n.ts._ffVisibility.private }}</option> + </MkSelect> + </SearchMarker> - <FormSection> - <template #label>{{ i18n.ts.lockdown }}<span class="_beta">{{ i18n.ts.beta }}</span></template> + <SearchMarker :keywords="['follower', 'visibility']"> + <MkSelect v-model="followersVisibility" @update:modelValue="save()"> + <template #label><SearchLabel>{{ i18n.ts.followersVisibility }}</SearchLabel></template> + <option value="public">{{ i18n.ts._ffVisibility.public }}</option> + <option value="followers">{{ i18n.ts._ffVisibility.followers }}</option> + <option value="private">{{ i18n.ts._ffVisibility.private }}</option> + </MkSelect> + </SearchMarker> - <div class="_gaps_m"> - <MkSwitch :modelValue="requireSigninToViewContents" @update:modelValue="update_requireSigninToViewContents"> - {{ i18n.ts._accountSettings.requireSigninToViewContents }} - <template #caption> - <div>{{ i18n.ts._accountSettings.requireSigninToViewContentsDescription1 }}</div> - <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription2 }}</div> - <div v-if="instance.federation !== 'none'"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription3 }}</div> - </template> + <SearchMarker :keywords="['online', 'status']"> + <MkSwitch v-model="hideOnlineStatus" @update:modelValue="save()"> + <template #label><SearchLabel>{{ i18n.ts.hideOnlineStatus }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.hideOnlineStatusDescription }}</SearchKeyword></template> </MkSwitch> + </SearchMarker> - <FormSlot> - <template #label>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBefore }}</template> + <SearchMarker :keywords="['crawle', 'index', 'search']"> + <MkSwitch v-model="noCrawle" @update:modelValue="save()"> + <template #label><SearchLabel>{{ i18n.ts.noCrawle }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.noCrawleDescription }}</SearchKeyword></template> + </MkSwitch> + </SearchMarker> - <div class="_gaps_s"> - <MkSelect :modelValue="makeNotesFollowersOnlyBefore_type" @update:modelValue="makeNotesFollowersOnlyBefore = $event === 'relative' ? -604800 : $event === 'absolute' ? Math.floor(Date.now() / 1000) : null"> - <option :value="null">{{ i18n.ts.none }}</option> - <option value="relative">{{ i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod }}</option> - <option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option> - </MkSelect> + <SearchMarker :keywords="['index', 'search']"> + <MkSwitch v-model="noindex" @update:modelValue="save()"> + {{ i18n.ts.makeIndexable }} + <template #caption>{{ i18n.ts.makeIndexableDescription }}</template> + </MkSwitch> + </SearchMarker> - <MkSelect v-if="makeNotesFollowersOnlyBefore_type === 'relative'" v-model="makeNotesFollowersOnlyBefore"> - <option :value="-3600">{{ i18n.ts.oneHour }}</option> - <option :value="-86400">{{ i18n.ts.oneDay }}</option> - <option :value="-259200">{{ i18n.ts.threeDays }}</option> - <option :value="-604800">{{ i18n.ts.oneWeek }}</option> - <option :value="-2592000">{{ i18n.ts.oneMonth }}</option> - <option :value="-7776000">{{ i18n.ts.threeMonths }}</option> - <option :value="-31104000">{{ i18n.ts.oneYear }}</option> - </MkSelect> + <SearchMarker :keywords="['explore']"> + <MkSwitch v-model="isExplorable" @update:modelValue="save()"> + <template #label><SearchLabel>{{ i18n.ts.makeExplorable }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.makeExplorableDescription }}</SearchKeyword></template> + </MkSwitch> + </SearchMarker> - <MkInput - v-if="makeNotesFollowersOnlyBefore_type === 'absolute'" - :modelValue="formatDateTimeString(new Date(makeNotesFollowersOnlyBefore * 1000), 'yyyy-MM-dd')" - type="date" - :manualSave="true" - @update:modelValue="makeNotesFollowersOnlyBefore = Math.floor(new Date($event).getTime() / 1000)" - > - </MkInput> - </div> + <SearchMarker :keywords="['rss', 'feed']"> + <MkSwitch v-model="enableRss" @update:modelValue="save()"> + {{ i18n.ts.enableRss }} + <template #caption>{{ i18n.ts.enableRssDescription }}</template> + </MkSwitch> + </SearchMarker> - <template #caption> - <div>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription }}</div> - <div v-if="instance.federation !== 'none'"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div> - </template> - </FormSlot> + <SearchMarker :keywords="['crawle', 'ai']"> + <MkSwitch v-model="preventAiLearning" @update:modelValue="save()"> + <template #label><SearchLabel>{{ i18n.ts.preventAiLearning }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.preventAiLearningDescription }}</SearchKeyword></template> + </MkSwitch> + </SearchMarker> - <FormSlot> - <template #label>{{ i18n.ts._accountSettings.makeNotesHiddenBefore }}</template> + <FormSection> + <SearchMarker :keywords="['chat']"> + <MkSelect v-model="chatScope" @update:modelValue="save()"> + <template #label><SearchLabel>{{ i18n.ts._chat.chatAllowedUsers }}</SearchLabel></template> + <option value="everyone">{{ i18n.ts._chat._chatAllowedUsers.everyone }}</option> + <option value="followers">{{ i18n.ts._chat._chatAllowedUsers.followers }}</option> + <option value="following">{{ i18n.ts._chat._chatAllowedUsers.following }}</option> + <option value="mutual">{{ i18n.ts._chat._chatAllowedUsers.mutual }}</option> + <option value="none">{{ i18n.ts._chat._chatAllowedUsers.none }}</option> + <template #caption>{{ i18n.ts._chat.chatAllowedUsers_note }}</template> + </MkSelect> + </SearchMarker> + </FormSection> - <div class="_gaps_s"> - <MkSelect :modelValue="makeNotesHiddenBefore_type" @update:modelValue="makeNotesHiddenBefore = $event === 'relative' ? -604800 : $event === 'absolute' ? Math.floor(Date.now() / 1000) : null"> - <option :value="null">{{ i18n.ts.none }}</option> - <option value="relative">{{ i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod }}</option> - <option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option> - </MkSelect> + <SearchMarker :keywords="['lockdown']"> + <FormSection> + <template #label><SearchLabel>{{ i18n.ts.lockdown }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template> - <MkSelect v-if="makeNotesHiddenBefore_type === 'relative'" v-model="makeNotesHiddenBefore"> - <option :value="-3600">{{ i18n.ts.oneHour }}</option> - <option :value="-86400">{{ i18n.ts.oneDay }}</option> - <option :value="-259200">{{ i18n.ts.threeDays }}</option> - <option :value="-604800">{{ i18n.ts.oneWeek }}</option> - <option :value="-2592000">{{ i18n.ts.oneMonth }}</option> - <option :value="-7776000">{{ i18n.ts.threeMonths }}</option> - <option :value="-31104000">{{ i18n.ts.oneYear }}</option> - </MkSelect> + <div class="_gaps_m"> + <SearchMarker :keywords="['login', 'signin']"> + <MkSwitch :modelValue="requireSigninToViewContents" @update:modelValue="update_requireSigninToViewContents"> + <template #label><SearchLabel>{{ i18n.ts._accountSettings.requireSigninToViewContents }}</SearchLabel></template> + <template #caption> + <div>{{ i18n.ts._accountSettings.requireSigninToViewContentsDescription1 }}</div> + <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription2 }}</div> + </template> + </MkSwitch> + </SearchMarker> - <MkInput - v-if="makeNotesHiddenBefore_type === 'absolute'" - :modelValue="formatDateTimeString(new Date(makeNotesHiddenBefore * 1000), 'yyyy-MM-dd')" - type="date" - :manualSave="true" - @update:modelValue="makeNotesHiddenBefore = Math.floor(new Date($event).getTime() / 1000)" - > - </MkInput> - </div> + <SearchMarker :keywords="['follower']"> + <FormSlot> + <template #label><SearchLabel>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBefore }}</SearchLabel></template> - <template #caption> - <div>{{ i18n.ts._accountSettings.makeNotesHiddenBeforeDescription }}</div> - <div v-if="instance.federation !== 'none'"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div> - </template> - </FormSlot> + <div class="_gaps_s"> + <MkSelect :modelValue="makeNotesFollowersOnlyBefore_type" @update:modelValue="makeNotesFollowersOnlyBefore = $event === 'relative' ? -604800 : $event === 'absolute' ? Math.floor(Date.now() / 1000) : null"> + <option :value="null">{{ i18n.ts.none }}</option> + <option value="relative">{{ i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod }}</option> + <option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option> + </MkSelect> - <MkFolder v-if="instance.federation !== 'none'"> - <template #label>{{ i18n.ts.authorizedFetchSection }}</template> - <template #suffix>{{ computedAllowUnsignedFetch !== 'always' ? i18n.ts.enabled : i18n.ts.disabled }}</template> + <MkSelect v-if="makeNotesFollowersOnlyBefore_type === 'relative'" v-model="makeNotesFollowersOnlyBefore"> + <option :value="-3600">{{ i18n.ts.oneHour }}</option> + <option :value="-86400">{{ i18n.ts.oneDay }}</option> + <option :value="-259200">{{ i18n.ts.threeDays }}</option> + <option :value="-604800">{{ i18n.ts.oneWeek }}</option> + <option :value="-2592000">{{ i18n.ts.oneMonth }}</option> + <option :value="-7776000">{{ i18n.ts.threeMonths }}</option> + <option :value="-31104000">{{ i18n.ts.oneYear }}</option> + </MkSelect> - <MkRadios v-model="allowUnsignedFetch" @update:modelValue="save()"> - <template #label>{{ i18n.ts.authorizedFetchLabel }}</template> - <template #caption>{{ i18n.ts.authorizedFetchDescription }}</template> - <option value="never">{{ i18n.ts._authorizedFetchValue.never }} - {{ i18n.ts._authorizedFetchValueDescription.never }}</option> - <option value="always">{{ i18n.ts._authorizedFetchValue.always }} - {{ i18n.ts._authorizedFetchValueDescription.always }}</option> - <option value="essential">{{ i18n.ts._authorizedFetchValue.essential }} - {{ i18n.ts._authorizedFetchValueDescription.essential }}</option> - <option value="staff">{{ i18n.ts._authorizedFetchValue.staff }} - {{ i18n.tsx._authorizedFetchValueDescription.staff({ value: i18n.ts._authorizedFetchValue[instance.allowUnsignedFetch] }) }}</option> - </MkRadios> - </MkFolder> - </div> - </FormSection> + <MkInput + v-if="makeNotesFollowersOnlyBefore_type === 'absolute'" + :modelValue="formatDateTimeString(new Date(makeNotesFollowersOnlyBefore * 1000), 'yyyy-MM-dd')" + type="date" + :manualSave="true" + @update:modelValue="makeNotesFollowersOnlyBefore = Math.floor(new Date($event).getTime() / 1000)" + > + </MkInput> + </div> - <FormSection> - <div class="_gaps_m"> - <MkSwitch v-model="rememberNoteVisibility" @update:modelValue="save()">{{ i18n.ts.rememberNoteVisibility }}</MkSwitch> - <MkFolder v-if="!rememberNoteVisibility"> - <template #label>{{ i18n.ts.defaultNoteVisibility }}</template> - <template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template> - <template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</template> - <template v-else-if="defaultNoteVisibility === 'followers'" #suffix>{{ i18n.ts._visibility.followers }}</template> - <template v-else-if="defaultNoteVisibility === 'specified'" #suffix>{{ i18n.ts._visibility.specified }}</template> + <template #caption> + <div><SearchKeyword>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription }}</SearchKeyword></div> + </template> + </FormSlot> + </SearchMarker> - <div class="_gaps_m"> - <MkSelect v-model="defaultNoteVisibility"> - <option value="public">{{ i18n.ts._visibility.public }}</option> - <option value="home">{{ i18n.ts._visibility.home }}</option> - <option value="followers">{{ i18n.ts._visibility.followers }}</option> - <option value="specified">{{ i18n.ts._visibility.specified }}</option> - </MkSelect> - <MkSwitch v-model="defaultNoteLocalOnly">{{ i18n.ts._visibility.disableFederation }}</MkSwitch> - </div> - </MkFolder> + <SearchMarker :keywords="['hidden']"> + <FormSlot> + <template #label><SearchLabel>{{ i18n.ts._accountSettings.makeNotesHiddenBefore }}</SearchLabel></template> - <MkSwitch v-model="keepCw" @update:modelValue="save()">{{ i18n.ts.keepCw }}</MkSwitch> + <div class="_gaps_s"> + <MkSelect :modelValue="makeNotesHiddenBefore_type" @update:modelValue="makeNotesHiddenBefore = $event === 'relative' ? -604800 : $event === 'absolute' ? Math.floor(Date.now() / 1000) : null"> + <option :value="null">{{ i18n.ts.none }}</option> + <option value="relative">{{ i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod }}</option> + <option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option> + </MkSelect> - <MkInput v-model="defaultCW" type="text" manualSave @update:modelValue="save()"> - <template #label>{{ i18n.ts.defaultCW }}</template> - <template #caption>{{ i18n.ts.defaultCWDescription }}</template> - </MkInput> + <MkSelect v-if="makeNotesHiddenBefore_type === 'relative'" v-model="makeNotesHiddenBefore"> + <option :value="-3600">{{ i18n.ts.oneHour }}</option> + <option :value="-86400">{{ i18n.ts.oneDay }}</option> + <option :value="-259200">{{ i18n.ts.threeDays }}</option> + <option :value="-604800">{{ i18n.ts.oneWeek }}</option> + <option :value="-2592000">{{ i18n.ts.oneMonth }}</option> + <option :value="-7776000">{{ i18n.ts.threeMonths }}</option> + <option :value="-31104000">{{ i18n.ts.oneYear }}</option> + </MkSelect> - <MkSelect v-model="defaultCWPriority" :disabled="!defaultCW || !keepCw" @update:modelValue="save()"> - <template #label>{{ i18n.ts.defaultCWPriority }}</template> - <template #caption>{{ i18n.ts.defaultCWPriorityDescription }}</template> - <option value="default">{{ i18n.ts._defaultCWPriority.default }}</option> - <option value="parent">{{ i18n.ts._defaultCWPriority.parent }}</option> - <option value="parentDefault">{{ i18n.ts._defaultCWPriority.parentDefault }}</option> - <option value="defaultParent">{{ i18n.ts._defaultCWPriority.defaultParent }}</option> - </MkSelect> - </div> - </FormSection> -</div> + <MkInput + v-if="makeNotesHiddenBefore_type === 'absolute'" + :modelValue="formatDateTimeString(new Date(makeNotesHiddenBefore * 1000), 'yyyy-MM-dd')" + type="date" + :manualSave="true" + @update:modelValue="makeNotesHiddenBefore = Math.floor(new Date($event).getTime() / 1000)" + > + </MkInput> + </div> + + <template #caption> + <div><SearchKeyword>{{ i18n.ts._accountSettings.makeNotesHiddenBeforeDescription }}</SearchKeyword></div> + <div v-if="instance.federation !== 'none'"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div> + </template> + </FormSlot> + </SearchMarker> + + <SearchMarker :keywords="['federate', 'auth', 'fetch']"> + <MkFolder v-if="instance.federation !== 'none'"> + <template #label>{{ i18n.ts.authorizedFetchSection }}</template> + <template #suffix>{{ computedAllowUnsignedFetch !== 'always' ? i18n.ts.enabled : i18n.ts.disabled }}</template> + + <MkRadios v-model="allowUnsignedFetch" @update:modelValue="save()"> + <template #label>{{ i18n.ts.authorizedFetchLabel }}</template> + <template #caption>{{ i18n.ts.authorizedFetchDescription }}</template> + <option value="never">{{ i18n.ts._authorizedFetchValue.never }} - {{ i18n.ts._authorizedFetchValueDescription.never }}</option> + <option value="always">{{ i18n.ts._authorizedFetchValue.always }} - {{ i18n.ts._authorizedFetchValueDescription.always }}</option> + <option value="essential">{{ i18n.ts._authorizedFetchValue.essential }} - {{ i18n.ts._authorizedFetchValueDescription.essential }}</option> + <option value="staff">{{ i18n.ts._authorizedFetchValue.staff }} - {{ i18n.tsx._authorizedFetchValueDescription.staff({ value: i18n.ts._authorizedFetchValue[instance.allowUnsignedFetch] }) }}</option> + </MkRadios> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['note', 'visib']"> + <div class="_gaps_m"> + <MkSwitch v-model="rememberNoteVisibility" @update:modelValue="save()">{{ i18n.ts.rememberNoteVisibility }}</MkSwitch> + <MkFolder v-if="!rememberNoteVisibility"> + <template #label>{{ i18n.ts.defaultNoteVisibility }}</template> + <template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template> + <template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</template> + <template v-else-if="defaultNoteVisibility === 'followers'" #suffix>{{ i18n.ts._visibility.followers }}</template> + <template v-else-if="defaultNoteVisibility === 'specified'" #suffix>{{ i18n.ts._visibility.specified }}</template> + + <div class="_gaps_m"> + <MkSelect v-model="defaultNoteVisibility"> + <option value="public">{{ i18n.ts._visibility.public }}</option> + <option value="home">{{ i18n.ts._visibility.home }}</option> + <option value="followers">{{ i18n.ts._visibility.followers }}</option> + <option value="specified">{{ i18n.ts._visibility.specified }}</option> + </MkSelect> + <MkSwitch v-model="defaultNoteLocalOnly">{{ i18n.ts._visibility.disableFederation }}</MkSwitch> + </div> + </MkFolder> + </div> + </SearchMarker> + + <SearchMarker :keywords="['keep', 'cw', 'content', 'warning']"> + <div class="_gaps_m"> + <MkSwitch v-model="keepCw" @update:modelValue="save()">{{ i18n.ts.keepCw }}</MkSwitch> + + <MkInput v-model="defaultCW" type="text" manualSave @update:modelValue="save()"> + <template #label>{{ i18n.ts.defaultCW }}</template> + <template #caption>{{ i18n.ts.defaultCWDescription }}</template> + </MkInput> + + <MkSelect v-model="defaultCWPriority" :disabled="!defaultCW || !keepCw" @update:modelValue="save()"> + <template #label>{{ i18n.ts.defaultCWPriority }}</template> + <template #caption>{{ i18n.ts.defaultCWPriorityDescription }}</template> + <option value="default">{{ i18n.ts._defaultCWPriority.default }}</option> + <option value="parent">{{ i18n.ts._defaultCWPriority.parent }}</option> + <option value="parentDefault">{{ i18n.ts._defaultCWPriority.parentDefault }}</option> + <option value="defaultParent">{{ i18n.ts._defaultCWPriority.defaultParent }}</option> + </MkSelect> + </div> + </SearchMarker> + + <MkInfo warn>{{ i18n.ts._accountSettings.mayNotEffectSomeSituations }}</MkInfo> + </div> + </FormSection> + </SearchMarker> + </div> +</SearchMarker> </template> <script lang="ts" setup> @@ -196,19 +269,21 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkSelect from '@/components/MkSelect.vue'; import FormSection from '@/components/form/section.vue'; import MkFolder from '@/components/MkFolder.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { signinRequired } from '@/account.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { ensureSignin } from '@/i.js'; +import { definePage } from '@/page.js'; import FormSlot from '@/components/form/slot.vue'; -import { formatDateTimeString } from '@/scripts/format-time-string.js'; +import { formatDateTimeString } from '@/utility/format-time-string.js'; import MkInput from '@/components/MkInput.vue'; import * as os from '@/os.js'; +import MkDisableSection from '@/components/MkDisableSection.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; import MkRadios from '@/components/MkRadios.vue'; -const $i = signinRequired(); +const $i = ensureSignin(); const isLocked = ref($i.isLocked); const autoAcceptFollowed = ref($i.autoAcceptFollowed); @@ -223,6 +298,7 @@ const hideOnlineStatus = ref($i.hideOnlineStatus); const publicReactions = ref($i.publicReactions); const followingVisibility = ref($i.followingVisibility); const followersVisibility = ref($i.followersVisibility); +const chatScope = ref($i.chatScope); const defaultCW = ref($i.defaultCW); const defaultCWPriority = ref($i.defaultCWPriority); const allowUnsignedFetch = ref($i.allowUnsignedFetch); @@ -290,6 +366,7 @@ function save() { publicReactions: !!publicReactions.value, followingVisibility: followingVisibility.value, followersVisibility: followersVisibility.value, + chatScope: chatScope.value, defaultCWPriority: defaultCWPriority.value, defaultCW: defaultCW.value, allowUnsignedFetch: allowUnsignedFetch.value, @@ -300,7 +377,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.privacy, icon: 'ti ti-lock-open', })); diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 67ba833c5c..fc2743df0f 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -4,122 +4,168 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_gaps_m"> - <div class="_panel"> - <div :class="$style.banner" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }"> - <MkButton primary rounded :class="$style.bannerEdit" @click="changeOrRemoveBanner">{{ i18n.ts._profile.changeBanner }}</MkButton> - <MkButton primary rounded :class="$style.backgroundEdit" @click="changeOrRemoveBackground">{{ i18n.ts._profile.changeBackground }}</MkButton> - </div> - <div :class="$style.avatarContainer"> - <MkAvatar :class="$style.avatar" :user="$i" forceShowDecoration @click="changeOrRemoveAvatar"/> - <div class="_buttonsCenter"> - <MkButton primary rounded @click="changeOrRemoveAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton> - <MkButton primary rounded link to="/settings/avatar-decoration">{{ i18n.ts.decorate }} <i class="ti ti-sparkles"></i></MkButton> +<SearchMarker path="/settings/profile" :label="i18n.ts.profile" :keywords="['profile']" icon="ti ti-user"> + <div class="_gaps_m"> + <div class="_panel"> + <div :class="$style.banner" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }"> + <div :class="$style.bannerEdit"> + <SearchMarker :keywords="['banner', 'change', 'remove']"> + <MkButton primary rounded @click="changeOrRemoveBanner">{{ <SearchLabel>{{ i18n.ts._profile.changeBanner }}</SearchLabel> }}</MkButton> + </SearchMarker> + </div> + <div :class="$style.backgroundEdit"> + <SearchMarker :keywords="['background', 'change', 'remove']"> + <MkButton primary rounded @click="changeOrRemoveBackground">{{ <SearchLabel>{{ i18n.ts._profile.changeBackground }}</SearchLabel> }}</MkButton> + </SearchMarker> + </div> + </div> + <div :class="$style.avatarContainer"> + <MkAvatar :class="$style.avatar" :user="$i" forceShowDecoration @click="changeOrRemoveAvatar"/> + <div class="_buttonsCenter"> + <SearchMarker :keywords="['avatar', 'icon', 'change']"> + <MkButton primary rounded @click="changeOrRemoveAvatar"><SearchLabel>{{ i18n.ts._profile.changeAvatar }}</SearchLabel></MkButton> + </SearchMarker> + <MkButton primary rounded link to="/settings/avatar-decoration">{{ i18n.ts.decorate }} <i class="ti ti-sparkles"></i></MkButton> + </div> </div> </div> - </div> - - <MkInput v-model="profile.name" :max="30" manualSave :mfmAutocomplete="['emoji']"> - <template #label>{{ i18n.ts._profile.name }}</template> - </MkInput> - <MkTextarea v-model="profile.description" :max="500" tall manualSave mfmAutocomplete :mfmPreview="true"> - <template #label>{{ i18n.ts._profile.description }}</template> - <template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template> - </MkTextarea> + <SearchMarker :keywords="['name']"> + <MkInput v-model="profile.name" :max="30" manualSave :mfmAutocomplete="['emoji']"> + <template #label><SearchLabel>{{ i18n.ts._profile.name }}</SearchLabel></template> + </MkInput> + </SearchMarker> - <MkInput v-model="profile.location" manualSave> - <template #label>{{ i18n.ts.location }}</template> - <template #prefix><i class="ti ti-map-pin"></i></template> - </MkInput> + <SearchMarker :keywords="['description', 'bio']"> + <MkTextarea v-model="profile.description" :max="500" tall manualSave mfmAutocomplete :mfmPreview="true"> + <template #label><SearchLabel>{{ i18n.ts._profile.description }}</SearchLabel></template> + <template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template> + </MkTextarea> + </SearchMarker> - <MkInput v-model="profile.birthday" :max="setMaxBirthDate()" type="date" manualSave> - <template #label>{{ i18n.ts.birthday }}</template> - <template #prefix><i class="ti ti-cake"></i></template> - </MkInput> + <SearchMarker :keywords="['location', 'locale']"> + <MkInput v-model="profile.location" manualSave> + <template #label><SearchLabel>{{ i18n.ts.location }}</SearchLabel></template> + <template #prefix><i class="ti ti-map-pin"></i></template> + </MkInput> + </SearchMarker> - <MkInput v-model="profile.listenbrainz" manualSave> - <template #label>{{ i18n.ts._profile.listenbrainz }}</template> - <template #prefix><i class="ph-headphones ph-bold ph-lg"></i></template> - </MkInput> + <SearchMarker :keywords="['birthday', 'birthdate', 'age']"> + <MkInput v-model="profile.birthday" type="date" manualSave> + <template #label><SearchLabel>{{ i18n.ts.birthday }}</SearchLabel></template> + <template #prefix><i class="ti ti-cake"></i></template> + </MkInput> + </SearchMarker> - <MkSelect v-model="profile.lang"> - <template #label>{{ i18n.ts.language }}</template> - <option v-for="x in Object.keys(langmap)" :key="x" :value="x">{{ langmap[x].nativeName }}</option> - </MkSelect> - - <FormSlot> - <MkFolder> - <template #icon><i class="ti ti-list"></i></template> - <template #label>{{ i18n.ts._profile.metadataEdit }}</template> - <template #footer> - <div class="_buttons"> - <MkButton primary @click="saveFields"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> - <MkButton :disabled="fields.length >= 16" @click="addField"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> - <MkButton v-if="!fieldEditMode" :disabled="fields.length <= 1" danger @click="fieldEditMode = !fieldEditMode"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> - <MkButton v-else @click="fieldEditMode = !fieldEditMode"><i class="ti ti-arrows-sort"></i> {{ i18n.ts.rearrange }}</MkButton> - </div> - </template> + <SearchMarker :keywords="['listenbrain', 'music']"> + <MkInput v-model="profile.listenbrainz" manualSave> + <template #label><SearchLabel>{{ i18n.ts._profile.listenbrainz }}</SearchLabel></template> + <template #prefix><i class="ph-headphones ph-bold ph-lg"></i></template> + </MkInput> + </SearchMarker> - <div :class="$style.metadataRoot" class="_gaps_s"> - <MkInfo>{{ i18n.ts._profile.verifiedLinkDescription }}</MkInfo> + <SearchMarker :keywords="['language', 'locale']"> + <MkSelect v-model="profile.lang"> + <template #label><SearchLabel>{{ i18n.ts.language }}</SearchLabel></template> + <option v-for="x in Object.keys(langmap)" :key="x" :value="x">{{ langmap[x].nativeName }}</option> + </MkSelect> + </SearchMarker> - <Sortable - v-model="fields" - class="_gaps_s" - itemKey="id" - :animation="150" - :handle="'.' + $style.dragItemHandle" - @start="e => e.item.classList.add('active')" - @end="e => e.item.classList.remove('active')" - > - <template #item="{element, index}"> - <div v-panel :class="$style.fieldDragItem"> - <button v-if="!fieldEditMode" class="_button" :class="$style.dragItemHandle" tabindex="-1"><i class="ti ti-menu"></i></button> - <button v-if="fieldEditMode" :disabled="fields.length <= 1" class="_button" :class="$style.dragItemRemove" @click="deleteField(index)"><i class="ti ti-x"></i></button> - <div :class="$style.dragItemForm"> - <FormSplit :minWidth="200"> - <MkInput v-model="element.name" small :placeholder="i18n.ts._profile.metadataLabel"> - </MkInput> - <MkInput v-model="element.value" small :placeholder="i18n.ts._profile.metadataContent"> - </MkInput> - </FormSplit> - </div> + <SearchMarker :keywords="['metadata']"> + <FormSlot> + <MkFolder> + <template #icon><i class="ti ti-list"></i></template> + <template #label><SearchLabel>{{ i18n.ts._profile.metadataEdit }}</SearchLabel></template> + <template #footer> + <div class="_buttons"> + <MkButton primary @click="saveFields"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> + <MkButton :disabled="fields.length >= 16" @click="addField"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + <MkButton v-if="!fieldEditMode" :disabled="fields.length <= 1" danger @click="fieldEditMode = !fieldEditMode"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + <MkButton v-else @click="fieldEditMode = !fieldEditMode"><i class="ti ti-arrows-sort"></i> {{ i18n.ts.rearrange }}</MkButton> </div> </template> - </Sortable> - </div> - </MkFolder> - <template #caption>{{ i18n.ts._profile.metadataDescription }}</template> - </FormSlot> - <MkInput v-model="profile.followedMessage" :max="200" manualSave :mfmPreview="false"> - <template #label>{{ i18n.ts._profile.followedMessage }}<span class="_beta">{{ i18n.ts.beta }}</span></template> - <template #caption> - <div>{{ i18n.ts._profile.followedMessageDescription }}</div> - <div>{{ i18n.ts._profile.followedMessageDescriptionForLockedAccount }}</div> - </template> - </MkInput> + <div :class="$style.metadataRoot" class="_gaps_s"> + <MkInfo>{{ i18n.ts._profile.verifiedLinkDescription }}</MkInfo> - <MkSelect v-model="reactionAcceptance"> - <template #label>{{ i18n.ts.reactionAcceptance }}</template> - <option :value="null">{{ i18n.ts.all }}</option> - <option value="likeOnlyForRemote">{{ i18n.ts.likeOnlyForRemote }}</option> - <option value="nonSensitiveOnly">{{ i18n.ts.nonSensitiveOnly }}</option> - <option value="nonSensitiveOnlyForLocalLikeOnlyForRemote">{{ i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote }}</option> - <option value="likeOnly">{{ i18n.ts.likeOnly }}</option> - </MkSelect> + <Sortable + v-model="fields" + class="_gaps_s" + itemKey="id" + :animation="150" + :handle="'.' + $style.dragItemHandle" + @start="e => e.item.classList.add('active')" + @end="e => e.item.classList.remove('active')" + > + <template #item="{element, index}"> + <div v-panel :class="$style.fieldDragItem"> + <button v-if="!fieldEditMode" class="_button" :class="$style.dragItemHandle" tabindex="-1"><i class="ti ti-menu"></i></button> + <button v-if="fieldEditMode" :disabled="fields.length <= 1" class="_button" :class="$style.dragItemRemove" @click="deleteField(index)"><i class="ti ti-x"></i></button> + <div :class="$style.dragItemForm"> + <FormSplit :minWidth="200"> + <MkInput v-model="element.name" small :placeholder="i18n.ts._profile.metadataLabel"> + </MkInput> + <MkInput v-model="element.value" small :placeholder="i18n.ts._profile.metadataContent"> + </MkInput> + </FormSplit> + </div> + </div> + </template> + </Sortable> + </div> + </MkFolder> + <template #caption>{{ i18n.ts._profile.metadataDescription }}</template> + </FormSlot> + </SearchMarker> - <MkFolder> - <template #label>{{ i18n.ts.advancedSettings }}</template> + <SearchMarker :keywords="['follow', 'message']"> + <MkInput v-model="profile.followedMessage" :max="200" manualSave :mfmPreview="false"> + <template #label><SearchLabel>{{ i18n.ts._profile.followedMessage }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template> + <template #caption> + <div><SearchKeyword>{{ i18n.ts._profile.followedMessageDescription }}</SearchKeyword></div> + <div>{{ i18n.ts._profile.followedMessageDescriptionForLockedAccount }}</div> + </template> + </MkInput> + </SearchMarker> - <div class="_gaps_m"> - <MkSwitch v-model="profile.isCat">{{ i18n.ts.flagAsCat }}<template #caption>{{ i18n.ts.flagAsCatDescription }}</template></MkSwitch> - <MkSwitch v-if="profile.isCat" v-model="profile.speakAsCat">{{ i18n.ts.flagSpeakAsCat }}<template #caption>{{ i18n.ts.flagSpeakAsCatDescription }}</template></MkSwitch> - <MkSwitch v-model="profile.isBot">{{ i18n.ts.flagAsBot }}<template #caption>{{ i18n.ts.flagAsBotDescription }}</template></MkSwitch> - </div> - </MkFolder> -</div> + <SearchMarker :keywords="['reaction']"> + <MkSelect v-model="reactionAcceptance"> + <template #label><SearchLabel>{{ i18n.ts.reactionAcceptance }}</SearchLabel></template> + <option :value="null">{{ i18n.ts.all }}</option> + <option value="likeOnlyForRemote">{{ i18n.ts.likeOnlyForRemote }}</option> + <option value="nonSensitiveOnly">{{ i18n.ts.nonSensitiveOnly }}</option> + <option value="nonSensitiveOnlyForLocalLikeOnlyForRemote">{{ i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote }}</option> + <option value="likeOnly">{{ i18n.ts.likeOnly }}</option> + </MkSelect> + </SearchMarker> + + <SearchMarker> + <MkFolder> + <template #label><SearchLabel>{{ i18n.ts.advancedSettings }}</SearchLabel></template> + + <div class="_gaps_m"> + <SearchMarker :keywords="['cat']"> + <MkSwitch v-model="profile.isCat"> + <template #label><SearchLabel>{{ i18n.ts.flagAsCat }}</SearchLabel></template> + <template #caption>{{ i18n.ts.flagAsCatDescription }}</template> + </MkSwitch> + <MkSwitch v-if="profile.isCat" v-model="profile.speakAsCat"> + <template #label><SearchLabel>{{ i18n.ts.flagSpeakAsCat }}</SearchLabel></template> + <template #caption>{{ i18n.ts.flagSpeakAsCatDescription }}</template> + </MkSwitch> + </SearchMarker> + + <SearchMarker :keywords="['bot']"> + <MkSwitch v-model="profile.isBot"> + <template #label><SearchLabel>{{ i18n.ts.flagAsBot }}</SearchLabel></template> + <template #caption>{{ i18n.ts.flagAsBotDescription }}</template> + </MkSwitch> + </SearchMarker> + </div> + </MkFolder> + </SearchMarker> + </div> +</SearchMarker> </template> <script lang="ts" setup> @@ -131,23 +177,22 @@ import MkSelect from '@/components/MkSelect.vue'; import FormSplit from '@/components/form/split.vue'; import MkFolder from '@/components/MkFolder.vue'; import FormSlot from '@/components/form/slot.vue'; -import { selectFile } from '@/scripts/select-file.js'; +import { selectFile } from '@/utility/select-file.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { signinRequired } from '@/account.js'; -import { langmap } from '@/scripts/langmap.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { defaultStore } from '@/store.js'; -import { globalEvents } from '@/events.js'; +import { ensureSignin } from '@/i.js'; +import { langmap } from '@/utility/langmap.js'; +import { definePage } from '@/page.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { store } from '@/store.js'; import MkInfo from '@/components/MkInfo.vue'; import MkTextarea from '@/components/MkTextarea.vue'; -const $i = signinRequired(); +const $i = ensureSignin(); const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); -const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance')); +const reactionAcceptance = computed(store.makeGetterSetter('reactionAcceptance')); const now = new Date(); @@ -203,7 +248,6 @@ function saveFields() { os.apiWithDialog('i/update', { fields: fields.value.filter(field => field.name !== '' && field.value !== '').map(field => ({ name: field.name, value: field.value })), }); - globalEvents.emit('requestClearPageCache'); } function save() { @@ -238,7 +282,6 @@ function save() { text: i18n.ts.yourNameContainsProhibitedWordsDescription, }, }); - globalEvents.emit('requestClearPageCache'); claimAchievement('profileFilled'); if (profile.name === 'syuilo' || profile.name === 'しゅいろ') { claimAchievement('setNameToSyuilo'); @@ -270,6 +313,7 @@ function changeAvatar(ev) { }); $i.avatarId = i.avatarId; $i.avatarUrl = i.avatarUrl; + claimAchievement('profileFilled'); globalEvents.emit('requestClearPageCache'); }); } @@ -296,7 +340,6 @@ function changeBanner(ev) { }); $i.bannerId = i.bannerId; $i.bannerUrl = i.bannerUrl; - globalEvents.emit('requestClearPageCache'); }); } @@ -396,7 +439,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.profile, icon: 'ti ti-user', })); diff --git a/packages/frontend/src/pages/settings/roles.vue b/packages/frontend/src/pages/settings/roles.vue deleted file mode 100644 index 5346a58a79..0000000000 --- a/packages/frontend/src/pages/settings/roles.vue +++ /dev/null @@ -1,48 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div class="_gaps_m"> - <FormSection first> - <template #label>{{ i18n.ts.rolesAssignedToMe }}</template> - <div class="_gaps_s"> - <MkRolePreview v-for="role in $i.roles" :key="role.id" :role="role" :forModeration="false"/> - </div> - </FormSection> - <FormSection> - <template #label>{{ i18n.ts._role.policies }}</template> - <div class="_gaps_s"> - <div v-for="policy in Object.keys($i.policies)" :key="policy"> - {{ policy }} ... {{ $i.policies[policy] }} - </div> - </div> - </FormSection> -</div> -</template> - -<script lang="ts" setup> -import { computed } from 'vue'; -import FormSection from '@/components/form/section.vue'; -import * as os from '@/os.js'; -import { i18n } from '@/i18n.js'; -import { signinRequired } from '@/account.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import MkRolePreview from '@/components/MkRolePreview.vue'; - -const $i = signinRequired(); - -const headerActions = computed(() => []); - -const headerTabs = computed(() => []); - -definePageMetadata(() => ({ - title: i18n.ts.roles, - icon: 'ti ti-badges', -})); -</script> - -<style lang="scss" module> - -</style> diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue index 8f9d4f858b..391118effd 100644 --- a/packages/frontend/src/pages/settings/security.vue +++ b/packages/frontend/src/pages/settings/security.vue @@ -4,39 +4,52 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_gaps_m"> - <FormSection first> - <template #label>{{ i18n.ts.password }}</template> - <MkButton primary @click="change()">{{ i18n.ts.changePassword }}</MkButton> - </FormSection> +<SearchMarker path="/settings/security" :label="i18n.ts.security" :keywords="['security']" icon="ti ti-lock" :inlining="['2fa']"> + <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/locked_with_key_3d.png" color="#ffbf00"> + <SearchKeyword>{{ i18n.ts._settings.securityBanner }}</SearchKeyword> + </MkFeatureBanner> - <X2fa/> + <SearchMarker :keywords="['password']"> + <FormSection first> + <template #label><SearchLabel>{{ i18n.ts.password }}</SearchLabel></template> - <FormSection> - <template #label>{{ i18n.ts.signinHistory }}</template> - <MkPagination :pagination="pagination" disableAutoLoad> - <template #default="{items}"> - <div> - <div v-for="item in items" :key="item.id" v-panel class="timnmucd"> - <header> - <i v-if="item.success" class="ti ti-check icon succ"></i> - <i v-else class="ti ti-circle-x icon fail"></i> - <code class="ip _monospace">{{ item.ip }}</code> - <MkTime :time="item.createdAt" class="time"/> - </header> + <SearchMarker> + <MkButton primary @click="change()"> + <SearchLabel>{{ i18n.ts.changePassword }}</SearchLabel> + </MkButton> + </SearchMarker> + </FormSection> + </SearchMarker> + + <X2fa/> + + <FormSection> + <template #label>{{ i18n.ts.signinHistory }}</template> + <MkPagination :pagination="pagination" disableAutoLoad> + <template #default="{items}"> + <div> + <div v-for="item in items" :key="item.id" v-panel class="timnmucd"> + <header> + <i v-if="item.success" class="ti ti-check icon succ"></i> + <i v-else class="ti ti-circle-x icon fail"></i> + <code class="ip _monospace">{{ item.ip }}</code> + <MkTime :time="item.createdAt" class="time"/> + </header> + </div> </div> - </div> - </template> - </MkPagination> - </FormSection> + </template> + </MkPagination> + </FormSection> - <FormSection> - <FormSlot> - <MkButton danger @click="regenerateToken"><i class="ti ti-refresh"></i> {{ i18n.ts.regenerateLoginToken }}</MkButton> - <template #caption>{{ i18n.ts.regenerateLoginTokenDescription }}</template> - </FormSlot> - </FormSection> -</div> + <FormSection> + <FormSlot> + <MkButton danger @click="regenerateToken"><i class="ti ti-refresh"></i> {{ i18n.ts.regenerateLoginToken }}</MkButton> + <template #caption>{{ i18n.ts.regenerateLoginTokenDescription }}</template> + </FormSlot> + </FormSection> + </div> +</SearchMarker> </template> <script lang="ts" setup> @@ -47,9 +60,10 @@ import FormSlot from '@/components/form/slot.vue'; import MkButton from '@/components/MkButton.vue'; import MkPagination from '@/components/MkPagination.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; const pagination = { endpoint: 'i/signin-history' as const, @@ -103,7 +117,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.security, icon: 'ti ti-lock', })); diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue index 56f65e2309..1bac19fe47 100644 --- a/packages/frontend/src/pages/settings/sounds.sound.vue +++ b/packages/frontend/src/pages/settings/sounds.sound.vue @@ -32,15 +32,15 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed, watch } from 'vue'; -import type { SoundType } from '@/scripts/sound.js'; +import type { SoundType } from '@/utility/sound.js'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; import MkRange from '@/components/MkRange.vue'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { playMisskeySfxFile, soundsTypes, getSoundDuration } from '@/scripts/sound.js'; -import { selectFile } from '@/scripts/select-file.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { playMisskeySfxFile, soundsTypes, getSoundDuration } from '@/utility/sound.js'; +import { selectFile } from '@/utility/select-file.js'; const props = defineProps<{ type: SoundType; diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue index 9fcf564e55..4461ee1ab1 100644 --- a/packages/frontend/src/pages/settings/sounds.vue +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -4,63 +4,88 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_gaps_m"> - <MkSwitch v-model="notUseSound"> - <template #label>{{ i18n.ts.notUseSound }}</template> - </MkSwitch> - <MkSwitch v-model="useSoundOnlyWhenActive"> - <template #label>{{ i18n.ts.useSoundOnlyWhenActive }}</template> - </MkSwitch> - <MkRange v-model="masterVolume" :min="0" :max="1" :step="0.05" :textConverter="(v) => `${Math.floor(v * 100)}%`"> - <template #label>{{ i18n.ts.masterVolume }}</template> - </MkRange> +<SearchMarker path="/settings/sounds" :label="i18n.ts.sounds" :keywords="['sounds']" icon="ti ti-music"> + <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/speaker_high_volume_3d.png" color="#ff006f"> + <SearchKeyword>{{ i18n.ts._settings.soundsBanner }}</SearchKeyword> + </MkFeatureBanner> - <FormSection> - <template #label>{{ i18n.ts.sounds }}</template> - <div class="_gaps_s"> - <MkFolder v-for="type in operationTypes" :key="type"> - <template #label>{{ i18n.ts._sfx[type] }}</template> - <template #suffix>{{ getSoundTypeName(sounds[type].type) }}</template> - <Suspense> - <template #default> - <XSound :type="sounds[type].type" :volume="sounds[type].volume" :fileId="sounds[type].fileId" :fileUrl="sounds[type].fileUrl" @update="(res) => updated(type, res)"/> - </template> - <template #fallback> - <MkLoading/> - </template> - </Suspense> - </MkFolder> - </div> - </FormSection> + <SearchMarker :keywords="['mute']"> + <MkPreferenceContainer k="sound.notUseSound"> + <MkSwitch v-model="notUseSound"> + <template #label><SearchLabel>{{ i18n.ts.notUseSound }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> - <MkButton danger @click="reset()"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton> -</div> + <SearchMarker :keywords="['active', 'mute']"> + <MkPreferenceContainer k="sound.useSoundOnlyWhenActive"> + <MkSwitch v-model="useSoundOnlyWhenActive"> + <template #label><SearchLabel>{{ i18n.ts.useSoundOnlyWhenActive }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['volume', 'master']"> + <MkPreferenceContainer k="sound.masterVolume"> + <MkRange v-model="masterVolume" :min="0" :max="1" :step="0.05" :textConverter="(v) => `${Math.floor(v * 100)}%`"> + <template #label><SearchLabel>{{ i18n.ts.masterVolume }}</SearchLabel></template> + </MkRange> + </MkPreferenceContainer> + </SearchMarker> + + <FormSection> + <template #label>{{ i18n.ts.sounds }}</template> + <div class="_gaps_s"> + <MkFolder v-for="type in operationTypes" :key="type"> + <template #label>{{ i18n.ts._sfx[type] }}</template> + <template #suffix>{{ getSoundTypeName(sounds[type].type) }}</template> + <Suspense> + <template #default> + <XSound :type="sounds[type].type" :volume="sounds[type].volume" :fileId="sounds[type].fileId" :fileUrl="sounds[type].fileUrl" @update="(res) => updated(type, res)"/> + </template> + <template #fallback> + <MkLoading/> + </template> + </Suspense> + </MkFolder> + </div> + </FormSection> + + <MkButton danger @click="reset()"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton> + </div> +</SearchMarker> </template> <script lang="ts" setup> -import { Ref, computed, ref } from 'vue'; +import { computed, ref } from 'vue'; import XSound from './sounds.sound.vue'; -import type { SoundType, OperationType } from '@/scripts/sound.js'; -import type { SoundStore } from '@/store.js'; +import type { Ref } from 'vue'; +import type { SoundType, OperationType } from '@/utility/sound.js'; +import type { SoundStore } from '@/preferences/def.js'; +import { prefer } from '@/preferences.js'; import MkRange from '@/components/MkRange.vue'; import MkButton from '@/components/MkButton.vue'; import FormSection from '@/components/form/section.vue'; import MkFolder from '@/components/MkFolder.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { operationTypes } from '@/scripts/sound.js'; -import { defaultStore } from '@/store.js'; +import { definePage } from '@/page.js'; +import { operationTypes } from '@/utility/sound.js'; import MkSwitch from '@/components/MkSwitch.vue'; +import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; +import { PREF_DEF } from '@/preferences/def.js'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; -const notUseSound = computed(defaultStore.makeGetterSetter('sound_notUseSound')); -const useSoundOnlyWhenActive = computed(defaultStore.makeGetterSetter('sound_useSoundOnlyWhenActive')); -const masterVolume = computed(defaultStore.makeGetterSetter('sound_masterVolume')); +const notUseSound = prefer.model('sound.notUseSound'); +const useSoundOnlyWhenActive = prefer.model('sound.useSoundOnlyWhenActive'); +const masterVolume = prefer.model('sound.masterVolume'); const sounds = ref<Record<OperationType, Ref<SoundStore>>>({ - note: defaultStore.reactiveState.sound_note, - noteMy: defaultStore.reactiveState.sound_noteMy, - notification: defaultStore.reactiveState.sound_notification, - reaction: defaultStore.reactiveState.sound_reaction, + note: prefer.r['sound.on.note'], + noteMy: prefer.r['sound.on.noteMy'], + notification: prefer.r['sound.on.notification'], + reaction: prefer.r['sound.on.reaction'], + chatMessage: prefer.r['sound.on.chatMessage'], }); function getSoundTypeName(f: SoundType): string { @@ -82,14 +107,14 @@ async function updated(type: keyof typeof sounds.value, sound) { volume: sound.volume, }; - defaultStore.set(`sound_${type}`, v); + prefer.commit(`sound.on.${type}`, v); sounds.value[type] = v; } function reset() { for (const sound of Object.keys(sounds.value) as Array<keyof typeof sounds.value>) { - const v = defaultStore.def[`sound_${sound}`].default; - defaultStore.set(`sound_${sound}`, v); + const v = PREF_DEF[`sound.on.${sound}`].default; + prefer.commit(`sound.on.${sound}`, v); sounds.value[sound] = v; } } @@ -98,7 +123,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.sounds, icon: 'ti ti-music', })); diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue index 140b6beb14..dbb640123a 100644 --- a/packages/frontend/src/pages/settings/statusbar.statusbar.vue +++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue @@ -94,17 +94,17 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkButton from '@/components/MkButton.vue'; import MkRange from '@/components/MkRange.vue'; -import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { deepClone } from '@/scripts/clone.js'; +import { deepClone } from '@/utility/clone.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ _id: string; userLists: Misskey.entities.UserList[] | null; }>(); -const statusbar = reactive(deepClone(defaultStore.state.statusbars.find(x => x.id === props._id))); +const statusbar = reactive(deepClone(prefer.s.statusbars.find(x => x.id === props._id))); watch(() => statusbar.type, () => { if (statusbar.type === 'rss') { @@ -134,13 +134,13 @@ watch(() => statusbar.type, () => { watch(statusbar, save); async function save() { - const i = defaultStore.state.statusbars.findIndex(x => x.id === props._id); - const statusbars = deepClone(defaultStore.state.statusbars); + const i = prefer.s.statusbars.findIndex(x => x.id === props._id); + const statusbars = deepClone(prefer.s.statusbars); statusbars[i] = deepClone(statusbar); - defaultStore.set('statusbars', statusbars); + prefer.commit('statusbars', statusbars); } function del() { - defaultStore.set('statusbars', defaultStore.state.statusbars.filter(x => x.id !== props._id)); + prefer.commit('statusbars', prefer.s.statusbars.filter(x => x.id !== props._id)); } </script> diff --git a/packages/frontend/src/pages/settings/statusbar.vue b/packages/frontend/src/pages/settings/statusbar.vue index 1ae3de7994..7e6a536216 100644 --- a/packages/frontend/src/pages/settings/statusbar.vue +++ b/packages/frontend/src/pages/settings/statusbar.vue @@ -21,12 +21,12 @@ import { v4 as uuid } from 'uuid'; import XStatusbar from './statusbar.statusbar.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; +import { prefer } from '@/preferences.js'; -const statusbars = defaultStore.reactiveState.statusbars; +const statusbars = prefer.r.statusbars; const userLists = ref<Misskey.entities.UserList[] | null>(null); @@ -37,20 +37,20 @@ onMounted(() => { }); async function add() { - defaultStore.push('statusbars', { + prefer.commit('statusbars', [...statusbars.value, { id: uuid(), type: null, black: false, size: 'medium', props: {}, - }); + }]); } const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.statusbar, icon: 'ti ti-list', })); diff --git a/packages/frontend/src/pages/settings/theme.install.vue b/packages/frontend/src/pages/settings/theme.install.vue index 4f05d3784c..ac95279402 100644 --- a/packages/frontend/src/pages/settings/theme.install.vue +++ b/packages/frontend/src/pages/settings/theme.install.vue @@ -10,8 +10,8 @@ SPDX-License-Identifier: AGPL-3.0-only </MkCodeEditor> <div class="_buttons"> - <MkButton :disabled="installThemeCode == null" inline @click="() => previewTheme(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton> - <MkButton :disabled="installThemeCode == null" primary inline @click="() => install(installThemeCode)"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton> + <MkButton :disabled="installThemeCode == null || installThemeCode.trim() === ''" inline @click="() => previewTheme(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton> + <MkButton :disabled="installThemeCode == null || installThemeCode.trim() === ''" primary inline @click="() => install(installThemeCode)"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton> </div> </div> </template> @@ -20,11 +20,13 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed } from 'vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkButton from '@/components/MkButton.vue'; -import { parseThemeCode, previewTheme, installTheme } from '@/scripts/install-theme.js'; +import { parseThemeCode, previewTheme, installTheme } from '@/theme.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; +import { useRouter } from '@/router.js'; +const router = useRouter(); const installThemeCode = ref<string | null>(null); async function install(code: string): Promise<void> { @@ -35,6 +37,8 @@ async function install(code: string): Promise<void> { type: 'success', text: i18n.tsx._theme.installed({ name: theme.name }), }); + installThemeCode.value = null; + router.push('/settings/theme'); } catch (err) { switch (err.message.toLowerCase()) { case 'this theme is already installed': @@ -59,7 +63,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts._theme.install, icon: 'ti ti-download', })); diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue index 579ca6b20b..c68c04bb44 100644 --- a/packages/frontend/src/pages/settings/theme.manage.vue +++ b/packages/frontend/src/pages/settings/theme.manage.vue @@ -33,16 +33,17 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref } from 'vue'; import JSON5 from 'json5'; +import type { Theme } from '@/theme.js'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; -import { Theme, getBuiltinThemesRef } from '@/scripts/theme.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { getBuiltinThemesRef } from '@/theme.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import * as os from '@/os.js'; import { getThemes, removeTheme } from '@/theme-store.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const installedThemes = ref(getThemes()); const builtinThemes = getBuiltinThemesRef(); @@ -62,7 +63,6 @@ const selectedThemeCode = computed(() => { function copyThemeCode() { copyToClipboard(selectedThemeCode.value); - os.success(); } function uninstall() { @@ -76,7 +76,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts._theme.manage, icon: 'ti ti-tool', })); diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue index 9f7842ecdc..b0c0f0b5bb 100644 --- a/packages/frontend/src/pages/settings/theme.vue +++ b/packages/frontend/src/pages/settings/theme.vue @@ -4,137 +4,271 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_gaps_m rsljpzjq"> - <div v-adaptive-border class="rfqxtzch _panel"> - <div class="toggle"> - <div class="toggleWrapper"> - <input id="dn" v-model="darkMode" type="checkbox" class="dn"/> - <label for="dn" class="toggle"> - <span class="before">{{ i18n.ts.light }}</span> - <span class="after">{{ i18n.ts.dark }}</span> - <span class="toggle__handler"> - <span class="crater crater--1"></span> - <span class="crater crater--2"></span> - <span class="crater crater--3"></span> - </span> - <span class="star star--1"></span> - <span class="star star--2"></span> - <span class="star star--3"></span> - <span class="star star--4"></span> - <span class="star star--5"></span> - <span class="star star--6"></span> - </label> +<SearchMarker path="/settings/theme" :label="i18n.ts.theme" :keywords="['theme']" icon="ti ti-palette"> + <div class="_gaps_m"> + <div v-adaptive-border class="rfqxtzch _panel"> + <div class="toggle"> + <div class="toggleWrapper"> + <input id="dn" v-model="darkMode" type="checkbox" class="dn"/> + <label for="dn" class="toggle"> + <span class="before">{{ i18n.ts.light }}</span> + <span class="after">{{ i18n.ts.dark }}</span> + <span class="toggle__handler"> + <span class="crater crater--1"></span> + <span class="crater crater--2"></span> + <span class="crater crater--3"></span> + </span> + <span class="star star--1"></span> + <span class="star star--2"></span> + <span class="star star--3"></span> + <span class="star star--4"></span> + <span class="star star--5"></span> + <span class="star star--6"></span> + </label> + </div> + </div> + <div class="sync"> + <SearchMarker :keywords="['sync', 'device', 'dark', 'light', 'mode']"> + <MkSwitch v-model="syncDeviceDarkMode"> + <template #label><SearchLabel>{{ i18n.ts.syncDeviceDarkMode }}</SearchLabel></template> + </MkSwitch> + </SearchMarker> </div> </div> - <div class="sync"> - <MkSwitch v-model="syncDeviceDarkMode">{{ i18n.ts.syncDeviceDarkMode }}</MkSwitch> - </div> - </div> - <div class="selects"> - <MkSelect v-model="lightThemeId" large class="select"> - <template #label>{{ i18n.ts.themeForLightMode }}</template> - <template #prefix><i class="ti ti-sun"></i></template> - <option v-if="instanceLightTheme" :key="'instance:' + instanceLightTheme.id" :value="instanceLightTheme.id">{{ instanceLightTheme.name }}</option> - <optgroup v-if="installedLightThemes.length > 0" :label="i18n.ts._theme.installedThemes"> - <option v-for="x in installedLightThemes" :key="'installed:' + x.id" :value="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="i18n.ts._theme.builtinThemes"> - <option v-for="x in builtinLightThemes" :key="'builtin:' + x.id" :value="x.id">{{ x.name }}</option> - </optgroup> - </MkSelect> - <MkSelect v-model="darkThemeId" large class="select"> - <template #label>{{ i18n.ts.themeForDarkMode }}</template> - <template #prefix><i class="ti ti-moon"></i></template> - <option v-if="instanceDarkTheme" :key="'instance:' + instanceDarkTheme.id" :value="instanceDarkTheme.id">{{ instanceDarkTheme.name }}</option> - <optgroup v-if="installedDarkThemes.length > 0" :label="i18n.ts._theme.installedThemes"> - <option v-for="x in installedDarkThemes" :key="'installed:' + x.id" :value="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="i18n.ts._theme.builtinThemes"> - <option v-for="x in builtinDarkThemes" :key="'builtin:' + x.id" :value="x.id">{{ x.name }}</option> - </optgroup> - </MkSelect> - </div> + <div class="_gaps"> + <template v-if="!darkMode"> + <SearchMarker :keywords="['light', 'theme']"> + <MkFolder :defaultOpen="true" :max-height="500"> + <template #icon><i class="ti ti-sun"></i></template> + <template #label><SearchLabel>{{ i18n.ts.themeForLightMode }}</SearchLabel></template> + <template #caption>{{ lightThemeName }}</template> + + <div class="_gaps_m"> + <FormSection v-if="instanceLightTheme != null" first> + <template #label>{{ i18n.ts._theme.instanceTheme }}</template> + <div :class="$style.themeSelect"> + <div :class="$style.themeItemOuter"> + <input + :id="`themeRadio_${instanceLightTheme.id}`" + v-model="lightThemeId" + type="radio" + name="lightTheme" + :class="$style.themeRadio" + :value="instanceLightTheme.id" + /> + <label :for="`themeRadio_${instanceLightTheme.id}`" :class="$style.themeItemRoot" class="_button"> + <MkThemePreview :theme="instanceLightTheme" :class="$style.themeItemPreview"/> + <div :class="$style.themeItemCaption">{{ instanceLightTheme.name }}</div> + </label> + </div> + </div> + </FormSection> - <FormSection> - <div class="_formLinksGrid"> - <FormLink to="/settings/theme/manage"><template #icon><i class="ti ti-tool"></i></template>{{ i18n.ts._theme.manage }}<template #suffix>{{ themesCount }}</template></FormLink> - <FormLink to="https://assets.misskey.io/theme/list" external><template #icon><i class="ti ti-world"></i></template>{{ i18n.ts._theme.explore }}</FormLink> - <FormLink to="/settings/theme/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._theme.install }}</FormLink> - <FormLink to="/theme-editor"><template #icon><i class="ti ti-paint"></i></template>{{ i18n.ts._theme.make }}</FormLink> + <FormSection v-if="installedLightThemes.length > 0" :first="instanceLightTheme == null"> + <template #label>{{ i18n.ts._theme.installedThemes }}</template> + <div :class="$style.themeSelect"> + <div v-for="theme in installedLightThemes" :class="$style.themeItemOuter"> + <input + :id="`themeRadio_${theme.id}`" + v-model="lightThemeId" + type="radio" + name="lightTheme" + :class="$style.themeRadio" + :value="theme.id" + /> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button"> + <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> + <div :class="$style.themeItemCaption">{{ theme.name }}</div> + </label> + </div> + </div> + </FormSection> + + <FormSection :first="installedLightThemes.length === 0 && instanceLightTheme == null"> + <template #label>{{ i18n.ts._theme.builtinThemes }}</template> + <div :class="$style.themeSelect"> + <div v-for="theme in builtinLightThemes" :class="$style.themeItemOuter"> + <input + :id="`themeRadio_${theme.id}`" + v-model="lightThemeId" + type="radio" + name="lightTheme" + :class="$style.themeRadio" + :value="theme.id" + /> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button"> + <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> + <div :class="$style.themeItemCaption">{{ theme.name }}</div> + </label> + </div> + </div> + </FormSection> + </div> + </MkFolder> + </SearchMarker> + </template> + <template v-else> + <SearchMarker :keywords="['dark', 'theme']"> + <MkFolder :defaultOpen="true" :max-height="500"> + <template #icon><i class="ti ti-moon"></i></template> + <template #label><SearchLabel>{{ i18n.ts.themeForDarkMode }}</SearchLabel></template> + <template #caption>{{ darkThemeName }}</template> + + <div class="_gaps_m"> + <FormSection v-if="instanceDarkTheme != null" first> + <template #label>{{ i18n.ts._theme.instanceTheme }}</template> + <div :class="$style.themeSelect"> + <div :class="$style.themeItemOuter"> + <input + :id="`themeRadio_${instanceDarkTheme.id}`" + v-model="darkThemeId" + type="radio" + name="darkTheme" + :class="$style.themeRadio" + :value="instanceDarkTheme.id" + /> + <label :for="`themeRadio_${instanceDarkTheme.id}`" :class="$style.themeItemRoot" class="_button"> + <MkThemePreview :theme="instanceDarkTheme" :class="$style.themeItemPreview"/> + <div :class="$style.themeItemCaption">{{ instanceDarkTheme.name }}</div> + </label> + </div> + </div> + </FormSection> + + <FormSection v-if="installedDarkThemes.length > 0" :first="instanceDarkTheme == null"> + <template #label>{{ i18n.ts._theme.installedThemes }}</template> + <div :class="$style.themeSelect"> + <div v-for="theme in installedDarkThemes" :class="$style.themeItemOuter"> + <input + :id="`themeRadio_${theme.id}`" + v-model="darkThemeId" + type="radio" + name="darkTheme" + :class="$style.themeRadio" + :value="theme.id" + /> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button"> + <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> + <div :class="$style.themeItemCaption">{{ theme.name }}</div> + </label> + </div> + </div> + </FormSection> + + <FormSection :first="installedDarkThemes.length === 0 && instanceDarkTheme == null"> + <template #label>{{ i18n.ts._theme.builtinThemes }}</template> + <div :class="$style.themeSelect"> + <div v-for="theme in builtinDarkThemes" :class="$style.themeItemOuter"> + <input + :id="`themeRadio_${theme.id}`" + v-model="darkThemeId" + type="radio" + name="darkTheme" + :class="$style.themeRadio" + :value="theme.id" + /> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button"> + <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> + <div :class="$style.themeItemCaption">{{ theme.name }}</div> + </label> + </div> + </div> + </FormSection> + </div> + </MkFolder> + </SearchMarker> + </template> </div> - </FormSection> - <MkButton v-if="wallpaper == null" @click="setWallpaper">{{ i18n.ts.setWallpaper }}</MkButton> - <MkButton v-else @click="wallpaper = null">{{ i18n.ts.removeWallpaper }}</MkButton> -</div> + <FormSection> + <div class="_formLinksGrid"> + <FormLink to="/settings/theme/manage"><template #icon><i class="ti ti-tool"></i></template>{{ i18n.ts._theme.manage }}<template #suffix>{{ themesCount }}</template></FormLink> + <FormLink to="https://assets.misskey.io/theme/list" external><template #icon><i class="ti ti-world"></i></template>{{ i18n.ts._theme.explore }}</FormLink> + <FormLink to="/settings/theme/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._theme.install }}</FormLink> + <FormLink to="/theme-editor"><template #icon><i class="ti ti-paint"></i></template>{{ i18n.ts._theme.make }}</FormLink> + </div> + </FormSection> + + <SearchMarker :keywords="['wallpaper']"> + <MkButton v-if="wallpaper == null" @click="setWallpaper"><SearchLabel>{{ i18n.ts.setWallpaper }}</SearchLabel></MkButton> + <MkButton v-else @click="wallpaper = null">{{ i18n.ts.removeWallpaper }}</MkButton> + </SearchMarker> + </div> +</SearchMarker> </template> <script lang="ts" setup> import { computed, onActivated, ref, watch } from 'vue'; import JSON5 from 'json5'; +import defaultLightTheme from '@@/themes/l-light.json5'; +import defaultDarkTheme from '@@/themes/d-green-lime.json5'; +import type { Theme } from '@/theme.js'; import MkSwitch from '@/components/MkSwitch.vue'; -import MkSelect from '@/components/MkSelect.vue'; import FormSection from '@/components/form/section.vue'; import FormLink from '@/components/form/link.vue'; import MkButton from '@/components/MkButton.vue'; -import { getBuiltinThemesRef } from '@/scripts/theme.js'; -import { selectFile } from '@/scripts/select-file.js'; -import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js'; -import { ColdDeviceStorage, defaultStore } from '@/store.js'; +import MkFolder from '@/components/MkFolder.vue'; +import MkThemePreview from '@/components/MkThemePreview.vue'; +import { getBuiltinThemesRef } from '@/theme.js'; +import { selectFile } from '@/utility/select-file.js'; +import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js'; +import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { uniqueBy } from '@/scripts/array.js'; -import { fetchThemes, getThemes } from '@/theme-store.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { uniqueBy } from '@/utility/array.js'; +import { getThemes } from '@/theme-store.js'; +import { definePage } from '@/page.js'; import { miLocalStorage } from '@/local-storage.js'; -import { reloadAsk } from '@/scripts/reload-ask.js'; -import * as os from '@/os.js'; +import { reloadAsk } from '@/utility/reload-ask.js'; +import { prefer } from '@/preferences.js'; const installedThemes = ref(getThemes()); const builtinThemes = getBuiltinThemesRef(); -const instanceDarkTheme = computed(() => instance.defaultDarkTheme ? JSON5.parse(instance.defaultDarkTheme) : null); +const instanceDarkTheme = computed<Theme | null>(() => instance.defaultDarkTheme ? JSON5.parse(instance.defaultDarkTheme) : null); const installedDarkThemes = computed(() => installedThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark')); const builtinDarkThemes = computed(() => builtinThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark')); -const instanceLightTheme = computed(() => instance.defaultLightTheme ? JSON5.parse(instance.defaultLightTheme) : null); +const instanceLightTheme = computed<Theme | null>(() => instance.defaultLightTheme ? JSON5.parse(instance.defaultLightTheme) : null); const installedLightThemes = computed(() => installedThemes.value.filter(t => t.base === 'light' || t.kind === 'light')); const builtinLightThemes = computed(() => builtinThemes.value.filter(t => t.base === 'light' || t.kind === 'light')); const themes = computed(() => uniqueBy([instanceDarkTheme.value, instanceLightTheme.value, ...builtinThemes.value, ...installedThemes.value].filter(x => x != null), theme => theme.id)); -const darkTheme = ColdDeviceStorage.ref('darkTheme'); +const darkTheme = prefer.r.darkTheme; +const darkThemeName = computed(() => darkTheme.value?.name ?? defaultDarkTheme.name); const darkThemeId = computed({ get() { - return darkTheme.value.id; + return darkTheme.value ? darkTheme.value.id : defaultDarkTheme.id; }, set(id) { const t = themes.value.find(x => x.id === id); if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる - ColdDeviceStorage.set('darkTheme', t); + prefer.commit('darkTheme', t); } }, }); -const lightTheme = ColdDeviceStorage.ref('lightTheme'); +const lightTheme = prefer.r.lightTheme; +const lightThemeName = computed(() => lightTheme.value?.name ?? defaultLightTheme.name); const lightThemeId = computed({ get() { - return lightTheme.value.id; + return lightTheme.value ? lightTheme.value.id : defaultLightTheme.id; }, set(id) { const t = themes.value.find(x => x.id === id); if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる - ColdDeviceStorage.set('lightTheme', t); + prefer.commit('lightTheme', t); } }, }); -const darkMode = computed(defaultStore.makeGetterSetter('darkMode')); -const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode')); +const darkMode = computed(store.makeGetterSetter('darkMode')); +const syncDeviceDarkMode = prefer.model('syncDeviceDarkMode'); const wallpaper = ref(miLocalStorage.getItem('wallpaper')); const themesCount = installedThemes.value.length; watch(syncDeviceDarkMode, () => { if (syncDeviceDarkMode.value) { - defaultStore.set('darkMode', isDeviceDarkmode()); + store.set('darkMode', isDeviceDarkmode()); } }); @@ -148,12 +282,6 @@ watch(wallpaper, async () => { }); onActivated(() => { - fetchThemes().then(() => { - installedThemes.value = getThemes(); - }); -}); - -fetchThemes().then(() => { installedThemes.value = getThemes(); }); @@ -167,12 +295,63 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.theme, icon: 'ti ti-palette', })); </script> +<style module> +.themeSelect { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: var(--MI-margin); +} + +.themeItemOuter { + position: relative; +} + +.themeRadio { + position: absolute; + clip: rect(0, 0, 0, 0); + pointer-events: none; +} + +.themeItemRoot { + position: relative; + display: block; + overflow: clip; + box-sizing: border-box; + border: 2px solid var(--MI_THEME-divider); + border-radius: var(--MI-radius); +} + +.themeRadio:focus-visible + .themeItemRoot { + outline: 2px solid var(--MI_THEME-focus); + outline-offset: 2px; +} + +.themeRadio:checked + .themeItemRoot { + border-color: var(--MI_THEME-accent); +} + +.themeItemPreview { + display: block; + width: calc(100% + 2px); + height: auto; + margin-left: -1px; + border-bottom: 1px solid var(--MI_THEME-divider); +} + +.themeItemCaption { + box-sizing: border-box; + padding: 8px 12px; + text-align: center; + font-size: 80%; +} +</style> + <style lang="scss" scoped> .rfqxtzch { border-radius: var(--MI-radius-sm); @@ -408,17 +587,4 @@ definePageMetadata(() => ({ border-top: solid 0.5px var(--MI_THEME-divider); } } - -.rsljpzjq { - > .selects { - display: flex; - gap: 1.5em var(--MI-margin); - flex-wrap: wrap; - - > .select { - flex: 1; - min-width: 280px; - } - } -} </style> diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue index 22b008fb61..6a6cec70ba 100644 --- a/packages/frontend/src/pages/settings/webhook.edit.vue +++ b/packages/frontend/src/pages/settings/webhook.edit.vue @@ -76,10 +76,10 @@ import FormSection from '@/components/form/section.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { useRouter } from '@/router/supplier.js'; +import { definePage } from '@/page.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -155,7 +155,7 @@ const headerActions = computed(() => []); // eslint-disable-next-line @typescript-eslint/no-unused-vars const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: 'Edit webhook', icon: 'ti ti-webhook', })); diff --git a/packages/frontend/src/pages/settings/webhook.new.vue b/packages/frontend/src/pages/settings/webhook.new.vue index 727c4df2d6..e853f967cb 100644 --- a/packages/frontend/src/pages/settings/webhook.new.vue +++ b/packages/frontend/src/pages/settings/webhook.new.vue @@ -46,7 +46,7 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const name = ref(''); const url = ref(''); @@ -82,7 +82,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: 'Create new webhook', icon: 'ti ti-webhook', })); diff --git a/packages/frontend/src/pages/settings/webhook.vue b/packages/frontend/src/pages/settings/webhook.vue deleted file mode 100644 index af8b7ca945..0000000000 --- a/packages/frontend/src/pages/settings/webhook.vue +++ /dev/null @@ -1,57 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div class="_gaps_m"> - <FormLink :to="`/settings/webhook/new`"> - {{ i18n.ts._webhookSettings.createWebhook }} - </FormLink> - - <FormSection> - <MkPagination :pagination="pagination"> - <template #default="{items}"> - <div class="_gaps"> - <FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit/${webhook.id}`"> - <template #icon> - <i v-if="webhook.active === false" class="ti ti-player-pause"></i> - <i v-else-if="webhook.latestStatus === null" class="ti ti-circle"></i> - <i v-else-if="[200, 201, 204].includes(webhook.latestStatus)" class="ti ti-check" :style="{ color: 'var(--MI_THEME-success)' }"></i> - <i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--MI_THEME-error)' }"></i> - </template> - {{ webhook.name || webhook.url }} - <template #suffix> - <MkTime v-if="webhook.latestSentAt" :time="webhook.latestSentAt"></MkTime> - </template> - </FormLink> - </div> - </template> - </MkPagination> - </FormSection> -</div> -</template> - -<script lang="ts" setup> -import { computed } from 'vue'; -import MkPagination from '@/components/MkPagination.vue'; -import FormSection from '@/components/form/section.vue'; -import FormLink from '@/components/form/link.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { i18n } from '@/i18n.js'; - -const pagination = { - endpoint: 'i/webhooks/list' as const, - limit: 100, - noPaging: true, -}; - -const headerActions = computed(() => []); - -const headerTabs = computed(() => []); - -definePageMetadata(() => ({ - title: 'Webhook', - icon: 'ti ti-webhook', -})); -</script> diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue index 37f6558d64..57afdb9121 100644 --- a/packages/frontend/src/pages/share.vue +++ b/packages/frontend/src/pages/share.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <MkPostForm v-if="state === 'writing'" @@ -26,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton @click="goToMisskey">{{ i18n.ts.goToMisskey }}</MkButton> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -37,9 +36,9 @@ import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import MkPostForm from '@/components/MkPostForm.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { postMessageToParentWindow } from '@/scripts/post-message.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; +import { postMessageToParentWindow } from '@/utility/post-message.js'; import { i18n } from '@/i18n.js'; const urlParams = new URLSearchParams(window.location.search); @@ -182,12 +181,12 @@ function close(): void { // 閉じなければ100ms後タイムラインに window.setTimeout(() => { - location.href = '/'; + window.location.href = '/'; }, 100); } function goToMisskey(): void { - location.href = '/'; + window.location.href = '/'; } function onPosted(): void { @@ -199,7 +198,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.share, icon: 'ti ti-share', })); diff --git a/packages/frontend/src/pages/signup-complete.vue b/packages/frontend/src/pages/signup-complete.vue index 503e6e0f73..27f6b35859 100644 --- a/packages/frontend/src/pages/signup-complete.vue +++ b/packages/frontend/src/pages/signup-complete.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkAnimBg style="position: fixed; top: 0;"/> +<PageWithAnimBg> <div :class="$style.formContainer"> <form :class="$style.form" class="_panel" @submit.prevent="submit()"> <div :class="$style.banner"> @@ -21,17 +20,16 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </form> </div> -</div> +</PageWithAnimBg> </template> <script lang="ts" setup> import { ref } from 'vue'; import MkButton from '@/components/MkButton.vue'; -import MkAnimBg from '@/components/MkAnimBg.vue'; -import { login } from '@/account.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { login } from '@/accounts.js'; const submitting = ref(false); @@ -71,8 +69,8 @@ function submit() { min-height: 100svh; padding: 32px 32px 64px 32px; box-sizing: border-box; -display: grid; -place-content: center; + display: grid; + place-content: center; } .form { diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue index a45b61fb10..f0f7390d2c 100644 --- a/packages/frontend/src/pages/tag.vue +++ b/packages/frontend/src/pages/tag.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :displayBackButton="true" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :displayBackButton="true" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <MkNotes ref="notes" class="" :pagination="pagination"/> </MkSpacer> @@ -16,19 +15,19 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSpacer> </div> </template> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, ref } from 'vue'; import MkNotes from '@/components/MkNotes.vue'; import MkButton from '@/components/MkButton.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; -import { defaultStore } from '@/store.js'; +import { $i } from '@/i.js'; +import { store } from '@/store.js'; import * as os from '@/os.js'; -import { genEmbedCode } from '@/scripts/get-embed-code.js'; +import { genEmbedCode } from '@/utility/get-embed-code.js'; const props = defineProps<{ tag: string; @@ -44,11 +43,11 @@ const pagination = { const notes = ref<InstanceType<typeof MkNotes>>(); async function post() { - defaultStore.set('postFormHashtags', props.tag); - defaultStore.set('postFormWithHashtags', true); + store.set('postFormHashtags', props.tag); + store.set('postFormWithHashtags', true); await os.post(); - defaultStore.set('postFormHashtags', ''); - defaultStore.set('postFormWithHashtags', false); + store.set('postFormHashtags', ''); + store.set('postFormWithHashtags', false); notes.value?.pagingComponent?.reload(); } @@ -63,12 +62,12 @@ const headerActions = computed(() => [{ genEmbedCode('tags', props.tag); }, }], ev.currentTarget ?? ev.target); - } + }, }]); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: props.tag, icon: 'ti ti-hash', })); diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue index e49d6af470..d3c37a32b6 100644 --- a/packages/frontend/src/pages/theme-editor.vue +++ b/packages/frontend/src/pages/theme-editor.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800" :marginMin="16" :marginMax="32"> <div class="cwepdizn _gaps_m"> <MkFolder :defaultOpen="true"> @@ -69,7 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -78,23 +77,23 @@ import { toUnicode } from 'punycode.js'; import tinycolor from 'tinycolor2'; import { v4 as uuid } from 'uuid'; import JSON5 from 'json5'; - import lightTheme from '@@/themes/_light.json5'; import darkTheme from '@@/themes/_dark.json5'; +import { host } from '@@/js/config.js'; +import type { Theme } from '@/theme.js'; import MkButton from '@/components/MkButton.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkFolder from '@/components/MkFolder.vue'; - -import { $i } from '@/account.js'; -import { Theme, applyTheme } from '@/scripts/theme.js'; -import { host } from '@@/js/config.js'; +import { $i } from '@/i.js'; +import { applyTheme } from '@/theme.js'; import * as os from '@/os.js'; -import { ColdDeviceStorage, defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import { addTheme } from '@/theme-store.js'; import { i18n } from '@/i18n.js'; -import { useLeaveGuard } from '@/scripts/use-leave-guard.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { useLeaveGuard } from '@/use/use-leave-guard.js'; +import { definePage } from '@/page.js'; +import { prefer } from '@/preferences.js'; const bgColors = [ { color: '#f5f5f5', kind: 'light', forPreview: '#f5f5f5' }, @@ -196,10 +195,10 @@ async function saveAs() { if (description.value) theme.value.desc = description.value; await addTheme(theme.value); applyTheme(theme.value); - if (defaultStore.state.darkMode) { - ColdDeviceStorage.set('darkTheme', theme.value); + if (store.s.darkMode) { + prefer.commit('darkTheme', theme.value); } else { - ColdDeviceStorage.set('lightTheme', theme.value); + prefer.commit('lightTheme', theme.value); } changed.value = false; os.alert({ @@ -219,7 +218,7 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.themeEditor, icon: 'ti ti-palette', })); diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 032a19a6eb..095d1651e1 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -4,15 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :displayMyAvatar="true"/></template> +<PageWithHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :displayMyAvatar="true"> <MkSpacer :contentMax="800"> <MkHorizontalSwipe v-model:tab="src" :tabs="$i ? headerTabs : headerTabsWhenNotLogin"> - <div :key="src" ref="rootEl"> - <MkInfo v-if="isBasicTimeline(src) && !defaultStore.reactiveState.timelineTutorials.value[src]" style="margin-bottom: var(--MI-margin);" closable @close="closeTutorial()"> + <div ref="rootEl"> + <MkInfo v-if="isBasicTimeline(src) && !store.r.timelineTutorials.value[src]" style="margin-bottom: var(--MI-margin);" closable @close="closeTutorial()"> {{ i18n.ts._timelineDescription[src] }} </MkInfo> - <MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--MI-margin);"/> + <MkPostForm v-if="prefer.r.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--MI-margin);"/> <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> <div :class="$style.tl"> <MkTimeline @@ -32,56 +31,60 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkHorizontalSwipe> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> -import { computed, watch, provide, shallowRef, ref, onMounted, onActivated } from 'vue'; +import { computed, watch, provide, useTemplateRef, ref, onMounted, onActivated } from 'vue'; +import { scroll } from '@@/js/scroll.js'; import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; +import type { MenuItem } from '@/types/menu.js'; +import type { BasicTimelineType } from '@/timelines.js'; import MkTimeline from '@/components/MkTimeline.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkPostForm from '@/components/MkPostForm.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; -import { scroll } from '@@/js/scroll.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { $i } from '@/i.js'; +import { definePage } from '@/page.js'; import { antennasCache, userListsCache, favoritedChannelsCache } from '@/cache.js'; -import { deviceKind } from '@/scripts/device-kind.js'; -import { deepMerge } from '@/scripts/merge.js'; -import type { MenuItem } from '@/types/menu.js'; +import { deviceKind } from '@/utility/device-kind.js'; +import { deepMerge } from '@/utility/merge.js'; import { miLocalStorage } from '@/local-storage.js'; import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; -import type { BasicTimelineType } from '@/timelines.js'; -import { useRouter } from '@/router/supplier.js'; +import { prefer } from '@/preferences.js'; +import { useRouter } from '@/router.js'; provide('shouldOmitHeaderTitle', true); -const router = useRouter(); +const tlComponent = useTemplateRef('tlComponent'); +const rootEl = useTemplateRef('rootEl'); -const tlComponent = shallowRef<InstanceType<typeof MkTimeline>>(); -const rootEl = shallowRef<HTMLElement>(); +const router = useRouter(); +router.useListener('same', () => { + top(); +}); type TimelinePageSrc = BasicTimelineType | `list:${string}`; const queue = ref(0); const srcWhenNotSignin = ref<'local' | 'global'>(isAvailableBasicTimeline('local') ? 'local' : 'global'); const src = computed<TimelinePageSrc>({ - get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin.value), + get: () => ($i ? store.r.tl.value.src : srcWhenNotSignin.value), set: (x) => saveSrc(x), }); const withRenotes = computed<boolean>({ - get: () => defaultStore.reactiveState.tl.value.filter.withRenotes, + get: () => store.r.tl.value.filter.withRenotes, set: (x) => saveTlFilter('withRenotes', x), }); // computed内での無限ループを防ぐためのフラグ const localSocialTLFilterSwitchStore = ref<'withReplies' | 'onlyFiles' | false>( - defaultStore.reactiveState.tl.value.filter.withReplies ? 'withReplies' : - defaultStore.reactiveState.tl.value.filter.onlyFiles ? 'onlyFiles' : + store.r.tl.value.filter.withReplies ? 'withReplies' : + store.r.tl.value.filter.onlyFiles ? 'onlyFiles' : false, ); @@ -91,7 +94,7 @@ const withReplies = computed<boolean>({ if (['local', 'social'].includes(src.value) && localSocialTLFilterSwitchStore.value === 'onlyFiles') { return false; } else { - return defaultStore.reactiveState.tl.value.filter.withReplies; + return store.r.tl.value.filter.withReplies; } }, set: (x) => saveTlFilter('withReplies', x), @@ -106,7 +109,7 @@ const onlyFiles = computed<boolean>({ if (['local', 'social'].includes(src.value) && localSocialTLFilterSwitchStore.value === 'withReplies') { return false; } else { - return defaultStore.reactiveState.tl.value.filter.onlyFiles; + return store.r.tl.value.filter.onlyFiles; } }, set: (x) => saveTlFilter('onlyFiles', x), @@ -123,7 +126,7 @@ watch([withReplies, onlyFiles], ([withRepliesTo, onlyFilesTo]) => { }); const withSensitive = computed<boolean>({ - get: () => defaultStore.reactiveState.tl.value.filter.withSensitive, + get: () => store.r.tl.value.filter.withSensitive, set: (x) => saveTlFilter('withSensitive', x), }); @@ -136,7 +139,7 @@ function queueUpdated(q: number): void { } function top(): void { - if (rootEl.value) scroll(rootEl.value, { top: 0 }); + if (rootEl.value) scroll(rootEl.value, { top: 0, behavior: 'smooth' }); } async function chooseList(ev: MouseEvent): Promise<void> { @@ -204,23 +207,23 @@ async function chooseChannel(ev: MouseEvent): Promise<void> { } function saveSrc(newSrc: TimelinePageSrc): void { - const out = deepMerge({ src: newSrc }, defaultStore.state.tl); + const out = deepMerge({ src: newSrc }, store.s.tl); if (newSrc.startsWith('userList:')) { const id = newSrc.substring('userList:'.length); - out.userList = defaultStore.reactiveState.pinnedUserLists.value.find(l => l.id === id) ?? null; + out.userList = prefer.r.pinnedUserLists.value.find(l => l.id === id) ?? null; } - defaultStore.set('tl', out); + store.set('tl', out); if (['local', 'global'].includes(newSrc)) { srcWhenNotSignin.value = newSrc as 'local' | 'global'; } } -function saveTlFilter(key: keyof typeof defaultStore.state.tl.filter, newValue: boolean) { +function saveTlFilter(key: keyof typeof store.s.tl.filter, newValue: boolean) { if (key !== 'withReplies' || $i) { - const out = deepMerge({ filter: { [key]: newValue } }, defaultStore.state.tl); - defaultStore.set('tl', out); + const out = deepMerge({ filter: { [key]: newValue } }, store.s.tl); + store.set('tl', out); } } @@ -239,9 +242,9 @@ function focus(): void { function closeTutorial(): void { if (!isBasicTimeline(src.value)) return; - const before = defaultStore.state.timelineTutorials; + const before = store.s.timelineTutorials; before[src.value] = true; - defaultStore.set('timelineTutorials', before); + store.set('timelineTutorials', before); } function switchTlIfNeeded() { @@ -311,7 +314,7 @@ const headerActions = computed(() => { return tmp; }); -const headerTabs = computed(() => [...(defaultStore.reactiveState.pinnedUserLists.value.map(l => ({ +const headerTabs = computed(() => [...(prefer.r.pinnedUserLists.value.map(l => ({ key: 'list:' + l.id, title: l.name, icon: 'ti ti-star', @@ -350,7 +353,7 @@ const headerTabsWhenNotLogin = computed(() => [...availableBasicTimelines().map( iconOnly: true, }))] as Tab[]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.timeline, icon: isBasicTimeline(src.value) ? basicTimelineIconClass(src.value) : 'ti ti-home', })); diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue index 85e44b2503..a57d2bb51b 100644 --- a/packages/frontend/src/pages/user-list-timeline.vue +++ b/packages/frontend/src/pages/user-list-timeline.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :displayBackButton="true" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :displayBackButton="true" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <div ref="rootEl"> <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> @@ -22,18 +21,18 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> -import { computed, watch, ref, shallowRef } from 'vue'; +import { computed, watch, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; -import MkTimeline from '@/components/MkTimeline.vue'; import { scroll } from '@@/js/scroll.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import MkTimeline from '@/components/MkTimeline.vue'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; import { defaultStore } from '@/store.js'; import { deepMerge } from '@/scripts/merge.js'; import * as os from '@/os.js'; @@ -47,12 +46,14 @@ const props = defineProps<{ const list = ref<Misskey.entities.UserList | null>(null); const queue = ref(0); -const tlEl = shallowRef<InstanceType<typeof MkTimeline>>(); -const rootEl = shallowRef<HTMLElement>(); +const tlEl = useTemplateRef('tlEl'); +const rootEl = useTemplateRef('rootEl'); + const withRenotes = computed<boolean>({ get: () => defaultStore.reactiveState.tl.value.filter.withRenotes, set: (x) => saveTlFilter('withRenotes', x), }); + const onlyFiles = computed<boolean>({ get: () => defaultStore.reactiveState.tl.value.filter.onlyFiles, set: (x) => saveTlFilter('onlyFiles', x), @@ -105,7 +106,7 @@ const headerActions = computed(() => list.value ? [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: list.value ? list.value.name : i18n.ts.lists, icon: 'ti ti-list', })); diff --git a/packages/frontend/src/pages/user-tag.vue b/packages/frontend/src/pages/user-tag.vue index a77493fe47..d1dc721a4b 100644 --- a/packages/frontend/src/pages/user-tag.vue +++ b/packages/frontend/src/pages/user-tag.vue @@ -4,21 +4,19 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> - +<PageWithHeader> <MkSpacer :contentMax="1200"> <div class="_gaps_s"> <MkUserList :pagination="tagUsers"/> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed } from 'vue'; import MkUserList from '@/components/MkUserList.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const props = defineProps<{ tag: string; @@ -34,7 +32,7 @@ const tagUsers = computed(() => ({ }, })); -definePageMetadata(() => ({ +definePage(() => ({ title: props.tag, icon: 'ti ti-user-search', })); diff --git a/packages/frontend/src/pages/user/achievements.vue b/packages/frontend/src/pages/user/achievements.vue index 403e74904c..8f13e959e1 100644 --- a/packages/frontend/src/pages/user/achievements.vue +++ b/packages/frontend/src/pages/user/achievements.vue @@ -13,8 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'; import * as Misskey from 'misskey-js'; import MkAchievements from '@/components/MkAchievements.vue'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { $i } from '@/account.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { $i } from '@/i.js'; const props = defineProps<{ user: Misskey.entities.User; diff --git a/packages/frontend/src/pages/user/activity.following.vue b/packages/frontend/src/pages/user/activity.following.vue index aa2c791c76..f5d2002669 100644 --- a/packages/frontend/src/pages/user/activity.following.vue +++ b/packages/frontend/src/pages/user/activity.following.vue @@ -14,16 +14,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; -import { Chart, ChartDataset } from 'chart.js'; +import { onMounted, useTemplateRef, ref } from 'vue'; +import { Chart } from 'chart.js'; import * as Misskey from 'misskey-js'; import gradient from 'chartjs-plugin-gradient'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { initChart } from '@/scripts/init-chart.js'; -import { chartLegend } from '@/scripts/chart-legend.js'; +import type { ChartDataset } from 'chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { initChart } from '@/utility/init-chart.js'; +import { chartLegend } from '@/utility/chart-legend.js'; import MkChartLegend from '@/components/MkChartLegend.vue'; initChart(); @@ -32,8 +33,8 @@ const props = defineProps<{ user: Misskey.entities.User; }>(); -const chartEl = shallowRef<HTMLCanvasElement>(null); -const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>(); +const chartEl = useTemplateRef('chartEl'); +const legendEl = useTemplateRef('legendEl'); const now = new Date(); let chartInstance: Chart = null; const chartLimit = 30; @@ -63,7 +64,7 @@ async function renderChart() { const raw = await misskeyApi('charts/user/following', { userId: props.user.id, limit: chartLimit, span: 'day' }); - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const colorFollowLocal = '#008FFB'; const colorFollowRemote = '#008FFB88'; diff --git a/packages/frontend/src/pages/user/activity.notes.vue b/packages/frontend/src/pages/user/activity.notes.vue index 64514716d6..01c62810d4 100644 --- a/packages/frontend/src/pages/user/activity.notes.vue +++ b/packages/frontend/src/pages/user/activity.notes.vue @@ -14,16 +14,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; -import { Chart, ChartDataset } from 'chart.js'; +import { onMounted, useTemplateRef, ref } from 'vue'; +import { Chart } from 'chart.js'; import * as Misskey from 'misskey-js'; import gradient from 'chartjs-plugin-gradient'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { initChart } from '@/scripts/init-chart.js'; -import { chartLegend } from '@/scripts/chart-legend.js'; +import type { ChartDataset } from 'chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { initChart } from '@/utility/init-chart.js'; +import { chartLegend } from '@/utility/chart-legend.js'; import MkChartLegend from '@/components/MkChartLegend.vue'; initChart(); @@ -32,8 +33,8 @@ const props = defineProps<{ user: Misskey.entities.User; }>(); -const chartEl = shallowRef<HTMLCanvasElement>(null); -const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>(); +const chartEl = useTemplateRef('chartEl'); +const legendEl = useTemplateRef('legendEl'); const now = new Date(); let chartInstance: Chart = null; const chartLimit = 50; @@ -63,7 +64,7 @@ async function renderChart() { const raw = await misskeyApi('charts/user/notes', { userId: props.user.id, limit: chartLimit, span: 'day' }); - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const colorNormal = '#008FFB'; const colorReply = '#FEB019'; diff --git a/packages/frontend/src/pages/user/activity.pv.vue b/packages/frontend/src/pages/user/activity.pv.vue index ce24807f93..ed12b1b5c7 100644 --- a/packages/frontend/src/pages/user/activity.pv.vue +++ b/packages/frontend/src/pages/user/activity.pv.vue @@ -14,16 +14,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; -import { Chart, ChartDataset } from 'chart.js'; +import { onMounted, useTemplateRef, ref } from 'vue'; +import { Chart } from 'chart.js'; import * as Misskey from 'misskey-js'; import gradient from 'chartjs-plugin-gradient'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { initChart } from '@/scripts/init-chart.js'; -import { chartLegend } from '@/scripts/chart-legend.js'; +import type { ChartDataset } from 'chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { initChart } from '@/utility/init-chart.js'; +import { chartLegend } from '@/utility/chart-legend.js'; import MkChartLegend from '@/components/MkChartLegend.vue'; initChart(); @@ -32,8 +33,8 @@ const props = defineProps<{ user: Misskey.entities.User; }>(); -const chartEl = shallowRef<HTMLCanvasElement>(null); -const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>(); +const chartEl = useTemplateRef('chartEl'); +const legendEl = useTemplateRef('legendEl'); const now = new Date(); let chartInstance: Chart = null; const chartLimit = 30; @@ -63,7 +64,7 @@ async function renderChart() { const raw = await misskeyApi('charts/user/pv', { userId: props.user.id, limit: chartLimit, span: 'day' }); - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const colorUser = '#3498db'; const colorVisitor = '#2ecc71'; diff --git a/packages/frontend/src/pages/user/followers.vue b/packages/frontend/src/pages/user/followers.vue index 3a7d7b9ebd..b8ba023f74 100644 --- a/packages/frontend/src/pages/user/followers.vue +++ b/packages/frontend/src/pages/user/followers.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :displayBackButton="true" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :displayBackButton="true" :tabs="headerTabs"> <MkSpacer :contentMax="1000"> <Transition name="fade" mode="out-in"> <div v-if="user"> @@ -15,15 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLoading v-else/> </Transition> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import XFollowList from './follow-list.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ @@ -52,7 +51,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.user, icon: 'ti ti-user', ...user.value ? { diff --git a/packages/frontend/src/pages/user/following.vue b/packages/frontend/src/pages/user/following.vue index 045afd030a..1fe64c3042 100644 --- a/packages/frontend/src/pages/user/following.vue +++ b/packages/frontend/src/pages/user/following.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :displayBackButton="true" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :displayBackButton="true" :tabs="headerTabs"> <MkSpacer :contentMax="1000"> <Transition name="fade" mode="out-in"> <div v-if="user"> @@ -15,15 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLoading v-else/> </Transition> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import XFollowList from './follow-list.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ @@ -52,7 +51,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.user, icon: 'ti ti-user', ...user.value ? { diff --git a/packages/frontend/src/pages/user/home.stories.impl.ts b/packages/frontend/src/pages/user/home.stories.impl.ts index c623ef9ee4..66d3579041 100644 --- a/packages/frontend/src/pages/user/home.stories.impl.ts +++ b/packages/frontend/src/pages/user/home.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../../.storybook/fakes.js'; import { commonHandlers } from '../../../.storybook/mocks.js'; diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index fb067e9a65..802de9778c 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -12,7 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="profile _gaps"> <MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/> - <MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/> + <MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!"/> + <MkInfo v-if="user.host == null && user.username.includes('.')">{{ i18n.ts.isSystemAccount }}</MkInfo> <div :key="user.id" class="main _panel"> <div class="banner-container" :class="{ [$style.bannerContainerTall]: useTallBanner }"> @@ -58,7 +59,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div v-if="user.followedMessage != null" class="followedMessage"> - <MkFukidashi class="fukidashi" :tail="narrow ? 'none' : 'left'" negativeMargin shadow> + <MkFukidashi class="fukidashi" :tail="narrow ? 'none' : 'left'" negativeMargin> <div class="messageHeader">{{ i18n.ts.messageToFollower }}</div> <div><MkSparkle><Mfm :plain="true" :text="user.followedMessage" :author="user"/></MkSparkle></div> </MkFukidashi> @@ -205,21 +206,21 @@ import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkButton from '@/components/MkButton.vue'; -import { getUserMenu } from '@/scripts/get-user-menu.js'; +import { getUserMenu } from '@/utility/get-user-menu.js'; import number from '@/filters/number.js'; import { userPage } from '@/filters/user.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; -import { $i, iAmModerator } from '@/account.js'; +import { $i, iAmModerator } from '@/i.js'; import { dateString } from '@/filters/date.js'; -import { confetti } from '@/scripts/confetti.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; -import { useRouter } from '@/router/supplier.js'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; +import { confetti } from '@/utility/confetti.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/utility/isFfVisibleForMe.js'; +import { useRouter } from '@/router.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; import { infoImageUrl } from '@/instance.js'; import MkSparkle from '@/components/MkSparkle.vue'; +import { prefer } from '@/preferences.js'; const MkNote = defineAsyncComponent(() => defaultStore.state.noteDesign === 'sharkey' @@ -331,7 +332,7 @@ const AllPagination = { const style = computed(() => { if (props.user.bannerUrl == null) return {}; - if (defaultStore.state.disableShowingAnimatedImages) { + if (prefer.s.disableShowingAnimatedImages) { return { backgroundImage: `url(${ getStaticImageUrl(props.user.bannerUrl) })`, }; diff --git a/packages/frontend/src/pages/user/index.files.vue b/packages/frontend/src/pages/user/index.files.vue index 44e35e3479..804be43493 100644 --- a/packages/frontend/src/pages/user/index.files.vue +++ b/packages/frontend/src/pages/user/index.files.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; import { i18n } from '@/i18n.js'; import MkNoteMediaGrid from '@/components/MkNoteMediaGrid.vue'; diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue index ba02559d68..9d8eb0da2b 100644 --- a/packages/frontend/src/pages/user/index.vue +++ b/packages/frontend/src/pages/user/index.vue @@ -4,41 +4,38 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :displayBackButton="true" :actions="headerActions" :tabs="headerTabs"/></template> - <div> - <div v-if="user"> - <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <XHome v-if="tab === 'home'" key="home" :user="user" @unfoldFiles="() => { tab = 'files'; }"/> - <MkSpacer v-else-if="tab === 'notes'" key="notes" :contentMax="800" style="padding-top: 0"> - <XTimeline :user="user"/> - </MkSpacer> - <XFiles v-else-if="tab === 'files'" :user="user"/> - <XActivity v-else-if="tab === 'activity'" key="activity" :user="user"/> - <XAchievements v-else-if="tab === 'achievements'" key="achievements" :user="user"/> - <XReactions v-else-if="tab === 'reactions'" key="reactions" :user="user"/> - <XClips v-else-if="tab === 'clips'" key="clips" :user="user"/> - <XLists v-else-if="tab === 'lists'" key="lists" :user="user"/> - <XPages v-else-if="tab === 'pages'" key="pages" :user="user"/> - <XFlashs v-else-if="tab === 'flashs'" key="flashs" :user="user"/> - <XGallery v-else-if="tab === 'gallery'" key="gallery" :user="user"/> - <XRaw v-else-if="tab === 'raw'" key="raw" :user="user"/> - </MkHorizontalSwipe> - </div> - <MkError v-else-if="error" @retry="fetchUser()"/> - <MkLoading v-else/> +<PageWithHeader v-model:tab="tab" :displayBackButton="true" :tabs="headerTabs" :actions="headerActions"> + <div v-if="user"> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <XHome v-if="tab === 'home'" :user="user" @unfoldFiles="() => { tab = 'files'; }"/> + <MkSpacer v-else-if="tab === 'notes'" :contentMax="800" style="padding-top: 0"> + <XTimeline :user="user"/> + </MkSpacer> + <XFiles v-else-if="tab === 'files'" :user="user"/> + <XActivity v-else-if="tab === 'activity'" :user="user"/> + <XAchievements v-else-if="tab === 'achievements'" :user="user"/> + <XReactions v-else-if="tab === 'reactions'" :user="user"/> + <XClips v-else-if="tab === 'clips'" :user="user"/> + <XLists v-else-if="tab === 'lists'" :user="user"/> + <XPages v-else-if="tab === 'pages'" :user="user"/> + <XFlashs v-else-if="tab === 'flashs'" :user="user"/> + <XGallery v-else-if="tab === 'gallery'" :user="user"/> + <XRaw v-else-if="tab === 'raw'" :user="user"/> + </MkHorizontalSwipe> </div> -</MkStickyContainer> + <MkError v-else-if="error" @retry="fetchUser()"/> + <MkLoading v-else/> +</PageWithHeader> </template> <script lang="ts" setup> import { defineAsyncComponent, computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { acct as getAcct } from '@/filters/user.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { serverContext, assertServerContext } from '@/server-context.js'; @@ -147,7 +144,7 @@ const headerTabs = computed(() => user.value ? [{ icon: 'ti ti-code', }] : []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.user, icon: 'ti ti-user', ...user.value ? { diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue index c5731bd2a9..b95f79f42d 100644 --- a/packages/frontend/src/pages/welcome.entrance.a.vue +++ b/packages/frontend/src/pages/welcome.entrance.a.vue @@ -4,31 +4,24 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-if="meta" class="rsqzvsbo"> - <MkFeaturedPhotos class="bg"/> - <XTimeline class="tl"/> - <div class="shape1"></div> - <div class="shape2"></div> - <div class="logo-wrapper"> - <div class="powered-by">Powered by</div> - <img :src="misskeysvg" class="misskey"/> +<div v-if="meta" :class="$style.root"> + <MkFeaturedPhotos :class="$style.bg"/> + <XTimeline :class="$style.tl"/> + <div :class="$style.shape1"></div> + <div :class="$style.shape2"></div> + <div :class="$style.logoWrapper"> + <div :class="$style.poweredBy">Powered by</div> + <img :src="misskeysvg" :class="$style.misskey"/> </div> - <div class="emojis"> - <MkEmoji :normal="true" :noStyle="true" emoji="👍"/> - <MkEmoji :normal="true" :noStyle="true" emoji="❤"/> - <MkEmoji :normal="true" :noStyle="true" emoji="😆"/> - <MkEmoji :normal="true" :noStyle="true" emoji="🎉"/> - <MkEmoji :normal="true" :noStyle="true" emoji="🍮"/> - </div> - <div class="contents"> + <div :class="$style.contents"> <MkVisitorDashboard/> </div> - <div v-if="instances && instances.length > 0" class="federation"> + <div v-if="instances && instances.length > 0" :class="$style.federation"> <MarqueeText :duration="40"> <MkA v-for="instance in instances" :key="instance.id" :class="$style.federationInstance" :to="`/instance-info/${instance.host}`" behavior="window"> <!--<MkInstanceCardMini :instance="instance"/>--> - <img v-if="instance.iconUrl" class="icon" :src="getInstanceIcon(instance)" alt=""/> - <span class="name _monospace">{{ instance.host }}</span> + <img v-if="instance.iconUrl" :class="$style.federationInstanceIcon" :src="getInstanceIcon(instance)" alt=""/> + <span class="_monospace">{{ instance.host }}</span> </MkA> </MarqueeText> </div> @@ -42,9 +35,9 @@ import XTimeline from './welcome.timeline.vue'; import MarqueeText from '@/components/MkMarquee.vue'; import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; import misskeysvg from '/client-assets/sharkey.svg'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue'; -import { getProxiedImageUrl } from '@/scripts/media-proxy.js'; +import { getProxiedImageUrl } from '@/utility/media-proxy.js'; import { instance as meta } from '@/instance.js'; const instances = ref<Misskey.entities.FederationInstance[]>(); @@ -66,122 +59,111 @@ misskeyApiGet('federation/instances', { }); </script> -<style lang="scss" scoped> -.rsqzvsbo { - > .bg { - position: fixed; - top: 0; - right: 0; - width: 80vw; // 100%からshapeの幅を引いている - height: 100vh; - } +<style lang="scss" module> +.root { + height: 100cqh; + overflow: auto; + overscroll-behavior: contain; +} - > .tl { - position: fixed; - top: 0; - bottom: 0; - right: 64px; - margin: auto; - padding: 128px 0; - width: 500px; - height: calc(100% - 256px); - overflow: hidden; - -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); - mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); +.bg { + position: fixed; + top: 0; + right: 0; + width: 80vw; // 100%からshapeの幅を引いている + height: 100vh; +} - @media (max-width: 1200px) { - display: none; - } - } +.tl { + position: fixed; + top: 0; + bottom: 0; + right: 64px; + margin: auto; + padding: 128px 0; + width: 500px; + height: calc(100% - 256px); + overflow: hidden; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); - > .shape1 { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background: var(--MI_THEME-accent); - clip-path: polygon(0% 0%, 45% 0%, 20% 100%, 0% 100%); + @media (max-width: 1200px) { + display: none; } - > .shape2 { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background: var(--MI_THEME-accent); - clip-path: polygon(0% 0%, 25% 0%, 35% 100%, 0% 100%); - opacity: 0.5; - } - - > .logo-wrapper { - position: fixed; - top: 36px; - left: 36px; - flex: auto; - color: #fff; - user-select: none; - pointer-events: none; +} - > .powered-by { - margin-bottom: 2px; - } +.shape1 { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: var(--MI_THEME-accent); + clip-path: polygon(0% 0%, 45% 0%, 20% 100%, 0% 100%); +} +.shape2 { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: var(--MI_THEME-accent); + clip-path: polygon(0% 0%, 25% 0%, 35% 100%, 0% 100%); + opacity: 0.5; +} - > .misskey { - width: 140px; - @media (max-width: 450px) { - width: 130px; - } - } - } +.logoWrapper { + position: fixed; + top: 36px; + left: 36px; + flex: auto; + color: #fff; + user-select: none; + pointer-events: none; +} - > .emojis { - position: fixed; - bottom: 32px; - left: 35px; +.poweredBy { + margin-bottom: 2px; +} - > * { - margin-right: 8px; - } +.misskey { + width: 120px; - @media (max-width: 1200px) { - display: none; - } + @media (max-width: 450px) { + width: 100px; } +} - > .contents { - position: relative; - width: min(430px, calc(100% - 32px)); - margin-left: 128px; - padding: 100px 0 100px 0; +.contents { + position: relative; + width: min(430px, calc(100% - 32px)); + margin-left: 128px; + padding: 100px 0 100px 0; - @media (max-width: 1200px) { - margin: auto; - } + @media (max-width: 1200px) { + margin: auto; } +} - > .federation { - position: fixed; - bottom: 16px; - left: 0; - right: 0; - margin: auto; - background: var(--MI_THEME-acrylicPanel); - -webkit-backdrop-filter: var(--MI-blur, blur(15px)); - backdrop-filter: var(--MI-blur, blur(15px)); - border-radius: var(--MI-radius-ellipse); - overflow: clip; - width: 800px; - padding: 8px 0; +.federation { + position: fixed; + bottom: 16px; + left: 0; + right: 0; + margin: auto; + background: var(--MI_THEME-acrylicPanel); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); + border-radius: var(--MI-radius-ellipse); + overflow: clip; + width: 800px; + padding: 8px 0; - @media (max-width: 900px) { - display: none; - } + @media (max-width: 900px) { + display: none; } } -</style> -<style lang="scss" module> .federationInstance { display: inline-flex; align-items: center; @@ -190,13 +172,13 @@ misskeyApiGet('federation/instances', { margin: 0 10px 0 0; background: var(--MI_THEME-panel); border-radius: var(--MI-radius-ellipse); +} - > :global(.icon) { - display: inline-block; - width: 20px; - height: 20px; - margin-right: 5px; - border-radius: var(--MI-radius-ellipse); - } +.federationInstanceIcon { + display: inline-block; + width: 20px; + height: 20px; + margin-right: 5px; + border-radius: var(--MI-radius-ellipse); } </style> diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue index 58e815d89c..da14f180d5 100644 --- a/packages/frontend/src/pages/welcome.setup.vue +++ b/packages/frontend/src/pages/welcome.setup.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkAnimBg style="position: fixed; top: 0;"/> +<PageWithAnimBg> <div :class="$style.formContainer"> <form :class="$style.form" class="_panel" @submit.prevent="submit()"> <div :class="$style.title"> @@ -35,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </form> </div> -</div> +</PageWithAnimBg> </template> <script lang="ts" setup> @@ -44,10 +43,9 @@ import { host, version } from '@@/js/config.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { login } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import MkAnimBg from '@/components/MkAnimBg.vue'; +import { login } from '@/accounts.js'; const username = ref(''); const password = ref(''); diff --git a/packages/frontend/src/pages/welcome.timeline.note.vue b/packages/frontend/src/pages/welcome.timeline.note.vue index 460a225f23..5ca487a70b 100644 --- a/packages/frontend/src/pages/welcome.timeline.note.vue +++ b/packages/frontend/src/pages/welcome.timeline.note.vue @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, onUpdated, onMounted } from 'vue'; +import { ref, useTemplateRef, onUpdated, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; import MkMediaList from '@/components/MkMediaList.vue'; @@ -45,7 +45,7 @@ defineProps<{ note: Misskey.entities.Note; }>(); -const noteTextEl = shallowRef<HTMLDivElement>(); +const noteTextEl = useTemplateRef('noteTextEl'); const shouldCollapse = ref(false); const showContent = ref(false); diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue index 2964b15164..058be6c099 100644 --- a/packages/frontend/src/pages/welcome.timeline.vue +++ b/packages/frontend/src/pages/welcome.timeline.vue @@ -21,15 +21,15 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { onUpdated, ref, shallowRef } from 'vue'; -import XNote from '@/pages/welcome.timeline.note.vue'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; +import { onUpdated, ref, useTemplateRef } from 'vue'; import { getScrollContainer } from '@@/js/scroll.js'; +import XNote from '@/pages/welcome.timeline.note.vue'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; const notes = ref<Misskey.entities.Note[]>([]); const isScrolling = ref(false); const scrollState = ref<null | 'intro' | 'loop'>(null); -const notesMainContainerEl = shallowRef<HTMLElement>(); +const notesMainContainerEl = useTemplateRef('notesMainContainerEl'); misskeyApiGet('notes/featured').then(_notes => { notes.value = _notes.filter(n => n.cw == null); diff --git a/packages/frontend/src/pages/welcome.vue b/packages/frontend/src/pages/welcome.vue index 38d257506c..d3e571c053 100644 --- a/packages/frontend/src/pages/welcome.vue +++ b/packages/frontend/src/pages/welcome.vue @@ -16,7 +16,7 @@ import * as Misskey from 'misskey-js'; import XSetup from './welcome.setup.vue'; import XEntrance from './welcome.entrance.a.vue'; import { instanceName } from '@@/js/config.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { fetchInstance } from '@/instance.js'; const instance = ref<Misskey.entities.MetaDetailed | null>(null); @@ -29,7 +29,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: instanceName, icon: null, })); diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts index 27bb34da36..581ab35341 100644 --- a/packages/frontend/src/plugin.ts +++ b/packages/frontend/src/plugin.ts @@ -3,179 +3,431 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ref } from 'vue'; +import { ref, defineAsyncComponent } from 'vue'; import { Interpreter, Parser, utils, values } from '@syuilo/aiscript'; -import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; -import { Plugin, noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions, pageViewInterruptors } from '@/store.js'; -import { warningExternalWebsite } from '@/scripts/warning-external-website.js'; +import { compareVersions } from 'compare-versions'; +import { v4 as uuid } from 'uuid'; +import * as Misskey from 'misskey-js'; +import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js'; +import { store } from '@/store.js'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; +import { warningExternalWebsite } from '@/utility/warning-external-website.js'; + +export type Plugin = { + installId: string; + name: string; + active: boolean; + config?: Record<string, { default: any }>; + configData: Record<string, any>; + src: string | null; + version: string; + author?: string; + description?: string; + permissions?: string[]; +}; + +export type AiScriptPluginMeta = { + name: string; + version: string; + author: string; + description?: string; + permissions?: string[]; + config?: Record<string, any>; +}; const parser = new Parser(); -const pluginContexts = new Map<string, Interpreter>(); -export const pluginLogs = ref(new Map<string, string[]>()); -export async function install(plugin: Plugin): Promise<void> { +export function isSupportedAiScriptVersion(version: string): boolean { + try { + return (compareVersions(version, '0.12.0') >= 0); + } catch (err) { + return false; + } +} + +export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta> { + if (!code) { + throw new Error('code is required'); + } + + const lv = utils.getLangVersion(code); + if (lv == null) { + throw new Error('No language version annotation found'); + } else if (!isSupportedAiScriptVersion(lv)) { + throw new Error(`Aiscript version '${lv}' is not supported`); + } + + let ast; + try { + ast = parser.parse(code); + } catch (err) { + throw new Error('Aiscript syntax error'); + } + + const meta = Interpreter.collectMetadata(ast); + if (meta == null) { + throw new Error('Meta block not found'); + } + + const metadata = meta.get(null); + if (metadata == null) { + throw new Error('Metadata not found'); + } + + const { name, version, author, description, permissions, config } = metadata; + if (name == null || version == null || author == null) { + throw new Error('Required property not found'); + } + + return { + name, + version, + author, + description, + permissions, + config, + }; +} + +export async function authorizePlugin(plugin: Plugin) { + if (plugin.permissions == null || plugin.permissions.length === 0) return; + if (Object.hasOwn(store.s.pluginTokens, plugin.installId)) return; + + const token = await new Promise<string>((res, rej) => { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), { + title: i18n.ts.tokenRequested, + information: i18n.ts.pluginTokenRequestedDescription, + initialName: plugin.name, + initialPermissions: plugin.permissions, + }, { + done: async result => { + const { name, permissions } = result; + const { token } = await misskeyApi('miauth/gen-token', { + session: null, + name: name, + permission: permissions, + }); + res(token); + }, + closed: () => dispose(), + }); + }); + + store.set('pluginTokens', { + ...store.s.pluginTokens, + [plugin.installId]: token, + }); +} + +export async function installPlugin(code: string, meta?: AiScriptPluginMeta) { + if (!code) return; + + let realMeta: AiScriptPluginMeta; + if (!meta) { + realMeta = await parsePluginMeta(code); + } else { + realMeta = meta; + } + + if (prefer.s.plugins.some(x => x.name === realMeta.name)) { + throw new Error('Plugin already installed'); + } + + const installId = uuid(); + + const plugin = { + ...realMeta, + installId, + active: true, + configData: {}, + src: code, + }; + + prefer.commit('plugins', prefer.s.plugins.concat(plugin)); + + await authorizePlugin(plugin); + + await launchPlugin(installId); +} + +export async function uninstallPlugin(plugin: Plugin) { + abortPlugin(plugin); + prefer.commit('plugins', prefer.s.plugins.filter(x => x.installId !== plugin.installId)); + if (Object.hasOwn(store.s.pluginTokens, plugin.installId)) { + await os.apiWithDialog('i/revoke-token', { + token: store.s.pluginTokens[plugin.installId], + }); + const pluginTokens = { ...store.s.pluginTokens }; + delete pluginTokens[plugin.installId]; + store.set('pluginTokens', pluginTokens); + } +} + +const pluginContexts = new Map<Plugin['installId'], Interpreter>(); + +export const pluginLogs = ref(new Map<Plugin['installId'], { + at: number; + message: string; + isSystem?: boolean; + isError?: boolean; +}[]>()); + +type HandlerDef = { + post_form_action: { + title: string, + handler: <T>(form: T, update: (key: unknown, value: unknown) => void) => void; + }; + user_action: { + title: string, + handler: (user: Misskey.entities.UserDetailed) => void; + }; + note_action: { + title: string, + handler: (note: Misskey.entities.Note) => void; + }; + note_view_interruptor: { + handler: (note: Misskey.entities.Note) => unknown; + }; + note_post_interruptor: { + handler: (note: FIXME) => unknown; + }; + page_view_interruptor: { + handler: (page: Misskey.entities.Page) => unknown; + }; +}; + +type PluginHandler<K extends keyof HandlerDef> = { + pluginInstallId: string; + type: K; + ctx: HandlerDef[K]; +}; + +let pluginHandlers: PluginHandler<keyof HandlerDef>[] = []; + +function addPluginHandler<K extends keyof HandlerDef>(installId: Plugin['installId'], type: K, ctx: PluginHandler<K>['ctx']) { + pluginLogs.value.get(installId)!.push({ + at: Date.now(), + isSystem: true, + message: `Handler registered: ${type}`, + }); + pluginHandlers.push({ pluginInstallId: installId, type, ctx }); +} + +export function launchPlugins() { + for (const plugin of prefer.s.plugins) { + if (plugin.active) { + launchPlugin(plugin.installId); + } + } +} + +async function launchPlugin(id: Plugin['installId']): Promise<void> { + const plugin = prefer.s.plugins.find(x => x.installId === id); + if (!plugin) return; + // 後方互換性のため if (plugin.src == null) return; + pluginLogs.value.set(plugin.installId, []); + + function systemLog(message: string, isError = false): void { + pluginLogs.value.get(plugin.installId)?.push({ + at: Date.now(), + isSystem: true, + message, + isError, + }); + } + + systemLog('Starting plugin...'); + + await authorizePlugin(plugin); + const aiscript = new Interpreter(createPluginEnv({ plugin: plugin, - storageKey: 'plugins:' + plugin.id, + storageKey: 'plugins:' + plugin.installId, }), { in: aiScriptReadline, out: (value): void => { - console.log(value); - pluginLogs.value.get(plugin.id).push(utils.reprValue(value)); + pluginLogs.value.get(plugin.installId)!.push({ + at: Date.now(), + message: utils.reprValue(value), + }); }, log: (): void => { }, err: (err): void => { - pluginLogs.value.get(plugin.id).push(`${err}`); + pluginLogs.value.get(plugin.installId)!.push({ + at: Date.now(), + message: `${err}`, + isError: true, + }); throw err; // install時のtry-catchに反応させる }, }); - initPlugin({ plugin, aiscript }); + pluginContexts.set(plugin.installId, aiscript); aiscript.exec(parser.parse(plugin.src)).then( () => { console.info('Plugin installed:', plugin.name, 'v' + plugin.version); + systemLog('Plugin started'); }, (err) => { console.error('Plugin install failed:', plugin.name, 'v' + plugin.version); + systemLog(`${err}`, true); throw err; }, ); } +export function abortPlugin(plugin: Plugin): void { + const pluginContext = pluginContexts.get(plugin.installId); + if (!pluginContext) return; + + pluginContext.abort(); + pluginContexts.delete(plugin.installId); + pluginLogs.value.delete(plugin.installId); + pluginHandlers = pluginHandlers.filter(x => x.pluginInstallId !== plugin.installId); +} + +export function reloadPlugin(plugin: Plugin): void { + abortPlugin(plugin); + launchPlugin(plugin.installId); +} + +export async function configPlugin(plugin: Plugin) { + if (plugin.config == null) { + throw new Error('This plugin does not have a config'); + } + + const config = plugin.config; + for (const key in plugin.configData) { + config[key].default = plugin.configData[key]; + } + + const { canceled, result } = await os.form(plugin.name, config); + if (canceled) return; + + prefer.commit('plugins', prefer.s.plugins.map(x => x.installId === plugin.installId ? { ...x, configData: result } : x)); + + reloadPlugin(plugin); +} + +export function changePluginActive(plugin: Plugin, active: boolean) { + prefer.commit('plugins', prefer.s.plugins.map(x => x.installId === plugin.installId ? { ...x, active } : x)); + + if (active) { + launchPlugin(plugin.installId); + } else { + abortPlugin(plugin); + } +} + function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<string, values.Value> { + const id = opts.plugin.installId; + const config = new Map<string, values.Value>(); for (const [k, v] of Object.entries(opts.plugin.config ?? {})) { config.set(k, utils.jsToVal(typeof opts.plugin.configData[k] !== 'undefined' ? opts.plugin.configData[k] : v.default)); } - return { - ...createAiScriptEnv({ ...opts, token: opts.plugin.token }), - //#region Deprecated - 'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => { - utils.assertString(title); - registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler }); - }), - 'Mk:register_user_action': values.FN_NATIVE(([title, handler]) => { - utils.assertString(title); - registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler }); - }), - 'Mk:register_note_action': values.FN_NATIVE(([title, handler]) => { - utils.assertString(title); - registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler }); - }), - //#endregion - 'Plugin:register_post_form_action': values.FN_NATIVE(([title, handler]) => { + function withContext<T>(fn: (ctx: Interpreter) => T): T { + const ctx = pluginContexts.get(id); + if (!ctx) throw new Error('Plugin context not found'); + return fn(ctx); + } + + const env: Record<string, values.Value> = { + ...createAiScriptEnv({ ...opts, token: store.s.pluginTokens[id] }), + + 'Plugin:register:post_form_action': values.FN_NATIVE(([title, handler]) => { utils.assertString(title); - registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler }); + utils.assertFunction(handler); + addPluginHandler(id, 'post_form_action', { + title: title.value, + handler: withContext(ctx => (form, update) => { + ctx.execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => { + if (!key || !value) { + return; + } + update(utils.valToJs(key), utils.valToJs(value)); + })]); + }), + }); }), - 'Plugin:register_user_action': values.FN_NATIVE(([title, handler]) => { + + 'Plugin:register:user_action': values.FN_NATIVE(([title, handler]) => { utils.assertString(title); - registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler }); + utils.assertFunction(handler); + addPluginHandler(id, 'user_action', { + title: title.value, + handler: withContext(ctx => (user) => { + ctx.execFn(handler, [utils.jsToVal(user)]); + }), + }); }), - 'Plugin:register_note_action': values.FN_NATIVE(([title, handler]) => { + + 'Plugin:register:note_action': values.FN_NATIVE(([title, handler]) => { utils.assertString(title); - registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler }); + utils.assertFunction(handler); + addPluginHandler(id, 'note_action', { + title: title.value, + handler: withContext(ctx => (note) => { + ctx.execFn(handler, [utils.jsToVal(note)]); + }), + }); }), - 'Plugin:register_note_view_interruptor': values.FN_NATIVE(([handler]) => { - registerNoteViewInterruptor({ pluginId: opts.plugin.id, handler }); + + 'Plugin:register:note_view_interruptor': values.FN_NATIVE(([handler]) => { + utils.assertFunction(handler); + addPluginHandler(id, 'note_view_interruptor', { + handler: withContext(ctx => async (note) => { + return utils.valToJs(await ctx.execFn(handler, [utils.jsToVal(note)])); + }), + }); }), - 'Plugin:register_note_post_interruptor': values.FN_NATIVE(([handler]) => { - registerNotePostInterruptor({ pluginId: opts.plugin.id, handler }); + + 'Plugin:register:note_post_interruptor': values.FN_NATIVE(([handler]) => { + utils.assertFunction(handler); + addPluginHandler(id, 'note_post_interruptor', { + handler: withContext(ctx => async (note) => { + return utils.valToJs(await ctx.execFn(handler, [utils.jsToVal(note)])); + }), + }); }), - 'Plugin:register_page_view_interruptor': values.FN_NATIVE(([handler]) => { - registerPageViewInterruptor({ pluginId: opts.plugin.id, handler }); + + 'Plugin:register:page_view_interruptor': values.FN_NATIVE(([handler]) => { + utils.assertFunction(handler); + addPluginHandler(id, 'page_view_interruptor', { + handler: withContext(ctx => async (page) => { + return utils.valToJs(await ctx.execFn(handler, [utils.jsToVal(page)])); + }), + }); }), + 'Plugin:open_url': values.FN_NATIVE(([url]) => { utils.assertString(url); warningExternalWebsite(url.value); }), + 'Plugin:config': values.OBJ(config), }; -} - -function initPlugin({ plugin, aiscript }): void { - pluginContexts.set(plugin.id, aiscript); - pluginLogs.value.set(plugin.id, []); -} - -function registerPostFormAction({ pluginId, title, handler }): void { - postFormActions.push({ - title, handler: (form, update) => { - const pluginContext = pluginContexts.get(pluginId); - if (!pluginContext) { - return; - } - pluginContext.execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => { - if (!key || !value) { - return; - } - update(utils.valToJs(key), utils.valToJs(value)); - })]); - }, - }); -} - -function registerUserAction({ pluginId, title, handler }): void { - userActions.push({ - title, handler: (user) => { - const pluginContext = pluginContexts.get(pluginId); - if (!pluginContext) { - return; - } - pluginContext.execFn(handler, [utils.jsToVal(user)]); - }, - }); -} - -function registerNoteAction({ pluginId, title, handler }): void { - noteActions.push({ - title, handler: (note) => { - const pluginContext = pluginContexts.get(pluginId); - if (!pluginContext) { - return; - } - pluginContext.execFn(handler, [utils.jsToVal(note)]); - }, - }); -} -function registerNoteViewInterruptor({ pluginId, handler }): void { - noteViewInterruptors.push({ - handler: async (note) => { - const pluginContext = pluginContexts.get(pluginId); - if (!pluginContext) { - return; - } - return utils.valToJs(await pluginContext.execFn(handler, [utils.jsToVal(note)])); - }, - }); -} + // 後方互換性のため + env['Plugin:register_post_form_action'] = env['Plugin:register:post_form_action']; + env['Plugin:register_user_action'] = env['Plugin:register:user_action']; + env['Plugin:register_note_action'] = env['Plugin:register:note_action']; + env['Plugin:register_note_view_interruptor'] = env['Plugin:register:note_view_interruptor']; + env['Plugin:register_note_post_interruptor'] = env['Plugin:register:note_post_interruptor']; + env['Plugin:register_page_view_interruptor'] = env['Plugin:register:page_view_interruptor']; -function registerNotePostInterruptor({ pluginId, handler }): void { - notePostInterruptors.push({ - handler: async (note) => { - const pluginContext = pluginContexts.get(pluginId); - if (!pluginContext) { - return; - } - return utils.valToJs(await pluginContext.execFn(handler, [utils.jsToVal(note)])); - }, - }); + return env; } -function registerPageViewInterruptor({ pluginId, handler }): void { - pageViewInterruptors.push({ - handler: async (page) => { - const pluginContext = pluginContexts.get(pluginId); - if (!pluginContext) { - return; - } - return utils.valToJs(await pluginContext.execFn(handler, [utils.jsToVal(page)])); - }, - }); +export function getPluginHandlers<K extends keyof HandlerDef>(type: K): HandlerDef[K][] { + return pluginHandlers.filter((x): x is PluginHandler<K> => x.type === type).map(x => x.ctx); } diff --git a/packages/frontend/src/pref-migrate.ts b/packages/frontend/src/pref-migrate.ts new file mode 100644 index 0000000000..beeaac8383 --- /dev/null +++ b/packages/frontend/src/pref-migrate.ts @@ -0,0 +1,142 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { v4 as uuid } from 'uuid'; +import type { DeckProfile } from '@/deck.js'; +import { ColdDeviceStorage, store } from '@/store.js'; +import { prefer } from '@/preferences.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { deckStore } from '@/ui/deck/deck-store.js'; +import { unisonReload } from '@/utility/unison-reload.js'; + +// TODO: そのうち消す +export function migrateOldSettings() { + store.loaded.then(async () => { + const themes = await misskeyApi('i/registry/get', { scope: ['client'], key: 'themes' }).catch(() => []); + if (themes.length > 0) { + prefer.commit('themes', themes); + } + + const plugins = ColdDeviceStorage.get('plugins'); + prefer.commit('plugins', plugins.map(p => ({ + ...p, + installId: (p as any).id, + id: undefined, + }))); + + prefer.commit('deck.profile', deckStore.s.profile); + misskeyApi('i/registry/keys', { + scope: ['client', 'deck', 'profiles'], + }).then(async keys => { + const profiles: DeckProfile[] = []; + for (const key of keys) { + const deck = await misskeyApi('i/registry/get', { + scope: ['client', 'deck', 'profiles'], + key: key, + }); + profiles.push({ + id: uuid(), + name: key, + columns: deck.columns, + layout: deck.layout, + }); + } + prefer.commit('deck.profiles', profiles); + }); + + prefer.commit('lightTheme', ColdDeviceStorage.get('lightTheme')); + prefer.commit('darkTheme', ColdDeviceStorage.get('darkTheme')); + prefer.commit('syncDeviceDarkMode', ColdDeviceStorage.get('syncDeviceDarkMode')); + prefer.commit('emojiPalettes', [{ + id: 'reactions', + name: '', + emojis: store.s.reactions, + }, { + id: 'pinnedEmojis', + name: '', + emojis: store.s.pinnedEmojis, + }]); + prefer.commit('emojiPaletteForMain', 'pinnedEmojis'); + prefer.commit('emojiPaletteForReaction', 'reactions'); + prefer.commit('overridedDeviceKind', store.s.overridedDeviceKind); + prefer.commit('widgets', store.s.widgets); + prefer.commit('keepCw', store.s.keepCw); + prefer.commit('collapseRenotes', store.s.collapseRenotes); + prefer.commit('rememberNoteVisibility', store.s.rememberNoteVisibility); + prefer.commit('uploadFolder', store.s.uploadFolder); + prefer.commit('keepOriginalUploading', store.s.keepOriginalUploading); + prefer.commit('menu', store.s.menu); + prefer.commit('statusbars', store.s.statusbars); + prefer.commit('pinnedUserLists', store.s.pinnedUserLists); + prefer.commit('serverDisconnectedBehavior', store.s.serverDisconnectedBehavior); + prefer.commit('nsfw', store.s.nsfw); + prefer.commit('highlightSensitiveMedia', store.s.highlightSensitiveMedia); + prefer.commit('animation', store.s.animation); + prefer.commit('animatedMfm', store.s.animatedMfm); + prefer.commit('advancedMfm', store.s.advancedMfm); + prefer.commit('showReactionsCount', store.s.showReactionsCount); + prefer.commit('enableQuickAddMfmFunction', store.s.enableQuickAddMfmFunction); + prefer.commit('loadRawImages', store.s.loadRawImages); + prefer.commit('imageNewTab', store.s.imageNewTab); + prefer.commit('disableShowingAnimatedImages', store.s.disableShowingAnimatedImages); + prefer.commit('emojiStyle', store.s.emojiStyle); + prefer.commit('menuStyle', store.s.menuStyle); + prefer.commit('useBlurEffectForModal', store.s.useBlurEffectForModal); + prefer.commit('useBlurEffect', store.s.useBlurEffect); + prefer.commit('showFixedPostForm', store.s.showFixedPostForm); + prefer.commit('showFixedPostFormInChannel', store.s.showFixedPostFormInChannel); + prefer.commit('enableInfiniteScroll', store.s.enableInfiniteScroll); + prefer.commit('useReactionPickerForContextMenu', store.s.useReactionPickerForContextMenu); + prefer.commit('showGapBetweenNotesInTimeline', store.s.showGapBetweenNotesInTimeline); + prefer.commit('instanceTicker', store.s.instanceTicker); + prefer.commit('emojiPickerScale', store.s.emojiPickerScale); + prefer.commit('emojiPickerWidth', store.s.emojiPickerWidth); + prefer.commit('emojiPickerHeight', store.s.emojiPickerHeight); + prefer.commit('emojiPickerStyle', store.s.emojiPickerStyle); + prefer.commit('reportError', store.s.reportError); + prefer.commit('squareAvatars', store.s.squareAvatars); + prefer.commit('showAvatarDecorations', store.s.showAvatarDecorations); + prefer.commit('numberOfPageCache', store.s.numberOfPageCache); + prefer.commit('showNoteActionsOnlyHover', store.s.showNoteActionsOnlyHover); + prefer.commit('showClipButtonInNoteFooter', store.s.showClipButtonInNoteFooter); + prefer.commit('reactionsDisplaySize', store.s.reactionsDisplaySize); + prefer.commit('limitWidthOfReaction', store.s.limitWidthOfReaction); + prefer.commit('forceShowAds', store.s.forceShowAds); + prefer.commit('aiChanMode', store.s.aiChanMode); + prefer.commit('devMode', store.s.devMode); + prefer.commit('mediaListWithOneImageAppearance', store.s.mediaListWithOneImageAppearance); + prefer.commit('notificationPosition', store.s.notificationPosition); + prefer.commit('notificationStackAxis', store.s.notificationStackAxis); + prefer.commit('enableCondensedLine', store.s.enableCondensedLine); + prefer.commit('keepScreenOn', store.s.keepScreenOn); + prefer.commit('disableStreamingTimeline', store.s.disableStreamingTimeline); + prefer.commit('useGroupedNotifications', store.s.useGroupedNotifications); + prefer.commit('dataSaver', store.s.dataSaver); + prefer.commit('enableSeasonalScreenEffect', store.s.enableSeasonalScreenEffect); + prefer.commit('enableHorizontalSwipe', store.s.enableHorizontalSwipe); + prefer.commit('useNativeUiForVideoAudioPlayer', store.s.useNativeUIForVideoAudioPlayer); + prefer.commit('keepOriginalFilename', store.s.keepOriginalFilename); + prefer.commit('alwaysConfirmFollow', store.s.alwaysConfirmFollow); + prefer.commit('confirmWhenRevealingSensitiveMedia', store.s.confirmWhenRevealingSensitiveMedia); + prefer.commit('contextMenu', store.s.contextMenu); + prefer.commit('skipNoteRender', store.s.skipNoteRender); + prefer.commit('showSoftWordMutedWord', store.s.showSoftWordMutedWord); + prefer.commit('confirmOnReact', store.s.confirmOnReact); + prefer.commit('defaultFollowWithReplies', store.s.defaultWithReplies); + prefer.commit('sound.masterVolume', store.s.sound_masterVolume); + prefer.commit('sound.notUseSound', store.s.sound_notUseSound); + prefer.commit('sound.useSoundOnlyWhenActive', store.s.sound_useSoundOnlyWhenActive); + prefer.commit('sound.on.note', store.s.sound_note as any); + prefer.commit('sound.on.noteMy', store.s.sound_noteMy as any); + prefer.commit('sound.on.notification', store.s.sound_notification as any); + prefer.commit('sound.on.reaction', store.s.sound_reaction as any); + prefer.commit('defaultNoteVisibility', store.s.defaultNoteVisibility); + prefer.commit('defaultNoteLocalOnly', store.s.defaultNoteLocalOnly); + + window.setTimeout(() => { + unisonReload(); + }, 5000); + }); +} diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts new file mode 100644 index 0000000000..73c89e23af --- /dev/null +++ b/packages/frontend/src/preferences.ts @@ -0,0 +1,150 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { PreferencesProfile, StorageProvider } from '@/preferences/manager.js'; +import { cloudBackup } from '@/preferences/utility.js'; +import { miLocalStorage } from '@/local-storage.js'; +import { isSameScope, PreferencesManager } from '@/preferences/manager.js'; +import { store } from '@/store.js'; +import { $i } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { TAB_ID } from '@/tab-id.js'; + +function createPrefManager(storageProvider: StorageProvider) { + let profile: PreferencesProfile; + + const savedProfileRaw = miLocalStorage.getItem('preferences'); + if (savedProfileRaw == null) { + profile = PreferencesManager.newProfile(); + miLocalStorage.setItem('preferences', JSON.stringify(profile)); + } else { + profile = PreferencesManager.normalizeProfile(JSON.parse(savedProfileRaw)); + } + + return new PreferencesManager(profile, storageProvider); +} + +const syncGroup = 'default'; + +const storageProvider: StorageProvider = { + save: (ctx) => { + miLocalStorage.setItem('preferences', JSON.stringify(ctx.profile)); + miLocalStorage.setItem('latestPreferencesUpdate', `${TAB_ID}/${Date.now()}`); + }, + + cloudGet: async (ctx) => { + // TODO: この取得方法だとアカウントが変わると保存場所も変わってしまうので改修する + // 例えば複数アカウントある場合でも設定値を保存するための「プライマリアカウント」を設定できるようにするとか + try { + const cloudData = await misskeyApi('i/registry/get', { + scope: ['client', 'preferences', 'sync'], + key: syncGroup + ':' + ctx.key, + }) as [any, any][]; + const target = cloudData.find(([scope]) => isSameScope(scope, ctx.scope)); + if (target == null) return null; + return { + value: target[1], + }; + } catch (err: any) { + if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する + return null; + } else { + throw err; + } + } + }, + + cloudSet: async (ctx) => { + let cloudData: [any, any][] = []; + try { + cloudData = await misskeyApi('i/registry/get', { + scope: ['client', 'preferences', 'sync'], + key: syncGroup + ':' + ctx.key, + }) as [any, any][]; + } catch (err: any) { + if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する + cloudData = []; + } else { + throw err; + } + } + + const i = cloudData.findIndex(([scope]) => isSameScope(scope, ctx.scope)); + + if (i === -1) { + cloudData.push([ctx.scope, ctx.value]); + } else { + cloudData[i] = [ctx.scope, ctx.value]; + } + + await misskeyApi('i/registry/set', { + scope: ['client', 'preferences', 'sync'], + key: syncGroup + ':' + ctx.key, + value: cloudData, + }); + }, + + cloudGets: async (ctx) => { + // TODO: 値の取得を1つのリクエストで済ませたい(バックエンド側でAPIの新設が必要) + const fetchings = ctx.needs.map(need => storageProvider.cloudGet(need).then(res => [need.key, res] as const)); + const cloudDatas = await Promise.all(fetchings); + + const res = {} as Partial<Record<string, any>>; + for (const cloudData of cloudDatas) { + if (cloudData[1] != null) { + res[cloudData[0]] = cloudData[1].value; + } + } + + return res; + }, +}; + +export const prefer = createPrefManager(storageProvider); + +let latestSyncedAt = Date.now(); + +function syncBetweenTabs() { + const latest = miLocalStorage.getItem('latestPreferencesUpdate'); + if (latest == null) return; + + const latestTab = latest.split('/')[0]; + const latestAt = parseInt(latest.split('/')[1]); + + if (latestTab === TAB_ID) return; + if (latestAt <= latestSyncedAt) return; + + prefer.rewriteProfile(PreferencesManager.normalizeProfile(JSON.parse(miLocalStorage.getItem('preferences')!))); + + latestSyncedAt = Date.now(); + + if (_DEV_) console.log('prefer:synced'); +} + +window.setInterval(syncBetweenTabs, 5000); + +window.document.addEventListener('visibilitychange', () => { + if (window.document.visibilityState === 'visible') { + syncBetweenTabs(); + } +}); + +let latestBackupAt = 0; + +window.setInterval(() => { + if ($i == null) return; + if (!store.s.enablePreferencesAutoCloudBackup) return; + if (window.document.visibilityState !== 'visible') return; // 同期されていない古い値がバックアップされるのを防ぐ + if (prefer.profile.modifiedAt <= latestBackupAt) return; + + cloudBackup().then(() => { + latestBackupAt = Date.now(); + }); +}, 1000 * 60 * 3); + +if (_DEV_) { + (window as any).prefer = prefer; + (window as any).cloudBackup = cloudBackup; +} diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts new file mode 100644 index 0000000000..127ebeef0c --- /dev/null +++ b/packages/frontend/src/preferences/def.ts @@ -0,0 +1,384 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { hemisphere } from '@@/js/intl-const.js'; +import type { Theme } from '@/theme.js'; +import type { SoundType } from '@/utility/sound.js'; +import type { Plugin } from '@/plugin.js'; +import type { DeviceKind } from '@/utility/device-kind.js'; +import type { DeckProfile } from '@/deck.js'; +import type { PreferencesDefinition } from './manager.js'; +import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js'; + +/** サウンド設定 */ +export type SoundStore = { + type: Exclude<SoundType, '_driveFile_'>; + volume: number; +} | { + type: '_driveFile_'; + + /** ドライブのファイルID */ + fileId: string; + + /** ファイルURL(こちらが優先される) */ + fileUrl: string; + + volume: number; +}; + +// NOTE: デフォルト値は他の設定の状態に依存してはならない(依存していた場合、ユーザーがその設定項目単体で「初期値にリセット」した場合不具合の原因になる) + +export const PREF_DEF = { + // TODO: 持つのはホストやユーザーID、ユーザー名など最低限にしといて、その他のプロフィール情報はpreferences外で管理した方が綺麗そう + // 現状だと、updateCurrentAccount/updateCurrentAccountPartialが呼ばれるたびに「設定」へのcommitが行われて不自然(明らかに設定の更新とは捉えにくい)だし + accounts: { + default: [] as [host: string, user: Misskey.entities.User][], + }, + + pinnedUserLists: { + accountDependent: true, + default: [] as Misskey.entities.UserList[], + }, + uploadFolder: { + accountDependent: true, + default: null as string | null, + }, + widgets: { + accountDependent: true, + default: [{ + name: 'calendar', + id: 'a', place: 'right', data: {}, + }, { + name: 'notifications', + id: 'b', place: 'right', data: {}, + }, { + name: 'trends', + id: 'c', place: 'right', data: {}, + }] as { + name: string; + id: string; + place: string | null; + data: Record<string, any>; + }[], + }, + 'deck.profile': { + accountDependent: true, + default: null as string | null, + }, + 'deck.profiles': { + accountDependent: true, + default: [] as DeckProfile[], + }, + + emojiPalettes: { + serverDependent: true, + default: [{ + id: 'a', + name: '', + emojis: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], + }] as { + id: string; + name: string; + emojis: string[]; + }[], + }, + emojiPaletteForReaction: { + serverDependent: true, + default: null as string | null, + }, + emojiPaletteForMain: { + serverDependent: true, + default: null as string | null, + }, + + overridedDeviceKind: { + default: null as DeviceKind | null, + }, + themes: { + default: [] as Theme[], + }, + lightTheme: { + default: null as Theme | null, + }, + darkTheme: { + default: null as Theme | null, + }, + syncDeviceDarkMode: { + default: true, + }, + defaultNoteVisibility: { + default: 'public' as (typeof Misskey.noteVisibilities)[number], + }, + defaultNoteLocalOnly: { + default: false, + }, + keepCw: { + default: true, + }, + keepOriginalUploading: { + default: false, + }, + rememberNoteVisibility: { + default: false, + }, + reportError: { + default: false, + }, + collapseRenotes: { + default: true, + }, + menu: { + default: [ + 'notifications', + 'clips', + 'drive', + 'followRequests', + 'chat', + '-', + 'explore', + 'announcements', + 'channels', + 'search', + '-', + 'ui', + ], + }, + statusbars: { + default: [] as { + name: string; + id: string; + type: string; + size: 'verySmall' | 'small' | 'medium' | 'large' | 'veryLarge'; + black: boolean; + props: Record<string, any>; + }[], + }, + serverDisconnectedBehavior: { + default: 'quiet' as 'quiet' | 'reload' | 'dialog', + }, + nsfw: { + default: 'respect' as 'respect' | 'force' | 'ignore', + }, + highlightSensitiveMedia: { + default: false, + }, + animation: { + default: !window.matchMedia('(prefers-reduced-motion)').matches, + }, + animatedMfm: { + default: !window.matchMedia('(prefers-reduced-motion)').matches, + }, + advancedMfm: { + default: true, + }, + showReactionsCount: { + default: false, + }, + enableQuickAddMfmFunction: { + default: false, + }, + loadRawImages: { + default: false, + }, + imageNewTab: { + default: false, + }, + disableShowingAnimatedImages: { + default: window.matchMedia('(prefers-reduced-motion)').matches, + }, + emojiStyle: { + default: 'twemoji', // twemoji / fluentEmoji / native + }, + menuStyle: { + default: 'auto' as 'auto' | 'popup' | 'drawer', + }, + useBlurEffectForModal: { + default: DEFAULT_DEVICE_KIND === 'desktop', + }, + useBlurEffect: { + default: DEFAULT_DEVICE_KIND === 'desktop', + }, + showFixedPostForm: { + default: false, + }, + showFixedPostFormInChannel: { + default: false, + }, + enableInfiniteScroll: { + default: true, + }, + useReactionPickerForContextMenu: { + default: false, + }, + showGapBetweenNotesInTimeline: { + default: false, + }, + instanceTicker: { + default: 'remote' as 'none' | 'remote' | 'always', + }, + emojiPickerScale: { + default: 2, + }, + emojiPickerWidth: { + default: 2, + }, + emojiPickerHeight: { + default: 3, + }, + emojiPickerStyle: { + default: 'auto' as 'auto' | 'popup' | 'drawer', + }, + squareAvatars: { + default: false, + }, + showAvatarDecorations: { + default: true, + }, + numberOfPageCache: { + default: 3, + }, + showNoteActionsOnlyHover: { + default: false, + }, + showClipButtonInNoteFooter: { + default: false, + }, + reactionsDisplaySize: { + default: 'medium' as 'small' | 'medium' | 'large', + }, + limitWidthOfReaction: { + default: true, + }, + forceShowAds: { + default: false, + }, + aiChanMode: { + default: false, + }, + devMode: { + default: false, + }, + mediaListWithOneImageAppearance: { + default: 'expand' as 'expand' | '16_9' | '1_1' | '2_3', + }, + notificationPosition: { + default: 'rightBottom' as 'leftTop' | 'leftBottom' | 'rightTop' | 'rightBottom', + }, + notificationStackAxis: { + default: 'horizontal' as 'vertical' | 'horizontal', + }, + enableCondensedLine: { + default: true, + }, + keepScreenOn: { + default: false, + }, + disableStreamingTimeline: { + default: false, + }, + useGroupedNotifications: { + default: true, + }, + dataSaver: { + default: { + media: false, + avatar: false, + urlPreview: false, + code: false, + } as Record<string, boolean>, + }, + hemisphere: { + default: hemisphere as 'N' | 'S', + }, + enableSeasonalScreenEffect: { + default: false, + }, + enableHorizontalSwipe: { + default: true, + }, + useNativeUiForVideoAudioPlayer: { + default: false, + }, + keepOriginalFilename: { + default: true, + }, + alwaysConfirmFollow: { + default: true, + }, + confirmWhenRevealingSensitiveMedia: { + default: false, + }, + contextMenu: { + default: 'app' as 'app' | 'appWithShift' | 'native', + }, + skipNoteRender: { + default: true, + }, + showSoftWordMutedWord: { + default: false, + }, + confirmOnReact: { + default: false, + }, + defaultFollowWithReplies: { + default: false, + }, + makeEveryTextElementsSelectable: { + default: DEFAULT_DEVICE_KIND === 'desktop', + }, + plugins: { + default: [] as Plugin[], + }, + + 'sound.masterVolume': { + default: 0.3, + }, + 'sound.notUseSound': { + default: false, + }, + 'sound.useSoundOnlyWhenActive': { + default: false, + }, + 'sound.on.note': { + default: { type: 'syuilo/n-aec', volume: 1 } as SoundStore, + }, + 'sound.on.noteMy': { + default: { type: 'syuilo/n-cea-4va', volume: 1 } as SoundStore, + }, + 'sound.on.notification': { + default: { type: 'syuilo/n-ea', volume: 1 } as SoundStore, + }, + 'sound.on.reaction': { + default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore, + }, + 'sound.on.chatMessage': { + default: { type: 'syuilo/waon', volume: 1 } as SoundStore, + }, + + 'deck.alwaysShowMainColumn': { + default: true, + }, + 'deck.navWindow': { + default: true, + }, + 'deck.useSimpleUiForNonRootPages': { + default: true, + }, + 'deck.columnAlign': { + default: 'left' as 'left' | 'right' | 'center', + }, + + 'game.dropAndFusion': { + default: { + bgmVolume: 0.25, + sfxVolume: 1, + }, + }, + + 'experimental.stackingRouterView': { + default: false, + }, +} satisfies PreferencesDefinition; diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts new file mode 100644 index 0000000000..f96aa2f368 --- /dev/null +++ b/packages/frontend/src/preferences/manager.ts @@ -0,0 +1,476 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { computed, onUnmounted, ref, watch } from 'vue'; +import { v4 as uuid } from 'uuid'; +import { host, version } from '@@/js/config.js'; +import { PREF_DEF } from './def.js'; +import type { Ref, WritableComputedRef } from 'vue'; +import type { MenuItem } from '@/types/menu.js'; +import { $i } from '@/i.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; +import { deepEqual } from '@/utility/deep-equal.js'; + +// NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない + +//type DottedToNested<T extends Record<string, any>> = { +// [K in keyof T as K extends string ? K extends `${infer A}.${infer B}` ? A : K : K]: K extends `${infer A}.${infer B}` ? DottedToNested<{ [key in B]: T[K] }> : T[K]; +//}; + +type PREF = typeof PREF_DEF; +type ValueOf<K extends keyof PREF> = PREF[K]['default']; + +type Scope = Partial<{ + server: string | null; // host + account: string | null; // userId + device: string | null; // 将来のため +}>; + +type ValueMeta = Partial<{ + sync: boolean; +}>; + +type PrefRecord<K extends keyof PREF> = [scope: Scope, value: ValueOf<K>, meta: ValueMeta]; + +function parseScope(scope: Scope): { + server: string | null; + account: string | null; + device: string | null; +} { + return { + server: scope.server ?? null, + account: scope.account ?? null, + device: scope.device ?? null, + }; +} + +function makeScope(scope: Partial<{ + server: string | null; + account: string | null; + device: string | null; +}>): Scope { + const c = {} as Scope; + if (scope.server != null) c.server = scope.server; + if (scope.account != null) c.account = scope.account; + if (scope.device != null) c.device = scope.device; + return c; +} + +export function isSameScope(a: Scope, b: Scope): boolean { + // null と undefined (キー無し) は区別したくないので == で比較 + // eslint-disable-next-line eqeqeq + return a.server == b.server && a.account == b.account && a.device == b.device; +} + +export type PreferencesProfile = { + id: string; + version: string; + type: 'main'; + modifiedAt: number; + name: string; + preferences: { + [K in keyof PREF]: PrefRecord<K>[]; + }; +}; + +export type StorageProvider = { + save: (ctx: { profile: PreferencesProfile; }) => void; + cloudGets: <K extends keyof PREF>(ctx: { needs: { key: K; scope: Scope; }[] }) => Promise<Partial<Record<K, ValueOf<K>>>>; + cloudGet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; }) => Promise<{ value: ValueOf<K>; } | null>; + cloudSet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; value: ValueOf<K>; }) => Promise<void>; +}; + +export type PreferencesDefinition = Record<string, { + default: any; + accountDependent?: boolean; + serverDependent?: boolean; +}>; + +export class PreferencesManager { + private storageProvider: StorageProvider; + public profile: PreferencesProfile; + public cloudReady: Promise<void>; + + /** + * static / state の略 (static が予約語のため) + */ + public s = {} as { + [K in keyof PREF]: ValueOf<K>; + }; + + /** + * reactive の略 + */ + public r = {} as { + [K in keyof PREF]: Ref<ValueOf<K>>; + }; + + constructor(profile: PreferencesProfile, storageProvider: StorageProvider) { + this.profile = profile; + this.storageProvider = storageProvider; + + const states = this.genStates(); + + for (const key in states) { + this.s[key] = states[key]; + this.r[key] = ref(this.s[key]); + } + + this.cloudReady = this.fetchCloudValues(); + + // TODO: 定期的にクラウドの値をフェッチ + } + + private isAccountDependentKey<K extends keyof PREF>(key: K): boolean { + return (PREF_DEF as PreferencesDefinition)[key].accountDependent === true; + } + + private isServerDependentKey<K extends keyof PREF>(key: K): boolean { + return (PREF_DEF as PreferencesDefinition)[key].serverDependent === true; + } + + private rewriteRawState<K extends keyof PREF>(key: K, value: ValueOf<K>) { + const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除 + this.r[key].value = this.s[key] = v; + } + + public commit<K extends keyof PREF>(key: K, value: ValueOf<K>) { + const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除 + + if (deepEqual(this.s[key], v)) { + if (_DEV_) console.log('(skip) prefer:commit', key, v); + return; + } + + if (_DEV_) console.log('prefer:commit', key, v); + + this.rewriteRawState(key, v); + + const record = this.getMatchedRecordOf(key); + + if (parseScope(record[0]).account == null && this.isAccountDependentKey(key)) { + this.profile.preferences[key].push([makeScope({ + server: host, + account: $i!.id, + }), v, {}]); + this.save(); + return; + } + + if (parseScope(record[0]).server == null && this.isServerDependentKey(key)) { + this.profile.preferences[key].push([makeScope({ + server: host, + }), v, {}]); + this.save(); + return; + } + + record[1] = v; + this.save(); + + if (record[2].sync) { + // awaitの必要なし + // TODO: リクエストを間引く + this.storageProvider.cloudSet({ key, scope: record[0], value: record[1] }); + } + } + + /** + * 特定のキーの、簡易的なcomputed refを作ります + * 主にvue上で設定コントロールのmodelとして使う用 + */ + public model<K extends keyof PREF, V extends ValueOf<K> = ValueOf<K>>( + key: K, + getter?: (v: ValueOf<K>) => V, + setter?: (v: V) => ValueOf<K>, + ): WritableComputedRef<V> { + const valueRef = ref(this.s[key]); + + const stop = watch(this.r[key], val => { + valueRef.value = val; + }); + + // NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする + onUnmounted(() => { + stop(); + }); + + // TODO: VueのcustomRef使うと良い感じになるかも + return computed({ + get: () => { + if (getter) { + return getter(valueRef.value); + } else { + return valueRef.value; + } + }, + set: (value) => { + const val = setter ? setter(value) : value; + this.commit(key, val); + valueRef.value = val; + }, + }); + } + + private genStates() { + const states = {} as { [K in keyof PREF]: ValueOf<K> }; + for (const _key in PREF_DEF) { + const key = _key as keyof PREF; + const record = this.getMatchedRecordOf(key); + (states[key] as any) = record[1]; + } + + return states; + } + + private async fetchCloudValues() { + const needs = [] as { key: keyof PREF; scope: Scope; }[]; + for (const _key in PREF_DEF) { + const key = _key as keyof PREF; + const record = this.getMatchedRecordOf(key); + if (record[2].sync) { + needs.push({ + key, + scope: record[0], + }); + } + } + + const cloudValues = await this.storageProvider.cloudGets({ needs }); + + for (const _key in PREF_DEF) { + const key = _key as keyof PREF; + const record = this.getMatchedRecordOf(key); + if (record[2].sync && Object.hasOwn(cloudValues, key) && cloudValues[key] !== undefined) { + const cloudValue = cloudValues[key]; + if (!deepEqual(cloudValue, record[1])) { + this.rewriteRawState(key, cloudValue); + record[1] = cloudValue; + if (_DEV_) console.log('cloud fetched', key, cloudValue); + } + } + } + + this.save(); + if (_DEV_) console.log('cloud fetch completed'); + } + + public static newProfile(): PreferencesProfile { + const data = {} as PreferencesProfile['preferences']; + for (const key in PREF_DEF) { + data[key] = [[makeScope({}), PREF_DEF[key].default, {}]]; + } + return { + id: uuid(), + version: version, + type: 'main', + modifiedAt: Date.now(), + name: '', + preferences: data, + }; + } + + public static normalizeProfile(profileLike: any): PreferencesProfile { + const data = {} as PreferencesProfile['preferences']; + for (const key in PREF_DEF) { + const records = profileLike.preferences[key]; + if (records == null || records.length === 0) { + data[key] = [[makeScope({}), PREF_DEF[key].default, {}]]; + continue; + } else { + data[key] = records; + + // alpha段階ではmetaが無かったのでマイグレート + // TODO: そのうち消す + for (const record of data[key] as any[][]) { + if (record.length === 2) { + record.push({}); + } + } + } + } + + return { + ...profileLike, + preferences: data, + }; + } + + public save() { + this.profile.modifiedAt = Date.now(); + this.profile.version = version; + this.storageProvider.save({ profile: this.profile }); + } + + public getMatchedRecordOf<K extends keyof PREF>(key: K): PrefRecord<K> { + const records = this.profile.preferences[key]; + + if ($i == null) return records.find(([scope, v]) => parseScope(scope).account == null)!; + + const accountOverrideRecord = records.find(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account === $i!.id); + if (accountOverrideRecord) return accountOverrideRecord; + + const serverOverrideRecord = records.find(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account == null); + if (serverOverrideRecord) return serverOverrideRecord; + + const record = records.find(([scope, v]) => parseScope(scope).account == null); + return record!; + } + + public isAccountOverrided<K extends keyof PREF>(key: K): boolean { + if ($i == null) return false; + return this.profile.preferences[key].some(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account === $i!.id) ?? false; + } + + public setAccountOverride<K extends keyof PREF>(key: K) { + if ($i == null) return; + if (this.isAccountDependentKey(key)) throw new Error('already account-dependent'); + if (this.isAccountOverrided(key)) return; + + const records = this.profile.preferences[key]; + records.push([makeScope({ + server: host, + account: $i!.id, + }), this.s[key], {}]); + + this.save(); + } + + public clearAccountOverride<K extends keyof PREF>(key: K) { + if ($i == null) return; + if (this.isAccountDependentKey(key)) throw new Error('cannot clear override for this account-dependent property'); + + const records = this.profile.preferences[key]; + + const index = records.findIndex(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account === $i!.id); + if (index === -1) return; + + records.splice(index, 1); + + this.rewriteRawState(key, this.getMatchedRecordOf(key)[1]); + + this.save(); + } + + public isSyncEnabled<K extends keyof PREF>(key: K): boolean { + return this.getMatchedRecordOf(key)[2].sync ?? false; + } + + public async enableSync<K extends keyof PREF>(key: K): Promise<{ enabled: boolean; } | null> { + if (this.isSyncEnabled(key)) return Promise.resolve(null); + + const record = this.getMatchedRecordOf(key); + + const existing = await this.storageProvider.cloudGet({ key, scope: record[0] }); + if (existing != null && !deepEqual(existing.value, record[1])) { + const { canceled, result } = await os.select({ + title: i18n.ts.preferenceSyncConflictTitle, + text: i18n.ts.preferenceSyncConflictText, + items: [{ + text: i18n.ts.preferenceSyncConflictChoiceServer, + value: 'remote', + }, { + text: i18n.ts.preferenceSyncConflictChoiceDevice, + value: 'local', + }, { + text: i18n.ts.preferenceSyncConflictChoiceCancel, + value: null, + }], + default: 'remote', + }); + if (canceled || result == null) return { enabled: false }; + + if (result === 'remote') { + this.commit(key, existing.value); + } else if (result === 'local') { + // nop + } + } + + record[2].sync = true; + this.save(); + + // awaitの必要性は無い + this.storageProvider.cloudSet({ key, scope: record[0], value: this.s[key] }); + + return { enabled: true }; + } + + public disableSync<K extends keyof PREF>(key: K) { + if (!this.isSyncEnabled(key)) return; + + const record = this.getMatchedRecordOf(key); + delete record[2].sync; + this.save(); + } + + public renameProfile(name: string) { + this.profile.name = name; + this.save(); + } + + public rewriteProfile(profile: PreferencesProfile) { + this.profile = profile; + const states = this.genStates(); + for (const _key in states) { + const key = _key as keyof PREF; + this.rewriteRawState(key, states[key]); + } + + this.fetchCloudValues(); + } + + public getPerPrefMenu<K extends keyof PREF>(key: K): MenuItem[] { + const overrideByAccount = ref(this.isAccountOverrided(key)); + watch(overrideByAccount, () => { + if (overrideByAccount.value) { + this.setAccountOverride(key); + } else { + this.clearAccountOverride(key); + } + }); + + const sync = ref(this.isSyncEnabled(key)); + watch(sync, () => { + if (sync.value) { + this.enableSync(key).then((res) => { + if (res == null) return; + if (!res.enabled) sync.value = false; + }); + } else { + this.disableSync(key); + } + }); + + return [{ + icon: 'ti ti-copy', + text: i18n.ts.copyPreferenceId, + action: () => { + copyToClipboard(key); + }, + }, { + icon: 'ti ti-refresh', + text: i18n.ts.resetToDefaultValue, + danger: true, + action: () => { + this.commit(key, PREF_DEF[key].default); + }, + }, { + type: 'divider', + }, { + type: 'switch', + icon: 'ti ti-user-cog', + text: i18n.ts.overrideByAccount, + ref: overrideByAccount, + }, { + type: 'switch', + icon: 'ti ti-cloud-cog', + text: i18n.ts.syncBetweenDevices, + ref: sync, + }]; + } +} diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts new file mode 100644 index 0000000000..adba908c3c --- /dev/null +++ b/packages/frontend/src/preferences/utility.ts @@ -0,0 +1,226 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ref, watch } from 'vue'; +import type { PreferencesProfile } from './manager.js'; +import type { MenuItem } from '@/types/menu.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { i18n } from '@/i18n.js'; +import { miLocalStorage } from '@/local-storage.js'; +import { prefer } from '@/preferences.js'; +import * as os from '@/os.js'; +import { store } from '@/store.js'; +import { $i } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { unisonReload } from '@/utility/unison-reload.js'; + +function canAutoBackup() { + return prefer.profile.name != null && prefer.profile.name.trim() !== ''; +} + +export function getPreferencesProfileMenu(): MenuItem[] { + const autoBackupEnabled = ref(store.s.enablePreferencesAutoCloudBackup); + + watch(autoBackupEnabled, () => { + if (autoBackupEnabled.value) { + if (!canAutoBackup()) { + autoBackupEnabled.value = false; + os.alert({ + type: 'warning', + title: i18n.ts._preferencesBackup.youNeedToNameYourProfileToEnableAutoBackup, + }); + return; + } + + store.set('enablePreferencesAutoCloudBackup', true); + } else { + store.set('enablePreferencesAutoCloudBackup', false); + } + }); + + const menu: MenuItem[] = [{ + type: 'label', + text: prefer.profile.name || `(${i18n.ts.noName})`, + }, { + text: i18n.ts.rename, + icon: 'ti ti-pencil', + action: () => { + renameProfile(); + }, + }, { + type: 'switch', + icon: 'ti ti-cloud-up', + text: i18n.ts._preferencesBackup.autoBackup, + ref: autoBackupEnabled, + }, { + text: i18n.ts.export, + icon: 'ti ti-download', + action: () => { + exportCurrentProfile(); + }, + }, { + type: 'divider', + }, { + text: i18n.ts._preferencesBackup.restoreFromBackup, + icon: 'ti ti-cloud-down', + action: () => { + restoreFromCloudBackup(); + }, + }, { + text: i18n.ts.import, + icon: 'ti ti-upload', + action: () => { + importProfile(); + }, + }]; + + if (prefer.s.devMode) { + menu.push({ + type: 'divider', + }, { + text: 'Copy profile as text', + icon: 'ti ti-clipboard', + action: () => { + copyToClipboard(JSON.stringify(prefer.profile, null, '\t')); + }, + }); + } + + return menu; +} + +async function renameProfile() { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts._preferencesProfile.profileName, + text: i18n.ts._preferencesProfile.profileNameDescription + '\n' + i18n.ts._preferencesProfile.profileNameDescription2, + placeholder: prefer.profile.name || null, + default: prefer.profile.name || null, + }); + if (canceled || name == null || name.trim() === '') return; + + prefer.renameProfile(name); +} + +function exportCurrentProfile() { + const p = prefer.profile; + const txtBlob = new Blob([JSON.stringify(p)], { type: 'text/plain' }); + const dummya = window.document.createElement('a'); + dummya.href = URL.createObjectURL(txtBlob); + dummya.download = `${p.name || p.id}.misskeypreferences`; + dummya.click(); +} + +function importProfile() { + const input = window.document.createElement('input'); + input.type = 'file'; + input.accept = '.misskeypreferences'; + input.onchange = async () => { + if (input.files == null || input.files.length === 0) return; + + const file = input.files[0]; + const txt = await file.text(); + const profile = JSON.parse(txt) as PreferencesProfile; + + miLocalStorage.setItem('preferences', JSON.stringify(profile)); + miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true'); + shouldSuggestRestoreBackup.value = false; + unisonReload(); + }; + + input.click(); +} + +export async function cloudBackup() { + if ($i == null) return; + if (!canAutoBackup()) { + throw new Error('Profile name is not set'); + } + + await misskeyApi('i/registry/set', { + scope: ['client', 'preferences', 'backups'], + key: prefer.profile.name, + value: prefer.profile, + }); +} + +export async function restoreFromCloudBackup() { + if ($i == null) return; + + // TODO: 更新日時でソートして取得したい + const keys = await misskeyApi('i/registry/keys', { + scope: ['client', 'preferences', 'backups'], + }); + + if (_DEV_) console.log(keys); + + if (keys.length === 0) { + os.alert({ + type: 'warning', + title: i18n.ts._preferencesBackup.noBackupsFoundTitle, + text: i18n.ts._preferencesBackup.noBackupsFoundDescription, + }); + return; + } + + const select = await os.select({ + title: i18n.ts._preferencesBackup.selectBackupToRestore, + items: keys.map(k => ({ + text: k, + value: k, + })), + }); + if (select.canceled) return; + if (select.result == null) return; + + const profile = await misskeyApi('i/registry/get', { + scope: ['client', 'preferences', 'backups'], + key: select.result, + }); + + if (_DEV_) console.log(profile); + + miLocalStorage.setItem('preferences', JSON.stringify(profile)); + miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true'); + store.set('enablePreferencesAutoCloudBackup', true); + shouldSuggestRestoreBackup.value = false; + unisonReload(); +} + +export async function enableAutoBackup() { + if (!canAutoBackup()) { + await renameProfile(); + } + + if (!canAutoBackup()) { + return; + } + + store.set('enablePreferencesAutoCloudBackup', true); +} + +export const shouldSuggestRestoreBackup = ref(false); + +if ($i != null) { + if (new Date($i.createdAt).getTime() > (Date.now() - 1000 * 60 * 30)) { // アカウント作成直後は意味ないので除外 + miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true'); + } else { + if (miLocalStorage.getItem('hidePreferencesRestoreSuggestion') !== 'true') { + misskeyApi('i/registry/keys', { + scope: ['client', 'preferences', 'backups'], + }).then(keys => { + if (keys.length === 0) { + miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true'); + } else { + shouldSuggestRestoreBackup.value = true; + } + }); + } + } +} + +export function hideRestoreBackupSuggestion() { + miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true'); + shouldSuggestRestoreBackup.value = false; +} diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router.definition.ts index 2d50a27dbf..725b844ad2 100644 --- a/packages/frontend/src/router/definition.ts +++ b/packages/frontend/src/router.definition.ts @@ -3,10 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { AsyncComponentLoader, defineAsyncComponent } from 'vue'; -import type { IRouter, RouteDef } from '@/nirax.js'; -import { Router } from '@/nirax.js'; -import { $i, iAmModerator } from '@/account.js'; +import { defineAsyncComponent } from 'vue'; +import type { AsyncComponentLoader } from 'vue'; +import type { RouteDef } from '@/lib/nirax.js'; +import { $i, iAmModerator } from '@/i.js'; import MkLoading from '@/pages/_loading_.vue'; import MkError from '@/pages/_error_.vue'; @@ -16,7 +16,7 @@ export const page = (loader: AsyncComponentLoader) => defineAsyncComponent({ errorComponent: MkError, }); -const routes: RouteDef[] = [{ +export const ROUTE_DEF = [{ path: '/@:username/pages/:pageName(*)', component: page(() => import('@/pages/page.vue')), }, { @@ -41,6 +41,22 @@ const routes: RouteDef[] = [{ path: '/clips/:clipId', component: page(() => import('@/pages/clip.vue')), }, { + path: '/chat', + component: page(() => import('@/pages/chat/home.vue')), + loginRequired: true, +}, { + path: '/chat/user/:userId', + component: page(() => import('@/pages/chat/room.vue')), + loginRequired: true, +}, { + path: '/chat/room/:roomId', + component: page(() => import('@/pages/chat/room.vue')), + loginRequired: true, +}, { + path: '/chat/messages/:messageId', + component: page(() => import('@/pages/chat/message.vue')), + loginRequired: true, +}, { path: '/instance-info/:host', component: page(() => import('@/pages/instance-info.vue')), }, { @@ -57,17 +73,13 @@ const routes: RouteDef[] = [{ name: 'avatarDecoration', component: page(() => import('@/pages/settings/avatar-decoration.vue')), }, { - path: '/roles', - name: 'roles', - component: page(() => import('@/pages/settings/roles.vue')), - }, { path: '/privacy', name: 'privacy', component: page(() => import('@/pages/settings/privacy.vue')), }, { - path: '/emoji-picker', - name: 'emojiPicker', - component: page(() => import('@/pages/settings/emoji-picker.vue')), + path: '/emoji-palette', + name: 'emoji-palette', + component: page(() => import('@/pages/settings/emoji-palette.vue')), }, { path: '/drive', name: 'drive', @@ -89,9 +101,9 @@ const routes: RouteDef[] = [{ name: 'security', component: page(() => import('@/pages/settings/security.vue')), }, { - path: '/general', - name: 'general', - component: page(() => import('@/pages/settings/general.vue')), + path: '/preferences', + name: 'preferences', + component: page(() => import('@/pages/settings/preferences.vue')), }, { path: '/theme/install', name: 'theme', @@ -117,6 +129,10 @@ const routes: RouteDef[] = [{ name: 'sounds', component: page(() => import('@/pages/settings/sounds.vue')), }, { + path: '/accessibility', + name: 'accessibility', + component: page(() => import('@/pages/settings/accessibility.vue')), + }, { path: '/plugin/install', name: 'plugin', component: page(() => import('@/pages/settings/plugin.install.vue')), @@ -125,48 +141,36 @@ const routes: RouteDef[] = [{ name: 'plugin', component: page(() => import('@/pages/settings/plugin.vue')), }, { - path: '/import-export', - name: 'import-export', - component: page(() => import('@/pages/settings/import-export.vue')), + path: '/account-data', + name: 'account-data', + component: page(() => import('@/pages/settings/account-data.vue')), }, { path: '/mute-block', name: 'mute-block', component: page(() => import('@/pages/settings/mute-block.vue')), }, { - path: '/api', - name: 'api', - component: page(() => import('@/pages/settings/api.vue')), + path: '/connect', + name: 'connect', + component: page(() => import('@/pages/settings/connect.vue')), }, { path: '/apps', - name: 'api', + name: 'connect', component: page(() => import('@/pages/settings/apps.vue')), }, { path: '/webhook/edit/:webhookId', - name: 'webhook', + name: 'connect', component: page(() => import('@/pages/settings/webhook.edit.vue')), }, { path: '/webhook/new', - name: 'webhook', + name: 'connect', component: page(() => import('@/pages/settings/webhook.new.vue')), }, { - path: '/webhook', - name: 'webhook', - component: page(() => import('@/pages/settings/webhook.vue')), - }, { path: '/deck', name: 'deck', component: page(() => import('@/pages/settings/deck.vue')), }, { - path: '/preferences-backups', - name: 'preferences-backups', - component: page(() => import('@/pages/settings/preferences-backups.vue')), - }, { - path: '/migration', - name: 'migration', - component: page(() => import('@/pages/settings/migration.vue')), - }, { path: '/custom-css', - name: 'general', + name: 'preferences', component: page(() => import('@/pages/settings/custom-css.vue')), }, { path: '/accounts', @@ -590,7 +594,6 @@ const routes: RouteDef[] = [{ name: 'index', path: '/', component: $i ? page(() => import('@/pages/timeline.vue')) : page(() => import('@/pages/welcome.vue')), - globalCacheKey: 'index', }, { // テスト用リダイレクト設定。ログイン中ユーザのプロフィールにリダイレクトする path: '/redirect-test', @@ -599,8 +602,4 @@ const routes: RouteDef[] = [{ }, { path: '/:(*)', component: page(() => import('@/pages/not-found.vue')), -}]; - -export function createMainRouter(path: string): IRouter { - return new Router(routes, path, !!$i, page(() => import('@/pages/not-found.vue'))); -} +}] satisfies RouteDef[]; diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts new file mode 100644 index 0000000000..d702da80fa --- /dev/null +++ b/packages/frontend/src/router.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { inject } from 'vue'; +import { page } from '@/router.definition.js'; +import { $i } from '@/i.js'; +import { Nirax } from '@/lib/nirax.js'; +import { ROUTE_DEF } from '@/router.definition.js'; +import { analytics } from '@/analytics.js'; +import { DI } from '@/di.js'; + +export type Router = Nirax<typeof ROUTE_DEF>; + +export function createRouter(fullPath: string): Router { + return new Nirax(ROUTE_DEF, fullPath, !!$i, page(() => import('@/pages/not-found.vue'))); +} + +export const mainRouter = createRouter(window.location.pathname + window.location.search + window.location.hash); + +window.addEventListener('popstate', (event) => { + mainRouter.replace(window.location.pathname + window.location.search + window.location.hash); +}); + +mainRouter.addListener('push', ctx => { + window.history.pushState({ }, '', ctx.fullPath); +}); + +mainRouter.addListener('replace', ctx => { + window.history.replaceState({ }, '', ctx.fullPath); +}); + +mainRouter.addListener('change', ctx => { + if (_DEV_) console.log('mainRouter: change', ctx.fullPath); + analytics.page({ + path: ctx.fullPath, + title: ctx.fullPath, + }); +}); + +mainRouter.init(); + +export function useRouter(): Router { + return inject(DI.router) ?? mainRouter; +} diff --git a/packages/frontend/src/router/main.ts b/packages/frontend/src/router/main.ts deleted file mode 100644 index 8307df1150..0000000000 --- a/packages/frontend/src/router/main.ts +++ /dev/null @@ -1,198 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { EventEmitter } from 'eventemitter3'; -import { IRouter, Resolved, RouteDef, RouterEvent, RouterFlag } from '@/nirax.js'; - -import type { App, ShallowRef } from 'vue'; - -/** - * {@link Router}による画面遷移を可能とするために{@link mainRouter}をセットアップする。 - * また、{@link Router}のインスタンスを作成するためのファクトリも{@link provide}経由で公開する(`routerFactory`というキーで取得可能) - */ -export function setupRouter(app: App, routerFactory: ((path: string) => IRouter)): void { - app.provide('routerFactory', routerFactory); - - const mainRouter = routerFactory(location.pathname + location.search + location.hash); - - window.addEventListener('popstate', (event) => { - mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key); - }); - - mainRouter.addListener('push', ctx => { - window.history.pushState({ key: ctx.key }, '', ctx.path); - }); - - mainRouter.addListener('same', () => { - window.scroll({ top: 0, behavior: 'smooth' }); - }); - - mainRouter.addListener('replace', ctx => { - window.history.replaceState({ key: ctx.key }, '', ctx.path); - }); - - mainRouter.init(); - - setMainRouter(mainRouter); -} - -function getMainRouter(): IRouter { - const router = mainRouterHolder; - if (!router) { - throw new Error('mainRouter is not found.'); - } - - return router; -} - -/** - * メインルータを設定する。一度設定すると、それ以降は変更できない。 - * {@link setupRouter}から呼び出されることのみを想定している。 - */ -export function setMainRouter(router: IRouter) { - if (mainRouterHolder) { - throw new Error('mainRouter is already exists.'); - } - - mainRouterHolder = router; -} - -/** - * {@link mainRouter}用のプロキシ実装。 - * {@link mainRouter}は起動シーケンスの一部にて初期化されるため、僅かにundefinedになる期間がある。 - * その僅かな期間のためだけに型をundefined込みにしたくないのでこのクラスを緩衝材として使用する。 - */ -class MainRouterProxy implements IRouter { - private supplier: () => IRouter; - - constructor(supplier: () => IRouter) { - this.supplier = supplier; - } - - get current(): Resolved { - return this.supplier().current; - } - - get currentRef(): ShallowRef<Resolved> { - return this.supplier().currentRef; - } - - get currentRoute(): ShallowRef<RouteDef> { - return this.supplier().currentRoute; - } - - get navHook(): ((path: string, flag?: RouterFlag) => boolean) | null { - return this.supplier().navHook; - } - - set navHook(value) { - this.supplier().navHook = value; - } - - getCurrentKey(): string { - return this.supplier().getCurrentKey(); - } - - getCurrentPath(): string { - return this.supplier().getCurrentPath(); - } - - push(path: string, flag?: RouterFlag): void { - this.supplier().push(path, flag); - } - - replace(path: string, key?: string | null): void { - this.supplier().replace(path, key); - } - - resolve(path: string): Resolved | null { - return this.supplier().resolve(path); - } - - init(): void { - this.supplier().init(); - } - - eventNames(): Array<EventEmitter.EventNames<RouterEvent>> { - return this.supplier().eventNames(); - } - - listeners<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - ): Array<EventEmitter.EventListener<RouterEvent, T>> { - return this.supplier().listeners(event); - } - - listenerCount( - event: EventEmitter.EventNames<RouterEvent>, - ): number { - return this.supplier().listenerCount(event); - } - - emit<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - ...args: EventEmitter.EventArgs<RouterEvent, T> - ): boolean { - return this.supplier().emit(event, ...args); - } - - on<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn: EventEmitter.EventListener<RouterEvent, T>, - context?: any, - ): this { - this.supplier().on(event, fn, context); - return this; - } - - addListener<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn: EventEmitter.EventListener<RouterEvent, T>, - context?: any, - ): this { - this.supplier().addListener(event, fn, context); - return this; - } - - once<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn: EventEmitter.EventListener<RouterEvent, T>, - context?: any, - ): this { - this.supplier().once(event, fn, context); - return this; - } - - removeListener<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn?: EventEmitter.EventListener<RouterEvent, T>, - context?: any, - once?: boolean, - ): this { - this.supplier().removeListener(event, fn, context, once); - return this; - } - - off<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn?: EventEmitter.EventListener<RouterEvent, T>, - context?: any, - once?: boolean, - ): this { - this.supplier().off(event, fn, context, once); - return this; - } - - removeAllListeners( - event?: EventEmitter.EventNames<RouterEvent>, - ): this { - this.supplier().removeAllListeners(event); - return this; - } -} - -let mainRouterHolder: IRouter | null = null; - -export const mainRouter: IRouter = new MainRouterProxy(getMainRouter); diff --git a/packages/frontend/src/router/supplier.ts b/packages/frontend/src/router/supplier.ts deleted file mode 100644 index 7da236f4e7..0000000000 --- a/packages/frontend/src/router/supplier.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { inject } from 'vue'; -import { IRouter, Router } from '@/nirax.js'; -import { mainRouter } from '@/router/main.js'; - -/** - * メインの{@link Router}を取得する。 - * あらかじめ{@link setupRouter}を実行しておく必要がある({@link provide}により{@link IRouter}のインスタンスを注入可能であるならばこの限りではない) - */ -export function useRouter(): IRouter { - return inject<Router | null>('router', null) ?? mainRouter; -} - -/** - * 任意の{@link Router}を取得するためのファクトリを取得する。 - * あらかじめ{@link setupRouter}を実行しておく必要がある。 - */ -export function useRouterFactory(): (path: string) => IRouter { - const factory = inject<(path: string) => IRouter>('routerFactory'); - if (!factory) { - console.error('routerFactory is not defined.'); - throw new Error('routerFactory is not defined.'); - } - - return factory; -} diff --git a/packages/frontend/src/scripts/gen-search-query.ts b/packages/frontend/src/scripts/gen-search-query.ts deleted file mode 100644 index a85ee01e26..0000000000 --- a/packages/frontend/src/scripts/gen-search-query.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as Misskey from 'misskey-js'; -import { host as localHost } from '@@/js/config.js'; - -export async function genSearchQuery(v: any, q: string) { - let host: string; - let userId: string; - if (q.split(' ').some(x => x.startsWith('@'))) { - for (const at of q.split(' ').filter(x => x.startsWith('@')).map(x => x.substring(1))) { - if (at.includes('.')) { - if (at === localHost || at === '.') { - host = null; - } else { - host = at; - } - } else { - const user = await v.api('users/show', Misskey.acct.parse(at)).catch(x => null); - if (user) { - userId = user.id; - } else { - // todo: show error - } - } - } - } - return { - query: q.split(' ').filter(x => !x.startsWith('/') && !x.startsWith('@')).join(' '), - host: host, - userId: userId, - }; -} diff --git a/packages/frontend/src/scripts/install-plugin.ts b/packages/frontend/src/scripts/install-plugin.ts deleted file mode 100644 index 72ff8bd5ff..0000000000 --- a/packages/frontend/src/scripts/install-plugin.ts +++ /dev/null @@ -1,135 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineAsyncComponent } from 'vue'; -import { compareVersions } from 'compare-versions'; -import { v4 as uuid } from 'uuid'; -import { Interpreter, Parser, utils } from '@syuilo/aiscript'; -import type { Plugin } from '@/store.js'; -import { ColdDeviceStorage } from '@/store.js'; -import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { i18n } from '@/i18n.js'; - -export type AiScriptPluginMeta = { - name: string; - version: string; - author: string; - description?: string; - permissions?: string[]; - config?: Record<string, any>; -}; - -const parser = new Parser(); - -export function savePlugin({ id, meta, src, token }: { - id: string; - meta: AiScriptPluginMeta; - src: string; - token: string; -}) { - ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({ - ...meta, - id, - active: true, - configData: {}, - token: token, - src: src, - } as Plugin)); -} - -export function isSupportedAiScriptVersion(version: string): boolean { - try { - return (compareVersions(version, '0.12.0') >= 0); - } catch (err) { - return false; - } -} - -export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta> { - if (!code) { - throw new Error('code is required'); - } - - const lv = utils.getLangVersion(code); - if (lv == null) { - throw new Error('No language version annotation found'); - } else if (!isSupportedAiScriptVersion(lv)) { - throw new Error(`Aiscript version '${lv}' is not supported`); - } - - let ast; - try { - ast = parser.parse(code); - } catch (err) { - if (err instanceof Error) { - throw new Error(`Aiscript syntax error\n${(err as Error).message}`); - } else { - throw new Error('Aiscript syntax error'); - } - } - - const meta = Interpreter.collectMetadata(ast); - if (meta == null) { - throw new Error('Meta block not found'); - } - - const metadata = meta.get(null); - if (metadata == null) { - throw new Error('Metadata not found'); - } - - const { name, version, author, description, permissions, config } = metadata; - if (name == null || version == null || author == null) { - throw new Error('Required property not found'); - } - - return { - name, - version, - author, - description, - permissions, - config, - }; -} - -export async function installPlugin(code: string, meta?: AiScriptPluginMeta) { - if (!code) return; - - let realMeta: AiScriptPluginMeta; - if (!meta) { - realMeta = await parsePluginMeta(code); - } else { - realMeta = meta; - } - - const token = realMeta.permissions == null || realMeta.permissions.length === 0 ? null : await new Promise((res, rej) => { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), { - title: i18n.ts.tokenRequested, - information: i18n.ts.pluginTokenRequestedDescription, - initialName: realMeta.name, - initialPermissions: realMeta.permissions, - }, { - done: async result => { - const { name, permissions } = result; - const { token } = await misskeyApi('miauth/gen-token', { - session: null, - name: name, - permission: permissions, - }); - res(token); - }, - closed: () => dispose(), - }); - }); - - savePlugin({ - id: uuid(), - meta: realMeta, - token, - src: code, - }); -} diff --git a/packages/frontend/src/scripts/install-theme.ts b/packages/frontend/src/scripts/install-theme.ts deleted file mode 100644 index 866f1225bf..0000000000 --- a/packages/frontend/src/scripts/install-theme.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import JSON5 from 'json5'; -import { addTheme, getThemes } from '@/theme-store.js'; -import { Theme, applyTheme, validateTheme } from '@/scripts/theme.js'; - -export function parseThemeCode(code: string): Theme { - let theme; - - try { - theme = JSON5.parse(code); - } catch (err) { - throw new Error('Failed to parse theme json'); - } - if (!validateTheme(theme)) { - throw new Error('This theme is invaild'); - } - if (getThemes().some(t => t.id === theme.id)) { - throw new Error('This theme is already installed'); - } - - return theme; -} - -export function previewTheme(code: string): void { - const theme = parseThemeCode(code); - if (theme) applyTheme(theme, false); -} - -export async function installTheme(code: string): Promise<void> { - const theme = parseThemeCode(code); - if (!theme) return; - await addTheme(theme); -} diff --git a/packages/frontend/src/scripts/lookup.ts b/packages/frontend/src/scripts/lookup.ts deleted file mode 100644 index 54ec2ce39b..0000000000 --- a/packages/frontend/src/scripts/lookup.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { i18n } from '@/i18n.js'; -import { Router } from '@/nirax.js'; -import { mainRouter } from '@/router/main.js'; - -export async function lookup(router?: Router) { - const _router = router ?? mainRouter; - - const { canceled, result: temp } = await os.inputText({ - title: i18n.ts.lookup, - }); - const query = temp ? temp.trim() : ''; - if (canceled || query.length <= 1) return; - - if (query.startsWith('@') && !query.includes(' ')) { - _router.push(`/${query}`); - return; - } - - if (query.startsWith('#')) { - _router.push(`/tags/${encodeURIComponent(query.substring(1))}`); - return; - } - - if (query.startsWith('http://') || query.startsWith('https://')) { - const promise = misskeyApi('ap/show', { - uri: query, - }); - - os.promiseDialog(promise, null, (err) => { - let title = i18n.ts.somethingHappened; - let text = err.message + '\n' + err.id; - - switch (err.id) { - case '974b799e-1a29-4889-b706-18d4dd93e266': - title = i18n.ts._remoteLookupErrors._federationNotAllowed.title; - text = i18n.ts._remoteLookupErrors._federationNotAllowed.description; - break; - case '1a5eab56-e47b-48c2-8d5e-217b897d70db': - title = i18n.ts._remoteLookupErrors._uriInvalid.title; - text = i18n.ts._remoteLookupErrors._uriInvalid.description; - break; - case '81b539cf-4f57-4b29-bc98-032c33c0792e': - title = i18n.ts._remoteLookupErrors._requestFailed.title; - text = i18n.ts._remoteLookupErrors._requestFailed.description; - break; - case '70193c39-54f3-4813-82f0-70a680f7495b': - title = i18n.ts._remoteLookupErrors._responseInvalid.title; - text = i18n.ts._remoteLookupErrors._responseInvalid.description; - break; - case 'a2c9c61a-cb72-43ab-a964-3ca5fddb410a': - title = i18n.ts._remoteLookupErrors._responseInvalid.title; - text = i18n.ts._remoteLookupErrors._responseInvalidIdHostNotMatch.description; - break; - case 'dc94d745-1262-4e63-a17d-fecaa57efc82': - title = i18n.ts._remoteLookupErrors._noSuchObject.title; - text = i18n.ts._remoteLookupErrors._noSuchObject.description; - break; - } - - os.alert({ - type: 'error', - title, - text, - }); - }, i18n.ts.fetchingAsApObject); - - const res = await promise; - - if (res.type === 'User') { - _router.push(`/@${res.object.username}@${res.object.host}`); - } else if (res.type === 'Note') { - _router.push(`/notes/${res.object.id}`); - } - - return; - } -} diff --git a/packages/frontend/src/server-context.ts b/packages/frontend/src/server-context.ts index e79d3fa314..744bfa4b7b 100644 --- a/packages/frontend/src/server-context.ts +++ b/packages/frontend/src/server-context.ts @@ -5,7 +5,7 @@ import * as Misskey from 'misskey-js'; -const providedContextEl = document.getElementById('misskey_clientCtx'); +const providedContextEl = window.document.getElementById('misskey_clientCtx'); export type ServerContext = { clip?: Misskey.entities.Clip; diff --git a/packages/frontend/src/signout.ts b/packages/frontend/src/signout.ts new file mode 100644 index 0000000000..e7d7cdfd22 --- /dev/null +++ b/packages/frontend/src/signout.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { apiUrl } from '@@/js/config.js'; +import { defaultMemoryStorage } from '@/memory-storage'; +import { waiting } from '@/os.js'; +import { unisonReload, reloadChannel } from '@/utility/unison-reload.js'; +import { $i } from '@/i.js'; + +export async function signout() { + if (!$i) return; + + // TODO: preferの自動バックアップがオンの場合、いろいろ消す前に強制バックアップ + + waiting(); + + localStorage.clear(); + defaultMemoryStorage.clear(); + + const idbPromises = ['MisskeyClient', 'keyval-store'].map((name, i, arr) => new Promise((res, rej) => { + indexedDB.deleteDatabase(name); + })); + + await Promise.all(idbPromises); + + //#region Remove service worker registration + try { + if (navigator.serviceWorker.controller) { + const registration = await navigator.serviceWorker.ready; + const push = await registration.pushManager.getSubscription(); + if (push) { + await window.fetch(`${apiUrl}/sw/unregister`, { + method: 'POST', + body: JSON.stringify({ + i: $i.token, + endpoint: push.endpoint, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + } + } + + await navigator.serviceWorker.getRegistrations() + .then(registrations => { + return Promise.all(registrations.map(registration => registration.unregister())); + }); + } catch (err) {} + //#endregion + + unisonReload('/'); +} diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 69fcef32c2..0ba1a17969 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -5,70 +5,21 @@ import { markRaw, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { searchEngineMap } from '@/utility/search-engine-map.js'; +import lightTheme from '@@/themes/l-light.json5'; +import darkTheme from '@@/themes/d-green-lime.json5'; import { hemisphere } from '@@/js/intl-const.js'; -import lightTheme from '@@/themes/l-cherry.json5'; -import darkTheme from '@@/themes/d-ice.json5'; -import { searchEngineMap } from './scripts/search-engine-map.js'; -import type { SoundType } from '@/scripts/sound.js'; -import type { Ast } from '@syuilo/aiscript'; -import { DEFAULT_DEVICE_KIND, type DeviceKind } from '@/scripts/device-kind.js'; +import type { DeviceKind } from '@/utility/device-kind.js'; +import type { Plugin } from '@/plugin.js'; import { miLocalStorage } from '@/local-storage.js'; -import { defaultFollowingFeedState } from '@/scripts/following-feed-utils.js'; -import { Storage } from '@/pizzax.js'; +import { Pizzax } from '@/lib/pizzax.js'; +import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js'; +import { defaultFollowingFeedState } from '@/utility/following-feed-utils.js'; -interface PostFormAction { - title: string, - handler: <T>(form: T, update: (key: unknown, value: unknown) => void) => void; -} - -interface UserAction { - title: string, - handler: (user: Misskey.entities.UserDetailed) => void; -} - -interface NoteAction { - title: string, - handler: (note: Misskey.entities.Note) => void; -} - -interface NoteViewInterruptor { - handler: (note: Misskey.entities.Note) => unknown; -} - -interface NotePostInterruptor { - handler: (note: FIXME) => unknown; -} - -interface PageViewInterruptor { - handler: (page: Misskey.entities.Page) => unknown; -} - -/** サウンド設定 */ -export type SoundStore = { - type: Exclude<SoundType, '_driveFile_'>; - volume: number; -} | { - type: '_driveFile_'; - - /** ドライブのファイルID */ - fileId: string; - - /** ファイルURL(こちらが優先される) */ - fileUrl: string; - - volume: number; -} - -export const postFormActions: PostFormAction[] = []; -export const userActions: UserAction[] = []; -export const noteActions: NoteAction[] = []; -export const noteViewInterruptors: NoteViewInterruptor[] = []; -export const notePostInterruptors: NotePostInterruptor[] = []; -export const pageViewInterruptors: PageViewInterruptor[] = []; - -// TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう) -// あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない -export const defaultStore = markRaw(new Storage('base', { +/** + * 「状態」を管理するストア(not「設定」) + */ +export const store = markRaw(new Pizzax('base', { accountSetupWizard: { where: 'account', default: 0, @@ -86,18 +37,103 @@ export const defaultStore = markRaw(new Storage('base', { where: 'account', default: false, }, - keepCw: { + memo: { where: 'account', - default: true, + default: null, }, - showFullAcct: { + reactionAcceptance: { + where: 'account', + default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null, + }, + mutedAds: { where: 'account', + default: [] as string[], + }, + visibility: { + where: 'deviceAccount', + default: 'public' as (typeof Misskey.noteVisibilities)[number], + }, + localOnly: { + where: 'deviceAccount', default: false, }, - collapseRenotes: { + showPreview: { + where: 'device', + default: false, + }, + tl: { + where: 'deviceAccount', + default: { + src: 'home' as 'home' | 'local' | 'social' | 'global' | 'bubble' | `list:${string}`, + userList: null as Misskey.entities.UserList | null, + filter: { + withReplies: true, + withRenotes: true, + withSensitive: true, + onlyFiles: false, + withBots: true, + }, + }, + }, + darkMode: { + where: 'device', + default: false, + }, + recentlyUsedEmojis: { + where: 'device', + default: [] as string[], + }, + recentlyUsedUsers: { + where: 'device', + default: [] as string[], + }, + menuDisplay: { + where: 'device', + default: 'sideFull' as 'sideFull' | 'sideIcon' | 'top', + }, + postFormWithHashtags: { + where: 'device', + default: false, + }, + postFormHashtags: { + where: 'device', + default: '', + }, + additionalUnicodeEmojiIndexes: { + where: 'device', + default: {} as Record<string, Record<string, string[]>>, + }, + pluginTokens: { + where: 'deviceAccount', + default: {} as Record<string, string>, // plugin id, token + }, + accountTokens: { + where: 'device', + default: {} as Record<string, string>, // host/userId, token + }, + + enablePreferencesAutoCloudBackup: { + where: 'device', + default: false, + }, + showPreferencesAutoCloudBackupSuggestion: { + where: 'device', + default: true, + }, + + //#region TODO: そのうち消す (preferに移行済み) + defaultWithReplies: { where: 'account', default: false, }, + reactions: { + where: 'account', + default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], + }, + pinnedEmojis: { + where: 'account', + default: [], + }, collapseNotesRepliedTo: { where: 'account', default: false, @@ -114,8 +150,21 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, - rememberNoteVisibility: { + widgets: { where: 'account', + default: [] as { + name: string; + id: string; + place: string | null; + data: Record<string, any>; + }[], + }, + overridedDeviceKind: { + where: 'device', + default: null as DeviceKind | null, + }, + defaultSideView: { + where: 'device', default: false, }, defaultNoteVisibility: { @@ -126,42 +175,30 @@ export const defaultStore = markRaw(new Storage('base', { where: 'account', default: false, }, - uploadFolder: { + keepCw: { where: 'account', - default: null as string | null, + default: true, }, - pastedFileName: { + collapseRenotes: { where: 'account', - default: 'yyyy-MM-dd HH-mm-ss [{{number}}]', + default: true, }, - keepOriginalUploading: { + rememberNoteVisibility: { where: 'account', default: false, }, - memo: { - where: 'account', - default: null, - }, - reactions: { - where: 'account', - default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], - }, - pinnedEmojis: { + uploadFolder: { where: 'account', - default: [], + default: null as string | null, }, - reactionAcceptance: { + keepOriginalUploading: { where: 'account', - default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null, + default: false, }, like: { where: 'account', default: null as string | null, }, - mutedAds: { - where: 'account', - default: [] as string[], - }, autoloadConversation: { where: 'account', default: true, @@ -182,7 +219,6 @@ export const defaultStore = markRaw(new Storage('base', { where: 'account', default: true, }, - menu: { where: 'deviceAccount', default: [ @@ -198,18 +234,6 @@ export const defaultStore = markRaw(new Storage('base', { 'achievements', ], }, - visibility: { - where: 'deviceAccount', - default: 'public' as (typeof Misskey.noteVisibilities)[number], - }, - localOnly: { - where: 'deviceAccount', - default: false, - }, - showPreview: { - where: 'device', - default: false, - }, statusbars: { where: 'deviceAccount', default: [] as { @@ -221,29 +245,6 @@ export const defaultStore = markRaw(new Storage('base', { props: Record<string, any>; }[], }, - widgets: { - where: 'account', - default: [] as { - name: string; - id: string; - place: string | null; - data: Record<string, any>; - }[], - }, - tl: { - where: 'deviceAccount', - default: { - src: 'home' as 'home' | 'local' | 'social' | 'global' | 'bubble' | `list:${string}`, - userList: null as Misskey.entities.UserList | null, - filter: { - withReplies: true, - withRenotes: true, - withBots: true, - withSensitive: true, - onlyFiles: false, - }, - }, - }, pinnedUserLists: { where: 'deviceAccount', default: [] as Misskey.entities.UserList[], @@ -252,11 +253,6 @@ export const defaultStore = markRaw(new Storage('base', { where: 'account', default: defaultFollowingFeedState, }, - - overridedDeviceKind: { - where: 'device', - default: null as DeviceKind | null, - }, serverDisconnectedBehavior: { where: 'device', default: 'disabled' as 'quiet' | 'dialog' | 'disabled', @@ -361,10 +357,6 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, - darkMode: { - where: 'device', - default: false, - }, instanceTicker: { where: 'device', default: 'remote' as 'none' | 'remote' | 'always', @@ -385,22 +377,6 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: 'auto' as 'auto' | 'popup' | 'drawer', }, - recentlyUsedEmojis: { - where: 'device', - default: [] as string[], - }, - recentlyUsedUsers: { - where: 'device', - default: [] as string[], - }, - defaultSideView: { - where: 'device', - default: false, - }, - menuDisplay: { - where: 'device', - default: 'sideFull' as 'sideFull' | 'sideIcon' | 'top', - }, reportError: { where: 'device', default: false, @@ -413,18 +389,6 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: true, }, - postFormWithHashtags: { - where: 'device', - default: false, - }, - postFormHashtags: { - where: 'device', - default: '', - }, - themeInitial: { - where: 'device', - default: true, - }, numberOfPageCache: { where: 'device', default: 3, @@ -489,18 +453,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: true, }, - additionalUnicodeEmojiIndexes: { - where: 'device', - default: {} as Record<string, Record<string, string[]>>, - }, keepScreenOn: { where: 'device', default: false, }, - defaultWithReplies: { - where: 'account', - default: false, - }, disableStreamingTimeline: { where: 'device', default: false, @@ -522,17 +478,6 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, - dropAndFusion: { - where: 'device', - default: { - bgmVolume: 0.25, - sfxVolume: 1, - }, - }, - hemisphere: { - where: 'device', - default: hemisphere as 'N' | 'S', - }, enableHorizontalSwipe: { where: 'device', default: true, @@ -565,7 +510,14 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, - + confirmOnReact: { + where: 'device', + default: false, + }, + hemisphere: { + where: 'device', + default: hemisphere as 'N' | 'S', + }, sound_masterVolume: { where: 'device', default: 0.3, @@ -580,56 +532,49 @@ export const defaultStore = markRaw(new Storage('base', { }, sound_note: { where: 'device', - default: { type: 'syuilo/n-aec', volume: 0 } as SoundStore, + default: { type: 'syuilo/n-aec', volume: 1 }, }, sound_noteMy: { where: 'device', - default: { type: 'syuilo/n-cea-4va', volume: 1 } as SoundStore, + default: { type: 'syuilo/n-cea-4va', volume: 1 }, }, sound_notification: { where: 'device', - default: { type: 'syuilo/n-ea', volume: 1 } as SoundStore, + default: { type: 'syuilo/n-ea', volume: 1 }, }, sound_reaction: { where: 'device', - default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore, + default: { type: 'syuilo/bubble2', volume: 1 }, + }, + dropAndFusion: { + where: 'device', + default: { + bgmVolume: 0.25, + sfxVolume: 1, + }, }, + //#endregion })); // TODO: 他のタブと永続化されたstateを同期 const PREFIX = 'miux:' as const; -export type Plugin = { - id: string; - name: string; - active: boolean; - config?: Record<string, { default: any }>; - configData: Record<string, any>; - token: string; - src: string | null; - version: string; - ast: Ast.Node[]; - author?: string; - description?: string; - permissions?: string[]; -}; - interface Watcher { key: string; callback: (value: unknown) => void; } +// TODO: 消す(preferに移行済みのため) /** * 常にメモリにロードしておく必要がないような設定情報を保管するストレージ(非リアクティブ) */ - export class ColdDeviceStorage { public static default = { - lightTheme, - darkTheme, - syncDeviceDarkMode: true, - plugins: [] as Plugin[], + lightTheme, // TODO: 消す(preferに移行済みのため) + darkTheme, // TODO: 消す(preferに移行済みのため) + syncDeviceDarkMode: true, // TODO: 消す(preferに移行済みのため) + plugins: [] as Plugin[], // TODO: 消す(preferに移行済みのため) }; public static watchers: Watcher[] = []; diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts index e63dac951c..25544d9d88 100644 --- a/packages/frontend/src/stream.ts +++ b/packages/frontend/src/stream.ts @@ -5,10 +5,10 @@ import * as Misskey from 'misskey-js'; import { markRaw } from 'vue'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { wsOrigin } from '@@/js/config.js'; // TODO: No WebsocketモードでStreamMockが使えそう -//import { StreamMock } from '@/scripts/stream-mock.js'; +//import { StreamMock } from '@/utility/stream-mock.js'; // heart beat interval in ms const HEART_BEAT_INTERVAL = 1000 * 60; @@ -29,10 +29,10 @@ export function useStream(): Misskey.IStream { timeoutHeartBeat = window.setTimeout(heartbeat, HEART_BEAT_INTERVAL); // send heartbeat right now when last send time is over HEART_BEAT_INTERVAL - document.addEventListener('visibilitychange', () => { + window.document.addEventListener('visibilitychange', () => { if ( !stream - || document.visibilityState !== 'visible' + || window.document.visibilityState !== 'visible' || Date.now() - lastHeartbeatCall < HEART_BEAT_INTERVAL ) return; heartbeat(); @@ -42,7 +42,7 @@ export function useStream(): Misskey.IStream { } function heartbeat(): void { - if (stream != null && document.visibilityState === 'visible') { + if (stream != null && window.document.visibilityState === 'visible') { stream.heartbeat(); } lastHeartbeatCall = Date.now(); diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index 35443cdb8a..6568c738c5 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -111,6 +111,11 @@ html { &.useSystemFont { font-family: system-ui; } + + &:not(.forceSelectableAll) { + user-select: none; + -webkit-user-select: none; + } } html._themeChanging_ { @@ -138,10 +143,6 @@ a { outline-offset: 2px; } - &:hover { - text-decoration: underline; - } - &[target="_blank"] { -webkit-touch-callout: default; } @@ -150,6 +151,8 @@ a { textarea, input { tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent; + user-select: text; + -webkit-user-select: text; } optgroup, option { @@ -158,8 +161,9 @@ optgroup, option { } hr { - margin: var(--MI-margin) 0 var(--MI-margin) 0; + margin: 0; border: none; + border-radius: 999px; height: 1px; background: var(--MI_THEME-divider); } @@ -192,6 +196,29 @@ rt { text-align: center; } +._pageContainer { + container-type: size; + contain: strict; + overflow: auto; + overscroll-behavior: contain; +} + +._pageScrollable { + height: 100%; + overflow: clip; + overflow-y: scroll; + overscroll-behavior: contain; +} + +._pageScrollableReversed { + height: 100%; + overflow: clip; + overflow-y: scroll; + overscroll-behavior: contain; + display: flex; + flex-direction: column-reverse; +} + ._indicatorCircle { display: inline-block; width: 1em; @@ -213,6 +240,16 @@ rt { padding: 0.3em 0.5em; } +._selectable { + user-select: text; + -webkit-user-select: text; +} + +._selectableAtomic { + user-select: all; + -webkit-user-select: all; +} + ._noSelect { user-select: none; -webkit-user-select: none; @@ -226,11 +263,6 @@ rt { text-overflow: ellipsis; } -._ghost { - @extend ._noSelect; - pointer-events: none; -} - ._modalBg { position: fixed; top: 0; @@ -333,13 +365,13 @@ rt { ._gaps_m { display: flex; flex-direction: column; - gap: 1.5em; + gap: 21px; } ._gaps_s { display: flex; flex-direction: column; - gap: 0.75em; + gap: 10px; } ._gaps { @@ -457,6 +489,10 @@ rt { color: var(--MI_THEME-link); } +._love { + color: var(--MI_THEME-love); +} + ._caption { font-size: 0.8em; opacity: 0.7; diff --git a/packages/frontend/src/tab-id.ts b/packages/frontend/src/tab-id.ts new file mode 100644 index 0000000000..49b69f72d2 --- /dev/null +++ b/packages/frontend/src/tab-id.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { v4 as uuid } from 'uuid'; + +// HMR有効時にバグか知らんけど複数回実行されるのでその対策 +export const TAB_ID = window.sessionStorage.getItem('TAB_ID') ?? uuid(); +window.sessionStorage.setItem('TAB_ID', TAB_ID); +if (_DEV_) console.log('TAB_ID', TAB_ID); diff --git a/packages/frontend/src/theme-store.ts b/packages/frontend/src/theme-store.ts index c41cc17652..2ae5d8730e 100644 --- a/packages/frontend/src/theme-store.ts +++ b/packages/frontend/src/theme-store.ts @@ -3,28 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Theme, getBuiltinThemes } from '@/scripts/theme.js'; -import { miLocalStorage } from '@/local-storage.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { $i } from '@/account.js'; - -const lsCacheKey = $i ? `themes:${$i.id}` as const : null; +import type { Theme } from '@/theme.js'; +import { getBuiltinThemes } from '@/theme.js'; +import { $i } from '@/i.js'; +import { prefer } from '@/preferences.js'; export function getThemes(): Theme[] { if ($i == null) return []; - return JSON.parse(miLocalStorage.getItem(lsCacheKey!) ?? '[]'); -} - -export async function fetchThemes(): Promise<void> { - if ($i == null) return; - - try { - const themes = await misskeyApi('i/registry/get', { scope: ['client'], key: 'themes' }); - miLocalStorage.setItem(lsCacheKey!, JSON.stringify(themes)); - } catch (err) { - if (err.code === 'NO_SUCH_KEY') return; - throw err; - } + return prefer.s.themes; } export async function addTheme(theme: Theme): Promise<void> { @@ -33,15 +19,15 @@ export async function addTheme(theme: Theme): Promise<void> { if (builtinThemes.some(t => t.id === theme.id)) { throw new Error('builtin theme'); } - await fetchThemes(); - const themes = getThemes().concat(theme); - await misskeyApi('i/registry/set', { scope: ['client'], key: 'themes', value: themes }); - miLocalStorage.setItem(lsCacheKey!, JSON.stringify(themes)); + const themes = getThemes(); + if (themes.some(t => t.id === theme.id)) { + throw new Error('already exists'); + } + prefer.commit('themes', [...themes, theme]); } export async function removeTheme(theme: Theme): Promise<void> { if ($i == null) return; const themes = getThemes().filter(t => t.id !== theme.id); - await misskeyApi('i/registry/set', { scope: ['client'], key: 'themes', value: themes }); - miLocalStorage.setItem(lsCacheKey!, JSON.stringify(themes)); + prefer.commit('themes', themes); } diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/theme.ts index 8242e7d2e4..4f61ab6e0e 100644 --- a/packages/frontend/src/scripts/theme.ts +++ b/packages/frontend/src/theme.ts @@ -7,10 +7,12 @@ import { ref } from 'vue'; import tinycolor from 'tinycolor2'; import lightTheme from '@@/themes/_light.json5'; import darkTheme from '@@/themes/_dark.json5'; -import { deepClone } from './clone.js'; +import JSON5 from 'json5'; import type { BundledTheme } from 'shiki/themes'; +import { deepClone } from '@/utility/clone.js'; import { globalEvents } from '@/events.js'; import { miLocalStorage } from '@/local-storage.js'; +import { addTheme, getThemes } from '@/theme-store.js'; export type Theme = { id: string; @@ -70,15 +72,18 @@ let timeout: number | null = null; export function applyTheme(theme: Theme, persist = true) { if (timeout) window.clearTimeout(timeout); - document.documentElement.classList.add('_themeChanging_'); + window.document.documentElement.classList.add('_themeChanging_'); timeout = window.setTimeout(() => { - document.documentElement.classList.remove('_themeChanging_'); + window.document.documentElement.classList.remove('_themeChanging_'); + + // 色計算など再度行えるようにクライアント全体に通知 + globalEvents.emit('themeChanged'); }, 1000); const colorScheme = theme.base === 'dark' ? 'dark' : 'light'; - document.documentElement.dataset.colorScheme = colorScheme; + window.document.documentElement.dataset.colorScheme = colorScheme; // Deep copy const _theme = deepClone(theme); @@ -90,7 +95,7 @@ export function applyTheme(theme: Theme, persist = true) { const props = compile(_theme); - for (const tag of document.head.children) { + for (const tag of window.document.head.children) { if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') { tag.setAttribute('content', props['htmlThemeColor']); break; @@ -123,21 +128,22 @@ export function applyTheme(theme: Theme, persist = true) { for (const [k, v] of Object.entries(props)) { if (k.startsWith('font')) continue; - document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString()); + window.document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString()); } - document.documentElement.style.setProperty('color-scheme', colorScheme); + window.document.documentElement.style.setProperty('color-scheme', colorScheme); if (persist) { miLocalStorage.setItem('theme', JSON.stringify(props)); + miLocalStorage.setItem('themeId', theme.id); miLocalStorage.setItem('colorScheme', colorScheme); } // 色計算など再度行えるようにクライアント全体に通知 - globalEvents.emit('themeChanged'); + globalEvents.emit('themeChanging'); } -function compile(theme: Theme): Record<string, string> { +export function compile(theme: Theme): Record<string, string> { function getColor(val: string): tinycolor.Instance { if (val[0] === '@') { // ref (prop) return getColor(theme.props[val.substring(1)]); @@ -188,3 +194,32 @@ export function validateTheme(theme: Record<string, any>): boolean { if (theme.props == null || typeof theme.props !== 'object') return false; return true; } + +export function parseThemeCode(code: string): Theme { + let theme; + + try { + theme = JSON5.parse(code); + } catch (err) { + throw new Error('Failed to parse theme json'); + } + if (!validateTheme(theme)) { + throw new Error('This theme is invaild'); + } + if (getThemes().some(t => t.id === theme.id)) { + throw new Error('This theme is already installed'); + } + + return theme; +} + +export function previewTheme(code: string): void { + const theme = parseThemeCode(code); + if (theme) applyTheme(theme, false); +} + +export async function installTheme(code: string): Promise<void> { + const theme = parseThemeCode(code); + if (!theme) return; + await addTheme(theme); +} diff --git a/packages/frontend/src/timelines.ts b/packages/frontend/src/timelines.ts index 5080ef4b96..c8ac726891 100644 --- a/packages/frontend/src/timelines.ts +++ b/packages/frontend/src/timelines.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { instance } from '@/instance.js'; export const basicTimelineTypes = [ diff --git a/packages/frontend/src/types/menu.ts b/packages/frontend/src/types/menu.ts index 138eb7dd62..820759ce61 100644 --- a/packages/frontend/src/types/menu.ts +++ b/packages/frontend/src/types/menu.ts @@ -4,27 +4,31 @@ */ import * as Misskey from 'misskey-js'; -import { ComputedRef, Ref } from 'vue'; +import type { Component, ComputedRef, Ref } from 'vue'; +import type { ComponentProps as CP } from 'vue-component-type-helpers'; -interface MenuRadioOptionsDef extends Record<string, any> { } +type ComponentProps<T extends Component> = { [K in keyof CP<T>]: CP<T>[K] | Ref<CP<T>[K]> }; + +type MenuRadioOptionsDef = Record<string, any>; export type MenuAction = (ev: MouseEvent) => void; export type MenuDivider = { type: 'divider' }; export type MenuNull = undefined; -export type MenuLabel = { type: 'label', text: string }; -export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User }; -export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean }; +export type MenuLabel = { type: 'label', text: string, caption?: string }; +export type MenuLink = { type: 'link', to: string, text: string, caption?: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User }; +export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, caption?: string, icon?: string, indicate?: boolean }; export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction }; -export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, icon?: string, disabled?: boolean | Ref<boolean> }; -export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction }; -export type MenuRadio = { type: 'radio', text: string, icon?: string, ref: Ref<MenuRadioOptionsDef[keyof MenuRadioOptionsDef]>, options: MenuRadioOptionsDef, disabled?: boolean | Ref<boolean> }; -export type MenuRadioOption = { type: 'radioOption', text: string, action: MenuAction; active?: boolean | ComputedRef<boolean> }; -export type MenuParent = { type: 'parent', text: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) }; +export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, caption?: string, icon?: string, disabled?: boolean | Ref<boolean> }; +export type MenuButton = { type?: 'button', text: string, caption?: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction }; +export type MenuRadio = { type: 'radio', text: string, caption?: string, icon?: string, ref: Ref<MenuRadioOptionsDef[keyof MenuRadioOptionsDef]>, options: MenuRadioOptionsDef, disabled?: boolean | Ref<boolean> }; +export type MenuRadioOption = { type: 'radioOption', text: string, caption?: string, action: MenuAction; active?: boolean | ComputedRef<boolean> }; +export type MenuComponent<T extends Component = any> = { type: 'component', component: T, props?: ComponentProps<T> }; +export type MenuParent = { type: 'parent', text: string, caption?: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) }; export type MenuPending = { type: 'pending' }; -type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuRadio | MenuRadioOption | MenuParent; -type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent>; +type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuRadio | MenuRadioOption | MenuComponent | MenuParent; +type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuComponent | MenuParent>; export type MenuItem = OuterMenuItem | OuterPromiseMenuItem; -export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuRadio | MenuRadioOption | MenuParent; +export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuRadio | MenuRadioOption | MenuComponent | MenuParent; diff --git a/packages/frontend/src/ui/_common_/PreferenceRestore.vue b/packages/frontend/src/ui/_common_/PreferenceRestore.vue new file mode 100644 index 0000000000..5fd9f5e44b --- /dev/null +++ b/packages/frontend/src/ui/_common_/PreferenceRestore.vue @@ -0,0 +1,64 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root"> + <span :class="$style.icon"> + <i class="ti ti-info-circle"></i> + </span> + <span :class="$style.title">{{ i18n.ts._preferencesBackup.backupFound }}</span> + <span :class="$style.body"><button class="_textButton" @click="restore">{{ i18n.ts.restore }}</button> | <button class="_textButton" @click="skip">{{ i18n.ts.skip }}</button></span> +</div> +</template> + +<script lang="ts" setup> +import { $i } from '@/i.js'; +import { i18n } from '@/i18n.js'; +import { hideRestoreBackupSuggestion, restoreFromCloudBackup } from '@/preferences/utility.js'; + +function restore() { + restoreFromCloudBackup(); +} + +function skip() { + hideRestoreBackupSuggestion(); +} +</script> + +<style lang="scss" module> +.root { + --height: 24px; + font-size: 0.85em; + display: flex; + vertical-align: bottom; + width: 100%; + line-height: var(--height); + height: var(--height); + overflow: clip; + contain: strict; + background: var(--MI_THEME-panel); +} + +.icon { + margin-left: 10px; +} + +.title { + padding: 0 10px; + font-weight: bold; + + &:empty { + display: none; + } +} + +.body { + min-width: 0; + flex: 1; + overflow: clip; + white-space: nowrap; + text-overflow: ellipsis; +} +</style> diff --git a/packages/frontend/src/ui/_common_/announcements.vue b/packages/frontend/src/ui/_common_/announcements.vue index d153dc8726..f9af8e1ee7 100644 --- a/packages/frontend/src/ui/_common_/announcements.vue +++ b/packages/frontend/src/ui/_common_/announcements.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; </script> <style lang="scss" module> diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts index 6d36df9874..fcb9f2acfc 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -9,7 +9,7 @@ import * as os from '@/os.js'; import { instance } from '@/instance.js'; import { host } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; function toolsMenuItems(): MenuItem[] { return [{ diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index 227d9bb7e6..a7203b1eb9 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -17,18 +17,18 @@ SPDX-License-Identifier: AGPL-3.0-only <TransitionGroup tag="div" :class="[$style.notifications, { - [$style.notificationsPosition_leftTop]: defaultStore.state.notificationPosition === 'leftTop', - [$style.notificationsPosition_leftBottom]: defaultStore.state.notificationPosition === 'leftBottom', - [$style.notificationsPosition_rightTop]: defaultStore.state.notificationPosition === 'rightTop', - [$style.notificationsPosition_rightBottom]: defaultStore.state.notificationPosition === 'rightBottom', - [$style.notificationsStackAxis_vertical]: defaultStore.state.notificationStackAxis === 'vertical', - [$style.notificationsStackAxis_horizontal]: defaultStore.state.notificationStackAxis === 'horizontal', + [$style.notificationsPosition_leftTop]: prefer.s.notificationPosition === 'leftTop', + [$style.notificationsPosition_leftBottom]: prefer.s.notificationPosition === 'leftBottom', + [$style.notificationsPosition_rightTop]: prefer.s.notificationPosition === 'rightTop', + [$style.notificationsPosition_rightBottom]: prefer.s.notificationPosition === 'rightBottom', + [$style.notificationsStackAxis_vertical]: prefer.s.notificationStackAxis === 'vertical', + [$style.notificationsStackAxis_horizontal]: prefer.s.notificationStackAxis === 'horizontal', }]" - :moveClass="defaultStore.state.animation ? $style.transition_notification_move : ''" - :enterActiveClass="defaultStore.state.animation ? $style.transition_notification_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_notification_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_notification_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_notification_leaveTo : ''" + :moveClass="prefer.s.animation ? $style.transition_notification_move : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_notification_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_notification_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_notification_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_notification_leaveTo : ''" > <div v-for="notification in notifications" :key="notification.id" :class="$style.notification" :style="{ @@ -56,13 +56,13 @@ import * as Misskey from 'misskey-js'; import { swInject } from './sw-inject.js'; import XNotification from './notification.vue'; import { popups } from '@/os.js'; -import { pendingApiRequestsCount } from '@/scripts/misskey-api.js'; -import { uploads } from '@/scripts/upload.js'; -import * as sound from '@/scripts/sound.js'; -import { $i } from '@/account.js'; +import { pendingApiRequestsCount } from '@/utility/misskey-api.js'; +import { uploads } from '@/utility/upload.js'; +import * as sound from '@/utility/sound.js'; +import { $i } from '@/i.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; import { globalEvents } from '@/events.js'; const SkOneko = defineAsyncComponent(() => import('@/components/SkOneko.vue')); @@ -75,7 +75,7 @@ const dev = _DEV_; const notifications = ref<Misskey.entities.Notification[]>([]); function onNotification(notification: Misskey.entities.Notification, isClient = false) { - if (document.visibilityState === 'visible') { + if (window.document.visibilityState === 'visible') { if (!isClient && notification.type !== 'test') { // サーバーサイドのテスト通知の際は自動で既読をつけない(テストできないので) useStream().send('readNotification'); diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue index aa72de6089..18d9f2f5f8 100644 --- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue +++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue @@ -53,12 +53,13 @@ import { computed, defineAsyncComponent, toRef } from 'vue'; import { openInstanceMenu } from './common.js'; import * as os from '@/os.js'; import { navbarItemDef } from '@/navbar.js'; -import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; +import { openAccountMenu as openAccountMenu_ } from '@/accounts.js'; +import { $i } from '@/i.js'; -const menu = toRef(defaultStore.state, 'menu'); +const menu = toRef(prefer.s, 'menu'); const otherMenuItemIndicated = computed(() => { for (const def in navbarItemDef) { if (menu.value.includes(def)) continue; diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index 062a8faf3f..d590455ae5 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -9,12 +9,12 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.top"> <div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"></div> <button v-tooltip.noDelay.right="instance.name ?? i18n.ts.instance" class="_button" :class="$style.instance" @click="openInstanceMenu"> - <img :src="instance.sidebarLogoUrl && !iconOnly ? instance.sidebarLogoUrl : instance.iconUrl || '/apple-touch-icon.png'" alt="" :class="instance.sidebarLogoUrl && !iconOnly ? $style.wideInstanceIcon : $style.instanceIcon"/> + <img :src="instance.sidebarLogoUrl && !iconOnly ? instance.sidebarLogoUrl : instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="instance.sidebarLogoUrl && !iconOnly ? $style.wideInstanceIcon : $style.instanceIcon" style="viewTransitionName: navbar-serverIcon;"/> </button> </div> <div :class="$style.middle"> <MkA v-tooltip.noDelay.right="i18n.ts.timeline" :class="$style.item" :activeClass="$style.active" to="/" exact> - <i :class="$style.itemIcon" class="ti ti-home ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.timeline }}</span> + <i :class="$style.itemIcon" class="ti ti-home ti-fw" style="viewTransitionName: navbar-homeIcon;"></i><span :class="$style.itemText">{{ i18n.ts.timeline }}</span> </MkA> <template v-for="item in menu"> <div v-if="item === '-'" :class="$style.divider"></div> @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}" > - <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[item].icon]"></i><span :class="$style.itemText">{{ navbarItemDef[item].title }}</span> + <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[item].icon]" :style="{ viewTransitionName: 'navbar-item-' + item }"></i><span :class="$style.itemText">{{ navbarItemDef[item].title }}</span> <span v-if="navbarItemDef[item].indicated" :class="$style.itemIndicator" class="_blink"> <span v-if="navbarItemDef[item].indicateValue" class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ navbarItemDef[item].indicateValue }}</span> <i v-else class="_indicatorCircle"></i> @@ -37,14 +37,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <div :class="$style.divider"></div> <MkA v-if="$i != null && ($i.isAdmin || $i.isModerator)" v-tooltip.noDelay.right="i18n.ts.controlPanel" :class="$style.item" :activeClass="$style.active" to="/admin"> - <i :class="$style.itemIcon" class="ti ti-dashboard ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.controlPanel }}</span> + <i :class="$style.itemIcon" class="ti ti-dashboard ti-fw" style="viewTransitionName: navbar-controlPanel;"></i><span :class="$style.itemText">{{ i18n.ts.controlPanel }}</span> </MkA> <button class="_button" :class="$style.item" @click="more"> - <i :class="$style.itemIcon" class="ti ti-grid-dots ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.more }}</span> + <i :class="$style.itemIcon" class="ti ti-grid-dots ti-fw" style="viewTransitionName: navbar-more;"></i><span :class="$style.itemText">{{ i18n.ts.more }}</span> <span v-if="otherMenuItemIndicated" :class="$style.itemIndicator" class="_blink"><i class="_indicatorCircle"></i></span> </button> <MkA v-tooltip.noDelay.right="i18n.ts.settings" :class="$style.item" :activeClass="$style.active" to="/settings"> - <i :class="$style.itemIcon" class="ti ti-settings ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.settings }}</span> + <i :class="$style.itemIcon" class="ti ti-settings ti-fw" style="viewTransitionName: navbar-settings;"></i><span :class="$style.itemText">{{ i18n.ts.settings }}</span> </MkA> </div> <div :class="$style.bottom"> @@ -52,25 +52,39 @@ SPDX-License-Identifier: AGPL-3.0-only <i class="ti ti-pencil ti-fw" :class="$style.postIcon"></i><span :class="$style.postText">{{ i18n.ts.note }}</span> </button> <button v-if="$i != null" v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="_button" :class="[$style.account]" @click="openAccountMenu"> - <MkAvatar :user="$i" :class="$style.avatar"/><MkAcct class="_nowrap" :class="$style.acct" :user="$i"/> + <MkAvatar :user="$i" :class="$style.avatar" style="viewTransitionName: navbar-avatar;"/><MkAcct class="_nowrap" :class="$style.acct" :user="$i"/> </button> </div> </div> - <button v-if="!forceIconOnly" class="_button" :class="$style.toggleButton" @click="toggleIconOnly"> - <!-- - <svg viewBox="0 0 16 48" :class="$style.toggleButtonShape"> - <g transform="matrix(0.333333,0,0,0.222222,0.000895785,13.3333)"> - <path d="M23.935,-24C37.223,-24 47.995,-7.842 47.995,12.09C47.995,34.077 47.995,62.07 47.995,84.034C47.995,93.573 45.469,102.721 40.972,109.466C36.475,116.211 30.377,120 24.018,120L23.997,120C10.743,120 -0.003,136.118 -0.003,156C-0.003,156 -0.003,156 -0.003,156L-0.003,-60L-0.003,-59.901C-0.003,-50.379 2.519,-41.248 7.007,-34.515C11.496,-27.782 17.584,-24 23.931,-24C23.932,-24 23.934,-24 23.935,-24Z" style="fill:var(--MI_THEME-navBg);"/> - </g> - </svg> - --> - <svg viewBox="0 0 16 64" :class="$style.toggleButtonShape"> - <g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)"> - <path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/> - </g> - </svg> - <i :class="'ti ' + `ti-chevron-${ iconOnly ? 'right' : 'left' }`" style="font-size: 12px; margin-left: -8px;"></i> - </button> + + <!-- + <svg viewBox="0 0 16 48" :class="$style.subButtonShape"> + <g transform="matrix(0.333333,0,0,0.222222,0.000895785,13.3333)"> + <path d="M23.935,-24C37.223,-24 47.995,-7.842 47.995,12.09C47.995,34.077 47.995,62.07 47.995,84.034C47.995,93.573 45.469,102.721 40.972,109.466C36.475,116.211 30.377,120 24.018,120L23.997,120C10.743,120 -0.003,136.118 -0.003,156C-0.003,156 -0.003,156 -0.003,156L-0.003,-60L-0.003,-59.901C-0.003,-50.379 2.519,-41.248 7.007,-34.515C11.496,-27.782 17.584,-24 23.931,-24C23.932,-24 23.934,-24 23.935,-24Z" style="fill:var(--MI_THEME-navBg);"/> + </g> + </svg> + --> + + <div v-if="!forceIconOnly" :class="$style.subButtons"> + <div :class="[$style.subButton, $style.menuEditButton]"> + <svg viewBox="0 0 16 64" :class="$style.subButtonShape"> + <g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)"> + <path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/> + </g> + </svg> + <button class="_button" :class="$style.subButtonClickable" @click="menuEdit"><i :class="$style.subButtonIcon" class="ti ti-settings-2"></i></button> + </div> + <div :class="$style.subButtonGapFill"></div> + <div :class="$style.subButtonGapFillDivider"></div> + <div :class="[$style.subButton, $style.toggleButton]"> + <svg viewBox="0 0 16 64" :class="$style.subButtonShape"> + <g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)"> + <path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/> + </g> + </svg> + <button class="_button" :class="$style.subButtonClickable" @click="toggleIconOnly"><i v-if="iconOnly" class="ti ti-chevron-right" :class="$style.subButtonIcon"></i><i v-else class="ti ti-chevron-left" :class="$style.subButtonIcon"></i></button> + </div> + </div> </div> </template> @@ -79,18 +93,23 @@ import { computed, defineAsyncComponent, ref, watch } from 'vue'; import { openInstanceMenu } from './common.js'; import * as os from '@/os.js'; import { navbarItemDef } from '@/navbar.js'; -import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js'; +import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js'; +import { useRouter } from '@/router.js'; +import { prefer } from '@/preferences.js'; +import { openAccountMenu as openAccountMenu_ } from '@/accounts.js'; +import { $i } from '@/i.js'; + +const router = useRouter(); const forceIconOnly = ref(window.innerWidth <= 1279); const iconOnly = computed(() => { - return forceIconOnly.value || (defaultStore.reactiveState.menuDisplay.value === 'sideIcon'); + return forceIconOnly.value || (store.r.menuDisplay.value === 'sideIcon'); }); -const menu = computed(() => defaultStore.state.menu); +const menu = computed(() => prefer.s.menu); const otherMenuItemIndicated = computed(() => { for (const def in navbarItemDef) { if (menu.value.includes(def)) continue; @@ -105,12 +124,18 @@ function calcViewState() { window.addEventListener('resize', calcViewState); -watch(defaultStore.reactiveState.menuDisplay, () => { +watch(store.r.menuDisplay, () => { calcViewState(); }); function toggleIconOnly() { - defaultStore.set('menuDisplay', iconOnly.value ? 'sideFull' : 'sideIcon'); + if (window.document.startViewTransition && prefer.s.animation) { + window.document.startViewTransition(() => { + store.set('menuDisplay', iconOnly.value ? 'sideFull' : 'sideIcon'); + }); + } else { + store.set('menuDisplay', iconOnly.value ? 'sideFull' : 'sideIcon'); + } } function openAccountMenu(ev: MouseEvent) { @@ -128,6 +153,10 @@ function more(ev: MouseEvent) { closed: () => dispose(), }); } + +function menuEdit() { + router.push('/settings/navbar'); +} </script> <style lang="scss" module> @@ -136,6 +165,8 @@ function more(ev: MouseEvent) { --nav-icon-only-width: 80px; --nav-bg-transparent: color(from var(--MI_THEME-navBg) srgb r g b / 0.5); + --subButtonWidth: 20px; + flex: 0 0 var(--nav-width); width: var(--nav-width); box-sizing: border-box; @@ -171,23 +202,80 @@ function more(ev: MouseEvent) { direction: ltr; } -.toggleButton { +.subButtons { position: fixed; - bottom: 20px; left: var(--nav-width); + bottom: 80px; z-index: 1001; - width: 16px; - height: 64px; box-sizing: border-box; } -.toggleButtonShape { +.subButton { + display: block; + position: relative; + z-index: 1002; + width: var(--subButtonWidth); + height: 50px; + box-sizing: border-box; + align-content: center; +} + +.subButtonShape { position: absolute; z-index: -1; top: 0; + bottom: 0; left: 0; - width: 16px; + margin: auto; + width: var(--subButtonWidth); + height: calc(var(--subButtonWidth) * 4); +} + +.subButtonClickable { + position: absolute; + display: block; + max-width: unset; + width: 24px; + height: 42px; + top: 0; + bottom: 0; + left: -4px; + margin: auto; + font-size: 10px; + + &:hover { + color: var(--MI_THEME-fgHighlighted); + + .subButtonIcon { + opacity: 1; + } + } +} + +.subButtonIcon { + margin-left: -4px; + opacity: 0.7; +} + +.subButtonGapFill { + position: relative; + z-index: 1001; + width: var(--subButtonWidth); height: 64px; + margin-top: -32px; + margin-bottom: -32px; + pointer-events: none; + background: var(--MI_THEME-navBg); +} + +.subButtonGapFillDivider { + position: relative; + z-index: 1010; + margin-left: -2px; + width: 14px; + height: 1px; + background: var(--MI_THEME-divider); + pointer-events: none; } .root:not(.iconOnly) { @@ -426,7 +514,7 @@ function more(ev: MouseEvent) { font-size: 0.9em; } - .toggleButton { + .subButtons { left: var(--nav-width); } } @@ -630,7 +718,7 @@ function more(ev: MouseEvent) { } } - .toggleButton { + .subButtons { left: var(--nav-icon-only-width); } } diff --git a/packages/frontend/src/ui/_common_/statusbar-federation.vue b/packages/frontend/src/ui/_common_/statusbar-federation.vue index e234bb3a33..16e72fa227 100644 --- a/packages/frontend/src/ui/_common_/statusbar-federation.vue +++ b/packages/frontend/src/ui/_common_/statusbar-federation.vue @@ -34,9 +34,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MarqueeText from '@/components/MkMarquee.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { useInterval } from '@@/js/use-interval.js'; -import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; +import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; const props = defineProps<{ display?: 'marquee' | 'oneByOne'; diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue index da8fa8bb21..4da89a181e 100644 --- a/packages/frontend/src/ui/_common_/statusbar-rss.vue +++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue @@ -31,7 +31,7 @@ import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MarqueeText from '@/components/MkMarquee.vue'; import { useInterval } from '@@/js/use-interval.js'; -import { shuffle } from '@/scripts/shuffle.js'; +import { shuffle } from '@/utility/shuffle.js'; const props = defineProps<{ url?: string; diff --git a/packages/frontend/src/ui/_common_/statusbar-user-list.vue b/packages/frontend/src/ui/_common_/statusbar-user-list.vue index 078b595dca..c5bee51162 100644 --- a/packages/frontend/src/ui/_common_/statusbar-user-list.vue +++ b/packages/frontend/src/ui/_common_/statusbar-user-list.vue @@ -34,9 +34,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MarqueeText from '@/components/MkMarquee.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { useInterval } from '@@/js/use-interval.js'; -import { getNoteSummary } from '@/scripts/get-note-summary.js'; +import { getNoteSummary } from '@/utility/get-note-summary.js'; import { notePage } from '@/filters/note.js'; const props = defineProps<{ diff --git a/packages/frontend/src/ui/_common_/statusbars.vue b/packages/frontend/src/ui/_common_/statusbars.vue index ed881bef22..a8d87599e6 100644 --- a/packages/frontend/src/ui/_common_/statusbars.vue +++ b/packages/frontend/src/ui/_common_/statusbars.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> <div - v-for="x in defaultStore.reactiveState.statusbars.value" :key="x.id" :class="[$style.item, { [$style.black]: x.black, + v-for="x in prefer.r.statusbars.value" :key="x.id" :class="[$style.item, { [$style.black]: x.black, [$style.verySmall]: x.size === 'verySmall', [$style.small]: x.size === 'small', [$style.large]: x.size === 'large', @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent } from 'vue'; import { instance } from '@/instance.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const XRss = defineAsyncComponent(() => import('./statusbar-rss.vue')); const XFederation = defineAsyncComponent(() => import('./statusbar-federation.vue')); const XUserList = defineAsyncComponent(() => import('./statusbar-user-list.vue')); diff --git a/packages/frontend/src/ui/_common_/stream-indicator.vue b/packages/frontend/src/ui/_common_/stream-indicator.vue index cc62a28b14..5f7600881f 100644 --- a/packages/frontend/src/ui/_common_/stream-indicator.vue +++ b/packages/frontend/src/ui/_common_/stream-indicator.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-if="hasDisconnected && defaultStore.state.serverDisconnectedBehavior === 'quiet'" :class="$style.root" class="_panel _shadow" @click="resetDisconnected"> +<div v-if="hasDisconnected && prefer.s.serverDisconnectedBehavior === 'quiet'" :class="$style.root" class="_panel _shadow" @click="resetDisconnected"> <div><i class="ti ti-alert-triangle"></i> {{ i18n.ts.disconnectedFromServer }}</div> <div :class="$style.command" class="_buttons"> <MkButton small primary @click="reload">{{ i18n.ts.reload }}</MkButton> @@ -19,7 +19,7 @@ import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const zIndex = os.claimZIndex('high'); @@ -34,7 +34,7 @@ function resetDisconnected() { } function reload() { - location.reload(); + window.location.reload(); } useStream().on('_disconnected_', onDisconnected); diff --git a/packages/frontend/src/ui/_common_/sw-inject.ts b/packages/frontend/src/ui/_common_/sw-inject.ts index ff851ad99f..1459881ba1 100644 --- a/packages/frontend/src/ui/_common_/sw-inject.ts +++ b/packages/frontend/src/ui/_common_/sw-inject.ts @@ -4,11 +4,12 @@ */ import { post } from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { $i, login } from '@/account.js'; -import { getAccountFromId } from '@/scripts/get-account-from-id.js'; -import { deepClone } from '@/scripts/clone.js'; -import { mainRouter } from '@/router/main.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { $i } from '@/i.js'; +import { getAccountFromId } from '@/utility/get-account-from-id.js'; +import { deepClone } from '@/utility/clone.js'; +import { mainRouter } from '@/router.js'; +import { login } from '@/accounts.js'; export function swInject() { navigator.serviceWorker.addEventListener('message', async ev => { diff --git a/packages/frontend/src/ui/_common_/upload.vue b/packages/frontend/src/ui/_common_/upload.vue index 12de579d90..1135d236fc 100644 --- a/packages/frontend/src/ui/_common_/upload.vue +++ b/packages/frontend/src/ui/_common_/upload.vue @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { } from 'vue'; import * as os from '@/os.js'; -import { uploads } from '@/scripts/upload.js'; +import { uploads } from '@/utility/upload.js'; import { i18n } from '@/i18n.js'; const zIndex = os.claimZIndex('high'); diff --git a/packages/frontend/src/ui/classic.header.vue b/packages/frontend/src/ui/classic.header.vue index f4633314ae..7d4235bd4e 100644 --- a/packages/frontend/src/ui/classic.header.vue +++ b/packages/frontend/src/ui/classic.header.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="body"> <div class="left"> <button v-click-anime class="item _button instance" @click="openInstanceMenu"> - <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" class="_ghost"/> + <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" draggable="false"/> </button> <MkA v-click-anime v-tooltip="i18n.ts.timeline" class="item index" activeClass="active" to="/" exact> <i class="ti ti-home ti-fw"></i> @@ -51,17 +51,18 @@ import { computed, defineAsyncComponent, onMounted, ref } from 'vue'; import { openInstanceMenu } from './_common_/common.js'; import * as os from '@/os.js'; import { navbarItemDef } from '@/navbar.js'; -import { openAccountMenu as openAccountMenu_, $i } from '@/account.js'; import MkButton from '@/components/MkButton.vue'; -import { defaultStore } from '@/store.js'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; +import { openAccountMenu as openAccountMenu_ } from '@/accounts.js'; +import { $i } from '@/i.js'; const WINDOW_THRESHOLD = 1400; const settingsWindowed = ref(window.innerWidth > WINDOW_THRESHOLD); -const menu = ref(defaultStore.state.menu); -// const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay')); +const menu = ref(prefer.s.menu); +// const menuDisplay = computed(store.makeGetterSetter('menuDisplay')); const otherNavItemIndicated = computed<boolean>(() => { for (const def in navbarItemDef) { if (menu.value.includes(def)) continue; diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue index f17027bcde..6e81f72549 100644 --- a/packages/frontend/src/ui/classic.sidebar.vue +++ b/packages/frontend/src/ui/classic.sidebar.vue @@ -41,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="divider"></div> <div class="about"> <button v-click-anime class="item _button" @click="openInstanceMenu"> - <img :src="instance.sidebarLogoUrl && !iconOnly ? instance.sidebarLogoUrl : instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" :class="{ wideIcon: instance.sidebarLogoUrl && !iconOnly }" class="_ghost" /> + <img :src="instance.sidebarLogoUrl && !iconOnly ? instance.sidebarLogoUrl : instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" :class="{ wideIcon: instance.sidebarLogoUrl && !iconOnly }" class="_ghost" draggable="false" /> </button> </div> <!--<MisskeyLogo class="misskey"/>--> @@ -49,23 +49,25 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, computed, watch, ref, shallowRef } from 'vue'; +import { defineAsyncComponent, computed, watch, ref, useTemplateRef } from 'vue'; import { openInstanceMenu } from './_common_/common.js'; // import { host } from '@@/js/config.js'; import * as os from '@/os.js'; import { navbarItemDef } from '@/navbar.js'; -import { openAccountMenu as openAccountMenu_, $i } from '@/account.js'; import MkButton from '@/components/MkButton.vue'; -// import { StickySidebar } from '@/scripts/sticky-sidebar.js'; +// import { StickySidebar } from '@/utility/sticky-sidebar.js'; // import { mainRouter } from '@/router.js'; //import MisskeyLogo from '@assets/client/sharkey.svg'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; +import { openAccountMenu as openAccountMenu_ } from '@/accounts.js'; +import { $i } from '@/i.js'; const WINDOW_THRESHOLD = 1400; -const menu = ref(defaultStore.state.menu); +const menu = ref(prefer.s.menu); const otherNavItemIndicated = computed<boolean>(() => { for (const def in navbarItemDef) { if (menu.value.includes(def)) continue; @@ -73,7 +75,7 @@ const otherNavItemIndicated = computed<boolean>(() => { } return false; }); -const el = shallowRef<HTMLElement>(); +const el = useTemplateRef('el'); // let accounts = $ref([]); // let connection = $ref(null); const iconOnly = ref(false); @@ -100,7 +102,7 @@ function openAccountMenu(ev: MouseEvent) { }, ev); } -watch(defaultStore.reactiveState.menuDisplay, () => { +watch(store.r.menuDisplay, () => { calcViewState(); }); diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue index f8cd5fa8be..c252b03c82 100644 --- a/packages/frontend/src/ui/classic.vue +++ b/packages/frontend/src/ui/classic.vue @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> - <Transition :name="defaultStore.state.animation ? 'tray-back' : ''"> + <Transition :name="prefer.s.animation ? 'tray-back' : ''"> <div v-if="widgetsShowing" class="tray-back _modalBg" @@ -35,29 +35,32 @@ SPDX-License-Identifier: AGPL-3.0-only ></div> </Transition> - <Transition :name="defaultStore.state.animation ? 'tray' : ''"> + <Transition :name="prefer.s.animation ? 'tray' : ''"> <XWidgets v-if="widgetsShowing" class="tray"/> </Transition> - <iframe v-if="defaultStore.state.aiChanMode" ref="live2d" class="ivnzpscs" src="https://misskey-dev.github.io/mascot-web/?scale=2&y=1.4"></iframe> + <iframe v-if="prefer.s.aiChanMode" ref="live2d" class="ivnzpscs" src="https://misskey-dev.github.io/mascot-web/?scale=2&y=1.4"></iframe> <XCommon/> </div> </template> <script lang="ts" setup> -import { defineAsyncComponent, onMounted, provide, ref, computed, shallowRef } from 'vue'; +import { defineAsyncComponent, onMounted, provide, ref, computed, useTemplateRef } from 'vue'; +import { instanceName } from '@@/js/config.js'; +import { isLink } from '@@/js/is-link.js'; import XSidebar from './classic.sidebar.vue'; import XCommon from './_common_/common.vue'; -import { instanceName } from '@@/js/config.js'; -import { StickySidebar } from '@/scripts/sticky-sidebar.js'; +import type { PageMetadata } from '@/page.js'; +import { StickySidebar } from '@/utility/sticky-sidebar.js'; import * as os from '@/os.js'; -import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; -import { defaultStore } from '@/store.js'; +import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; +import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; import { miLocalStorage } from '@/local-storage.js'; -import { mainRouter } from '@/router/main.js'; -import { isLink } from '@@/js/is-link.js'; +import { mainRouter } from '@/router.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; const XHeaderMenu = defineAsyncComponent(() => import('./classic.header.vue')); const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); @@ -73,20 +76,20 @@ const widgetsShowing = ref(false); const fullView = ref(false); const globalHeaderHeight = ref(0); const wallpaper = miLocalStorage.getItem('wallpaper') != null; -const showMenuOnTop = computed(() => defaultStore.state.menuDisplay === 'top'); -const live2d = shallowRef<HTMLIFrameElement>(); +const showMenuOnTop = computed(() => store.s.menuDisplay === 'top'); +const live2d = useTemplateRef('live2d'); const widgetsLeft = ref<HTMLElement>(); const widgetsRight = ref<HTMLElement>(); -provide('router', mainRouter); +provide(DI.router, mainRouter); provideMetadataReceiver((metadataGetter) => { const info = metadataGetter(); pageMetadata.value = info; if (pageMetadata.value) { if (isRoot.value && pageMetadata.value.title === instanceName) { - document.title = pageMetadata.value.title; + window.document.title = pageMetadata.value.title; } else { - document.title = `${pageMetadata.value.title} | ${instanceName}`; + window.document.title = `${pageMetadata.value.title} | ${instanceName}`; } } }); @@ -95,7 +98,7 @@ provide('shouldHeaderThin', showMenuOnTop.value); provide('forceSpacerMin', true); function attachSticky(el: HTMLElement) { - const sticky = new StickySidebar(el, 0, defaultStore.state.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す + const sticky = new StickySidebar(el, 0, store.s.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す window.addEventListener('scroll', () => { sticky.calc(window.scrollY); }, { passive: true }); @@ -109,7 +112,7 @@ function onContextmenu(ev: MouseEvent) { if (isLink(ev.target)) return; if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return; if (window.getSelection().toString() !== '') return; - const path = mainRouter.getCurrentPath(); + const path = mainRouter.getCurrentFullPath(); os.contextMenu([{ type: 'label', text: path, @@ -136,32 +139,17 @@ if (window.innerWidth < 1024) { const currentUI = miLocalStorage.getItem('ui'); miLocalStorage.setItem('ui_temp', currentUI ?? 'default'); miLocalStorage.setItem('ui', 'default'); - location.reload(); + window.location.reload(); } -document.documentElement.style.overflowY = 'scroll'; - -defaultStore.loaded.then(() => { - if (defaultStore.state.widgets.length === 0) { - defaultStore.set('widgets', [{ - name: 'calendar', - id: 'a', place: null, data: {}, - }, { - name: 'notifications', - id: 'b', place: null, data: {}, - }, { - name: 'trends', - id: 'c', place: null, data: {}, - }]); - } -}); +window.document.documentElement.style.overflowY = 'scroll'; onMounted(() => { window.addEventListener('resize', () => { isDesktop.value = (window.innerWidth >= DESKTOP_THRESHOLD); }, { passive: true }); - if (defaultStore.state.aiChanMode) { + if (prefer.s.aiChanMode) { const iframeRect = live2d.value.getBoundingClientRect(); window.addEventListener('mousemove', ev => { live2d.value.contentWindow.postMessage({ diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index 36caca5fc0..18094b4444 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.main"> <XAnnouncements v-if="$i"/> <XStatusBars/> - <div ref="columnsEl" :class="[$style.sections, { [$style.center]: deckStore.reactiveState.columnAlign.value === 'center', [$style.snapScroll]: snapScroll }]" @contextmenu.self.prevent="onContextmenu" @wheel.self="onWheel"> + <div ref="columnsEl" :class="[$style.sections, { [$style.center]: prefer.r['deck.columnAlign'].value === 'center', [$style.snapScroll]: snapScroll }]" @contextmenu.self.prevent="onContextmenu" @wheel.self="onWheel"> <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> <section v-for="ids in layout" @@ -41,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="$style.sideMenu"> <div :class="$style.sideMenuTop"> - <button v-tooltip.noDelay.left="`${i18n.ts._deck.profile}: ${deckStore.state.profile}`" :class="$style.sideMenuButton" class="_button" @click="changeProfile"><i class="ti ti-caret-down"></i></button> + <button v-tooltip.noDelay.left="`${i18n.ts._deck.profile}: ${prefer.s['deck.profile']}`" :class="$style.sideMenuButton" class="_button" @click="switchProfileMenu"><i class="ti ti-caret-down"></i></button> <button v-tooltip.noDelay.left="i18n.ts._deck.deleteProfile" :class="$style.sideMenuButton" class="_button" @click="deleteProfile"><i class="ti ti-trash"></i></button> </div> <div :class="$style.sideMenuMiddle"> @@ -67,10 +67,10 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_menuDrawerBg_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_menuDrawerBg_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_menuDrawerBg_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_menuDrawerBg_leaveTo : ''" > <div v-if="drawerMenuShowing" @@ -82,10 +82,10 @@ SPDX-License-Identifier: AGPL-3.0-only </Transition> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_menuDrawer_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_menuDrawer_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_menuDrawer_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_menuDrawer_leaveTo : ''" > <div v-if="drawerMenuShowing" :class="$style.menu"> <XDrawerMenu/> @@ -97,22 +97,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, ref, watch, shallowRef } from 'vue'; +import { computed, defineAsyncComponent, ref, useTemplateRef } from 'vue'; import { v4 as uuid } from 'uuid'; import XCommon from './_common_/common.vue'; -import { deckStore, columnTypes, addColumn as addColumnToStore, forceSaveDeck, loadDeck, getProfiles, deleteProfile as deleteProfile_ } from './deck/deck-store.js'; -import type { ColumnType } from './deck/deck-store.js'; -import type { MenuItem } from '@/types/menu.js'; import XSidebar from '@/ui/_common_/navbar.vue'; import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { navbarItemDef } from '@/navbar.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; -import { deviceKind } from '@/scripts/device-kind.js'; -import { defaultStore } from '@/store.js'; +import { deviceKind } from '@/utility/device-kind.js'; +import { prefer } from '@/preferences.js'; import XMainColumn from '@/ui/deck/main-column.vue'; import XTlColumn from '@/ui/deck/tl-column.vue'; import XAntennaColumn from '@/ui/deck/antenna-column.vue'; @@ -124,7 +120,8 @@ import XMentionsColumn from '@/ui/deck/mentions-column.vue'; import XDirectColumn from '@/ui/deck/direct-column.vue'; import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue'; import XFollowingColumn from '@/ui/deck/following-column.vue'; -import { mainRouter } from '@/router/main.js'; +import { mainRouter } from '@/router.js'; +import { columns, layout, columnTypes, switchProfileMenu, addColumn as addColumnToStore, deleteProfile as deleteProfile_ } from '@/deck.js'; const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); @@ -144,8 +141,8 @@ const columnComponents = { mainRouter.navHook = (path, flag): boolean => { if (flag === 'forcePage') return false; - const noMainColumn = !deckStore.state.columns.some(x => x.type === 'main'); - if (deckStore.state.navWindow || noMainColumn) { + const noMainColumn = !columns.value.some(x => x.type === 'main'); + if (prefer.s['deck.navWindow'] || noMainColumn) { os.pageWindow(path); return true; } @@ -167,8 +164,6 @@ watch(route, () => { }); */ -const columns = deckStore.reactiveState.columns; -const layout = deckStore.reactiveState.layout; const menuIndicated = computed(() => { if ($i == null) return false; for (const def in navbarItemDef) { @@ -181,7 +176,7 @@ function showSettings() { os.pageWindow('/settings/deck'); } -const columnsEl = shallowRef<HTMLElement>(); +const columnsEl = useTemplateRef('columnsEl'); const addColumn = async (ev) => { const { canceled, result: column } = await os.select({ @@ -195,7 +190,7 @@ const addColumn = async (ev) => { addColumnToStore({ type: column, id: uuid(), - name: i18n.ts._deck._columns[column], + name: null, width: 330, soundSetting: { type: null, volume: 1 }, }); @@ -214,68 +209,23 @@ function onWheel(ev: WheelEvent) { } } -document.documentElement.style.overflowY = 'hidden'; -document.documentElement.style.scrollBehavior = 'auto'; - -loadDeck(); - -function changeProfile(ev: MouseEvent) { - let items: MenuItem[] = [{ - text: deckStore.state.profile, - active: true, - action: () => {}, - }]; - getProfiles().then(profiles => { - items.push(...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({ - text: k, - action: () => { - deckStore.set('profile', k); - unisonReload(); - }, - }))), { type: 'divider' as const }, { - text: i18n.ts._deck.newProfile, - icon: 'ti ti-plus', - action: async () => { - const { canceled, result: name } = await os.inputText({ - title: i18n.ts._deck.profile, - minLength: 1, - }); - - if (canceled || name == null) return; - - os.promiseDialog((async () => { - await deckStore.set('profile', name); - await forceSaveDeck(); - })(), () => { - unisonReload(); - }); - }, - }); - }).then(() => { - os.popupMenu(items, ev.currentTarget ?? ev.target); - }); -} +window.document.documentElement.style.overflowY = 'hidden'; +window.document.documentElement.style.scrollBehavior = 'auto'; async function deleteProfile() { + if (prefer.s['deck.profile'] == null) return; + const { canceled } = await os.confirm({ type: 'warning', - text: i18n.tsx.deleteAreYouSure({ x: deckStore.state.profile }), + text: i18n.tsx.deleteAreYouSure({ x: prefer.s['deck.profile'] }), }); if (canceled) return; - os.promiseDialog((async () => { - if (deckStore.state.profile === 'default') { - await deckStore.set('columns', []); - await deckStore.set('layout', []); - await forceSaveDeck(); - } else { - await deleteProfile_(deckStore.state.profile); - } - await deckStore.set('profile', 'default'); - })(), () => { - unisonReload(); - }); + await deleteProfile_(prefer.s['deck.profile']); + + os.success(); } + </script> <style> diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue index a41639e71c..194b56c842 100644 --- a/packages/frontend/src/ui/deck/antenna-column.vue +++ b/packages/frontend/src/ui/deck/antenna-column.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }"> <template #header> - <i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name }}</span> + <i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name || antennaName || i18n.ts._deck._columns.antenna }}</span> </template> <MkTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @note="onNote"/> @@ -14,27 +14,29 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, shallowRef, watch, defineAsyncComponent } from 'vue'; -import type { entities as MisskeyEntities } from 'misskey-js'; +import { onMounted, ref, useTemplateRef, watch, defineAsyncComponent } from 'vue'; import XColumn from './column.vue'; -import { updateColumn, Column } from './deck-store.js'; +import type { entities as MisskeyEntities } from 'misskey-js'; +import type { Column } from '@/deck.js'; +import type { MenuItem } from '@/types/menu.js'; +import type { SoundStore } from '@/preferences/def.js'; +import { updateColumn } from '@/deck.js'; import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import type { MenuItem } from '@/types/menu.js'; import { antennasCache } from '@/cache.js'; -import { SoundStore } from '@/store.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/scripts/sound.js'; +import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; isStacked: boolean; }>(); -const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); +const timeline = useTemplateRef('timeline'); const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); +const antennaName = ref<string | null>(null); onMounted(() => { if (props.column.antennaId == null) { @@ -42,6 +44,13 @@ onMounted(() => { } }); +watch([() => props.column.name, () => props.column.antennaId], () => { + if (!props.column.name && props.column.antennaId) { + misskeyApi('antennas/show', { antennaId: props.column.antennaId }) + .then(value => antennaName.value = value.name); + } +}); + watch(soundSetting, v => { updateColumn(props.column.id, { soundSetting: v }); }); diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue index 661d45b110..39376108d4 100644 --- a/packages/frontend/src/ui/deck/channel-column.vue +++ b/packages/frontend/src/ui/deck/channel-column.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }"> <template #header> - <i class="ti ti-device-tv"></i><span style="margin-left: 8px;">{{ column.name }}</span> + <i class="ti ti-device-tv"></i><span style="margin-left: 8px;">{{ column.name || channel?.name || i18n.ts._deck._columns.channel }}</span> </template> <template v-if="column.channelId"> @@ -19,27 +19,28 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, watch } from 'vue'; +import { onMounted, ref, shallowRef, watch, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import XColumn from './column.vue'; -import { updateColumn, Column } from './deck-store.js'; +import type { Column } from '@/deck.js'; +import type { MenuItem } from '@/types/menu.js'; +import type { SoundStore } from '@/preferences/def.js'; +import { updateColumn } from '@/deck.js'; import MkTimeline from '@/components/MkTimeline.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { favoritedChannelsCache } from '@/cache.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import type { MenuItem } from '@/types/menu.js'; -import { SoundStore } from '@/store.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/scripts/sound.js'; +import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; isStacked: boolean; }>(); -const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); +const timeline = useTemplateRef('timeline'); const channel = shallowRef<Misskey.entities.Channel>(); const withRenotes = ref(props.column.withRenotes ?? true); const onlyFiles = ref(props.column.onlyFiles ?? false); @@ -58,9 +59,18 @@ watch(onlyFiles, v => { const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); -if (props.column.channelId == null) { - setChannel(); -} +onMounted(() => { + if (props.column.channelId == null) { + setChannel(); + } +}); + +watch([() => props.column.name, () => props.column.channelId], () => { + if (!props.column.name && props.column.channelId) { + misskeyApi('channels/show', { channelId: props.column.channelId }) + .then(value => channel.value = value); + } +}); watch(soundSetting, v => { updateColumn(props.column.id, { soundSetting: v }); diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue index 2eb232096e..febf8ca6f8 100644 --- a/packages/frontend/src/ui/deck/column.vue +++ b/packages/frontend/src/ui/deck/column.vue @@ -42,11 +42,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onBeforeUnmount, onMounted, provide, watch, shallowRef, ref, computed } from 'vue'; -import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn, Column } from './deck-store.js'; +import { onBeforeUnmount, onMounted, provide, watch, useTemplateRef, ref, computed } from 'vue'; +import type { Column } from '@/deck.js'; +import type { MenuItem } from '@/types/menu.js'; +import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn } from '@/deck.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import type { MenuItem } from '@/types/menu.js'; provide('shouldHeaderThin', true); provide('shouldOmitHeaderTitle', true); @@ -67,7 +68,7 @@ const emit = defineEmits<{ (ev: 'headerWheel', ctx: WheelEvent): void; }>(); -const body = shallowRef<HTMLDivElement | null>(); +const body = useTemplateRef('body'); const dragging = ref(false); watch(dragging, v => os.deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd')); @@ -99,7 +100,7 @@ function onOtherDragEnd() { function toggleActive() { if (!props.isStacked) return; updateColumn(props.column.id, { - active: !props.column.active, + active: props.column.active == null ? false : !props.column.active, }); } @@ -128,7 +129,8 @@ function getMenu() { icon: 'ti ti-settings', text: i18n.ts._deck.configureColumn, action: async () => { - const { canceled, result } = await os.form(props.column.name, { + const name = props.column.name ?? i18n.ts._deck._columns[props.column.type]; + const { canceled, result } = await os.form(name, { name: { type: 'string', label: i18n.ts.name, @@ -143,7 +145,7 @@ function getMenu() { flexible: { type: 'boolean', label: i18n.ts._deck.flexible, - default: props.column.flexible, + default: props.column.flexible ?? null, }, }); if (canceled) return; @@ -356,7 +358,6 @@ function onDrop(ev) { > .body { background: var(--MI_THEME-bg) !important; - overflow-y: scroll !important; scrollbar-color: var(--MI_THEME-scrollbarHandle) transparent; &::-webkit-scrollbar-track { diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts index 8e5b1dd1ac..c58b5d7aad 100644 --- a/packages/frontend/src/ui/deck/deck-store.ts +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -3,59 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { throttle } from 'throttle-debounce'; -import { computed, markRaw, Ref } from 'vue'; -import { notificationTypes } from 'misskey-js'; -import type { BasicTimelineType } from '@/timelines.js'; -import { Storage } from '@/pizzax.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { deepClone } from '@/scripts/clone.js'; -import { SoundStore } from '@/store.js'; +import { markRaw } from 'vue'; +import type { Column } from '@/deck.js'; +import { Pizzax } from '@/lib/pizzax.js'; -type ColumnWidget = { - name: string; - id: string; - data: Record<string, any>; -}; - -export const columnTypes = [ - 'main', - 'widgets', - 'notifications', - 'tl', - 'antenna', - 'list', - 'channel', - 'mentions', - 'direct', - 'roleTimeline', - 'following', -] as const; - -export type ColumnType = typeof columnTypes[number]; - -export type Column = { - id: string; - type: ColumnType; - name: string | null; - width: number; - widgets?: ColumnWidget[]; - active?: boolean; - flexible?: boolean; - antennaId?: string; - listId?: string; - channelId?: string; - roleId?: string; - excludeTypes?: typeof notificationTypes[number][]; - tl?: BasicTimelineType; - withRenotes?: boolean; - withReplies?: boolean; - withSensitive?: boolean; - onlyFiles?: boolean; - soundSetting: SoundStore; -}; - -export const deckStore = markRaw(new Storage('deck', { +// TODO: 消す(移行済みのため) +export const deckStore = markRaw(new Pizzax('deck', { profile: { where: 'deviceAccount', default: 'default', @@ -68,278 +21,4 @@ export const deckStore = markRaw(new Storage('deck', { where: 'deviceAccount', default: [] as Column['id'][][], }, - columnAlign: { - where: 'deviceAccount', - default: 'left' as 'left' | 'right' | 'center', - }, - alwaysShowMainColumn: { - where: 'deviceAccount', - default: true, - }, - navWindow: { - where: 'deviceAccount', - default: true, - }, - useSimpleUiForNonRootPages: { - where: 'deviceAccount', - default: true, - }, })); - -export const loadDeck = async () => { - let deck; - - try { - deck = await misskeyApi('i/registry/get', { - scope: ['client', 'deck', 'profiles'], - key: deckStore.state.profile, - }); - } catch (err) { - if (err.code === 'NO_SUCH_KEY') { - // 後方互換性のため - if (deckStore.state.profile === 'default') { - saveDeck(); - return; - } - - deckStore.set('columns', []); - deckStore.set('layout', []); - return; - } - throw err; - } - - deckStore.set('columns', deck.columns); - deckStore.set('layout', deck.layout); -}; - -export async function forceSaveDeck() { - await misskeyApi('i/registry/set', { - scope: ['client', 'deck', 'profiles'], - key: deckStore.state.profile, - value: { - columns: deckStore.reactiveState.columns.value, - layout: deckStore.reactiveState.layout.value, - }, - }); -} - -// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する -export const saveDeck = throttle(1000, () => { - forceSaveDeck(); -}); - -export async function getProfiles(): Promise<string[]> { - return await misskeyApi('i/registry/keys', { - scope: ['client', 'deck', 'profiles'], - }); -} - -export async function deleteProfile(key: string): Promise<void> { - return await misskeyApi('i/registry/remove', { - scope: ['client', 'deck', 'profiles'], - key: key, - }); -} - -export function addColumn(column: Column) { - if (column.name === undefined) column.name = null; - deckStore.push('columns', column); - deckStore.push('layout', [column.id]); - saveDeck(); -} - -export function removeColumn(id: Column['id']) { - deckStore.set('columns', deckStore.state.columns.filter(c => c.id !== id)); - deckStore.set('layout', deckStore.state.layout - .map(ids => ids.filter(_id => _id !== id)) - .filter(ids => ids.length > 0)); - saveDeck(); -} - -export function swapColumn(a: Column['id'], b: Column['id']) { - const aX = deckStore.state.layout.findIndex(ids => ids.indexOf(a) !== -1); - const aY = deckStore.state.layout[aX].findIndex(id => id === a); - const bX = deckStore.state.layout.findIndex(ids => ids.indexOf(b) !== -1); - const bY = deckStore.state.layout[bX].findIndex(id => id === b); - const layout = deepClone(deckStore.state.layout); - layout[aX][aY] = b; - layout[bX][bY] = a; - deckStore.set('layout', layout); - saveDeck(); -} - -export function swapLeftColumn(id: Column['id']) { - const layout = deepClone(deckStore.state.layout); - deckStore.state.layout.some((ids, i) => { - if (ids.includes(id)) { - const left = deckStore.state.layout[i - 1]; - if (left) { - layout[i - 1] = deckStore.state.layout[i]; - layout[i] = left; - deckStore.set('layout', layout); - } - return true; - } - }); - saveDeck(); -} - -export function swapRightColumn(id: Column['id']) { - const layout = deepClone(deckStore.state.layout); - deckStore.state.layout.some((ids, i) => { - if (ids.includes(id)) { - const right = deckStore.state.layout[i + 1]; - if (right) { - layout[i + 1] = deckStore.state.layout[i]; - layout[i] = right; - deckStore.set('layout', layout); - } - return true; - } - }); - saveDeck(); -} - -export function swapUpColumn(id: Column['id']) { - const layout = deepClone(deckStore.state.layout); - const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id)); - const ids = deepClone(deckStore.state.layout[idsIndex]); - ids.some((x, i) => { - if (x === id) { - const up = ids[i - 1]; - if (up) { - ids[i - 1] = id; - ids[i] = up; - - layout[idsIndex] = ids; - deckStore.set('layout', layout); - } - return true; - } - }); - saveDeck(); -} - -export function swapDownColumn(id: Column['id']) { - const layout = deepClone(deckStore.state.layout); - const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id)); - const ids = deepClone(deckStore.state.layout[idsIndex]); - ids.some((x, i) => { - if (x === id) { - const down = ids[i + 1]; - if (down) { - ids[i + 1] = id; - ids[i] = down; - - layout[idsIndex] = ids; - deckStore.set('layout', layout); - } - return true; - } - }); - saveDeck(); -} - -export function stackLeftColumn(id: Column['id']) { - let layout = deepClone(deckStore.state.layout); - const i = deckStore.state.layout.findIndex(ids => ids.includes(id)); - layout = layout.map(ids => ids.filter(_id => _id !== id)); - layout[i - 1].push(id); - layout = layout.filter(ids => ids.length > 0); - deckStore.set('layout', layout); - saveDeck(); -} - -export function popRightColumn(id: Column['id']) { - let layout = deepClone(deckStore.state.layout); - const i = deckStore.state.layout.findIndex(ids => ids.includes(id)); - const affected = layout[i]; - layout = layout.map(ids => ids.filter(_id => _id !== id)); - layout.splice(i + 1, 0, [id]); - layout = layout.filter(ids => ids.length > 0); - deckStore.set('layout', layout); - - const columns = deepClone(deckStore.state.columns); - for (const column of columns) { - if (affected.includes(column.id)) { - column.active = true; - } - } - deckStore.set('columns', columns); - - saveDeck(); -} - -export function addColumnWidget(id: Column['id'], widget: ColumnWidget) { - const columns = deepClone(deckStore.state.columns); - const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const column = deepClone(deckStore.state.columns[columnIndex]); - if (column == null) return; - if (column.widgets == null) column.widgets = []; - column.widgets.unshift(widget); - columns[columnIndex] = column; - deckStore.set('columns', columns); - saveDeck(); -} - -export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) { - const columns = deepClone(deckStore.state.columns); - const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const column = deepClone(deckStore.state.columns[columnIndex]); - if (column == null || column.widgets == null) return; - column.widgets = column.widgets.filter(w => w.id !== widget.id); - columns[columnIndex] = column; - deckStore.set('columns', columns); - saveDeck(); -} - -export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) { - const columns = deepClone(deckStore.state.columns); - const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const column = deepClone(deckStore.state.columns[columnIndex]); - if (column == null) return; - column.widgets = widgets; - columns[columnIndex] = column; - deckStore.set('columns', columns); - saveDeck(); -} - -export function updateColumnWidget(id: Column['id'], widgetId: string, widgetData: any) { - const columns = deepClone(deckStore.state.columns); - const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const column = deepClone(deckStore.state.columns[columnIndex]); - if (column == null || column.widgets == null) return; - column.widgets = column.widgets.map(w => w.id === widgetId ? { - ...w, - data: widgetData, - } : w); - columns[columnIndex] = column; - deckStore.set('columns', columns); - saveDeck(); -} - -export async function updateColumn<TColumn>(id: Column['id'], column: Partial<TColumn>) { - const columns = deepClone(deckStore.state.columns); - const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const currentColumn = deepClone(deckStore.state.columns[columnIndex]); - if (currentColumn == null) return; - for (const [k, v] of Object.entries(column)) { - currentColumn[k] = v; - } - columns[columnIndex] = currentColumn; - await Promise.all([ - deckStore.set('columns', columns), - saveDeck(), - ]); -} - -export function getColumn<TColumn extends Column>(id: Column['id']): TColumn { - return deckStore.state.columns.find(c => c.id === id) as TColumn; -} - -export function getReactiveColumn<TColumn extends Column>(id: Column['id']): Ref<TColumn> { - return computed(() => { - return deckStore.reactiveState.columns.value.find(c => c.id === id) as TColumn; - }); -} diff --git a/packages/frontend/src/ui/deck/direct-column.vue b/packages/frontend/src/ui/deck/direct-column.vue index d12a18f760..772188d773 100644 --- a/packages/frontend/src/ui/deck/direct-column.vue +++ b/packages/frontend/src/ui/deck/direct-column.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <XColumn :column="column" :isStacked="isStacked" :refresher="() => reloadTimeline()"> - <template #header><i class="ti ti-mail" style="margin-right: 8px;"></i>{{ column.name }}</template> + <template #header><i class="ti ti-mail" style="margin-right: 8px;"></i>{{ column.name || i18n.ts._deck._columns.direct }}</template> <MkNotes ref="tlComponent" :pagination="pagination"/> </XColumn> @@ -14,8 +14,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import XColumn from './column.vue'; -import { Column } from './deck-store.js'; +import type { Column } from '@/deck.js'; import MkNotes from '@/components/MkNotes.vue'; +import { i18n } from '@/i18n.js'; defineProps<{ column: Column; diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue index 8762fb0cce..77c1ea80a7 100644 --- a/packages/frontend/src/ui/deck/list-column.vue +++ b/packages/frontend/src/ui/deck/list-column.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }"> <template #header> - <i class="ti ti-list"></i><span style="margin-left: 8px;">{{ column.name }}</span> + <i class="ti ti-list"></i><span style="margin-left: 8px;">{{ (column.name || listName) ?? i18n.ts._deck._columns.list }}</span> </template> <MkTimeline v-if="column.listId" ref="timeline" :key="column.listId + column.withRenotes + column.onlyFiles" src="list" :list="column.listId" :withRenotes="withRenotes" :onlyFiles="onlyFiles" @note="onNote"/> @@ -14,33 +14,44 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { watch, shallowRef, ref } from 'vue'; -import type { entities as MisskeyEntities } from 'misskey-js'; +import { watch, useTemplateRef, ref, onMounted } from 'vue'; import XColumn from './column.vue'; -import { updateColumn, Column } from './deck-store.js'; +import type { entities as MisskeyEntities } from 'misskey-js'; +import type { Column } from '@/deck.js'; +import type { MenuItem } from '@/types/menu.js'; +import type { SoundStore } from '@/preferences/def.js'; +import { updateColumn } from '@/deck.js'; import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import type { MenuItem } from '@/types/menu.js'; -import { SoundStore } from '@/store.js'; import { userListsCache } from '@/cache.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/scripts/sound.js'; +import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; isStacked: boolean; }>(); -const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); +const timeline = useTemplateRef('timeline'); const withRenotes = ref(props.column.withRenotes ?? true); const onlyFiles = ref(props.column.onlyFiles ?? false); const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); +const listName = ref<string | null>(null); -if (props.column.listId == null) { - setList(); -} +onMounted(() => { + if (props.column.listId == null) { + setList(); + } +}); + +watch([() => props.column.name, () => props.column.listId], () => { + if (!props.column.name && props.column.listId) { + misskeyApi('users/lists/show', { listId: props.column.listId }) + .then(value => listName.value = value.name); + } +}); watch(withRenotes, v => { updateColumn(props.column.id, { diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue index f8c712c371..78454d2e49 100644 --- a/packages/frontend/src/ui/deck/main-column.vue +++ b/packages/frontend/src/ui/deck/main-column.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<XColumn v-if="deckStore.state.alwaysShowMainColumn || mainRouter.currentRoute.value.name !== 'index'" :column="column" :isStacked="isStacked"> +<XColumn v-if="prefer.s['deck.alwaysShowMainColumn'] || mainRouter.currentRoute.value.name !== 'index'" :column="column" :isStacked="isStacked"> <template #header> <template v-if="pageMetadata"> <i :class="pageMetadata.icon"></i> @@ -12,33 +12,34 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </template> - <div ref="contents"> - <RouterView @contextmenu.stop="onContextmenu"/> + <div style="height: 100%;"> + <StackingRouterView v-if="prefer.s['experimental.stackingRouterView']" @contextmenu.stop="onContextmenu"/> + <RouterView v-else @contextmenu.stop="onContextmenu"/> </div> </XColumn> </template> <script lang="ts" setup> import { provide, shallowRef, ref } from 'vue'; +import { isLink } from '@@/js/is-link.js'; import XColumn from './column.vue'; -import { deckStore, Column } from '@/ui/deck/deck-store.js'; +import type { Column } from '@/deck.js'; +import type { PageMetadata } from '@/page.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; -import { useScrollPositionManager } from '@/nirax.js'; -import { getScrollContainer } from '@@/js/scroll.js'; -import { isLink } from '@@/js/is-link.js'; -import { mainRouter } from '@/router/main.js'; +import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; +import { mainRouter } from '@/router.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; defineProps<{ column: Column; isStacked: boolean; }>(); -const contents = shallowRef<HTMLElement>(); const pageMetadata = ref<null | PageMetadata>(null); -provide('router', mainRouter); +provide(DI.router, mainRouter); provideMetadataReceiver((metadataGetter) => { const info = metadataGetter(); pageMetadata.value = info; @@ -68,6 +69,4 @@ function onContextmenu(ev: MouseEvent) { }, }], ev); } - -useScrollPositionManager(() => getScrollContainer(contents.value), mainRouter); </script> diff --git a/packages/frontend/src/ui/deck/mentions-column.vue b/packages/frontend/src/ui/deck/mentions-column.vue index 7b25a55ec3..ffd0307940 100644 --- a/packages/frontend/src/ui/deck/mentions-column.vue +++ b/packages/frontend/src/ui/deck/mentions-column.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <XColumn :column="column" :isStacked="isStacked" :refresher="() => reloadTimeline()"> - <template #header><i class="ti ti-at" style="margin-right: 8px;"></i>{{ column.name }}</template> + <template #header><i class="ti ti-at" style="margin-right: 8px;"></i>{{ column.name || i18n.ts._deck._columns.mentions }}</template> <MkNotes ref="tlComponent" :pagination="pagination"/> </XColumn> @@ -14,8 +14,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import XColumn from './column.vue'; -import { Column } from './deck-store.js'; +import type { Column } from '@/deck.js'; import MkNotes from '@/components/MkNotes.vue'; +import { i18n } from '../../i18n.js'; defineProps<{ column: Column; diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue index 19ccfc1f7c..8378dddfef 100644 --- a/packages/frontend/src/ui/deck/notifications-column.vue +++ b/packages/frontend/src/ui/deck/notifications-column.vue @@ -5,16 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <XColumn :column="column" :isStacked="isStacked" :menu="menu" :refresher="async () => { await notificationsComponent?.reload() }"> - <template #header><i class="ti ti-bell" style="margin-right: 8px;"></i>{{ column.name }}</template> + <template #header><i class="ti ti-bell" style="margin-right: 8px;"></i>{{ column.name || i18n.ts._deck._columns.notifications }}</template> <XNotifications ref="notificationsComponent" :excludeTypes="props.column.excludeTypes"/> </XColumn> </template> <script lang="ts" setup> -import { defineAsyncComponent, shallowRef } from 'vue'; +import { defineAsyncComponent, useTemplateRef } from 'vue'; import XColumn from './column.vue'; -import { updateColumn, Column } from './deck-store.js'; +import type { Column } from '@/deck.js'; +import { updateColumn } from '@/deck.js'; import XNotifications from '@/components/MkNotifications.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; @@ -24,7 +25,7 @@ const props = defineProps<{ isStacked: boolean; }>(); -const notificationsComponent = shallowRef<InstanceType<typeof XNotifications>>(); +const notificationsComponent = useTemplateRef('notificationsComponent'); function func() { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSelectWindow.vue')), { diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue index beb4237978..468b3e49e0 100644 --- a/packages/frontend/src/ui/deck/role-timeline-column.vue +++ b/packages/frontend/src/ui/deck/role-timeline-column.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }"> <template #header> - <i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name }}</span> + <i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name || roleName || i18n.ts._deck._columns.roleTimeline }}</span> </template> <MkTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId" @note="onNote"/> @@ -14,25 +14,27 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, shallowRef, watch } from 'vue'; +import { onMounted, ref, useTemplateRef, watch } from 'vue'; import XColumn from './column.vue'; -import { updateColumn, Column } from './deck-store.js'; +import type { Column } from '@/deck.js'; +import type { MenuItem } from '@/types/menu.js'; +import type { SoundStore } from '@/preferences/def.js'; +import { updateColumn } from '@/deck.js'; import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import type { MenuItem } from '@/types/menu.js'; -import { SoundStore } from '@/store.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/scripts/sound.js'; +import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; isStacked: boolean; }>(); -const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); +const timeline = useTemplateRef('timeline'); const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); +const roleName = ref<string | null>(null); onMounted(() => { if (props.column.roleId == null) { @@ -40,6 +42,13 @@ onMounted(() => { } }); +watch([() => props.column.name, () => props.column.roleId], () => { + if (!props.column.name && props.column.roleId) { + misskeyApi('roles/show', { roleId: props.column.roleId }) + .then(value => roleName.value = value.name); + } +}); + watch(soundSetting, v => { updateColumn(props.column.id, { soundSetting: v }); }); diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index 8f5553ccae..c3e68dadf0 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }"> <template #header> <i v-if="column.tl != null" :class="basicTimelineIconClass(column.tl)"/> - <span style="margin-left: 8px;">{{ column.name }}</span> + <span style="margin-left: 8px;">{{ column.name || (column.tl ? i18n.ts._timelines[column.tl] : null) || i18n.ts._deck._columns.tl }}</span> </template> <div v-if="!isAvailableBasicTimeline(column.tl)" :class="$style.disabled"> @@ -32,25 +32,25 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, watch, ref, shallowRef, computed } from 'vue'; +import { onMounted, watch, ref, useTemplateRef, computed } from 'vue'; import XColumn from './column.vue'; -import { removeColumn, updateColumn, Column } from './deck-store.js'; +import type { Column } from '@/deck.js'; import type { MenuItem } from '@/types/menu.js'; +import type { SoundStore } from '@/preferences/def.js'; +import { removeColumn, updateColumn } from '@/deck.js'; import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { hasWithReplies, isAvailableBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; -import { instance } from '@/instance.js'; -import { SoundStore } from '@/store.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/scripts/sound.js'; +import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; isStacked: boolean; }>(); -const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); +const timeline = useTemplateRef('timeline'); const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); const withRenotes = ref(props.column.withRenotes ?? true); diff --git a/packages/frontend/src/ui/deck/tl-note-notification.ts b/packages/frontend/src/ui/deck/tl-note-notification.ts index 275ea56ba0..728c0d0d29 100644 --- a/packages/frontend/src/ui/deck/tl-note-notification.ts +++ b/packages/frontend/src/ui/deck/tl-note-notification.ts @@ -4,9 +4,10 @@ */ import * as Misskey from 'misskey-js'; -import { Ref } from 'vue'; -import { SoundStore } from '@/store.js'; -import { getSoundDuration, playMisskeySfxFile, soundsTypes, SoundType } from '@/scripts/sound.js'; +import type { Ref } from 'vue'; +import type { SoundType } from '@/utility/sound.js'; +import type { SoundStore } from '@/preferences/def.js'; +import { getSoundDuration, playMisskeySfxFile, soundsTypes } from '@/utility/sound.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/ui/deck/widgets-column.vue b/packages/frontend/src/ui/deck/widgets-column.vue index a0e62c8264..4e84ef0ba0 100644 --- a/packages/frontend/src/ui/deck/widgets-column.vue +++ b/packages/frontend/src/ui/deck/widgets-column.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <XColumn :menu="menu" :naked="true" :column="column" :isStacked="isStacked"> - <template #header><i class="ti ti-apps" style="margin-right: 8px;"></i>{{ column.name }}</template> + <template #header><i class="ti ti-apps" style="margin-right: 8px;"></i>{{ column.name || i18n.ts._deck._columns[props.column.type] }}</template> <div :class="$style.root"> <div v-if="!(column.widgets && column.widgets.length > 0) && !edit" :class="$style.intro">{{ i18n.ts._deck.widgetsIntroduction }}</div> @@ -17,7 +17,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import XColumn from './column.vue'; -import { addColumnWidget, Column, removeColumnWidget, setColumnWidgets, updateColumnWidget } from './deck-store.js'; +import { addColumnWidget, removeColumnWidget, setColumnWidgets, updateColumnWidget } from '@/deck.js'; +import type { Column } from '@/deck.js'; import XWidgets from '@/components/MkWidgets.vue'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/ui/minimum.vue b/packages/frontend/src/ui/minimum.vue index 9e41c48c5b..ec20ac1114 100644 --- a/packages/frontend/src/ui/minimum.vue +++ b/packages/frontend/src/ui/minimum.vue @@ -5,9 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> - <div style="container-type: inline-size;"> - <RouterView/> - </div> + <RouterView/> <XCommon/> </div> @@ -15,35 +13,35 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, provide, ref } from 'vue'; -import XCommon from './_common_/common.vue'; -import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { instanceName } from '@@/js/config.js'; -import { mainRouter } from '@/router/main.js'; +import XCommon from './_common_/common.vue'; +import type { PageMetadata } from '@/page.js'; +import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; +import { mainRouter } from '@/router.js'; +import { DI } from '@/di.js'; const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index'); const pageMetadata = ref<null | PageMetadata>(null); -provide('router', mainRouter); +provide(DI.router, mainRouter); provideMetadataReceiver((metadataGetter) => { const info = metadataGetter(); pageMetadata.value = info; if (pageMetadata.value) { if (isRoot.value && pageMetadata.value.title === instanceName) { - document.title = pageMetadata.value.title; + window.document.title = pageMetadata.value.title; } else { - document.title = `${pageMetadata.value.title} | ${instanceName}`; + window.document.title = `${pageMetadata.value.title} | ${instanceName}`; } } }); provideReactiveMetadata(pageMetadata); - -document.documentElement.style.overflowY = 'scroll'; </script> <style lang="scss" module> .root { - min-height: 100dvh; - box-sizing: border-box; + position: relative; + height: 100dvh; } </style> diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index e8c71f61cf..6724c6f6c9 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -7,16 +7,29 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.root"> <XSidebar v-if="!isMobile" :class="$style.sidebar"/> - <MkStickyContainer ref="contents" :class="$style.contents" style="container-type: inline-size;" @contextmenu.stop="onContextmenu"> - <template #header> - <div> - <XAnnouncements v-if="$i"/> - <XStatusBars :class="$style.statusbars"/> - </div> - </template> - <RouterView/> - <div :class="$style.spacer"></div> - </MkStickyContainer> + <div :class="$style.contents" @contextmenu.stop="onContextmenu"> + <div> + <XPreferenceRestore v-if="shouldSuggestRestoreBackup"/> + <XAnnouncements v-if="$i"/> + <XStatusBars :class="$style.statusbars"/> + </div> + <div :class="$style.content"> + <StackingRouterView v-if="prefer.s['experimental.stackingRouterView']"/> + <RouterView v-else/> + </div> + <div v-if="isMobile" ref="navFooter" :class="$style.nav"> + <button :class="$style.navButton" class="_button" @click="drawerMenuShowing = true"><i :class="$style.navButtonIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated" :class="$style.navButtonIndicator" class="_blink"><i class="_indicatorCircle"></i></span></button> + <button :class="$style.navButton" class="_button" @click="mainRouter.push('/')"><i :class="$style.navButtonIcon" class="ti ti-home"></i></button> + <button :class="$style.navButton" class="_button" @click="mainRouter.push('/my/notifications')"> + <i :class="$style.navButtonIcon" class="ti ti-bell"></i> + <span v-if="$i?.hasUnreadNotification" :class="$style.navButtonIndicator" class="_blink"> + <span class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ $i.unreadNotificationsCount > 99 ? '99+' : $i.unreadNotificationsCount }}</span> + </span> + </button> + <button :class="$style.navButton" class="_button" @click="widgetsShowing = true"><i :class="$style.navButtonIcon" class="ti ti-apps"></i></button> + <button :class="$style.postButton" class="_button" @click="os.post()"><i :class="$style.navButtonIcon" class="ti ti-pencil"></i></button> + </div> + </div> <div v-if="isDesktop && !pageMetadata?.needWideArea" :class="$style.widgets"> <XWidgets/> @@ -24,24 +37,11 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="!isDesktop && !pageMetadata?.needWideArea && !isMobile" :class="$style.widgetButton" class="_button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button> - <div v-if="isMobile" ref="navFooter" :class="$style.nav"> - <button :class="$style.navButton" class="_button" @click="drawerMenuShowing = true"><i :class="$style.navButtonIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated" :class="$style.navButtonIndicator" class="_blink"><i class="_indicatorCircle"></i></span></button> - <button :class="$style.navButton" class="_button" @click="isRoot ? top() : mainRouter.push('/')"><i :class="$style.navButtonIcon" class="ti ti-home"></i></button> - <button :class="$style.navButton" class="_button" @click="mainRouter.push('/my/notifications')"> - <i :class="$style.navButtonIcon" class="ti ti-bell"></i> - <span v-if="$i?.hasUnreadNotification" :class="$style.navButtonIndicator" class="_blink"> - <span class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ $i.unreadNotificationsCount > 99 ? '99+' : $i.unreadNotificationsCount }}</span> - </span> - </button> - <button :class="$style.navButton" class="_button" @click="widgetsShowing = true"><i :class="$style.navButtonIcon" class="ti ti-apps"></i></button> - <button :class="$style.postButton" class="_button" @click="os.post()"><i :class="$style.navButtonIcon" class="ti ti-pencil"></i></button> - </div> - <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_menuDrawerBg_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_menuDrawerBg_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_menuDrawerBg_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_menuDrawerBg_leaveTo : ''" > <div v-if="drawerMenuShowing" @@ -53,10 +53,10 @@ SPDX-License-Identifier: AGPL-3.0-only </Transition> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_menuDrawer_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_menuDrawer_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_menuDrawer_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_menuDrawer_leaveTo : ''" > <div v-if="drawerMenuShowing" :class="$style.menuDrawer"> <XDrawerMenu/> @@ -64,10 +64,10 @@ SPDX-License-Identifier: AGPL-3.0-only </Transition> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_leaveTo : ''" > <div v-if="widgetsShowing" @@ -79,10 +79,10 @@ SPDX-License-Identifier: AGPL-3.0-only </Transition> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_widgetsDrawer_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_widgetsDrawer_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_widgetsDrawer_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_widgetsDrawer_leaveTo : ''" > <div v-if="widgetsShowing" :class="$style.widgetsDrawer"> <button class="_button" :class="$style.widgetsCloseButton" @click="widgetsShowing = false"><i class="ti ti-x"></i></button> @@ -95,28 +95,30 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, provide, onMounted, computed, ref, watch, shallowRef, Ref } from 'vue'; +import { defineAsyncComponent, provide, onMounted, computed, ref, watch, useTemplateRef } from 'vue'; import { instanceName } from '@@/js/config.js'; -import { CURRENT_STICKY_BOTTOM } from '@@/js/const.js'; import { isLink } from '@@/js/is-link.js'; import XCommon from './_common_/common.vue'; -import type MkStickyContainer from '@/components/global/MkStickyContainer.vue'; +import type { Ref } from 'vue'; +import type { PageMetadata } from '@/page.js'; import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; import * as os from '@/os.js'; -import { defaultStore } from '@/store.js'; import { navbarItemDef } from '@/navbar.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; -import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { $i } from '@/i.js'; +import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; +import { deviceKind } from '@/utility/device-kind.js'; import { miLocalStorage } from '@/local-storage.js'; -import { useScrollPositionManager } from '@/nirax.js'; -import { mainRouter } from '@/router/main.js'; +import { mainRouter } from '@/router.js'; +import { prefer } from '@/preferences.js'; +import { shouldSuggestRestoreBackup } from '@/preferences/utility.js'; +import { DI } from '@/di.js'; const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue')); const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); +const XPreferenceRestore = defineAsyncComponent(() => import('@/ui/_common_/PreferenceRestore.vue')); const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index'); @@ -132,18 +134,17 @@ window.addEventListener('resize', () => { const pageMetadata = ref<null | PageMetadata>(null); const widgetsShowing = ref(false); -const navFooter = shallowRef<HTMLElement>(); -const contents = shallowRef<InstanceType<typeof MkStickyContainer>>(); +const navFooter = useTemplateRef('navFooter'); -provide('router', mainRouter); +provide(DI.router, mainRouter); provideMetadataReceiver((metadataGetter) => { const info = metadataGetter(); pageMetadata.value = info; if (pageMetadata.value) { if (isRoot.value && pageMetadata.value.title === instanceName) { - document.title = pageMetadata.value.title; + window.document.title = pageMetadata.value.title; } else { - document.title = `${pageMetadata.value.title} | ${instanceName}`; + window.document.title = `${pageMetadata.value.title} | ${instanceName}`; } } }); @@ -168,25 +169,10 @@ if (window.innerWidth > 1024) { if (tempUI) { miLocalStorage.setItem('ui', tempUI); miLocalStorage.removeItem('ui_temp'); - location.reload(); + window.location.reload(); } } -defaultStore.loaded.then(() => { - if (defaultStore.state.widgets.length === 0) { - defaultStore.set('widgets', [{ - name: 'calendar', - id: 'a', place: 'right', data: {}, - }, { - name: 'notifications', - id: 'b', place: 'right', data: {}, - }, { - name: 'trends', - id: 'c', place: 'right', data: {}, - }]); - } -}); - onMounted(() => { if (!isDesktop.value) { window.addEventListener('resize', () => { @@ -199,7 +185,7 @@ const onContextmenu = (ev) => { if (isLink(ev.target)) return; if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return; if (window.getSelection()?.toString() !== '') return; - const path = mainRouter.getCurrentPath(); + const path = mainRouter.getCurrentFullPath(); os.contextMenu([{ type: 'label', text: path, @@ -212,31 +198,19 @@ const onContextmenu = (ev) => { }], ev); }; -function top() { - contents.value.rootEl.scrollTo({ - top: 0, - behavior: 'smooth', - }); -} - const navFooterHeight = ref(0); -provide<Ref<number>>(CURRENT_STICKY_BOTTOM, navFooterHeight); watch(navFooter, () => { if (navFooter.value) { navFooterHeight.value = navFooter.value.offsetHeight; - document.body.style.setProperty('--MI-stickyBottom', `${navFooterHeight.value}px`); - document.body.style.setProperty('--MI-minBottomSpacing', 'var(--MI-minBottomSpacingMobile)'); + window.document.body.style.setProperty('--MI-minBottomSpacing', 'var(--MI-minBottomSpacingMobile)'); } else { navFooterHeight.value = 0; - document.body.style.setProperty('--MI-stickyBottom', '0px'); - document.body.style.setProperty('--MI-minBottomSpacing', '0px'); + window.document.body.style.setProperty('--MI-minBottomSpacing', '0px'); } }, { immediate: true, }); - -useScrollPositionManager(() => contents.value.rootEl, mainRouter); </script> <style> @@ -322,87 +296,27 @@ $widgets-hide-threshold: 1090px; } .contents { + display: flex; + flex-direction: column; flex: 1; height: 100%; min-width: 0; - overflow: auto; - overflow-y: scroll; - overscroll-behavior: unset; background: var(--MI_THEME-bg); } -.widgets { - width: 350px; - height: 100%; - box-sizing: border-box; - overflow: auto; - padding: var(--MI-margin) var(--MI-margin) calc(var(--MI-margin) + env(safe-area-inset-bottom, 0px)); - border-left: solid 0.5px var(--MI_THEME-divider); - background: var(--MI_THEME-bg); - - @media (max-width: $widgets-hide-threshold) { - display: none; - } -} - -.widgetButton { - display: block; - position: fixed; - z-index: 1000; - bottom: 32px; - right: 32px; - width: 64px; - height: 64px; - border-radius: var(--MI-radius-full); - box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12); - font-size: 22px; - background: var(--MI_THEME-panel); -} - -.widgetsDrawerBg { - z-index: 1001; -} - -.widgetsDrawer { - position: fixed; - top: 0; - right: 0; - z-index: 1001; - width: 310px; - height: 100dvh; - padding: var(--MI-margin) var(--MI-margin) calc(var(--MI-margin) + env(safe-area-inset-bottom, 0px)) !important; - box-sizing: border-box; - overflow: auto; - overscroll-behavior: contain; - background: var(--MI_THEME-bg); -} - -.widgetsCloseButton { - padding: 8px; - display: block; - margin: 0 auto; -} - -@media (min-width: 370px) { - .widgetsCloseButton { - display: none; - } +.content { + flex: 1; + min-height: 0; } .nav { - position: fixed; - z-index: 1000; - bottom: 0; - left: 0; padding: 12px 12px max(12px, env(safe-area-inset-bottom, 0px)) 12px; display: grid; grid-template-columns: 1fr 1fr 1fr 1fr 1fr; grid-gap: 8px; width: 100%; box-sizing: border-box; - -webkit-backdrop-filter: var(--MI-blur, blur(24px)); - backdrop-filter: var(--MI-blur, blur(24px)); - background-color: var(--MI_THEME-header); + background: var(--MI_THEME-bg); border-top: solid 0.5px var(--MI_THEME-divider); } @@ -486,7 +400,61 @@ $widgets-hide-threshold: 1090px; left: 0; } -.spacer { - height: calc(var(--MI-minBottomSpacing)); +.widgets { + width: 350px; + height: 100%; + box-sizing: border-box; + overflow: auto; + padding: var(--MI-margin) var(--MI-margin) calc(var(--MI-margin) + env(safe-area-inset-bottom, 0px)); + border-left: solid 0.5px var(--MI_THEME-divider); + background: var(--MI_THEME-bg); + + @media (max-width: $widgets-hide-threshold) { + display: none; + } +} + +.widgetButton { + display: block; + position: fixed; + z-index: 1000; + bottom: 32px; + right: 32px; + width: 64px; + height: 64px; + border-radius: 100%; + box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12); + font-size: 22px; + background: var(--MI_THEME-panel); +} + +.widgetsDrawerBg { + z-index: 1001; +} + +.widgetsDrawer { + position: fixed; + top: 0; + right: 0; + z-index: 1001; + width: 310px; + height: 100dvh; + padding: var(--MI-margin) var(--MI-margin) calc(var(--MI-margin) + env(safe-area-inset-bottom, 0px)) !important; + box-sizing: border-box; + overflow: auto; + overscroll-behavior: contain; + background: var(--MI_THEME-bg); +} + +.widgetsCloseButton { + padding: 8px; + display: block; + margin: 0 auto; +} + +@media (min-width: 370px) { + .widgetsCloseButton { + display: none; + } } </style> diff --git a/packages/frontend/src/ui/universal.widgets.vue b/packages/frontend/src/ui/universal.widgets.vue index fc0a4475d2..1a6d62e19b 100644 --- a/packages/frontend/src/ui/universal.widgets.vue +++ b/packages/frontend/src/ui/universal.widgets.vue @@ -19,7 +19,7 @@ const editMode = ref(false); <script lang="ts" setup> import XWidgets from '@/components/MkWidgets.vue'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ // null = 全てのウィジェットを表示 @@ -31,24 +31,24 @@ const props = withDefaults(defineProps<{ }); const widgets = computed(() => { - if (props.place === null) return defaultStore.reactiveState.widgets.value; - if (props.place === 'left') return defaultStore.reactiveState.widgets.value.filter(w => w.place === 'left'); - return defaultStore.reactiveState.widgets.value.filter(w => w.place !== 'left'); + if (props.place === null) return prefer.r.widgets.value; + if (props.place === 'left') return prefer.r.widgets.value.filter(w => w.place === 'left'); + return prefer.r.widgets.value.filter(w => w.place !== 'left'); }); function addWidget(widget) { - defaultStore.set('widgets', [{ + prefer.commit('widgets', [{ ...widget, place: props.place, - }, ...defaultStore.state.widgets]); + }, ...prefer.s.widgets]); } function removeWidget(widget) { - defaultStore.set('widgets', defaultStore.state.widgets.filter(w => w.id !== widget.id)); + prefer.commit('widgets', prefer.s.widgets.filter(w => w.id !== widget.id)); } function updateWidget({ id, data }) { - defaultStore.set('widgets', defaultStore.state.widgets.map(w => w.id === id ? { + prefer.commit('widgets', prefer.s.widgets.map(w => w.id === id ? { ...w, data, place: props.place, @@ -57,18 +57,18 @@ function updateWidget({ id, data }) { function updateWidgets(thisWidgets) { if (props.place === null) { - defaultStore.set('widgets', thisWidgets); + prefer.commit('widgets', thisWidgets); return; } if (props.place === 'left') { - defaultStore.set('widgets', [ + prefer.commit('widgets', [ ...thisWidgets.map(w => ({ ...w, place: 'left' })), - ...defaultStore.state.widgets.filter(w => w.place !== 'left' && !thisWidgets.some(t => w.id === t.id)), + ...prefer.s.widgets.filter(w => w.place !== 'left' && !thisWidgets.some(t => w.id === t.id)), ]); return; } - defaultStore.set('widgets', [ - ...defaultStore.state.widgets.filter(w => w.place === 'left' && !thisWidgets.some(t => w.id === t.id)), + prefer.commit('widgets', [ + ...prefer.s.widgets.filter(w => w.place === 'left' && !thisWidgets.some(t => w.id === t.id)), ...thisWidgets.map(w => ({ ...w, place: 'right' })), ]); } diff --git a/packages/frontend/src/ui/visitor.vue b/packages/frontend/src/ui/visitor.vue index f048ac2124..8fef1fd997 100644 --- a/packages/frontend/src/ui/visitor.vue +++ b/packages/frontend/src/ui/visitor.vue @@ -4,81 +4,40 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="mk-app"> - <div v-if="!narrow && !isRoot" class="side"> - <div class="banner" :style="{ backgroundImage: instance.backgroundImageUrl ? `url(${ instance.backgroundImageUrl })` : 'none' }"></div> - <div class="dashboard"> +<div :class="$style.root"> + <a v-if="isRoot" href="https://github.com/misskey-dev/misskey" target="_blank" class="github-corner" aria-label="View source on GitHub"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:var(--MI_THEME-panel); color:var(--MI_THEME-fg); position: fixed; z-index: 10; top: 0; border: 0; right: 0;" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a> + + <div v-if="!narrow && !isRoot" :class="$style.side"> + <div :class="$style.banner" :style="{ backgroundImage: instance.backgroundImageUrl ? `url(${ instance.backgroundImageUrl })` : 'none' }"></div> + <div :class="$style.dashboard"> <MkVisitorDashboard/> </div> </div> - <div class="main"> - <div v-if="!isRoot" class="header"> - <div v-if="narrow === false" class="wide"> - <MkA to="/" class="link" activeClass="active"><i class="ti ti-home icon"></i> {{ i18n.ts.home }}</MkA> - <MkA v-if="isTimelineAvailable" to="/timeline" class="link" activeClass="active"><i class="ph-chat-text ph-bold ph-lg icon"></i> {{ i18n.ts.timeline }}</MkA> - <MkA to="/explore" class="link" activeClass="active"><i class="ti ti-hash icon"></i> {{ i18n.ts.explore }}</MkA> - <MkA to="/channels" class="link" activeClass="active"><i class="ti ti-device-tv icon"></i> {{ i18n.ts.channel }}</MkA> - </div> - <div v-else-if="narrow === true" class="narrow"> - <button class="menu _button" @click="showMenu = true"> - <i class="ti ti-menu-2 icon"></i> - </button> - </div> - </div> - <div class="contents"> - <main v-if="!isRoot" style="container-type: inline-size;"> - <RouterView/> - </main> - <main v-else> - <RouterView/> - </main> + <div :class="$style.main"> + <button v-if="!isRoot" :class="$style.homeButton" class="_button" @click="goHome"> + <i class="ti ti-home"></i> + </button> + <div :class="$style.content"> + <RouterView/> </div> </div> - - <Transition :name="'tray-back'"> - <div - v-if="showMenu" - class="menu-back _modalBg" - @click="showMenu = false" - @touchstart.passive="showMenu = false" - ></div> - </Transition> - - <Transition :name="'tray'"> - <div v-if="showMenu" class="menu"> - <MkA to="/" class="link" activeClass="active"><i class="ti ti-home icon"></i>{{ i18n.ts.home }}</MkA> - <MkA v-if="isTimelineAvailable" to="/timeline" class="link" activeClass="active"><i class="ph-chat-text ph-bold ph-lg icon"></i>{{ i18n.ts.timeline }}</MkA> - <MkA to="/explore" class="link" activeClass="active"><i class="ti ti-hash icon"></i>{{ i18n.ts.explore }}</MkA> - <MkA to="/announcements" class="link" activeClass="active"><i class="ti ti-speakerphone icon"></i>{{ i18n.ts.announcements }}</MkA> - <MkA to="/channels" class="link" activeClass="active"><i class="ti ti-device-tv icon"></i>{{ i18n.ts.channel }}</MkA> - <div class="divider"></div> - <MkA to="/pages" class="link" activeClass="active"><i class="ti ti-news icon"></i>{{ i18n.ts.pages }}</MkA> - <MkA to="/play" class="link" activeClass="active"><i class="ti ti-player-play icon"></i>Play</MkA> - <MkA to="/gallery" class="link" activeClass="active"><i class="ph-images-square ph-bold ph-lgs icon"></i>{{ i18n.ts.gallery }}</MkA> - <div class="action"> - <button class="_buttonPrimary" @click="signup()">{{ i18n.ts.signup }}</button> - <button class="_button" @click="signin()">{{ i18n.ts.login }}</button> - </div> - </div> - </Transition> </div> <XCommon/> </template> <script lang="ts" setup> import { onMounted, provide, ref, computed } from 'vue'; -import XCommon from './_common_/common.vue'; import { instanceName } from '@@/js/config.js'; +import XCommon from './_common_/common.vue'; +import type { PageMetadata } from '@/page.js'; import * as os from '@/os.js'; import { instance } from '@/instance.js'; -import XSigninDialog from '@/components/MkSigninDialog.vue'; -import XSignupDialog from '@/components/MkSignupDialog.vue'; -import { ColdDeviceStorage, defaultStore } from '@/store.js'; -import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; +import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; import { i18n } from '@/i18n.js'; import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue'; -import { mainRouter } from '@/router/main.js'; +import { mainRouter } from '@/router.js'; +import { DI } from '@/di.js'; const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index'); @@ -86,57 +45,25 @@ const DESKTOP_THRESHOLD = 1100; const pageMetadata = ref<null | PageMetadata>(null); -provide('router', mainRouter); +provide(DI.router, mainRouter); provideMetadataReceiver((metadataGetter) => { const info = metadataGetter(); pageMetadata.value = info; if (pageMetadata.value) { if (isRoot.value && pageMetadata.value.title === instanceName) { - document.title = pageMetadata.value.title; + window.document.title = pageMetadata.value.title; } else { - document.title = `${pageMetadata.value.title} | ${instanceName}`; + window.document.title = `${pageMetadata.value.title} | ${instanceName}`; } } }); provideReactiveMetadata(pageMetadata); -const announcements = { - endpoint: 'announcements', - limit: 10, -}; - -const isTimelineAvailable = ref(instance.policies?.ltlAvailable || instance.policies?.gtlAvailable); - -const showMenu = ref(false); const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD); const narrow = ref(window.innerWidth < 1280); -const keymap = computed(() => { - return { - 'd': () => { - if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; - defaultStore.set('darkMode', !defaultStore.state.darkMode); - }, - 's': () => { - mainRouter.push('/search'); - }, - }; -}); - -function signin() { - const { dispose } = os.popup(XSigninDialog, { - autoSet: true, - }, { - closed: () => dispose(), - }); -} - -function signup() { - const { dispose } = os.popup(XSignupDialog, { - autoSet: true, - }, { - closed: () => dispose(), - }); +function goHome() { + mainRouter.push('/'); } onMounted(() => { @@ -146,152 +73,73 @@ onMounted(() => { }, { passive: true }); } }); - -defineExpose({ - showMenu: showMenu, -}); </script> <style> .github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}} </style> -<style lang="scss" scoped> -.tray-enter-active, -.tray-leave-active { - opacity: 1; - transform: translateX(0); - transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.tray-enter-from, -.tray-leave-active { - opacity: 0; - transform: translateX(-240px); -} - -.tray-back-enter-active, -.tray-back-leave-active { - opacity: 1; - transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.tray-back-enter-from, -.tray-back-leave-active { - opacity: 0; +<style lang="scss" module> +.root { + display: flex; + height: 100dvh; + overflow: clip; } -.mk-app { +.main { display: flex; - min-height: 100vh; - - > .side { - position: sticky; - top: 0; - left: 0; - width: 500px; - height: 100vh; - background: var(--MI_THEME-accent); - z-index: 1; - - > .banner { - position: absolute; - top: 0; - left: 0; - width: 100%; - aspect-ratio: 1.5; - background-position: center; - background-size: cover; - -webkit-mask-image: linear-gradient(rgba(0, 0, 0, 1.0), transparent); - mask-image: linear-gradient(rgba(0, 0, 0, 1.0), transparent); - } - - > .dashboard { - position: relative; - padding: 32px; - box-sizing: border-box; - max-height: 100%; - overflow: auto; - } - } - - > .main { - flex: 1; - min-width: 0; - - > .header { - background: var(--MI_THEME-panel); - position: relative; - z-index: 1; - - > .wide { - line-height: 50px; - padding: 0 16px; - - > .link { - padding: 0 16px; - } - } - - > .narrow { - > .menu { - padding: 16px; - } - } - } - } - - > .menu-back { - position: fixed; - z-index: 1001; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - } - - > .menu { - position: fixed; - z-index: 1001; - top: 0; - left: 0; - width: 240px; - height: 100vh; - background: var(--MI_THEME-panel); - - > .link { - display: block; - padding: 16px; - - > .icon { - margin-right: 1em; - } - } + flex-direction: column; + flex: 1; + min-width: 0; +} - > .divider { - margin: 8px auto; - width: calc(100% - 32px); - border-top: solid 0.5px var(--MI_THEME-divider); - } +.homeButton { + position: fixed; + z-index: 1000; + bottom: 16px; + right: 16px; + width: 60px; + height: 60px; + background: var(--MI_THEME-panel); + border-radius: 999px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} - > .action { - padding: 16px; +.side { + position: sticky; + top: 0; + left: 0; + width: 500px; + height: 100vh; + background: var(--MI_THEME-accent); + z-index: 1; + overflow-y: scroll; + background: var(--MI_THEME-accent); +} - > button { - display: block; - width: 100%; - padding: 10px; - box-sizing: border-box; - text-align: center; - border-radius: var(--MI-radius-ellipse); +.banner { + position: absolute; + top: 0; + left: 0; + width: 100%; + aspect-ratio: 1.5; + background-position: center; + background-size: cover; + -webkit-mask-image: linear-gradient(rgba(0, 0, 0, 1.0), transparent); + mask-image: linear-gradient(rgba(0, 0, 0, 1.0), transparent); +} - &._button { - background: var(--MI_THEME-panel); - } +.dashboard { + position: relative; + padding: 32px; + box-sizing: border-box; + max-height: 100%; + overflow: auto; +} - &:first-child { - margin-bottom: 16px; - } - } - } - } +.content { + display: flex; + flex-direction: column; + height: 100dvh; } </style> diff --git a/packages/frontend/src/ui/zen.vue b/packages/frontend/src/ui/zen.vue index 757aa6669d..66b4496827 100644 --- a/packages/frontend/src/ui/zen.vue +++ b/packages/frontend/src/ui/zen.vue @@ -4,46 +4,50 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="showBottom ? $style.rootWithBottom : $style.root"> - <div style="container-type: inline-size;"> - <RouterView/> +<div :class="$style.root"> + <div :class="$style.contents"> + <div style="flex: 1; min-height: 0;"> + <RouterView/> + </div> + + <!-- + デッキUIが設定されている場合はデッキUIに戻れるようにする (ただし?zenが明示された場合は表示しない) + See https://github.com/misskey-dev/misskey/issues/10905 + --> + <div v-if="showBottom" :class="$style.bottom"> + <button v-tooltip="i18n.ts.goToMisskey" :class="['_button', '_shadow', $style.button]" @click="goToMisskey"><i class="ti ti-home"></i></button> + </div> </div> <XCommon/> </div> - -<!-- - デッキUIが設定されている場合はデッキUIに戻れるようにする (ただし?zenが明示された場合は表示しない) - See https://github.com/misskey-dev/misskey/issues/10905 ---> -<div v-if="showBottom" :class="$style.bottom"> - <button v-tooltip="i18n.ts.goToMisskey" :class="['_button', '_shadow', $style.button]" @click="goToMisskey"><i class="ti ti-home"></i></button> -</div> </template> <script lang="ts" setup> import { computed, provide, ref } from 'vue'; -import XCommon from './_common_/common.vue'; -import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { instanceName, ui } from '@@/js/config.js'; +import XCommon from './_common_/common.vue'; +import type { PageMetadata } from '@/page.js'; +import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { mainRouter } from '@/router/main.js'; +import { mainRouter } from '@/router.js'; +import { DI } from '@/di.js'; const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index'); const pageMetadata = ref<null | PageMetadata>(null); -const showBottom = !(new URLSearchParams(location.search)).has('zen') && ui === 'deck'; +const showBottom = !(new URLSearchParams(window.location.search)).has('zen') && ui === 'deck'; -provide('router', mainRouter); +provide(DI.router, mainRouter); provideMetadataReceiver((metadataGetter) => { const info = metadataGetter(); pageMetadata.value = info; if (pageMetadata.value) { if (isRoot.value && pageMetadata.value.title === instanceName) { - document.title = pageMetadata.value.title; + window.document.title = pageMetadata.value.title; } else { - document.title = `${pageMetadata.value.title} | ${instanceName}`; + window.document.title = `${pageMetadata.value.title} | ${instanceName}`; } } }); @@ -52,19 +56,16 @@ provideReactiveMetadata(pageMetadata); function goToMisskey() { window.location.href = '/'; } - -document.documentElement.style.overflowY = 'scroll'; </script> <style lang="scss" module> .root { - min-height: 100dvh; - box-sizing: border-box; } -.rootWithBottom { - min-height: calc(100dvh - (60px + (var(--MI-margin) * 2) + env(safe-area-inset-bottom, 0px))); - box-sizing: border-box; +.contents { + display: flex; + flex-direction: column; + height: 100dvh; } .bottom { @@ -74,7 +75,6 @@ document.documentElement.style.overflowY = 'scroll'; } .button { - position: fixed !important; padding: 0; aspect-ratio: 1; width: 100%; diff --git a/packages/frontend/src/scripts/use-chart-tooltip.ts b/packages/frontend/src/use/use-chart-tooltip.ts index bba64fc6ee..bba64fc6ee 100644 --- a/packages/frontend/src/scripts/use-chart-tooltip.ts +++ b/packages/frontend/src/use/use-chart-tooltip.ts diff --git a/packages/frontend/src/scripts/use-form.ts b/packages/frontend/src/use/use-form.ts index 0d505fe466..26cca839c3 100644 --- a/packages/frontend/src/scripts/use-form.ts +++ b/packages/frontend/src/use/use-form.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { computed, Reactive, reactive, watch } from 'vue'; +import { computed, reactive, watch } from 'vue'; +import type { Reactive } from 'vue'; function copy<T>(v: T): T { return JSON.parse(JSON.stringify(v)); diff --git a/packages/frontend/src/scripts/use-leave-guard.ts b/packages/frontend/src/use/use-leave-guard.ts index 5f7e56e8a9..395c12a756 100644 --- a/packages/frontend/src/scripts/use-leave-guard.ts +++ b/packages/frontend/src/use/use-leave-guard.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Ref } from 'vue'; +import type { Ref } from 'vue'; export function useLeaveGuard(enabled: Ref<boolean>) { /* TODO diff --git a/packages/frontend/src/scripts/use-note-capture.ts b/packages/frontend/src/use/use-note-capture.ts index d15d9043c2..9cc4778edf 100644 --- a/packages/frontend/src/scripts/use-note-capture.ts +++ b/packages/frontend/src/use/use-note-capture.ts @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { onUnmounted, Ref, ShallowRef } from 'vue'; +import { onUnmounted } from 'vue'; +import type { Ref, ShallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import { useStream } from '@/stream.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { misskeyApi } from './misskey-api.js'; export function useNoteCapture(props: { @@ -123,7 +124,7 @@ export function useNoteCapture(props: { function capture(withHandler = false): void { if (connection) { // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する - connection.send(document.body.contains(props.rootEl.value ?? null as Node | null) ? 'sr' : 's', { id: note.value.id }); + connection.send(window.document.body.contains(props.rootEl.value ?? null as Node | null) ? 'sr' : 's', { id: note.value.id }); if (pureNote.value.id !== note.value.id) connection.send('s', { id: pureNote.value.id }); if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated); } diff --git a/packages/frontend/src/scripts/use-tooltip.ts b/packages/frontend/src/use/use-tooltip.ts index a26d08cce7..af76a3a1e8 100644 --- a/packages/frontend/src/scripts/use-tooltip.ts +++ b/packages/frontend/src/use/use-tooltip.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Ref, ref, watch, onUnmounted } from 'vue'; +import { ref, watch, onUnmounted } from 'vue'; +import type { Ref } from 'vue'; export function useTooltip( elRef: Ref<HTMLElement | { $el: HTMLElement } | null | undefined>, @@ -28,7 +29,7 @@ export function useTooltip( if (!isHovering) return; if (elRef.value == null) return; const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el; - if (!document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため + if (!window.document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため const showing = ref(true); onShow(showing); @@ -37,7 +38,7 @@ export function useTooltip( }; autoHidingTimer = window.setInterval(() => { - if (elRef.value == null || !document.body.contains(elRef.value instanceof Element ? elRef.value : elRef.value.$el)) { + if (elRef.value == null || !window.document.body.contains(elRef.value instanceof Element ? elRef.value : elRef.value.$el)) { if (!isHovering) return; isHovering = false; window.clearTimeout(timeoutId); diff --git a/packages/frontend/src/scripts/achievements.ts b/packages/frontend/src/utility/achievements.ts index f5d0ab559f..f6ab587ae1 100644 --- a/packages/frontend/src/scripts/achievements.ts +++ b/packages/frontend/src/utility/achievements.ts @@ -3,8 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { $i } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { $i } from '@/i.js'; export const ACHIEVEMENT_TYPES = [ 'notes1', diff --git a/packages/frontend/src/scripts/admin-lookup.ts b/packages/frontend/src/utility/admin-lookup.ts index 1b57b853c9..7405e229fe 100644 --- a/packages/frontend/src/scripts/admin-lookup.ts +++ b/packages/frontend/src/utility/admin-lookup.ts @@ -6,7 +6,7 @@ import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; export async function lookupUser() { const { canceled, result } = await os.inputText({ diff --git a/packages/frontend/src/scripts/array.ts b/packages/frontend/src/utility/array.ts index f2feb29dfc..f2feb29dfc 100644 --- a/packages/frontend/src/scripts/array.ts +++ b/packages/frontend/src/utility/array.ts diff --git a/packages/frontend/src/scripts/autocomplete.ts b/packages/frontend/src/utility/autocomplete.ts index 8a3a6bf6db..0276cc3c20 100644 --- a/packages/frontend/src/scripts/autocomplete.ts +++ b/packages/frontend/src/utility/autocomplete.ts @@ -3,9 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { nextTick, Ref, ref, defineAsyncComponent } from 'vue'; +import { nextTick, ref, defineAsyncComponent } from 'vue'; import getCaretCoordinates from 'textarea-caret'; import { toASCII } from 'punycode.js'; +import type { Ref } from 'vue'; import { popup } from '@/os.js'; export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam'; @@ -97,15 +98,21 @@ export class Autocomplete { const isMention = mentionIndex !== -1; const isHashtag = hashtagIndex !== -1; - const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam?.includes(' '); + const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam.includes(' '); const isMfmTag = mfmTagIndex !== -1 && !isMfmParam; const isEmoji = emojiIndex !== -1 && text.split(/:[\p{Letter}\p{Number}\p{Mark}_+-]+:/u).pop()!.includes(':'); let opened = false; if (isMention && this.onlyType.includes('user')) { - const username = text.substring(mentionIndex + 1); - if (username !== '' && username.match(/^[a-zA-Z0-9_]+$/)) { + // ユーザのサジェスト中に@を入力すると、その位置から新たにユーザ名を取りなおそうとしてしまう + // この動きはリモートユーザのサジェストを阻害するので、@を検知したらその位置よりも前の@を探し、 + // ホスト名を含むリモートのユーザ名を全て拾えるようにする + const mentionIndexAlt = text.lastIndexOf('@', mentionIndex - 1); + const username = mentionIndexAlt === -1 + ? text.substring(mentionIndex + 1) + : text.substring(mentionIndexAlt + 1); + if (username !== '' && username.match(/^[a-zA-Z0-9_@.]+$/)) { this.open('user', username); opened = true; } else if (username === '') { diff --git a/packages/frontend/src/utility/autogen/settings-search-index.ts b/packages/frontend/src/utility/autogen/settings-search-index.ts new file mode 100644 index 0000000000..64fe328478 --- /dev/null +++ b/packages/frontend/src/utility/autogen/settings-search-index.ts @@ -0,0 +1,937 @@ + +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// This file was automatically generated by create-search-index. +// Do not edit this file. + +import { i18n } from '@/i18n.js'; + +export type SearchIndexItem = { + id: string; + path?: string; + label: string; + keywords: string[]; + icon?: string; + children?: SearchIndexItem[]; +}; + +export const searchIndexes: SearchIndexItem[] = [ + { + id: 'flXd1LC7r', + children: [ + { + id: 'hB11H5oul', + label: i18n.ts.syncDeviceDarkMode, + keywords: ['sync', 'device', 'dark', 'light', 'mode'], + }, + { + id: 'fDbLtIKeo', + label: i18n.ts.themeForLightMode, + keywords: ['light', 'theme'], + }, + { + id: 'CsSVILKpX', + label: i18n.ts.themeForDarkMode, + keywords: ['dark', 'theme'], + }, + { + id: '8wcoRp76b', + label: i18n.ts.setWallpaper, + keywords: ['wallpaper'], + }, + ], + label: i18n.ts.theme, + keywords: ['theme'], + path: '/settings/theme', + icon: 'ti ti-palette', + }, + { + id: '6fFIRXUww', + children: [ + { + id: 'EcwZE7dCl', + label: i18n.ts.notUseSound, + keywords: ['mute'], + }, + { + id: '9MxYVIf7k', + label: i18n.ts.useSoundOnlyWhenActive, + keywords: ['active', 'mute'], + }, + { + id: '94afQxKat', + label: i18n.ts.masterVolume, + keywords: ['volume', 'master'], + }, + ], + label: i18n.ts.sounds, + keywords: ['sounds', i18n.ts._settings.soundsBanner], + path: '/settings/sounds', + icon: 'ti ti-music', + }, + { + id: '5BjnxMfYV', + children: [ + { + id: '75QPEg57v', + children: [ + { + id: 'CiHijRkGG', + label: i18n.ts.changePassword, + keywords: [], + }, + ], + label: i18n.ts.password, + keywords: ['password'], + }, + { + id: '2fa', + children: [ + { + id: 'qCXM0HtJ7', + label: i18n.ts.totp, + keywords: ['totp', 'app', i18n.ts.totpDescription], + }, + { + id: '3g1RePuD9', + label: i18n.ts.securityKeyAndPasskey, + keywords: ['security', 'key', 'passkey'], + }, + { + id: 'pFRud5u8k', + label: i18n.ts.passwordLessLogin, + keywords: ['password', 'less', 'key', 'passkey', 'login', 'signin', i18n.ts.passwordLessLoginDescription], + }, + ], + label: i18n.ts['2fa'], + keywords: ['2fa'], + }, + ], + label: i18n.ts.security, + keywords: ['security', i18n.ts._settings.securityBanner], + path: '/settings/security', + icon: 'ti ti-lock', + }, + { + id: 'w4L6myH61', + children: [ + { + id: 'ru8DrOn3J', + label: i18n.ts._profile.changeBanner, + keywords: ['banner', 'change'], + }, + { + id: 'CCnD8Apnu', + label: i18n.ts._profile.changeAvatar, + keywords: ['avatar', 'icon', 'change'], + }, + { + id: 'yFEVCJxFX', + label: i18n.ts._profile.name, + keywords: ['name'], + }, + { + id: '2O1S5reaB', + label: i18n.ts._profile.description, + keywords: ['description', 'bio'], + }, + { + id: 'pWi4OLS8g', + label: i18n.ts.location, + keywords: ['location', 'locale'], + }, + { + id: 'oLO5X6Wtw', + label: i18n.ts.birthday, + keywords: ['birthday', 'birthdate', 'age'], + }, + { + id: 'm2trKwPgq', + label: i18n.ts.language, + keywords: ['language', 'locale'], + }, + { + id: 'kfDZxCDp9', + label: i18n.ts._profile.metadataEdit, + keywords: ['metadata'], + }, + { + id: 'uPt3MFymp', + label: i18n.ts._profile.followedMessage, + keywords: ['follow', 'message', i18n.ts._profile.followedMessageDescription], + }, + { + id: 'wuGg0tBjw', + label: i18n.ts.reactionAcceptance, + keywords: ['reaction'], + }, + { + id: 'EezPpmMnf', + children: [ + { + id: 'f2cRLh8ad', + label: i18n.ts.flagAsCat, + keywords: ['cat'], + }, + { + id: 'eVoViiF3h', + label: i18n.ts.flagAsBot, + keywords: ['bot'], + }, + ], + label: i18n.ts.advancedSettings, + keywords: [], + }, + ], + label: i18n.ts.profile, + keywords: ['profile'], + path: '/settings/profile', + icon: 'ti ti-user', + }, + { + id: '2rp9ka5Ht', + children: [ + { + id: 'BhAQiHogN', + label: i18n.ts.makeFollowManuallyApprove, + keywords: ['follow', 'lock', i18n.ts.lockedAccountInfo], + }, + { + id: '4DeWGsPaD', + label: i18n.ts.autoAcceptFollowed, + keywords: ['follow', 'auto', 'accept'], + }, + { + id: 'iaM6zUmO9', + label: i18n.ts.makeReactionsPublic, + keywords: ['reaction', 'public', i18n.ts.makeReactionsPublicDescription], + }, + { + id: '5Q6uhghzV', + label: i18n.ts.followingVisibility, + keywords: ['following', 'visibility'], + }, + { + id: 'pZ9q65FX5', + label: i18n.ts.followersVisibility, + keywords: ['follower', 'visibility'], + }, + { + id: 'DMS4yvAGg', + label: i18n.ts.hideOnlineStatus, + keywords: ['online', 'status', i18n.ts.hideOnlineStatusDescription], + }, + { + id: '8rEsGuN8w', + label: i18n.ts.noCrawle, + keywords: ['crawle', 'index', 'search', i18n.ts.noCrawleDescription], + }, + { + id: 's7LdSpiLn', + label: i18n.ts.preventAiLearning, + keywords: ['crawle', 'ai', i18n.ts.preventAiLearningDescription], + }, + { + id: 'l2Wf1s2ad', + label: i18n.ts.makeExplorable, + keywords: ['explore', i18n.ts.makeExplorableDescription], + }, + { + id: 'xEYlOghao', + label: i18n.ts._chat.chatAllowedUsers, + keywords: ['chat'], + }, + { + id: 'BnOtlyaAh', + children: [ + { + id: 'BzMIVBpL0', + label: i18n.ts._accountSettings.requireSigninToViewContents, + keywords: ['login', 'signin'], + }, + { + id: 'jJUqPqBAv', + label: i18n.ts._accountSettings.makeNotesFollowersOnlyBefore, + keywords: ['follower', i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription], + }, + { + id: 'ra10txIFV', + label: i18n.ts._accountSettings.makeNotesHiddenBefore, + keywords: ['hidden', i18n.ts._accountSettings.makeNotesHiddenBeforeDescription], + }, + ], + label: i18n.ts.lockdown, + keywords: ['lockdown'], + }, + ], + label: i18n.ts.privacy, + keywords: ['privacy', i18n.ts._settings.privacyBanner], + path: '/settings/privacy', + icon: 'ti ti-lock-open', + }, + { + id: '3yCAv0IsZ', + children: [ + { + id: 'AKvDrxSj5', + children: [ + { + id: 'cAszhShB0', + label: i18n.ts.uiLanguage, + keywords: ['language'], + }, + { + id: 'apz9AutPm', + label: i18n.ts.overridedDeviceKind, + keywords: ['device', 'type', 'kind', 'smartphone', 'tablet', 'desktop'], + }, + { + id: 'nqRVtw1xw', + label: i18n.ts.useBlurEffect, + keywords: ['blur'], + }, + { + id: 'EO5WHBeG8', + label: i18n.ts.useBlurEffectForModal, + keywords: ['blur', 'modal'], + }, + { + id: 'CWpyT9vLK', + label: i18n.ts.showAvatarDecorations, + keywords: ['avatar', 'icon', 'decoration', 'show'], + }, + { + id: '1wwACqQz1', + label: i18n.ts.alwaysConfirmFollow, + keywords: ['follow', 'confirm', 'always'], + }, + { + id: '1x3JNXj8N', + label: i18n.ts.highlightSensitiveMedia, + keywords: ['highlight', 'sensitive', 'nsfw', 'image', 'photo', 'picture', 'media', 'thumbnail'], + }, + { + id: 'CfAg0Qekq', + label: i18n.ts.confirmWhenRevealingSensitiveMedia, + keywords: ['sensitive', 'nsfw', 'media', 'image', 'photo', 'picture', 'attachment', 'confirm'], + }, + { + id: 'aefexW9fD', + label: i18n.ts.enableAdvancedMfm, + keywords: ['mfm', 'enable', 'show', 'advanced'], + }, + { + id: 'lu9v5Spqg', + label: i18n.ts.enableInfiniteScroll, + keywords: ['auto', 'load', 'auto', 'more', 'scroll'], + }, + { + id: '6kMj4HVOg', + label: i18n.ts.emojiStyle, + keywords: ['emoji', 'style', 'native', 'system', 'fluent', 'twemoji'], + }, + { + id: 'DftdlLbNu', + label: i18n.ts.pinnedList, + keywords: ['pinned', 'list'], + }, + ], + label: i18n.ts.general, + keywords: ['general'], + }, + { + id: 'CQldliCSi', + children: [ + { + id: 'kMB2hPyq3', + label: i18n.ts.showFixedPostForm, + keywords: ['post', 'form', 'timeline'], + }, + { + id: 'jC7LtTnmc', + label: i18n.ts.showFixedPostFormInChannel, + keywords: ['post', 'form', 'timeline', 'channel'], + }, + { + id: 'p2wlrnwLo', + label: i18n.ts.collapseRenotes, + keywords: ['renote', i18n.ts.collapseRenotesDescription], + }, + { + id: '6SFn3t8VS', + label: i18n.ts.showGapBetweenNotesInTimeline, + keywords: ['note', 'timeline', 'gap'], + }, + { + id: 'nygexkaUk', + label: i18n.ts.disableStreamingTimeline, + keywords: ['disable', 'streaming', 'timeline'], + }, + { + id: '7vnQgR42v', + label: i18n.ts.showNoteActionsOnlyHover, + keywords: ['hover', 'show', 'footer', 'action'], + }, + { + id: 'x5q4XZ7Kv', + label: i18n.ts.showClipButtonInNoteFooter, + keywords: ['footer', 'action', 'clip', 'show'], + }, + { + id: 'x9irZWjaF', + label: i18n.ts.showReactionsCount, + keywords: ['reaction', 'count', 'show'], + }, + { + id: 'dHPv9mrxi', + label: i18n.ts.confirmOnReact, + keywords: ['reaction', 'confirm'], + }, + { + id: 'bj42W4cvN', + label: i18n.ts.loadRawImages, + keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'quality', 'raw', 'attachment'], + }, + { + id: 'fzPca1Gk9', + label: i18n.ts.useReactionPickerForContextMenu, + keywords: ['reaction', 'picker', 'contextmenu', 'open'], + }, + { + id: 'mNU5IBln7', + label: i18n.ts.reactionsDisplaySize, + keywords: ['reaction', 'size', 'scale', 'display'], + }, + { + id: 'kYgorbLUy', + label: i18n.ts.limitWidthOfReaction, + keywords: ['reaction', 'size', 'scale', 'display', 'width', 'limit'], + }, + { + id: 'm75VEWI3S', + label: i18n.ts.mediaListWithOneImageAppearance, + keywords: ['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'list', 'size', 'height'], + }, + { + id: 'CA42sC9Mx', + label: i18n.ts.instanceTicker, + keywords: ['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation'], + }, + { + id: 'knEhibyFp', + label: i18n.ts.displayOfSensitiveMedia, + keywords: ['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'nsfw', 'sensitive', 'display', 'show', 'hide', 'visibility'], + }, + ], + label: i18n.ts._settings.timelineAndNote, + keywords: ['timeline', 'note'], + }, + { + id: 'yIR4YP0yU', + children: [ + { + id: 'cBkUgQNpH', + label: i18n.ts.keepCw, + keywords: ['remember', 'keep', 'note', 'cw'], + }, + { + id: 'Bv4YywaKL', + label: i18n.ts.rememberNoteVisibility, + keywords: ['remember', 'keep', 'note', 'visibility'], + }, + { + id: 'F3kpUNvSQ', + label: i18n.ts.enableQuickAddMfmFunction, + keywords: ['mfm', 'enable', 'show', 'advanced', 'picker', 'form', 'function', 'fn'], + }, + { + id: 'BBxwy4F6E', + label: i18n.ts.defaultNoteVisibility, + keywords: ['default', 'note', 'visibility'], + }, + ], + label: i18n.ts.postForm, + keywords: ['post', 'form'], + }, + { + id: 'e5XnQWk68', + children: [ + { + id: 'rOttgccaS', + label: i18n.ts.useGroupedNotifications, + keywords: ['group'], + }, + { + id: 'Ek4Cw3VPq', + label: i18n.ts.position, + keywords: ['position'], + }, + { + id: 'pZLzt3i0s', + label: i18n.ts.stackAxis, + keywords: ['stack', 'axis', 'direction'], + }, + ], + label: i18n.ts.notifications, + keywords: ['notification'], + }, + { + id: 'c9mbgmHQp', + label: i18n.ts.dataSaver, + keywords: ['datasaver'], + }, + { + id: '5h8vhCX1S', + children: [ + { + id: 'bDv03znUy', + label: i18n.ts.squareAvatars, + keywords: ['avatar', 'icon', 'square'], + }, + { + id: 'nkR2LWURW', + label: i18n.ts.seasonalScreenEffect, + keywords: ['effect', 'show'], + }, + { + id: 'sCscGhMmH', + label: i18n.ts.openImageInNewTab, + keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'new', 'tab'], + }, + { + id: '4yCgcFElF', + label: i18n.ts.withRepliesByDefaultForNewlyFollowed, + keywords: ['follow', 'replies'], + }, + { + id: '5iMpm5rES', + label: i18n.ts.whenServerDisconnected, + keywords: ['server', 'disconnect', 'reconnect', 'reload', 'streaming'], + }, + { + id: 'dlQjnWBVU', + label: i18n.ts.numberOfPageCache, + keywords: ['cache', 'page'], + }, + { + id: 'qY5xTzl35', + label: i18n.ts.forceShowAds, + keywords: ['ad', 'show'], + }, + { + id: '2VSnj81vC', + label: i18n.ts.hemisphere, + keywords: [], + }, + { + id: 'vuG3aG3IE', + label: i18n.ts.additionalEmojiDictionary, + keywords: ['emoji', 'dictionary', 'additional', 'extra'], + }, + ], + label: i18n.ts.other, + keywords: ['other'], + }, + ], + label: i18n.ts.preferences, + keywords: ['general', 'preferences', i18n.ts._settings.preferencesBanner], + path: '/settings/preferences', + icon: 'ti ti-adjustments', + }, + { + id: 'mwkwtw83Y', + label: i18n.ts.plugins, + keywords: ['plugin', 'addon', 'extension', i18n.ts._settings.pluginBanner], + path: '/settings/plugin', + icon: 'ti ti-plug', + }, + { + id: 'F1uK9ssiY', + children: [ + { + id: 'E0ndmaP6Q', + label: i18n.ts._role.policies, + keywords: ['account', 'info'], + }, + { + id: 'r5SjfwZJc', + label: i18n.ts.rolesAssignedToMe, + keywords: ['roles'], + }, + { + id: 'cm7LrjgaW', + label: i18n.ts.accountMigration, + keywords: ['account', 'move', 'migration'], + }, + { + id: 'ozfqNviP3', + label: i18n.ts.closeAccount, + keywords: ['account', 'close', 'delete', i18n.ts._accountDelete.requestAccountDelete], + }, + { + id: 'tpywgkpxy', + label: i18n.ts.experimentalFeatures, + keywords: ['experimental', 'feature', 'flags'], + }, + { + id: 'zWbGKohZ2', + label: i18n.ts.developer, + keywords: ['developer', 'mode', 'debug'], + }, + ], + label: i18n.ts.other, + keywords: ['other'], + path: '/settings/other', + icon: 'ti ti-dots', + }, + { + id: '3icEvyv2D', + children: [ + { + id: 'lO3uFTkPN', + children: [ + { + id: '5JKaXRqyt', + label: i18n.ts.showMutedWord, + keywords: ['show'], + }, + ], + label: i18n.ts.wordMute, + keywords: ['note', 'word', 'soft', 'mute', 'hide'], + }, + { + id: 'fMkjL3dK4', + label: i18n.ts.hardWordMute, + keywords: ['note', 'word', 'hard', 'mute', 'hide'], + }, + { + id: 'cimSzQXN0', + label: i18n.ts.instanceMute, + keywords: ['note', 'server', 'instance', 'host', 'federation', 'mute', 'hide'], + }, + { + id: 'gq8rPy3Du', + label: `${i18n.ts.mutedUsers} (${ i18n.ts.renote })`, + keywords: ['renote', 'mute', 'hide', 'user'], + }, + { + id: 'mh2r7EUbF', + label: i18n.ts.mutedUsers, + keywords: ['note', 'mute', 'hide', 'user'], + }, + { + id: 'AUS1OgHrn', + label: i18n.ts.blockedUsers, + keywords: ['block', 'user'], + }, + ], + label: i18n.ts.muteAndBlock, + keywords: ['mute', 'block', i18n.ts._settings.muteAndBlockBanner], + path: '/settings/mute-block', + icon: 'ti ti-ban', + }, + { + id: 'yR1OSyLiT', + children: [ + { + id: 'yMJzyzOUk', + label: i18n.ts._emojiPalette.enableSyncBetweenDevicesForPalettes, + keywords: ['sync', 'palettes', 'devices'], + }, + { + id: 'wCE09vgZr', + label: i18n.ts._emojiPalette.paletteForMain, + keywords: ['main', 'palette'], + }, + { + id: 'uCzRPrSNx', + label: i18n.ts._emojiPalette.paletteForReaction, + keywords: ['reaction', 'palette'], + }, + { + id: 'hgQr28WUk', + children: [ + { + id: 'fY04NIHSQ', + label: i18n.ts.size, + keywords: ['emoji', 'picker', 'scale', 'size'], + }, + { + id: '3j7vlaL7t', + label: i18n.ts.numberOfColumn, + keywords: ['emoji', 'picker', 'width', 'column', 'size'], + }, + { + id: 'zPX8z1Bcy', + label: i18n.ts.height, + keywords: ['emoji', 'picker', 'height', 'size'], + }, + { + id: '2CSkZa4tl', + label: i18n.ts.style, + keywords: ['emoji', 'picker', 'style'], + }, + ], + label: i18n.ts.emojiPickerDisplay, + keywords: ['emoji', 'picker', 'display'], + }, + ], + label: i18n.ts.emojiPalette, + keywords: ['emoji', 'palette'], + path: '/settings/emoji-palette', + icon: 'ti ti-mood-happy', + }, + { + id: '3Tcxw4Fwl', + children: [ + { + id: 'iIai9O65I', + label: i18n.ts.emailAddress, + keywords: ['email', 'address'], + }, + { + id: 'i6cC6oi0m', + label: i18n.ts.receiveAnnouncementFromInstance, + keywords: ['announcement', 'email'], + }, + { + id: 'C1YTinP11', + label: i18n.ts.emailNotification, + keywords: ['notification', 'email'], + }, + ], + label: i18n.ts.email, + keywords: ['email'], + path: '/settings/email', + icon: 'ti ti-mail', + }, + { + id: 'tnYoppRiv', + children: [ + { + id: 'cN3dsGNxu', + label: i18n.ts.usageAmount, + keywords: ['capacity', 'usage'], + }, + { + id: 'rOAOU2P6C', + label: i18n.ts.statistics, + keywords: ['statistics', 'usage'], + }, + { + id: 'uXGlQXATx', + label: i18n.ts.uploadFolder, + keywords: ['default', 'upload', 'folder'], + }, + { + id: 'goQdtf3dD', + label: i18n.ts.keepOriginalUploading, + keywords: ['keep', 'original', 'raw', 'upload', i18n.ts.keepOriginalUploadingDescription], + }, + { + id: '83xRo0XJl', + label: i18n.ts.keepOriginalFilename, + keywords: ['keep', 'original', 'filename', i18n.ts.keepOriginalFilenameDescription], + }, + { + id: 'wf77yRQQq', + label: i18n.ts.alwaysMarkSensitive, + keywords: ['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file'], + }, + { + id: '3pxwNB8e4', + label: i18n.ts.enableAutoSensitive, + keywords: ['auto', 'nsfw', 'sensitive', 'media', 'file', i18n.ts.enableAutoSensitiveDescription], + }, + ], + label: i18n.ts.drive, + keywords: ['drive', i18n.ts._settings.driveBanner], + path: '/settings/drive', + icon: 'ti ti-cloud', + }, + { + id: 'FfZdOs8y', + children: [ + { + id: 'B1ZU6Ur54', + label: i18n.ts._deck.enableSyncBetweenDevicesForProfiles, + keywords: ['sync', 'profiles', 'devices'], + }, + { + id: 'iEF0gqNAo', + label: i18n.ts._deck.useSimpleUiForNonRootPages, + keywords: ['ui', 'root', 'page'], + }, + { + id: 'BNdSeWxZn', + label: i18n.ts.defaultNavigationBehaviour, + keywords: ['default', 'navigation', 'behaviour', 'window'], + }, + { + id: 'zT9pGm8DF', + label: i18n.ts._deck.alwaysShowMainColumn, + keywords: ['always', 'show', 'main', 'column'], + }, + { + id: '5dk2xv1vc', + label: i18n.ts._deck.columnAlign, + keywords: ['column', 'align'], + }, + ], + label: i18n.ts.deck, + keywords: ['deck', 'ui'], + path: '/settings/deck', + icon: 'ti ti-columns', + }, + { + id: 'BlJ2rsw9h', + children: [ + { + id: '9bLU1nIjt', + label: i18n.ts._settings.api, + keywords: ['api', 'app', 'token', 'accessToken'], + }, + { + id: '5VSGOVYR0', + label: i18n.ts._settings.webhook, + keywords: ['webhook'], + }, + ], + label: i18n.ts._settings.serviceConnection, + keywords: ['app', 'service', 'connect', 'webhook', 'api', 'token', i18n.ts._settings.serviceConnectionBanner], + path: '/settings/connect', + icon: 'ti ti-link', + }, + { + id: 'gtaOSdIJB', + label: i18n.ts.avatarDecorations, + keywords: ['avatar', 'icon', 'decoration'], + path: '/settings/avatar-decoration', + icon: 'ti ti-sparkles', + }, + { + id: 'zK6posor9', + label: i18n.ts.accounts, + keywords: ['accounts'], + path: '/settings/accounts', + icon: 'ti ti-users', + }, + { + id: '330Q4mf8E', + children: [ + { + id: 'eGSjUDIKu', + label: i18n.ts._exportOrImport.allNotes, + keywords: ['notes'], + }, + { + id: 'iMDgUVgRu', + label: i18n.ts._exportOrImport.favoritedNotes, + keywords: ['favorite', 'notes'], + }, + { + id: '3y6KgkVbT', + label: i18n.ts._exportOrImport.clips, + keywords: ['clip', 'notes'], + }, + { + id: 'cKiHkj8HE', + label: i18n.ts._exportOrImport.followingList, + keywords: ['following', 'users'], + }, + { + id: '3zzmQXn0t', + label: i18n.ts._exportOrImport.userLists, + keywords: ['user', 'lists'], + }, + { + id: '3ZGXcEqWZ', + label: i18n.ts._exportOrImport.muteList, + keywords: ['mute', 'users'], + }, + { + id: '84oL7B1Dr', + label: i18n.ts._exportOrImport.blockingList, + keywords: ['block', 'users'], + }, + { + id: 'ckqi48Kbl', + label: i18n.ts.antennas, + keywords: ['antennas'], + }, + ], + label: i18n.ts._settings.accountData, + keywords: ['import', 'export', 'data', 'archive', i18n.ts._settings.accountDataBanner], + path: '/settings/account-data', + icon: 'ti ti-package', + }, + { + id: 'f08Mi1Uwn', + children: [ + { + id: 'C5dRH2Ypy', + label: i18n.ts.reduceUiAnimation, + keywords: ['animation', 'motion', 'reduce'], + }, + { + id: '5mZxz2cru', + label: i18n.ts.disableShowingAnimatedImages, + keywords: ['disable', 'animation', 'image', 'photo', 'picture', 'media', 'thumbnail', 'gif'], + }, + { + id: 'c0Iy5hL5o', + label: i18n.ts.enableAnimatedMfm, + keywords: ['mfm', 'enable', 'show', 'animated'], + }, + { + id: '4HYFjs2Nv', + label: i18n.ts.enableHorizontalSwipe, + keywords: ['swipe', 'horizontal', 'tab'], + }, + { + id: 'kYVJ3SVNq', + label: i18n.ts.keepScreenOn, + keywords: ['keep', 'screen', 'display', 'on'], + }, + { + id: 'w4Bv0meAt', + label: i18n.ts.useNativeUIForVideoAudioPlayer, + keywords: ['native', 'system', 'video', 'audio', 'player', 'media'], + }, + { + id: 'b1GYEEJeh', + label: i18n.ts._settings.makeEveryTextElementsSelectable, + keywords: ['text', 'selectable'], + }, + { + id: 'vVLxwINTJ', + label: i18n.ts.menuStyle, + keywords: ['menu', 'style', 'popup', 'drawer'], + }, + { + id: '14cMhMLHL', + label: i18n.ts._contextMenu.title, + keywords: ['contextmenu', 'system', 'native'], + }, + { + id: 'oSo4LXMX9', + label: i18n.ts.fontSize, + keywords: ['font', 'size'], + }, + { + id: '7LQSAThST', + label: i18n.ts.useSystemFont, + keywords: ['font', 'system', 'native'], + }, + ], + label: i18n.ts.accessibility, + keywords: ['accessibility', i18n.ts._settings.accessibilityBanner], + path: '/settings/accessibility', + icon: 'ti ti-accessible', + }, +] as const; + +export type SearchIndex = typeof searchIndexes; diff --git a/packages/frontend/src/scripts/boost-quote.ts b/packages/frontend/src/utility/boost-quote.ts index feb949772b..feb949772b 100644 --- a/packages/frontend/src/scripts/boost-quote.ts +++ b/packages/frontend/src/utility/boost-quote.ts diff --git a/packages/frontend/src/scripts/cache.ts b/packages/frontend/src/utility/cache.ts index 0fbdf34d5d..0fbdf34d5d 100644 --- a/packages/frontend/src/scripts/cache.ts +++ b/packages/frontend/src/utility/cache.ts diff --git a/packages/frontend/src/scripts/chart-legend.ts b/packages/frontend/src/utility/chart-legend.ts index 2d534f60c1..e701d18dd2 100644 --- a/packages/frontend/src/scripts/chart-legend.ts +++ b/packages/frontend/src/utility/chart-legend.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Plugin } from 'chart.js'; +import type { Plugin } from 'chart.js'; import MkChartLegend from '@/components/MkChartLegend.vue'; export const chartLegend = (legend: InstanceType<typeof MkChartLegend>) => ({ diff --git a/packages/frontend/src/scripts/chart-vline.ts b/packages/frontend/src/utility/chart-vline.ts index 24e41245e7..465ca591c6 100644 --- a/packages/frontend/src/scripts/chart-vline.ts +++ b/packages/frontend/src/utility/chart-vline.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Plugin } from 'chart.js'; +import type { Plugin } from 'chart.js'; export const chartVLine = (vLineColor: string) => ({ id: 'vLine', diff --git a/packages/frontend/src/scripts/check-animated-mfm.ts b/packages/frontend/src/utility/check-animated-mfm.ts index 2614dfb4f1..2614dfb4f1 100644 --- a/packages/frontend/src/scripts/check-animated-mfm.ts +++ b/packages/frontend/src/utility/check-animated-mfm.ts diff --git a/packages/frontend/src/scripts/check-permissions.ts b/packages/frontend/src/utility/check-permissions.ts index ed86529d5b..2de8fd2cd1 100644 --- a/packages/frontend/src/scripts/check-permissions.ts +++ b/packages/frontend/src/utility/check-permissions.ts @@ -4,7 +4,7 @@ */ import { instance } from '@/instance.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; export const notesSearchAvailable = ( // FIXME: instance.policies would be null in Vitest diff --git a/packages/frontend/src/scripts/check-reaction-permissions.ts b/packages/frontend/src/utility/check-reaction-permissions.ts index c3c3f419a9..281ea2520e 100644 --- a/packages/frontend/src/scripts/check-reaction-permissions.ts +++ b/packages/frontend/src/utility/check-reaction-permissions.ts @@ -4,7 +4,7 @@ */ import * as Misskey from 'misskey-js'; -import { UnicodeEmojiDef } from '@@/js/emojilist.js'; +import type { UnicodeEmojiDef } from '@@/js/emojilist.js'; export function checkReactionPermissions(me: Misskey.entities.MeDetailed, note: Misskey.entities.Note, emoji: Misskey.entities.EmojiSimple | UnicodeEmojiDef | string): boolean { if (typeof emoji === 'string') return true; // UnicodeEmojiDefにも無い絵文字であれば文字列で来る。Unicode絵文字であることには変わりないので常にリアクション可能とする; diff --git a/packages/frontend/src/scripts/check-word-mute.ts b/packages/frontend/src/utility/check-word-mute.ts index 194ef0f420..194ef0f420 100644 --- a/packages/frontend/src/scripts/check-word-mute.ts +++ b/packages/frontend/src/utility/check-word-mute.ts diff --git a/packages/frontend/src/scripts/chiptune2.ts b/packages/frontend/src/utility/chiptune2.ts index 220002ff1e..220002ff1e 100644 --- a/packages/frontend/src/scripts/chiptune2.ts +++ b/packages/frontend/src/utility/chiptune2.ts diff --git a/packages/frontend/src/scripts/clear-cache.ts b/packages/frontend/src/utility/clear-cache.ts index 71d1232710..b6ae254727 100644 --- a/packages/frontend/src/scripts/clear-cache.ts +++ b/packages/frontend/src/utility/clear-cache.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { unisonReload } from '@/scripts/unison-reload.js'; +import { unisonReload } from '@/utility/unison-reload.js'; import * as os from '@/os.js'; import { miLocalStorage } from '@/local-storage.js'; import { fetchCustomEmojis } from '@/custom-emojis.js'; diff --git a/packages/frontend/src/scripts/clicker-game.ts b/packages/frontend/src/utility/clicker-game.ts index f9c4bc1829..0544be7757 100644 --- a/packages/frontend/src/scripts/clicker-game.ts +++ b/packages/frontend/src/utility/clicker-game.ts @@ -4,7 +4,7 @@ */ import { ref, computed } from 'vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; type SaveData = { gameVersion: number; diff --git a/packages/frontend/src/scripts/clone.ts b/packages/frontend/src/utility/clone.ts index ea8eea14b5..ea8eea14b5 100644 --- a/packages/frontend/src/scripts/clone.ts +++ b/packages/frontend/src/utility/clone.ts diff --git a/packages/frontend/src/scripts/code-highlighter.ts b/packages/frontend/src/utility/code-highlighter.ts index 4d57dcd944..4f2aff9d4c 100644 --- a/packages/frontend/src/scripts/code-highlighter.ts +++ b/packages/frontend/src/utility/code-highlighter.ts @@ -10,18 +10,20 @@ import { bundledThemesInfo } from 'shiki/themes'; import { bundledLanguagesInfo } from 'shiki/langs'; import lightTheme from '@@/themes/_light.json5'; import darkTheme from '@@/themes/_dark.json5'; +import defaultLightTheme from '@@/themes/l-light.json5'; +import defaultDarkTheme from '@@/themes/d-green-lime.json5'; import { unique } from './array.js'; import { deepClone } from './clone.js'; import { deepMerge } from './merge.js'; import type { HighlighterCore, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki/core'; -import { ColdDeviceStorage } from '@/store.js'; +import { prefer } from '@/preferences.js'; let _highlighter: HighlighterCore | null = null; export async function getTheme(mode: 'light' | 'dark', getName: true): Promise<string>; export async function getTheme(mode: 'light' | 'dark', getName?: false): Promise<ThemeRegistration | ThemeRegistrationRaw>; export async function getTheme(mode: 'light' | 'dark', getName = false): Promise<ThemeRegistration | ThemeRegistrationRaw | string | null> { - const theme = deepClone(ColdDeviceStorage.get(mode === 'light' ? 'lightTheme' : 'darkTheme')); + const theme = deepClone(mode === 'light' ? prefer.s.lightTheme ?? defaultLightTheme : prefer.s.darkTheme ?? defaultDarkTheme); if (theme.base) { const base = [lightTheme, darkTheme].find(x => x.id === theme.base); @@ -77,19 +79,19 @@ async function initHighlighter() { ], }); - ColdDeviceStorage.watch('lightTheme', async () => { - const newTheme = await getTheme('light'); - if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) { - highlighter.loadTheme(newTheme); - } - }); - - ColdDeviceStorage.watch('darkTheme', async () => { - const newTheme = await getTheme('dark'); - if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) { - highlighter.loadTheme(newTheme); - } - }); + // TODO + //watch('lightTheme', async () => { + // const newTheme = await getTheme('light'); + // if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) { + // highlighter.loadTheme(newTheme); + // } + //}); + //watch('darkTheme', async () => { + // const newTheme = await getTheme('dark'); + // if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) { + // highlighter.loadTheme(newTheme); + // } + //}); _highlighter = highlighter; diff --git a/packages/frontend/src/scripts/collect-page-vars.ts b/packages/frontend/src/utility/collect-page-vars.ts index 5096c0669e..5096c0669e 100644 --- a/packages/frontend/src/scripts/collect-page-vars.ts +++ b/packages/frontend/src/utility/collect-page-vars.ts diff --git a/packages/frontend/src/scripts/color.ts b/packages/frontend/src/utility/color.ts index a11255ffd1..a11255ffd1 100644 --- a/packages/frontend/src/scripts/color.ts +++ b/packages/frontend/src/utility/color.ts diff --git a/packages/frontend/src/scripts/confetti.ts b/packages/frontend/src/utility/confetti.ts index 8e53a6ceeb..8e53a6ceeb 100644 --- a/packages/frontend/src/scripts/confetti.ts +++ b/packages/frontend/src/utility/confetti.ts diff --git a/packages/frontend/src/scripts/contains.ts b/packages/frontend/src/utility/contains.ts index 6137c06e85..6137c06e85 100644 --- a/packages/frontend/src/scripts/contains.ts +++ b/packages/frontend/src/utility/contains.ts diff --git a/packages/frontend/src/scripts/copy-to-clipboard.ts b/packages/frontend/src/utility/copy-to-clipboard.ts index 08f5b52dae..08a759588e 100644 --- a/packages/frontend/src/scripts/copy-to-clipboard.ts +++ b/packages/frontend/src/utility/copy-to-clipboard.ts @@ -3,9 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; + /** * Clipboardに値をコピー(TODO: 文字列以外も対応) */ export function copyToClipboard(input: string | null) { - if (input) navigator.clipboard.writeText(input); -} + if (input) { + navigator.clipboard.writeText(input); + os.toast(i18n.ts.copiedToClipboard); + } +}; diff --git a/packages/frontend/src/utility/deep-equal.ts b/packages/frontend/src/utility/deep-equal.ts new file mode 100644 index 0000000000..2859641dc7 --- /dev/null +++ b/packages/frontend/src/utility/deep-equal.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +type JsonLike = string | number | boolean | null | undefined | JsonLike[] | { [key: string]: JsonLike } | Map<string, JsonLike>; + +export function deepEqual(a: JsonLike, b: JsonLike): boolean { + if (a === b) return true; + if (typeof a !== typeof b) return false; + + if (a === null) return b === null; + + if (a === undefined) return b === undefined; + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) return false; + } + return true; + } else if (a instanceof Map && b instanceof Map) { + if (a.size !== b.size) return false; + for (const [k, v] of a) { + if (!deepEqual(v, b.get(k))) return false; + } + return true; + } else if (((typeof a) === 'object') && ((typeof b) === 'object')) { + const aks = Object.keys(a); + const bks = Object.keys(b as { [key: string]: JsonLike }); + if (aks.length !== bks.length) return false; + for (let i = 0; i < aks.length; i++) { + const k = aks[i]; + if (!deepEqual(a[k], (b as { [key: string]: JsonLike })[k])) return false; + } + return true; + } + + return false; +} diff --git a/packages/frontend/src/scripts/device-kind.ts b/packages/frontend/src/utility/device-kind.ts index 7aadb617ca..7aadb617ca 100644 --- a/packages/frontend/src/scripts/device-kind.ts +++ b/packages/frontend/src/utility/device-kind.ts diff --git a/packages/frontend/src/scripts/emoji-picker.ts b/packages/frontend/src/utility/emoji-picker.ts index 14b5cbf35e..6279786b2d 100644 --- a/packages/frontend/src/scripts/emoji-picker.ts +++ b/packages/frontend/src/utility/emoji-picker.ts @@ -3,9 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { defineAsyncComponent, Ref, ref } from 'vue'; +import { defineAsyncComponent, ref, watch } from 'vue'; +import type { Ref } from 'vue'; import { popup } from '@/os.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; /** * 絵文字ピッカーを表示する。 @@ -24,7 +25,14 @@ class EmojiPicker { } public async init() { - const emojisRef = defaultStore.reactiveState.pinnedEmojis; + const emojisRef = ref<string[]>([]); + + watch([prefer.r.emojiPaletteForMain, prefer.r.emojiPalettes], () => { + emojisRef.value = prefer.s.emojiPaletteForMain == null ? prefer.s.emojiPalettes[0].emojis : prefer.s.emojiPalettes.find(palette => palette.id === prefer.s.emojiPaletteForMain)?.emojis ?? []; + }, { + immediate: true, + }); + await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { src: this.src, pinnedEmojis: emojisRef, diff --git a/packages/frontend/src/scripts/extract-mentions.ts b/packages/frontend/src/utility/extract-mentions.ts index 89a5ce1df8..89a5ce1df8 100644 --- a/packages/frontend/src/scripts/extract-mentions.ts +++ b/packages/frontend/src/utility/extract-mentions.ts diff --git a/packages/frontend/src/scripts/extract-url-from-mfm.ts b/packages/frontend/src/utility/extract-url-from-mfm.ts index a4c84aa740..baebbff8ae 100644 --- a/packages/frontend/src/scripts/extract-url-from-mfm.ts +++ b/packages/frontend/src/utility/extract-url-from-mfm.ts @@ -4,7 +4,7 @@ */ import * as mfm from '@transfem-org/sfm-js'; -import { unique } from '@/scripts/array.js'; +import { unique } from '@/utility/array.js'; // unique without hash // [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ] diff --git a/packages/frontend/src/scripts/favicon-dot.ts b/packages/frontend/src/utility/favicon-dot.ts index a903b4cc7f..a903b4cc7f 100644 --- a/packages/frontend/src/scripts/favicon-dot.ts +++ b/packages/frontend/src/utility/favicon-dot.ts diff --git a/packages/frontend/src/scripts/file-drop.ts b/packages/frontend/src/utility/file-drop.ts index c2e863c0dc..4259fe25e9 100644 --- a/packages/frontend/src/scripts/file-drop.ts +++ b/packages/frontend/src/utility/file-drop.ts @@ -15,7 +15,7 @@ export type DroppedDirectory = { isFile: false; path: string; children: DroppedItem[]; -} +}; export async function extractDroppedItems(ev: DragEvent): Promise<DroppedItem[]> { const dropItems = ev.dataTransfer?.items; diff --git a/packages/frontend/src/scripts/focus-trap.ts b/packages/frontend/src/utility/focus-trap.ts index fb7caea830..13d3bc56d2 100644 --- a/packages/frontend/src/scripts/focus-trap.ts +++ b/packages/frontend/src/utility/focus-trap.ts @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js'; +import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js'; const focusTrapElements = new Set<HTMLElement>(); const ignoreElements = [ @@ -50,7 +50,7 @@ function releaseFocusTrap(el: HTMLElement): void { const highestZIndexElement = getHighestZIndexElement(); - if (el.parentElement != null && el !== document.body) { + if (el.parentElement != null && el !== window.document.body) { el.parentElement.childNodes.forEach((siblingNode) => { const siblingEl = getHTMLElementOrNull(siblingNode); if (!siblingEl) return; @@ -104,7 +104,7 @@ export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEl el.inert = false; } - if (el.parentElement != null && el !== document.body) { + if (el.parentElement != null && el !== window.document.body) { el.parentElement.childNodes.forEach((siblingNode) => { const siblingEl = getHTMLElementOrNull(siblingNode); if (!siblingEl) return; diff --git a/packages/frontend/src/scripts/focus.ts b/packages/frontend/src/utility/focus.ts index 81278b17ea..cbbe8226d7 100644 --- a/packages/frontend/src/scripts/focus.ts +++ b/packages/frontend/src/utility/focus.ts @@ -4,7 +4,7 @@ */ import { getScrollPosition, getScrollContainer, getStickyBottom, getStickyTop } from '@@/js/scroll.js'; -import { getElementOrNull, getNodeOrNull } from '@/scripts/get-dom-node-or-null.js'; +import { getElementOrNull, getNodeOrNull } from '@/utility/get-dom-node-or-null.js'; type MaybeHTMLElement = EventTarget | Node | Element | HTMLElement; @@ -58,7 +58,7 @@ export const focusParent = (input: MaybeHTMLElement | null | undefined, self = f const focusOrScroll = (element: HTMLElement, scroll: boolean) => { if (scroll) { - const scrollContainer = getScrollContainer(element) ?? document.documentElement; + const scrollContainer = getScrollContainer(element) ?? window.document.documentElement; const scrollContainerTop = getScrollPosition(scrollContainer); const stickyTop = getStickyTop(element, scrollContainer); const stickyBottom = getStickyBottom(element, scrollContainer); @@ -74,7 +74,7 @@ const focusOrScroll = (element: HTMLElement, scroll: boolean) => { scrollContainer.scrollTo({ top: scrollTo, behavior: 'instant' }); } - if (document.activeElement !== element) { + if (window.document.activeElement !== element) { element.focus({ preventScroll: true }); } }; diff --git a/packages/frontend/src/scripts/following-feed-utils.ts b/packages/frontend/src/utility/following-feed-utils.ts index 39f17949d6..39f17949d6 100644 --- a/packages/frontend/src/scripts/following-feed-utils.ts +++ b/packages/frontend/src/utility/following-feed-utils.ts diff --git a/packages/frontend/src/scripts/form.ts b/packages/frontend/src/utility/form.ts index 1032e97ac9..1032e97ac9 100644 --- a/packages/frontend/src/scripts/form.ts +++ b/packages/frontend/src/utility/form.ts diff --git a/packages/frontend/src/scripts/format-time-string.ts b/packages/frontend/src/utility/format-time-string.ts index 35ad77d982..d383f143e1 100644 --- a/packages/frontend/src/scripts/format-time-string.ts +++ b/packages/frontend/src/utility/format-time-string.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -const defaultLocaleStringFormats: {[index: string]: string} = { +const defaultLocaleStringFormats: { [index: string]: string } = { 'weekday': 'narrow', 'era': 'narrow', 'year': 'numeric', diff --git a/packages/frontend/src/scripts/fullscreen.ts b/packages/frontend/src/utility/fullscreen.ts index 7a0a018ef3..6702393cf1 100644 --- a/packages/frontend/src/scripts/fullscreen.ts +++ b/packages/frontend/src/utility/fullscreen.ts @@ -35,8 +35,8 @@ export const requestFullscreen = ({ videoEl, playerEl, options }: RequestFullscr export const exitFullscreen = ({ videoEl }: ExitFullscreenProps) => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (document.exitFullscreen != null) { - document.exitFullscreen(); + if (window.document.exitFullscreen != null) { + window.document.exitFullscreen(); return; } if (videoEl.webkitExitFullscreen != null) { diff --git a/packages/frontend/src/scripts/get-account-from-id.ts b/packages/frontend/src/utility/get-account-from-id.ts index 40afa10f2d..5d9662a747 100644 --- a/packages/frontend/src/scripts/get-account-from-id.ts +++ b/packages/frontend/src/utility/get-account-from-id.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { get } from '@/scripts/idb-proxy.js'; +import { get } from '@/utility/idb-proxy.js'; export async function getAccountFromId(id: string) { const accounts = await get('accounts') as { token: string; id: string; }[]; diff --git a/packages/frontend/src/scripts/get-appear-note.ts b/packages/frontend/src/utility/get-appear-note.ts index 40ce80eac9..40ce80eac9 100644 --- a/packages/frontend/src/scripts/get-appear-note.ts +++ b/packages/frontend/src/utility/get-appear-note.ts diff --git a/packages/frontend/src/scripts/get-bg-color.ts b/packages/frontend/src/utility/get-bg-color.ts index ccf60b454f..ccf60b454f 100644 --- a/packages/frontend/src/scripts/get-bg-color.ts +++ b/packages/frontend/src/utility/get-bg-color.ts diff --git a/packages/frontend/src/scripts/get-dom-node-or-null.ts b/packages/frontend/src/utility/get-dom-node-or-null.ts index fbf54675fd..fbf54675fd 100644 --- a/packages/frontend/src/scripts/get-dom-node-or-null.ts +++ b/packages/frontend/src/utility/get-dom-node-or-null.ts diff --git a/packages/frontend/src/scripts/get-drive-file-menu.ts b/packages/frontend/src/utility/get-drive-file-menu.ts index c8ab9238d3..3c6cbba002 100644 --- a/packages/frontend/src/scripts/get-drive-file-menu.ts +++ b/packages/frontend/src/utility/get-drive-file-menu.ts @@ -5,12 +5,12 @@ import * as Misskey from 'misskey-js'; import { defineAsyncComponent } from 'vue'; +import type { MenuItem } from '@/types/menu.js'; import { i18n } from '@/i18n.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import type { MenuItem } from '@/types/menu.js'; -import { defaultStore } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { prefer } from '@/preferences.js'; function rename(file: Misskey.entities.DriveFile) { os.inputText({ @@ -65,7 +65,6 @@ function toggleSensitive(file: Misskey.entities.DriveFile) { function copyUrl(file: Misskey.entities.DriveFile) { copyToClipboard(file.url); - os.success(); } /* @@ -148,9 +147,9 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss action: () => deleteFile(file), }); - if (defaultStore.state.devMode) { + if (prefer.s.devMode) { menuItems.push({ type: 'divider' }, { - icon: 'ti ti-id', + icon: 'ti ti-hash', text: i18n.ts.copyFileId, action: () => { copyToClipboard(file.id); diff --git a/packages/frontend/src/scripts/get-embed-code.ts b/packages/frontend/src/utility/get-embed-code.ts index 158ab9c7f8..d458e64f19 100644 --- a/packages/frontend/src/scripts/get-embed-code.ts +++ b/packages/frontend/src/utility/get-embed-code.ts @@ -4,11 +4,11 @@ */ import { defineAsyncComponent } from 'vue'; import { v4 as uuid } from 'uuid'; -import type { EmbedParams, EmbeddableEntity } from '@@/js/embed-page.js'; import { url } from '@@/js/config.js'; -import * as os from '@/os.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { defaultEmbedParams, embedRouteWithScrollbar } from '@@/js/embed-page.js'; +import type { EmbedParams, EmbeddableEntity } from '@@/js/embed-page.js'; +import * as os from '@/os.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; const MOBILE_THRESHOLD = 500; @@ -74,7 +74,6 @@ export function genEmbedCode(entity: EmbeddableEntity, id: string, params?: Embe // PCじゃない場合はコードカスタマイズ画面を出さずにそのままコピー if (window.innerWidth < MOBILE_THRESHOLD) { copyToClipboard(getEmbedCode(`/embed/${entity}/${id}`, _params)); - os.success(); } else { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkEmbedCodeGenDialog.vue')), { entity, diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts index 463fec6f97..6762ce7e43 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/utility/get-note-menu.ts @@ -3,25 +3,28 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { defineAsyncComponent, Ref, ShallowRef } from 'vue'; +import { defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; import { url } from '@@/js/config.js'; import { claimAchievement } from './achievements.js'; +import type { Ref, ShallowRef } from 'vue'; import type { MenuItem } from '@/types/menu.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { defaultStore, noteActions } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { store } from '@/store.js'; import { miLocalStorage } from '@/local-storage.js'; -import { getUserMenu } from '@/scripts/get-user-menu.js'; +import { getUserMenu } from '@/utility/get-user-menu.js'; import { clipsCache, favoritedChannelsCache } from '@/cache.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; -import { isSupportShare } from '@/scripts/navigator.js'; -import { getAppearNote } from '@/scripts/get-appear-note.js'; -import { genEmbedCode } from '@/scripts/get-embed-code.js'; +import { isSupportShare } from '@/utility/navigator.js'; +import { getAppearNote } from '@/utility/get-appear-note.js'; +import { genEmbedCode } from '@/utility/get-embed-code.js'; +import { prefer } from '@/preferences.js'; +import { getPluginHandlers } from '@/plugin.js'; export async function getNoteClipMenu(props: { note: Misskey.entities.Note; @@ -154,7 +157,6 @@ export function getCopyNoteLinkMenu(note: Misskey.entities.Note, text: string): text, action: (): void => { copyToClipboard(`${url}/notes/${note.id}`); - os.success(); }, }; } @@ -245,7 +247,6 @@ export function getNoteMenu(props: { function copyContent(): void { copyToClipboard(appearNote.text); - os.success(); } function togglePin(pin: boolean): void { @@ -332,7 +333,6 @@ export function getNoteMenu(props: { text: i18n.ts.copyRemoteLink, action: () => { copyToClipboard(appearNote.url ?? appearNote.uri); - os.success(); }, }, { icon: 'ti ti-external-link', @@ -497,7 +497,6 @@ export function getNoteMenu(props: { text: i18n.ts.copyRemoteLink, action: () => { copyToClipboard(appearNote.url ?? appearNote.uri); - os.success(); }, }, { icon: 'ti ti-external-link', @@ -511,6 +510,7 @@ export function getNoteMenu(props: { } } + const noteActions = getPluginHandlers('note_action'); if (noteActions.length > 0) { menuItems.push({ type: 'divider' }); @@ -523,13 +523,12 @@ export function getNoteMenu(props: { }))); } - if (defaultStore.state.devMode) { + if (prefer.s.devMode) { menuItems.push({ type: 'divider' }, { - icon: 'ti ti-id', + icon: 'ti ti-hash', text: i18n.ts.copyNoteId, action: () => { copyToClipboard(appearNote.id); - os.success(); }, }); } @@ -574,7 +573,7 @@ export function getRenoteMenu(props: { icon: 'ti ti-repeat', action: () => { const el = props.renoteButton.value; - if (el) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -612,7 +611,7 @@ export function getRenoteMenu(props: { icon: 'ti ti-repeat', action: () => { const el = props.renoteButton.value; - if (el) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -621,8 +620,8 @@ export function getRenoteMenu(props: { }); } - const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; - const localOnly = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; + const configuredVisibility = prefer.s.rememberNoteVisibility ? store.s.visibility : prefer.s.defaultNoteVisibility; + const localOnly = prefer.s.rememberNoteVisibility ? store.s.localOnly : prefer.s.defaultNoteLocalOnly; let visibility = appearNote.visibility; visibility = smallerVisibility(visibility, configuredVisibility); @@ -663,7 +662,7 @@ export function getRenoteMenu(props: { text: channel.name, action: () => { const el = props.renoteButton.value; - if (el) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); diff --git a/packages/frontend/src/scripts/get-note-summary.ts b/packages/frontend/src/utility/get-note-summary.ts index 4e093bcf4c..4e093bcf4c 100644 --- a/packages/frontend/src/scripts/get-note-summary.ts +++ b/packages/frontend/src/utility/get-note-summary.ts diff --git a/packages/frontend/src/scripts/get-note-versions-menu.ts b/packages/frontend/src/utility/get-note-versions-menu.ts index 345cec9018..345cec9018 100644 --- a/packages/frontend/src/scripts/get-note-versions-menu.ts +++ b/packages/frontend/src/utility/get-note-versions-menu.ts diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts index 2fbdaf5d3c..9693197ab4 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/utility/get-user-menu.ts @@ -6,21 +6,22 @@ import { toUnicode } from 'punycode.js'; import { defineAsyncComponent, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; -import { i18n } from '@/i18n.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { host, url } from '@@/js/config.js'; +import type { Router } from '@/router.js'; +import type { MenuItem } from '@/types/menu.js'; +import { i18n } from '@/i18n.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; 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 { misskeyApi } from '@/utility/misskey-api.js'; +import { $i, iAmModerator } from '@/i.js'; +import { notesSearchAvailable, canSearchNonLocalNotes } from '@/utility/check-permissions.js'; import { antennasCache, rolesCache, userListsCache } from '@/cache.js'; -import { mainRouter } from '@/router/main.js'; -import { genEmbedCode } from '@/scripts/get-embed-code.js'; -import type { MenuItem } from '@/types/menu.js'; +import { mainRouter } from '@/router.js'; +import { genEmbedCode } from '@/utility/get-embed-code.js'; +import { prefer } from '@/preferences.js'; +import { getPluginHandlers } from '@/plugin.js'; -export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) { +export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router = mainRouter) { const meId = $i ? $i.id : null; const cleanups = [] as (() => void)[]; @@ -149,24 +150,6 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter const menuItems: MenuItem[] = []; - menuItems.push({ - icon: 'ti ti-at', - text: i18n.ts.copyUsername, - action: () => { - copyToClipboard(`@${user.username}@${user.host ?? host}`); - }, - }); - - if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) { - menuItems.push({ - icon: 'ti ti-search', - text: i18n.ts.searchThisUsersNotes, - action: () => { - router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`); - }, - }); - } - if (iAmModerator) { menuItems.push({ icon: 'ti ti-user-exclamation', @@ -174,10 +157,27 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter action: () => { router.push(`/admin/user/${user.id}`); }, - }); + }, { type: 'divider' }); } menuItems.push({ + icon: 'ti ti-at', + text: i18n.ts.copyUsername, + action: () => { + copyToClipboard(`@${user.username}@${user.host ?? host}`); + }, + }); + + menuItems.push({ + icon: 'ti ti-share', + text: i18n.ts.copyProfileUrl, + action: () => { + const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`; + copyToClipboard(`${url}/${canonical}`); + }, + }); + + menuItems.push({ icon: 'ti ti-rss', text: i18n.ts.copyRSS, action: () => { @@ -208,24 +208,18 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter }); } - menuItems.push({ - icon: 'ti ti-share', - text: i18n.ts.copyProfileUrl, - action: () => { - const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`; - copyToClipboard(`${url}/${canonical}`); - }, - }); - - if ($i) { + if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) { menuItems.push({ - icon: 'ti ti-mail', - text: i18n.ts.sendMessage, + icon: 'ti ti-search', + text: i18n.ts.searchThisUsersNotes, action: () => { - const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`; - os.post({ specified: user, initialText: `${canonical} ` }); + router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`); }, - }, { type: 'divider' }, { + }); + } + + if ($i) { + menuItems.push({ type: 'divider' }, { icon: 'ti ti-pencil', text: i18n.ts.editMemo, action: editMemo, @@ -250,7 +244,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter listId: list.id, userId: user.id, }).then(() => { - list.userIds?.splice(list.userIds?.indexOf(user.id), 1); + list.userIds?.splice(list.userIds.indexOf(user.id), 1); }); } })); @@ -361,6 +355,18 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter //} menuItems.push({ type: 'divider' }, { + icon: 'ti ti-mail', + text: i18n.ts.sendMessage, + action: () => { + const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`; + os.post({ specified: user, initialText: `${canonical} ` }); + }, + }, { + type: 'link', + icon: 'ti ti-messages', + text: i18n.ts._chat.chatWithThisUser, + to: `/chat/user/${user.id}`, + }, { type: 'divider' }, { icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off', text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute, action: toggleMute, @@ -397,9 +403,9 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter }); } - if (defaultStore.state.devMode) { + if (prefer.s.devMode) { menuItems.push({ type: 'divider' }, { - icon: 'ti ti-id', + icon: 'ti ti-hash', text: i18n.ts.copyUserId, action: () => { copyToClipboard(user.id); @@ -417,6 +423,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter }); } + const userActions = getPluginHandlers('user_action'); if (userActions.length > 0) { menuItems.push({ type: 'divider' }, ...userActions.map(action => ({ icon: 'ti ti-plug', diff --git a/packages/frontend/src/scripts/get-user-name.ts b/packages/frontend/src/utility/get-user-name.ts index 56e91abba0..56e91abba0 100644 --- a/packages/frontend/src/scripts/get-user-name.ts +++ b/packages/frontend/src/utility/get-user-name.ts diff --git a/packages/frontend/src/scripts/hotkey.ts b/packages/frontend/src/utility/hotkey.ts index d5304ee210..852abb6140 100644 --- a/packages/frontend/src/scripts/hotkey.ts +++ b/packages/frontend/src/utility/hotkey.ts @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -import { getHTMLElementOrNull } from "@/scripts/get-dom-node-or-null.js"; +import { getHTMLElementOrNull } from "@/utility/get-dom-node-or-null.js"; //#region types export type Keymap = Record<string, CallbackFunction | CallbackObject>; @@ -54,9 +54,9 @@ export const makeHotkey = (keymap: Keymap) => { const actions = parseKeymap(keymap); 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; + if (window.document.activeElement != null) { + if (IGNORE_ELEMENTS.includes(window.document.activeElement.tagName.toLowerCase())) return; + if (getHTMLElementOrNull(window.document.activeElement)?.isContentEditable) return; } for (const action of actions) { if (matchPatterns(ev, action)) { diff --git a/packages/frontend/src/scripts/idb-proxy.ts b/packages/frontend/src/utility/idb-proxy.ts index 20f51660c7..20f51660c7 100644 --- a/packages/frontend/src/scripts/idb-proxy.ts +++ b/packages/frontend/src/utility/idb-proxy.ts diff --git a/packages/frontend/src/scripts/idle-render.ts b/packages/frontend/src/utility/idle-render.ts index 6adfedcb9f..6adfedcb9f 100644 --- a/packages/frontend/src/scripts/idle-render.ts +++ b/packages/frontend/src/utility/idle-render.ts diff --git a/packages/frontend/src/scripts/init-chart.ts b/packages/frontend/src/utility/init-chart.ts index 41e1636aa7..260899c1d7 100644 --- a/packages/frontend/src/scripts/init-chart.ts +++ b/packages/frontend/src/utility/init-chart.ts @@ -24,7 +24,7 @@ import { import gradient from 'chartjs-plugin-gradient'; import zoomPlugin from 'chartjs-plugin-zoom'; import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import 'chartjs-adapter-date-fns'; export function initChart() { @@ -50,9 +50,9 @@ export function initChart() { ); // フォントカラー - Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-fg'); + Chart.defaults.color = getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-fg'); - Chart.defaults.borderColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + Chart.defaults.borderColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; Chart.defaults.animation = false; } diff --git a/packages/frontend/src/scripts/initialize-sw.ts b/packages/frontend/src/utility/initialize-sw.ts index 867ebf19ed..867ebf19ed 100644 --- a/packages/frontend/src/scripts/initialize-sw.ts +++ b/packages/frontend/src/utility/initialize-sw.ts diff --git a/packages/frontend/src/scripts/intl-const.ts b/packages/frontend/src/utility/intl-const.ts index 385f59ec39..385f59ec39 100644 --- a/packages/frontend/src/scripts/intl-const.ts +++ b/packages/frontend/src/utility/intl-const.ts diff --git a/packages/frontend/src/utility/intl-string.ts b/packages/frontend/src/utility/intl-string.ts new file mode 100644 index 0000000000..a5b5bbb592 --- /dev/null +++ b/packages/frontend/src/utility/intl-string.ts @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { versatileLang } from '@@/js/intl-const.js'; +import type { toHiragana as toHiraganaType } from 'wanakana'; + +let toHiragana: typeof toHiraganaType = (str?: string) => str ?? ''; +let isWanakanaLoaded = false; + +/** + * ローマ字変換のセットアップ(日本語以外の環境で読み込まないのでlazy-loading) + * + * ここの比較系関数を使う際は事前に呼び出す必要がある + */ +export async function initIntlString(forceWanakana = false) { + if ((!versatileLang.includes('ja') && !forceWanakana) || isWanakanaLoaded) return; + const { toHiragana: _toHiragana } = await import('wanakana'); + toHiragana = _toHiragana; + isWanakanaLoaded = true; +} + +/** + * - 全角英数字を半角に + * - 半角カタカナを全角に + * - 濁点・半濁点がリガチャになっている(例: `か` + `゛` )ひらがな・カタカナを結合 + * - 異体字を正規化 + * - 小文字に揃える + * - 文字列のトリム + */ +export function normalizeString(str: string) { + const segmenter = new Intl.Segmenter(versatileLang, { granularity: 'grapheme' }); + return [...segmenter.segment(str)].map(({ segment }) => segment.normalize('NFKC')).join('').toLowerCase().trim(); +} + +// https://qiita.com/non-caffeine/items/77360dda05c8ce510084 +const hyphens = [ + 0x002d, // hyphen-minus + 0x02d7, // modifier letter minus sign + 0x1173, // hangul jongseong eu + 0x1680, // ogham space mark + 0x1b78, // balinese musical symbol left-hand open pang + 0x2010, // hyphen + 0x2011, // non-breaking hyphen + 0x2012, // figure dash + 0x2013, // en dash + 0x2014, // em dash + 0x2015, // horizontal bar + 0x2043, // hyphen bullet + 0x207b, // superscript minus + 0x2212, // minus sign + 0x25ac, // black rectangle + 0x2500, // box drawings light horizontal + 0x2501, // box drawings heavy horizontal + 0x2796, // heavy minus sign + 0x30fc, // katakana-hiragana prolonged sound mark + 0x3161, // hangul letter eu + 0xfe58, // small em dash + 0xfe63, // small hyphen-minus + 0xff0d, // fullwidth hyphen-minus + 0xff70, // halfwidth katakana-hiragana prolonged sound mark + 0x10110, // aegean number ten + 0x10191, // roman uncia sign +]; + +const hyphensCodePoints = hyphens.map(code => `\\u{${code.toString(16).padStart(4, '0')}}`); + +/** ハイフンを統一(ローマ字半角入力時に`ー`と`-`が判定できない問題の調整) */ +export function normalizeHyphens(str: string) { + return str.replace(new RegExp(`[${hyphensCodePoints.join('')}]`, 'ug'), '\u002d'); +} + +/** + * `normalizeString` に加えて、カタカナ・ローマ字をひらがなに揃え、ハイフンを統一 + * + * (ローマ字じゃないものもローマ字として認識され変換されるので、文字列比較の際は `normalizeString` を併用する必要あり) + */ +export function normalizeStringWithHiragana(str: string) { + return normalizeHyphens(toHiragana(normalizeString(str), { convertLongVowelMark: false })); +} + +/** aとbが同じかどうか */ +export function compareStringEquals(a: string, b: string) { + return ( + normalizeString(a) === normalizeString(b) || + normalizeStringWithHiragana(a) === normalizeStringWithHiragana(b) + ); +} + +/** baseにqueryが含まれているかどうか */ +export function compareStringIncludes(base: string, query: string) { + return ( + normalizeString(base).includes(normalizeString(query)) || + normalizeStringWithHiragana(base).includes(normalizeStringWithHiragana(query)) + ); +} diff --git a/packages/frontend/src/scripts/is-device-darkmode.ts b/packages/frontend/src/utility/is-device-darkmode.ts index 4f487c7cb9..4f487c7cb9 100644 --- a/packages/frontend/src/scripts/is-device-darkmode.ts +++ b/packages/frontend/src/utility/is-device-darkmode.ts diff --git a/packages/frontend/src/scripts/isFfVisibleForMe.ts b/packages/frontend/src/utility/isFfVisibleForMe.ts index e28e5725bc..48ef1c4e49 100644 --- a/packages/frontend/src/scripts/isFfVisibleForMe.ts +++ b/packages/frontend/src/utility/isFfVisibleForMe.ts @@ -4,7 +4,7 @@ */ import * as Misskey from 'misskey-js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): boolean { if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true; diff --git a/packages/frontend/src/scripts/key-event.ts b/packages/frontend/src/utility/key-event.ts index a72776d48c..020a6c2174 100644 --- a/packages/frontend/src/scripts/key-event.ts +++ b/packages/frontend/src/utility/key-event.ts @@ -7,7 +7,7 @@ * {@link KeyboardEvent.code} の値を表す文字列。不足分は適宜追加する * @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values */ -export type KeyCode = +export type KeyCode = ( | 'Backspace' | 'Tab' | 'Enter' @@ -94,32 +94,32 @@ export type KeyCode = | 'Quote' | 'Meta' | 'AltGraph' - ; +); /** * 修飾キーを表す文字列。不足分は適宜追加する。 */ -export type KeyModifier = +export type KeyModifier = ( | 'Shift' | 'Control' | 'Alt' | 'Meta' - ; +); /** * 押下されたキー以外の状態を表す文字列。不足分は適宜追加する。 */ -export type KeyState = +export type KeyState = ( | 'composing' | 'repeat' - ; +); export type KeyEventHandler = { modifiers?: KeyModifier[]; states?: KeyState[]; code: KeyCode | 'any'; handler: (event: KeyboardEvent) => void; -} +}; export function handleKeyEvent(event: KeyboardEvent, handlers: KeyEventHandler[]) { function checkModifier(ev: KeyboardEvent, modifiers? : KeyModifier[]) { diff --git a/packages/frontend/src/scripts/langmap.ts b/packages/frontend/src/utility/langmap.ts index b32de15963..b32de15963 100644 --- a/packages/frontend/src/scripts/langmap.ts +++ b/packages/frontend/src/utility/langmap.ts diff --git a/packages/frontend/src/scripts/libopenmpt/LICENSE b/packages/frontend/src/utility/libopenmpt/LICENSE index 2daefe981f..2daefe981f 100644 --- a/packages/frontend/src/scripts/libopenmpt/LICENSE +++ b/packages/frontend/src/utility/libopenmpt/LICENSE diff --git a/packages/frontend/src/scripts/libopenmpt/libopenmpt.js b/packages/frontend/src/utility/libopenmpt/libopenmpt.js index e2535529ce..e2535529ce 100644 --- a/packages/frontend/src/scripts/libopenmpt/libopenmpt.js +++ b/packages/frontend/src/utility/libopenmpt/libopenmpt.js diff --git a/packages/frontend/src/scripts/libopenmpt/libopenmpt.wasm b/packages/frontend/src/utility/libopenmpt/libopenmpt.wasm Binary files differindex 8c11a68d5d..8c11a68d5d 100644 --- a/packages/frontend/src/scripts/libopenmpt/libopenmpt.wasm +++ b/packages/frontend/src/utility/libopenmpt/libopenmpt.wasm diff --git a/packages/frontend/src/scripts/libopenmpt/readme.md b/packages/frontend/src/utility/libopenmpt/readme.md index 4b99a6c40f..4b99a6c40f 100644 --- a/packages/frontend/src/scripts/libopenmpt/readme.md +++ b/packages/frontend/src/utility/libopenmpt/readme.md diff --git a/packages/frontend/src/scripts/login-id.ts b/packages/frontend/src/utility/login-id.ts index b52735caa0..b52735caa0 100644 --- a/packages/frontend/src/scripts/login-id.ts +++ b/packages/frontend/src/utility/login-id.ts diff --git a/packages/frontend/src/utility/lookup.ts b/packages/frontend/src/utility/lookup.ts new file mode 100644 index 0000000000..90611094fa --- /dev/null +++ b/packages/frontend/src/utility/lookup.ts @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Router } from '@/router.js'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import { mainRouter } from '@/router.js'; + +export async function lookup(router?: Router) { + const _router = router ?? mainRouter; + + const { canceled, result: temp } = await os.inputText({ + title: i18n.ts.lookup, + }); + const query = temp ? temp.trim() : ''; + if (canceled || query.length <= 1) return; + + if (query.startsWith('@') && !query.includes(' ')) { + _router.push(`/${query}`); + return; + } + + if (query.startsWith('#')) { + _router.push(`/tags/${encodeURIComponent(query.substring(1))}`); + return; + } + + if (query.startsWith('https://')) { + const res = await apLookup(query); + + if (res.type === 'User') { + _router.push(`/@${res.object.username}@${res.object.host}`); + } else if (res.type === 'Note') { + _router.push(`/notes/${res.object.id}`); + } + + return; + } +} + +export async function apLookup(query: string) { + const promise = misskeyApi('ap/show', { + uri: query, + }); + + os.promiseDialog(promise, null, (err) => { + let title = i18n.ts.somethingHappened; + let text = err.message + '\n' + err.id; + + switch (err.id) { + case '974b799e-1a29-4889-b706-18d4dd93e266': + title = i18n.ts._remoteLookupErrors._federationNotAllowed.title; + text = i18n.ts._remoteLookupErrors._federationNotAllowed.description; + break; + case '1a5eab56-e47b-48c2-8d5e-217b897d70db': + title = i18n.ts._remoteLookupErrors._uriInvalid.title; + text = i18n.ts._remoteLookupErrors._uriInvalid.description; + break; + case '81b539cf-4f57-4b29-bc98-032c33c0792e': + title = i18n.ts._remoteLookupErrors._requestFailed.title; + text = i18n.ts._remoteLookupErrors._requestFailed.description; + break; + case '70193c39-54f3-4813-82f0-70a680f7495b': + title = i18n.ts._remoteLookupErrors._responseInvalid.title; + text = i18n.ts._remoteLookupErrors._responseInvalid.description; + break; + case 'dc94d745-1262-4e63-a17d-fecaa57efc82': + title = i18n.ts._remoteLookupErrors._noSuchObject.title; + text = i18n.ts._remoteLookupErrors._noSuchObject.description; + break; + } + + os.alert({ + type: 'error', + title, + text, + }); + }, i18n.ts.fetchingAsApObject); + + return await promise; +} diff --git a/packages/frontend/src/scripts/media-has-audio.ts b/packages/frontend/src/utility/media-has-audio.ts index 4bf3ee5d97..4bf3ee5d97 100644 --- a/packages/frontend/src/scripts/media-has-audio.ts +++ b/packages/frontend/src/utility/media-has-audio.ts diff --git a/packages/frontend/src/scripts/media-proxy.ts b/packages/frontend/src/utility/media-proxy.ts index 78eba35ead..78eba35ead 100644 --- a/packages/frontend/src/scripts/media-proxy.ts +++ b/packages/frontend/src/utility/media-proxy.ts diff --git a/packages/frontend/src/scripts/merge.ts b/packages/frontend/src/utility/merge.ts index 004b6d42a4..004b6d42a4 100644 --- a/packages/frontend/src/scripts/merge.ts +++ b/packages/frontend/src/utility/merge.ts diff --git a/packages/frontend/src/scripts/mfm-function-picker.ts b/packages/frontend/src/utility/mfm-function-picker.ts index 2911469cdd..2d0f950f20 100644 --- a/packages/frontend/src/scripts/mfm-function-picker.ts +++ b/packages/frontend/src/utility/mfm-function-picker.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Ref, nextTick } from 'vue'; +import { nextTick } from 'vue'; +import type { Ref } from 'vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { MFM_TAGS } from '@@/js/const.js'; diff --git a/packages/frontend/src/scripts/misskey-api.ts b/packages/frontend/src/utility/misskey-api.ts index dc07ad477b..72ba54ade3 100644 --- a/packages/frontend/src/scripts/misskey-api.ts +++ b/packages/frontend/src/utility/misskey-api.ts @@ -6,7 +6,7 @@ import * as Misskey from 'misskey-js'; import { ref } from 'vue'; import { apiUrl } from '@@/js/config.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; export const pendingApiRequestsCount = ref(0); export type Endpoint = keyof Misskey.Endpoints; diff --git a/packages/frontend/src/scripts/navigator.ts b/packages/frontend/src/utility/navigator.ts index ffc0a457f4..ffc0a457f4 100644 --- a/packages/frontend/src/scripts/navigator.ts +++ b/packages/frontend/src/utility/navigator.ts diff --git a/packages/frontend/src/scripts/physics.ts b/packages/frontend/src/utility/physics.ts index 8a4e9319b3..5de34fd094 100644 --- a/packages/frontend/src/scripts/physics.ts +++ b/packages/frontend/src/utility/physics.ts @@ -28,7 +28,7 @@ export function physics(container: HTMLElement) { // create renderer const render = Matter.Render.create({ engine: engine, - //element: document.getElementById('debug'), + //element: window.document.getElementById('debug'), options: { width: containerWidth, height: containerHeight, diff --git a/packages/frontend/src/scripts/player-url-transform.ts b/packages/frontend/src/utility/player-url-transform.ts index 39c6df6500..39c6df6500 100644 --- a/packages/frontend/src/scripts/player-url-transform.ts +++ b/packages/frontend/src/utility/player-url-transform.ts diff --git a/packages/frontend/src/scripts/please-login.ts b/packages/frontend/src/utility/please-login.ts index a8a330eb6d..9253105f48 100644 --- a/packages/frontend/src/scripts/please-login.ts +++ b/packages/frontend/src/utility/please-login.ts @@ -4,7 +4,7 @@ */ import { defineAsyncComponent } from 'vue'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { popup } from '@/os.js'; diff --git a/packages/frontend/src/scripts/popout.ts b/packages/frontend/src/utility/popout.ts index 5b141222e8..5b141222e8 100644 --- a/packages/frontend/src/scripts/popout.ts +++ b/packages/frontend/src/utility/popout.ts diff --git a/packages/frontend/src/scripts/popup-position.ts b/packages/frontend/src/utility/popup-position.ts index be49532cf8..be49532cf8 100644 --- a/packages/frontend/src/scripts/popup-position.ts +++ b/packages/frontend/src/utility/popup-position.ts diff --git a/packages/frontend/src/scripts/post-message.ts b/packages/frontend/src/utility/post-message.ts index 11b6f52ddd..11b6f52ddd 100644 --- a/packages/frontend/src/scripts/post-message.ts +++ b/packages/frontend/src/utility/post-message.ts diff --git a/packages/frontend/src/utility/random-id.ts b/packages/frontend/src/utility/random-id.ts new file mode 100644 index 0000000000..4e5943a97f --- /dev/null +++ b/packages/frontend/src/utility/random-id.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const CHARS = 'abcdefghijklmnopqrstuvwxyz'; // CSSの<custom-ident>などで使われることもあるのでa-z以外使うな + +export function randomId(length = 32, characters = CHARS) { + let result = ''; + const charactersLength = characters.length; + for ( let i = 0; i < length; i++ ) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} diff --git a/packages/frontend/src/scripts/reaction-picker.ts b/packages/frontend/src/utility/reaction-picker.ts index 7aec05c0cf..7c159fa2da 100644 --- a/packages/frontend/src/scripts/reaction-picker.ts +++ b/packages/frontend/src/utility/reaction-picker.ts @@ -4,9 +4,10 @@ */ import * as Misskey from 'misskey-js'; -import { defineAsyncComponent, Ref, ref } from 'vue'; +import { defineAsyncComponent, ref, watch } from 'vue'; +import type { Ref } from 'vue'; import { popup } from '@/os.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; class ReactionPicker { private src: Ref<HTMLElement | null> = ref(null); @@ -20,7 +21,14 @@ class ReactionPicker { } public async init() { - const reactionsRef = defaultStore.reactiveState.reactions; + const reactionsRef = ref<string[]>([]); + + watch([prefer.r.emojiPaletteForReaction, prefer.r.emojiPalettes], () => { + reactionsRef.value = prefer.s.emojiPaletteForReaction == null ? prefer.s.emojiPalettes[0].emojis : prefer.s.emojiPalettes.find(palette => palette.id === prefer.s.emojiPaletteForReaction)?.emojis ?? []; + }, { + immediate: true, + }); + await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { src: this.src, pinnedEmojis: reactionsRef, diff --git a/packages/frontend/src/scripts/reload-ask.ts b/packages/frontend/src/utility/reload-ask.ts index 733d91b85a..7c7ea113d4 100644 --- a/packages/frontend/src/scripts/reload-ask.ts +++ b/packages/frontend/src/utility/reload-ask.ts @@ -5,7 +5,7 @@ import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; +import { unisonReload } from '@/utility/unison-reload.js'; let isReloadConfirming = false; @@ -35,6 +35,6 @@ export async function reloadAsk(opts: { if (opts.unison) { unisonReload(); } else { - location.reload(); + window.location.reload(); } } diff --git a/packages/frontend/src/scripts/sanitize-html.ts b/packages/frontend/src/utility/sanitize-html.ts index fc9db9bbdb..fc9db9bbdb 100644 --- a/packages/frontend/src/scripts/sanitize-html.ts +++ b/packages/frontend/src/utility/sanitize-html.ts diff --git a/packages/frontend/src/scripts/search-emoji.ts b/packages/frontend/src/utility/search-emoji.ts index 4192a2df8f..4192a2df8f 100644 --- a/packages/frontend/src/scripts/search-emoji.ts +++ b/packages/frontend/src/utility/search-emoji.ts diff --git a/packages/frontend/src/scripts/search-engine-map.ts b/packages/frontend/src/utility/search-engine-map.ts index 03e5061597..03e5061597 100644 --- a/packages/frontend/src/scripts/search-engine-map.ts +++ b/packages/frontend/src/utility/search-engine-map.ts diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/utility/select-file.ts index c25b4d73bd..b9b3687483 100644 --- a/packages/frontend/src/scripts/select-file.ts +++ b/packages/frontend/src/utility/select-file.ts @@ -6,11 +6,11 @@ import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; -import { uploadFile } from '@/scripts/upload.js'; +import { uploadFile } from '@/utility/upload.js'; +import { prefer } from '@/preferences.js'; export function chooseFileFromPc( multiple: boolean, @@ -20,12 +20,12 @@ export function chooseFileFromPc( nameConverter?: (file: File) => string | undefined; }, ): Promise<Misskey.entities.DriveFile[]> { - const uploadFolder = options?.uploadFolder ?? defaultStore.state.uploadFolder; - const keepOriginal = options?.keepOriginal ?? defaultStore.state.keepOriginalUploading; + const uploadFolder = options?.uploadFolder ?? prefer.s.uploadFolder; + const keepOriginal = options?.keepOriginal ?? prefer.s.keepOriginalUploading; const nameConverter = options?.nameConverter ?? (() => undefined); return new Promise((res, rej) => { - const input = document.createElement('input'); + const input = window.document.createElement('input'); input.type = 'file'; input.multiple = multiple; input.onchange = () => { @@ -82,7 +82,7 @@ export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> { misskeyApi('drive/files/upload-from-url', { url: url, - folderId: defaultStore.state.uploadFolder, + folderId: prefer.s.uploadFolder, marker, }); @@ -96,7 +96,7 @@ export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> { function select(src: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> { return new Promise((res, rej) => { - const keepOriginal = ref(defaultStore.state.keepOriginalUploading); + const keepOriginal = ref(prefer.s.keepOriginalUploading); os.popupMenu([label ? { text: label, diff --git a/packages/frontend/src/scripts/show-moved-dialog.ts b/packages/frontend/src/utility/show-moved-dialog.ts index 35b3ef79d8..db21b028cd 100644 --- a/packages/frontend/src/scripts/show-moved-dialog.ts +++ b/packages/frontend/src/utility/show-moved-dialog.ts @@ -4,7 +4,7 @@ */ import * as os from '@/os.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; export function showMovedDialog() { diff --git a/packages/frontend/src/scripts/show-suspended-dialog.ts b/packages/frontend/src/utility/show-suspended-dialog.ts index 8b89dbb936..8b89dbb936 100644 --- a/packages/frontend/src/scripts/show-suspended-dialog.ts +++ b/packages/frontend/src/utility/show-suspended-dialog.ts diff --git a/packages/frontend/src/scripts/show-system-account-dialog.ts b/packages/frontend/src/utility/show-system-account-dialog.ts index 3c28d901fc..3c28d901fc 100644 --- a/packages/frontend/src/scripts/show-system-account-dialog.ts +++ b/packages/frontend/src/utility/show-system-account-dialog.ts diff --git a/packages/frontend/src/scripts/shuffle.ts b/packages/frontend/src/utility/shuffle.ts index 1f6ef1928c..1f6ef1928c 100644 --- a/packages/frontend/src/scripts/shuffle.ts +++ b/packages/frontend/src/utility/shuffle.ts diff --git a/packages/frontend/src/scripts/snowfall-effect.ts b/packages/frontend/src/utility/snowfall-effect.ts index d88bdb6660..5c86969876 100644 --- a/packages/frontend/src/scripts/snowfall-effect.ts +++ b/packages/frontend/src/utility/snowfall-effect.ts @@ -156,7 +156,7 @@ export class SnowfallEffect { easing: 0.0005, }; /** - * @throws {Error} - Thrown when it fails to get WebGL context for the canvas + * @throws {Error} - Thrown when it fails to get WebGL context for the canvas */ constructor(options: { sakura?: boolean; @@ -172,7 +172,7 @@ export class SnowfallEffect { const gl = canvas.getContext('webgl2', { antialias: true }); if (gl == null) throw new Error('Failed to get WebGL context'); - document.body.append(canvas); + window.document.body.append(canvas); this.canvas = canvas; this.gl = gl; @@ -190,7 +190,7 @@ export class SnowfallEffect { } private initCanvas(): HTMLCanvasElement { - const canvas = document.createElement('canvas'); + const canvas = window.document.createElement('canvas'); Object.assign(canvas.style, { position: 'fixed', diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/utility/sound.ts index 2008afe045..f217bdfcd5 100644 --- a/packages/frontend/src/scripts/sound.ts +++ b/packages/frontend/src/utility/sound.ts @@ -3,8 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { SoundStore } from '@/store.js'; -import { defaultStore } from '@/store.js'; +import type { SoundStore } from '@/preferences/def.js'; +import { prefer } from '@/preferences.js'; +import { PREF_DEF } from '@/preferences/def.js'; let ctx: AudioContext; const cache = new Map<string, AudioBuffer>(); @@ -76,6 +77,7 @@ export const operationTypes = [ 'note', 'notification', 'reaction', + 'chatMessage', ] as const; /** サウンドの種類 */ @@ -107,7 +109,7 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; }) let response: Response; try { - response = await fetch(url); + response = await window.fetch(url); } catch (err) { return; } @@ -127,11 +129,11 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; }) * @param type スプライトの種類を指定 */ export function playMisskeySfx(operationType: OperationType) { - const sound = defaultStore.state[`sound_${operationType}`]; + const sound = prefer.s[`sound.on.${operationType}`]; playMisskeySfxFile(sound).then((succeed) => { if (!succeed && sound.type === '_driveFile_') { // ドライブファイルが存在しない場合はデフォルトのサウンドを再生する - const soundName = defaultStore.def[`sound_${operationType}`].default.type as Exclude<SoundType, '_driveFile_'>; + const soundName = PREF_DEF[`sound_${operationType}`].default.type as Exclude<SoundType, '_driveFile_'>; if (_DEV_) console.log(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`); playMisskeySfxFileInternal({ type: soundName, @@ -166,7 +168,7 @@ async function playMisskeySfxFileInternal(soundStore: SoundStore): Promise<boole if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) { return false; } - const masterVolume = defaultStore.state.sound_masterVolume; + const masterVolume = prefer.s['sound.masterVolume']; if (isMute() || masterVolume === 0 || soundStore.volume === 0) { return true; // ミュート時は成功として扱う } @@ -198,10 +200,10 @@ export function createSourceNode(buffer: AudioBuffer, opts: { pan?: number; playbackRate?: number; }): { - soundSource: AudioBufferSourceNode; - panNode: StereoPannerNode; - gainNode: GainNode; -} { + soundSource: AudioBufferSourceNode; + panNode: StereoPannerNode; + gainNode: GainNode; + } { const panNode = ctx.createStereoPanner(); panNode.pan.value = opts.pan ?? 0; @@ -225,7 +227,7 @@ export function createSourceNode(buffer: AudioBuffer, opts: { * @param file ファイルのURL(ドライブIDではない) */ export async function getSoundDuration(file: string): Promise<number> { - const audioEl = document.createElement('audio'); + const audioEl = window.document.createElement('audio'); audioEl.src = file; return new Promise((resolve) => { const si = setInterval(() => { @@ -242,13 +244,13 @@ export async function getSoundDuration(file: string): Promise<number> { * ミュートすべきかどうかを判断する */ export function isMute(): boolean { - if (defaultStore.state.sound_notUseSound) { + if (prefer.s['sound.notUseSound']) { // サウンドを出力しない return true; } // noinspection RedundantIfStatementJS - if (defaultStore.state.sound_useSoundOnlyWhenActive && document.visibilityState === 'hidden') { + if (prefer.s['sound.useSoundOnlyWhenActive'] && window.document.visibilityState === 'hidden') { // ブラウザがアクティブな時のみサウンドを出力する return true; } diff --git a/packages/frontend/src/scripts/sticky-sidebar.ts b/packages/frontend/src/utility/sticky-sidebar.ts index 50f1e6ecc8..867c9b8324 100644 --- a/packages/frontend/src/scripts/sticky-sidebar.ts +++ b/packages/frontend/src/utility/sticky-sidebar.ts @@ -18,7 +18,7 @@ export class StickySidebar { this.container = container; this.el = this.container.children[0] as HTMLElement; this.el.style.position = 'sticky'; - this.spacer = document.createElement('div'); + this.spacer = window.document.createElement('div'); this.container.prepend(this.spacer); this.marginTop = marginTop; this.offsetTop = this.container.getBoundingClientRect().top; diff --git a/packages/frontend/src/scripts/stream-mock.ts b/packages/frontend/src/utility/stream-mock.ts index cb0e607fcb..9b1b368de4 100644 --- a/packages/frontend/src/scripts/stream-mock.ts +++ b/packages/frontend/src/utility/stream-mock.ts @@ -37,9 +37,9 @@ export class StreamMock extends EventEmitter<StreamEvents> implements IStream { // do nothing } - public send(typeOrPayload: string): void - public send(typeOrPayload: string, payload: any): void - public send(typeOrPayload: Record<string, any> | any[]): void + public send(typeOrPayload: string): void; + public send(typeOrPayload: string, payload: any): void; + public send(typeOrPayload: Record<string, any> | any[]): void; public send(typeOrPayload: string | Record<string, any> | any[], payload?: any): void { // do nothing } diff --git a/packages/frontend/src/scripts/test-utils.ts b/packages/frontend/src/utility/test-utils.ts index 52bb2d94e0..52bb2d94e0 100644 --- a/packages/frontend/src/scripts/test-utils.ts +++ b/packages/frontend/src/utility/test-utils.ts diff --git a/packages/frontend/src/scripts/theme-editor.ts b/packages/frontend/src/utility/theme-editor.ts index 0092af1640..ea07e5f2ff 100644 --- a/packages/frontend/src/scripts/theme-editor.ts +++ b/packages/frontend/src/utility/theme-editor.ts @@ -5,7 +5,8 @@ import { v4 as uuid } from 'uuid'; -import { themeProps, Theme } from './theme.js'; +import type { Theme } from '@/theme.js'; +import { themeProps } from '@/theme.js'; export type Default = null; export type Color = string; diff --git a/packages/frontend/src/scripts/time.ts b/packages/frontend/src/utility/time.ts index 275b67ed00..275b67ed00 100644 --- a/packages/frontend/src/scripts/time.ts +++ b/packages/frontend/src/utility/time.ts diff --git a/packages/frontend/src/scripts/timezones.ts b/packages/frontend/src/utility/timezones.ts index c7582e06da..c7582e06da 100644 --- a/packages/frontend/src/scripts/timezones.ts +++ b/packages/frontend/src/utility/timezones.ts diff --git a/packages/frontend/src/scripts/touch.ts b/packages/frontend/src/utility/touch.ts index 13c9d648dc..adc2e4c093 100644 --- a/packages/frontend/src/scripts/touch.ts +++ b/packages/frontend/src/utility/touch.ts @@ -4,7 +4,7 @@ */ import { ref } from 'vue'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { deviceKind } from '@/utility/device-kind.js'; const isTouchSupported = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0; diff --git a/packages/frontend/src/scripts/unison-reload.ts b/packages/frontend/src/utility/unison-reload.ts index a24941d02e..c4804192f8 100644 --- a/packages/frontend/src/scripts/unison-reload.ts +++ b/packages/frontend/src/utility/unison-reload.ts @@ -12,9 +12,9 @@ export const reloadChannel = new BroadcastChannel<string | null>('reload'); export function unisonReload(path?: string) { if (path !== undefined) { reloadChannel.postMessage(path); - location.href = path; + window.location.href = path; } else { reloadChannel.postMessage(null); - location.reload(); + window.location.reload(); } } diff --git a/packages/frontend/src/scripts/upload.ts b/packages/frontend/src/utility/upload.ts index 713573a377..e13d793ffb 100644 --- a/packages/frontend/src/scripts/upload.ts +++ b/packages/frontend/src/utility/upload.ts @@ -7,13 +7,13 @@ import { reactive, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { v4 as uuid } from 'uuid'; import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; -import { getCompressionConfig } from './upload/compress-config.js'; -import { defaultStore } from '@/store.js'; import { apiUrl } from '@@/js/config.js'; -import { $i } from '@/account.js'; +import { getCompressionConfig } from './upload/compress-config.js'; +import { $i } from '@/i.js'; import { alert } from '@/os.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; +import { prefer } from '@/preferences.js'; type Uploading = { id: string; @@ -32,9 +32,9 @@ const mimeTypeMap = { export function uploadFile( file: File, - folder?: string | Misskey.entities.DriveFolder, + folder?: string | Misskey.entities.DriveFolder | null, name?: string, - keepOriginal: boolean = defaultStore.state.keepOriginalUploading, + keepOriginal: boolean = prefer.s.keepOriginalUploading, ): Promise<Misskey.entities.DriveFile> { if ($i == null) throw new Error('Not logged in'); @@ -59,7 +59,7 @@ export function uploadFile( const ctx = reactive<Uploading>({ id, - name: defaultStore.state.keepOriginalFilename ? filename : id + extension, + name: prefer.s.keepOriginalFilename ? filename : id + extension, progressMax: undefined, progressValue: undefined, img: window.URL.createObjectURL(file), diff --git a/packages/frontend/src/scripts/upload/compress-config.ts b/packages/frontend/src/utility/upload/compress-config.ts index 3046b7f518..3046b7f518 100644 --- a/packages/frontend/src/scripts/upload/compress-config.ts +++ b/packages/frontend/src/utility/upload/compress-config.ts diff --git a/packages/frontend/src/scripts/upload/isWebpSupported.ts b/packages/frontend/src/utility/upload/isWebpSupported.ts index 2511236ecc..affd81fd57 100644 --- a/packages/frontend/src/scripts/upload/isWebpSupported.ts +++ b/packages/frontend/src/utility/upload/isWebpSupported.ts @@ -6,7 +6,7 @@ let isWebpSupportedCache: boolean | undefined; export function isWebpSupported() { if (isWebpSupportedCache === undefined) { - const canvas = document.createElement('canvas'); + const canvas = window.document.createElement('canvas'); canvas.width = 1; canvas.height = 1; isWebpSupportedCache = canvas.toDataURL('image/webp').startsWith('data:image/webp'); diff --git a/packages/frontend/src/scripts/warning-external-website.ts b/packages/frontend/src/utility/warning-external-website.ts index 0c9b5ba806..0c9b5ba806 100644 --- a/packages/frontend/src/scripts/warning-external-website.ts +++ b/packages/frontend/src/utility/warning-external-website.ts diff --git a/packages/frontend/src/widgets/WidgetActivity.vue b/packages/frontend/src/widgets/WidgetActivity.vue index 0aaf18ddd1..db03d1406c 100644 --- a/packages/frontend/src/widgets/WidgetActivity.vue +++ b/packages/frontend/src/widgets/WidgetActivity.vue @@ -21,13 +21,14 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentProps, WidgetComponentEmits, WidgetComponentExpose } from './widget.js'; import XCalendar from './WidgetActivity.calendar.vue'; import XChart from './WidgetActivity.chart.vue'; -import { GetFormResultType } from '@/scripts/form.js'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; +import type { GetFormResultType } from '@/utility/form.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; const name = 'activity'; diff --git a/packages/frontend/src/widgets/WidgetAichan.vue b/packages/frontend/src/widgets/WidgetAichan.vue index 00001005de..2bc7facc88 100644 --- a/packages/frontend/src/widgets/WidgetAichan.vue +++ b/packages/frontend/src/widgets/WidgetAichan.vue @@ -10,9 +10,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, shallowRef } from 'vue'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { onMounted, onUnmounted, useTemplateRef } from 'vue'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentProps, WidgetComponentEmits, WidgetComponentExpose } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; const name = 'ai'; @@ -34,7 +35,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name, emit, ); -const live2d = shallowRef<HTMLIFrameElement>(); +const live2d = useTemplateRef('live2d'); const touched = () => { //if (this.live2d) this.live2d.changeExpression('gurugurume'); diff --git a/packages/frontend/src/widgets/WidgetAiscript.vue b/packages/frontend/src/widgets/WidgetAiscript.vue index 946124058c..698b4049c0 100644 --- a/packages/frontend/src/widgets/WidgetAiscript.vue +++ b/packages/frontend/src/widgets/WidgetAiscript.vue @@ -21,12 +21,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import { Interpreter, Parser, utils } from '@syuilo/aiscript'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; import * as os from '@/os.js'; import MkContainer from '@/components/MkContainer.vue'; -import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; -import { $i } from '@/account.js'; +import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; const name = 'aiscript'; diff --git a/packages/frontend/src/widgets/WidgetAiscriptApp.vue b/packages/frontend/src/widgets/WidgetAiscriptApp.vue index fa79e4aeb7..429b0e0ffb 100644 --- a/packages/frontend/src/widgets/WidgetAiscriptApp.vue +++ b/packages/frontend/src/widgets/WidgetAiscriptApp.vue @@ -13,16 +13,19 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, Ref, ref, watch } from 'vue'; +import { onMounted, ref, watch } from 'vue'; +import type { Ref } from 'vue'; import { Interpreter, Parser } from '@syuilo/aiscript'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; import * as os from '@/os.js'; -import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; -import { $i } from '@/account.js'; +import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js'; +import { $i } from '@/i.js'; import MkAsUi from '@/components/MkAsUi.vue'; import MkContainer from '@/components/MkContainer.vue'; -import { AsUiComponent, AsUiRoot, registerAsUiLib } from '@/scripts/aiscript/ui.js'; +import { registerAsUiLib } from '@/aiscript/ui.js'; +import type { AsUiComponent, AsUiRoot } from '@/aiscript/ui.js'; const name = 'aiscriptApp'; diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue index c2bda85ac7..6fe743aed2 100644 --- a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue +++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkAvatar v-for="user in users" :key="user.id" :user="user.followee" link preview></MkAvatar> </div> <div v-else :class="$style.bdayFFallback"> - <img :src="infoImageUrl" class="_ghost" :class="$style.bdayFFallbackImage"/> + <img :src="infoImageUrl" draggable="false" :class="$style.bdayFFallbackImage"/> <div>{{ i18n.ts.nothing }}</div> </div> </div> @@ -26,13 +26,14 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import { useInterval } from '@@/js/use-interval.js'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; const name = i18n.ts._widgets.birthdayFollowings; diff --git a/packages/frontend/src/widgets/WidgetButton.vue b/packages/frontend/src/widgets/WidgetButton.vue index 6080e120ec..4afe735a22 100644 --- a/packages/frontend/src/widgets/WidgetButton.vue +++ b/packages/frontend/src/widgets/WidgetButton.vue @@ -13,11 +13,12 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { Interpreter, Parser } from '@syuilo/aiscript'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; import * as os from '@/os.js'; -import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; -import { $i } from '@/account.js'; +import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js'; +import { $i } from '@/i.js'; import MkButton from '@/components/MkButton.vue'; const name = 'button'; diff --git a/packages/frontend/src/widgets/WidgetCalendar.vue b/packages/frontend/src/widgets/WidgetCalendar.vue index a3fe93f025..bc6a61399a 100644 --- a/packages/frontend/src/widgets/WidgetCalendar.vue +++ b/packages/frontend/src/widgets/WidgetCalendar.vue @@ -39,8 +39,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; import { i18n } from '@/i18n.js'; import { useInterval } from '@@/js/use-interval.js'; @@ -207,7 +208,7 @@ defineExpose<WidgetComponentExpose>({ .meter { width: 100%; overflow: hidden; - background: var(--MI_THEME-X11); + background: light-dark(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.3)); border-radius: var(--MI-radius-sm); } diff --git a/packages/frontend/src/widgets/WidgetClicker.vue b/packages/frontend/src/widgets/WidgetClicker.vue index 5c978fdf72..87ffd3d732 100644 --- a/packages/frontend/src/widgets/WidgetClicker.vue +++ b/packages/frontend/src/widgets/WidgetClicker.vue @@ -12,8 +12,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; import MkClickerGame from '@/components/MkClickerGame.vue'; diff --git a/packages/frontend/src/widgets/WidgetClock.vue b/packages/frontend/src/widgets/WidgetClock.vue index b3128ef27e..826ecf6e02 100644 --- a/packages/frontend/src/widgets/WidgetClock.vue +++ b/packages/frontend/src/widgets/WidgetClock.vue @@ -30,12 +30,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed } from 'vue'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; import MkAnalogClock from '@/components/MkAnalogClock.vue'; import MkDigitalClock from '@/components/MkDigitalClock.vue'; -import { timezones } from '@/scripts/timezones.js'; +import { timezones } from '@/utility/timezones.js'; import { i18n } from '@/i18n.js'; const name = 'clock'; diff --git a/packages/frontend/src/widgets/WidgetDigitalClock.vue b/packages/frontend/src/widgets/WidgetDigitalClock.vue index fa9a98d571..d79ec79d4f 100644 --- a/packages/frontend/src/widgets/WidgetDigitalClock.vue +++ b/packages/frontend/src/widgets/WidgetDigitalClock.vue @@ -15,9 +15,10 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed } from 'vue'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; -import { timezones } from '@/scripts/timezones.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; +import { timezones } from '@/utility/timezones.js'; import MkDigitalClock from '@/components/MkDigitalClock.vue'; const name = 'digitalClock'; diff --git a/packages/frontend/src/widgets/WidgetFederation.vue b/packages/frontend/src/widgets/WidgetFederation.vue index 260dd06c13..2843aa2bae 100644 --- a/packages/frontend/src/widgets/WidgetFederation.vue +++ b/packages/frontend/src/widgets/WidgetFederation.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="wbrkwalb"> <MkLoading v-if="fetching"/> - <TransitionGroup v-else tag="div" :name="defaultStore.state.animation ? 'chart' : ''" class="instances"> + <TransitionGroup v-else tag="div" :name="prefer.s.animation ? 'chart' : ''" class="instances"> <div v-for="(instance, i) in instances" :key="instance.id" class="instance"> <img :src="getInstanceIcon(instance)" alt=""/> <div class="body"> @@ -27,15 +27,16 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useInterval } from '@@/js/use-interval.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; import MkMiniChart from '@/components/MkMiniChart.vue'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; -import { useInterval } from '@@/js/use-interval.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; -import { defaultStore } from '@/store.js'; +import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; +import { prefer } from '@/preferences.js'; const name = 'federation'; diff --git a/packages/frontend/src/widgets/WidgetInstanceCloud.vue b/packages/frontend/src/widgets/WidgetInstanceCloud.vue index d090372b9a..0c9f98f9e3 100644 --- a/packages/frontend/src/widgets/WidgetInstanceCloud.vue +++ b/packages/frontend/src/widgets/WidgetInstanceCloud.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkContainer :naked="widgetProps.transparent" :showHeader="false" class="mkw-instance-cloud"> <div class=""> - <MkTagCloud v-if="activeInstances"> + <MkTagCloud v-if="activeInstances" ref="cloud"> <li v-for="instance in activeInstances" :key="instance.id"> <a @click.prevent="onInstanceClick(instance)"> <img style="width: 32px;" :src="getInstanceIcon(instance)"> @@ -18,16 +18,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef } from 'vue'; +import { shallowRef, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useInterval } from '@@/js/use-interval.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; import MkTagCloud from '@/components/MkTagCloud.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useInterval } from '@@/js/use-interval.js'; -import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; const name = 'instanceCloud'; @@ -49,7 +50,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name, emit, ); -const cloud = shallowRef<InstanceType<typeof MkTagCloud> | null>(); +const cloud = useTemplateRef('cloud'); const activeInstances = shallowRef<Misskey.entities.FederationInstance[] | null>(null); function onInstanceClick(i) { diff --git a/packages/frontend/src/widgets/WidgetInstanceInfo.vue b/packages/frontend/src/widgets/WidgetInstanceInfo.vue index b99f9bdb2b..9708b63a8d 100644 --- a/packages/frontend/src/widgets/WidgetInstanceInfo.vue +++ b/packages/frontend/src/widgets/WidgetInstanceInfo.vue @@ -20,8 +20,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; import { host } from '@@/js/config.js'; import { instance } from '@/instance.js'; diff --git a/packages/frontend/src/widgets/WidgetJobQueue.vue b/packages/frontend/src/widgets/WidgetJobQueue.vue index 0ee6b863dc..485e532d51 100644 --- a/packages/frontend/src/widgets/WidgetJobQueue.vue +++ b/packages/frontend/src/widgets/WidgetJobQueue.vue @@ -52,13 +52,14 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onUnmounted, reactive, ref } from 'vue'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; import { useStream } from '@/stream.js'; import kmg from '@/filters/kmg.js'; -import * as sound from '@/scripts/sound.js'; -import { deepClone } from '@/scripts/clone.js'; -import { defaultStore } from '@/store.js'; +import * as sound from '@/utility/sound.js'; +import { deepClone } from '@/utility/clone.js'; +import { prefer } from '@/preferences.js'; const name = 'jobQueue'; @@ -103,7 +104,7 @@ const prev = reactive({} as typeof current); const jammedAudioBuffer = ref<AudioBuffer | null>(null); const jammedSoundNodePlaying = ref<boolean>(false); -if (defaultStore.state.sound_masterVolume) { +if (prefer.s['sound.masterVolume']) { sound.loadAudio('/client-assets/sounds/syuilo/queue-jammed.mp3').then(buf => { if (!buf) throw new Error('[WidgetJobQueue] Failed to initialize AudioBuffer'); jammedAudioBuffer.value = buf; diff --git a/packages/frontend/src/widgets/WidgetMemo.vue b/packages/frontend/src/widgets/WidgetMemo.vue index 5addf1066d..e6e18fd974 100644 --- a/packages/frontend/src/widgets/WidgetMemo.vue +++ b/packages/frontend/src/widgets/WidgetMemo.vue @@ -17,10 +17,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, watch } from 'vue'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; const name = 'memo'; @@ -47,12 +48,12 @@ const { widgetProps, configure } = useWidgetPropsManager(name, emit, ); -const text = ref<string | null>(defaultStore.state.memo); +const text = ref<string | null>(store.s.memo); const changed = ref(false); let timeoutId; const saveMemo = () => { - defaultStore.set('memo', text.value); + store.set('memo', text.value); changed.value = false; }; @@ -62,7 +63,7 @@ const onChange = () => { timeoutId = window.setTimeout(saveMemo, 1000); }; -watch(() => defaultStore.reactiveState.memo, newText => { +watch(() => store.r.memo, newText => { text.value = newText.value; }); diff --git a/packages/frontend/src/widgets/WidgetNotifications.vue b/packages/frontend/src/widgets/WidgetNotifications.vue index 773c078b49..c5e1324ef5 100644 --- a/packages/frontend/src/widgets/WidgetNotifications.vue +++ b/packages/frontend/src/widgets/WidgetNotifications.vue @@ -17,8 +17,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent } from 'vue'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; import XNotifications from '@/components/MkNotifications.vue'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/widgets/WidgetOnlineUsers.vue b/packages/frontend/src/widgets/WidgetOnlineUsers.vue index d8c4e259c8..f6bd4c0025 100644 --- a/packages/frontend/src/widgets/WidgetOnlineUsers.vue +++ b/packages/frontend/src/widgets/WidgetOnlineUsers.vue @@ -15,9 +15,10 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; import { useInterval } from '@@/js/use-interval.js'; import { i18n } from '@/i18n.js'; import number from '@/filters/number.js'; diff --git a/packages/frontend/src/widgets/WidgetPhotos.vue b/packages/frontend/src/widgets/WidgetPhotos.vue index 60a4770e40..5483d2f7cc 100644 --- a/packages/frontend/src/widgets/WidgetPhotos.vue +++ b/packages/frontend/src/widgets/WidgetPhotos.vue @@ -24,13 +24,14 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onUnmounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; import { useStream } from '@/stream.js'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; import { i18n } from '@/i18n.js'; const name = 'photos'; @@ -69,7 +70,7 @@ const onDriveFileCreated = (file) => { }; const thumbnail = (image: Misskey.entities.DriveFile): string => { - return defaultStore.state.disableShowingAnimatedImages + return prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl ?? image.url; }; diff --git a/packages/frontend/src/widgets/WidgetPostForm.vue b/packages/frontend/src/widgets/WidgetPostForm.vue index 7f344505d8..3170eab305 100644 --- a/packages/frontend/src/widgets/WidgetPostForm.vue +++ b/packages/frontend/src/widgets/WidgetPostForm.vue @@ -9,8 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { } from 'vue'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkPostForm from '@/components/MkPostForm.vue'; const name = 'postForm'; diff --git a/packages/frontend/src/widgets/WidgetProfile.vue b/packages/frontend/src/widgets/WidgetProfile.vue index ae39098305..3fe8378a39 100644 --- a/packages/frontend/src/widgets/WidgetProfile.vue +++ b/packages/frontend/src/widgets/WidgetProfile.vue @@ -22,9 +22,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; -import { $i } from '@/account.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; +import { $i } from '@/i.js'; import { userPage } from '@/filters/user.js'; const name = 'profile'; diff --git a/packages/frontend/src/widgets/WidgetRss.vue b/packages/frontend/src/widgets/WidgetRss.vue index 3e43687709..132eb0a629 100644 --- a/packages/frontend/src/widgets/WidgetRss.vue +++ b/packages/frontend/src/widgets/WidgetRss.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="ekmkgxbj"> <MkLoading v-if="fetching"/> <div v-else-if="(!items || items.length === 0) && widgetProps.showHeader" class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> <div v-else :class="$style.feed"> @@ -25,12 +25,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, watch, computed } from 'vue'; import * as Misskey from 'misskey-js'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; -import MkContainer from '@/components/MkContainer.vue'; import { url as base } from '@@/js/config.js'; -import { i18n } from '@/i18n.js'; import { useInterval } from '@@/js/use-interval.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; +import MkContainer from '@/components/MkContainer.vue'; +import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; const name = 'rss'; @@ -76,7 +77,7 @@ const fetchEndpoint = computed(() => { const intervalClear = ref<(() => void) | undefined>(); const tick = () => { - if (document.visibilityState === 'hidden' && rawItems.value.length !== 0) return; + if (window.document.visibilityState === 'hidden' && rawItems.value.length !== 0) return; window.fetch(fetchEndpoint.value, {}) .then(res => res.json()) diff --git a/packages/frontend/src/widgets/WidgetRssTicker.vue b/packages/frontend/src/widgets/WidgetRssTicker.vue index 4f594b720f..b5be4d35c2 100644 --- a/packages/frontend/src/widgets/WidgetRssTicker.vue +++ b/packages/frontend/src/widgets/WidgetRssTicker.vue @@ -29,11 +29,12 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, watch, computed } from 'vue'; import * as Misskey from 'misskey-js'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import MarqueeText from '@/components/MkMarquee.vue'; -import { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; -import { shuffle } from '@/scripts/shuffle.js'; +import { shuffle } from '@/utility/shuffle.js'; import { url as base } from '@@/js/config.js'; import { useInterval } from '@@/js/use-interval.js'; @@ -107,7 +108,7 @@ const intervalClear = ref<(() => void) | undefined>(); const key = ref(0); const tick = () => { - if (document.visibilityState === 'hidden' && rawItems.value.length !== 0) return; + if (window.document.visibilityState === 'hidden' && rawItems.value.length !== 0) return; window.fetch(fetchEndpoint.value, {}) .then(res => res.json()) diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue index 3fea1d7053..2ccbb7a28f 100644 --- a/packages/frontend/src/widgets/WidgetSlideshow.vue +++ b/packages/frontend/src/widgets/WidgetSlideshow.vue @@ -17,13 +17,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, shallowRef } from 'vue'; +import { onMounted, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; -import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; import { useInterval } from '@@/js/use-interval.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; const name = 'slideshow'; @@ -53,8 +54,8 @@ const { widgetProps, configure, save } = useWidgetPropsManager(name, const images = ref<Misskey.entities.DriveFile[]>([]); const fetching = ref(true); -const slideA = shallowRef<HTMLElement>(); -const slideB = shallowRef<HTMLElement>(); +const slideA = useTemplateRef('slideA'); +const slideB = useTemplateRef('slideB'); const change = () => { if (images.value.length === 0) return; diff --git a/packages/frontend/src/widgets/WidgetTimeline.vue b/packages/frontend/src/widgets/WidgetTimeline.vue index a4685fd1fc..47dec05303 100644 --- a/packages/frontend/src/widgets/WidgetTimeline.vue +++ b/packages/frontend/src/widgets/WidgetTimeline.vue @@ -32,10 +32,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; import MkTimeline from '@/components/MkTimeline.vue'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/widgets/WidgetTrends.vue b/packages/frontend/src/widgets/WidgetTrends.vue index 47a4efc106..db09031c33 100644 --- a/packages/frontend/src/widgets/WidgetTrends.vue +++ b/packages/frontend/src/widgets/WidgetTrends.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="wbrkwala"> <MkLoading v-if="fetching"/> - <TransitionGroup v-else tag="div" :name="defaultStore.state.animation ? 'chart' : ''" class="tags"> + <TransitionGroup v-else tag="div" :name="prefer.s.animation ? 'chart' : ''" class="tags"> <div v-for="stat in stats" :key="stat.tag"> <div class="tag"> <MkA class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</MkA> @@ -26,14 +26,15 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useInterval } from '@@/js/use-interval.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; import MkMiniChart from '@/components/MkMiniChart.vue'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; -import { useInterval } from '@@/js/use-interval.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const name = 'hashtags'; diff --git a/packages/frontend/src/widgets/WidgetUnixClock.vue b/packages/frontend/src/widgets/WidgetUnixClock.vue index 832cd575cc..f51ef12a2a 100644 --- a/packages/frontend/src/widgets/WidgetUnixClock.vue +++ b/packages/frontend/src/widgets/WidgetUnixClock.vue @@ -17,8 +17,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onUnmounted, ref, watch } from 'vue'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; const name = 'unixClock'; diff --git a/packages/frontend/src/widgets/WidgetUserList.vue b/packages/frontend/src/widgets/WidgetUserList.vue index 72391d622e..eb86732817 100644 --- a/packages/frontend/src/widgets/WidgetUserList.vue +++ b/packages/frontend/src/widgets/WidgetUserList.vue @@ -26,11 +26,12 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import { GetFormResultType } from '@/scripts/form.js'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { useInterval } from '@@/js/use-interval.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; diff --git a/packages/frontend/src/widgets/index.ts b/packages/frontend/src/widgets/index.ts index 37742f8c2e..047c6d7104 100644 --- a/packages/frontend/src/widgets/index.ts +++ b/packages/frontend/src/widgets/index.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { App, defineAsyncComponent } from 'vue'; +import { defineAsyncComponent } from 'vue'; +import type { App } from 'vue'; export default function(app: App) { app.component('WidgetProfile', defineAsyncComponent(() => import('./WidgetProfile.vue'))); diff --git a/packages/frontend/src/widgets/server-metric/index.vue b/packages/frontend/src/widgets/server-metric/index.vue index 86d84b4f33..9026fefb20 100644 --- a/packages/frontend/src/widgets/server-metric/index.vue +++ b/packages/frontend/src/widgets/server-metric/index.vue @@ -22,15 +22,16 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onUnmounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from '../widget.js'; +import { useWidgetPropsManager } from '../widget.js'; +import type { WidgetComponentProps, WidgetComponentEmits, WidgetComponentExpose } from '../widget.js'; import XCpuMemory from './cpu-mem.vue'; import XNet from './net.vue'; import XCpu from './cpu.vue'; import XMemory from './mem.vue'; import XDisk from './disk.vue'; import MkContainer from '@/components/MkContainer.vue'; -import { GetFormResultType } from '@/scripts/form.js'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; +import type { GetFormResultType } from '@/utility/form.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/widgets/widget.ts b/packages/frontend/src/widgets/widget.ts index bfe8067adf..de4c369cbb 100644 --- a/packages/frontend/src/widgets/widget.ts +++ b/packages/frontend/src/widgets/widget.ts @@ -5,9 +5,9 @@ import { reactive, watch } from 'vue'; import { throttle } from 'throttle-debounce'; -import { Form, GetFormResultType } from '@/scripts/form.js'; +import type { Form, GetFormResultType } from '@/utility/form.js'; import * as os from '@/os.js'; -import { deepClone } from '@/scripts/clone.js'; +import { deepClone } from '@/utility/clone.js'; export type Widget<P extends Record<string, unknown>> = { id: string; |