This commit is contained in:
かっこかり 2026-01-24 14:27:37 +09:00 committed by GitHub
commit df0e7cac79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 261 additions and 55 deletions

View File

@ -14,10 +14,13 @@
- Enhance: ウィジェットの表示設定をプレビューを見ながら行えるように - Enhance: ウィジェットの表示設定をプレビューを見ながら行えるように
- Enhance: ウィジェットの設定項目のラベルの多言語対応 - Enhance: ウィジェットの設定項目のラベルの多言語対応
- Enhance: 画面幅が広いときにメディアを横並びで表示できるようにするオプションを追加 - Enhance: 画面幅が広いときにメディアを横並びで表示できるようにするオプションを追加
- Enhance: アカウント管理ページで、全てのアカウントから一括でログアウトできるように
- Enhance: パフォーマンスの向上 - Enhance: パフォーマンスの向上
- Fix: ドライブクリーナーでファイルを削除しても画面に反映されない問題を修正 #16061 - Fix: ドライブクリーナーでファイルを削除しても画面に反映されない問題を修正 #16061
- Fix: 非ログイン時にログインを求めるダイアログが表示された後にダイアログのぼかしが解除されず操作不能になることがある問題を修正 - Fix: 非ログイン時にログインを求めるダイアログが表示された後にダイアログのぼかしが解除されず操作不能になることがある問題を修正
- Fix: ドライブのソートが「登録日(昇順)」の場合に正しく動作しない問題を修正 - Fix: ドライブのソートが「登録日(昇順)」の場合に正しく動作しない問題を修正
- Fix: ログアウトボタンを押下するとすべてのアカウントからログアウトする問題を修正
- Fix: アカウント管理ページで、アカウントの追加・削除を行ってもリストに反映されない問題を修正
- Fix: 高度なMFMのピッカーを使用する際の挙動を改善 - Fix: 高度なMFMのピッカーを使用する際の挙動を改善
- Fix: 管理画面でアーカイブ済のお知らせを表示した際にアクティブなお知らせが多い旨の警告が出る問題を修正 - Fix: 管理画面でアーカイブ済のお知らせを表示した際にアクティブなお知らせが多い旨の警告が出る問題を修正
- Fix: ファイルタブのセンシティブメディアを開く際に確認ダイアログを出す設定が適用されない問題を修正 - Fix: ファイルタブのセンシティブメディアを開く際に確認ダイアログを出す設定が適用されない問題を修正

View File

@ -34,6 +34,7 @@ noAccountDescription: "自己紹介はありません"
login: "ログイン" login: "ログイン"
loggingIn: "ログイン中" loggingIn: "ログイン中"
logout: "ログアウト" logout: "ログアウト"
logoutFromAll: "すべてのアカウントからログアウト"
signup: "新規登録" signup: "新規登録"
uploading: "アップロード中" uploading: "アップロード中"
save: "保存" save: "保存"
@ -992,6 +993,7 @@ numberOfPageCache: "ページキャッシュ数"
numberOfPageCacheDescription: "多くすると利便性が向上しますが、負荷とメモリ使用量が増えます。" numberOfPageCacheDescription: "多くすると利便性が向上しますが、負荷とメモリ使用量が増えます。"
logoutConfirm: "ログアウトしますか?" logoutConfirm: "ログアウトしますか?"
logoutWillClearClientData: "ログアウトするとクライアントの設定情報がブラウザから消去されます。再ログイン時に設定情報を復元できるようにするためには、設定の自動バックアップを有効にしてください。" logoutWillClearClientData: "ログアウトするとクライアントの設定情報がブラウザから消去されます。再ログイン時に設定情報を復元できるようにするためには、設定の自動バックアップを有効にしてください。"
logoutFromOtherAccountConfirm: "{username}からログアウトしますか?"
lastActiveDate: "最終利用日時" lastActiveDate: "最終利用日時"
statusbar: "ステータスバー" statusbar: "ステータスバー"
pleaseSelect: "選択してください" pleaseSelect: "選択してください"

View File

