Compare commits

..

41 Commits

Author SHA1 Message Date
github-actions[bot]
3280a3d661 Bump version to 2025.3.2-alpha.10 2025-03-13 09:07:37 +00:00
syuilo
bdf80c49d8 fix(frontend): better migration detection
Fix #15656
2025-03-13 18:05:44 +09:00
syuilo
59169a6450 🎨 2025-03-13 17:42:35 +09:00
syuilo
5d228fb0f3 enhance(frontend): re-organize settings page 2025-03-13 17:39:53 +09:00
syuilo
10b67e1b3a enhance(frontend): improve emoji picker settings 2025-03-13 16:56:47 +09:00
syuilo
3ced310f77 refactor(frontend): organize use functions 2025-03-13 14:05:04 +09:00
syuilo
010ec113c2 refactor(frontend): cond -> scope 2025-03-13 13:45:23 +09:00
syuilo
30005ba959 enhance(frontend): tweak search index 2025-03-13 09:26:04 +09:00
syuilo
6b69588c03 enhance(frontend): improve deck setting page 2025-03-13 09:24:15 +09:00
syuilo
8593aa1418 refactor 2025-03-13 09:10:09 +09:00
syuilo
9876ff9a7a Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2025-03-13 09:07:25 +09:00
syuilo
ce6eba77d9 🎨 2025-03-13 09:07:22 +09:00
syuilo
9b2af53025 enhance(frontend): improve pref manager 2025-03-13 09:02:38 +09:00
syuilo
7b6ff19ea3 Update CHANGELOG.md 2025-03-12 21:49:23 +09:00
github-actions[bot]
c9fa95429a Bump version to 2025.3.2-alpha.9 2025-03-12 12:45:35 +00:00
饺子w (Yumechi)
e5d117dc98 fix(backend): tighten an overly relaxed criteria and remove capability of matching multiple final URLs in URL authority checking (#15655)
Signed-off-by: eternal-flame-AD <yume@yumechi.jp>
2025-03-12 12:39:24 +00:00
syuilo
4a73feb041 enhance(frontend): make deck profiles syncable 2025-03-12 21:12:08 +09:00
syuilo
a06b9eefaa enhance(frontend): suppress needless confirmation when turn on pref sync 2025-03-12 21:05:39 +09:00
syuilo
3129fcf164 fix(frontend): fix type errors 2025-03-12 20:17:54 +09:00
syuilo
35a4544477 add todo 2025-03-12 18:54:36 +09:00
zyoshoka
aa1cc2f817 fix(storybook): use type-only imports in generated stories (#15654) 2025-03-12 16:51:10 +09:00
github-actions[bot]
15685be4cc Bump version to 2025.3.2-alpha.8 2025-03-12 06:10:35 +00:00
syuilo
8508c4dadc refactor 2025-03-12 15:07:45 +09:00
かっこかり
e594fb0037 enhance(dev): frontendの検索インデックス作成を単独のコマンドで行えるように (#15653) 2025-03-12 14:37:57 +09:00
syuilo
a369721791 remove todo 2025-03-12 14:35:22 +09:00
syuilo
f8e244f48d enhance(frontend): アカウントオーバーライド設定とデバイス間同期の併用に対応 2025-03-12 14:34:10 +09:00
syuilo
8410611512 Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2025-03-12 13:04:44 +09:00
syuilo
caab1ec7c3 🎨 2025-03-12 13:04:41 +09:00
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
112 changed files with 3341 additions and 2525 deletions

View File

@@ -6,13 +6,17 @@
### Client
- Feat: 設定の管理が強化されました
- 自動でバックアップされるように
- 任意の設定項目をデバイス間で同期できるように(実験的)
- Enhance: プラグインの管理が強化されました
- インストール/アンインストール/設定の変更時にリロード不要になりました
- Enhance: CWの注釈テキストが入力されていない場合, Postボタンを非アクティブに
- Enhance: CWを無効にした場合, 注釈テキストが最大入力文字数を超えていても投稿できるように
- Enhance: テーマ設定画面のデザインを改善
- Fix: テーマ切り替え時に一部の色が変わらない問題を修正
### Server
- Fix: プロフィール追加情報で無効なURLに入力された場合に照会エラーを出るのを修正
- Fix: ActivityPubリクエストURLチェック実装は仕様に従っていないのを修正
## 2025.3.1

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

124
locales/index.d.ts vendored
View File

@@ -5310,6 +5310,126 @@ export interface Locale extends ILocale {
* 復元
*/
"restore": string;
/**
* デバイス間で同期
*/
"syncBetweenDevices": string;
/**
* サーバーに設定値が存在します
*/
"preferenceSyncConflictTitle": string;
/**
* 同期が有効にされた設定項目は設定値をサーバーに保存しますが、この設定項目のサーバーに保存された設定値が見つかりました。どちらの設定値で上書きしますか?
*/
"preferenceSyncConflictText": string;
/**
* サーバーの設定値
*/
"preferenceSyncConflictChoiceServer": string;
/**
* デバイスの設定値
*/
"preferenceSyncConflictChoiceDevice": string;
/**
* 同期の有効化をキャンセル
*/
"preferenceSyncConflictChoiceCancel": string;
/**
* ペースト
*/
"paste": string;
/**
* 絵文字パレット
*/
"emojiPalette": string;
/**
* 投稿フォーム
*/
"postForm": string;
"_emojiPalette": {
/**
* パレット
*/
"palettes": string;
/**
* パレットのデバイス間同期を有効にする
*/
"enableSyncBetweenDevicesForPalettes": string;
/**
* メインで使用するパレット
*/
"paletteForMain": string;
/**
* リアクションで使用するパレット
*/
"paletteForReaction": 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": {
/**
* プロファイル名
@@ -9758,6 +9878,10 @@ export interface Locale extends ILocale {
* 幅を自動調整
*/
"flexible": string;
/**
* プロファイル情報のデバイス間同期を有効にする
*/
"enableSyncBetweenDevicesForProfiles": string;
"_columns": {
/**
* メイン

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2025.3.2-alpha.5",
"version": "2025.3.2-alpha.10",
"codename": "nasubi",
"repository": {
"type": "git",
@@ -24,6 +24,7 @@
"build": "pnpm build-pre && pnpm -r build && pnpm build-assets",
"build-storybook": "pnpm --filter frontend build-storybook",
"build-misskey-js-with-types": "pnpm build-pre && pnpm --filter backend... --filter=!misskey-js build && pnpm --filter backend generate-api-json --no-build && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build && pnpm --filter misskey-js api",
"build-frontend-search-index": "pnpm --filter frontend build-search-index",
"start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js",
"start:test": "ncp ./.github/misskey/test.yml ./.config/test.yml && cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js",
"init": "pnpm migrate",

View File

@@ -16,7 +16,7 @@ import type { Config } from '@/config.js';
import { StatusError } from '@/misc/status-error.js';
import { bindThis } from '@/decorators.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import { assertActivityMatchesUrls, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
import { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
import type { IObject } from '@/core/activitypub/type.js';
import type { Response } from 'node-fetch';
import type { URL } from 'node:url';
@@ -265,7 +265,7 @@ export class HttpRequestService {
const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject;
assertActivityMatchesUrls(url, activity, [finalUrl], allowSoftfail);
assertActivityMatchesUrl(url, activity, finalUrl, allowSoftfail);
return activity;
}

View File

@@ -17,7 +17,7 @@ import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
import type Logger from '@/logger.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import { assertActivityMatchesUrls, FetchAllowSoftFailMask as FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
import { assertActivityMatchesUrl, FetchAllowSoftFailMask as FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
import type { IObject } from './type.js';
type Request = {
@@ -258,7 +258,7 @@ export class ApRequestService {
const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject;
assertActivityMatchesUrls(url, activity, [finalUrl], allowSoftfail);
assertActivityMatchesUrl(url, activity, finalUrl, allowSoftfail);
return activity;
}

View File

@@ -75,7 +75,7 @@ function normalizeSynonymousSubdomain(url: URL | string): URL {
return new URL(urlParsed.toString().replace(host, normalizedHost));
}
export function assertActivityMatchesUrls(requestUrl: string | URL, activity: IObject, candidateUrls: (string | URL)[], allowSoftfail: FetchAllowSoftFailMask): FetchAllowSoftFailMask {
export function assertActivityMatchesUrl(requestUrl: string | URL, activity: IObject, finalUrl: string | URL, allowSoftfail: FetchAllowSoftFailMask): FetchAllowSoftFailMask {
// must have a unique identifier to verify authority
if (!activity.id) {
throw new Error('bad Activity: missing id field');
@@ -95,26 +95,32 @@ export function assertActivityMatchesUrls(requestUrl: string | URL, activity: IO
const requestUrlParsed = normalizeSynonymousSubdomain(requestUrl);
const idParsed = normalizeSynonymousSubdomain(activity.id);
const candidateUrlsParsed = candidateUrls.map(it => normalizeSynonymousSubdomain(it));
const finalUrlParsed = normalizeSynonymousSubdomain(finalUrl);
// mastodon sends activities with hash in the URL
// currently it only happens with likes, deletes etc.
// but object ID never has hash
requestUrlParsed.hash = '';
finalUrlParsed.hash = '';
const requestUrlSecure = requestUrlParsed.protocol === 'https:';
const finalUrlSecure = candidateUrlsParsed.every(it => it.protocol === 'https:');
const finalUrlSecure = finalUrlParsed.protocol === 'https:';
if (requestUrlSecure && !finalUrlSecure) {
throw new Error(`bad Activity: id(${activity.id}) is not allowed to have http:// in the url`);
}
// Compare final URL to the ID
if (!candidateUrlsParsed.some(it => it.href === idParsed.href)) {
requireSoftfail(FetchAllowSoftFailMask.NonCanonicalId, `bad Activity: id(${activity.id}) does not match response url(${candidateUrlsParsed.map(it => it.toString())})`);
if (finalUrlParsed.href !== idParsed.href) {
requireSoftfail(FetchAllowSoftFailMask.NonCanonicalId, `bad Activity: id(${activity.id}) does not match response url(${finalUrlParsed.toString()})`);
// at lease host need to match exactly (ActivityPub requirement)
if (!candidateUrlsParsed.some(it => idParsed.host === it.host)) {
throw new Error(`bad Activity: id(${activity.id}) does not match response host(${candidateUrlsParsed.map(it => it.host)})`);
if (idParsed.host !== finalUrlParsed.host) {
throw new Error(`bad Activity: id(${activity.id}) does not match response host(${finalUrlParsed.host})`);
}
}
// Compare request URL to the ID
if (!requestUrlParsed.href.includes(idParsed.href)) {
if (requestUrlParsed.href !== idParsed.href) {
requireSoftfail(FetchAllowSoftFailMask.NonCanonicalId, `bad Activity: id(${activity.id}) does not match request url(${requestUrlParsed.toString()})`);
// if cross-origin lookup is allowed, we can accept some variation between the original request URL to the final object ID (but not between the final URL and the object ID)

View File

@@ -8,7 +8,7 @@ import httpSignature from '@peertube/http-signature';
import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
import { assertActivityMatchesUrls, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
import { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
import { IObject } from '@/core/activitypub/type.js';
export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => {
@@ -66,23 +66,26 @@ describe('ap-request', () => {
});
test('rejects non matching domain', () => {
assert.doesNotThrow(() => assertActivityMatchesUrls(
assert.doesNotThrow(() => assertActivityMatchesUrl(
'https://alice.example.com/abc',
{ id: 'https://alice.example.com/abc' } as IObject,
[
'https://alice.example.com/abc',
],
FetchAllowSoftFailMask.Strict,
), 'validation should pass base case');
assert.throws(() => assertActivityMatchesUrls(
assert.throws(() => assertActivityMatchesUrl(
'https://alice.example.com/abc',
{ id: 'https://bob.example.com/abc' } as IObject,
[
'https://alice.example.com/abc',
],
FetchAllowSoftFailMask.Any,
), 'validation should fail no matter what if the response URL is inconsistent with the object ID');
assert.doesNotThrow(() => assertActivityMatchesUrl(
'https://alice.example.com/abc#test',
{ id: 'https://alice.example.com/abc' } as IObject,
'https://alice.example.com/abc',
FetchAllowSoftFailMask.Strict,
), 'validation should pass with hash in request URL');
// fix issues like threads
// https://github.com/misskey-dev/misskey/issues/15039
const withOrWithoutWWW = [
@@ -97,89 +100,71 @@ describe('ap-request', () => {
),
withOrWithoutWWW,
).forEach(([[a, b], c]) => {
assert.doesNotThrow(() => assertActivityMatchesUrls(
assert.doesNotThrow(() => assertActivityMatchesUrl(
a,
{ id: b } as IObject,
[
c,
],
FetchAllowSoftFailMask.Strict,
), 'validation should pass with or without www. subdomain');
});
});
test('cross origin lookup', () => {
assert.doesNotThrow(() => assertActivityMatchesUrls(
assert.doesNotThrow(() => assertActivityMatchesUrl(
'https://alice.example.com/abc',
{ id: 'https://bob.example.com/abc' } as IObject,
[
'https://bob.example.com/abc',
],
FetchAllowSoftFailMask.CrossOrigin | FetchAllowSoftFailMask.NonCanonicalId,
), 'validation should pass if the response is otherwise consistent and cross-origin is allowed');
assert.throws(() => assertActivityMatchesUrls(
assert.throws(() => assertActivityMatchesUrl(
'https://alice.example.com/abc',
{ id: 'https://bob.example.com/abc' } as IObject,
[
'https://bob.example.com/abc',
],
FetchAllowSoftFailMask.Strict,
), 'validation should fail if the response is otherwise consistent and cross-origin is not allowed');
});
test('rejects non-canonical ID', () => {
assert.throws(() => assertActivityMatchesUrls(
assert.throws(() => assertActivityMatchesUrl(
'https://alice.example.com/@alice',
{ id: 'https://alice.example.com/users/alice' } as IObject,
[
'https://alice.example.com/users/alice'
],
'https://alice.example.com/users/alice',
FetchAllowSoftFailMask.Strict,
), 'throws if the response ID did not exactly match the expected ID');
assert.doesNotThrow(() => assertActivityMatchesUrls(
assert.doesNotThrow(() => assertActivityMatchesUrl(
'https://alice.example.com/@alice',
{ id: 'https://alice.example.com/users/alice' } as IObject,
[
'https://alice.example.com/users/alice',
],
FetchAllowSoftFailMask.NonCanonicalId,
), 'does not throw if non-canonical ID is allowed');
});
test('origin relaxed alignment', () => {
assert.doesNotThrow(() => assertActivityMatchesUrls(
assert.doesNotThrow(() => assertActivityMatchesUrl(
'https://alice.example.com/abc',
{ id: 'https://ap.alice.example.com/abc' } as IObject,
[
'https://ap.alice.example.com/abc',
],
FetchAllowSoftFailMask.MisalignedOrigin | FetchAllowSoftFailMask.NonCanonicalId,
), 'validation should pass if response is a subdomain of the expected origin');
assert.throws(() => assertActivityMatchesUrls(
assert.throws(() => assertActivityMatchesUrl(
'https://alice.multi-tenant.example.com/abc',
{ id: 'https://alice.multi-tenant.example.com/abc' } as IObject,
[
'https://bob.multi-tenant.example.com/abc',
],
FetchAllowSoftFailMask.MisalignedOrigin | FetchAllowSoftFailMask.NonCanonicalId,
), 'validation should fail if response is a disjoint domain of the expected origin');
assert.throws(() => assertActivityMatchesUrls(
assert.throws(() => assertActivityMatchesUrl(
'https://alice.example.com/abc',
{ id: 'https://ap.alice.example.com/abc' } as IObject,
[
'https://ap.alice.example.com/abc',
],
FetchAllowSoftFailMask.Strict,
), 'throws if relaxed origin is forbidden');
});
test('resist HTTP downgrade', () => {
assert.throws(() => assertActivityMatchesUrls(
assert.throws(() => assertActivityMatchesUrl(
'https://alice.example.com/abc',
{ id: 'https://alice.example.com/abc' } as IObject,
[
'http://alice.example.com/abc',
],
FetchAllowSoftFailMask.Strict,
), 'throws if HTTP downgrade is detected');
});

View File

@@ -17,8 +17,52 @@ interface SatisfiesExpression extends estree.BaseExpression {
reference: estree.Identifier;
}
interface ImportDeclaration extends estree.ImportDeclaration {
kind?: 'type';
}
const generator = {
...GENERATOR,
ImportDeclaration(node: ImportDeclaration, state: State) {
state.write('import ');
if (node.kind === 'type') state.write('type ');
const { specifiers } = node;
if (specifiers.length > 0) {
let i = 0;
for (; i < specifiers.length; i++) {
if (i > 0) {
state.write(', ');
}
const specifier = specifiers[i]!;
if (specifier.type === 'ImportDefaultSpecifier') {
state.write(specifier.local.name, specifier);
} else if (specifier.type === 'ImportNamespaceSpecifier') {
state.write(`* as ${specifier.local.name}`, specifier);
} else {
break;
}
}
if (i < specifiers.length) {
state.write('{');
for (; i < specifiers.length; i++) {
const specifier = specifiers[i]! as estree.ImportSpecifier;
const { name } = specifier.imported as estree.Identifier;
state.write(name, specifier);
if (name !== specifier.local.name) {
state.write(` as ${specifier.local.name}`);
}
if (i < specifiers.length - 1) {
state.write(', ');
}
}
state.write('}');
}
state.write(' from ');
}
this.Literal(node.source, state);
state.write(';');
},
SatisfiesExpression(node: SatisfiesExpression, state: State) {
switch (node.expression.type) {
case 'ArrowFunctionExpression': {
@@ -155,7 +199,8 @@ function toStories(component: string): Promise<string> {
/> as estree.ImportSpecifier,
]),
]}
/> as estree.ImportDeclaration,
kind={'type'}
/> as ImportDeclaration,
...(hasMsw
? [
<import-declaration
@@ -165,7 +210,7 @@ function toStories(component: string): Promise<string> {
local={<identifier name='msw' /> as estree.Identifier}
/> as estree.ImportNamespaceSpecifier,
]}
/> as estree.ImportDeclaration,
/> as ImportDeclaration,
]
: []),
...(hasImplStories
@@ -176,7 +221,7 @@ function toStories(component: string): Promise<string> {
specifiers={[
<import-default-specifier local={identifier} /> as estree.ImportDefaultSpecifier,
]}
/> as estree.ImportDeclaration,
/> as ImportDeclaration,
]),
...(hasMetaStories
? [
@@ -187,7 +232,7 @@ function toStories(component: string): Promise<string> {
local={<identifier name='storiesMeta' /> as estree.Identifier}
/> as estree.ImportNamespaceSpecifier,
]}
/> as estree.ImportDeclaration,
/> as ImportDeclaration,
]
: []),
<variable-declaration

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

@@ -1428,6 +1428,23 @@ async function processVueFile(
};
}
export async function generateSearchIndex(options: Options, transformedCodeCache: Record<string, string> = {}) {
const filePaths = options.targetFilePaths.reduce<string[]>((acc, filePathPattern) => {
const matchedFiles = glob.sync(filePathPattern);
return [...acc, ...matchedFiles];
}, []);
for (const filePath of filePaths) {
const id = path.resolve(filePath); // 絶対パスに変換
const code = fs.readFileSync(filePath, 'utf-8'); // ファイル内容を読み込む
const { transformedCodeCache: newCache } = await processVueFile(code, id, options, transformedCodeCache); // processVueFile 関数を呼び出す
transformedCodeCache = newCache; // キャッシュを更新
}
await analyzeVueProps({ ...options, transformedCodeCache }); // 開発サーバー起動時にも analyzeVueProps を実行
return transformedCodeCache; // キャッシュを返す
}
// Rollup プラグインとして export
export default function pluginCreateSearchIndex(options: Options): Plugin {
@@ -1445,19 +1462,7 @@ export default function pluginCreateSearchIndex(options: Options): Plugin {
return;
}
const filePaths = options.targetFilePaths.reduce<string[]>((acc, filePathPattern) => {
const matchedFiles = glob.sync(filePathPattern);
return [...acc, ...matchedFiles];
}, []);
for (const filePath of filePaths) {
const id = path.resolve(filePath); // 絶対パスに変換
const code = fs.readFileSync(filePath, 'utf-8'); // ファイル内容を読み込む
const { transformedCodeCache: newCache } = await processVueFile(code, id, options, transformedCodeCache); // processVueFile 関数を呼び出す
transformedCodeCache = newCache; // キャッシュを更新
}
await analyzeVueProps({ ...options, transformedCodeCache }); // 開発サーバー起動時にも analyzeVueProps を実行
transformedCodeCache = await generateSearchIndex(options, transformedCodeCache);
},
async transform(code, id) {

View File

@@ -5,6 +5,7 @@
"scripts": {
"watch": "vite",
"build": "vite build",
"build-search-index": "vite-node --config \"./vite-node.config.ts\" \"./scripts/generate-search-index.ts\"",
"storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"",
"build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
"build-storybook": "pnpm build-storybook-pre && storybook build --webpack-stats-json storybook-static",
@@ -133,6 +134,7 @@
"start-server-and-test": "2.0.10",
"storybook": "8.6.4",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"vite-node": "3.0.8",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "3.0.8",
"vitest-fetch-mock": "0.4.5",

View File

@@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { searchIndexes } from '../vite.config.js';
import { generateSearchIndex } from '../lib/vite-plugin-create-search-index.js';
async function main() {
for (const searchIndex of searchIndexes) {
await generateSearchIndex(searchIndex);
}
}
main();

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

@@ -326,6 +326,7 @@ export async function common(createVue: () => App<Element>) {
return {
isClientUpdated,
lastVersion,
app,
};
}

View File

@@ -6,9 +6,12 @@
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 { compareVersions } from 'compare-versions';
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';
@@ -28,9 +31,10 @@ import { prefer } from '@/preferences.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { deckStore } from '@/ui/deck/deck-store.js';
import { launchPlugins } from '@/plugin.js';
import { unisonReload } from '@/utility/unison-reload.js';
export async function mainBoot() {
const { isClientUpdated } = await common(() => {
const { isClientUpdated, lastVersion } = await common(() => {
let uiStyle = ui;
const searchParams = new URLSearchParams(window.location.search);
@@ -70,88 +74,59 @@ export async function mainBoot() {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {
closed: () => dispose(),
});
}
const stream = useStream();
let reloadDialogShowing = false;
stream.on('_disconnected_', async () => {
if (prefer.s.serverDisconnectedBehavior === 'reload') {
location.reload();
} else if (prefer.s.serverDisconnectedBehavior === 'dialog') {
if (reloadDialogShowing) return;
reloadDialogShowing = true;
const { canceled } = await confirm({
type: 'warning',
title: i18n.ts.disconnectedFromServer,
text: i18n.ts.reloadConfirm,
});
reloadDialogShowing = false;
if (!canceled) {
location.reload();
}
}
});
stream.on('emojiAdded', emojiData => {
addCustomEmoji(emojiData.emoji);
});
stream.on('emojiUpdated', emojiData => {
updateCustomEmojis(emojiData.emojis);
});
stream.on('emojiDeleted', emojiData => {
removeCustomEmojis(emojiData.emojis);
});
launchPlugins();
try {
if (prefer.s.enableSeasonalScreenEffect) {
const month = new Date().getMonth() + 1;
if (prefer.s.hemisphere === 'S') {
// ▼南半球
if (month === 7 || month === 8) {
const SnowfallEffect = (await import('@/utility/snowfall-effect.js')).SnowfallEffect;
new SnowfallEffect({}).render();
}
} else {
// ▼北半球
if (month === 12 || month === 1) {
const SnowfallEffect = (await import('@/utility/snowfall-effect.js')).SnowfallEffect;
new SnowfallEffect({}).render();
} else if (month === 3 || month === 4) {
const SakuraEffect = (await import('@/utility/snowfall-effect.js')).SnowfallEffect;
new SakuraEffect({
sakura: true,
}).render();
}
}
}
} catch (error) {
// console.error(error);
console.error('Failed to initialise the seasonal screen effect canvas context:', error);
}
if ($i) {
store.loaded.then(async () => {
// prefereces migration
// TODO: そのうち消す
if (store.s.menu.length > 0) {
if (lastVersion && (compareVersions('2025.3.2-alpha.0', lastVersion) === 1)) {
console.log('Preferences migration');
store.loaded.then(async () => {
const themes = await misskeyApi('i/registry/get', { scope: ['client'], key: 'themes' }).catch(() => []);
if (themes.length > 0) {
prefer.commit('themes', themes);
}
const plugins = ColdDeviceStorage.get('plugins');
prefer.commit('plugins', plugins.map(p => ({
...p,
installId: (p as any).id,
id: undefined,
})));
prefer.commit('deck.profile', deckStore.s.profile);
misskeyApi('i/registry/keys', {
scope: ['client', 'deck', 'profiles'],
}).then(async keys => {
const profiles: DeckProfile[] = [];
for (const key of keys) {
const deck = await misskeyApi('i/registry/get', {
scope: ['client', 'deck', 'profiles'],
key: key,
});
profiles.push({
id: uuid(),
name: key,
columns: deck.columns,
layout: deck.layout,
});
}
prefer.commit('deck.profiles', profiles);
});
prefer.commit('lightTheme', ColdDeviceStorage.get('lightTheme'));
prefer.commit('darkTheme', ColdDeviceStorage.get('darkTheme'));
prefer.commit('syncDeviceDarkMode', ColdDeviceStorage.get('syncDeviceDarkMode'));
prefer.commit('emojiPalettes', [{
id: 'reactions',
name: '',
emojis: store.s.reactions,
}, {
id: 'pinnedEmojis',
name: '',
emojis: store.s.pinnedEmojis,
}]);
prefer.commit('emojiPaletteForMain', 'pinnedEmojis');
prefer.commit('emojiPaletteForReaction', 'reactions');
prefer.commit('overridedDeviceKind', store.s.overridedDeviceKind);
prefer.commit('widgets', store.s.widgets);
prefer.commit('keepCw', store.s.keepCw);
@@ -223,12 +198,78 @@ 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', []);
window.setTimeout(() => {
unisonReload();
}, 5000);
});
}
}
const stream = useStream();
let reloadDialogShowing = false;
stream.on('_disconnected_', async () => {
if (prefer.s.serverDisconnectedBehavior === 'reload') {
location.reload();
} else if (prefer.s.serverDisconnectedBehavior === 'dialog') {
if (reloadDialogShowing) return;
reloadDialogShowing = true;
const { canceled } = await confirm({
type: 'warning',
title: i18n.ts.disconnectedFromServer,
text: i18n.ts.reloadConfirm,
});
reloadDialogShowing = false;
if (!canceled) {
location.reload();
}
}
});
stream.on('emojiAdded', emojiData => {
addCustomEmoji(emojiData.emoji);
});
stream.on('emojiUpdated', emojiData => {
updateCustomEmojis(emojiData.emojis);
});
stream.on('emojiDeleted', emojiData => {
removeCustomEmojis(emojiData.emojis);
});
launchPlugins();
try {
if (prefer.s.enableSeasonalScreenEffect) {
const month = new Date().getMonth() + 1;
if (prefer.s.hemisphere === 'S') {
// ▼南半球
if (month === 7 || month === 8) {
const SnowfallEffect = (await import('@/utility/snowfall-effect.js')).SnowfallEffect;
new SnowfallEffect({}).render();
}
} else {
// ▼北半球
if (month === 12 || month === 1) {
const SnowfallEffect = (await import('@/utility/snowfall-effect.js')).SnowfallEffect;
new SnowfallEffect({}).render();
} else if (month === 3 || month === 4) {
const SakuraEffect = (await import('@/utility/snowfall-effect.js')).SnowfallEffect;
new SakuraEffect({
sakura: true,
}).render();
}
}
}
} catch (error) {
// console.error(error);
console.error('Failed to initialise the seasonal screen effect canvas context:', error);
}
if ($i) {
store.loaded.then(async () => {
if (store.s.accountSetupWizard !== -1) {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {
closed: () => dispose(),

View File

@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button
v-if="!link"
ref="el" class="_button"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly }]"
:type="type"
:name="name"
:value="value"
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</button>
<MkA
v-else class="_button"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly }]"
:to="to ?? '#'"
:behavior="linkBehavior"
@mousedown="onMousedown"
@@ -57,6 +57,7 @@ const props = defineProps<{
name?: string;
value?: string;
disabled?: boolean;
iconOnly?: boolean;
}>();
const emit = defineEmits<{
@@ -147,6 +148,11 @@ function onMousedown(evt: MouseEvent): void {
background: var(--MI_THEME-buttonHoverBg);
}
&.iconOnly {
padding: 7px;
min-width: auto;
}
&.small {
font-size: 90%;
padding: 6px 12px;
@@ -220,28 +226,28 @@ function onMousedown(evt: MouseEvent): void {
background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB));
&:not(:disabled):hover {
background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5)));
background: linear-gradient(90deg, hsl(from var(--MI_THEME-buttonGradateA) h s calc(l + 5)), hsl(from var(--MI_THEME-buttonGradateB) h s calc(l + 5)));
}
&:not(:disabled):active {
background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5)));
background: linear-gradient(90deg, hsl(from var(--MI_THEME-buttonGradateA) h s calc(l + 5)), hsl(from var(--MI_THEME-buttonGradateB) h s calc(l + 5)));
}
}
&.danger {
font-weight: bold;
color: #ff2a2a;
color: var(--MI_THEME-error);
&.primary {
color: #fff;
background: #ff2a2a;
background: var(--MI_THEME-error);
&:not(:disabled):hover {
background: #ff4242;
background: hsl(from var(--MI_THEME-error) h s calc(l + 10));
}
&:not(:disabled):active {
background: #d42e2e;
background: hsl(from var(--MI_THEME-error) h s calc(l - 10));
}
}
}

View File

@@ -55,7 +55,7 @@ import { Chart } from 'chart.js';
import * as Misskey from 'misskey-js';
import { misskeyApiGet } from '@/utility/misskey-api.js';
import { store } from '@/store.js';
import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js';
import { alpha } from '@/utility/color.js';
import date from '@/filters/date.js';

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

@@ -18,7 +18,7 @@ import { Chart } from 'chart.js';
import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { store } from '@/store.js';
import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { alpha } from '@/utility/color.js';
import { initChart } from '@/utility/init-chart.js';

View File

@@ -88,7 +88,7 @@ import { onMounted, ref, computed, shallowRef } from 'vue';
import { Chart } from 'chart.js';
import MkSelect from '@/components/MkSelect.vue';
import MkChart from '@/components/MkChart.vue';
import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { $i } from '@/account.js';
import * as os from '@/os.js';
import { misskeyApiGet } from '@/utility/misskey-api.js';

View File

@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { defineAsyncComponent, ref } from 'vue';
import { url as local } from '@@/js/config.js';
import { useTooltip } from '@/utility/use-tooltip.js';
import { useTooltip } from '@/use/use-tooltip.js';
import * as os from '@/os.js';
import { isEnabledUrlPreview } from '@/instance.js';
import type { MkABehavior } from '@/components/global/MkA.vue';

View File

@@ -177,12 +177,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts">
import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, unref, watch } from 'vue';
import MkSwitchButton from '@/components/MkSwitch.button.vue';
import type { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js';
import type { Keymap } from '@/utility/hotkey.js';
import MkSwitchButton from '@/components/MkSwitch.button.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { isTouchUsing } from '@/utility/touch.js';
import type { Keymap } from '@/utility/hotkey.js';
import { isFocusable } from '@/utility/focus.js';
import { getNodeOrNull } from '@/utility/get-dom-node-or-null.js';
@@ -558,11 +558,11 @@ onBeforeUnmount(() => {
}
&.danger {
--menuFg: #ff2a2a;
--menuFg: var(--MI_THEME-error);
--menuHoverFg: #fff;
--menuHoverBg: #ff4242;
--menuHoverBg: var(--MI_THEME-error);
--menuActiveFg: #fff;
--menuActiveBg: #d42e2e;
--menuActiveBg: hsl(from var(--MI_THEME-error) h s calc(l - 10));
}
&.radio {

View File

@@ -211,9 +211,9 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { $i } from '@/account.js';
import { i18n } from '@/i18n.js';
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
import { useNoteCapture } from '@/utility/use-note-capture.js';
import { useNoteCapture } from '@/use/use-note-capture.js';
import { deepClone } from '@/utility/clone.js';
import { useTooltip } from '@/utility/use-tooltip.js';
import { useTooltip } from '@/use/use-tooltip.js';
import { claimAchievement } from '@/utility/achievements.js';
import { getNoteSummary } from '@/utility/get-note-summary.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';

View File

@@ -241,9 +241,9 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { $i } from '@/account.js';
import { i18n } from '@/i18n.js';
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
import { useNoteCapture } from '@/utility/use-note-capture.js';
import { useNoteCapture } from '@/use/use-note-capture.js';
import { deepClone } from '@/utility/clone.js';
import { useTooltip } from '@/utility/use-tooltip.js';
import { useTooltip } from '@/use/use-tooltip.js';
import { claimAchievement } from '@/utility/achievements.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/utility/show-moved-dialog.js';

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

@@ -4,14 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<div :class="$style.root" @contextmenu.prevent.stop="showMenu($event, true)">
<div :class="$style.body">
<slot></slot>
</div>
<div :class="$style.menu">
<i v-if="isSyncEnabled" class="ti ti-cloud-cog" style="color: var(--MI_THEME-accent); opacity: 0.7;"></i>
<i v-if="isAccountOverrided" class="ti ti-user-cog" style="color: var(--MI_THEME-accent); opacity: 0.7;"></i>
<div :class="$style.buttons">
<button class="_button" style="color: var(--MI_THEME-fg)" @click="showMenu"><i class="ti ti-dots"></i></button>
<button class="_button" style="color: var(--MI_THEME-fg)" @click="showMenu($event)"><i class="ti ti-dots"></i></button>
</div>
</div>
</div>
@@ -21,25 +22,33 @@ 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) {
function showMenu(ev: MouseEvent, contextmenu?: boolean) {
const i = window.setInterval(() => {
isAccountOverrided.value = profileManager.isAccountOverrided(props.k);
isAccountOverrided.value = prefer.isAccountOverrided(props.k);
isSyncEnabled.value = prefer.isSyncEnabled(props.k);
}, 100);
os.popupMenu(profileManager.getPerPrefMenu(props.k), ev.currentTarget ?? ev.target, {
if (contextmenu) {
os.contextMenu(prefer.getPerPrefMenu(props.k), ev).then(() => {
window.clearInterval(i);
});
} else {
os.popupMenu(prefer.getPerPrefMenu(props.k), ev.currentTarget ?? ev.target, {
onClosing: () => {
window.clearInterval(i);
},
});
}
}
</script>
<style lang="scss" module>
@@ -48,7 +57,7 @@ function showMenu(ev: MouseEvent) {
display: flex;
&:hover {
&::after {
&::before {
content: '';
position: absolute;
top: -8px;

View File

@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { defineAsyncComponent, shallowRef } from 'vue';
import { useTooltip } from '@/utility/use-tooltip.js';
import { useTooltip } from '@/use/use-tooltip.js';
import * as os from '@/os.js';
const props = defineProps<{

View File

@@ -26,7 +26,7 @@ import XDetails from '@/components/MkReactionsViewer.details.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import * as os from '@/os.js';
import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
import { useTooltip } from '@/utility/use-tooltip.js';
import { useTooltip } from '@/use/use-tooltip.js';
import { $i } from '@/account.js';
import MkReactionEffect from '@/components/MkReactionEffect.vue';
import { claimAchievement } from '@/utility/achievements.js';

View File

@@ -17,7 +17,7 @@ import { onMounted, nextTick, shallowRef, ref } from 'vue';
import { Chart } from 'chart.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { store } from '@/store.js';
import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { alpha } from '@/utility/color.js';
import { initChart } from '@/utility/init-chart.js';

View File

@@ -12,7 +12,7 @@ import { onMounted, shallowRef } from 'vue';
import { Chart } from 'chart.js';
import tinycolor from 'tinycolor2';
import { store } from '@/store.js';
import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js';
import { alpha } from '@/utility/color.js';
import { initChart } from '@/utility/init-chart.js';

View File

@@ -19,7 +19,7 @@ import gradient from 'chartjs-plugin-gradient';
import tinycolor from 'tinycolor2';
import { misskeyApi } from '@/utility/misskey-api.js';
import { store } from '@/store.js';
import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js';
import { initChart } from '@/utility/init-chart.js';

View File

@@ -29,7 +29,7 @@ import { defineAsyncComponent, ref } from 'vue';
import { toUnicode as decodePunycode } from 'punycode.js';
import { url as local } from '@@/js/config.js';
import * as os from '@/os.js';
import { useTooltip } from '@/utility/use-tooltip.js';
import { useTooltip } from '@/use/use-tooltip.js';
import { isEnabledUrlPreview } from '@/instance.js';
import type { MkABehavior } from '@/components/global/MkA.vue';

View File

@@ -90,7 +90,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script setup lang="ts">
import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, shallowRef, toRefs, watch } from 'vue';
import { GridEventEmitter } from '@/components/grid/grid.js';
import { useTooltip } from '@/utility/use-tooltip.js';
import { useTooltip } from '@/use/use-tooltip.js';
import * as os from '@/os.js';
import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js';
import type { Size } from '@/components/grid/grid.js';

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;
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);
}
store.set('deck.columns', deck.columns);
store.set('deck.layout', deck.layout);
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

@@ -143,11 +143,11 @@ import MkInfo from '@/components/MkInfo.vue';
import { physics } from '@/utility/physics.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { store } from '@/store.js';
import * as os from '@/os.js';
import { definePage } from '@/page.js';
import { claimAchievement, claimedAchievements } from '@/utility/achievements.js';
import { $i } from '@/account.js';
import { prefer } from '@/preferences.js';
const patronsWithIcon = [{
name: 'カイヤン',
@@ -406,7 +406,7 @@ const easterEggEngine = ref<{ stop: () => void } | null>(null);
const containerEl = shallowRef<HTMLElement>();
function iconLoaded() {
const emojis = store.s.reactions;
const emojis = prefer.s.emojiPalettes[0].emojis;
const containerWidth = containerEl.value.offsetWidth;
for (let i = 0; i < 32; i++) {
easterEggEmojis.value.push({

View File

@@ -163,7 +163,7 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { fetchInstance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { useForm } from '@/utility/use-form.js';
import { useForm } from '@/use/use-form.js';
import MkFormFooter from '@/components/MkFormFooter.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkInfo from '@/components/MkInfo.vue';

View File

@@ -18,7 +18,7 @@ import { Chart } from 'chart.js';
import gradient from 'chartjs-plugin-gradient';
import { misskeyApi } from '@/utility/misskey-api.js';
import { store } from '@/store.js';
import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js';
import { initChart } from '@/utility/init-chart.js';

View File

@@ -25,7 +25,7 @@ import { Chart } from 'chart.js';
import gradient from 'chartjs-plugin-gradient';
import isChromatic from 'chromatic';
import { misskeyApi } from '@/utility/misskey-api.js';
import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js';
import { store } from '@/store.js';
import { alpha } from '@/utility/color.js';

View File

@@ -54,7 +54,7 @@ import { misskeyApiGet } from '@/utility/misskey-api.js';
import number from '@/filters/number.js';
import MkNumberDiff from '@/components/MkNumberDiff.vue';
import { i18n } from '@/i18n.js';
import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
const topSubInstancesForPie = ref<InstanceForPie[] | null>(null);
const topPubInstancesForPie = ref<InstanceForPie[] | null>(null);

View File

@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onMounted, shallowRef } from 'vue';
import { Chart } from 'chart.js';
import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { initChart } from '@/utility/init-chart.js';
export type InstanceForPie = {

View File

@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { onMounted, shallowRef } from 'vue';
import { Chart } from 'chart.js';
import { store } from '@/store.js';
import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js';
import { alpha } from '@/utility/color.js';
import { initChart } from '@/utility/init-chart.js';

View File

@@ -119,7 +119,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkInput from '@/components/MkInput.vue';
import MkLink from '@/components/MkLink.vue';
import { useForm } from '@/utility/use-form.js';
import { useForm } from '@/use/use-form.js';
import MkFormFooter from '@/components/MkFormFooter.vue';
const meta = await misskeyApi('admin/meta');

View File

@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { onMounted, shallowRef } from 'vue';
import { Chart } from 'chart.js';
import { store } from '@/store.js';
import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js';
import { alpha } from '@/utility/color.js';
import { initChart } from '@/utility/init-chart.js';

View File

@@ -135,7 +135,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { fetchInstance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { useForm } from '@/utility/use-form.js';
import { useForm } from '@/use/use-form.js';
import MkFormFooter from '@/components/MkFormFooter.vue';
const meta = await misskeyApi('admin/meta');

View File

@@ -273,7 +273,7 @@ import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import { useForm } from '@/utility/use-form.js';
import { useForm } from '@/use/use-form.js';
import MkFormFooter from '@/components/MkFormFooter.vue';
import MkRadios from '@/components/MkRadios.vue';

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">
@@ -56,6 +60,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</SearchMarker>
</div>
<SearchMarker :keywords="['menu', 'style', 'popup', 'drawer']">
<MkPreferenceContainer k="menuStyle">
<MkSelect v-model="menuStyle">
<template #label><SearchLabel>{{ i18n.ts.menuStyle }}</SearchLabel></template>
<option value="auto">{{ i18n.ts.auto }}</option>
<option value="popup">{{ i18n.ts.popup }}</option>
<option value="drawer">{{ i18n.ts.drawer }}</option>
</MkSelect>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['contextmenu', 'system', 'native']">
<MkPreferenceContainer k="contextMenu">
<MkSelect v-model="contextMenu">
@@ -66,6 +81,22 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSelect>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['font', 'size']">
<MkRadios v-model="fontSize">
<template #label><SearchLabel>{{ i18n.ts.fontSize }}</SearchLabel></template>
<option :value="null"><span style="font-size: 14px;">Aa</span></option>
<option value="1"><span style="font-size: 15px;">Aa</span></option>
<option value="2"><span style="font-size: 16px;">Aa</span></option>
<option value="3"><span style="font-size: 17px;">Aa</span></option>
</MkRadios>
</SearchMarker>
<SearchMarker :keywords="['font', 'system', 'native']">
<MkSwitch v-model="useSystemFont">
<template #label><SearchLabel>{{ i18n.ts.useSystemFont }}</SearchLabel></template>
</MkSwitch>
</SearchMarker>
</div>
</SearchMarker>
</template>
@@ -79,6 +110,9 @@ import { reloadAsk } from '@/utility/reload-ask.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
import { miLocalStorage } from '@/local-storage.js';
import MkRadios from '@/components/MkRadios.vue';
const reduceAnimation = prefer.model('animation', v => !v, v => !v);
const animatedMfm = prefer.model('animatedMfm');
@@ -87,10 +121,32 @@ const keepScreenOn = prefer.model('keepScreenOn');
const enableHorizontalSwipe = prefer.model('enableHorizontalSwipe');
const useNativeUiForVideoAudioPlayer = prefer.model('useNativeUiForVideoAudioPlayer');
const contextMenu = prefer.model('contextMenu');
const menuStyle = prefer.model('menuStyle');
const fontSize = ref(miLocalStorage.getItem('fontSize'));
const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null);
watch(fontSize, () => {
if (fontSize.value == null) {
miLocalStorage.removeItem('fontSize');
} else {
miLocalStorage.setItem('fontSize', fontSize.value);
}
});
watch(useSystemFont, () => {
if (useSystemFont.value) {
miLocalStorage.setItem('useSystemFont', 't');
} else {
miLocalStorage.removeItem('useSystemFont');
}
});
watch([
keepScreenOn,
contextMenu,
fontSize,
useSystemFont,
], async () => {
await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
});

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', 'archive']" icon="ti ti-package">
<div class="_gaps_m">
<MkFeatureBanner icon="/client-assets/package_3d.png" color="#ff9100">
<SearchKeyword>{{ i18n.ts._settings.accountDataBanner }}</SearchKeyword>
</MkFeatureBanner>
<div class="_gaps_s">
<SearchMarker :keywords="['notes']">
<MkFolder>
<template #icon><i class="ti ti-pencil"></i></template>
<template #label><SearchLabel>{{ i18n.ts._exportOrImport.allNotes }}</SearchLabel></template>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts.export }}</template>
<template #icon><i class="ti ti-download"></i></template>
<MkButton primary :class="$style.button" inline @click="exportNotes()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
</MkFolder>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['favorite', 'notes']">
<MkFolder>
<template #icon><i class="ti ti-star"></i></template>
<template #label><SearchLabel>{{ i18n.ts._exportOrImport.favoritedNotes }}</SearchLabel></template>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts.export }}</template>
<template #icon><i class="ti ti-download"></i></template>
<MkButton primary :class="$style.button" inline @click="exportFavorites()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
</MkFolder>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['clip', 'notes']">
<MkFolder>
<template #icon><i class="ti ti-star"></i></template>
<template #label><SearchLabel>{{ i18n.ts._exportOrImport.clips }}</SearchLabel></template>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts.export }}</template>
<template #icon><i class="ti ti-download"></i></template>
<MkButton primary :class="$style.button" inline @click="exportClips()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
</MkFolder>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['following', 'users']">
<MkFolder>
<template #icon><i class="ti ti-users"></i></template>
<template #label><SearchLabel>{{ i18n.ts._exportOrImport.followingList }}</SearchLabel></template>
<div class="_gaps_s">
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts.export }}</template>
<template #icon><i class="ti ti-download"></i></template>
<div class="_gaps_s">
<MkSwitch v-model="excludeMutingUsers">
{{ i18n.ts._exportOrImport.excludeMutingUsers }}
</MkSwitch>
<MkSwitch v-model="excludeInactiveUsers">
{{ i18n.ts._exportOrImport.excludeInactiveUsers }}
</MkSwitch>
<MkButton primary :class="$style.button" inline @click="exportFollowing()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
</div>
</MkFolder>
<MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportFollowing" :defaultOpen="true">
<template #label>{{ i18n.ts.import }}</template>
<template #icon><i class="ti ti-upload"></i></template>
<MkSwitch v-model="withReplies">
{{ i18n.ts._exportOrImport.withReplies }}
</MkSwitch>
<MkButton primary :class="$style.button" inline @click="importFollowing($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
</MkFolder>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['user', 'lists']">
<MkFolder>
<template #icon><i class="ti ti-users"></i></template>
<template #label><SearchLabel>{{ i18n.ts._exportOrImport.userLists }}</SearchLabel></template>
<div class="_gaps_s">
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts.export }}</template>
<template #icon><i class="ti ti-download"></i></template>
<MkButton primary :class="$style.button" inline @click="exportUserLists()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
</MkFolder>
<MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportUserLists" :defaultOpen="true">
<template #label>{{ i18n.ts.import }}</template>
<template #icon><i class="ti ti-upload"></i></template>
<MkButton primary :class="$style.button" inline @click="importUserLists($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
</MkFolder>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['mute', 'users']">
<MkFolder>
<template #icon><i class="ti ti-user-off"></i></template>
<template #label><SearchLabel>{{ i18n.ts._exportOrImport.muteList }}</SearchLabel></template>
<div class="_gaps_s">
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts.export }}</template>
<template #icon><i class="ti ti-download"></i></template>
<MkButton primary :class="$style.button" inline @click="exportMuting()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
</MkFolder>
<MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportMuting" :defaultOpen="true">
<template #label>{{ i18n.ts.import }}</template>
<template #icon><i class="ti ti-upload"></i></template>
<MkButton primary :class="$style.button" inline @click="importMuting($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
</MkFolder>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['block', 'users']">
<MkFolder>
<template #icon><i class="ti ti-user-off"></i></template>
<template #label><SearchLabel>{{ i18n.ts._exportOrImport.blockingList }}</SearchLabel></template>
<div class="_gaps_s">
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts.export }}</template>
<template #icon><i class="ti ti-download"></i></template>
<MkButton primary :class="$style.button" inline @click="exportBlocking()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
</MkFolder>
<MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportBlocking" :defaultOpen="true">
<template #label>{{ i18n.ts.import }}</template>
<template #icon><i class="ti ti-upload"></i></template>
<MkButton primary :class="$style.button" inline @click="importBlocking($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
</MkFolder>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['antennas']">
<MkFolder>
<template #icon><i class="ti ti-antenna"></i></template>
<template #label><SearchLabel>{{ i18n.ts.antennas }}</SearchLabel></template>
<div class="_gaps_s">
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts.export }}</template>
<template #icon><i class="ti ti-download"></i></template>
<MkButton primary :class="$style.button" inline @click="exportAntennas()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
</MkFolder>
<MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportAntennas" :defaultOpen="true">
<template #label>{{ i18n.ts.import }}</template>
<template #icon><i class="ti ti-upload"></i></template>
<MkButton primary :class="$style.button" inline @click="importAntennas($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
</MkFolder>
</div>
</MkFolder>
</SearchMarker>
</div>
</div>
</SearchMarker>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { selectFile } from '@/utility/select-file.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { $i } from '@/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

@@ -1,320 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
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">
<FormSection first>
<div class="_gaps_m">
<div class="_gaps_s">
<SearchMarker :keywords="['blur']">
<MkPreferenceContainer k="useBlurEffect">
<MkSwitch v-model="useBlurEffect">
<template #label><SearchLabel>{{ i18n.ts.useBlurEffect }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['blur', 'modal']">
<MkPreferenceContainer k="useBlurEffectForModal">
<MkSwitch v-model="useBlurEffectForModal">
<template #label><SearchLabel>{{ i18n.ts.useBlurEffectForModal }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['highlight', 'sensitive', 'nsfw', 'image', 'photo', 'picture', 'media', 'thumbnail']">
<MkPreferenceContainer k="highlightSensitiveMedia">
<MkSwitch v-model="highlightSensitiveMedia">
<template #label><SearchLabel>{{ i18n.ts.highlightSensitiveMedia }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['avatar', 'icon', 'square']">
<MkPreferenceContainer k="squareAvatars">
<MkSwitch v-model="squareAvatars">
<template #label><SearchLabel>{{ i18n.ts.squareAvatars }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['avatar', 'icon', 'decoration', 'show']">
<MkPreferenceContainer k="showAvatarDecorations">
<MkSwitch v-model="showAvatarDecorations">
<template #label><SearchLabel>{{ i18n.ts.showAvatarDecorations }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['note', 'timeline', 'gap']">
<MkPreferenceContainer k="showGapBetweenNotesInTimeline">
<MkSwitch v-model="showGapBetweenNotesInTimeline">
<template #label><SearchLabel>{{ i18n.ts.showGapBetweenNotesInTimeline }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['effect', 'show']">
<MkPreferenceContainer k="enableSeasonalScreenEffect">
<MkSwitch v-model="enableSeasonalScreenEffect">
<template #label><SearchLabel>{{ i18n.ts.seasonalScreenEffect }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
</div>
<SearchMarker :keywords="['menu', 'style', 'popup', 'drawer']">
<MkPreferenceContainer k="menuStyle">
<MkSelect v-model="menuStyle">
<template #label><SearchLabel>{{ i18n.ts.menuStyle }}</SearchLabel></template>
<option value="auto">{{ i18n.ts.auto }}</option>
<option value="popup">{{ i18n.ts.popup }}</option>
<option value="drawer">{{ i18n.ts.drawer }}</option>
</MkSelect>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['emoji', 'style', 'native', 'system', 'fluent', 'twemoji']">
<MkPreferenceContainer k="emojiStyle">
<div>
<MkRadios v-model="emojiStyle">
<template #label><SearchLabel>{{ i18n.ts.emojiStyle }}</SearchLabel></template>
<option value="native">{{ i18n.ts.native }}</option>
<option value="fluentEmoji">Fluent Emoji</option>
<option value="twemoji">Twemoji</option>
</MkRadios>
<div style="margin: 8px 0 0 0; font-size: 1.5em;"><Mfm :key="emojiStyle" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div>
</div>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['font', 'size']">
<MkRadios v-model="fontSize">
<template #label><SearchLabel>{{ i18n.ts.fontSize }}</SearchLabel></template>
<option :value="null"><span style="font-size: 14px;">Aa</span></option>
<option value="1"><span style="font-size: 15px;">Aa</span></option>
<option value="2"><span style="font-size: 16px;">Aa</span></option>
<option value="3"><span style="font-size: 17px;">Aa</span></option>
</MkRadios>
</SearchMarker>
<SearchMarker :keywords="['font', 'system', 'native']">
<MkSwitch v-model="useSystemFont">
<template #label><SearchLabel>{{ i18n.ts.useSystemFont }}</SearchLabel></template>
</MkSwitch>
</SearchMarker>
</div>
</FormSection>
<SearchMarker :keywords="['note', 'display']">
<FormSection>
<template #label><SearchLabel>{{ i18n.ts.displayOfNote }}</SearchLabel></template>
<div class="_gaps_m">
<SearchMarker :keywords="['reaction', 'size', 'scale', 'display']">
<MkPreferenceContainer k="reactionsDisplaySize">
<MkRadios v-model="reactionsDisplaySize">
<template #label><SearchLabel>{{ i18n.ts.reactionsDisplaySize }}</SearchLabel></template>
<option value="small">{{ i18n.ts.small }}</option>
<option value="medium">{{ i18n.ts.medium }}</option>
<option value="large">{{ i18n.ts.large }}</option>
</MkRadios>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['reaction', 'size', 'scale', 'display', 'width', 'limit']">
<MkPreferenceContainer k="limitWidthOfReaction">
<MkSwitch v-model="limitWidthOfReaction">
<template #label><SearchLabel>{{ i18n.ts.limitWidthOfReaction }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'list', 'size', 'height']">
<MkPreferenceContainer k="mediaListWithOneImageAppearance">
<MkRadios v-model="mediaListWithOneImageAppearance">
<template #label><SearchLabel>{{ i18n.ts.mediaListWithOneImageAppearance }}</SearchLabel></template>
<option value="expand">{{ i18n.ts.default }}</option>
<option value="16_9">{{ i18n.tsx.limitTo({ x: '16:9' }) }}</option>
<option value="1_1">{{ i18n.tsx.limitTo({ x: '1:1' }) }}</option>
<option value="2_3">{{ i18n.tsx.limitTo({ x: '2:3' }) }}</option>
</MkRadios>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation']">
<MkPreferenceContainer k="instanceTicker">
<MkSelect v-if="instance.federation !== 'none'" v-model="instanceTicker">
<template #label><SearchLabel>{{ i18n.ts.instanceTicker }}</SearchLabel></template>
<option value="none">{{ i18n.ts._instanceTicker.none }}</option>
<option value="remote">{{ i18n.ts._instanceTicker.remote }}</option>
<option value="always">{{ i18n.ts._instanceTicker.always }}</option>
</MkSelect>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'nsfw', 'sensitive', 'display', 'show', 'hide', 'visibility']">
<MkPreferenceContainer k="nsfw">
<MkSelect v-model="nsfw">
<template #label><SearchLabel>{{ i18n.ts.displayOfSensitiveMedia }}</SearchLabel></template>
<option value="respect">{{ i18n.ts._displayOfSensitiveMedia.respect }}</option>
<option value="ignore">{{ i18n.ts._displayOfSensitiveMedia.ignore }}</option>
<option value="force">{{ i18n.ts._displayOfSensitiveMedia.force }}</option>
</MkSelect>
</MkPreferenceContainer>
</SearchMarker>
</div>
</FormSection>
</SearchMarker>
<SearchMarker :keywords="['notification', 'display']">
<FormSection>
<template #label><SearchLabel>{{ i18n.ts.notificationDisplay }}</SearchLabel></template>
<div class="_gaps_m">
<SearchMarker :keywords="['position']">
<MkPreferenceContainer k="notificationPosition">
<MkRadios v-model="notificationPosition">
<template #label><SearchLabel>{{ i18n.ts.position }}</SearchLabel></template>
<option value="leftTop"><i class="ti ti-align-box-left-top"></i> {{ i18n.ts.leftTop }}</option>
<option value="rightTop"><i class="ti ti-align-box-right-top"></i> {{ i18n.ts.rightTop }}</option>
<option value="leftBottom"><i class="ti ti-align-box-left-bottom"></i> {{ i18n.ts.leftBottom }}</option>
<option value="rightBottom"><i class="ti ti-align-box-right-bottom"></i> {{ i18n.ts.rightBottom }}</option>
</MkRadios>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['stack', 'axis', 'direction']">
<MkPreferenceContainer k="notificationStackAxis">
<MkRadios v-model="notificationStackAxis">
<template #label><SearchLabel>{{ i18n.ts.stackAxis }}</SearchLabel></template>
<option value="vertical"><i class="ti ti-carousel-vertical"></i> {{ i18n.ts.vertical }}</option>
<option value="horizontal"><i class="ti ti-carousel-horizontal"></i> {{ i18n.ts.horizontal }}</option>
</MkRadios>
</MkPreferenceContainer>
</SearchMarker>
<MkButton @click="testNotification">{{ i18n.ts._notification.checkNotificationBehavior }}</MkButton>
</div>
</FormSection>
</SearchMarker>
<FormSection>
<FormLink to="/settings/custom-css"><template #icon><i class="ti ti-code"></i></template>{{ i18n.ts.customCss }}</FormLink>
</FormSection>
</div>
</SearchMarker>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkRadios from '@/components/MkRadios.vue';
import { prefer } from '@/preferences.js';
import { reloadAsk } from '@/utility/reload-ask.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { miLocalStorage } from '@/local-storage.js';
import FormLink from '@/components/form/link.vue';
import { globalEvents } from '@/events.js';
import { claimAchievement } from '@/utility/achievements.js';
import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';
import { instance } from '@/instance.js';
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
const fontSize = ref(miLocalStorage.getItem('fontSize'));
const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null);
const showAvatarDecorations = prefer.model('showAvatarDecorations');
const emojiStyle = prefer.model('emojiStyle');
const menuStyle = prefer.model('menuStyle');
const useBlurEffectForModal = prefer.model('useBlurEffectForModal');
const useBlurEffect = prefer.model('useBlurEffect');
const highlightSensitiveMedia = prefer.model('highlightSensitiveMedia');
const squareAvatars = prefer.model('squareAvatars');
const enableSeasonalScreenEffect = prefer.model('enableSeasonalScreenEffect');
const showGapBetweenNotesInTimeline = prefer.model('showGapBetweenNotesInTimeline');
const mediaListWithOneImageAppearance = prefer.model('mediaListWithOneImageAppearance');
const reactionsDisplaySize = prefer.model('reactionsDisplaySize');
const limitWidthOfReaction = prefer.model('limitWidthOfReaction');
const notificationPosition = prefer.model('notificationPosition');
const notificationStackAxis = prefer.model('notificationStackAxis');
const nsfw = prefer.model('nsfw');
const instanceTicker = prefer.model('instanceTicker');
watch(fontSize, () => {
if (fontSize.value == null) {
miLocalStorage.removeItem('fontSize');
} else {
miLocalStorage.setItem('fontSize', fontSize.value);
}
});
watch(useSystemFont, () => {
if (useSystemFont.value) {
miLocalStorage.setItem('useSystemFont', 't');
} else {
miLocalStorage.removeItem('useSystemFont');
}
});
watch([
fontSize,
useSystemFont,
squareAvatars,
highlightSensitiveMedia,
enableSeasonalScreenEffect,
showGapBetweenNotesInTimeline,
mediaListWithOneImageAppearance,
reactionsDisplaySize,
limitWidthOfReaction,
mediaListWithOneImageAppearance,
reactionsDisplaySize,
limitWidthOfReaction,
instanceTicker,
], async () => {
await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
});
let smashCount = 0;
let smashTimer: number | null = null;
function testNotification(): void {
const notification: Misskey.entities.Notification = {
id: Math.random().toString(),
createdAt: new Date().toUTCString(),
isRead: false,
type: 'test',
};
globalEvents.emit('clientNotification', notification);
// セルフ通知破壊 実績関連
smashCount++;
if (smashCount >= 10) {
claimAchievement('smashTestNotificationButton');
smashCount = 0;
}
if (smashTimer) {
clearTimeout(smashTimer);
}
smashTimer = window.setTimeout(() => {
smashCount = 0;
}, 300);
}
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePage(() => ({
title: i18n.ts.appearance,
icon: 'ti ti-device-desktop',
}));
</script>

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

@@ -4,34 +4,79 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<SearchMarker path="/settings/deck" :label="i18n.ts.deck" :keywords="['deck', 'ui']" icon="ti ti-columns">
<div class="_gaps_m">
<MkSwitch v-model="useSimpleUiForNonRootPages">{{ i18n.ts._deck.useSimpleUiForNonRootPages }}</MkSwitch>
<SearchMarker :keywords="['sync', 'profiles', 'devices']">
<MkSwitch :modelValue="profilesSyncEnabled" @update:modelValue="changeProfilesSyncEnabled">
<template #label><SearchLabel>{{ i18n.ts._deck.enableSyncBetweenDevicesForProfiles }}</SearchLabel></template>
</MkSwitch>
</SearchMarker>
<MkSwitch v-model="navWindow">{{ i18n.ts.defaultNavigationBehaviour }}: {{ i18n.ts.openInWindow }}</MkSwitch>
<SearchMarker :keywords="['ui', 'root', 'page']">
<MkPreferenceContainer k="deck.useSimpleUiForNonRootPages">
<MkSwitch v-model="useSimpleUiForNonRootPages">
<template #label><SearchLabel>{{ i18n.ts._deck.useSimpleUiForNonRootPages }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<MkSwitch v-model="alwaysShowMainColumn">{{ i18n.ts._deck.alwaysShowMainColumn }}</MkSwitch>
<SearchMarker :keywords="['default', 'navigation', 'behaviour', 'window']">
<MkPreferenceContainer k="deck.navWindow">
<MkSwitch v-model="navWindow">
<template #label><SearchLabel>{{ i18n.ts.defaultNavigationBehaviour }}</SearchLabel>: {{ i18n.ts.openInWindow }}</template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['always', 'show', 'main', 'column']">
<MkPreferenceContainer k="deck.alwaysShowMainColumn">
<MkSwitch v-model="alwaysShowMainColumn">
<template #label><SearchLabel>{{ i18n.ts._deck.alwaysShowMainColumn }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['column', 'align']">
<MkPreferenceContainer k="deck.columnAlign">
<MkRadios v-model="columnAlign">
<template #label>{{ i18n.ts._deck.columnAlign }}</template>
<template #label><SearchLabel>{{ i18n.ts._deck.columnAlign }}</SearchLabel></template>
<option value="left">{{ i18n.ts.left }}</option>
<option value="center">{{ i18n.ts.center }}</option>
</MkRadios>
</MkPreferenceContainer>
</SearchMarker>
</div>
</SearchMarker>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRadios from '@/components/MkRadios.vue';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { prefer } from '@/preferences.js';
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
const navWindow = prefer.model('deck.navWindow');
const useSimpleUiForNonRootPages = prefer.model('deck.useSimpleUiForNonRootPages');
const alwaysShowMainColumn = prefer.model('deck.alwaysShowMainColumn');
const columnAlign = prefer.model('deck.columnAlign');
const profilesSyncEnabled = ref(prefer.isSyncEnabled('deck.profiles'));
function changeProfilesSyncEnabled(value: boolean) {
if (value) {
prefer.enableSync('deck.profiles').then((res) => {
if (res == null) return;
if (res.enabled) profilesSyncEnabled.value = true;
});
} else {
prefer.disableSync('deck.profiles');
profilesSyncEnabled.value = false;
}
}
const headerActions = computed(() => []);
const headerTabs = computed(() => []);

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

@@ -0,0 +1,166 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-palette"></i></template>
<template #label>{{ palette.name === '' ? '(' + i18n.ts.noName + ')' : palette.name }}</template>
<template #footer>
<div class="_buttons">
<MkButton @click="rename"><i class="ti ti-pencil"></i> {{ i18n.ts.rename }}</MkButton>
<MkButton @click="copy"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
<MkButton danger @click="paste"><i class="ti ti-clipboard"></i> {{ i18n.ts.paste }}</MkButton>
<MkButton danger iconOnly style="margin-left: auto;" @click="del"><i class="ti ti-trash"></i></MkButton>
</div>
</template>
<div>
<div v-panel style="border-radius: 6px;">
<Sortable
v-model="emojis"
:class="$style.emojis"
:itemKey="item => item"
:animation="150"
:delay="100"
:delayOnTouchOnly="true"
:group="{ name: 'SortableEmojiPalettes' }"
>
<template #item="{element}">
<button class="_button" :class="$style.emojisItem" @click="remove(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
</template>
<template #footer>
<button class="_button" :class="$style.emojisAdd" @click="pick">
<i class="ti ti-plus"></i>
</button>
</template>
</Sortable>
</div>
<div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div>
</div>
</MkFolder>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import Sortable from 'vuedraggable';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { deepClone } from '@/utility/clone.js';
import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue';
import MkEmoji from '@/components/global/MkEmoji.vue';
import MkFolder from '@/components/MkFolder.vue';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
const props = defineProps<{
palette: {
id: string;
name: string;
emojis: string[];
};
}>();
const emit = defineEmits<{
(ev: 'updateEmojis', emojis: string[]): void,
(ev: 'updateName', name: string): void,
(ev: 'del'): void,
}>();
const emojis = ref<string[]>(deepClone(props.palette.emojis));
watch(emojis, () => {
emit('updateEmojis', emojis.value);
}, { deep: true });
function remove(reaction: string, ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts.remove,
action: () => {
emojis.value = emojis.value.filter(x => x !== reaction);
},
}], getHTMLElement(ev));
}
function pick(ev: MouseEvent) {
os.pickEmoji(getHTMLElement(ev), {
showPinned: false,
}).then(it => {
const emoji = it;
if (!emojis.value.includes(emoji)) {
emojis.value.push(emoji);
}
});
}
function getHTMLElement(ev: MouseEvent): HTMLElement {
const target = ev.currentTarget ?? ev.target;
return target as HTMLElement;
}
function rename() {
os.inputText({
title: i18n.ts.rename,
default: props.palette.name,
}).then(({ canceled, result: name }) => {
if (canceled) return;
if (name != null) {
emit('updateName', name);
}
});
}
function copy() {
copyToClipboard(emojis.value.join(' '));
}
function paste() {
// TODO: validate
navigator.clipboard.readText().then(text => {
emojis.value = text.split(' ');
});
}
function del(ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts.delete,
action: () => {
emit('del');
},
}], ev.currentTarget ?? ev.target);
}
</script>
<style lang="scss" module>
.tab {
margin: calc(var(--MI-margin) / 2) 0;
padding: calc(var(--MI-margin) / 2) 0;
background: var(--MI_THEME-bg);
}
.emojis {
padding: 12px;
font-size: 1.1em;
}
.emojisItem {
display: inline-block;
padding: 8px;
cursor: move;
}
.emojisAdd {
display: inline-block;
padding: 8px;
}
.editorCaption {
font-size: 0.85em;
padding: 8px 0 0 0;
color: var(--MI_THEME-fgTransparentWeak);
}
</style>

View File

@@ -0,0 +1,251 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<SearchMarker path="/settings/emoji-palette" :label="i18n.ts.emojiPalette" :keywords="['emoji', 'palette']" icon="ti ti-mood-happy">
<div class="_gaps_m">
<FormSection first>
<template #label>{{ i18n.ts._emojiPalette.palettes }}</template>
<div class="_gaps_s">
<XPalette
v-for="palette in prefer.r.emojiPalettes.value"
:key="palette.id"
:palette="palette"
@updateEmojis="emojis => updatePaletteEmojis(palette.id, emojis)"
@updateName="name => updatePaletteName(palette.id, name)"
@del="delPalette(palette.id)"
/>
<MkButton primary rounded style="margin: auto;" @click="addPalette"><i class="ti ti-plus"></i></MkButton>
</div>
</FormSection>
<FormSection>
<div class="_gaps_m">
<SearchMarker :keywords="['sync', 'palettes', 'devices']">
<MkSwitch :modelValue="palettesSyncEnabled" @update:modelValue="changePalettesSyncEnabled">
<template #label><SearchLabel>{{ i18n.ts._emojiPalette.enableSyncBetweenDevicesForPalettes }}</SearchLabel></template>
</MkSwitch>
</SearchMarker>
</div>
</FormSection>
<FormSection>
<div class="_gaps_m">
<SearchMarker :keywords="['main', 'palette']">
<MkPreferenceContainer k="emojiPaletteForMain">
<MkSelect v-model="emojiPaletteForMain">
<template #label><SearchLabel>{{ i18n.ts._emojiPalette.paletteForMain }}</SearchLabel></template>
<option key="-" :value="null">({{ i18n.ts.auto }})</option>
<option v-for="palette in prefer.r.emojiPalettes.value" :key="palette.id" :value="palette.id">{{ palette.name === '' ? '(' + i18n.ts.noName + ')' : palette.name }}</option>
</MkSelect>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['reaction', 'palette']">
<MkPreferenceContainer k="emojiPaletteForReaction">
<MkSelect v-model="emojiPaletteForReaction">
<template #label><SearchLabel>{{ i18n.ts._emojiPalette.paletteForReaction }}</SearchLabel></template>
<option key="-" :value="null">({{ i18n.ts.auto }})</option>
<option v-for="palette in prefer.r.emojiPalettes.value" :key="palette.id" :value="palette.id">{{ palette.name === '' ? '(' + i18n.ts.noName + ')' : palette.name }}</option>
</MkSelect>
</MkPreferenceContainer>
</SearchMarker>
</div>
</FormSection>
<SearchMarker :keywords="['emoji', 'picker', 'display']">
<FormSection>
<template #label><SearchLabel>{{ i18n.ts.emojiPickerDisplay }}</SearchLabel></template>
<div class="_gaps_m">
<SearchMarker :keywords="['emoji', 'picker', 'scale', 'size']">
<MkPreferenceContainer k="emojiPickerScale">
<MkRadios v-model="emojiPickerScale">
<template #label><SearchLabel>{{ i18n.ts.size }}</SearchLabel></template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
</MkRadios>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['emoji', 'picker', 'width', 'column', 'size']">
<MkPreferenceContainer k="emojiPickerWidth">
<MkRadios v-model="emojiPickerWidth">
<template #label><SearchLabel>{{ i18n.ts.numberOfColumn }}</SearchLabel></template>
<option :value="1">5</option>
<option :value="2">6</option>
<option :value="3">7</option>
<option :value="4">8</option>
<option :value="5">9</option>
</MkRadios>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['emoji', 'picker', 'height', 'size']">
<MkPreferenceContainer k="emojiPickerHeight">
<MkRadios v-model="emojiPickerHeight">
<template #label><SearchLabel>{{ i18n.ts.height }}</SearchLabel></template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
<option :value="4">{{ i18n.ts.large }}+</option>
</MkRadios>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['emoji', 'picker', 'style']">
<MkPreferenceContainer k="emojiPickerStyle">
<MkSelect v-model="emojiPickerStyle">
<template #label><SearchLabel>{{ i18n.ts.style }}</SearchLabel></template>
<template #caption>{{ i18n.ts.needReloadToApply }}</template>
<option value="auto">{{ i18n.ts.auto }}</option>
<option value="popup">{{ i18n.ts.popup }}</option>
<option value="drawer">{{ i18n.ts.drawer }}</option>
</MkSelect>
</MkPreferenceContainer>
</SearchMarker>
<MkButton @click="previewPicker"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
</div>
</FormSection>
</SearchMarker>
</div>
</SearchMarker>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { v4 as uuid } from 'uuid';
import XPalette from './emoji-palette.palette.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';
import MkSelect from '@/components/MkSelect.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import MkFolder from '@/components/MkFolder.vue';
import { prefer } from '@/preferences.js';
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import { emojiPicker } from '@/utility/emoji-picker.js';
const emojiPaletteForReaction = prefer.model('emojiPaletteForReaction');
const emojiPaletteForMain = prefer.model('emojiPaletteForMain');
const emojiPickerScale = prefer.model('emojiPickerScale');
const emojiPickerWidth = prefer.model('emojiPickerWidth');
const emojiPickerHeight = prefer.model('emojiPickerHeight');
const emojiPickerStyle = prefer.model('emojiPickerStyle');
const palettesSyncEnabled = ref(prefer.isSyncEnabled('emojiPalettes'));
function changePalettesSyncEnabled(value: boolean) {
if (value) {
prefer.enableSync('emojiPalettes').then((res) => {
if (res == null) return;
if (res.enabled) palettesSyncEnabled.value = true;
});
} else {
prefer.disableSync('emojiPalettes');
palettesSyncEnabled.value = false;
}
}
function addPalette() {
prefer.commit('emojiPalettes', [
...prefer.s.emojiPalettes,
{
id: uuid(),
name: '',
emojis: [],
},
]);
}
function updatePaletteEmojis(id: string, emojis: string[]) {
prefer.commit('emojiPalettes', prefer.s.emojiPalettes.map(palette => {
if (palette.id === id) {
return {
...palette,
emojis,
};
} else {
return palette;
}
}));
}
function updatePaletteName(id: string, name: string) {
prefer.commit('emojiPalettes', prefer.s.emojiPalettes.map(palette => {
if (palette.id === id) {
return {
...palette,
name,
};
} else {
return palette;
}
}));
}
function delPalette(id: string) {
if (prefer.s.emojiPalettes.length === 1) {
addPalette();
}
prefer.commit('emojiPalettes', prefer.s.emojiPalettes.filter(palette => palette.id !== id));
if (prefer.s.emojiPaletteForMain === id) {
prefer.commit('emojiPaletteForMain', null);
}
if (prefer.s.emojiPaletteForReaction === id) {
prefer.commit('emojiPaletteForReaction', null);
}
}
function getHTMLElement(ev: MouseEvent): HTMLElement {
const target = ev.currentTarget ?? ev.target;
return target as HTMLElement;
}
function previewPicker(ev: MouseEvent) {
emojiPicker.show(getHTMLElement(ev));
}
definePage(() => ({
title: i18n.ts.emojiPalette,
icon: 'ti ti-mood-happy',
}));
</script>
<style lang="scss" module>
.tab {
margin: calc(var(--MI-margin) / 2) 0;
padding: calc(var(--MI-margin) / 2) 0;
background: var(--MI_THEME-bg);
}
.emojis {
padding: 12px;
font-size: 1.1em;
}
.emojisItem {
display: inline-block;
padding: 8px;
cursor: move;
}
.emojisAdd {
display: inline-block;
padding: 8px;
}
.editorCaption {
font-size: 0.85em;
padding: 8px 0 0 0;
color: var(--MI_THEME-fgTransparentWeak);
}
</style>

View File

@@ -1,288 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps_m">
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-pin"></i></template>
<template #label>{{ i18n.ts.pinned }} ({{ i18n.ts.reaction }})</template>
<template #caption>{{ i18n.ts.pinnedEmojisForReactionSettingDescription }}</template>
<div class="_gaps">
<div>
<div v-panel style="border-radius: 6px;">
<Sortable
v-model="pinnedEmojisForReaction"
:class="$style.emojis"
:itemKey="item => item"
:animation="150"
:delay="100"
:delayOnTouchOnly="true"
>
<template #item="{element}">
<button class="_button" :class="$style.emojisItem" @click="removeReaction(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
</template>
<template #footer>
<button class="_button" :class="$style.emojisAdd" @click="chooseReaction">
<i class="ti ti-plus"></i>
</button>
</template>
</Sortable>
</div>
<div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div>
</div>
<div class="_buttons">
<MkButton inline @click="previewReaction"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
<MkButton inline danger @click="setDefaultReaction"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
<MkButton inline danger @click="overwriteFromPinnedEmojis"><i class="ti ti-copy"></i> {{ i18n.ts.overwriteFromPinnedEmojis }}</MkButton>
</div>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-pin"></i></template>
<template #label>{{ i18n.ts.pinned }} ({{ i18n.ts.general }})</template>
<template #caption>{{ i18n.ts.pinnedEmojisSettingDescription }}</template>
<div class="_gaps">
<div>
<div v-panel style="border-radius: 6px;">
<Sortable
v-model="pinnedEmojis"
:class="$style.emojis"
:itemKey="item => item"
:animation="150"
:delay="100"
:delayOnTouchOnly="true"
>
<template #item="{element}">
<button class="_button" :class="$style.emojisItem" @click="removeEmoji(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
</template>
<template #footer>
<button class="_button" :class="$style.emojisAdd" @click="chooseEmoji">
<i class="ti ti-plus"></i>
</button>
</template>
</Sortable>
</div>
<div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div>
</div>
<div class="_buttons">
<MkButton inline @click="previewEmoji"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
<MkButton inline danger @click="setDefaultEmoji"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
<MkButton inline danger @click="overwriteFromPinnedEmojisForReaction"><i class="ti ti-copy"></i> {{ i18n.ts.overwriteFromPinnedEmojisForReaction }}</MkButton>
</div>
</div>
</MkFolder>
<FormSection>
<template #label>{{ i18n.ts.emojiPickerDisplay }}</template>
<div class="_gaps_m">
<MkPreferenceContainer k="emojiPickerScale">
<MkRadios v-model="emojiPickerScale">
<template #label>{{ i18n.ts.size }}</template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
</MkRadios>
</MkPreferenceContainer>
<MkPreferenceContainer k="emojiPickerWidth">
<MkRadios v-model="emojiPickerWidth">
<template #label>{{ i18n.ts.numberOfColumn }}</template>
<option :value="1">5</option>
<option :value="2">6</option>
<option :value="3">7</option>
<option :value="4">8</option>
<option :value="5">9</option>
</MkRadios>
</MkPreferenceContainer>
<MkPreferenceContainer k="emojiPickerHeight">
<MkRadios v-model="emojiPickerHeight">
<template #label>{{ i18n.ts.height }}</template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
<option :value="4">{{ i18n.ts.large }}+</option>
</MkRadios>
</MkPreferenceContainer>
<MkPreferenceContainer k="emojiPickerStyle">
<MkSelect v-model="emojiPickerStyle">
<template #label>{{ i18n.ts.style }}</template>
<template #caption>{{ i18n.ts.needReloadToApply }}</template>
<option value="auto">{{ i18n.ts.auto }}</option>
<option value="popup">{{ i18n.ts.popup }}</option>
<option value="drawer">{{ i18n.ts.drawer }}</option>
</MkSelect>
</MkPreferenceContainer>
</div>
</FormSection>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import Sortable from 'vuedraggable';
import type { Ref } from 'vue';
import MkRadios from '@/components/MkRadios.vue';
import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';
import MkSelect from '@/components/MkSelect.vue';
import * as os from '@/os.js';
import { store } from '@/store.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { deepClone } from '@/utility/clone.js';
import { reactionPicker } from '@/utility/reaction-picker.js';
import { emojiPicker } from '@/utility/emoji-picker.js';
import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue';
import MkEmoji from '@/components/global/MkEmoji.vue';
import MkFolder from '@/components/MkFolder.vue';
import { prefer } from '@/preferences.js';
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
const pinnedEmojisForReaction: Ref<string[]> = ref(deepClone(store.s.reactions));
const pinnedEmojis: Ref<string[]> = ref(deepClone(store.s.pinnedEmojis));
const emojiPickerScale = prefer.model('emojiPickerScale');
const emojiPickerWidth = prefer.model('emojiPickerWidth');
const emojiPickerHeight = prefer.model('emojiPickerHeight');
const emojiPickerStyle = prefer.model('emojiPickerStyle');
const removeReaction = (reaction: string, ev: MouseEvent) => remove(pinnedEmojisForReaction, reaction, ev);
const chooseReaction = (ev: MouseEvent) => pickEmoji(pinnedEmojisForReaction, ev);
const setDefaultReaction = () => setDefault(pinnedEmojisForReaction);
const removeEmoji = (reaction: string, ev: MouseEvent) => remove(pinnedEmojis, reaction, ev);
const chooseEmoji = (ev: MouseEvent) => pickEmoji(pinnedEmojis, ev);
const setDefaultEmoji = () => setDefault(pinnedEmojis);
function previewReaction(ev: MouseEvent) {
reactionPicker.show(getHTMLElement(ev), null);
}
function previewEmoji(ev: MouseEvent) {
emojiPicker.show(getHTMLElement(ev));
}
async function overwriteFromPinnedEmojis() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.overwriteContentConfirm,
});
if (canceled) {
return;
}
pinnedEmojisForReaction.value = [...pinnedEmojis.value];
}
async function overwriteFromPinnedEmojisForReaction() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.overwriteContentConfirm,
});
if (canceled) {
return;
}
pinnedEmojis.value = [...pinnedEmojisForReaction.value];
}
function remove(itemsRef: Ref<string[]>, reaction: string, ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts.remove,
action: () => {
itemsRef.value = itemsRef.value.filter(x => x !== reaction);
},
}], getHTMLElement(ev));
}
async function setDefault(itemsRef: Ref<string[]>) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.resetAreYouSure,
});
if (canceled) return;
itemsRef.value = deepClone(store.def.reactions.default);
}
async function pickEmoji(itemsRef: Ref<string[]>, ev: MouseEvent) {
os.pickEmoji(getHTMLElement(ev), {
showPinned: false,
}).then(it => {
const emoji = it;
if (!itemsRef.value.includes(emoji)) {
itemsRef.value.push(emoji);
}
});
}
function getHTMLElement(ev: MouseEvent): HTMLElement {
const target = ev.currentTarget ?? ev.target;
return target as HTMLElement;
}
watch(pinnedEmojisForReaction, () => {
store.set('reactions', pinnedEmojisForReaction.value);
}, {
deep: true,
});
watch(pinnedEmojis, () => {
store.set('pinnedEmojis', pinnedEmojis.value);
}, {
deep: true,
});
definePage(() => ({
title: i18n.ts.emojiPicker,
icon: 'ti ti-mood-happy',
}));
</script>
<style lang="scss" module>
.tab {
margin: calc(var(--MI-margin) / 2) 0;
padding: calc(var(--MI-margin) / 2) 0;
background: var(--MI_THEME-bg);
}
.emojis {
padding: 12px;
font-size: 1.1em;
}
.emojisItem {
display: inline-block;
padding: 8px;
cursor: move;
}
.emojisAdd {
display: inline-block;
padding: 8px;
}
.editorCaption {
font-size: 0.85em;
padding: 8px 0 0 0;
color: var(--MI_THEME-fgTransparentWeak);
}
</style>

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

@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div ref="el" class="vvcocwet" :class="{ wide: !narrow }">
<div class="body">
<div v-if="!narrow || currentPage?.route.name == null" class="nav">
<div class="baaadecd">
<div class="_gaps_s">
<MkInfo v-if="emailNotConfigured" warn class="info">{{ i18n.ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
<MkInfo v-if="!store.r.enablePreferencesAutoCloudBackup.value && store.r.showPreferencesAutoCloudBackupSuggestion.value" class="info">
<div>{{ i18n.ts._preferencesBackup.autoPreferencesBackupIsNotEnabledForThisDevice }}</div>
@@ -86,16 +86,6 @@ const menuDef = computed<SuperMenuDef[]>(() => [{
text: i18n.ts.privacy,
to: '/settings/privacy',
active: currentPage.value?.route.name === 'privacy',
}, {
icon: 'ti ti-mood-happy',
text: i18n.ts.emojiPicker,
to: '/settings/emoji-picker',
active: currentPage.value?.route.name === 'emojiPicker',
}, {
icon: 'ti ti-cloud',
text: i18n.ts.drive,
to: '/settings/drive',
active: currentPage.value?.route.name === 'drive',
}, {
icon: 'ti ti-bell',
text: i18n.ts.notifications,
@@ -124,10 +114,10 @@ const menuDef = computed<SuperMenuDef[]>(() => [{
to: '/settings/theme',
active: currentPage.value?.route.name === 'theme',
}, {
icon: 'ti ti-device-desktop',
text: i18n.ts.appearance,
to: '/settings/appearance',
active: currentPage.value?.route.name === 'appearance',
icon: 'ti ti-mood-happy',
text: i18n.ts.emojiPalette,
to: '/settings/emoji-palette',
active: currentPage.value?.route.name === 'emoji-palette',
}, {
icon: 'ti ti-music',
text: i18n.ts.sounds,
@@ -146,6 +136,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 +151,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,
@@ -259,30 +249,6 @@ definePage(() => INFO.value);
<style lang="scss" scoped>
.vvcocwet {
> .body {
> .nav {
.baaadecd {
> .info {
margin: 16px 0;
}
> .accounts {
> .avatar {
display: block;
width: 50px;
height: 50px;
margin: 8px auto 16px auto;
}
}
}
}
> .main {
.bkzroven {
}
}
}
&.wide {
> .body {
display: flex;

View File

@@ -6,6 +6,11 @@ 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">
<MkFeatureBanner icon="/client-assets/prohibited_3d.png" color="#ff2600">
<SearchKeyword>{{ i18n.ts._settings.muteAndBlockBanner }}</SearchKeyword>
</MkFeatureBanner>
<div class="_gaps_s">
<SearchMarker
:label="i18n.ts.wordMute"
:keywords="['note', 'word', 'soft', 'mute', 'hide']"
@@ -168,6 +173,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</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

@@ -5,6 +5,15 @@ 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="['general']">
<MkFolder :defaultOpen="true">
<template #label><SearchLabel>{{ i18n.ts.general }}</SearchLabel></template>
<div class="_gaps_m">
<SearchMarker :keywords="['language']">
<MkSelect v-model="lang">
@@ -30,7 +39,86 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkRadios>
</SearchMarker>
<FormSection>
<div class="_gaps_s">
<SearchMarker :keywords="['blur']">
<MkPreferenceContainer k="useBlurEffect">
<MkSwitch v-model="useBlurEffect">
<template #label><SearchLabel>{{ i18n.ts.useBlurEffect }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['blur', 'modal']">
<MkPreferenceContainer k="useBlurEffectForModal">
<MkSwitch v-model="useBlurEffectForModal">
<template #label><SearchLabel>{{ i18n.ts.useBlurEffectForModal }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['avatar', 'icon', 'decoration', 'show']">
<MkPreferenceContainer k="showAvatarDecorations">
<MkSwitch v-model="showAvatarDecorations">
<template #label><SearchLabel>{{ i18n.ts.showAvatarDecorations }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['follow', 'confirm', 'always']">
<MkPreferenceContainer k="alwaysConfirmFollow">
<MkSwitch v-model="alwaysConfirmFollow">
<template #label><SearchLabel>{{ i18n.ts.alwaysConfirmFollow }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['highlight', 'sensitive', 'nsfw', 'image', 'photo', 'picture', 'media', 'thumbnail']">
<MkPreferenceContainer k="highlightSensitiveMedia">
<MkSwitch v-model="highlightSensitiveMedia">
<template #label><SearchLabel>{{ i18n.ts.highlightSensitiveMedia }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['sensitive', 'nsfw', 'media', 'image', 'photo', 'picture', 'attachment', 'confirm']">
<MkPreferenceContainer k="confirmWhenRevealingSensitiveMedia">
<MkSwitch v-model="confirmWhenRevealingSensitiveMedia">
<template #label><SearchLabel>{{ i18n.ts.confirmWhenRevealingSensitiveMedia }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
</div>
<SearchMarker :keywords="['emoji', 'style', 'native', 'system', 'fluent', 'twemoji']">
<MkPreferenceContainer k="emojiStyle">
<div>
<MkRadios v-model="emojiStyle">
<template #label><SearchLabel>{{ i18n.ts.emojiStyle }}</SearchLabel></template>
<option value="native">{{ i18n.ts.native }}</option>
<option value="fluentEmoji">Fluent Emoji</option>
<option value="twemoji">Twemoji</option>
</MkRadios>
<div style="margin: 8px 0 0 0; font-size: 1.5em;"><Mfm :key="emojiStyle" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div>
</div>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['pinned', 'list']">
<MkFolder>
<template #label><SearchLabel>{{ i18n.ts.pinnedList }}</SearchLabel></template>
<!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ -->
<MkButton v-if="prefer.r.pinnedUserLists.value.length === 0" @click="setPinnedList()">{{ i18n.ts.add }}</MkButton>
<MkButton v-else danger @click="removePinnedList()"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
</MkFolder>
</SearchMarker>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['timeline']">
<MkFolder :defaultOpen="true">
<template #label><SearchLabel>{{ i18n.ts.timeline }}</SearchLabel></template>
<div class="_gaps_s">
<SearchMarker :keywords="['post', 'form', 'timeline']">
<MkPreferenceContainer k="showFixedPostForm">
@@ -48,70 +136,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['pinned', 'list']">
<MkFolder>
<template #label><SearchLabel>{{ i18n.ts.pinnedList }}</SearchLabel></template>
<!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ -->
<MkButton v-if="prefer.r.pinnedUserLists.value.length === 0" @click="setPinnedList()">{{ i18n.ts.add }}</MkButton>
<MkButton v-else danger @click="removePinnedList()"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['mfm', 'enable', 'show', 'advanced', 'picker', 'form', 'function', 'fn']">
<MkPreferenceContainer k="enableQuickAddMfmFunction">
<MkSwitch v-model="enableQuickAddMfmFunction">
<template #label><SearchLabel>{{ i18n.ts.enableQuickAddMfmFunction }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
</div>
</FormSection>
<FormSection>
<div class="_gaps_m">
<SearchMarker :keywords="['remember', 'keep', 'note', 'visibility']">
<MkPreferenceContainer k="rememberNoteVisibility">
<MkSwitch v-model="rememberNoteVisibility">
<template #label><SearchLabel>{{ i18n.ts.rememberNoteVisibility }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['default', 'note', 'visibility']">
<MkDisableSection :disabled="rememberNoteVisibility">
<MkFolder>
<template #label><SearchLabel>{{ i18n.ts.defaultNoteVisibility }}</SearchLabel></template>
<template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template>
<template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</template>
<template v-else-if="defaultNoteVisibility === 'followers'" #suffix>{{ i18n.ts._visibility.followers }}</template>
<template v-else-if="defaultNoteVisibility === 'specified'" #suffix>{{ i18n.ts._visibility.specified }}</template>
<div class="_gaps_m">
<MkPreferenceContainer k="defaultNoteVisibility">
<MkSelect v-model="defaultNoteVisibility">
<option value="public">{{ i18n.ts._visibility.public }}</option>
<option value="home">{{ i18n.ts._visibility.home }}</option>
<option value="followers">{{ i18n.ts._visibility.followers }}</option>
<option value="specified">{{ i18n.ts._visibility.specified }}</option>
</MkSelect>
</MkPreferenceContainer>
<MkPreferenceContainer k="defaultNoteLocalOnly">
<MkSwitch v-model="defaultNoteLocalOnly">{{ i18n.ts._visibility.disableFederation }}</MkSwitch>
</MkPreferenceContainer>
</div>
</MkFolder>
</MkDisableSection>
</SearchMarker>
</div>
</FormSection>
<SearchMarker :keywords="['note']">
<FormSection>
<template #label><SearchLabel>{{ i18n.ts.note }}</SearchLabel></template>
<div class="_gaps_m">
<div class="_gaps_s">
<SearchMarker :keywords="['renote']">
<MkPreferenceContainer k="collapseRenotes">
<MkSwitch v-model="collapseRenotes">
@@ -121,6 +145,39 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['note', 'timeline', 'gap']">
<MkPreferenceContainer k="showGapBetweenNotesInTimeline">
<MkSwitch v-model="showGapBetweenNotesInTimeline">
<template #label><SearchLabel>{{ i18n.ts.showGapBetweenNotesInTimeline }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['load', 'auto', 'more']">
<MkPreferenceContainer k="enableInfiniteScroll">
<MkSwitch v-model="enableInfiniteScroll">
<template #label><SearchLabel>{{ i18n.ts.enableInfiniteScroll }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['disable', 'streaming', 'timeline']">
<MkPreferenceContainer k="disableStreamingTimeline">
<MkSwitch v-model="disableStreamingTimeline">
<template #label><SearchLabel>{{ i18n.ts.disableStreamingTimeline }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['note']">
<MkFolder :defaultOpen="true">
<template #label><SearchLabel>{{ i18n.ts.note }}</SearchLabel></template>
<div class="_gaps_m">
<div class="_gaps_s">
<SearchMarker :keywords="['hover', 'show', 'footer', 'action']">
<MkPreferenceContainer k="showNoteActionsOnlyHover">
<MkSwitch v-model="showNoteActionsOnlyHover">
@@ -153,6 +210,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['reaction', 'confirm']">
<MkPreferenceContainer k="confirmOnReact">
<MkSwitch v-model="confirmOnReact">
<template #label><SearchLabel>{{ i18n.ts.confirmOnReact }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['image', 'photo', 'picture', 'media', 'thumbnail', 'quality', 'raw', 'attachment']">
<MkPreferenceContainer k="loadRawImages">
<MkSwitch v-model="loadRawImages">
@@ -160,40 +225,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
</div>
</div>
</FormSection>
</SearchMarker>
<SearchMarker :keywords="['notification']">
<FormSection>
<template #label><SearchLabel>{{ i18n.ts.notifications }}</SearchLabel></template>
<div class="_gaps_m">
<SearchMarker :keywords="['group']">
<MkPreferenceContainer k="useGroupedNotifications">
<MkSwitch v-model="useGroupedNotifications">
<template #label><SearchLabel>{{ i18n.ts.useGroupedNotifications }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
</div>
</FormSection>
</SearchMarker>
<SearchMarker :keywords="['behavior']">
<FormSection>
<template #label><SearchLabel>{{ i18n.ts.behavior }}</SearchLabel></template>
<div class="_gaps_m">
<div class="_gaps_s">
<SearchMarker :keywords="['image', 'photo', 'picture', 'media', 'thumbnail', 'new', 'tab']">
<MkPreferenceContainer k="imageNewTab">
<MkSwitch v-model="imageNewTab">
<template #label><SearchLabel>{{ i18n.ts.openImageInNewTab }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['reaction', 'picker', 'contextmenu', 'open']">
<MkPreferenceContainer k="useReactionPickerForContextMenu">
@@ -202,47 +233,70 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
</div>
<SearchMarker :keywords="['load', 'auto', 'more']">
<MkPreferenceContainer k="enableInfiniteScroll">
<MkSwitch v-model="enableInfiniteScroll">
<template #label><SearchLabel>{{ i18n.ts.enableInfiniteScroll }}</SearchLabel></template>
<SearchMarker :keywords="['reaction', 'size', 'scale', 'display']">
<MkPreferenceContainer k="reactionsDisplaySize">
<MkRadios v-model="reactionsDisplaySize">
<template #label><SearchLabel>{{ i18n.ts.reactionsDisplaySize }}</SearchLabel></template>
<option value="small">{{ i18n.ts.small }}</option>
<option value="medium">{{ i18n.ts.medium }}</option>
<option value="large">{{ i18n.ts.large }}</option>
</MkRadios>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['reaction', 'size', 'scale', 'display', 'width', 'limit']">
<MkPreferenceContainer k="limitWidthOfReaction">
<MkSwitch v-model="limitWidthOfReaction">
<template #label><SearchLabel>{{ i18n.ts.limitWidthOfReaction }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['disable', 'streaming', 'timeline']">
<MkPreferenceContainer k="disableStreamingTimeline">
<MkSwitch v-model="disableStreamingTimeline">
<template #label><SearchLabel>{{ i18n.ts.disableStreamingTimeline }}</SearchLabel></template>
</MkSwitch>
<SearchMarker :keywords="['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'list', 'size', 'height']">
<MkPreferenceContainer k="mediaListWithOneImageAppearance">
<MkRadios v-model="mediaListWithOneImageAppearance">
<template #label><SearchLabel>{{ i18n.ts.mediaListWithOneImageAppearance }}</SearchLabel></template>
<option value="expand">{{ i18n.ts.default }}</option>
<option value="16_9">{{ i18n.tsx.limitTo({ x: '16:9' }) }}</option>
<option value="1_1">{{ i18n.tsx.limitTo({ x: '1:1' }) }}</option>
<option value="2_3">{{ i18n.tsx.limitTo({ x: '2:3' }) }}</option>
</MkRadios>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['follow', 'confirm', 'always']">
<MkPreferenceContainer k="alwaysConfirmFollow">
<MkSwitch v-model="alwaysConfirmFollow">
<template #label><SearchLabel>{{ i18n.ts.alwaysConfirmFollow }}</SearchLabel></template>
</MkSwitch>
<SearchMarker :keywords="['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation']">
<MkPreferenceContainer k="instanceTicker">
<MkSelect v-if="instance.federation !== 'none'" v-model="instanceTicker">
<template #label><SearchLabel>{{ i18n.ts.instanceTicker }}</SearchLabel></template>
<option value="none">{{ i18n.ts._instanceTicker.none }}</option>
<option value="remote">{{ i18n.ts._instanceTicker.remote }}</option>
<option value="always">{{ i18n.ts._instanceTicker.always }}</option>
</MkSelect>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['sensitive', 'nsfw', 'media', 'image', 'photo', 'picture', 'attachment', 'confirm']">
<MkPreferenceContainer k="confirmWhenRevealingSensitiveMedia">
<MkSwitch v-model="confirmWhenRevealingSensitiveMedia">
<template #label><SearchLabel>{{ i18n.ts.confirmWhenRevealingSensitiveMedia }}</SearchLabel></template>
</MkSwitch>
<SearchMarker :keywords="['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'nsfw', 'sensitive', 'display', 'show', 'hide', 'visibility']">
<MkPreferenceContainer k="nsfw">
<MkSelect v-model="nsfw">
<template #label><SearchLabel>{{ i18n.ts.displayOfSensitiveMedia }}</SearchLabel></template>
<option value="respect">{{ i18n.ts._displayOfSensitiveMedia.respect }}</option>
<option value="ignore">{{ i18n.ts._displayOfSensitiveMedia.ignore }}</option>
<option value="force">{{ i18n.ts._displayOfSensitiveMedia.force }}</option>
</MkSelect>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['reaction', 'confirm']">
<MkPreferenceContainer k="confirmOnReact">
<MkSwitch v-model="confirmOnReact">
<template #label><SearchLabel>{{ i18n.ts.confirmOnReact }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['post', 'form']">
<MkFolder :defaultOpen="true">
<template #label><SearchLabel>{{ i18n.ts.postForm }}</SearchLabel></template>
<div class="_gaps_m">
<div class="_gaps_s">
<SearchMarker :keywords="['remember', 'keep', 'note', 'cw']">
<MkPreferenceContainer k="keepCw">
<MkSwitch v-model="keepCw">
@@ -250,6 +304,123 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['remember', 'keep', 'note', 'visibility']">
<MkPreferenceContainer k="rememberNoteVisibility">
<MkSwitch v-model="rememberNoteVisibility">
<template #label><SearchLabel>{{ i18n.ts.rememberNoteVisibility }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['mfm', 'enable', 'show', 'advanced', 'picker', 'form', 'function', 'fn']">
<MkPreferenceContainer k="enableQuickAddMfmFunction">
<MkSwitch v-model="enableQuickAddMfmFunction">
<template #label><SearchLabel>{{ i18n.ts.enableQuickAddMfmFunction }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
</div>
<SearchMarker :keywords="['default', 'note', 'visibility']">
<MkDisableSection :disabled="rememberNoteVisibility">
<MkFolder>
<template #label><SearchLabel>{{ i18n.ts.defaultNoteVisibility }}</SearchLabel></template>
<template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template>
<template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</template>
<template v-else-if="defaultNoteVisibility === 'followers'" #suffix>{{ i18n.ts._visibility.followers }}</template>
<template v-else-if="defaultNoteVisibility === 'specified'" #suffix>{{ i18n.ts._visibility.specified }}</template>
<div class="_gaps_m">
<MkPreferenceContainer k="defaultNoteVisibility">
<MkSelect v-model="defaultNoteVisibility">
<option value="public">{{ i18n.ts._visibility.public }}</option>
<option value="home">{{ i18n.ts._visibility.home }}</option>
<option value="followers">{{ i18n.ts._visibility.followers }}</option>
<option value="specified">{{ i18n.ts._visibility.specified }}</option>
</MkSelect>
</MkPreferenceContainer>
<MkPreferenceContainer k="defaultNoteLocalOnly">
<MkSwitch v-model="defaultNoteLocalOnly">{{ i18n.ts._visibility.disableFederation }}</MkSwitch>
</MkPreferenceContainer>
</div>
</MkFolder>
</MkDisableSection>
</SearchMarker>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['notification']">
<MkFolder :defaultOpen="true">
<template #label><SearchLabel>{{ i18n.ts.notifications }}</SearchLabel></template>
<div class="_gaps_m">
<SearchMarker :keywords="['group']">
<MkPreferenceContainer k="useGroupedNotifications">
<MkSwitch v-model="useGroupedNotifications">
<template #label><SearchLabel>{{ i18n.ts.useGroupedNotifications }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['position']">
<MkPreferenceContainer k="notificationPosition">
<MkRadios v-model="notificationPosition">
<template #label><SearchLabel>{{ i18n.ts.position }}</SearchLabel></template>
<option value="leftTop"><i class="ti ti-align-box-left-top"></i> {{ i18n.ts.leftTop }}</option>
<option value="rightTop"><i class="ti ti-align-box-right-top"></i> {{ i18n.ts.rightTop }}</option>
<option value="leftBottom"><i class="ti ti-align-box-left-bottom"></i> {{ i18n.ts.leftBottom }}</option>
<option value="rightBottom"><i class="ti ti-align-box-right-bottom"></i> {{ i18n.ts.rightBottom }}</option>
</MkRadios>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['stack', 'axis', 'direction']">
<MkPreferenceContainer k="notificationStackAxis">
<MkRadios v-model="notificationStackAxis">
<template #label><SearchLabel>{{ i18n.ts.stackAxis }}</SearchLabel></template>
<option value="vertical"><i class="ti ti-carousel-vertical"></i> {{ i18n.ts.vertical }}</option>
<option value="horizontal"><i class="ti ti-carousel-horizontal"></i> {{ i18n.ts.horizontal }}</option>
</MkRadios>
</MkPreferenceContainer>
</SearchMarker>
<MkButton @click="testNotification">{{ i18n.ts._notification.checkNotificationBehavior }}</MkButton>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['other']">
<MkFolder :defaultOpen="true">
<template #label><SearchLabel>{{ i18n.ts.other }}</SearchLabel></template>
<div class="_gaps_m">
<div class="_gaps_s">
<SearchMarker :keywords="['avatar', 'icon', 'square']">
<MkPreferenceContainer k="squareAvatars">
<MkSwitch v-model="squareAvatars">
<template #label><SearchLabel>{{ i18n.ts.squareAvatars }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['effect', 'show']">
<MkPreferenceContainer k="enableSeasonalScreenEffect">
<MkSwitch v-model="enableSeasonalScreenEffect">
<template #label><SearchLabel>{{ i18n.ts.seasonalScreenEffect }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['image', 'photo', 'picture', 'media', 'thumbnail', 'new', 'tab']">
<MkPreferenceContainer k="imageNewTab">
<MkSwitch v-model="imageNewTab">
<template #label><SearchLabel>{{ i18n.ts.openImageInNewTab }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
</div>
<SearchMarker :keywords="['server', 'disconnect', 'reconnect', 'reload', 'streaming']">
@@ -272,7 +443,41 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :label="i18n.ts.dataSaver" :keywords="['datasaver']">
<SearchMarker :keywords="['ad', 'show']">
<MkPreferenceContainer k="forceShowAds">
<MkSwitch v-model="forceShowAds">
<template #label><SearchLabel>{{ i18n.ts.forceShowAds }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker>
<MkPreferenceContainer k="hemisphere">
<MkRadios v-model="hemisphere">
<template #label><SearchLabel>{{ i18n.ts.hemisphere }}</SearchLabel></template>
<option value="N">{{ i18n.ts._hemisphere.N }}</option>
<option value="S">{{ i18n.ts._hemisphere.S }}</option>
<template #caption>{{ i18n.ts._hemisphere.caption }}</template>
</MkRadios>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['emoji', 'dictionary', 'additional', 'extra']">
<MkFolder>
<template #label><SearchLabel>{{ i18n.ts.additionalEmojiDictionary }}</SearchLabel></template>
<div class="_buttons">
<template v-for="lang in emojiIndexLangs" :key="lang">
<MkButton v-if="store.r.additionalUnicodeEmojiIndexes.value[lang]" danger @click="removeEmojiIndex(lang)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }} ({{ getEmojiIndexLangName(lang) }})</MkButton>
<MkButton v-else @click="downloadEmojiIndex(lang)"><i class="ti ti-download"></i> {{ getEmojiIndexLangName(lang) }}{{ store.r.additionalUnicodeEmojiIndexes.value[lang] ? ` (${ i18n.ts.installed })` : '' }}</MkButton>
</template>
</div>
</MkFolder>
</SearchMarker>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['datasaver']">
<MkFolder>
<template #label><SearchLabel>{{ i18n.ts.dataSaver }}</SearchLabel></template>
@@ -304,57 +509,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
</SearchMarker>
</div>
</FormSection>
</SearchMarker>
<SearchMarker>
<FormSection>
<template #label><SearchLabel>{{ i18n.ts.other }}</SearchLabel></template>
<div class="_gaps">
<SearchMarker :keywords="['ad', 'show']">
<MkPreferenceContainer k="forceShowAds">
<MkSwitch v-model="forceShowAds">
<template #label><SearchLabel>{{ i18n.ts.forceShowAds }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker>
<MkPreferenceContainer k="hemisphere">
<MkRadios v-model="hemisphere">
<template #label><SearchLabel>{{ i18n.ts.hemisphere }}</SearchLabel></template>
<option value="N">{{ i18n.ts._hemisphere.N }}</option>
<option value="S">{{ i18n.ts._hemisphere.S }}</option>
<template #caption>{{ i18n.ts._hemisphere.caption }}</template>
</MkRadios>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['emoji', 'dictionary', 'additional', 'extra']">
<MkFolder>
<template #label><SearchLabel>{{ i18n.ts.additionalEmojiDictionary }}</SearchLabel></template>
<div class="_buttons">
<template v-for="lang in emojiIndexLangs" :key="lang">
<MkButton v-if="store.r.additionalUnicodeEmojiIndexes.value[lang]" danger @click="removeEmojiIndex(lang)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }} ({{ getEmojiIndexLangName(lang) }})</MkButton>
<MkButton v-else @click="downloadEmojiIndex(lang)"><i class="ti ti-download"></i> {{ getEmojiIndexLangName(lang) }}{{ store.r.additionalUnicodeEmojiIndexes.value[lang] ? ` (${ i18n.ts.installed })` : '' }}</MkButton>
</template>
</div>
</MkFolder>
</SearchMarker>
<FormLink to="/settings/navbar">{{ i18n.ts.navbar }}</FormLink>
<FormLink to="/settings/statusbar">{{ i18n.ts.statusbar }}</FormLink>
</div>
</FormSection>
</SearchMarker>
<FormSection>
<div class="_gaps">
<FormLink to="/settings/deck">{{ i18n.ts.deck }}</FormLink>
</div>
</FormSection>
<FormLink to="/settings/custom-css"><template #icon><i class="ti ti-code"></i></template>{{ i18n.ts.customCss }}</FormLink>
</div>
</SearchMarker>
</template>
@@ -362,6 +521,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { langs } from '@@/js/config.js';
import * as Misskey from 'misskey-js';
import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkRadios from '@/components/MkRadios.vue';
@@ -381,6 +541,10 @@ import { definePage } from '@/page.js';
import { miLocalStorage } from '@/local-storage.js';
import { prefer } from '@/preferences.js';
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
import { globalEvents } from '@/events.js';
import { claimAchievement } from '@/utility/achievements.js';
import { instance } from '@/instance.js';
const lang = ref(miLocalStorage.getItem('lang'));
const dataSaver = ref(prefer.s.dataSaver);
@@ -408,10 +572,24 @@ const useGroupedNotifications = prefer.model('useGroupedNotifications');
const alwaysConfirmFollow = prefer.model('alwaysConfirmFollow');
const confirmWhenRevealingSensitiveMedia = prefer.model('confirmWhenRevealingSensitiveMedia');
const confirmOnReact = prefer.model('confirmOnReact');
const contextMenu = prefer.model('contextMenu');
const defaultNoteVisibility = prefer.model('defaultNoteVisibility');
const defaultNoteLocalOnly = prefer.model('defaultNoteLocalOnly');
const rememberNoteVisibility = prefer.model('rememberNoteVisibility');
const showGapBetweenNotesInTimeline = prefer.model('showGapBetweenNotesInTimeline');
const notificationPosition = prefer.model('notificationPosition');
const notificationStackAxis = prefer.model('notificationStackAxis');
const instanceTicker = prefer.model('instanceTicker');
const highlightSensitiveMedia = prefer.model('highlightSensitiveMedia');
const mediaListWithOneImageAppearance = prefer.model('mediaListWithOneImageAppearance');
const reactionsDisplaySize = prefer.model('reactionsDisplaySize');
const limitWidthOfReaction = prefer.model('limitWidthOfReaction');
const squareAvatars = prefer.model('squareAvatars');
const enableSeasonalScreenEffect = prefer.model('enableSeasonalScreenEffect');
const showAvatarDecorations = prefer.model('showAvatarDecorations');
const nsfw = prefer.model('nsfw');
const emojiStyle = prefer.model('emojiStyle');
const useBlurEffectForModal = prefer.model('useBlurEffectForModal');
const useBlurEffect = prefer.model('useBlurEffect');
watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string);
@@ -428,7 +606,17 @@ watch([
disableStreamingTimeline,
alwaysConfirmFollow,
confirmWhenRevealingSensitiveMedia,
contextMenu,
showGapBetweenNotesInTimeline,
mediaListWithOneImageAppearance,
reactionsDisplaySize,
limitWidthOfReaction,
mediaListWithOneImageAppearance,
reactionsDisplaySize,
limitWidthOfReaction,
instanceTicker,
squareAvatars,
highlightSensitiveMedia,
enableSeasonalScreenEffect,
], async () => {
await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
});
@@ -517,6 +705,33 @@ watch(dataSaver, (to) => {
deep: true,
});
let smashCount = 0;
let smashTimer: number | null = null;
function testNotification(): void {
const notification: Misskey.entities.Notification = {
id: Math.random().toString(),
createdAt: new Date().toUTCString(),
isRead: false,
type: 'test',
};
globalEvents.emit('clientNotification', notification);
// セルフ通知破壊 実績関連
smashCount++;
if (smashCount >= 10) {
claimAchievement('smashTestNotificationButton');
smashCount = 0;
}
if (smashTimer) {
clearTimeout(smashTimer);
}
smashTimer = window.setTimeout(() => {
smashCount = 0;
}, 300);
}
const headerActions = computed(() => []);
const headerTabs = computed(() => []);

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

@@ -92,7 +92,7 @@ import * as os from '@/os.js';
import { store } from '@/store.js';
import { addTheme } from '@/theme-store.js';
import { i18n } from '@/i18n.js';
import { useLeaveGuard } from '@/utility/use-leave-guard.js';
import { useLeaveGuard } from '@/use/use-leave-guard.js';
import { definePage } from '@/page.js';
import { prefer } from '@/preferences.js';

View File

@@ -21,7 +21,7 @@ import * as Misskey from 'misskey-js';
import gradient from 'chartjs-plugin-gradient';
import { misskeyApi } from '@/utility/misskey-api.js';
import { store } from '@/store.js';
import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js';
import { initChart } from '@/utility/init-chart.js';
import { chartLegend } from '@/utility/chart-legend.js';

View File

@@ -21,7 +21,7 @@ import * as Misskey from 'misskey-js';
import gradient from 'chartjs-plugin-gradient';
import { misskeyApi } from '@/utility/misskey-api.js';
import { store } from '@/store.js';
import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js';
import { initChart } from '@/utility/init-chart.js';
import { chartLegend } from '@/utility/chart-legend.js';

View File

@@ -21,7 +21,7 @@ import * as Misskey from 'misskey-js';
import gradient from 'chartjs-plugin-gradient';
import { misskeyApi } from '@/utility/misskey-api.js';
import { store } from '@/store.js';
import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js';
import { initChart } from '@/utility/init-chart.js';
import { chartLegend } from '@/utility/chart-legend.js';

View File

@@ -4,35 +4,107 @@
*/
import { v4 as uuid } from 'uuid';
import type { PreferencesProfile } from '@/preferences/profile.js';
import type { PreferencesProfile, StorageProvider } from '@/preferences/manager.js';
import { cloudBackup } from '@/preferences/utility.js';
import { miLocalStorage } from '@/local-storage.js';
import { ProfileManager } from '@/preferences/profile.js';
import { isSameScope, PreferencesManager } from '@/preferences/manager.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 createPrefManager(storageProvider: StorageProvider) {
let profile: PreferencesProfile;
const savedProfileRaw = miLocalStorage.getItem('preferences');
if (savedProfileRaw == null) {
profile = ProfileManager.newProfile();
profile = PreferencesManager.newProfile();
miLocalStorage.setItem('preferences', JSON.stringify(profile));
} else {
profile = ProfileManager.normalizeProfile(JSON.parse(savedProfileRaw));
profile = PreferencesManager.normalizeProfile(JSON.parse(savedProfileRaw));
}
return new ProfileManager(profile);
return new PreferencesManager(profile, storageProvider);
}
export const profileManager = createProfileManager();
profileManager.addListener('updated', ({ profile: p }) => {
miLocalStorage.setItem('preferences', JSON.stringify(p));
const syncGroup = 'default';
const storageProvider: StorageProvider = {
save: (ctx) => {
miLocalStorage.setItem('preferences', JSON.stringify(ctx.profile));
miLocalStorage.setItem('latestPreferencesUpdate', `${TAB_ID}/${Date.now()}`);
},
cloudGet: async (ctx) => {
// TODO: この取得方法だとアカウントが変わると保存場所も変わってしまうので改修する
// 例えば複数アカウントある場合でも設定値を保存するための「プライマリアカウント」を設定できるようにするとか
try {
const cloudData = await misskeyApi('i/registry/get', {
scope: ['client', 'preferences', 'sync'],
key: syncGroup + ':' + ctx.key,
}) as [any, any][];
const target = cloudData.find(([scope]) => isSameScope(scope, ctx.scope));
if (target == null) return null;
return {
value: target[1],
};
} catch (err: any) {
if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する
return null;
} else {
throw err;
}
}
},
cloudSet: async (ctx) => {
let cloudData: [any, any][] = [];
try {
cloudData = await misskeyApi('i/registry/get', {
scope: ['client', 'preferences', 'sync'],
key: syncGroup + ':' + ctx.key,
}) as [any, any][];
} catch (err: any) {
if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する
cloudData = [];
} else {
throw err;
}
}
const i = cloudData.findIndex(([scope]) => isSameScope(scope, ctx.scope));
if (i === -1) {
cloudData.push([ctx.scope, ctx.value]);
} else {
cloudData[i] = [ctx.scope, ctx.value];
}
await misskeyApi('i/registry/set', {
scope: ['client', 'preferences', 'sync'],
key: syncGroup + ':' + ctx.key,
value: cloudData,
});
export const prefer = profileManager.store;
},
cloudGets: async (ctx) => {
// TODO: 値の取得を1つのリクエストで済ませたい(バックエンド側でAPIの新設が必要)
const fetchings = ctx.needs.map(need => storageProvider.cloudGet(need).then(res => [need.key, res] as const));
const cloudDatas = await Promise.all(fetchings);
const res = {} as Partial<Record<string, any>>;
for (const cloudData of cloudDatas) {
if (cloudData[1] != null) {
res[cloudData[0]] = cloudData[1].value;
}
}
return res;
},
};
export const prefer = createPrefManager(storageProvider);
let latestSyncedAt = Date.now();
@@ -46,7 +118,7 @@ function syncBetweenTabs() {
if (latestTab === TAB_ID) return;
if (latestAt <= latestSyncedAt) return;
profileManager.rewriteProfile(ProfileManager.normalizeProfile(JSON.parse(miLocalStorage.getItem('preferences')!)));
prefer.rewriteProfile(PreferencesManager.normalizeProfile(JSON.parse(miLocalStorage.getItem('preferences')!)));
latestSyncedAt = Date.now();
@@ -67,7 +139,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 +147,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,8 @@ import type { Theme } from '@/theme.js';
import type { SoundType } from '@/utility/sound.js';
import type { Plugin } from '@/plugin.js';
import type { DeviceKind } from '@/utility/device-kind.js';
import type { DeckProfile } from '@/deck.js';
import type { PreferencesDefinition } from './manager.js';
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
/** サウンド設定 */
@@ -27,6 +29,8 @@ export type SoundStore = {
volume: number;
};
// NOTE: デフォルト値は他の設定の状態に依存してはならない(依存していた場合、ユーザーがその設定項目単体で「初期値にリセット」した場合不具合の原因になる)
export const PREF_DEF = {
pinnedUserLists: {
accountDependent: true,
@@ -45,6 +49,35 @@ export const PREF_DEF = {
data: Record<string, any>;
}[],
},
'deck.profile': {
accountDependent: true,
default: null as string | null,
},
'deck.profiles': {
accountDependent: true,
default: [] as DeckProfile[],
},
emojiPalettes: {
serverDependent: true,
default: [{
id: 'a',
name: '',
emojis: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
}] as {
id: string;
name: string;
emojis: string[];
}[],
},
emojiPaletteForReaction: {
serverDependent: true,
default: null as string | null,
},
emojiPaletteForMain: {
serverDependent: true,
default: null as string | null,
},
overridedDeviceKind: {
default: null as DeviceKind | null,
@@ -170,13 +203,13 @@ export const PREF_DEF = {
default: 'remote' as 'none' | 'remote' | 'always',
},
emojiPickerScale: {
default: 1,
default: 2,
},
emojiPickerWidth: {
default: 1,
default: 2,
},
emojiPickerHeight: {
default: 2,
default: 3,
},
emojiPickerStyle: {
default: 'auto' as 'auto' | 'popup' | 'drawer',
@@ -315,7 +348,4 @@ export const PREF_DEF = {
sfxVolume: 1,
},
},
} satisfies Record<string, {
default: any;
accountDependent?: boolean;
}>;
} satisfies PreferencesDefinition;

View File

@@ -0,0 +1,452 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { computed, onUnmounted, ref, watch } from 'vue';
import { v4 as uuid } from 'uuid';
import { host, version } from '@@/js/config.js';
import { PREF_DEF } from './def.js';
import type { Ref, WritableComputedRef } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import { $i } from '@/account.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { deepEqual } from '@/utility/deep-equal.js';
// NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない
//type DottedToNested<T extends Record<string, any>> = {
// [K in keyof T as K extends string ? K extends `${infer A}.${infer B}` ? A : K : K]: K extends `${infer A}.${infer B}` ? DottedToNested<{ [key in B]: T[K] }> : T[K];
//};
type PREF = typeof PREF_DEF;
type ValueOf<K extends keyof PREF> = PREF[K]['default'];
type Account = string; // <host>/<userId>
type Scope = Partial<{
server: string | null; // 将来のため
account: Account | null;
device: string | null; // 将来のため
}>;
type ValueMeta = Partial<{
sync: boolean;
}>;
type PrefRecord<K extends keyof PREF> = [scope: Scope, value: ValueOf<K>, meta: ValueMeta];
function parseScope(scope: Scope): {
server: string | null;
account: Account | null;
device: string | null;
} {
return {
server: scope.server ?? null,
account: scope.account ?? null,
device: scope.device ?? null,
};
}
function makeScope(scope: Partial<{
server: string | null;
account: Account | null;
device: string | null;
}>): Scope {
const c = {} as Scope;
if (scope.server != null) c.server = scope.server;
if (scope.account != null) c.account = scope.account;
if (scope.device != null) c.device = scope.device;
return c;
}
export function isSameScope(a: Scope, b: Scope): boolean {
// null と undefined (キー無し) は区別したくないので == で比較
// eslint-disable-next-line eqeqeq
return a.server == b.server && a.account == b.account && a.device == b.device;
}
export type PreferencesProfile = {
id: string;
version: string;
type: 'main';
modifiedAt: number;
name: string;
preferences: {
[K in keyof PREF]: PrefRecord<K>[];
};
};
export type StorageProvider = {
save: (ctx: { profile: PreferencesProfile; }) => void;
cloudGets: <K extends keyof PREF>(ctx: { needs: { key: K; scope: Scope; }[] }) => Promise<Partial<Record<K, ValueOf<K>>>>;
cloudGet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; }) => Promise<{ value: ValueOf<K>; } | null>;
cloudSet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; value: ValueOf<K>; }) => Promise<void>;
};
export type PreferencesDefinition = Record<string, {
default: any;
accountDependent?: boolean;
serverDependent?: boolean;
}>;
export class PreferencesManager {
private storageProvider: StorageProvider;
public profile: PreferencesProfile;
public cloudReady: Promise<void>;
/**
* static / state の略 (static が予約語のため)
*/
public s = {} as {
[K in keyof PREF]: ValueOf<K>;
};
/**
* reactive の略
*/
public r = {} as {
[K in keyof PREF]: Ref<ValueOf<K>>;
};
constructor(profile: PreferencesProfile, storageProvider: StorageProvider) {
this.profile = profile;
this.storageProvider = storageProvider;
const states = this.genStates();
for (const key in states) {
this.s[key] = states[key];
this.r[key] = ref(this.s[key]);
}
this.cloudReady = this.fetchCloudValues();
// TODO: 定期的にクラウドの値をフェッチ
}
private isAccountDependentKey<K extends keyof PREF>(key: K): boolean {
return (PREF_DEF as PreferencesDefinition)[key].accountDependent === true;
}
private 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.getMatchedRecordOf(key);
if (parseScope(record[0]).account == null && this.isAccountDependentKey(key)) {
this.profile.preferences[key].push([makeScope({
account: `${host}/${$i!.id}`,
}), value, {}]);
this.save();
return;
}
record[1] = value;
this.save();
if (record[2].sync) {
// awaitの必要なし
// TODO: リクエストを間引く
this.storageProvider.cloudSet({ key, scope: record[0], value: record[1] });
}
}
/**
* 特定のキーの、簡易的なcomputed refを作ります
* 主にvue上で設定コントロールのmodelとして使う用
*/
public model<K extends keyof PREF, V extends ValueOf<K> = ValueOf<K>>(
key: K,
getter?: (v: ValueOf<K>) => V,
setter?: (v: V) => ValueOf<K>,
): WritableComputedRef<V> {
const valueRef = ref(this.s[key]);
const stop = watch(this.r[key], val => {
valueRef.value = val;
});
// NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする
onUnmounted(() => {
stop();
});
// TODO: VueのcustomRef使うと良い感じになるかも
return computed({
get: () => {
if (getter) {
return getter(valueRef.value);
} else {
return valueRef.value;
}
},
set: (value) => {
const val = setter ? setter(value) : value;
this.commit(key, val);
valueRef.value = val;
},
});
}
private genStates() {
const states = {} as { [K in keyof PREF]: ValueOf<K> };
for (const _key in PREF_DEF) {
const key = _key as keyof PREF;
const record = this.getMatchedRecordOf(key);
(states[key] as any) = record[1];
}
return states;
}
private async fetchCloudValues() {
const needs = [] as { key: keyof PREF; scope: Scope; }[];
for (const _key in PREF_DEF) {
const key = _key as keyof PREF;
const record = this.getMatchedRecordOf(key);
if (record[2].sync) {
needs.push({
key,
scope: record[0],
});
}
}
const cloudValues = await this.storageProvider.cloudGets({ needs });
for (const _key in PREF_DEF) {
const key = _key as keyof PREF;
const record = this.getMatchedRecordOf(key);
if (record[2].sync && Object.hasOwn(cloudValues, key) && cloudValues[key] !== undefined) {
const cloudValue = cloudValues[key];
if (!deepEqual(cloudValue, record[1])) {
this.rewriteRawState(key, cloudValue);
record[1] = cloudValue;
console.log('cloud fetched', key, cloudValue);
}
}
}
this.save();
console.log('cloud fetch completed');
}
public static newProfile(): PreferencesProfile {
const data = {} as PreferencesProfile['preferences'];
for (const key in PREF_DEF) {
data[key] = [[makeScope({}), PREF_DEF[key].default, {}]];
}
return {
id: uuid(),
version: version,
type: 'main',
modifiedAt: Date.now(),
name: '',
preferences: data,
};
}
public static normalizeProfile(profileLike: any): PreferencesProfile {
const data = {} as PreferencesProfile['preferences'];
for (const key in PREF_DEF) {
const records = profileLike.preferences[key];
if (records == null || records.length === 0) {
data[key] = [[makeScope({}), PREF_DEF[key].default, {}]];
continue;
} else {
data[key] = records;
// alpha段階ではmetaが無かったのでマイグレート
// TODO: そのうち消す
for (const record of data[key] as any[][]) {
if (record.length === 2) {
record.push({});
}
}
}
}
return {
...profileLike,
preferences: data,
};
}
public save() {
this.profile.modifiedAt = Date.now();
this.profile.version = version;
this.storageProvider.save({ profile: this.profile });
}
public getMatchedRecordOf<K extends keyof PREF>(key: K): PrefRecord<K> {
const records = this.profile.preferences[key];
if ($i == null) return records.find(([scope, v]) => parseScope(scope).account == null)!;
const accountOverrideRecord = records.find(([scope, v]) => parseScope(scope).account === `${host}/${$i!.id}`);
if (accountOverrideRecord) return accountOverrideRecord;
const record = records.find(([scope, v]) => parseScope(scope).account == null);
return record!;
}
public isAccountOverrided<K extends keyof PREF>(key: K): boolean {
if ($i == null) return false;
return this.profile.preferences[key].some(([scope, v]) => parseScope(scope).account === `${host}/${$i!.id}`) ?? false;
}
public setAccountOverride<K extends keyof PREF>(key: K) {
if ($i == null) return;
if (this.isAccountDependentKey(key)) throw new Error('already account-dependent');
if (this.isAccountOverrided(key)) return;
const records = this.profile.preferences[key];
records.push([makeScope({
account: `${host}/${$i!.id}`,
}), this.s[key], {}]);
this.save();
}
public clearAccountOverride<K extends keyof PREF>(key: K) {
if ($i == null) return;
if (this.isAccountDependentKey(key)) throw new Error('cannot clear override for this account-dependent property');
const records = this.profile.preferences[key];
const index = records.findIndex(([scope, v]) => parseScope(scope).account === `${host}/${$i!.id}`);
if (index === -1) return;
records.splice(index, 1);
this.rewriteRawState(key, this.getMatchedRecordOf(key)[1]);
this.save();
}
public isSyncEnabled<K extends keyof PREF>(key: K): boolean {
return this.getMatchedRecordOf(key)[2].sync ?? false;
}
public async enableSync<K extends keyof PREF>(key: K): Promise<{ enabled: boolean; } | null> {
if (this.isSyncEnabled(key)) return Promise.resolve(null);
const record = this.getMatchedRecordOf(key);
const existing = await this.storageProvider.cloudGet({ key, scope: record[0] });
if (existing != null && !deepEqual(existing.value, record[1])) {
const { canceled, result } = await os.select({
title: i18n.ts.preferenceSyncConflictTitle,
text: i18n.ts.preferenceSyncConflictText,
items: [{
text: i18n.ts.preferenceSyncConflictChoiceServer,
value: 'remote',
}, {
text: i18n.ts.preferenceSyncConflictChoiceDevice,
value: 'local',
}, {
text: i18n.ts.preferenceSyncConflictChoiceCancel,
value: null,
}],
default: 'remote',
});
if (canceled || result == null) return { enabled: false };
if (result === 'remote') {
this.commit(key, existing.value);
} else if (result === 'local') {
// nop
}
}
record[2].sync = true;
this.save();
// awaitの必要性は無い
this.storageProvider.cloudSet({ key, scope: record[0], value: this.s[key] });
return { enabled: true };
}
public disableSync<K extends keyof PREF>(key: K) {
if (!this.isSyncEnabled(key)) return;
const record = this.getMatchedRecordOf(key);
delete record[2].sync;
this.save();
}
public renameProfile(name: string) {
this.profile.name = name;
this.save();
}
public rewriteProfile(profile: PreferencesProfile) {
this.profile = profile;
const states = this.genStates();
for (const _key in states) {
const key = _key as keyof PREF;
this.rewriteRawState(key, states[key]);
}
this.fetchCloudValues();
}
public getPerPrefMenu<K extends keyof PREF>(key: K): MenuItem[] {
const overrideByAccount = ref(this.isAccountOverrided(key));
watch(overrideByAccount, () => {
if (overrideByAccount.value) {
this.setAccountOverride(key);
} else {
this.clearAccountOverride(key);
}
});
const sync = ref(this.isSyncEnabled(key));
watch(sync, () => {
if (sync.value) {
this.enableSync(key).then((res) => {
if (res == null) return;
if (!res.enabled) sync.value = false;
});
} else {
this.disableSync(key);
}
});
return [{
icon: 'ti ti-copy',
text: i18n.ts.copyPreferenceId,
action: () => {
copyToClipboard(key);
},
}, {
icon: 'ti ti-refresh',
text: i18n.ts.resetToDefaultValue,
danger: true,
action: () => {
this.commit(key, PREF_DEF[key].default);
},
}, {
type: 'divider',
}, {
type: 'switch',
icon: 'ti ti-user-cog',
text: i18n.ts.overrideByAccount,
ref: overrideByAccount,
}, {
type: 'switch',
icon: 'ti ti-cloud-cog',
text: i18n.ts.syncBetweenDevices,
ref: sync,
}];
}
}

View File

@@ -1,236 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { 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 { MenuItem } from '@/types/menu.js';
import { $i } from '@/account.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { i18n } from '@/i18n.js';
// NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない
//type DottedToNested<T extends Record<string, any>> = {
// [K in keyof T as K extends string ? K extends `${infer A}.${infer B}` ? A : K : K]: K extends `${infer A}.${infer B}` ? DottedToNested<{ [key in B]: T[K] }> : T[K];
//};
type PREF = typeof PREF_DEF;
type ValueOf<K extends keyof PREF> = PREF[K]['default'];
type Account = string; // <host>/<userId>
type Cond = {
server: string | null; // 将来のため
account: Account | null;
device: string | null; // 将来のため
};
export type PreferencesProfile = {
id: string;
version: string;
type: 'main';
modifiedAt: number;
name: string;
preferences: {
[K in keyof PREF]: [Cond, ValueOf<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>;
}>;
constructor(profile: PreferencesProfile) {
super();
this.profile = profile;
const states = this.genStates();
this.store = new Store(states);
this.store.addListener('updated', ({ key, value }) => {
console.log('prefer:set', key, value);
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;
}
record[1] = value;
this.save();
});
}
private genStates() {
const states = {} as { [K in keyof PREF]: ValueOf<K> };
let key: keyof PREF;
for (key in PREF_DEF) {
const record = this.getMatchedRecord(key);
states[key] = record[1];
}
return states;
}
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]];
}
return {
id: uuid(),
version: version,
type: 'main',
modifiedAt: Date.now(),
name: '',
preferences: data,
syncByAccount: [],
};
}
public static normalizeProfile(profile: any): PreferencesProfile {
const data = {} as PreferencesProfile['preferences'];
let key: keyof PREF;
for (key in PREF_DEF) {
const records = profile.preferences[key];
if (records == null || records.length === 0) {
data[key] = [[{
server: null,
account: null,
device: null,
}, PREF_DEF[key].default]];
continue;
} else {
data[key] = records;
}
}
return {
...profile,
preferences: data,
};
}
public save() {
this.profile.modifiedAt = Date.now();
this.profile.version = version;
this.emit('updated', { profile: this.profile });
}
public getMatchedRecord<K extends keyof PREF>(key: K): [Cond, ValueOf<K>] {
const records = this.profile.preferences[key];
if ($i == null) return records.find(([cond, v]) => cond.account == null)!;
const accountOverrideRecord = records.find(([cond, v]) => cond.account === `${host}/${$i!.id}`);
if (accountOverrideRecord) return accountOverrideRecord;
const record = records.find(([cond, v]) => 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;
}
public setAccountOverride<K extends keyof PREF>(key: K) {
if ($i == null) return;
if (PREF_DEF[key].accountDependent) throw new Error('already account-dependent');
if (this.isAccountOverrided(key)) return;
const records = this.profile.preferences[key];
records.push([{
server: null,
account: `${host}/${$i!.id}`,
device: null,
}, this.store.s[key]]);
this.save();
}
public clearAccountOverride<K extends keyof PREF>(key: K) {
if ($i == null) return;
if (PREF_DEF[key].accountDependent) throw new Error('cannot clear override for this account-dependent property');
const records = this.profile.preferences[key];
const index = records.findIndex(([cond, v]) => cond.account === `${host}/${$i!.id}`);
if (index === -1) return;
records.splice(index, 1);
this.store.rewrite(key, this.getMatchedRecord(key)[1]);
this.save();
}
public renameProfile(name: string) {
this.profile.name = name;
this.save();
}
public rewriteProfile(profile: PreferencesProfile) {
this.profile = profile;
const states = this.genStates();
for (const key in states) {
this.store.rewrite(key, states[key]);
}
}
public getPerPrefMenu<K extends keyof PREF>(key: K): MenuItem[] {
const overrideByAccount = ref(this.isAccountOverrided(key));
watch(overrideByAccount, () => {
if (overrideByAccount.value) {
this.setAccountOverride(key);
} else {
this.clearAccountOverride(key);
}
});
return [{
icon: 'ti ti-copy',
text: i18n.ts.copyPreferenceId,
action: () => {
copyToClipboard(key);
},
}, {
icon: 'ti ti-refresh',
text: i18n.ts.resetToDefaultValue,
danger: true,
action: () => {
this.store.commit(key, PREF_DEF[key].default);
},
}, {
type: 'divider',
}, {
type: 'switch',
icon: 'ti ti-user-cog',
text: i18n.ts.overrideByAccount,
ref: overrideByAccount,
}];
}
}

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

