From 8c9ec5827fa2040c8d705d2a01329da593d19fa3 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 13 Mar 2025 22:12:23 +0900 Subject: [PATCH] enhance(frontend): improve accounts management --- packages/frontend/src/account.ts | 390 ------------------ packages/frontend/src/accounts.ts | 341 +++++++++++++++ packages/frontend/src/aiscript/api.ts | 2 +- packages/frontend/src/boot/common.ts | 10 +- packages/frontend/src/boot/main-boot.ts | 24 +- .../src/components/MkAnnouncementDialog.vue | 5 +- .../frontend/src/components/MkAuthConfirm.vue | 9 +- .../frontend/src/components/MkClipPreview.vue | 2 +- .../src/components/MkCropperDialog.vue | 2 +- .../frontend/src/components/MkDrive.file.vue | 2 +- .../frontend/src/components/MkEmojiPicker.vue | 2 +- .../src/components/MkFollowButton.vue | 2 +- .../src/components/MkInstanceStats.vue | 2 +- .../frontend/src/components/MkMediaAudio.vue | 2 +- .../frontend/src/components/MkMediaImage.vue | 2 +- .../frontend/src/components/MkMediaVideo.vue | 2 +- .../frontend/src/components/MkMention.vue | 2 +- packages/frontend/src/components/MkNote.vue | 2 +- .../src/components/MkNoteDetailed.vue | 2 +- .../frontend/src/components/MkNoteSub.vue | 2 +- .../src/components/MkNotification.vue | 2 +- .../src/components/MkPasswordDialog.vue | 2 +- .../frontend/src/components/MkPostForm.vue | 5 +- .../frontend/src/components/MkPreview.vue | 2 +- .../MkPushNotificationAllowButton.vue | 3 +- .../components/MkReactionsViewer.reaction.vue | 2 +- packages/frontend/src/components/MkSignin.vue | 5 +- .../src/components/MkSignupDialog.form.vue | 4 +- .../frontend/src/components/MkTimeline.vue | 2 +- .../src/components/MkTokenGenerateWindow.vue | 2 +- .../src/components/MkTutorialDialog.Note.vue | 2 +- .../components/MkTutorialDialog.Sensitive.vue | 2 +- .../frontend/src/components/MkUserInfo.vue | 2 +- .../frontend/src/components/MkUserPopup.vue | 2 +- .../src/components/MkUserSelectDialog.vue | 2 +- .../components/MkUserSetupDialog.Profile.vue | 2 +- .../frontend/src/components/global/MkAd.vue | 2 +- .../src/components/global/MkCustomEmoji.vue | 2 +- .../src/components/global/MkPageHeader.vue | 3 +- packages/frontend/src/i.ts | 34 ++ packages/frontend/src/local-storage.ts | 1 - packages/frontend/src/navbar.ts | 2 +- packages/frontend/src/pages/about-misskey.vue | 2 +- packages/frontend/src/pages/about.emojis.vue | 2 +- packages/frontend/src/pages/achievements.vue | 2 +- packages/frontend/src/pages/admin-file.vue | 2 +- packages/frontend/src/pages/admin-user.vue | 2 +- packages/frontend/src/pages/announcement.vue | 5 +- packages/frontend/src/pages/announcements.vue | 5 +- packages/frontend/src/pages/auth.vue | 3 +- .../pages/avatar-decoration-edit-dialog.vue | 2 +- .../frontend/src/pages/avatar-decorations.vue | 2 +- packages/frontend/src/pages/channel.vue | 2 +- packages/frontend/src/pages/clip.vue | 2 +- .../src/pages/drop-and-fusion.game.vue | 2 +- packages/frontend/src/pages/emojis.emoji.vue | 2 +- packages/frontend/src/pages/flash/flash.vue | 2 +- .../frontend/src/pages/follow-requests.vue | 2 +- packages/frontend/src/pages/gallery/post.vue | 2 +- packages/frontend/src/pages/instance-info.vue | 2 +- packages/frontend/src/pages/invite.vue | 2 +- .../frontend/src/pages/my-lists/index.vue | 2 +- packages/frontend/src/pages/my-lists/list.vue | 2 +- packages/frontend/src/pages/note.vue | 2 +- .../src/pages/page-editor/page-editor.vue | 2 +- packages/frontend/src/pages/page.vue | 2 +- .../frontend/src/pages/reversi/game.board.vue | 2 +- .../src/pages/reversi/game.setting.vue | 2 +- packages/frontend/src/pages/reversi/game.vue | 2 +- packages/frontend/src/pages/reversi/index.vue | 2 +- packages/frontend/src/pages/scratchpad.vue | 2 +- packages/frontend/src/pages/search.note.vue | 2 +- .../src/pages/settings/2fa.qrdialog.vue | 2 +- packages/frontend/src/pages/settings/2fa.vue | 5 +- .../src/pages/settings/account-data.vue | 2 +- .../frontend/src/pages/settings/accounts.vue | 103 ++--- .../settings/avatar-decoration.decoration.vue | 2 +- .../settings/avatar-decoration.dialog.vue | 2 +- .../src/pages/settings/avatar-decoration.vue | 2 +- .../frontend/src/pages/settings/drive.vue | 2 +- .../frontend/src/pages/settings/email.vue | 2 +- .../frontend/src/pages/settings/index.vue | 3 +- .../frontend/src/pages/settings/migration.vue | 2 +- .../settings/mute-block.instance-mute.vue | 2 +- .../src/pages/settings/mute-block.vue | 2 +- .../src/pages/settings/notifications.vue | 2 +- .../frontend/src/pages/settings/other.vue | 3 +- .../frontend/src/pages/settings/privacy.vue | 2 +- .../frontend/src/pages/settings/profile.vue | 2 +- .../frontend/src/pages/signup-complete.vue | 2 +- packages/frontend/src/pages/tag.vue | 2 +- packages/frontend/src/pages/theme-editor.vue | 2 +- packages/frontend/src/pages/timeline.vue | 2 +- .../frontend/src/pages/user/achievements.vue | 2 +- packages/frontend/src/pages/user/home.vue | 2 +- packages/frontend/src/pages/user/index.vue | 2 +- packages/frontend/src/pages/welcome.setup.vue | 2 +- packages/frontend/src/pizzax.ts | 2 +- packages/frontend/src/preferences.ts | 2 +- packages/frontend/src/preferences/def.ts | 4 + packages/frontend/src/preferences/manager.ts | 2 +- packages/frontend/src/preferences/utility.ts | 2 +- packages/frontend/src/router/definition.ts | 2 +- packages/frontend/src/signout.ts | 54 +++ packages/frontend/src/store.ts | 4 + packages/frontend/src/stream.ts | 2 +- packages/frontend/src/theme-store.ts | 2 +- packages/frontend/src/timelines.ts | 2 +- .../src/ui/_common_/PreferenceRestore.vue | 2 +- .../src/ui/_common_/announcements.vue | 2 +- packages/frontend/src/ui/_common_/common.ts | 2 +- packages/frontend/src/ui/_common_/common.vue | 2 +- .../src/ui/_common_/navbar-for-mobile.vue | 3 +- packages/frontend/src/ui/_common_/navbar.vue | 3 +- .../frontend/src/ui/_common_/sw-inject.ts | 3 +- packages/frontend/src/ui/classic.header.vue | 4 +- packages/frontend/src/ui/classic.sidebar.vue | 3 +- packages/frontend/src/ui/deck.vue | 2 +- packages/frontend/src/ui/universal.vue | 2 +- packages/frontend/src/use/use-note-capture.ts | 2 +- packages/frontend/src/utility/achievements.ts | 2 +- .../utility/autogen/settings-search-index.ts | 7 + .../frontend/src/utility/check-permissions.ts | 2 +- .../frontend/src/utility/get-note-menu.ts | 2 +- .../frontend/src/utility/get-user-menu.ts | 2 +- .../frontend/src/utility/isFfVisibleForMe.ts | 2 +- packages/frontend/src/utility/misskey-api.ts | 2 +- packages/frontend/src/utility/please-login.ts | 2 +- .../frontend/src/utility/show-moved-dialog.ts | 2 +- packages/frontend/src/utility/upload.ts | 2 +- .../frontend/src/widgets/WidgetActivity.vue | 2 +- .../frontend/src/widgets/WidgetAiscript.vue | 2 +- .../src/widgets/WidgetAiscriptApp.vue | 2 +- .../src/widgets/WidgetBirthdayFollowings.vue | 2 +- .../frontend/src/widgets/WidgetButton.vue | 2 +- .../frontend/src/widgets/WidgetProfile.vue | 2 +- packages/frontend/test/aiscript/api.test.ts | 2 +- 137 files changed, 640 insertions(+), 622 deletions(-) delete mode 100644 packages/frontend/src/account.ts create mode 100644 packages/frontend/src/accounts.ts create mode 100644 packages/frontend/src/i.ts create mode 100644 packages/frontend/src/signout.ts diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts deleted file mode 100644 index c90d4da5ec..0000000000 --- a/packages/frontend/src/account.ts +++ /dev/null @@ -1,390 +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 { defaultMemoryStorage } from '@/memory-storage'; -import { showSuspendedDialog } from '@/utility/show-suspended-dialog.js'; -import { i18n } from '@/i18n.js'; -import { miLocalStorage } from '@/local-storage.js'; -import { del, get, set } from '@/utility/idb-proxy.js'; -import { waiting, popup, popupMenu, success, alert } from '@/os.js'; -import { misskeyApi } from '@/utility/misskey-api.js'; -import { unisonReload, reloadChannel } from '@/utility/unison-reload.js'; - -// TODO: 他のタブと永続化されたstateを同期 -// TODO: accountsはpreferences管理にする(tokenは別管理) - -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; - - defaultMemoryStorage.clear(); - - waiting(); - document.cookie.split(';').forEach((cookie) => { - const cookieName = cookie.split('=')[0].trim(); - if (cookieName === 'token') { - document.cookie = `${cookieName}=; max-age=0; path=/`; - } - }); - miLocalStorage.removeItem('account'); - await removeAccount($i.id); - 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 { - document.cookie = 'token=; path=/; max-age=0'; - document.cookie = `token=${token}; path=/queue; max-age=86400; 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 }>((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), - }); - } - - // 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) { - 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 | 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', - }); - } 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..2382a8ec32 --- /dev/null +++ b/packages/frontend/src/accounts.ts @@ -0,0 +1,341 @@ +/* + * 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'; + +// TODO: 他のタブと永続化されたstateを同期 + +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 { + 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 }>((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) { + 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で指定された先に移動 + 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/aiscript/api.ts b/packages/frontend/src/aiscript/api.ts index 3acc1127c9..e7e396023d 100644 --- a/packages/frontend/src/aiscript/api.ts +++ b/packages/frontend/src/aiscript/api.ts @@ -9,7 +9,7 @@ import { url, lang } from '@@/js/config.js'; import { assertStringAndIsIn } from './common.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { miLocalStorage } from '@/local-storage.js'; import { customEmojis } from '@/custom-emojis.js'; diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 122aa50ac0..73c4256c4b 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -15,7 +15,7 @@ import components from '@/components/index.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 { refreshCurrentAccount, login } from '@/accounts.js'; import { store } from '@/store.js'; import { fetchInstance, instance } from '@/instance.js'; import { deviceKind, updateDeviceKind } from '@/utility/device-kind.js'; @@ -29,6 +29,7 @@ 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) { console.info(`Misskey v${version}`); @@ -38,11 +39,6 @@ export async function common(createVue: () => App) { 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 = store; - window.addEventListener('error', event => { console.error(event); /* @@ -244,7 +240,7 @@ export async function common(createVue: () => App) { console.log('account cache found. refreshing...'); } - refreshAccount(); + refreshCurrentAccount(); } //#endregion diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index be72eeb9e1..64e3a236e8 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -16,7 +16,7 @@ import { i18n } from '@/i18n.js'; import { alert, confirm, popup, post, toast } from '@/os.js'; import { useStream } from '@/stream.js'; import * as sound from '@/utility/sound.js'; -import { $i, signout, updateAccountPartial } from '@/account.js'; +import { $i } from '@/i.js'; import { instance } from '@/instance.js'; import { ColdDeviceStorage, store } from '@/store.js'; import { reactionPicker } from '@/utility/reaction-picker.js'; @@ -32,6 +32,8 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { deckStore } from '@/ui/deck/deck-store.js'; import { launchPlugins } from '@/plugin.js'; import { unisonReload } from '@/utility/unison-reload.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; +import { signout } from '@/signout.js'; export async function mainBoot() { const { isClientUpdated, lastVersion } = await common(() => { @@ -480,11 +482,11 @@ export async function mainBoot() { // 自分の情報が更新されたとき main.on('meUpdated', i => { - updateAccountPartial(i); + updateCurrentAccountPartial(i); }); main.on('readAllNotifications', () => { - updateAccountPartial({ + updateCurrentAccountPartial({ hasUnreadNotification: false, unreadNotificationsCount: 0, }); @@ -492,39 +494,39 @@ export async function mainBoot() { main.on('unreadNotification', () => { const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1; - updateAccountPartial({ + updateCurrentAccountPartial({ hasUnreadNotification: true, unreadNotificationsCount, }); }); main.on('unreadMention', () => { - updateAccountPartial({ hasUnreadMentions: true }); + updateCurrentAccountPartial({ hasUnreadMentions: true }); }); main.on('readAllUnreadMentions', () => { - updateAccountPartial({ hasUnreadMentions: false }); + updateCurrentAccountPartial({ hasUnreadMentions: false }); }); main.on('unreadSpecifiedNote', () => { - updateAccountPartial({ hasUnreadSpecifiedNotes: true }); + updateCurrentAccountPartial({ hasUnreadSpecifiedNotes: true }); }); main.on('readAllUnreadSpecifiedNotes', () => { - updateAccountPartial({ hasUnreadSpecifiedNotes: false }); + updateCurrentAccountPartial({ hasUnreadSpecifiedNotes: false }); }); main.on('readAllAntennas', () => { - updateAccountPartial({ hasUnreadAntenna: false }); + updateCurrentAccountPartial({ hasUnreadAntenna: false }); }); main.on('unreadAntenna', () => { - updateAccountPartial({ hasUnreadAntenna: true }); + updateCurrentAccountPartial({ hasUnreadAntenna: true }); sound.playMisskeySfx('antenna'); }); main.on('readAllAnnouncements', () => { - updateAccountPartial({ hasUnreadAnnouncement: false }); + updateCurrentAccountPartial({ hasUnreadAnnouncement: false }); }); // 個人宛てお知らせが発行されたとき diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue index 41fd2564d8..582bb137bc 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.vue +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -29,7 +29,8 @@ 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; @@ -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/MkAuthConfirm.vue b/packages/frontend/src/components/MkAuthConfirm.vue index 090c31044e..00bf8e68d9 100644 --- a/packages/frontend/src/components/MkAuthConfirm.vue +++ b/packages/frontend/src/components/MkAuthConfirm.vue @@ -117,10 +117,9 @@ SPDX-License-Identifier: AGPL-3.0-only