@ -19,13 +19,15 @@ import { signout } from '@/signout.js';
type AccountWithToken = Misskey.entities.MeDetailed & { token: string }; type AccountWithToken = Misskey.entities.MeDetailed & { token: string };
export async function getAccounts(): Promise<{ export type AccountData = {
host: string; host: string;
id: Misskey.entities.User['id']; id: Misskey.entities.User['id'];
username: Misskey.entities.User['username']; username: Misskey.entities.User['username'];
user?: Misskey.entities.MeDetailed | null; user?: Misskey.entities.MeDetailed | null;
token: string | null; token: string | null;
}[]> { };
export async function getAccounts(): Promise<AccountData[]> {
const tokens = store.s.accountTokens; const tokens = store.s.accountTokens;
const accountInfos = store.s.accountInfos; const accountInfos = store.s.accountInfos;
const accounts = prefer.s.accounts; const accounts = prefer.s.accounts;
@ -33,8 +35,8 @@ export async function getAccounts(): Promise<{
host, host,
id: user.id, id: user.id,
username: user.username, username: user.username,
user: accountInfos[host + '/' + user.id], user: accountInfos[`${host}/${user.id}`],
token: tokens[host + '/' + user.id] ?? null, token: tokens[`${host}/${user.id}`] ?? null,
})); }));
} }
@ -53,10 +55,15 @@ export async function removeAccount(host: string, id: AccountWithToken['id']) {
const accountInfos = JSON.parse(JSON.stringify(store.s.accountInfos)); const accountInfos = JSON.parse(JSON.stringify(store.s.accountInfos));
delete accountInfos[host + '/' + id]; delete accountInfos[host + '/' + id];
store.set('accountInfos', accountInfos); store.set('accountInfos', accountInfos);
prefer.commit('accounts', prefer.s.accounts.filter(x => x[0] !== host || x[1].id !== id)); prefer.commit('accounts', prefer.s.accounts.filter(x => x[0] !== host || x[1].id !== id));
} }
export async function removeAccountAssociatedData(host: string, id: AccountWithToken['id']) {
// 設定・状態を削除
prefer.clearAccountSettingsFromDevice(host, id);
await store.clearAccountDataFromDevice(id);
}
const isAccountDeleted = Symbol('isAccountDeleted'); const isAccountDeleted = Symbol('isAccountDeleted');
function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<Misskey.entities.MeDetailed> { function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<Misskey.entities.MeDetailed> {
@ -162,14 +169,36 @@ export async function refreshCurrentAccount() {
}); });
} }
export async function login(token: AccountWithToken['token'], redirect?: string) { export async function refreshAccounts() {
const accounts = await getAccounts();
for (const account of accounts) {
if (account.host === host && account.id === $i?.id) {
await refreshCurrentAccount();
} else if (account.token) {
try {
const user = await fetchAccount(account.token, account.id);
store.set('accountInfos', { ...store.s.accountInfos, [account.host + '/' + account.id]: user });
} catch (e) {
if (e === isAccountDeleted) {
await removeAccount(account.host, account.id);
await removeAccountAssociatedData(account.host, account.id);
}
}
}
}
}
export async function login(token: AccountWithToken['token'], redirect?: string, showWaiting = true) {
const showing = ref(true); const showing = ref(true);
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
success: false, if (showWaiting) {
showing: showing, const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
}, { success: false,
closed: () => dispose(), showing: showing,
}); }, {
closed: () => dispose(),
});
}
const me = await fetchAccount(token, undefined, true).catch(reason => { const me = await fetchAccount(token, undefined, true).catch(reason => {
showing.value = false; showing.value = false;
@ -195,7 +224,7 @@ export async function login(token: AccountWithToken['token'], redirect?: string)
} }
export async function switchAccount(host: string, id: string) { export async function switchAccount(host: string, id: string) {
const token = store.s.accountTokens[host + '/' + id]; const token = store.s.accountTokens[`${host}/${id}`];
if (token) { if (token) {
login(token); login(token);
} else { } else {

View File

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-adaptive-bg :class="[$style.root]"> <div v-adaptive-bg :class="[$style.root]">
<MkAvatar :class="$style.avatar" :user="user" indicator/> <MkAvatar :class="$style.avatar" :user="user" indicator/>
<div :class="$style.body"> <div :class="$style.body">
<span :class="$style.name"><MkUserName :user="user"/></span> <span :class="$style.name"><MkUserName :user="user"/><slot name="nameSuffix"></slot></span>
<span :class="$style.sub"><slot name="sub"><span class="_monospace">@{{ acct(user) }}</span></slot></span> <span :class="$style.sub"><slot name="sub"><span class="_monospace">@{{ acct(user) }}</span></slot></span>
</div> </div>
<MkMiniChart v-if="chartValues" :class="$style.chart" :src="chartValues"/> <MkMiniChart v-if="chartValues" :class="$style.chart" :src="chartValues"/>

View File

@ -12,7 +12,7 @@ import { BroadcastChannel } from 'broadcast-channel';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { get, set } from '@/utility/idb-proxy.js'; import { get, set, delMany } from '@/utility/idb-proxy.js';
import { store } from '@/store.js'; import { store } from '@/store.js';
import { deepClone } from '@/utility/clone.js'; import { deepClone } from '@/utility/clone.js';
import { deepMerge } from '@/utility/merge.js'; import { deepMerge } from '@/utility/merge.js';
@ -222,6 +222,18 @@ export class Pizzax<T extends StateDef> {
return this.def[key].default; return this.def[key].default;
} }
/** 現在のアカウントに紐づくデータをデバイスから削除します */
public async clearAccountDataFromDevice(id = $i?.id) {
if (id == null) return;
const deviceAccountStateKey = `pizzax::${this.key}::${id}` satisfies typeof this.deviceAccountStateKeyName;
const registryCacheKey = `pizzax::${this.key}::cache::${id}` satisfies typeof this.registryCacheKeyName;
await this.addIdbSetJob(async () => {
await delMany([deviceAccountStateKey, registryCacheKey]);
});
}
/** /**
* computed refを作ります * computed refを作ります
* vue上で設定コントロールのmodelとして使う用 * vue上で設定コントロールのmodelとして使う用

View File

@ -8,11 +8,23 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps"> <div class="_gaps">
<div class="_buttons"> <div class="_buttons">
<MkButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</MkButton> <MkButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</MkButton>
<!--<MkButton @click="refreshAllAccounts"><i class="ti ti-refresh"></i></MkButton>--> <MkButton @click="refreshAllAccounts"><i class="ti ti-refresh"></i> {{ i18n.ts.reloadAccountsList }}</MkButton>
<MkButton danger @click="logoutFromAll"><i class="ti ti-power"></i> {{ i18n.ts.logoutFromAll }}</MkButton>
</div> </div>
<template v-for="x in accounts" :key="x.host + x.id"> <template v-for="x in accounts" :key="x.host + x.id">
<MkUserCardMini v-if="x.user" :user="x.user" :class="$style.user" @click.prevent="showMenu(x.host, x.id, $event)"/> <MkUserCardMini v-if="x.user" :user="x.user" :class="$style.user" @click.prevent="showMenu(x, $event)">
<template #nameSuffix>
<span v-if="x.id === $i?.id" :class="$style.currentAccountTag">{{ i18n.ts.loggingIn }}</span>
</template>
</MkUserCardMini>
<button v-else v-panel class="_button" :class="$style.unknownUser" @click="showMenu(x, $event)">
<div :class="$style.unknownUserAvatarMock"><i class="ti ti-user-question"></i></div>
<div>
<div :class="$style.unknownUserTitle">{{ i18n.ts.unknown }}<span v-if="x.id === $i?.id" :class="$style.currentAccountTag">{{ i18n.ts.loggingIn }}</span></div>
<div :class="$style.unknownUserSub">ID: <span class="_monospace">{{ x.id }}</span></div>
</div>
</button>
</template> </template>
</div> </div>
</SearchMarker> </SearchMarker>
@ -20,36 +32,73 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import * as Misskey from 'misskey-js'; import { host as local } from '@@/js/config.js';
import type { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { switchAccount, removeAccount, login, getAccountWithSigninDialog, getAccountWithSignupDialog, getAccounts } from '@/accounts.js'; import { switchAccount, removeAccount, removeAccountAssociatedData, getAccountWithSigninDialog, getAccountWithSignupDialog, getAccounts, refreshAccounts } from '@/accounts.js';
import type { AccountData } from '@/accounts.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkUserCardMini from '@/components/MkUserCardMini.vue';
import { prefer } from '@/preferences.js'; import { signout } from '@/signout.js';
const accounts = await getAccounts(); const accounts = ref<AccountData[]>([]);
getAccounts().then((res) => {
accounts.value = res;
});
function refreshAllAccounts() { function refreshAllAccounts() {
// TODO os.promiseDialog((async () => {
await refreshAccounts();
accounts.value = await getAccounts();
})());
} }
function showMenu(host: string, id: string, ev: PointerEvent) { function showMenu(a: AccountData, ev: PointerEvent) {
let menu: MenuItem[]; const menu: MenuItem[] = [];
menu = [{ if ($i != null && $i.id === a.id && ($i.host ?? local) === a.host) {
text: i18n.ts.switch, menu.push({
icon: 'ti ti-switch-horizontal', text: i18n.ts.logout,
action: () => switchAccount(host, id), icon: 'ti ti-power',
}, { danger: true,
text: i18n.ts.remove, action: async () => {
icon: 'ti ti-trash', const { canceled } = await os.confirm({
action: () => removeAccount(host, id), type: 'warning',
}]; title: i18n.ts.logoutConfirm,
text: i18n.ts.logoutWillClearClientData,
});
if (canceled) return;
signout();
},
});
} else {
menu.push({
text: i18n.ts.switch,
icon: 'ti ti-switch-horizontal',
action: () => switchAccount(a.host, a.id),
}, {
text: i18n.ts.logout,
icon: 'ti ti-power',
danger: true,
action: async () => {
const { canceled } = await os.confirm({
type: 'warning',
title: i18n.tsx.logoutFromOtherAccountConfirm({ username: `<plain>@${a.username}</plain>` }),
text: i18n.ts.logoutWillClearClientData,
});
if (canceled) return;
await os.promiseDialog((async () => {
await removeAccount(a.host, a.id);
await removeAccountAssociatedData(a.host, a.id);
accounts.value = await getAccounts();
})());
},
});
}
os.popupMenu(menu, ev.currentTarget ?? ev.target); os.popupMenu(menu, ev.currentTarget ?? ev.target);
} }
@ -64,20 +113,30 @@ function addAccount(ev: PointerEvent) {
}], ev.currentTarget ?? ev.target); }], ev.currentTarget ?? ev.target);
} }
function addExistingAccount() { async function addExistingAccount() {
getAccountWithSigninDialog().then((res) => { const res = await getAccountWithSigninDialog();
if (res != null) { if (res != null) {
os.success(); os.success();
} }
}); accounts.value = await getAccounts();
} }
function createAccount() { async function createAccount() {
getAccountWithSignupDialog().then((res) => { const res = await getAccountWithSignupDialog();
if (res != null) { if (res != null) {
login(res.token); os.success();
} }
accounts.value = await getAccounts();
}
async function logoutFromAll() {
const { canceled } = await os.confirm({
type: 'warning',
title: i18n.ts.logoutConfirm,
text: i18n.ts.logoutWillClearClientData,
}); });
if (canceled) return;
signout(true);
} }
const headerActions = computed(() => []); const headerActions = computed(() => []);
@ -95,6 +154,16 @@ definePage(() => ({
cursor: pointer; cursor: pointer;
} }
.currentAccountTag {
display: inline-block;
margin-left: 8px;
padding: 0 6px;
font-size: 0.8em;
background: var(--MI_THEME-accentedBg);
color: var(--MI_THEME-accent);
border-radius: calc(var(--MI-radius) / 2);
}
.unknownUser { .unknownUser {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -94,7 +94,9 @@ export type StorageProvider = {
type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) => infer R ? R : Default> = { type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) => infer R ? R : Default> = {
default: Default; default: Default;
/** アカウントごとに異なる設定値をもたせるかどうか */
accountDependent?: boolean; accountDependent?: boolean;
/** サーバーごとに異なる設定値をもたせるかどうか(他のサーバーを同一クライアントから操作できるようになった際に使用) */
serverDependent?: boolean; serverDependent?: boolean;
mergeStrategy?: (a: T, b: T) => T; mergeStrategy?: (a: T, b: T) => T;
}; };
@ -451,6 +453,33 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
this.save(); this.save();
} }
/** 現在の操作アカウントに紐づく設定値をデバイスから削除します(ログアウト時などに使用) */
public clearAccountSettingsFromDevice(targetHost = host, id = this.currentAccount?.id) {
if (id == null) return;
let changed = false;
for (const _key in PREF_DEF) {
const key = _key as keyof PREF;
const records = this.profile.preferences[key];
const index = records.findIndex((record: PrefRecord<typeof key>) => {
const scope = parseScope(record[0]);
return scope.server === targetHost && scope.account === id;
});
if (index === -1) continue;
records.splice(index, 1);
changed = true;
this.rewriteRawState(key, this.getMatchedRecordOf(key)[1]);
}
if (changed) {
this.save();
}
}
public isSyncEnabled<K extends keyof PREF>(key: K): boolean { public isSyncEnabled<K extends keyof PREF>(key: K): boolean {
return this.getMatchedRecordOf(key)[2].sync ?? false; return this.getMatchedRecordOf(key)[2].sync ?? false;
} }

View File

@ -5,20 +5,18 @@
import { apiUrl } from '@@/js/config.js'; import { apiUrl } from '@@/js/config.js';
import { cloudBackup } from '@/preferences/utility.js'; import { cloudBackup } from '@/preferences/utility.js';
import { removeAccount, login } from '@/accounts.js';
import { host } from '@@/js/config.js';
import { store } from '@/store.js'; import { store } from '@/store.js';
import { prefer } from '@/preferences.js';
import { waiting } from '@/os.js'; import { waiting } from '@/os.js';
import { unisonReload } from '@/utility/unison-reload.js'; import { unisonReload } from '@/utility/unison-reload.js';
import { clear } from '@/utility/idb-proxy.js'; import { clear } from '@/utility/idb-proxy.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
export async function signout() { /** クライアントに保存しているすべてのデータを削除します。 */
if (!$i) return; async function removeAllData() {
if ($i == null) return;
waiting();
if (store.s.enablePreferencesAutoCloudBackup) {
await cloudBackup();
}
localStorage.clear(); localStorage.clear();
@ -74,6 +72,54 @@ export async function signout() {
// nothing // nothing
} }
//#endregion //#endregion
}
/** 現在のアカウントに関連するデータを削除します。 */
async function removeCurrentAccountData() {
if ($i == null) return;
// 設定・状態を削除
prefer.clearAccountSettingsFromDevice();
await store.clearAccountDataFromDevice();
}
export async function signout(all = false) {
if (!$i) return;
const currentAccountId = $i.id;
waiting();
if (store.s.enablePreferencesAutoCloudBackup) {
await cloudBackup();
}
if (prefer.s.accounts.length <= 1 || all) {
// 最後のアカウントを削除する場合・全てのアカウントからログアウトする場合は全データ削除
await removeAllData();
} else {
// 複数アカウントある場合は現在のアカウントのデータのみ削除
await removeCurrentAccountData();
// 現在のアカウント情報を削除
await removeAccount(host, $i.id);
// アカウント切り替え
const nextAccountToken = Object.entries(store.s.accountTokens).find(([key, _]) => {
const [accountHost, userId] = key.split('/');
return accountHost === host && userId !== currentAccountId;
})?.[1];
if (nextAccountToken != null) {
// ログインの際の遷移の挙動はlogin関数内で行うのでここではunisonReloadを呼ばず終了
await login(nextAccountToken, undefined, false);
return;
} else {
// 現時点では外部ホストのアカウントをログインさせることはできないので、
// 通常の全アカウントからのログアウトと同様に扱う(全データ削除)
await removeAllData();
}
}
unisonReload('/'); unisonReload('/');
} }

