Compare commits

...

13 Commits

Author SHA1 Message Date
github-actions[bot]
ffade9740e Bump version to 2025.3.2-alpha.7 2025-03-12 03:03:37 +00:00
syuilo
b03bcf26cd enhance(frontend): 設定値の同期を実装(実験的) 2025-03-12 11:39:05 +09:00
syuilo
ddbc83b2e4 chore(frontend): tweak settings page 2025-03-11 20:42:06 +09:00
syuilo
d185785f20 enhance(frontend): improve settings page 2025-03-11 14:52:04 +09:00
syuilo
02d7fbefc4 🎨 2025-03-11 12:08:15 +09:00
syuilo
f7ea92c68c chore: remove unused files 2025-03-11 12:02:41 +09:00
syuilo
e891d5c5d3 Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2025-03-11 11:44:34 +09:00
syuilo
57a6b630b7 chore: add note 2025-03-11 11:44:25 +09:00
github-actions[bot]
eda768a08c Bump version to 2025.3.2-alpha.6 2025-03-11 02:43:27 +00:00
syuilo
1f345eb839 enhance(frontend): deckをpreferences管理に 2025-03-11 11:14:55 +09:00
syuilo
1f2801af02 Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2025-03-10 21:42:30 +09:00
syuilo
a4ba096e2a chore(frontend): improve preference store stability 2025-03-10 21:42:17 +09:00
ろむねこ
6841cdfa76 enhance(frontend): CWの注釈テキストが入力されていない場合はPostボタンを非アクティブに (#15639)
* add condition to disable post button when CW text is empty

* standardize condition by using 1<= inserted of 0<

* unify CW text length condition to improve readability

* add missing CW state check

* fix state check, add empty/null check, improve max length validation

* simplify CW validation by removing minimum length check

* Update CHANGELOG

* remove CW text validation in post()

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2025-03-10 10:35:37 +00:00
57 changed files with 1486 additions and 1106 deletions

View File

@@ -6,7 +6,10 @@
### Client
- Feat: 設定の管理が強化されました
- 自動でバックアップされるように
- 任意の設定項目をデバイス間で同期できるように(実験的)
- Enhance: プラグインの管理が強化されました
- Enhance: CWの注釈テキストが入力されていない場合, Postボタンを非アクティブに
- Enhance: CWを無効にした場合, 注釈テキストが最大入力文字数を超えていても投稿できるように
- Enhance: テーマ設定画面のデザインを改善
- Fix: テーマ切り替え時に一部の色が変わらない問題を修正

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

90
locales/index.d.ts vendored
View File

@@ -5310,6 +5310,96 @@ export interface Locale extends ILocale {
* 復元
*/
"restore": string;
/**
* デバイス間で同期
*/
"syncBetweenDevices": string;
/**
* サーバーに設定値が存在します
*/
"preferenceSyncConflictTitle": string;
/**
* 同期が有効にされた設定項目は設定値をサーバーに保存しますが、この設定項目のサーバーに保存された設定値が見つかりました。どちらの設定値で上書きしますか?
*/
"preferenceSyncConflictText": string;
/**
* サーバーの設定値
*/
"preferenceSyncConflictChoiceServer": string;
/**
* デバイスの設定値
*/
"preferenceSyncConflictChoiceDevice": string;
/**
* 同期の有効化をキャンセル
*/
"preferenceSyncConflictChoiceCancel": string;
"_settings": {
/**
* ドライブの管理と設定、使用量の確認、ファイルをアップロードする際の設定を行えます。
*/
"driveBanner": string;
/**
* プラグインを利用するとクライアントの機能を拡張することができます。プラグインのインストール、個別の設定と管理が行えます。
*/
"pluginBanner": string;
/**
* サーバーからの受信する通知の種類と範囲や、プッシュ通知の設定が行えます。
*/
"notificationsBanner": string;
/**
* API
*/
"api": string;
/**
* Webhook
*/
"webhook": string;
/**
* サービス連携
*/
"serviceConnection": string;
/**
* 外部のアプリ・サービスと連携するためのアクセストークンやWebhookの管理と設定が行えます。
*/
"serviceConnectionBanner": string;
/**
* アカウントのデータ
*/
"accountData": string;
/**
* アカウントのデータをエクスポート/インポートして管理できます。
*/
"accountDataBanner": string;
/**
* 非表示にするコンテンツの設定や、特定のユーザーからのアクションを制限する設定と管理を行えます。
*/
"muteAndBlockBanner": string;
/**
* クライアントの視覚や動作に関するパーソナライズを行い、より最適に使用できるように設定できます。
*/
"accessibilityBanner": string;
/**
* コンテンツの公開範囲、見つけやすさ、フォローの承認制などアカウントのプライバシーに関する設定を行えます。
*/
"privacyBanner": string;
/**
* パスワード、ログイン方法、認証アプリ、パスキーなどアカウントのセキュリティに関する設定を行えます。
*/
"securityBanner": string;
/**
* 好みに応じた、クライアントの全体的な動作の設定が行えます。
*/
"preferencesBanner": string;
/**
* 好みに応じた、クライアントの見た目・表示方法に関する設定が行えます。
*/
"appearanceBanner": string;
/**
* クライアントで再生するサウンドの設定が行えます。
*/
"soundsBanner": string;
};
"_preferencesProfile": {
/**
* プロファイル名

View File

@@ -1323,6 +1323,30 @@ untitled: "無題"
noName: "名前はありません"
skip: "スキップ"
restore: "復元"
syncBetweenDevices: "デバイス間で同期"
preferenceSyncConflictTitle: "サーバーに設定値が存在します"
preferenceSyncConflictText: "同期が有効にされた設定項目は設定値をサーバーに保存しますが、この設定項目のサーバーに保存された設定値が見つかりました。どちらの設定値で上書きしますか?"
preferenceSyncConflictChoiceServer: "サーバーの設定値"
preferenceSyncConflictChoiceDevice: "デバイスの設定値"
preferenceSyncConflictChoiceCancel: "同期の有効化をキャンセル"
_settings:
driveBanner: "ドライブの管理と設定、使用量の確認、ファイルをアップロードする際の設定を行えます。"
pluginBanner: "プラグインを利用するとクライアントの機能を拡張することができます。プラグインのインストール、個別の設定と管理が行えます。"
notificationsBanner: "サーバーからの受信する通知の種類と範囲や、プッシュ通知の設定が行えます。"
api: "API"
webhook: "Webhook"
serviceConnection: "サービス連携"
serviceConnectionBanner: "外部のアプリ・サービスと連携するためのアクセストークンやWebhookの管理と設定が行えます。"
accountData: "アカウントのデータ"
accountDataBanner: "アカウントのデータをエクスポート/インポートして管理できます。"
muteAndBlockBanner: "非表示にするコンテンツの設定や、特定のユーザーからのアクションを制限する設定と管理を行えます。"
accessibilityBanner: "クライアントの視覚や動作に関するパーソナライズを行い、より最適に使用できるように設定できます。"
privacyBanner: "コンテンツの公開範囲、見つけやすさ、フォローの承認制などアカウントのプライバシーに関する設定を行えます。"
securityBanner: "パスワード、ログイン方法、認証アプリ、パスキーなどアカウントのセキュリティに関する設定を行えます。"
preferencesBanner: "好みに応じた、クライアントの全体的な動作の設定が行えます。"
appearanceBanner: "好みに応じた、クライアントの見た目・表示方法に関する設定が行えます。"
soundsBanner: "クライアントで再生するサウンドの設定が行えます。"
_preferencesProfile:
profileName: "プロファイル名"

View File

@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2025.3.2-alpha.5",
"version": "2025.3.2-alpha.7",
"codename": "nasubi",
"repository": {
"type": "git",

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -17,6 +17,7 @@ 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 };

View File

@@ -6,9 +6,11 @@
import { createApp, defineAsyncComponent, markRaw } from 'vue';
import { ui } from '@@/js/config.js';
import * as Misskey from 'misskey-js';
import { v4 as uuid } from 'uuid';
import { common } from './common.js';
import type { Component } from 'vue';
import type { Keymap } from '@/utility/hotkey.js';
import type { DeckProfile } from '@/deck.js';
import { i18n } from '@/i18n.js';
import { alert, confirm, popup, post, toast } from '@/os.js';
import { useStream } from '@/stream.js';
@@ -143,12 +145,34 @@ export async function mainBoot() {
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'));
@@ -223,9 +247,6 @@ export async function mainBoot() {
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);
store.set('deck.profile', deckStore.s.profile);
store.set('deck.columns', deckStore.s.columns);
store.set('deck.layout', deckStore.s.layout);
store.set('menu', []);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -265,7 +265,13 @@ const canPost = computed((): boolean => {
quoteId.value != null
) &&
(textLength.value <= maxTextLength.value) &&
(cwTextLength.value <= maxCwTextLength) &&
(
useCw.value ?
(
cw.value != null && cw.value.trim() !== '' &&
cwTextLength.value <= maxCwTextLength
) : true
) &&
(files.value.length <= 16) &&
(!poll.value || poll.value.choices.length >= 2);
});
@@ -744,14 +750,6 @@ function isAnnoying(text: string): boolean {
}
async function post(ev?: MouseEvent) {
if (useCw.value && (cw.value == null || cw.value.trim() === '')) {
os.alert({
type: 'error',
text: i18n.ts.cwNotationRequired,
});
return;
}
if (ev) {
const el = (ev.currentTarget ?? ev.target) as HTMLElement | null;

View File

@@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<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"><i class="ti ti-dots"></i></button>
@@ -21,20 +22,21 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref } from 'vue';
import type { PREF_DEF } from '@/preferences/def.js';
import * as os from '@/os.js';
import { profileManager } from '@/preferences.js';
import { prefer } from '@/preferences.js';
const props = withDefaults(defineProps<{
k: keyof typeof PREF_DEF;
}>(), {
});
const isAccountOverrided = ref(profileManager.isAccountOverrided(props.k));
const isAccountOverrided = ref(prefer.isAccountOverrided(props.k));
const isSyncEnabled = ref(prefer.isSyncEnabled(props.k));
function showMenu(ev: MouseEvent) {
const i = window.setInterval(() => {
isAccountOverrided.value = profileManager.isAccountOverrided(props.k);
isAccountOverrided.value = prefer.isAccountOverrided(props.k);
}, 100);
os.popupMenu(profileManager.getPerPrefMenu(props.k), ev.currentTarget ?? ev.target, {
os.popupMenu(prefer.getPerPrefMenu(props.k), ev.currentTarget ?? ev.target, {
onClosing: () => {
window.clearInterval(i);
},

View File

@@ -3,13 +3,23 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { throttle } from 'throttle-debounce';
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 { misskeyApi } from '@/utility/misskey-api.js';
import type { MenuItem } from '@/types/menu.js';
import { deepClone } from '@/utility/clone.js';
import { store } from '@/store.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;
@@ -53,127 +63,132 @@ export type Column = {
soundSetting?: SoundStore;
};
export const loadDeck = async () => {
let deck;
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 : []);
try {
deck = await misskeyApi('i/registry/get', {
scope: ['client', 'deck', 'profiles'],
key: store.s['deck.profile'],
});
} catch (err) {
if (typeof err === 'object' && err != null && 'code' in err && err.code === 'NO_SUCH_KEY') {
// 後方互換性のため
if (store.s['deck.profile'] === 'default') {
saveDeck();
return;
}
if (prefer.s['deck.profile'] == null) {
addProfile('Main');
}
store.set('deck.columns', []);
store.set('deck.layout', []);
return;
}
throw err;
}
export function forceSaveCurrentDeckProfile() {
const currentProfile = prefer.s['deck.profiles'].find(p => p.name === prefer.s['deck.profile']);
if (currentProfile == null) return;
store.set('deck.columns', deck.columns);
store.set('deck.layout', deck.layout);
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();
};
export async function forceSaveDeck() {
await misskeyApi('i/registry/set', {
scope: ['client', 'deck', 'profiles'],
key: store.s['deck.profile'],
value: {
columns: store.r['deck.columns'].value,
layout: store.r['deck.layout'].value,
},
});
function switchProfile(profile: DeckProfile) {
prefer.commit('deck.profile', profile.name);
const currentProfile = deepClone(profile);
columns.value = currentProfile.columns;
layout.value = currentProfile.layout;
forceSaveCurrentDeckProfile();
}
// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する
export const saveDeck = throttle(1000, () => {
forceSaveDeck();
});
function addProfile(name: string) {
if (name.trim() === '') return;
if (prefer.s['deck.profiles'].find(p => p.name === name)) return;
export async function getProfiles(): Promise<string[]> {
return await misskeyApi('i/registry/keys', {
scope: ['client', 'deck', 'profiles'],
});
const newProfile: DeckProfile = {
id: uuid(),
name,
columns: [],
layout: [],
};
prefer.commit('deck.profiles', [...prefer.s['deck.profiles'], newProfile]);
switchProfile(newProfile);
}
export async function deleteProfile(key: string): Promise<void> {
return await misskeyApi('i/registry/remove', {
scope: ['client', 'deck', 'profiles'],
key: key,
});
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;
store.push('deck.columns', column);
store.push('deck.layout', [column.id]);
saveDeck();
columns.value.push(column);
layout.value.push([column.id]);
saveCurrentDeckProfile();
}
export function removeColumn(id: Column['id']) {
store.set('deck.columns', store.s['deck.columns'].filter(c => c.id !== id));
store.set('deck.layout', store.s['deck.layout']
.map(ids => ids.filter(_id => _id !== id))
.filter(ids => ids.length > 0));
saveDeck();
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 = store.s['deck.layout'].findIndex(ids => ids.indexOf(a) !== -1);
const aY = store.s['deck.layout'][aX].findIndex(id => id === a);
const bX = store.s['deck.layout'].findIndex(ids => ids.indexOf(b) !== -1);
const bY = store.s['deck.layout'][bX].findIndex(id => id === b);
const layout = deepClone(store.s['deck.layout']);
layout[aX][aY] = b;
layout[bX][bY] = a;
store.set('deck.layout', layout);
saveDeck();
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 layout = deepClone(store.s['deck.layout']);
store.s['deck.layout'].some((ids, i) => {
const newLayout = deepClone(layout.value);
layout.value.some((ids, i) => {
if (ids.includes(id)) {
const left = store.s['deck.layout'][i - 1];
const left = layout.value[i - 1];
if (left) {
layout[i - 1] = store.s['deck.layout'][i];
layout[i] = left;
store.set('deck.layout', layout);
newLayout[i - 1] = layout.value[i];
newLayout[i] = left;
layout.value = newLayout;
}
return true;
}
return false;
});
saveDeck();
saveCurrentDeckProfile();
}
export function swapRightColumn(id: Column['id']) {
const layout = deepClone(store.s['deck.layout']);
store.s['deck.layout'].some((ids, i) => {
const newLayout = deepClone(layout.value);
layout.value.some((ids, i) => {
if (ids.includes(id)) {
const right = store.s['deck.layout'][i + 1];
const right = layout.value[i + 1];
if (right) {
layout[i + 1] = store.s['deck.layout'][i];
layout[i] = right;
store.set('deck.layout', layout);
newLayout[i + 1] = layout.value[i];
newLayout[i] = right;
layout.value = newLayout;
}
return true;
}
return false;
});
saveDeck();
saveCurrentDeckProfile();
}
export function swapUpColumn(id: Column['id']) {
const layout = deepClone(store.s['deck.layout']);
const idsIndex = store.s['deck.layout'].findIndex(ids => ids.includes(id));
const ids = deepClone(store.s['deck.layout'][idsIndex]);
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];
@@ -181,20 +196,20 @@ export function swapUpColumn(id: Column['id']) {
ids[i - 1] = id;
ids[i] = up;
layout[idsIndex] = ids;
store.set('deck.layout', layout);
newLayout[idsIndex] = ids;
layout.value = newLayout;
}
return true;
}
return false;
});
saveDeck();
saveCurrentDeckProfile();
}
export function swapDownColumn(id: Column['id']) {
const layout = deepClone(store.s['deck.layout']);
const idsIndex = store.s['deck.layout'].findIndex(ids => ids.includes(id));
const ids = deepClone(store.s['deck.layout'][idsIndex]);
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];
@@ -202,105 +217,137 @@ export function swapDownColumn(id: Column['id']) {
ids[i + 1] = id;
ids[i] = down;
layout[idsIndex] = ids;
store.set('deck.layout', layout);
newLayout[idsIndex] = ids;
layout.value = newLayout;
}
return true;
}
return false;
});
saveDeck();
saveCurrentDeckProfile();
}
export function stackLeftColumn(id: Column['id']) {
let layout = deepClone(store.s['deck.layout']);
const i = store.s['deck.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);
store.set('deck.layout', layout);
saveDeck();
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 layout = deepClone(store.s['deck.layout']);
const i = store.s['deck.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);
store.set('deck.layout', layout);
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 columns = deepClone(store.s['deck.columns']);
for (const column of columns) {
const newColumns = deepClone(columns.value);
for (const column of newColumns) {
if (affected.includes(column.id)) {
column.active = true;
}
}
store.set('deck.columns', columns);
columns.value = newColumns;
saveDeck();
saveCurrentDeckProfile();
}
export function addColumnWidget(id: Column['id'], widget: ColumnWidget) {
const columns = deepClone(store.s['deck.columns']);
const columnIndex = store.s['deck.columns'].findIndex(c => c.id === id);
const column = deepClone(store.s['deck.columns'][columnIndex]);
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);
columns[columnIndex] = column;
store.set('deck.columns', columns);
saveDeck();
newColumns[columnIndex] = column;
columns.value = newColumns;
saveCurrentDeckProfile();
}
export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) {
const columns = deepClone(store.s['deck.columns']);
const columnIndex = store.s['deck.columns'].findIndex(c => c.id === id);
const column = deepClone(store.s['deck.columns'][columnIndex]);
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);
columns[columnIndex] = column;
store.set('deck.columns', columns);
saveDeck();
newColumns[columnIndex] = column;
columns.value = newColumns;
saveCurrentDeckProfile();
}
export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) {
const columns = deepClone(store.s['deck.columns']);
const columnIndex = store.s['deck.columns'].findIndex(c => c.id === id);
const column = deepClone(store.s['deck.columns'][columnIndex]);
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;
columns[columnIndex] = column;
store.set('deck.columns', columns);
saveDeck();
newColumns[columnIndex] = column;
columns.value = newColumns;
saveCurrentDeckProfile();
}
export function updateColumnWidget(id: Column['id'], widgetId: string, widgetData: any) {
const columns = deepClone(store.s['deck.columns']);
const columnIndex = store.s['deck.columns'].findIndex(c => c.id === id);
const column = deepClone(store.s['deck.columns'][columnIndex]);
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);
columns[columnIndex] = column;
store.set('deck.columns', columns);
saveDeck();
newColumns[columnIndex] = column;
columns.value = newColumns;
saveCurrentDeckProfile();
}
export function updateColumn(id: Column['id'], column: Partial<Column>) {
const columns = deepClone(store.s['deck.columns']);
const columnIndex = store.s['deck.columns'].findIndex(c => c.id === id);
const currentColumn = deepClone(store.s['deck.columns'][columnIndex]);
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;
}
columns[columnIndex] = currentColumn;
store.set('deck.columns', columns);
saveDeck();
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);
}

View File

@@ -6,6 +6,10 @@ 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">
@@ -79,6 +83,7 @@ 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';
const reduceAnimation = prefer.model('animation', v => !v, v => !v);
const animatedMfm = prefer.model('animatedMfm');

View File

@@ -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']" 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 '@/account.js';
import { store } from '@/store.js';
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
const excludeMutingUsers = ref(false);
const excludeInactiveUsers = ref(false);
const withReplies = ref(store.s.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 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>

View File

@@ -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 '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.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(() => []);
definePage(() => ({
title: 'API',
icon: 'ti ti-api',
}));
</script>

View File

@@ -6,6 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<SearchMarker path="/settings/appearance" :label="i18n.ts.appearance" :keywords="['appearance']" icon="ti ti-device-desktop">
<div class="_gaps_m">
<MkFeatureBanner icon="/client-assets/desktop_computer_3d.png" color="#eaff00">
<SearchKeyword>{{ i18n.ts._settings.appearanceBanner }}</SearchKeyword>
</MkFeatureBanner>
<FormSection first>
<div class="_gaps_m">
<div class="_gaps_s">
@@ -227,6 +231,7 @@ import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';
import { instance } from '@/instance.js';
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
const fontSize = ref(miLocalStorage.getItem('fontSize'));
const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null);

View File

@@ -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><SearchLabel>{{ i18n.ts.manage }}</SearchLabel></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>

View File

@@ -6,6 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<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>
<SearchMarker :keywords="['capacity', 'usage']">
<FormSection first>
<template #label><SearchLabel>{{ i18n.ts.usageAmount }}</SearchLabel></template>
@@ -103,6 +107,7 @@ import { definePage } from '@/page.js';
import { signinRequired } from '@/account.js';
import { prefer } from '@/preferences.js';
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
const $i = signinRequired();

View File

@@ -1,263 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<SearchMarker path="/settings/import-export" :label="i18n.ts.importAndExport" :keywords="['import', 'export', 'data']" icon="ti ti-package">
<div class="_gaps_m">
<SearchMarker :keywords="['notes']">
<FormSection first>
<template #label><i class="ti ti-pencil"></i> <SearchLabel>{{ i18n.ts._exportOrImport.allNotes }}</SearchLabel></template>
<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>
</FormSection>
</SearchMarker>
<SearchMarker :keywords="['favorite', 'notes']">
<FormSection>
<template #label><i class="ti ti-star"></i> <SearchLabel>{{ i18n.ts._exportOrImport.favoritedNotes }}</SearchLabel></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>
</SearchMarker>
<SearchMarker :keywords="['clip', 'notes']">
<FormSection>
<template #label><i class="ti ti-star"></i> <SearchLabel>{{ i18n.ts._exportOrImport.clips }}</SearchLabel></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>
</SearchMarker>
<SearchMarker :keywords="['following', 'users']">
<FormSection>
<template #label><i class="ti ti-users"></i> <SearchLabel>{{ i18n.ts._exportOrImport.followingList }}</SearchLabel></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>
</SearchMarker>
<SearchMarker :keywords="['user', 'lists']">
<FormSection>
<template #label><i class="ti ti-users"></i> <SearchLabel>{{ i18n.ts._exportOrImport.userLists }}</SearchLabel></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>
</SearchMarker>
<SearchMarker :keywords="['mute', 'users']">
<FormSection>
<template #label><i class="ti ti-user-off"></i> <SearchLabel>{{ i18n.ts._exportOrImport.muteList }}</SearchLabel></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>
</SearchMarker>
<SearchMarker :keywords="['block', 'users']">
<FormSection>
<template #label><i class="ti ti-user-off"></i> <SearchLabel>{{ i18n.ts._exportOrImport.blockingList }}</SearchLabel></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>
</SearchMarker>
<SearchMarker :keywords="['antennas']">
<FormSection>
<template #label><i class="ti ti-antenna"></i> <SearchLabel>{{ i18n.ts.antennas }}</SearchLabel></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>
</SearchMarker>
</div>
</SearchMarker>
</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 * 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 '@/account.js';
import { store } from '@/store.js';
const excludeMutingUsers = ref(false);
const excludeInactiveUsers = ref(false);
const withReplies = ref(store.s.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 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.importAndExport,
icon: 'ti ti-package',
}));
</script>
<style module>
.button {
margin-right: 16px;
}
</style>

View File

@@ -91,11 +91,6 @@ const menuDef = computed<SuperMenuDef[]>(() => [{
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,
@@ -146,6 +141,11 @@ const menuDef = computed<SuperMenuDef[]>(() => [{
}],
}, {
items: [{
icon: 'ti ti-cloud',
text: i18n.ts.drive,
to: '/settings/drive',
active: currentPage.value?.route.name === 'drive',
}, {
icon: 'ti ti-badges',
text: i18n.ts.roles,
to: '/settings/roles',
@@ -156,20 +156,15 @@ const menuDef = computed<SuperMenuDef[]>(() => [{
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',
text: i18n.ts._settings.accountData,
to: '/settings/account-data',
active: currentPage.value?.route.name === 'account-data',
}, {
icon: 'ti ti-dots',
text: i18n.ts.other,

View File

@@ -6,167 +6,173 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<SearchMarker path="/settings/mute-block" :label="i18n.ts.muteAndBlock" icon="ti ti-ban" :keywords="['mute', 'block']">
<div class="_gaps_m">
<SearchMarker
:label="i18n.ts.wordMute"
:keywords="['note', 'word', 'soft', 'mute', 'hide']"
>
<MkFolder>
<template #icon><i class="ti ti-message-off"></i></template>
<template #label>{{ i18n.ts.wordMute }}</template>
<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>
<div class="_gaps_s">
<SearchMarker
:label="i18n.ts.wordMute"
:keywords="['note', 'word', 'soft', 'mute', 'hide']"
>
<MkFolder>
<template #icon><i class="ti ti-message-off"></i></template>
<template #label>{{ i18n.ts.wordMute }}</template>
<SearchMarker
:label="i18n.ts.showMutedWord"
:keywords="['show']"
>
<MkSwitch v-model="showSoftWordMutedWord">{{ i18n.ts.showMutedWord }}</MkSwitch>
</SearchMarker>
<div class="_gaps_m">
<MkInfo>{{ i18n.ts.wordMuteDescription }}</MkInfo>
<XWordMute :muted="$i.mutedWords" @save="saveMutedWords"/>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker
:label="i18n.ts.showMutedWord"
:keywords="['show']"
>
<MkSwitch v-model="showSoftWordMutedWord">{{ i18n.ts.showMutedWord }}</MkSwitch>
</SearchMarker>
<SearchMarker
:label="i18n.ts.hardWordMute"
:keywords="['note', 'word', 'hard', 'mute', 'hide']"
>
<MkFolder>
<template #icon><i class="ti ti-message-off"></i></template>
<template #label>{{ i18n.ts.hardWordMute }}</template>
<XWordMute :muted="$i.mutedWords" @save="saveMutedWords"/>
</div>
</MkFolder>
</SearchMarker>
<div class="_gaps_m">
<MkInfo>{{ i18n.ts.hardWordMuteDescription }}</MkInfo>
<XWordMute :muted="$i.hardMutedWords" @save="saveHardMutedWords"/>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker
:label="i18n.ts.hardWordMute"
:keywords="['note', 'word', 'hard', 'mute', 'hide']"
>
<MkFolder>
<template #icon><i class="ti ti-message-off"></i></template>
<template #label>{{ i18n.ts.hardWordMute }}</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>
<div class="_gaps_m">
<MkInfo>{{ i18n.ts.hardWordMuteDescription }}</MkInfo>
<XWordMute :muted="$i.hardMutedWords" @save="saveHardMutedWords"/>
</div>
</MkFolder>
</SearchMarker>
<XInstanceMute/>
</MkFolder>
</SearchMarker>
<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>
<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>
<XInstanceMute/>
</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.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>
<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>
<MkPagination :pagination="renoteMutingPagination">
<template #empty>
<div class="_fullinfo">
<img :src="infoImageUrl" class="_ghost"/>
<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]: 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>
</div>
</template>
</MkPagination>
</MkFolder>
</SearchMarker>
</template>
</MkPagination>
</MkFolder>
</SearchMarker>
<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>
<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="mutingPagination">
<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" class="_ghost"/>
<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>
<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>
</div>
</template>
</MkPagination>
</MkFolder>
</SearchMarker>
</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>
<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" class="_ghost"/>
<div>{{ i18n.ts.noUsers }}</div>
</div>
</template>
<MkPagination :pagination="blockingPagination">
<template #empty>
<div class="_fullinfo">
<img :src="infoImageUrl" class="_ghost"/>
<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>
<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>
</div>
</template>
</MkPagination>
</MkFolder>
</SearchMarker>
</template>
</MkPagination>
</MkFolder>
</SearchMarker>
</div>
</div>
</SearchMarker>
</template>
@@ -188,6 +194,7 @@ import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import { reloadAsk } from '@/utility/reload-ask.js';
import { prefer } from '@/preferences.js';
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
const $i = signinRequired();

View File

@@ -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">
@@ -63,6 +67,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { shallowRef, 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';
@@ -75,7 +80,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.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();

View File

@@ -4,8 +4,12 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<SearchMarker path="/settings/plugin" :label="i18n.ts.plugins" :keywords="['plugin']" icon="ti ti-plug">
<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>
<FormLink to="/settings/plugin/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._plugin.install }}</FormLink>
<FormSection>
@@ -98,6 +102,7 @@ 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 MkFeatureBanner from '@/components/MkFeatureBanner.vue';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { changePluginActive, configPlugin, pluginLogs, uninstallPlugin, reloadPlugin } from '@/plugin.js';

View File

@@ -6,6 +6,10 @@ 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>
<SearchMarker :keywords="['language']">
<MkSelect v-model="lang">
<template #label><SearchLabel>{{ i18n.ts.uiLanguage }}</SearchLabel></template>
@@ -381,6 +385,7 @@ 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';
const lang = ref(miLocalStorage.getItem('lang'));
const dataSaver = ref(prefer.s.dataSaver);

View File

@@ -6,6 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<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>
<SearchMarker :keywords="['follow', 'lock']">
<MkSwitch v-model="isLocked" @update:modelValue="save()">
<template #label><SearchLabel>{{ i18n.ts.makeFollowManuallyApprove }}</SearchLabel></template>
@@ -189,6 +193,7 @@ 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';
const $i = signinRequired();

View File

@@ -6,6 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<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>
<SearchMarker :keywords="['password']">
<FormSection first>
<template #label><SearchLabel>{{ i18n.ts.password }}</SearchLabel></template>
@@ -59,6 +63,7 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
const pagination = {
endpoint: 'i/signin-history' as const,

View File

@@ -6,6 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<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>
<SearchMarker :keywords="['mute']">
<MkPreferenceContainer k="sound.notUseSound">
<MkSwitch v-model="notUseSound">
@@ -70,6 +74,7 @@ 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 = prefer.model('sound.notUseSound');
const useSoundOnlyWhenActive = prefer.model('sound.useSoundOnlyWhenActive');

View File

@@ -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 { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
const pagination = {
endpoint: 'i/webhooks/list' as const,
limit: 100,
noPaging: true,
};
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePage(() => ({
title: 'Webhook',
icon: 'ti ti-webhook',
}));
</script>

View File

@@ -4,16 +4,17 @@
*/
import { v4 as uuid } from 'uuid';
import type { PreferencesProfile } from '@/preferences/profile.js';
import type { PreferencesProfile, StorageProvider } from '@/preferences/profile.js';
import { cloudBackup } from '@/preferences/utility.js';
import { miLocalStorage } from '@/local-storage.js';
import { ProfileManager } from '@/preferences/profile.js';
import { store } from '@/store.js';
import { $i } from '@/account.js';
import { misskeyApi } from '@/utility/misskey-api.js';
const TAB_ID = uuid();
function createProfileManager() {
function createProfileManager(storageProvider: StorageProvider) {
let profile: PreferencesProfile;
const savedProfileRaw = miLocalStorage.getItem('preferences');
@@ -24,15 +25,44 @@ function createProfileManager() {
profile = ProfileManager.normalizeProfile(JSON.parse(savedProfileRaw));
}
return new ProfileManager(profile);
return new ProfileManager(profile, storageProvider);
}
export const profileManager = createProfileManager();
profileManager.addListener('updated', ({ profile: p }) => {
miLocalStorage.setItem('preferences', JSON.stringify(p));
miLocalStorage.setItem('latestPreferencesUpdate', `${TAB_ID}/${Date.now()}`);
});
export const prefer = profileManager.store;
const storageProvider: StorageProvider = {
save: (ctx) => {
miLocalStorage.setItem('preferences', JSON.stringify(ctx.profile));
miLocalStorage.setItem('latestPreferencesUpdate', `${TAB_ID}/${Date.now()}`);
},
cloudGet: async (ctx) => {
// TODO: この取得方法だとアカウントが変わると保存場所も変わってしまうので改修する
// 例えば複数アカウントある場合でも設定値を保存するための「プライマリアカウント」を設定できるようにするとか
// TODO: keyのcondに応じた取得
try {
const value = await misskeyApi('i/registry/get', {
scope: ['client', 'preferences', 'sync'],
key: ctx.key,
});
return {
value,
};
} catch (err: any) {
if (err.code === 'NO_SUCH_KEY') {
return null;
} else {
throw err;
}
}
},
cloudSet: async (ctx) => {
await misskeyApi('i/registry/set', {
scope: ['client', 'preferences', 'sync'],
key: ctx.key,
value: ctx.value,
});
},
};
export const prefer = createProfileManager(storageProvider);
let latestSyncedAt = Date.now();
@@ -46,7 +76,7 @@ function syncBetweenTabs() {
if (latestTab === TAB_ID) return;
if (latestAt <= latestSyncedAt) return;
profileManager.rewriteProfile(ProfileManager.normalizeProfile(JSON.parse(miLocalStorage.getItem('preferences')!)));
prefer.rewriteProfile(ProfileManager.normalizeProfile(JSON.parse(miLocalStorage.getItem('preferences')!)));
latestSyncedAt = Date.now();
@@ -67,7 +97,7 @@ window.setInterval(() => {
if ($i == null) return;
if (!store.s.enablePreferencesAutoCloudBackup) return;
if (document.visibilityState !== 'visible') return; // 同期されていない古い値がバックアップされるのを防ぐ
if (profileManager.profile.modifiedAt <= latestBackupAt) return;
if (prefer.profile.modifiedAt <= latestBackupAt) return;
cloudBackup().then(() => {
latestBackupAt = Date.now();
@@ -75,7 +105,6 @@ window.setInterval(() => {
}, 1000 * 60 * 3);
if (_DEV_) {
(window as any).profileManager = profileManager;
(window as any).prefer = prefer;
(window as any).cloudBackup = cloudBackup;
}

View File

@@ -9,6 +9,7 @@ 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 { Column, DeckProfile } from '@/deck.js';
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
/** サウンド設定 */
@@ -45,6 +46,14 @@ export const PREF_DEF = {
data: Record<string, any>;
}[],
},
'deck.profile': {
accountDependent: true,
default: null as string | null,
},
'deck.profiles': {
accountDependent: true,
default: [] as DeckProfile[],
},
overridedDeviceKind: {
default: null as DeviceKind | null,
@@ -318,4 +327,5 @@ export const PREF_DEF = {
} satisfies Record<string, {
default: any;
accountDependent?: boolean;
serverDependent?: boolean;
}>;

View File

@@ -3,16 +3,16 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ref, watch } from 'vue';
import { computed, onUnmounted, ref, watch } from 'vue';
import { v4 as uuid } from 'uuid';
import { host, version } from '@@/js/config.js';
import { EventEmitter } from 'eventemitter3';
import { PREF_DEF } from './def.js';
import { Store } from './store.js';
import type { Ref, WritableComputedRef } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import { $i } from '@/account.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
// NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない
@@ -24,11 +24,41 @@ type PREF = typeof PREF_DEF;
type ValueOf<K extends keyof PREF> = PREF[K]['default'];
type Account = string; // <host>/<userId>
type Cond = {
type Cond = Partial<{
server: string | null; // 将来のため
account: Account | null;
device: string | null; // 将来のため
};
}>;
type ValueMeta = Partial<{
sync: boolean;
}>;
type PrefRecord<K extends keyof PREF> = [cond: Cond, value: ValueOf<K>, meta: ValueMeta];
function parseCond(cond: Cond): {
server: string | null;
account: Account | null;
device: string | null;
} {
return {
server: cond.server ?? null,
account: cond.account ?? null,
device: cond.device ?? null,
};
}
function makeCond(cond: Partial<{
server: string | null;
account: Account | null;
device: string | null;
}>): Cond {
const c = {} as Cond;
if (cond.server != null) c.server = cond.server;
if (cond.account != null) c.account = cond.account;
if (cond.device != null) c.device = cond.device;
return c;
}
export type PreferencesProfile = {
id: string;
@@ -37,51 +67,119 @@ export type PreferencesProfile = {
modifiedAt: number;
name: string;
preferences: {
[K in keyof PREF]: [Cond, ValueOf<K>][];
[K in keyof PREF]: PrefRecord<K>[];
};
syncByAccount: [Account, keyof PREF][],
};
export class ProfileManager extends EventEmitter<{
updated: (ctx: {
profile: PreferencesProfile
}) => void;
}> {
public profile: PreferencesProfile;
public store: Store<{
[K in keyof PREF]: ValueOf<K>;
}>;
export type StorageProvider = {
save: (ctx: { profile: PreferencesProfile; }) => void;
cloudGet: <K extends keyof PREF>(ctx: { key: K; }) => Promise<{ value: ValueOf<K>; } | null>;
cloudSet: <K extends keyof PREF>(ctx: { key: K; value: ValueOf<K>; }) => Promise<void>;
};
constructor(profile: PreferencesProfile) {
super();
export class ProfileManager {
private storageProvider: StorageProvider;
public profile: PreferencesProfile;
/**
* 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();
this.store = new Store(states);
this.store.addListener('updated', ({ key, value }) => {
console.log('prefer:set', key, value);
for (const key in states) {
this.s[key] = states[key];
this.r[key] = ref(this.s[key]);
}
const record = this.getMatchedRecord(key);
if (record[0].account == null && PREF_DEF[key].accountDependent) {
this.profile.preferences[key].push([{
server: null,
account: `${host}/${$i!.id}`,
device: null,
}, value]);
this.save();
return;
}
this.fetchCloudValues();
record[1] = value;
// TODO: 定期的にクラウドの値をフェッチ
}
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>) {
console.log('prefer:commit', key, value);
this.rewriteRawState(key, value);
const record = this.getMatchedRecord(key);
if (parseCond(record[0]).account == null && PREF_DEF[key].accountDependent) {
this.profile.preferences[key].push([makeCond({
account: `${host}/${$i!.id}`,
}), value, {}]);
this.save();
return;
}
if (record[2].sync) {
// awaitの必要なし
// TODO: リクエストを間引く
this.storageProvider.cloudSet({ key, value });
}
record[1] = value;
this.save();
}
/**
* 特定のキーの、簡易的な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> };
let key: keyof PREF;
for (key in PREF_DEF) {
for (const key in PREF_DEF) {
const record = this.getMatchedRecord(key);
states[key] = record[1];
}
@@ -89,15 +187,37 @@ export class ProfileManager extends EventEmitter<{
return states;
}
private fetchCloudValues() {
// TODO: 値の取得を1つのリクエストで済ませたい(バックエンド側でAPIの新設が必要)
const promises: Promise<void>[] = [];
for (const key in PREF_DEF) {
const record = this.getMatchedRecord(key);
if (record[2].sync) {
const getting = this.storageProvider.cloudGet({ key });
promises.push(getting.then((res) => {
if (res == null) return;
const value = res.value;
if (value !== this.s[key]) {
this.rewriteRawState(key, value);
record[1] = value;
console.log('cloud fetched', key, value);
}
}));
}
}
Promise.all(promises).then(() => {
console.log('cloud fetched all');
this.save();
console.log(this.s.showFixedPostForm, this.r.showFixedPostForm.value);
});
}
public static newProfile(): PreferencesProfile {
const data = {} as PreferencesProfile['preferences'];
let key: keyof PREF;
for (key in PREF_DEF) {
data[key] = [[{
server: null,
account: null,
device: null,
}, PREF_DEF[key].default]];
for (const key in PREF_DEF) {
data[key] = [[makeCond({}), PREF_DEF[key].default, {}]];
}
return {
id: uuid(),
@@ -106,29 +226,31 @@ export class ProfileManager extends EventEmitter<{
modifiedAt: Date.now(),
name: '',
preferences: data,
syncByAccount: [],
};
}
public static normalizeProfile(profile: any): PreferencesProfile {
public static normalizeProfile(profileLike: any): PreferencesProfile {
const data = {} as PreferencesProfile['preferences'];
let key: keyof PREF;
for (key in PREF_DEF) {
const records = profile.preferences[key];
for (const key in PREF_DEF) {
const records = profileLike.preferences[key];
if (records == null || records.length === 0) {
data[key] = [[{
server: null,
account: null,
device: null,
}, PREF_DEF[key].default]];
data[key] = [[makeCond({}), 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 {
...profile,
...profileLike,
preferences: data,
};
}
@@ -136,24 +258,24 @@ export class ProfileManager extends EventEmitter<{
public save() {
this.profile.modifiedAt = Date.now();
this.profile.version = version;
this.emit('updated', { profile: this.profile });
this.storageProvider.save({ profile: this.profile });
}
public getMatchedRecord<K extends keyof PREF>(key: K): [Cond, ValueOf<K>] {
public getMatchedRecord<K extends keyof PREF>(key: K): PrefRecord<K> {
const records = this.profile.preferences[key];
if ($i == null) return records.find(([cond, v]) => cond.account == null)!;
if ($i == null) return records.find(([cond, v]) => parseCond(cond).account == null)!;
const accountOverrideRecord = records.find(([cond, v]) => cond.account === `${host}/${$i!.id}`);
const accountOverrideRecord = records.find(([cond, v]) => parseCond(cond).account === `${host}/${$i!.id}`);
if (accountOverrideRecord) return accountOverrideRecord;
const record = records.find(([cond, v]) => cond.account == null);
const record = records.find(([cond, v]) => parseCond(cond).account == null);
return record!;
}
public isAccountOverrided<K extends keyof PREF>(key: K): boolean {
if ($i == null) return false;
return this.profile.preferences[key].some(([cond, v]) => cond.account === `${host}/${$i!.id}`) ?? false;
return this.profile.preferences[key].some(([cond, v]) => parseCond(cond).account === `${host}/${$i!.id}`) ?? false;
}
public setAccountOverride<K extends keyof PREF>(key: K) {
@@ -162,11 +284,9 @@ export class ProfileManager extends EventEmitter<{
if (this.isAccountOverrided(key)) return;
const records = this.profile.preferences[key];
records.push([{
server: null,
records.push([makeCond({
account: `${host}/${$i!.id}`,
device: null,
}, this.store.s[key]]);
}), this.s[key], {}]);
this.save();
}
@@ -177,16 +297,67 @@ export class ProfileManager extends EventEmitter<{
const records = this.profile.preferences[key];
const index = records.findIndex(([cond, v]) => cond.account === `${host}/${$i!.id}`);
const index = records.findIndex(([cond, v]) => parseCond(cond).account === `${host}/${$i!.id}`);
if (index === -1) return;
records.splice(index, 1);
this.store.rewrite(key, this.getMatchedRecord(key)[1]);
this.rewriteRawState(key, this.getMatchedRecord(key)[1]);
this.save();
}
public isSyncEnabled<K extends keyof PREF>(key: K): boolean {
return this.getMatchedRecord(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 existing = await this.storageProvider.cloudGet({ key });
if (existing != null) {
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
}
}
const record = this.getMatchedRecord(key);
record[2].sync = true;
this.save();
// awaitの必要性は無い
this.storageProvider.cloudSet({ key, value: this.s[key] });
return { enabled: true };
}
public disableSync<K extends keyof PREF>(key: K) {
if (!this.isSyncEnabled(key)) return;
const record = this.getMatchedRecord(key);
delete record[2].sync;
this.save();
}
public renameProfile(name: string) {
this.profile.name = name;
this.save();
@@ -196,13 +367,14 @@ export class ProfileManager extends EventEmitter<{
this.profile = profile;
const states = this.genStates();
for (const key in states) {
this.store.rewrite(key, states[key]);
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);
@@ -211,6 +383,18 @@ export class ProfileManager extends EventEmitter<{
}
});
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,
@@ -222,7 +406,7 @@ export class ProfileManager extends EventEmitter<{
text: i18n.ts.resetToDefaultValue,
danger: true,
action: () => {
this.store.commit(key, PREF_DEF[key].default);
this.commit(key, PREF_DEF[key].default);
},
}, {
type: 'divider',
@@ -231,6 +415,11 @@ export class ProfileManager extends EventEmitter<{
icon: 'ti ti-user-cog',
text: i18n.ts.overrideByAccount,
ref: overrideByAccount,
}, {
type: 'switch',
icon: 'ti ti-cloud-cog',
text: i18n.ts.syncBetweenDevices,
ref: sync,
}];
}
}

View File

@@ -1,92 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { computed, onUnmounted, ref, watch } from 'vue';
import { EventEmitter } from 'eventemitter3';
import type { Ref, WritableComputedRef } from 'vue';
// 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 StoreEvent<Data extends Record<string, any>> = {
updated: <K extends keyof Data>(ctx: {
key: K;
value: Data[K];
}) => void;
};
export class Store<Data extends Record<string, any>> extends EventEmitter<StoreEvent<Data>> {
/**
* static / state の略 (static が予約語のため)
*/
public s = {} as {
[K in keyof Data]: Data[K];
};
/**
* reactive の略
*/
public r = {} as {
[K in keyof Data]: Ref<Data[K]>;
};
constructor(data: { [K in keyof Data]: Data[K] }) {
super();
for (const key in data) {
this.s[key] = data[key];
this.r[key] = ref(this.s[key]);
}
}
public commit<K extends keyof Data>(key: K, value: Data[K]) {
this.r[key].value = this.s[key] = value;
this.emit('updated', { key, value });
}
public rewrite<K extends keyof Data>(key: K, value: Data[K]) {
this.r[key].value = this.s[key] = value;
}
/**
* 特定のキーの、簡易的なcomputed refを作ります
* 主にvue上で設定コントロールのmodelとして使う用
*/
public model<K extends keyof Data, V extends Data[K] = Data[K]>(
key: K,
getter?: (v: Data[K]) => V,
setter?: (v: V) => Data[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;
},
});
}
}

View File

@@ -9,7 +9,7 @@ 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, profileManager } from '@/preferences.js';
import { prefer } from '@/preferences.js';
import * as os from '@/os.js';
import { store } from '@/store.js';
import { $i } from '@/account.js';
@@ -17,7 +17,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { unisonReload } from '@/utility/unison-reload.js';
function canAutoBackup() {
return profileManager.profile.name != null && profileManager.profile.name.trim() !== '';
return prefer.profile.name != null && prefer.profile.name.trim() !== '';
}
export function getPreferencesProfileMenu(): MenuItem[] {
@@ -42,7 +42,7 @@ export function getPreferencesProfileMenu(): MenuItem[] {
const menu: MenuItem[] = [{
type: 'label',
text: profileManager.profile.name || `(${i18n.ts.noName})`,
text: prefer.profile.name || `(${i18n.ts.noName})`,
}, {
text: i18n.ts.rename,
icon: 'ti ti-pencil',
@@ -83,7 +83,7 @@ export function getPreferencesProfileMenu(): MenuItem[] {
text: 'Copy profile as text',
icon: 'ti ti-clipboard',
action: () => {
copyToClipboard(JSON.stringify(profileManager.profile, null, '\t'));
copyToClipboard(JSON.stringify(prefer.profile, null, '\t'));
},
});
}
@@ -95,16 +95,16 @@ 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: profileManager.profile.name || null,
default: profileManager.profile.name || null,
placeholder: prefer.profile.name || null,
default: prefer.profile.name || null,
});
if (canceled || name == null || name.trim() === '') return;
profileManager.renameProfile(name);
prefer.renameProfile(name);
}
function exportCurrentProfile() {
const p = profileManager.profile;
const p = prefer.profile;
const txtBlob = new Blob([JSON.stringify(p)], { type: 'text/plain' });
const dummya = document.createElement('a');
dummya.href = URL.createObjectURL(txtBlob);
@@ -140,8 +140,8 @@ export async function cloudBackup() {
await misskeyApi('i/registry/set', {
scope: ['client', 'preferences', 'backups'],
key: profileManager.profile.name,
value: profileManager.profile,
key: prefer.profile.name,
value: prefer.profile,
});
}

View File

@@ -134,33 +134,29 @@ 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',

View File

@@ -10,7 +10,6 @@ import darkTheme from '@@/themes/d-green-lime.json5';
import { hemisphere } from '@@/js/intl-const.js';
import type { DeviceKind } from '@/utility/device-kind.js';
import type { Plugin } from '@/plugin.js';
import type { Column } from '@/deck.js';
import { miLocalStorage } from '@/local-storage.js';
import { Storage } from '@/pizzax.js';
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
@@ -117,18 +116,6 @@ export const store = markRaw(new Storage('base', {
where: 'deviceAccount',
default: {} as Record<string, string>, // plugin id, token
},
'deck.profile': {
where: 'deviceAccount',
default: 'default',
},
'deck.columns': {
where: 'deviceAccount',
default: [] as Column[],
},
'deck.layout': {
where: 'deviceAccount',
default: [] as Column['id'][][],
},
enablePreferencesAutoCloudBackup: {
where: 'device',

View File

@@ -36,7 +36,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}: ${store.s['deck.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">
@@ -95,7 +95,6 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, defineAsyncComponent, ref, watch, shallowRef } from 'vue';
import { v4 as uuid } from 'uuid';
import XCommon from './_common_/common.vue';
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';
@@ -103,7 +102,6 @@ import * as os from '@/os.js';
import { navbarItemDef } from '@/navbar.js';
import { $i } from '@/account.js';
import { i18n } from '@/i18n.js';
import { unisonReload } from '@/utility/unison-reload.js';
import { deviceKind } from '@/utility/device-kind.js';
import { prefer } from '@/preferences.js';
import XMainColumn from '@/ui/deck/main-column.vue';
@@ -117,8 +115,7 @@ 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 { mainRouter } from '@/router/main.js';
import { store } from '@/store.js';
import { columnTypes, forceSaveDeck, getProfiles, loadDeck, addColumn as addColumnToStore, deleteProfile as deleteProfile_ } from '@/deck.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'));
@@ -137,7 +134,7 @@ const columnComponents = {
mainRouter.navHook = (path, flag): boolean => {
if (flag === 'forcePage') return false;
const noMainColumn = !store.s['deck.columns'].some(x => x.type === 'main');
const noMainColumn = !columns.value.some(x => x.type === 'main');
if (prefer.s['deck.navWindow'] || noMainColumn) {
os.pageWindow(path);
return true;
@@ -160,8 +157,6 @@ watch(route, () => {
});
*/
const columns = store.r['deck.columns'];
const layout = store.r['deck.layout'];
const menuIndicated = computed(() => {
if ($i == null) return false;
for (const def in navbarItemDef) {
@@ -210,65 +205,20 @@ function onWheel(ev: WheelEvent) {
document.documentElement.style.overflowY = 'hidden';
document.documentElement.style.scrollBehavior = 'auto';
loadDeck();
function changeProfile(ev: MouseEvent) {
let items: MenuItem[] = [{
text: store.s['deck.profile'],
active: true,
action: () => {},
}];
getProfiles().then(profiles => {
items.push(...(profiles.filter(k => k !== store.s['deck.profile']).map(k => ({
text: k,
action: () => {
store.set('deck.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 store.set('deck.profile', name);
await forceSaveDeck();
})(), () => {
unisonReload();
});
},
});
}).then(() => {
os.popupMenu(items, ev.currentTarget ?? ev.target);
});
}
async function deleteProfile() {
if (prefer.s['deck.profile'] == null) return;
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.tsx.deleteAreYouSure({ x: store.s['deck.profile'] }),
text: i18n.tsx.deleteAreYouSure({ x: prefer.s['deck.profile'] }),
});
if (canceled) return;
os.promiseDialog((async () => {
if (store.s['deck.profile'] === 'default') {
await store.set('deck.columns', []);
await store.set('deck.layout', []);
await forceSaveDeck();
} else {
await deleteProfile_(store.s['deck.profile']);
}
await store.set('deck.profile', 'default');
})(), () => {
unisonReload();
});
await deleteProfile_(prefer.s['deck.profile']);
os.success();
}
</script>
<style>

View File

@@ -100,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,
});
}

View File

@@ -52,23 +52,23 @@ export const searchIndexes: SearchIndexItem[] = [
id: '6fFIRXUww',
children: [
{
id: 'nO7NnzqiC',
id: 'EcwZE7dCl',
label: i18n.ts.notUseSound,
keywords: ['mute'],
},
{
id: 'oALW4ja7U',
id: '9MxYVIf7k',
label: i18n.ts.useSoundOnlyWhenActive,
keywords: ['active', 'mute'],
},
{
id: 'BbJK2SKT2',
id: '94afQxKat',
label: i18n.ts.masterVolume,
keywords: ['volume', 'master'],
},
],
label: i18n.ts.sounds,
keywords: ['sounds'],
keywords: ['sounds', i18n.ts._settings.soundsBanner],
path: '/settings/sounds',
icon: 'ti ti-music',
},
@@ -76,10 +76,10 @@ export const searchIndexes: SearchIndexItem[] = [
id: '5BjnxMfYV',
children: [
{
id: '3UqdSCaFw',
id: '75QPEg57v',
children: [
{
id: '75QPEg57v',
id: 'CiHijRkGG',
label: i18n.ts.changePassword,
keywords: [],
},
@@ -111,7 +111,7 @@ export const searchIndexes: SearchIndexItem[] = [
},
],
label: i18n.ts.security,
keywords: ['security'],
keywords: ['security', i18n.ts._settings.securityBanner],
path: '/settings/security',
icon: 'ti ti-lock',
},
@@ -195,65 +195,65 @@ export const searchIndexes: SearchIndexItem[] = [
id: '2rp9ka5Ht',
children: [
{
id: 'qBUSKPxLW',
id: 'BhAQiHogN',
label: i18n.ts.makeFollowManuallyApprove,
keywords: ['follow', 'lock', i18n.ts.lockedAccountInfo],
},
{
id: '3LZBlZCej',
id: '4DeWGsPaD',
label: i18n.ts.autoAcceptFollowed,
keywords: ['follow', 'auto', 'accept'],
},
{
id: '9gOp28wKG',
id: 'iaM6zUmO9',
label: i18n.ts.makeReactionsPublic,
keywords: ['reaction', 'public', i18n.ts.makeReactionsPublicDescription],
},
{
id: 'CjAkqMhct',
id: '5Q6uhghzV',
label: i18n.ts.followingVisibility,
keywords: ['following', 'visibility'],
},
{
id: '4nEwI6LYt',
id: 'pZ9q65FX5',
label: i18n.ts.followersVisibility,
keywords: ['follower', 'visibility'],
},
{
id: 'naMp37wTL',
id: 'DMS4yvAGg',
label: i18n.ts.hideOnlineStatus,
keywords: ['online', 'status', i18n.ts.hideOnlineStatusDescription],
},
{
id: 'p0dCVR0UP',
id: '8rEsGuN8w',
label: i18n.ts.noCrawle,
keywords: ['crawle', 'index', 'search', i18n.ts.noCrawleDescription],
},
{
id: 'aceURmNPq',
id: 's7LdSpiLn',
label: i18n.ts.preventAiLearning,
keywords: ['crawle', 'ai', i18n.ts.preventAiLearningDescription],
},
{
id: 'ahABA0j7u',
id: 'l2Wf1s2ad',
label: i18n.ts.makeExplorable,
keywords: ['explore', i18n.ts.makeExplorableDescription],
},
{
id: 'cyeDbLN8N',
id: '7vr04wKol',
children: [
{
id: 'xEYlOghao',
id: 'Av7fAaHv8',
label: i18n.ts._accountSettings.requireSigninToViewContents,
keywords: ['login', 'signin'],
},
{
id: 'sMmYFCS60',
id: 'lUtOQbnwi',
label: i18n.ts._accountSettings.makeNotesFollowersOnlyBefore,
keywords: ['follower', i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription],
},
{
id: 'ebJ9IUbik',
id: '83WWcjwS9',
label: i18n.ts._accountSettings.makeNotesHiddenBefore,
keywords: ['hidden', i18n.ts._accountSettings.makeNotesHiddenBeforeDescription],
},
@@ -263,7 +263,7 @@ export const searchIndexes: SearchIndexItem[] = [
},
],
label: i18n.ts.privacy,
keywords: ['privacy'],
keywords: ['privacy', i18n.ts._settings.privacyBanner],
path: '/settings/privacy',
icon: 'ti ti-lock-open',
},
@@ -271,75 +271,75 @@ export const searchIndexes: SearchIndexItem[] = [
id: '3yCAv0IsZ',
children: [
{
id: 'x1GWSQnPw',
id: 'kMJ5laK3n',
label: i18n.ts.uiLanguage,
keywords: ['language'],
},
{
id: 'EOSa4rtt3',
id: 'dlKebHH6k',
label: i18n.ts.overridedDeviceKind,
keywords: ['device', 'type', 'kind', 'smartphone', 'tablet', 'desktop'],
},
{
id: 'm9LhX8BG8',
id: 'nxvMUir3T',
label: i18n.ts.showFixedPostForm,
keywords: ['post', 'form', 'timeline'],
},
{
id: 'snyCQ5oKE',
id: '84MdeDWL1',
label: i18n.ts.showFixedPostFormInChannel,
keywords: ['post', 'form', 'timeline', 'channel'],
},
{
id: '8j36S4Ev6',
id: 'dOig3ye4Z',
label: i18n.ts.pinnedList,
keywords: ['pinned', 'list'],
},
{
id: 'CWpyT9vLK',
id: '4huRldNp5',
label: i18n.ts.enableQuickAddMfmFunction,
keywords: ['mfm', 'enable', 'show', 'advanced', 'picker', 'form', 'function', 'fn'],
},
{
id: '1yhown1Xc',
id: '1x3JNXj8N',
label: i18n.ts.rememberNoteVisibility,
keywords: ['remember', 'keep', 'note', 'visibility'],
},
{
id: 'wUeAI5QBV',
id: 'CfAg0Qekq',
label: i18n.ts.defaultNoteVisibility,
keywords: ['default', 'note', 'visibility'],
},
{
id: '6kMj4HVOg',
id: 'tMm9kH9gy',
children: [
{
id: 'DQIcvf64G',
id: 'hDdVkBFJP',
label: i18n.ts.collapseRenotes,
keywords: ['renote', i18n.ts.collapseRenotesDescription],
},
{
id: 'igFN7RIUa',
id: 'uJJyDABGu',
label: i18n.ts.showNoteActionsOnlyHover,
keywords: ['hover', 'show', 'footer', 'action'],
},
{
id: '9uxocbLO0',
id: 'ufc2X9voy',
label: i18n.ts.showClipButtonInNoteFooter,
keywords: ['footer', 'action', 'clip', 'show'],
},
{
id: 'eaT1O1Fao',
id: '7Jwvu8bK6',
label: i18n.ts.enableAdvancedMfm,
keywords: ['mfm', 'enable', 'show', 'advanced'],
},
{
id: 'omxZk3eET',
id: 'yb11lSY1G',
label: i18n.ts.showReactionsCount,
keywords: ['reaction', 'count', 'show'],
},
{
id: 'epvi2Nv2G',
id: 'fL49Zxe9i',
label: i18n.ts.loadRawImages,
keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'quality', 'raw', 'attachment'],
},
@@ -348,10 +348,10 @@ export const searchIndexes: SearchIndexItem[] = [
keywords: ['note'],
},
{
id: 'jb3HUeyrx',
id: 'bUOs2UKY4',
children: [
{
id: 'ykifk3NHS',
id: 'c8gA9Xj2a',
label: i18n.ts.useGroupedNotifications,
keywords: ['group'],
},
@@ -360,60 +360,60 @@ export const searchIndexes: SearchIndexItem[] = [
keywords: ['notification'],
},
{
id: 'abEAdSpYY',
id: 'tjGzqy3qa',
children: [
{
id: 'lBbtAg0Hm',
id: '3OeHscv45',
label: i18n.ts.openImageInNewTab,
keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'new', 'tab'],
},
{
id: 'E9whefUtX',
id: 'bFsNusspF',
label: i18n.ts.useReactionPickerForContextMenu,
keywords: ['reaction', 'picker', 'contextmenu', 'open'],
},
{
id: 'iQaBbJBva',
id: '2h3rY1izt',
label: i18n.ts.enableInfiniteScroll,
keywords: ['load', 'auto', 'more'],
},
{
id: 'hgEVGgJa1',
id: 'pkK3eeFKm',
label: i18n.ts.disableStreamingTimeline,
keywords: ['disable', 'streaming', 'timeline'],
},
{
id: 'yxehrHZ6x',
id: 'y2v7CV9zs',
label: i18n.ts.alwaysConfirmFollow,
keywords: ['follow', 'confirm', 'always'],
},
{
id: 'DdoFLaSG8',
id: 'A8a5hcLce',
label: i18n.ts.confirmWhenRevealingSensitiveMedia,
keywords: ['sensitive', 'nsfw', 'media', 'image', 'photo', 'picture', 'attachment', 'confirm'],
},
{
id: 'uIMCIK7kG',
id: 'utFrfuW7X',
label: i18n.ts.confirmOnReact,
keywords: ['reaction', 'confirm'],
},
{
id: 'zvM13vl26',
id: 'kmdsnVIQX',
label: i18n.ts.keepCw,
keywords: ['remember', 'keep', 'note', 'cw'],
},
{
id: 'm75VEWI3S',
id: 'mNRK0pt8L',
label: i18n.ts.whenServerDisconnected,
keywords: ['server', 'disconnect', 'reconnect', 'reload', 'streaming'],
},
{
id: 'bLO9vCyKW',
id: 'vE7KeV4U4',
label: i18n.ts.numberOfPageCache,
keywords: ['cache', 'page'],
},
{
id: 'iQ7Er89l5',
id: 'eJ2jme16W',
label: i18n.ts.dataSaver,
keywords: ['datasaver'],
},
@@ -422,20 +422,20 @@ export const searchIndexes: SearchIndexItem[] = [
keywords: ['behavior'],
},
{
id: 'C2WYcVM1d',
id: 'F3kpUNvSQ',
children: [
{
id: 'Cu7ErCM7C',
id: '4bfFRM0UD',
label: i18n.ts.forceShowAds,
keywords: ['ad', 'show'],
},
{
id: 'BBxwy4F6E',
id: '2pB0jWBHo',
label: i18n.ts.hemisphere,
keywords: [],
},
{
id: '9YdUwDC8d',
id: 'eIvnR6Xxo',
label: i18n.ts.additionalEmojiDictionary,
keywords: ['emoji', 'dictionary', 'additional', 'extra'],
},
@@ -445,14 +445,14 @@ export const searchIndexes: SearchIndexItem[] = [
},
],
label: i18n.ts.preferences,
keywords: ['general', 'preferences'],
keywords: ['general', 'preferences', i18n.ts._settings.preferencesBanner],
path: '/settings/preferences',
icon: 'ti ti-adjustments',
},
{
id: 'mwkwtw83Y',
label: i18n.ts.plugins,
keywords: ['plugin'],
keywords: ['plugin', 'addon', 'extension', i18n.ts._settings.pluginBanner],
path: '/settings/plugin',
icon: 'ti ti-plug',
},
@@ -494,10 +494,10 @@ export const searchIndexes: SearchIndexItem[] = [
id: '3icEvyv2D',
children: [
{
id: 'Tyt3gZTy',
id: 'lO3uFTkPN',
children: [
{
id: '9b7ZURyAt',
id: '5JKaXRqyt',
label: i18n.ts.showMutedWord,
keywords: ['show'],
},
@@ -506,85 +506,36 @@ export const searchIndexes: SearchIndexItem[] = [
keywords: ['note', 'word', 'soft', 'mute', 'hide'],
},
{
id: 'kdMk41II0',
id: 'fMkjL3dK4',
label: i18n.ts.hardWordMute,
keywords: ['note', 'word', 'hard', 'mute', 'hide'],
},
{
id: 'mjORQamAK',
id: 'cimSzQXN0',
label: i18n.ts.instanceMute,
keywords: ['note', 'server', 'instance', 'host', 'federation', 'mute', 'hide'],
},
{
id: '1ZT7S9FZd',
id: 'gq8rPy3Du',
label: `${i18n.ts.mutedUsers} (${ i18n.ts.renote })`,
keywords: ['renote', 'mute', 'hide', 'user'],
},
{
id: 'ANrPit3kQ',
id: 'mh2r7EUbF',
label: i18n.ts.mutedUsers,
keywords: ['note', 'mute', 'hide', 'user'],
},
{
id: 'bPAE4lfno',
id: 'AUS1OgHrn',
label: i18n.ts.blockedUsers,
keywords: ['block', 'user'],
},
],
label: i18n.ts.muteAndBlock,
keywords: ['mute', 'block'],
keywords: ['mute', 'block', i18n.ts._settings.muteAndBlockBanner],
path: '/settings/mute-block',
icon: 'ti ti-ban',
},
{
id: 'qE2vLlMkF',
children: [
{
id: 'hPPEzjvZC',
label: i18n.ts._exportOrImport.allNotes,
keywords: ['notes'],
},
{
id: 'AFaeHsCUB',
label: i18n.ts._exportOrImport.favoritedNotes,
keywords: ['favorite', 'notes'],
},
{
id: 'xyCPmQiRo',
label: i18n.ts._exportOrImport.clips,
keywords: ['clip', 'notes'],
},
{
id: 'Ch7hWAGUy',
label: i18n.ts._exportOrImport.followingList,
keywords: ['following', 'users'],
},
{
id: 'AwPgFboEx',
label: i18n.ts._exportOrImport.userLists,
keywords: ['user', 'lists'],
},
{
id: 'nporiHshC',
label: i18n.ts._exportOrImport.muteList,
keywords: ['mute', 'users'],
},
{
id: 'BsCzR7vNw',
label: i18n.ts._exportOrImport.blockingList,
keywords: ['block', 'users'],
},
{
id: 'dvf4IgYrQ',
label: i18n.ts.antennas,
keywords: ['antennas'],
},
],
label: i18n.ts.importAndExport,
keywords: ['import', 'export', 'data'],
path: '/settings/import-export',
icon: 'ti ti-package',
},
{
id: '3Tcxw4Fwl',
children: [
@@ -613,46 +564,65 @@ export const searchIndexes: SearchIndexItem[] = [
id: 'tnYoppRiv',
children: [
{
id: 'ncIq6TAR2',
id: 'cN3dsGNxu',
label: i18n.ts.usageAmount,
keywords: ['capacity', 'usage'],
},
{
id: '2c4CQSvSr',
id: 'rOAOU2P6C',
label: i18n.ts.statistics,
keywords: ['statistics', 'usage'],
},
{
id: 'pepHELHMt',
id: 'uXGlQXATx',
label: i18n.ts.uploadFolder,
keywords: ['default', 'upload', 'folder'],
},
{
id: 'xqOWrABxV',
id: 'goQdtf3dD',
label: i18n.ts.keepOriginalUploading,
keywords: ['keep', 'original', 'raw', 'upload', i18n.ts.keepOriginalUploadingDescription],
},
{
id: 'D8HUTGWE1',
id: '83xRo0XJl',
label: i18n.ts.keepOriginalFilename,
keywords: ['keep', 'original', 'filename', i18n.ts.keepOriginalFilenameDescription],
},
{
id: '6xAvsWSZi',
id: 'wf77yRQQq',
label: i18n.ts.alwaysMarkSensitive,
keywords: ['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file'],
},
{
id: 'csNNPF1KX',
id: '3pxwNB8e4',
label: i18n.ts.enableAutoSensitive,
keywords: ['auto', 'nsfw', 'sensitive', 'media', 'file', i18n.ts.enableAutoSensitiveDescription],
},
],
label: i18n.ts.drive,
keywords: ['drive'],
keywords: ['drive', i18n.ts._settings.driveBanner],
path: '/settings/drive',
icon: 'ti ti-cloud',
},
{
id: 'BlJ2rsw9h',
children: [
{
id: '9bLU1nIjt',
label: i18n.ts._settings.api,
keywords: ['api', 'app', 'token', 'accessToken'],
},
{
id: '5VSGOVYR0',
label: i18n.ts.manage,
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,
@@ -664,85 +634,85 @@ export const searchIndexes: SearchIndexItem[] = [
id: 'AqPvMgn3A',
children: [
{
id: 'j5gTtuMWP',
id: '1wtOIwAdm',
label: i18n.ts.useBlurEffect,
keywords: ['blur'],
},
{
id: 'C05WQNSIJ',
id: '6fLNMTwNt',
label: i18n.ts.useBlurEffectForModal,
keywords: ['blur', 'modal'],
},
{
id: 'snVKNr7Bw',
id: 'E0WXhhRB1',
label: i18n.ts.highlightSensitiveMedia,
keywords: ['highlight', 'sensitive', 'nsfw', 'image', 'photo', 'picture', 'media', 'thumbnail'],
},
{
id: 'DsS2CwjYE',
id: '7iZsGkplG',
label: i18n.ts.squareAvatars,
keywords: ['avatar', 'icon', 'square'],
},
{
id: 'xCcTDl651',
id: 'AfRMcC6IM',
label: i18n.ts.showAvatarDecorations,
keywords: ['avatar', 'icon', 'decoration', 'show'],
},
{
id: '3dHw723VD',
id: 'i7aSaEWaT',
label: i18n.ts.showGapBetweenNotesInTimeline,
keywords: ['note', 'timeline', 'gap'],
},
{
id: 'AWi72xbrl',
id: 'knj98Mx84',
label: i18n.ts.seasonalScreenEffect,
keywords: ['effect', 'show'],
},
{
id: 'Ces8FsJws',
id: 'Bzg77rYNd',
label: i18n.ts.menuStyle,
keywords: ['menu', 'style', 'popup', 'drawer'],
},
{
id: 'wDr9xSXCv',
id: '7AOZ1ZgDv',
label: i18n.ts.emojiStyle,
keywords: ['emoji', 'style', 'native', 'system', 'fluent', 'twemoji'],
},
{
id: 'vFB0pLzck',
id: 'fDelHUrBi',
label: i18n.ts.fontSize,
keywords: ['font', 'size'],
},
{
id: '23BhvYXPC',
id: 'siOW5aSwp',
label: i18n.ts.useSystemFont,
keywords: ['font', 'system', 'native'],
},
{
id: 'EeNLndAOa',
id: 's05dHQ1dW',
children: [
{
id: 'rAAPoaodS',
id: 'zoMbYCvP0',
label: i18n.ts.reactionsDisplaySize,
keywords: ['reaction', 'size', 'scale', 'display'],
},
{
id: 'qTLAvNWsc',
id: 'lGFzLnWfB',
label: i18n.ts.limitWidthOfReaction,
keywords: ['reaction', 'size', 'scale', 'display', 'width', 'limit'],
},
{
id: '2lWgzAm13',
id: '9E0v8VKIY',
label: i18n.ts.mediaListWithOneImageAppearance,
keywords: ['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'list', 'size', 'height'],
},
{
id: 'EU7HbxOR5',
id: 'xB7MPEF4Q',
label: i18n.ts.instanceTicker,
keywords: ['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation'],
},
{
id: 'AEtM0FAp1',
id: '7siYCSodm',
label: i18n.ts.displayOfSensitiveMedia,
keywords: ['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'nsfw', 'sensitive', 'display', 'show', 'hide', 'visibility'],
},
@@ -751,15 +721,15 @@ export const searchIndexes: SearchIndexItem[] = [
keywords: ['note', 'display'],
},
{
id: 'A1FMC2Zon',
id: 'uQfyiHMSs',
children: [
{
id: 'CB37G5ZDo',
id: 'y3uTXsSQ6',
label: i18n.ts.position,
keywords: ['position'],
},
{
id: 'gGS2i19hS',
id: 'PILAdkVM',
label: i18n.ts.stackAxis,
keywords: ['stack', 'axis', 'direction'],
},
@@ -769,51 +739,100 @@ export const searchIndexes: SearchIndexItem[] = [
},
],
label: i18n.ts.appearance,
keywords: ['appearance'],
keywords: ['appearance', i18n.ts._settings.appearanceBanner],
path: '/settings/appearance',
icon: 'ti ti-device-desktop',
},
{
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', i18n.ts._settings.accountDataBanner],
path: '/settings/account-data',
icon: 'ti ti-package',
},
{
id: 'f08Mi1Uwn',
children: [
{
id: '7ov7ceoij',
id: 'C5dRH2Ypy',
label: i18n.ts.reduceUiAnimation,
keywords: ['animation', 'motion', 'reduce'],
},
{
id: 'cXr3tFdpa',
id: '5mZxz2cru',
label: i18n.ts.disableShowingAnimatedImages,
keywords: ['disable', 'animation', 'image', 'photo', 'picture', 'media', 'thumbnail', 'gif'],
},
{
id: 'Ok1UBwtP',
id: 'c0Iy5hL5o',
label: i18n.ts.enableAnimatedMfm,
keywords: ['mfm', 'enable', 'show', 'animated'],
},
{
id: 'yPEpJigqY',
id: '4HYFjs2Nv',
label: i18n.ts.enableHorizontalSwipe,
keywords: ['swipe', 'horizontal', 'tab'],
},
{
id: 'h7iZtdTU3',
id: 'kYVJ3SVNq',
label: i18n.ts.keepScreenOn,
keywords: ['keep', 'screen', 'display', 'on'],
},
{
id: 'gP1BY3PDy',
id: 'w4Bv0meAt',
label: i18n.ts.useNativeUIForVideoAudioPlayer,
keywords: ['native', 'system', 'video', 'audio', 'player', 'media'],
},
{
id: 'jnMK3M6rs',
id: '1fV9WINCQ',
label: i18n.ts._contextMenu.title,
keywords: ['contextmenu', 'system', 'native'],
},
],
label: i18n.ts.accessibility,
keywords: ['accessibility'],
keywords: ['accessibility', i18n.ts._settings.accessibilityBanner],
path: '/settings/accessibility',
icon: 'ti ti-accessible',
},

View File

@@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2025.3.2-alpha.5",
"version": "2025.3.2-alpha.7",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",