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

View File

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

View File

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

View File

@ -19,13 +19,15 @@ import { signout } from '@/signout.js';
type AccountWithToken = Misskey.entities.MeDetailed & { token: string };
export async function getAccounts(): Promise<{
export type AccountData = {
host: string;
id: Misskey.entities.User['id'];
username: Misskey.entities.User['username'];
user?: Misskey.entities.MeDetailed | null;
token: string | null;
}[]> {
};
export async function getAccounts(): Promise<AccountData[]> {
const tokens = store.s.accountTokens;
const accountInfos = store.s.accountInfos;
const accounts = prefer.s.accounts;
@ -33,8 +35,8 @@ export async function getAccounts(): Promise<{
host,
id: user.id,
username: user.username,
user: accountInfos[host + '/' + user.id],
token: tokens[host + '/' + user.id] ?? null,
user: accountInfos[`${host}/${user.id}`],
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));
delete accountInfos[host + '/' + id];
store.set('accountInfos', accountInfos);
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');
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 { dispose } = popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
success: false,
showing: showing,
}, {
closed: () => dispose(),
});
if (showWaiting) {
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;
@ -195,7 +224,7 @@ export async function login(token: AccountWithToken['token'], redirect?: 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) {
login(token);
} else {

View File

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-adaptive-bg :class="[$style.root]">
<MkAvatar :class="$style.avatar" :user="user" indicator/>
<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>
</div>
<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 { $i } from '@/i.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 { deepClone } from '@/utility/clone.js';
import { deepMerge } from '@/utility/merge.js';
@ -222,6 +222,18 @@ export class Pizzax<T extends StateDef> {
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を作ります
* vue上で設定コントロールのmodelとして使う用

View File

@ -8,11 +8,23 @@ SPDX-License-Identifier: AGPL-3.0-only
<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>-->
<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>
<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>
</div>
</SearchMarker>
@ -20,36 +32,73 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
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 MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.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 { definePage } from '@/page.js';
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() {
// TODO
os.promiseDialog((async () => {
await refreshAccounts();
accounts.value = await getAccounts();
})());
}
function showMenu(host: string, id: string, ev: PointerEvent) {
let menu: MenuItem[];
function showMenu(a: AccountData, ev: PointerEvent) {
const menu: MenuItem[] = [];
menu = [{
text: i18n.ts.switch,
icon: 'ti ti-switch-horizontal',
action: () => switchAccount(host, id),
}, {
text: i18n.ts.remove,
icon: 'ti ti-trash',
action: () => removeAccount(host, id),
}];
if ($i != null && $i.id === a.id && ($i.host ?? local) === a.host) {
menu.push({
text: i18n.ts.logout,
icon: 'ti ti-power',
danger: true,
action: async () => {
const { canceled } = await os.confirm({
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);
}
@ -64,20 +113,30 @@ function addAccount(ev: PointerEvent) {
}], ev.currentTarget ?? ev.target);
}
function addExistingAccount() {
getAccountWithSigninDialog().then((res) => {
if (res != null) {
os.success();
}
});
async function addExistingAccount() {
const res = await getAccountWithSigninDialog();
if (res != null) {
os.success();
}
accounts.value = await getAccounts();
}
function createAccount() {
getAccountWithSignupDialog().then((res) => {
if (res != null) {
login(res.token);
}
async function createAccount() {
const res = await getAccountWithSignupDialog();
if (res != null) {
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(() => []);
@ -95,6 +154,16 @@ definePage(() => ({
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 {
display: flex;
align-items: center;

View File

@ -94,7 +94,9 @@ export type StorageProvider = {
type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) => infer R ? R : Default> = {
default: Default;
/** アカウントごとに異なる設定値をもたせるかどうか */
accountDependent?: boolean;
/** サーバーごとに異なる設定値をもたせるかどうか(他のサーバーを同一クライアントから操作できるようになった際に使用) */
serverDependent?: boolean;
mergeStrategy?: (a: T, b: T) => T;
};
@ -451,6 +453,33 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
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 {
return this.getMatchedRecordOf(key)[2].sync ?? false;
}

View File

@ -5,20 +5,18 @@
import { apiUrl } from '@@/js/config.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 { prefer } from '@/preferences.js';
import { waiting } from '@/os.js';
import { unisonReload } from '@/utility/unison-reload.js';
import { clear } from '@/utility/idb-proxy.js';
import { $i } from '@/i.js';
export async function signout() {
if (!$i) return;
waiting();
if (store.s.enablePreferencesAutoCloudBackup) {
await cloudBackup();
}
/** クライアントに保存しているすべてのデータを削除します。 */
async function removeAllData() {
if ($i == null) return;
localStorage.clear();
@ -74,6 +72,54 @@ export async function signout() {
// nothing
}
//#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('/');
}

View File

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

View File

@ -9,6 +9,7 @@ import {
get as iget,
set as iset,
del as idel,
delMany as idelMany,
clear as iclear,
} from 'idb-keyval';
import { miLocalStorage } from '@/local-storage.js';
@ -53,6 +54,13 @@ export async function del(key: string) {
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() {
if (idbAvailable) return iclear();
}

View File

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