@@ -4,12 +4,12 @@
*/
import { ref, watch } from 'vue';
import type { PreferencesProfile } from './profile.js';
import type { PreferencesProfile } from './manager.js';
import type { MenuItem } from '@/types/menu.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
import { prefer, 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

@@ -66,9 +66,9 @@ const routes: RouteDef[] = [{
name: 'privacy',
component: page(() => import('@/pages/settings/privacy.vue')),
}, {
path: '/emoji-picker',
name: 'emojiPicker',
component: page(() => import('@/pages/settings/emoji-picker.vue')),
path: '/emoji-palette',
name: 'emoji-palette',
component: page(() => import('@/pages/settings/emoji-palette.vue')),
}, {
path: '/drive',
name: 'drive',
@@ -105,10 +105,6 @@ const routes: RouteDef[] = [{
path: '/theme',
name: 'theme',
component: page(() => import('@/pages/settings/theme.vue')),
}, {
path: '/appearance',
name: 'appearance',
component: page(() => import('@/pages/settings/appearance.vue')),
}, {
path: '/navbar',
name: 'navbar',
@@ -134,33 +130,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';
@@ -40,14 +39,6 @@ export const store = markRaw(new Storage('base', {
where: 'account',
default: null,
},
reactions: {
where: 'account',
default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
},
pinnedEmojis: {
where: 'account',
default: [],
},
reactionAcceptance: {
where: 'account',
default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null,
@@ -117,18 +108,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',
@@ -140,6 +119,14 @@ export const store = markRaw(new Storage('base', {
},
//#region TODO: そのうち消す (preferに移行済み)
reactions: {
where: 'account',
default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
},
pinnedEmojis: {
where: 'account',
default: [],
},
widgets: {
where: 'account',
default: [] as {

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

Some files were not shown because too many files have changed in this diff Show More