View File

@ -99,11 +99,11 @@ export const store = markRaw(new Pizzax('base', {
}, },
accountTokens: { accountTokens: {
where: 'device', where: 'device',
default: {} as Record<string, string>, // host/userId, token default: {} as Record<`${string}/${string}`, string>, // host/userId, token
}, },
accountInfos: { accountInfos: {
where: 'device', where: 'device',
default: {} as Record<string, Misskey.entities.MeDetailed>, // host/userId, user default: {} as Record<`${string}/${string}`, Misskey.entities.MeDetailed>, // host/userId, user
}, },
enablePreferencesAutoCloudBackup: { enablePreferencesAutoCloudBackup: {

View File

@ -9,6 +9,7 @@ import {
get as iget, get as iget,
set as iset, set as iset,
del as idel, del as idel,
delMany as idelMany,
clear as iclear, clear as iclear,
} from 'idb-keyval'; } from 'idb-keyval';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
@ -53,6 +54,13 @@ export async function del(key: string) {
return miLocalStorage.removeItem(`${PREFIX}${key}`); return miLocalStorage.removeItem(`${PREFIX}${key}`);
} }
export async function delMany(keys: string[]) {
if (idbAvailable) return idelMany(keys);
for (const key of keys) {
miLocalStorage.removeItem(`${PREFIX}${key}`);
}
}
export async function clear() { export async function clear() {
if (idbAvailable) return iclear(); if (idbAvailable) return iclear();
} }

View File

@ -148,6 +148,10 @@ export interface Locale extends ILocale {
* *
*/ */
"logout": string; "logout": string;
/**
*
*/
"logoutFromAll": string;
/** /**
* *
*/ */
@ -3980,6 +3984,10 @@ export interface Locale extends ILocale {
* *
*/ */
"logoutWillClearClientData": string; "logoutWillClearClientData": string;
/**
* {username}
*/
"logoutFromOtherAccountConfirm": ParameterizedString<"username">;
/** /**
* *
*/ */