Merge remote-tracking branch 'misskey-original/develop' into develop

# Conflicts:
#	locales/index.d.ts
#	locales/ja-JP.yml
#	package.json
#	packages/backend/src/server/api/endpoints/admin/emoji/copy.ts
#	packages/backend/src/server/api/endpoints/admin/meta.ts
#	packages/backend/src/server/api/endpoints/channels/timeline.ts
#	packages/backend/src/server/api/endpoints/notes/featured.ts
#	packages/frontend/src/components/MkButton.vue
#	packages/frontend/src/components/MkClickerGame.vue
#	packages/frontend/src/components/MkDialog.vue
#	packages/frontend/src/components/MkDrive.vue
#	packages/frontend/src/components/MkEmojiEditDialog.vue
#	packages/frontend/src/components/MkEmojiPicker.section.vue
#	packages/frontend/src/components/MkEmojiPicker.vue
#	packages/frontend/src/components/MkFollowButton.vue
#	packages/frontend/src/components/MkInstanceTicker.vue
#	packages/frontend/src/components/MkLaunchPad.vue
#	packages/frontend/src/components/MkMenu.vue
#	packages/frontend/src/components/MkNote.vue
#	packages/frontend/src/components/MkNoteSimple.vue
#	packages/frontend/src/components/MkPostForm.vue
#	packages/frontend/src/components/MkRadio.vue
#	packages/frontend/src/components/MkSignupDialog.form.vue
#	packages/frontend/src/components/MkSwitch.vue
#	packages/frontend/src/custom-emojis.ts
#	packages/frontend/src/pages/about.emojis.vue
#	packages/frontend/src/pages/about.vue
#	packages/frontend/src/pages/admin/index.vue
#	packages/frontend/src/pages/admin/other-settings.vue
#	packages/frontend/src/pages/custom-emojis-manager.vue
#	packages/frontend/src/pages/settings/general.vue
#	packages/frontend/src/pages/settings/mute-block.vue
#	packages/frontend/src/pages/timeline.vue
#	packages/frontend/src/pages/user/home.vue
#	packages/frontend/src/pages/user/index.files.vue
#	packages/frontend/src/scripts/get-note-menu.ts
#	packages/frontend/src/store.ts
#	packages/frontend/src/ui/_common_/stream-indicator.vue
#	packages/frontend/src/ui/classic.sidebar.vue
#	packages/frontend/src/ui/universal.vue
#	packages/frontend/src/ui/universal.widgets.vue
#	packages/frontend/vite.config.ts
#	packages/misskey-js/etc/misskey-js.api.md
#	packages/misskey-js/src/api.types.ts
#	packages/misskey-js/src/entities.ts
#	packages/misskey-js/src/streaming.types.ts
This commit is contained in:
mattyatea 2023-12-13 07:08:57 +09:00
commit 8c4a08c383
404 changed files with 42042 additions and 11250 deletions

View File

@ -106,12 +106,16 @@ redis:
# ┌───────────────────────────┐ # ┌───────────────────────────┐
#───┘ MeiliSearch configuration └───────────────────────────── #───┘ MeiliSearch configuration └─────────────────────────────
# You can set scope to local (default value) or global
# (include notes from remote).
#meilisearch: #meilisearch:
# host: meilisearch # host: meilisearch
# port: 7700 # port: 7700
# apiKey: '' # apiKey: ''
# ssl: true # ssl: true
# index: '' # index: ''
# scope: local
# ┌───────────────┐ # ┌───────────────┐
#───┘ ID generation └─────────────────────────────────────────── #───┘ ID generation └───────────────────────────────────────────
@ -180,6 +184,9 @@ proxyRemoteFiles: true
# Sign to ActivityPub GET request (default: true) # Sign to ActivityPub GET request (default: true)
signToActivityPubGet: true signToActivityPubGet: true
# For security reasons, uploading attachments from the intranet is prohibited,
# but exceptions can be made from the following settings. Default value is "undefined".
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
#allowedPrivateNetworks: [ #allowedPrivateNetworks: [
# '127.0.0.1/32' # '127.0.0.1/32'
#] #]

View File

@ -118,6 +118,9 @@ redis:
# ┌───────────────────────────┐ # ┌───────────────────────────┐
#───┘ MeiliSearch configuration └───────────────────────────── #───┘ MeiliSearch configuration └─────────────────────────────
# You can set scope to local (default value) or global
# (include notes from remote).
#meilisearch: #meilisearch:
# host: localhost # host: localhost
# port: 7700 # port: 7700
@ -210,6 +213,9 @@ proxyRemoteFiles: true
# Sign to ActivityPub GET request (default: true) # Sign to ActivityPub GET request (default: true)
signToActivityPubGet: true signToActivityPubGet: true
# For security reasons, uploading attachments from the intranet is prohibited,
# but exceptions can be made from the following settings. Default value is "undefined".
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
#allowedPrivateNetworks: [ #allowedPrivateNetworks: [
# '127.0.0.1/32' # '127.0.0.1/32'
#] #]

29
.github/labeler.yml vendored
View File

@ -1,21 +1,34 @@
'packages/backend': 'packages/backend':
- packages/backend/**/* - any:
- changed-files:
- any-glob-to-any-file: ['packages/backend/**/*']
'packages/backend:test': 'packages/backend:test':
- packages/backend/test/**/* - any:
- changed-files:
- any-glob-to-any-file: ['packages/backend/test/**/*']
'packages/frontend': 'packages/frontend':
- packages/frontend/**/* - any:
- changed-files:
- any-glob-to-any-file: ['packages/frontend/**/*']
'packages/frontend:test': 'packages/frontend:test':
- cypress/**/* - any:
- changed-files:
- any-glob-to-any-file: ['cypress/**/*']
'packages/sw': 'packages/sw':
- packages/sw/**/* - any:
- changed-files:
- any-glob-to-any-file: ['packages/sw/**/*']
'packages/misskey-js': 'packages/misskey-js':
- packages/misskey-js/**/* - any:
- changed-files:
- any-glob-to-any-file: ['packages/misskey-js/**/*']
'packages/misskey-js:test': 'packages/misskey-js:test':
- packages/misskey-js/test/**/* - any:
- packages/misskey-js/test-d/**/* - changed-files:
- any-glob-to-any-file: ['packages/misskey-js/test/**/*', 'packages/misskey-js/test-d/**/*']

View File

@ -11,6 +11,6 @@ jobs:
pull-requests: write pull-requests: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/labeler@v4 - uses: actions/labeler@v5
with: with:
repo-token: "${{ secrets.GITHUB_TOKEN }}" repo-token: "${{ secrets.GITHUB_TOKEN }}"

View File

@ -6,6 +6,7 @@
### Client ### Client
- Fix: ページ一覧ページの表示がモバイル環境において崩れているのを修正 - Fix: ページ一覧ページの表示がモバイル環境において崩れているのを修正
- Fix: MFMでルビの中のテキストがnyaizeされない問題を修正
### Server ### Server
- -
@ -22,6 +23,8 @@
### Client ### Client
- Feat: 今日誕生日のフォロー中のユーザーを一覧表示できるウィジェットを追加 - Feat: 今日誕生日のフォロー中のユーザーを一覧表示できるウィジェットを追加
- Feat: データセーバーでコードハイライトの読み込みを削減できるように
- Enhance: 投稿フォームの絵文字ピッカーをリアクション時に使用するものと同じのを使用するように #12336
- Enhance: 絵文字のオートコンプリート機能強化 #12364 - Enhance: 絵文字のオートコンプリート機能強化 #12364
- Enhance: ユーザーのRawデータを表示するページが復活 - Enhance: ユーザーのRawデータを表示するページが復活
- Enhance: リアクション選択時に音を鳴らせるように - Enhance: リアクション選択時に音を鳴らせるように
@ -30,6 +33,11 @@
- Enhance: Shareページで投稿を完了すると、親ウィンドウ親フレームにpostMessageするように - Enhance: Shareページで投稿を完了すると、親ウィンドウ親フレームにpostMessageするように
- Enhance: チャンネル、クリップ、ページ、Play、ギャラリーにURLのコピーボタンを設置 #11305 - Enhance: チャンネル、クリップ、ページ、Play、ギャラリーにURLのコピーボタンを設置 #11305
- Enhance: ノートプレビューに「内容を隠す」が反映されるように - Enhance: ノートプレビューに「内容を隠す」が反映されるように
- Enhance: データセーバーの適用範囲を個別で設定できるように
- 従来のデータセーバーの設定はリセットされます
- Enhance: タイムライン上のタブからリスト、アンテナ、チャンネルの管理ページにジャンプできるように
- Feat: センシティブと判断されたウェブサイトのサムネイルをぼかすように
- ウェブサイトをセンシティブと判断する仕組みが動いていないため、summalyProxyを使用しないと機能しません。
- fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正 - fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正
- Fix: ウィジェットのジョブキューにて音声の発音方法変更に追従できていなかったのを修正 #12367 - Fix: ウィジェットのジョブキューにて音声の発音方法変更に追従できていなかったのを修正 #12367
- Enhance: 絵文字の詳細ページに記載される情報を追加 - Enhance: 絵文字の詳細ページに記載される情報を追加
@ -38,15 +46,25 @@
- Fix: 一度に大量の通知が入った際に通知音が音割れする問題を修正 - Fix: 一度に大量の通知が入った際に通知音が音割れする問題を修正
- Fix: 共有機能をサポートしていないブラウザの場合は共有ボタンを非表示にする #11305 - Fix: 共有機能をサポートしていないブラウザの場合は共有ボタンを非表示にする #11305
- Fix: 通知のグルーピング設定を変更してもリロードされるまで表示が変わらない問題を修正 #12470 - Fix: 通知のグルーピング設定を変更してもリロードされるまで表示が変わらない問題を修正 #12470
- Fix: 長い名前のチャンネルにおける投稿フォームの表示が崩れる問題を修正
- Fix: セキュリティ向上のためAiScriptの`Mk:apiExternal`を無効化
### Server ### Server
- Enhance: MFM `$[ruby ]` が他ソフトウェアと連合されるように - Enhance: MFM `$[ruby ]` が他ソフトウェアと連合されるように
- Enhance: Meilisearchを有効にした検索で、ユーザーのミュートやブロックを考慮するように
- Fix: 時間経過により無効化されたアンテナを再有効化したとき、サーバ再起動までその状況が反映されないのを修正 #12303 - Fix: 時間経過により無効化されたアンテナを再有効化したとき、サーバ再起動までその状況が反映されないのを修正 #12303
- Fix: ロールタイムラインが保存されない問題を修正 - Fix: ロールタイムラインが保存されない問題を修正
- Fix: api.jsonの生成ロジックを改善 #12402 - Fix: api.jsonの生成ロジックを改善 #12402
- Fix: 招待コードが使い回せる問題を修正 - Fix: 招待コードが使い回せる問題を修正
- Fix: 特定の条件下でチャンネルやユーザーのノート一覧に最新のノートが表示されなくなる問題を修正 - Fix: 特定の条件下でチャンネルやユーザーのノート一覧に最新のノートが表示されなくなる問題を修正
- Fix: 何もノートしていないユーザーのフィードにアクセスするとエラーになる問題を修正 - Fix: 何もノートしていないユーザーのフィードにアクセスするとエラーになる問題を修正
- Fix: リストタイムラインにてミュートが機能しないケースがある問題と、チャンネル投稿がストリーミングで流れてきてしまう問題を修正 #10443
- Fix: 「みつける」のなかにミュートしたユーザが現れてしまう問題を修正 #12383
- Fix: Social/Local/Home Timelineにてインスタンスミュートが効かない問題
- Fix: ユーザのノート一覧にてインスタンスミュートが効かない問題
- Fix: チャンネルのノート一覧にてインスタンスミュートが効かない問題
- Fix: 「みつける」が年越し時に壊れる問題を修正
- Fix: アカウントをブロックした際に、自身のユーザーのページでノートが相手に表示される問題を修正
## 2023.11.1 ## 2023.11.1
@ -176,6 +194,7 @@
### Client ### Client
- Enhance: TLの返信表示オプションを記憶するように - Enhance: TLの返信表示オプションを記憶するように
- Enhance: 投稿されてから時間が経過しているノートであることを視覚的に分かりやすく - Enhance: 投稿されてから時間が経過しているノートであることを視覚的に分かりやすく
- Feat: 絵文字ピッカーのカテゴリに「/」を入れることでフォルダ分け表示できるように
### Server ### Server
- Enhance: タイムライン取得時のパフォーマンスを向上 - Enhance: タイムライン取得時のパフォーマンスを向上

View File

@ -117,6 +117,10 @@ command.
- Server-side source files and automatically builds them if they are modified. Automatically start the server process(es). - Server-side source files and automatically builds them if they are modified. Automatically start the server process(es).
- Vite HMR (just the `vite` command) is available. The behavior may be different from production. - Vite HMR (just the `vite` command) is available. The behavior may be different from production.
- Service Worker is watched by esbuild. - Service Worker is watched by esbuild.
- The front end can be viewed by accessing `http://localhost:5173`.
- The backend listens on the port configured with `port` in .config/default.yml.
If you have not changed it from the default, it will be "http://localhost:3000".
If "port" in .config/default.yml is set to something other than 3000, you need to change the proxy settings in packages/frontend/vite.config.local-dev.ts.
### Dev Container ### Dev Container
Instead of running `pnpm` locally, you can use Dev Container to set up your development environment. Instead of running `pnpm` locally, you can use Dev Container to set up your development environment.

View File

@ -0,0 +1,42 @@
version: "3"
# このconfigは、 dockerでMisskey本体を起動せず、 redisとpostgresql などだけを起動します
services:
redis:
restart: always
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- ./redis:/data
healthcheck:
test: "redis-cli ping"
interval: 5s
retries: 20
db:
restart: always
image: postgres:15-alpine
ports:
- "5432:5432"
env_file:
- .config/docker.env
volumes:
- ./db:/var/lib/postgresql/data
healthcheck:
test: "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"
interval: 5s
retries: 20
# meilisearch:
# restart: always
# image: getmeili/meilisearch:v1.3.4
# environment:
# - MEILI_NO_ANALYTICS=true
# - MEILI_ENV=production
# env_file:
# - .config/meilisearch.env
# volumes:
# - ./meili_data:/meili_data

22
locales/index.d.ts vendored
View File

@ -333,6 +333,7 @@ export interface Locale {
"renameFolder": string; "renameFolder": string;
"deleteFolder": string; "deleteFolder": string;
"Folder": string; "Folder": string;
"folder": string;
"addFile": string; "addFile": string;
"emptyDrive": string; "emptyDrive": string;
"emptyFolder": string; "emptyFolder": string;
@ -459,7 +460,6 @@ export interface Locale {
"notFound": string; "notFound": string;
"notFoundDescription": string; "notFoundDescription": string;
"uploadFolder": string; "uploadFolder": string;
"cacheClear": string;
"markAsReadAllNotifications": string; "markAsReadAllNotifications": string;
"markAsReadAllUnreadNotes": string; "markAsReadAllUnreadNotes": string;
"markAsReadAllTalkMessages": string; "markAsReadAllTalkMessages": string;
@ -1210,6 +1210,8 @@ export interface Locale {
"signupPendingError": string; "signupPendingError": string;
"cwNotationRequired": string; "cwNotationRequired": string;
"doReaction": string; "doReaction": string;
"code": string;
"reloadRequiredToApplySettings": string;
"_announcement": { "_announcement": {
"forExistingUsers": string; "forExistingUsers": string;
"forExistingUsersDescription": string; "forExistingUsersDescription": string;
@ -2561,6 +2563,24 @@ export interface Locale {
}; };
}; };
}; };
"_dataSaver": {
"_media": {
"title": string;
"description": string;
};
"_avatar": {
"title": string;
"description": string;
};
"_urlPreview": {
"title": string;
"description": string;
};
"_code": {
"title": string;
"description": string;
};
};
"_schedulePost": { "_schedulePost": {
"list": string; "list": string;
"postDate": string; "postDate": string;

View File

@ -330,6 +330,7 @@ createFolder: "フォルダーを作成"
renameFolder: "フォルダー名を変更" renameFolder: "フォルダー名を変更"
deleteFolder: "フォルダーを削除" deleteFolder: "フォルダーを削除"
Folder: "フォルダー" Folder: "フォルダー"
folder: "フォルダー"
addFile: "ファイルを追加" addFile: "ファイルを追加"
emptyDrive: "ドライブは空です" emptyDrive: "ドライブは空です"
emptyFolder: "フォルダーは空です" emptyFolder: "フォルダーは空です"
@ -456,7 +457,6 @@ share: "共有"
notFound: "見つかりません" notFound: "見つかりません"
notFoundDescription: "指定されたURLに該当するページはありませんでした。" notFoundDescription: "指定されたURLに該当するページはありませんでした。"
uploadFolder: "既定アップロード先" uploadFolder: "既定アップロード先"
cacheClear: "キャッシュを削除"
markAsReadAllNotifications: "すべての通知を既読にする" markAsReadAllNotifications: "すべての通知を既読にする"
markAsReadAllUnreadNotes: "すべての投稿を既読にする" markAsReadAllUnreadNotes: "すべての投稿を既読にする"
markAsReadAllTalkMessages: "すべてのチャットを既読にする" markAsReadAllTalkMessages: "すべてのチャットを既読にする"
@ -1207,6 +1207,8 @@ useGroupedNotifications: "通知をグルーピングして表示する"
signupPendingError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。" signupPendingError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。"
cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。" cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。"
doReaction: "リアクションする" doReaction: "リアクションする"
code: "コード"
reloadRequiredToApplySettings: "設定の反映にはリロードが必要です。"
_announcement: _announcement:
forExistingUsers: "既存ユーザーのみ" forExistingUsers: "既存ユーザーのみ"
@ -2458,3 +2460,17 @@ _schedulePost:
willBePostedAtX: "{date}に投稿予約しました。" willBePostedAtX: "{date}に投稿予約しました。"
deleteAreYouSure: "予約投稿を削除しますか?" deleteAreYouSure: "予約投稿を削除しますか?"
deleteAndEditConfirm: "予約投稿を削除して編集しますか?" deleteAndEditConfirm: "予約投稿を削除して編集しますか?"
_dataSaver:
_media:
title: "メディアの読み込み"
description: "画像・動画が自動で読み込まれるのを防止します。隠れている画像・動画はタップすると読み込まれます。"
_avatar:
title: "アイコン画像"
description: "アイコン画像のアニメーションが停止します。アニメーション画像は通常の画像よりファイルサイズが大きいことがあるので、データ通信量をさらに削減できます。"
_urlPreview:
title: "URLプレビューのサムネイル"
description: "URLプレビューのサムネイル画像が読み込まれなくなります。"
_code:
title: "コードハイライト"
description: "MFMなどでコードハイライト記法が使われている場合、タップするまで読み込まれなくなります。コードハイライトではハイライトする言語ごとにその定義ファイルを読み込む必要がありますが、それらが自動で読み込まれなくなるため、通信量の削減が見込めます。"

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2023.12.0-beta.1-PrisMisskey.2", "version": "2023.12.0-beta.3",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
@ -18,6 +18,7 @@
"build-assets": "node ./scripts/build-assets.mjs", "build-assets": "node ./scripts/build-assets.mjs",
"build": "pnpm build-pre && pnpm -r build && pnpm build-assets", "build": "pnpm build-pre && pnpm -r build && pnpm build-assets",
"build-storybook": "pnpm --filter frontend build-storybook", "build-storybook": "pnpm --filter frontend build-storybook",
"build-misskey-js-with-types": "pnpm --filter backend build && pnpm --filter backend generate-api-json && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build",
"start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js", "start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js",
"start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js", "start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js",
"init": "pnpm migrate", "init": "pnpm migrate",
@ -26,7 +27,7 @@
"check:connect": "cd packages/backend && pnpm check:connect", "check:connect": "cd packages/backend && pnpm check:connect",
"migrateandstart": "pnpm migrate && pnpm start", "migrateandstart": "pnpm migrate && pnpm start",
"watch": "pnpm dev", "watch": "pnpm dev",
"dev": "node ./scripts/dev.mjs", "dev": "pnpm -r dev",
"lint": "pnpm -r lint", "lint": "pnpm -r lint",
"cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts", "cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts",
"cy:run": "pnpm cypress run", "cy:run": "pnpm cypress run",
@ -47,17 +48,18 @@
"execa": "8.0.1", "execa": "8.0.1",
"cssnano": "6.0.1", "cssnano": "6.0.1",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"postcss": "8.4.31", "postcss": "8.4.32",
"terser": "5.24.0", "terser": "5.24.0",
"typescript": "5.3.2" "typescript": "5.3.3"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "6.12.0", "@typescript-eslint/eslint-plugin": "6.13.2",
"@typescript-eslint/parser": "6.12.0", "@typescript-eslint/parser": "6.13.2",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "13.6.0", "cypress": "13.6.1",
"eslint": "8.54.0", "eslint": "8.55.0",
"start-server-and-test": "2.0.3" "start-server-and-test": "2.0.3",
"ncp": "2.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tensorflow/tfjs-core": "4.4.0" "@tensorflow/tfjs-core": "4.4.0"

View File

@ -16,6 +16,7 @@
"watch:swc": "swc src -d built -D -w", "watch:swc": "swc src -d built -D -w",
"build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", "build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
"watch": "node watch.mjs", "watch": "node watch.mjs",
"dev": "node ./built/boot/entry.js",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"eslint": "eslint --quiet \"src/**/*.ts\"", "eslint": "eslint --quiet \"src/**/*.ts\"",
"lint": "pnpm typecheck && pnpm eslint", "lint": "pnpm typecheck && pnpm eslint",
@ -60,13 +61,13 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.412.0", "@aws-sdk/client-s3": "3.412.0",
"@aws-sdk/lib-storage": "3.412.0", "@aws-sdk/lib-storage": "3.412.0",
"@bull-board/api": "5.9.2", "@bull-board/api": "5.10.2",
"@bull-board/fastify": "5.9.2", "@bull-board/fastify": "5.10.2",
"@bull-board/ui": "5.9.2", "@bull-board/ui": "5.10.2",
"@discordapp/twemoji": "14.1.2", "@discordapp/twemoji": "14.1.2",
"@fastify/accepts": "4.2.0", "@fastify/accepts": "4.3.0",
"@fastify/cookie": "9.2.0", "@fastify/cookie": "9.2.0",
"@fastify/cors": "8.4.1", "@fastify/cors": "8.4.2",
"@fastify/express": "2.3.0", "@fastify/express": "2.3.0",
"@fastify/http-proxy": "9.3.0", "@fastify/http-proxy": "9.3.0",
"@fastify/multipart": "8.0.0", "@fastify/multipart": "8.0.0",
@ -80,7 +81,7 @@
"@sinonjs/fake-timers": "11.2.2", "@sinonjs/fake-timers": "11.2.2",
"@smithy/node-http-handler": "2.1.10", "@smithy/node-http-handler": "2.1.10",
"@swc/cli": "0.1.63", "@swc/cli": "0.1.63",
"@swc/core": "1.3.99", "@swc/core": "1.3.100",
"accepts": "1.3.8", "accepts": "1.3.8",
"ajv": "8.12.0", "ajv": "8.12.0",
"archiver": "6.0.1", "archiver": "6.0.1",
@ -88,7 +89,7 @@
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"blurhash": "2.0.5", "blurhash": "2.0.5",
"body-parser": "1.20.2", "body-parser": "1.20.2",
"bullmq": "4.14.2", "bullmq": "4.15.2",
"cacheable-lookup": "7.0.0", "cacheable-lookup": "7.0.0",
"cbor": "9.0.1", "cbor": "9.0.1",
"chalk": "5.3.0", "chalk": "5.3.0",
@ -114,9 +115,9 @@
"ipaddr.js": "2.1.0", "ipaddr.js": "2.1.0",
"is-svg": "5.0.0", "is-svg": "5.0.0",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"jsdom": "23.0.0", "jsdom": "23.0.1",
"json5": "2.2.3", "json5": "2.2.3",
"jsonld": "8.3.1", "jsonld": "8.3.2",
"jsrsasign": "10.9.0", "jsrsasign": "10.9.0",
"meilisearch": "0.36.0", "meilisearch": "0.36.0",
"mfm-js": "0.23.3", "mfm-js": "0.23.3",
@ -124,7 +125,7 @@
"mime-types": "2.1.35", "mime-types": "2.1.35",
"misskey-js": "workspace:*", "misskey-js": "workspace:*",
"ms": "3.0.0-canary.1", "ms": "3.0.0-canary.1",
"nanoid": "5.0.3", "nanoid": "5.0.4",
"nested-property": "4.0.0", "nested-property": "4.0.0",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"nodemailer": "6.9.7", "nodemailer": "6.9.7",
@ -133,7 +134,7 @@
"oauth2orize": "1.12.0", "oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2", "oauth2orize-pkce": "0.1.2",
"os-utils": "0.0.14", "os-utils": "0.0.14",
"otpauth": "9.2.0", "otpauth": "9.2.1",
"parse5": "7.1.2", "parse5": "7.1.2",
"pg": "8.11.3", "pg": "8.11.3",
"pkce-challenge": "4.0.1", "pkce-challenge": "4.0.1",
@ -147,7 +148,7 @@
"ratelimiter": "3.4.1", "ratelimiter": "3.4.1",
"re2": "1.20.9", "re2": "1.20.9",
"redis-lock": "0.1.4", "redis-lock": "0.1.4",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.14",
"rename": "1.0.4", "rename": "1.0.4",
"rss-parser": "3.13.0", "rss-parser": "3.13.0",
"rxjs": "7.8.1", "rxjs": "7.8.1",
@ -159,14 +160,14 @@
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0", "stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly", "summaly": "github:misskey-dev/summaly",
"systeminformation": "5.21.18", "systeminformation": "5.21.20",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tmp": "0.2.1", "tmp": "0.2.1",
"tsc-alias": "1.8.8", "tsc-alias": "1.8.8",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"twemoji-parser": "14.0.0", "twemoji-parser": "14.0.0",
"typeorm": "0.3.17", "typeorm": "0.3.17",
"typescript": "5.3.2", "typescript": "5.3.3",
"ulid": "2.3.0", "ulid": "2.3.0",
"vary": "1.1.2", "vary": "1.1.2",
"web-push": "3.6.6", "web-push": "3.6.6",
@ -186,14 +187,14 @@
"@types/content-disposition": "0.5.8", "@types/content-disposition": "0.5.8",
"@types/fluent-ffmpeg": "2.1.24", "@types/fluent-ffmpeg": "2.1.24",
"@types/http-link-header": "1.0.5", "@types/http-link-header": "1.0.5",
"@types/jest": "29.5.10", "@types/jest": "29.5.11",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/jsdom": "21.1.6", "@types/jsdom": "21.1.6",
"@types/jsonld": "1.5.13", "@types/jsonld": "1.5.13",
"@types/jsrsasign": "10.5.12", "@types/jsrsasign": "10.5.12",
"@types/mime-types": "2.1.4", "@types/mime-types": "2.1.4",
"@types/ms": "0.7.34", "@types/ms": "0.7.34",
"@types/node": "20.10.0", "@types/node": "20.10.4",
"@types/node-fetch": "3.0.3", "@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.14", "@types/nodemailer": "6.4.14",
"@types/oauth": "0.9.4", "@types/oauth": "0.9.4",
@ -216,11 +217,11 @@
"@types/vary": "1.1.3", "@types/vary": "1.1.3",
"@types/web-push": "3.6.3", "@types/web-push": "3.6.3",
"@types/ws": "8.5.10", "@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "6.12.0", "@typescript-eslint/eslint-plugin": "6.13.2",
"@typescript-eslint/parser": "6.12.0", "@typescript-eslint/parser": "6.13.2",
"aws-sdk-client-mock": "3.0.0", "aws-sdk-client-mock": "3.0.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint": "8.54.0", "eslint": "8.55.0",
"eslint-plugin-import": "2.29.0", "eslint-plugin-import": "2.29.0",
"execa": "8.0.1", "execa": "8.0.1",
"jest": "29.7.0", "jest": "29.7.0",

View File

@ -4,6 +4,7 @@
*/ */
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { AccountMoveService } from './AccountMoveService.js'; import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js'; import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js'; import { AiService } from './AiService.js';
@ -195,6 +196,7 @@ const $SearchService: Provider = { provide: 'SearchService', useExisting: Search
const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService }; const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService };
const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService }; const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService };
const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService }; const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService };
const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService };
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService }; const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService }; const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
@ -331,6 +333,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ClipService, ClipService,
FeaturedService, FeaturedService,
FanoutTimelineService, FanoutTimelineService,
FanoutTimelineEndpointService,
ChannelFollowingService, ChannelFollowingService,
RegistryApiService, RegistryApiService,
ChartLoggerService, ChartLoggerService,
@ -460,6 +463,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ClipService, $ClipService,
$FeaturedService, $FeaturedService,
$FanoutTimelineService, $FanoutTimelineService,
$FanoutTimelineEndpointService,
$ChannelFollowingService, $ChannelFollowingService,
$RegistryApiService, $RegistryApiService,
$ChartLoggerService, $ChartLoggerService,
@ -590,6 +594,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ClipService, ClipService,
FeaturedService, FeaturedService,
FanoutTimelineService, FanoutTimelineService,
FanoutTimelineEndpointService,
ChannelFollowingService, ChannelFollowingService,
RegistryApiService, RegistryApiService,
FederationChart, FederationChart,
@ -718,6 +723,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ClipService, $ClipService,
$FeaturedService, $FeaturedService,
$FanoutTimelineService, $FanoutTimelineService,
$FanoutTimelineEndpointService,
$ChannelFollowingService, $ChannelFollowingService,
$RegistryApiService, $RegistryApiService,
$FederationChart, $FederationChart,

View File

@ -0,0 +1,186 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { MiUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js';
import { Packed } from '@/misc/json-schema.js';
import type { NotesRepository } from '@/models/_.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { FanoutTimelineName, FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { isPureRenote } from '@/misc/is-pure-renote.js';
import { CacheService } from '@/core/CacheService.js';
import { isReply } from '@/misc/is-reply.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
type TimelineOptions = {
untilId: string | null,
sinceId: string | null,
limit: number,
allowPartial: boolean,
me?: { id: MiUser['id'] } | undefined | null,
useDbFallback: boolean,
redisTimelines: FanoutTimelineName[],
noteFilter?: (note: MiNote) => boolean,
alwaysIncludeMyNotes?: boolean;
ignoreAuthorFromBlock?: boolean;
ignoreAuthorFromMute?: boolean;
excludeNoFiles?: boolean;
excludeReplies?: boolean;
excludePureRenotes: boolean;
dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise<MiNote[]>,
};
@Injectable()
export class FanoutTimelineEndpointService {
constructor(
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService,
private cacheService: CacheService,
private fanoutTimelineService: FanoutTimelineService,
) {
}
@bindThis
async timeline(ps: TimelineOptions): Promise<Packed<'Note'>[]> {
return await this.noteEntityService.packMany(await this.getMiNotes(ps), ps.me);
}
@bindThis
private async getMiNotes(ps: TimelineOptions): Promise<MiNote[]> {
let noteIds: string[];
let shouldFallbackToDb = false;
// 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える
if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]);
const shouldPrepend = ps.sinceId && !ps.untilId;
const idCompare: (a: string, b: string) => number = shouldPrepend ? (a, b) => a < b ? -1 : 1 : (a, b) => a > b ? -1 : 1;
const redisResult = await this.fanoutTimelineService.getMulti(ps.redisTimelines, ps.untilId, ps.sinceId);
// TODO: いい感じにgetMulti内でソート済だからuniqするときにredisResultが全てソート済なのを利用して再ソートを避けたい
const redisResultIds = Array.from(new Set(redisResult.flat(1)));
redisResultIds.sort(idCompare);
noteIds = redisResultIds.slice(0, ps.limit);
shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0);
if (!shouldFallbackToDb) {
let filter = ps.noteFilter ?? (_note => true);
if (ps.alwaysIncludeMyNotes && ps.me) {
const me = ps.me;
const parentFilter = filter;
filter = (note) => note.userId === me.id || parentFilter(note);
}
if (ps.excludeNoFiles) {
const parentFilter = filter;
filter = (note) => note.fileIds.length !== 0 && parentFilter(note);
}
if (ps.excludeReplies) {
const parentFilter = filter;
filter = (note) => !isReply(note, ps.me?.id) && parentFilter(note);
}
if (ps.excludePureRenotes) {
const parentFilter = filter;
filter = (note) => !isPureRenote(note) && parentFilter(note);
}
if (ps.me) {
const me = ps.me;
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
userMutedInstances,
] = await Promise.all([
this.cacheService.userMutingsCache.fetch(ps.me.id),
this.cacheService.renoteMutingsCache.fetch(ps.me.id),
this.cacheService.userBlockedCache.fetch(ps.me.id),
this.cacheService.userProfileCache.fetch(me.id).then(p => new Set(p.mutedInstances)),
]);
const parentFilter = filter;
filter = (note) => {
if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false;
if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false;
if (isPureRenote(note) && isUserRelated(note, userIdsWhoMeMutingRenotes, ps.ignoreAuthorFromMute)) return false;
if (isInstanceMuted(note, userMutedInstances)) return false;
return parentFilter(note);
};
}
const redisTimeline: MiNote[] = [];
let readFromRedis = 0;
let lastSuccessfulRate = 1; // rateをキャッシュする
while ((redisResultIds.length - readFromRedis) !== 0) {
const remainingToRead = ps.limit - redisTimeline.length;
// DBからの取り直しを減らす初回と同じ割合以上で成功すると仮定するが、クエリの長さを考えて三倍まで
const countToGet = Math.ceil(remainingToRead * Math.min(1.1 / lastSuccessfulRate, 3));
noteIds = redisResultIds.slice(readFromRedis, readFromRedis + countToGet);
readFromRedis += noteIds.length;
const gotFromDb = await this.getAndFilterFromDb(noteIds, filter, idCompare);
redisTimeline.push(...gotFromDb);
lastSuccessfulRate = gotFromDb.length / noteIds.length;
if (ps.allowPartial ? redisTimeline.length !== 0 : redisTimeline.length >= ps.limit) {
// 十分Redisからとれた
const result = redisTimeline.slice(0, ps.limit);
if (shouldPrepend) result.reverse();
return result;
}
}
// まだ足りない分はDBにフォールバック
const remainingToRead = ps.limit - redisTimeline.length;
let dbUntil: string | null;
let dbSince: string | null;
if (shouldPrepend) {
redisTimeline.reverse();
dbUntil = ps.untilId;
dbSince = noteIds[noteIds.length - 1];
} else {
dbUntil = noteIds[noteIds.length - 1];
dbSince = ps.sinceId;
}
const gotFromDb = await ps.dbFallback(dbUntil, dbSince, remainingToRead);
return shouldPrepend ? [...gotFromDb, ...redisTimeline] : [...redisTimeline, ...gotFromDb];
}
return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit);
}
private async getAndFilterFromDb(noteIds: string[], noteFilter: (note: MiNote) => boolean, idCompare: (a: string, b: string) => number): Promise<MiNote[]> {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
const notes = (await query.getMany()).filter(noteFilter);
notes.sort((a, b) => idCompare(a.id, b.id));
return notes;
}
}

View File

@ -9,6 +9,35 @@ import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
export type FanoutTimelineName =
// home timeline
| `homeTimeline:${string}`
| `homeTimelineWithFiles:${string}` // only notes with files are included
// local timeline
| `localTimeline` // replies are not included
| `localTimelineWithFiles` // only non-reply notes with files are included
| `localTimelineWithReplies` // only replies are included
| `localTimelineWithReplyTo:${string}` // Only replies to specific local user are included. Parameter is reply user id.
// antenna
| `antennaTimeline:${string}`
// user timeline
| `userTimeline:${string}` // replies are not included
| `userTimelineWithFiles:${string}` // only non-reply notes with files are included
| `userTimelineWithReplies:${string}` // only replies are included
| `userTimelineWithChannel:${string}` // only channel notes are included, replies are included
// user list timelines
| `userListTimeline:${string}`
| `userListTimelineWithFiles:${string}` // only notes with files are included
// channel timelines
| `channelTimeline:${string}` // replies are included
// role timelines
| `roleTimeline:${string}` // any notes are included
@Injectable() @Injectable()
export class FanoutTimelineService { export class FanoutTimelineService {
constructor( constructor(
@ -20,7 +49,7 @@ export class FanoutTimelineService {
} }
@bindThis @bindThis
public push(tl: string, id: string, maxlen: number, pipeline: Redis.ChainableCommander) { public push(tl: FanoutTimelineName, id: string, maxlen: number, pipeline: Redis.ChainableCommander) {
// リモートから遅れて届いた(もしくは後から追加された)投稿日時が古い投稿が追加されるとページネーション時に問題を引き起こすため、 // リモートから遅れて届いた(もしくは後から追加された)投稿日時が古い投稿が追加されるとページネーション時に問題を引き起こすため、
// 3分以内に投稿されたものでない場合、Redisにある最古のIDより新しい場合のみ追加する // 3分以内に投稿されたものでない場合、Redisにある最古のIDより新しい場合のみ追加する
if (this.idService.parse(id).date.getTime() > Date.now() - 1000 * 60 * 3) { if (this.idService.parse(id).date.getTime() > Date.now() - 1000 * 60 * 3) {
@ -41,7 +70,7 @@ export class FanoutTimelineService {
} }
@bindThis @bindThis
public get(name: string, untilId?: string | null, sinceId?: string | null) { public get(name: FanoutTimelineName, untilId?: string | null, sinceId?: string | null) {
if (untilId && sinceId) { if (untilId && sinceId) {
return this.redisForTimelines.lrange('list:' + name, 0, -1) return this.redisForTimelines.lrange('list:' + name, 0, -1)
.then(ids => ids.filter(id => id < untilId && id > sinceId).sort((a, b) => a > b ? -1 : 1)); .then(ids => ids.filter(id => id < untilId && id > sinceId).sort((a, b) => a > b ? -1 : 1));
@ -58,7 +87,7 @@ export class FanoutTimelineService {
} }
@bindThis @bindThis
public getMulti(name: string[], untilId?: string | null, sinceId?: string | null): Promise<string[][]> { public getMulti(name: FanoutTimelineName[], untilId?: string | null, sinceId?: string | null): Promise<string[][]> {
const pipeline = this.redisForTimelines.pipeline(); const pipeline = this.redisForTimelines.pipeline();
for (const n of name) { for (const n of name) {
pipeline.lrange('list:' + n, 0, -1); pipeline.lrange('list:' + n, 0, -1);
@ -79,7 +108,7 @@ export class FanoutTimelineService {
} }
@bindThis @bindThis
public purge(name: string) { public purge(name: FanoutTimelineName) {
return this.redisForTimelines.del('list:' + name); return this.redisForTimelines.del('list:' + name);
} }
} }

View File

@ -14,6 +14,8 @@ export const GALLERY_POSTS_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ご
const PER_USER_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 7; // 1週間ごと const PER_USER_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 7; // 1週間ごと
const HASHTAG_RANKING_WINDOW = 1000 * 60 * 60; // 1時間ごと const HASHTAG_RANKING_WINDOW = 1000 * 60 * 60; // 1時間ごと
const featuredEpoc = new Date('2023-01-01T00:00:00Z').getTime();
@Injectable() @Injectable()
export class FeaturedService { export class FeaturedService {
constructor( constructor(
@ -24,7 +26,7 @@ export class FeaturedService {
@bindThis @bindThis
private getCurrentWindow(windowRange: number): number { private getCurrentWindow(windowRange: number): number {
const passed = new Date().getTime() - new Date(new Date().getFullYear(), 0, 1).getTime(); const passed = new Date().getTime() - featuredEpoc;
return Math.floor(passed / windowRange); return Math.floor(passed / windowRange);
} }

View File

@ -7,11 +7,11 @@ import { Inject, Injectable } from '@nestjs/common';
import { ulid } from 'ulid'; import { ulid } from 'ulid';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { genAid, parseAid } from '@/misc/id/aid.js'; import { genAid, isSafeAidT, parseAid } from '@/misc/id/aid.js';
import { genAidx, parseAidx } from '@/misc/id/aidx.js'; import { genAidx, isSafeAidxT, parseAidx } from '@/misc/id/aidx.js';
import { genMeid, parseMeid } from '@/misc/id/meid.js'; import { genMeid, isSafeMeidT, parseMeid } from '@/misc/id/meid.js';
import { genMeidg, parseMeidg } from '@/misc/id/meidg.js'; import { genMeidg, isSafeMeidgT, parseMeidg } from '@/misc/id/meidg.js';
import { genObjectId, parseObjectId } from '@/misc/id/object-id.js'; import { genObjectId, isSafeObjectIdT, parseObjectId } from '@/misc/id/object-id.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { parseUlid } from '@/misc/id/ulid.js'; import { parseUlid } from '@/misc/id/ulid.js';
@ -26,6 +26,19 @@ export class IdService {
this.method = config.id.toLowerCase(); this.method = config.id.toLowerCase();
} }
@bindThis
public isSafeT(t: number): boolean {
switch (this.method) {
case 'aid': return isSafeAidT(t);
case 'aidx': return isSafeAidxT(t);
case 'meid': return isSafeMeidT(t);
case 'meidg': return isSafeMeidgT(t);
case 'ulid': return t > 0;
case 'objectid': return isSafeObjectIdT(t);
default: throw new Error('unrecognized id generation method');
}
}
/** /**
* IDを生成します() * IDを生成します()
* @param time * @param time

View File

@ -54,6 +54,7 @@ import { FeaturedService } from '@/core/FeaturedService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js';
import { isReply } from '@/misc/is-reply.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -859,7 +860,7 @@ export class NoteCreateService implements OnApplicationShutdown {
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue; if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
// 「自分自身への返信 or そのフォロワーへの返信」のどちらでもない場合 // 「自分自身への返信 or そのフォロワーへの返信」のどちらでもない場合
if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === following.followerId)) { if (isReply(note, following.followerId)) {
if (!following.withReplies) continue; if (!following.withReplies) continue;
} }
@ -877,7 +878,7 @@ export class NoteCreateService implements OnApplicationShutdown {
) continue; ) continue;
// 「自分自身への返信 or そのリストの作成者への返信」のどちらでもない場合 // 「自分自身への返信 or そのリストの作成者への返信」のどちらでもない場合
if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === userListMembership.userListUserId)) { if (isReply(note, userListMembership.userListUserId)) {
if (!userListMembership.withReplies) continue; if (!userListMembership.withReplies) continue;
} }
@ -895,11 +896,14 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
// 自分自身以外への返信 // 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) { if (isReply(note)) {
this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
if (note.visibility === 'public' && note.userHost == null) { if (note.visibility === 'public' && note.userHost == null) {
this.fanoutTimelineService.push('localTimelineWithReplies', note.id, 300, r); this.fanoutTimelineService.push('localTimelineWithReplies', note.id, 300, r);
if (note.replyUserHost == null) {
this.fanoutTimelineService.push(`localTimelineWithReplyTo:${note.replyUserId}`, note.id, 300 / 10, r);
}
} }
} else { } else {
this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);

View File

@ -77,7 +77,7 @@ export class NotePiningService {
} as MiUserNotePining); } as MiUserNotePining);
// Deliver to remote followers // Deliver to remote followers
if (this.userEntityService.isLocalUser(user)) { if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) {
this.deliverPinnedChange(user.id, note.id, true); this.deliverPinnedChange(user.id, note.id, true);
} }
} }
@ -105,7 +105,7 @@ export class NotePiningService {
}); });
// Deliver to remote followers // Deliver to remote followers
if (this.userEntityService.isLocalUser(user)) { if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) {
this.deliverPinnedChange(user.id, noteId, false); this.deliverPinnedChange(user.id, noteId, false);
} }
} }

View File

@ -12,6 +12,8 @@ import { MiNote } from '@/models/Note.js';
import { MiUser } from '@/models/_.js'; import { MiUser } from '@/models/_.js';
import type { NotesRepository } from '@/models/_.js'; import type { NotesRepository } from '@/models/_.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { CacheService } from '@/core/CacheService.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import type { Index, MeiliSearch } from 'meilisearch'; import type { Index, MeiliSearch } from 'meilisearch';
@ -74,6 +76,7 @@ export class SearchService {
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
private cacheService: CacheService,
private queryService: QueryService, private queryService: QueryService,
private idService: IdService, private idService: IdService,
) { ) {
@ -187,8 +190,19 @@ export class SearchService {
limit: pagination.limit, limit: pagination.limit,
}); });
if (res.hits.length === 0) return []; if (res.hits.length === 0) return [];
const notes = await this.notesRepository.findBy({ const [
userIdsWhoMeMuting,
userIdsWhoBlockingMe,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>()];
const notes = (await this.notesRepository.findBy({
id: In(res.hits.map(x => x.id)), id: In(res.hits.map(x => x.id)),
})).filter(note => {
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
return true;
}); });
return notes.sort((a, b) => a.id > b.id ? -1 : 1); return notes.sort((a, b) => a.id > b.id ? -1 : 1);
} else { } else {

View File

@ -304,8 +304,6 @@ export class UserFollowingService implements OnModuleInit {
}); });
} }
}); });
this.fanoutTimelineService.purge(`homeTimeline:${follower.id}`);
} }
// Publish followed event // Publish followed event
@ -373,8 +371,6 @@ export class UserFollowingService implements OnModuleInit {
}); });
} }
}); });
this.fanoutTimelineService.purge(`homeTimeline:${follower.id}`);
} }
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {

View File

@ -306,9 +306,15 @@ export class ApInboxService {
this.logger.info(`Creating the (Re)Note: ${uri}`); this.logger.info(`Creating the (Re)Note: ${uri}`);
const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc); const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc);
const createdAt = activity.published ? new Date(activity.published) : null;
if (createdAt && createdAt < this.idService.parse(renote.id).date) {
this.logger.warn('skip: malformed createdAt');
return;
}
await this.noteCreateService.create(actor, { await this.noteCreateService.create(actor, {
createdAt: activity.published ? new Date(activity.published) : null, createdAt,
renote, renote,
visibility: activityAudience.visibility, visibility: activityAudience.visibility,
visibleUsers: activityAudience.visibleUsers, visibleUsers: activityAudience.visibleUsers,

View File

@ -92,6 +92,10 @@ export class ApNoteService {
return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`); return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`);
} }
if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) {
return new Error('invalid Note: published timestamp is malformed');
}
return null; return null;
} }

View File

@ -34,3 +34,7 @@ export function parseAid(id: string): { date: Date; } {
const time = parseInt(id.slice(0, 8), 36) + TIME2000; const time = parseInt(id.slice(0, 8), 36) + TIME2000;
return { date: new Date(time) }; return { date: new Date(time) };
} }
export function isSafeAidT(t: number): boolean {
return t > TIME2000;
}

View File

@ -41,3 +41,7 @@ export function parseAidx(id: string): { date: Date; } {
const time = parseInt(id.slice(0, TIME_LENGTH), 36) + TIME2000; const time = parseInt(id.slice(0, TIME_LENGTH), 36) + TIME2000;
return { date: new Date(time) }; return { date: new Date(time) };
} }
export function isSafeAidxT(t: number): boolean {
return t > TIME2000;
}

View File

@ -38,3 +38,7 @@ export function parseMeid(id: string): { date: Date; } {
date: new Date(parseInt(id.slice(0, 12), 16) - 0x800000000000), date: new Date(parseInt(id.slice(0, 12), 16) - 0x800000000000),
}; };
} }
export function isSafeMeidT(t: number): boolean {
return t > 0;
}

View File

@ -38,3 +38,7 @@ export function parseMeidg(id: string): { date: Date; } {
date: new Date(parseInt(id.slice(1, 12), 16)), date: new Date(parseInt(id.slice(1, 12), 16)),
}; };
} }
export function isSafeMeidgT(t: number): boolean {
return t > 0;
}

View File

@ -38,3 +38,7 @@ export function parseObjectId(id: string): { date: Date; } {
date: new Date(parseInt(id.slice(0, 8), 16) * 1000), date: new Date(parseInt(id.slice(0, 8), 16) * 1000),
}; };
} }
export function isSafeObjectIdT(t: number): boolean {
return t > 0;
}

View File

@ -3,12 +3,13 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { MiNote } from '@/models/Note.js';
import type { Packed } from './json-schema.js'; import type { Packed } from './json-schema.js';
export function isInstanceMuted(note: Packed<'Note'>, mutedInstances: Set<string>): boolean { export function isInstanceMuted(note: Packed<'Note'> | MiNote, mutedInstances: Set<string>): boolean {
if (mutedInstances.has(note.user.host ?? '')) return true; if (mutedInstances.has(note.user?.host ?? '')) return true;
if (mutedInstances.has(note.reply?.user.host ?? '')) return true; if (mutedInstances.has(note.reply?.user?.host ?? '')) return true;
if (mutedInstances.has(note.renote?.user.host ?? '')) return true; if (mutedInstances.has(note.renote?.user?.host ?? '')) return true;
return false; return false;
} }

View File

@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MiUser } from '@/models/User.js';
export function isReply(note: any, viewerId?: MiUser['id'] | undefined | null): boolean {
return note.replyId && note.replyUserId !== note.userId && note.replyUserId !== viewerId;
}

View File

@ -36,6 +36,8 @@ import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js';
import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js'; import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js';
import { packedFlashSchema } from '@/models/json-schema/flash.js'; import { packedFlashSchema } from '@/models/json-schema/flash.js';
import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js';
import { packedSigninSchema } from '@/models/json-schema/signin.js';
import { packedRoleLiteSchema, packedRoleSchema } from '@/models/json-schema/role.js';
export const refs = { export const refs = {
UserLite: packedUserLiteSchema, UserLite: packedUserLiteSchema,
@ -71,6 +73,9 @@ export const refs = {
EmojiSimple: packedEmojiSimpleSchema, EmojiSimple: packedEmojiSimpleSchema,
EmojiDetailed: packedEmojiDetailedSchema, EmojiDetailed: packedEmojiDetailedSchema,
Flash: packedFlashSchema, Flash: packedFlashSchema,
Signin: packedSigninSchema,
RoleLite: packedRoleLiteSchema,
Role: packedRoleSchema,
}; };
export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>; export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;

View File

@ -0,0 +1,157 @@
const rolePolicyValue = {
type: 'object',
properties: {
value: {
oneOf: [
{
type: 'integer',
optional: false, nullable: false,
},
{
type: 'boolean',
optional: false, nullable: false,
},
],
},
priority: {
type: 'integer',
optional: false, nullable: false,
},
useDefault: {
type: 'boolean',
optional: false, nullable: false,
},
},
} as const;
export const packedRoleLiteSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
example: 'xxxxxxxxxx',
},
name: {
type: 'string',
optional: false, nullable: false,
example: 'New Role',
},
color: {
type: 'string',
optional: false, nullable: true,
example: '#000000',
},
iconUrl: {
type: 'string',
optional: false, nullable: true,
},
description: {
type: 'string',
optional: false, nullable: false,
},
isModerator: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
isAdministrator: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
displayOrder: {
type: 'integer',
optional: false, nullable: false,
example: 0,
},
},
} as const;
export const packedRoleSchema = {
type: 'object',
allOf: [
{
type: 'object',
ref: 'RoleLite',
},
{
type: 'object',
properties: {
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
updatedAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
target: {
type: 'string',
optional: false, nullable: false,
enum: ['manual', 'conditional'],
},
condFormula: {
type: 'object',
optional: false, nullable: false,
},
isPublic: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
isExplorable: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
asBadge: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
canEditMembersByModerator: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
policies: {
type: 'object',
optional: false, nullable: false,
properties: {
pinLimit: rolePolicyValue,
canInvite: rolePolicyValue,
clipLimit: rolePolicyValue,
canHideAds: rolePolicyValue,
inviteLimit: rolePolicyValue,
antennaLimit: rolePolicyValue,
gtlAvailable: rolePolicyValue,
ltlAvailable: rolePolicyValue,
webhookLimit: rolePolicyValue,
canPublicNote: rolePolicyValue,
userListLimit: rolePolicyValue,
wordMuteLimit: rolePolicyValue,
alwaysMarkNsfw: rolePolicyValue,
canSearchNotes: rolePolicyValue,
driveCapacityMb: rolePolicyValue,
rateLimitFactor: rolePolicyValue,
inviteLimitCycle: rolePolicyValue,
noteEachClipsLimit: rolePolicyValue,
inviteExpirationTime: rolePolicyValue,
canManageCustomEmojis: rolePolicyValue,
userEachUserListsLimit: rolePolicyValue,
canManageAvatarDecorations: rolePolicyValue,
canUseTranslator: rolePolicyValue,
},
},
usersCount: {
type: 'integer',
optional: false, nullable: false,
},
},
},
],
} as const;

View File

@ -0,0 +1,26 @@
export const packedSigninSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
ip: {
type: 'string',
optional: false, nullable: false,
},
headers: {
type: 'object',
optional: false, nullable: false,
},
success: {
type: 'boolean',
optional: false, nullable: false,
},
},
} as const;

View File

@ -3,6 +3,18 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
const notificationRecieveConfig = {
type: 'object',
nullable: false, optional: true,
properties: {
type: {
type: 'string',
nullable: false, optional: false,
enum: ['all', 'following', 'follower', 'mutualFollow', 'list', 'never'],
},
},
} as const;
export const packedUserLiteSchema = { export const packedUserLiteSchema = {
type: 'object', type: 'object',
properties: { properties: {
@ -326,41 +338,7 @@ export const packedUserDetailedNotMeOnlySchema = {
items: { items: {
type: 'object', type: 'object',
nullable: false, optional: false, nullable: false, optional: false,
properties: { ref: 'RoleLite',
id: {
type: 'string',
nullable: false, optional: false,
format: 'id',
},
name: {
type: 'string',
nullable: false, optional: false,
},
color: {
type: 'string',
nullable: true, optional: false,
},
iconUrl: {
type: 'string',
nullable: true, optional: false,
},
description: {
type: 'string',
nullable: false, optional: false,
},
isModerator: {
type: 'boolean',
nullable: false, optional: false,
},
isAdministrator: {
type: 'boolean',
nullable: false, optional: false,
},
displayOrder: {
type: 'number',
nullable: false, optional: false,
},
},
}, },
}, },
memo: { memo: {
@ -407,6 +385,7 @@ export const packedUserDetailedNotMeOnlySchema = {
notify: { notify: {
type: 'string', type: 'string',
nullable: false, optional: true, nullable: false, optional: true,
enum: ['normal', 'none'],
}, },
withReplies: { withReplies: {
type: 'boolean', type: 'boolean',
@ -562,6 +541,19 @@ export const packedMeDetailedOnlySchema = {
notificationRecieveConfig: { notificationRecieveConfig: {
type: 'object', type: 'object',
nullable: false, optional: false, nullable: false, optional: false,
properties: {
app: notificationRecieveConfig,
quote: notificationRecieveConfig,
reply: notificationRecieveConfig,
follow: notificationRecieveConfig,
renote: notificationRecieveConfig,
mention: notificationRecieveConfig,
reaction: notificationRecieveConfig,
pollEnded: notificationRecieveConfig,
achievementEarned: notificationRecieveConfig,
receiveFollowRequest: notificationRecieveConfig,
followRequestAccepted: notificationRecieveConfig,
},
}, },
emailNotificationTypes: { emailNotificationTypes: {
type: 'array', type: 'array',
@ -706,6 +698,23 @@ export const packedMeDetailedOnlySchema = {
items: { items: {
type: 'object', type: 'object',
nullable: false, optional: false, nullable: false, optional: false,
properties: {
id: {
type: 'string',
nullable: false, optional: false,
format: 'id',
example: 'xxxxxxxxxx',
},
name: {
type: 'string',
nullable: false, optional: false,
},
lastUsed: {
type: 'string',
nullable: false, optional: false,
format: 'date-time',
},
},
}, },
}, },
//#endregion //#endregion

View File

@ -370,8 +370,9 @@ export class ActivityPubServerService {
order: { id: 'DESC' }, order: { id: 'DESC' },
}); });
const pinnedNotes = await Promise.all(pinings.map(pining => const pinnedNotes = (await Promise.all(pinings.map(pining =>
this.notesRepository.findOneByOrFail({ id: pining.noteId }))); this.notesRepository.findOneByOrFail({ id: pining.noteId }))))
.filter(note => !note.localOnly && ['public', 'home'].includes(note.visibility));
const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note))); const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note)));

View File

@ -96,6 +96,11 @@ export class NodeinfoServerService {
metadata: { metadata: {
nodeName: meta.name, nodeName: meta.name,
nodeDescription: meta.description, nodeDescription: meta.description,
nodeAdmins: [{
name: meta.maintainerName,
email: meta.maintainerEmail,
}],
// deprecated
maintainer: { maintainer: {
name: meta.maintainerName, name: meta.maintainerName,
email: meta.maintainerEmail, email: meta.maintainerEmail,

View File

@ -119,8 +119,8 @@ export class ServerService implements OnApplicationShutdown {
return; return;
} }
const name = path.split('@')[0].replace('.webp', ''); const name = path.split('@')[0].replace(/\.webp$/i, '');
const host = path.split('@')[1]?.replace('.webp', ''); const host = path.split('@')[1]?.replace(/\.webp$/i, '');
const emoji = await this.emojisRepository.findOneBy({ const emoji = await this.emojisRepository.findOneBy({
// `@.` is the spec of ReactionService.decodeReaction // `@.` is the spec of ReactionService.decodeReaction

View File

@ -6,14 +6,12 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { EmojisRepository } from '@/models/_.js'; import type { EmojisRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiDriveFile } from '@/models/DriveFile.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { DriveService } from '@/core/DriveService.js'; import { DriveService } from '@/core/DriveService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { ApiError } from '../../../error.js'; import { ApiError } from '../../../error.js';
import {IsNull} from "typeorm";
export const meta = { export const meta = {
tags: ['admin'], tags: ['admin'],
@ -27,11 +25,11 @@ export const meta = {
code: 'NO_SUCH_EMOJI', code: 'NO_SUCH_EMOJI',
id: 'e2785b66-dca3-4087-9cac-b93c541cc425', id: 'e2785b66-dca3-4087-9cac-b93c541cc425',
}, },
duplicationEmojiAdd: { duplicateName: {
message: 'This emoji is already added.', message: 'Duplicate name.',
code: 'DUPLICATION_EMOJI_ADD', code: 'DUPLICATE_NAME',
id: 'mattyaski_emoji_duplication_error', id: 'f7a3462c-4e6e-4069-8421-b9bd4f4c3975',
} },
}, },
res: { res: {
@ -62,63 +60,43 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor( constructor(
@Inject(DI.emojisRepository) @Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository, private emojisRepository: EmojisRepository,
private emojiEntityService: EmojiEntityService, private emojiEntityService: EmojiEntityService,
private idService: IdService, private customEmojiService: CustomEmojiService,
private globalEventService: GlobalEventService,
private driveService: DriveService, private driveService: DriveService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const emoji = await this.emojisRepository.findOneBy({ id: ps.emojiId }); const emoji = await this.emojisRepository.findOneBy({ id: ps.emojiId });
if (emoji == null) { if (emoji == null) {
throw new ApiError(meta.errors.noSuchEmoji); throw new ApiError(meta.errors.noSuchEmoji);
} }
const duplicationEmoji = await this.emojisRepository.find({
where: {
name: emoji.name,
host: IsNull()
},
});
duplicationEmoji.forEach(
(_emoji) => {
if (_emoji.name === emoji.name) {
throw new ApiError(meta.errors.duplicationEmojiAdd);
}
}
)
let driveFile: MiDriveFile; let driveFile: MiDriveFile;
try { try {
// Create file // Create file
driveFile = await this.driveService.uploadFromUrl({ url: emoji.originalUrl, user: null, force: true }); driveFile = await this.driveService.uploadFromUrl({ url: emoji.originalUrl, user: null, force: true });
} catch (e) { } catch (e) {
// TODO: need to return Drive Error
throw new ApiError(); throw new ApiError();
} }
const copied = await this.emojisRepository.insert({ // Duplication Check
id: this.idService.gen(), const isDuplicate = await this.customEmojiService.checkDuplicate(emoji.name);
updatedAt: new Date(), if (isDuplicate) throw new ApiError(meta.errors.duplicateName);
const addedEmoji = await this.customEmojiService.add({
driveFile,
name: emoji.name, name: emoji.name,
category: emoji.category,
aliases: emoji.aliases,
host: null, host: null,
aliases: [],
originalUrl: driveFile.url,
publicUrl: driveFile.webpublicUrl ?? driveFile.url,
type: driveFile.webpublicType ?? driveFile.type,
license: emoji.license, license: emoji.license,
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); isSensitive: emoji.isSensitive,
localOnly: emoji.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction,
}, me);
this.globalEventService.publishBroadcastStream('emojiAdded', { return this.emojiEntityService.packDetailed(addedEmoji);
emoji: await this.emojiEntityService.packDetailed(copied.id),
});
return {
id: copied.id,
};
}); });
} }
} }

View File

@ -327,6 +327,82 @@ export const meta = {
type: 'number', type: 'number',
optional: false, nullable: false, optional: false, nullable: false,
}, },
backgroundImageUrl: {
type: 'string',
optional: false, nullable: true,
},
deeplAuthKey: {
type: 'string',
optional: false, nullable: true,
},
deeplIsPro: {
type: 'boolean',
optional: false, nullable: false,
},
defaultDarkTheme: {
type: 'string',
optional: false, nullable: true,
},
defaultLightTheme: {
type: 'string',
optional: false, nullable: true,
},
description: {
type: 'string',
optional: false, nullable: true,
},
disableRegistration: {
type: 'boolean',
optional: false, nullable: false,
},
impressumUrl: {
type: 'string',
optional: false, nullable: true,
},
maintainerEmail: {
type: 'string',
optional: false, nullable: true,
},
maintainerName: {
type: 'string',
optional: false, nullable: true,
},
name: {
type: 'string',
optional: false, nullable: true,
},
objectStorageS3ForcePathStyle: {
type: 'boolean',
optional: false, nullable: false,
},
privacyPolicyUrl: {
type: 'string',
optional: false, nullable: true,
},
repositoryUrl: {
type: 'string',
optional: false, nullable: false,
},
summalyProxy: {
type: 'string',
optional: false, nullable: true,
},
themeColor: {
type: 'string',
optional: false, nullable: true,
},
tosUrl: {
type: 'string',
optional: false, nullable: true,
},
uri: {
type: 'string',
optional: false, nullable: false,
},
version: {
type: 'string',
optional: false, nullable: false,
},
DiscordWebhookUrl: { DiscordWebhookUrl: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,

View File

@ -13,6 +13,12 @@ export const meta = {
requireCredential: true, requireCredential: true,
requireAdmin: true, requireAdmin: true,
res: {
type: 'object',
optional: false, nullable: false,
ref: 'Role',
},
} as const; } as const;
export const paramDef = { export const paramDef = {

View File

@ -14,6 +14,16 @@ export const meta = {
requireCredential: true, requireCredential: true,
requireModerator: true, requireModerator: true,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'Role',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {

View File

@ -23,6 +23,12 @@ export const meta = {
id: '07dc7d34-c0d8-49b7-96c6-db3ce64ee0b3', id: '07dc7d34-c0d8-49b7-96c6-db3ce64ee0b3',
}, },
}, },
res: {
type: 'object',
optional: false, nullable: false,
ref: 'Role',
},
} as const; } as const;
export const paramDef = { export const paramDef = {

View File

@ -4,18 +4,17 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { ChannelsRepository, MiNote, NotesRepository } from '@/models/_.js'; import type { ChannelsRepository, NotesRepository } from '@/models/_.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { MiLocalUser } from '@/models/User.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -51,6 +50,7 @@ export const paramDef = {
untilId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' }, sinceDate: { type: 'integer' },
untilDate: { type: 'integer' }, untilDate: { type: 'integer' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
}, },
required: ['channelId'], required: ['channelId'],
} as const; } as const;
@ -58,9 +58,6 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@ -70,7 +67,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService, private idService: IdService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService, private queryService: QueryService,
private fanoutTimelineService: FanoutTimelineService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private cacheService: CacheService, private cacheService: CacheService,
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private metaService: MetaService, private metaService: MetaService,
@ -78,7 +75,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
const isRangeSpecified = untilId != null && sinceId != null;
const serverSettings = await this.metaService.fetch(); const serverSettings = await this.metaService.fetch();
@ -92,45 +88,35 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me) this.activeUsersChart.read(me); if (me) this.activeUsersChart.read(me);
if (serverSettings.enableFanoutTimeline && (isRangeSpecified || sinceId == null)) { if (!serverSettings.enableFanoutTimeline) {
const [ return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id }, me), me);
userIdsWhoMeMuting, }
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
]) : [new Set<string>()];
let noteIds = await this.fanoutTimelineService.get(`channelTimeline:${channel.id}`, untilId, sinceId); return await this.fanoutTimelineEndpointService.timeline({
noteIds = noteIds.slice(0, ps.limit); untilId,
sinceId,
if (noteIds.length > 0) { limit: ps.limit,
const query = this.notesRepository.createQueryBuilder('note') allowPartial: ps.allowPartial,
.where('note.id IN (:...noteIds)', { noteIds: noteIds }) me,
.innerJoinAndSelect('note.user', 'user') useDbFallback: true,
.leftJoinAndSelect('note.reply', 'reply') redisTimelines: [`channelTimeline:${channel.id}`],
.leftJoinAndSelect('note.renote', 'renote') excludePureRenotes: false,
.leftJoinAndSelect('reply.user', 'replyUser') dbFallback: async (untilId, sinceId, limit) => {
.leftJoinAndSelect('renote.user', 'renoteUser') return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me);
.leftJoinAndSelect('note.channel', 'channel'); },
});
let timeline = await query.getMany();
timeline = timeline.filter(note => {
return !isUserRelated(note, userIdsWhoMeMuting);
}); });
// TODO: フィルタで件数が減った場合の埋め合わせ処理
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
if (timeline.length > 0) {
return await this.noteEntityService.packMany(timeline, me);
}
}
} }
private async getFromDb(ps: {
untilId: string | null,
sinceId: string | null,
limit: number,
channelId: string
}, me: MiLocalUser | null) {
//#region fallback to database //#region fallback to database
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere('note.channelId = :channelId', { channelId: channel.id }) .andWhere('note.channelId = :channelId', { channelId: ps.channelId })
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
@ -144,10 +130,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
//#endregion //#endregion
const timeline = await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();
return await this.noteEntityService.packMany(timeline, me);
//#endregion
});
} }
} }

View File

@ -36,13 +36,32 @@ export const paramDef = {
blocked: { type: 'boolean', nullable: true }, blocked: { type: 'boolean', nullable: true },
notResponding: { type: 'boolean', nullable: true }, notResponding: { type: 'boolean', nullable: true },
suspended: { type: 'boolean', nullable: true }, suspended: { type: 'boolean', nullable: true },
silenced: { type: "boolean", nullable: true }, silenced: { type: 'boolean', nullable: true },
federating: { type: 'boolean', nullable: true }, federating: { type: 'boolean', nullable: true },
subscribing: { type: 'boolean', nullable: true }, subscribing: { type: 'boolean', nullable: true },
publishing: { type: 'boolean', nullable: true }, publishing: { type: 'boolean', nullable: true },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
offset: { type: 'integer', default: 0 }, offset: { type: 'integer', default: 0 },
sort: { type: 'string' }, sort: {
type: 'string',
nullable: true,
enum: [
'+pubSub',
'-pubSub',
'+notes',
'-notes',
'+users',
'-users',
'+following',
'-following',
'+followers',
'-followers',
'+firstRetrievedAt',
'-firstRetrievedAt',
'+latestRequestReceivedAt',
'-latestRequestReceivedAt',
],
},
}, },
required: [], required: [],
} as const; } as const;
@ -103,18 +122,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
} }
if (typeof ps.silenced === "boolean") { if (typeof ps.silenced === 'boolean') {
const meta = await this.metaService.fetch(true); const meta = await this.metaService.fetch(true);
if (ps.silenced) { if (ps.silenced) {
if (meta.silencedHosts.length === 0) { if (meta.silencedHosts.length === 0) {
return []; return [];
} }
query.andWhere("instance.host IN (:...silences)", { query.andWhere('instance.host IN (:...silences)', {
silences: meta.silencedHosts, silences: meta.silencedHosts,
}); });
} else if (meta.silencedHosts.length > 0) { } else if (meta.silencedHosts.length > 0) {
query.andWhere("instance.host NOT IN (:...silences)", { query.andWhere('instance.host NOT IN (:...silences)', {
silences: meta.silencedHosts, silences: meta.silencedHosts,
}); });
} }

View File

@ -12,8 +12,17 @@ import { DI } from '@/di-symbols.js';
export const meta = { export const meta = {
requireCredential: true, requireCredential: true,
secure: true, secure: true,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'Signin',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {

View File

@ -250,6 +250,33 @@ export const meta = {
}, },
}, },
}, },
backgroundImageUrl: {
type: 'string',
optional: false, nullable: true,
},
impressumUrl: {
type: 'string',
optional: false, nullable: true,
},
logoImageUrl: {
type: 'string',
optional: false, nullable: true,
},
privacyPolicyUrl: {
type: 'string',
optional: false, nullable: true,
},
serverRules: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
},
},
themeColor: {
type: 'string',
optional: false, nullable: true,
},
}, },
}, },
} as const; } as const;

View File

@ -9,9 +9,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { FeaturedService } from '@/core/FeaturedService.js'; import { FeaturedService } from '@/core/FeaturedService.js';
import {isUserRelated} from "@/misc/is-user-related.js"; import { isUserRelated } from '@/misc/is-user-related.js';
import {CacheService} from "@/core/CacheService.js"; import { CacheService } from '@/core/CacheService.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@ -67,11 +66,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.globalNotesRankingCacheLastFetchedAt = Date.now(); this.globalNotesRankingCacheLastFetchedAt = Date.now();
} }
} }
const [
userIdsWhoMeMuting,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
]) : [new Set<string>()];
noteIds.sort((a, b) => a > b ? -1 : 1); noteIds.sort((a, b) => a > b ? -1 : 1);
if (ps.untilId) { if (ps.untilId) {
@ -83,6 +77,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return []; return [];
} }
const [
userIdsWhoMeMuting,
userIdsWhoBlockingMe,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>()];
const query = this.notesRepository.createQueryBuilder('note') const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds }) .where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
@ -92,13 +94,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel'); .leftJoinAndSelect('note.channel', 'channel');
let notes = await query.getMany(); const notes = (await query.getMany()).filter(note => {
notes.sort((a, b) => a.id > b.id ? -1 : 1); if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
notes = notes.filter(note => { if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
return !isUserRelated(note, userIdsWhoMeMuting);
return true;
}); });
notes.sort((a, b) => a.id > b.id ? -1 : 1);
// TODO: ミュート等考慮 // TODO: ミュート等考慮
return await this.noteEntityService.packMany(notes, me); return await this.noteEntityService.packMany(notes, me);
}); });
} }

View File

@ -5,20 +5,20 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository, FollowingsRepository, MiNote, ChannelFollowingsRepository } from '@/models/_.js'; import type { NotesRepository, ChannelFollowingsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { FanoutTimelineName } from '@/core/FanoutTimelineService.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { MiLocalUser } from '@/models/User.js'; import { MiLocalUser } from '@/models/User.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -42,6 +42,12 @@ export const meta = {
code: 'STL_DISABLED', code: 'STL_DISABLED',
id: '620763f4-f621-4533-ab33-0577a1a3c342', id: '620763f4-f621-4533-ab33-0577a1a3c342',
}, },
bothWithRepliesAndWithFiles: {
message: 'Specifying both withReplies and withFiles is not supported',
code: 'BOTH_WITH_REPLIES_AND_WITH_FILES',
id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f'
},
}, },
} as const; } as const;
@ -53,6 +59,7 @@ export const paramDef = {
untilId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' }, sinceDate: { type: 'integer' },
untilDate: { type: 'integer' }, untilDate: { type: 'integer' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
includeMyRenotes: { type: 'boolean', default: true }, includeMyRenotes: { type: 'boolean', default: true },
includeRenotedMyNotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true },
@ -77,10 +84,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private idService: IdService, private idService: IdService,
private cacheService: CacheService, private cacheService: CacheService,
private fanoutTimelineService: FanoutTimelineService,
private queryService: QueryService, private queryService: QueryService,
private userFollowingService: UserFollowingService, private userFollowingService: UserFollowingService,
private metaService: MetaService, private metaService: MetaService,
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@ -91,10 +98,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.stlDisabled); throw new ApiError(meta.errors.stlDisabled);
} }
if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles);
const serverSettings = await this.metaService.fetch(); const serverSettings = await this.metaService.fetch();
if (!serverSettings.enableFanoutTimeline) { if (!serverSettings.enableFanoutTimeline) {
return await this.getFromDb({ const timeline = await this.getFromDb({
untilId, untilId,
sinceId, sinceId,
limit: ps.limit, limit: ps.limit,
@ -104,103 +113,61 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withFiles: ps.withFiles, withFiles: ps.withFiles,
withReplies: ps.withReplies, withReplies: ps.withReplies,
}, me); }, me);
}
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]);
let noteIds: string[];
let shouldFallbackToDb = false;
if (ps.withFiles) {
const [htlNoteIds, ltlNoteIds] = await this.fanoutTimelineService.getMulti([
`homeTimelineWithFiles:${me.id}`,
'localTimelineWithFiles',
], untilId, sinceId);
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
} else if (ps.withReplies) {
const [htlNoteIds, ltlNoteIds, ltlReplyNoteIds] = await this.fanoutTimelineService.getMulti([
`homeTimeline:${me.id}`,
'localTimeline',
'localTimelineWithReplies',
], untilId, sinceId);
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds, ...ltlReplyNoteIds]));
} else {
const [htlNoteIds, ltlNoteIds] = await this.fanoutTimelineService.getMulti([
`homeTimeline:${me.id}`,
'localTimeline',
], untilId, sinceId);
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
shouldFallbackToDb = htlNoteIds.length === 0;
}
noteIds.sort((a, b) => a > b ? -1 : 1);
noteIds = noteIds.slice(0, ps.limit);
shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0);
let redisTimeline: MiNote[] = [];
if (!shouldFallbackToDb) {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
redisTimeline = await query.getMany();
redisTimeline = redisTimeline.filter(note => {
if (note.userId === me.id) {
return true;
}
if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
return true;
});
redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1);
}
if (redisTimeline.length > 0) {
process.nextTick(() => { process.nextTick(() => {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);
}); });
return await this.noteEntityService.packMany(redisTimeline, me); return await this.noteEntityService.packMany(timeline, me);
}
let timelineConfig: FanoutTimelineName[];
if (ps.withFiles) {
timelineConfig = [
`homeTimelineWithFiles:${me.id}`,
'localTimelineWithFiles',
];
} else if (ps.withReplies) {
timelineConfig = [
`homeTimeline:${me.id}`,
'localTimeline',
'localTimelineWithReplies',
];
} else { } else {
if (serverSettings.enableFanoutTimelineDbFallback) { // fallback to db timelineConfig = [
return await this.getFromDb({ `homeTimeline:${me.id}`,
'localTimeline',
];
}
const redisTimeline = await this.fanoutTimelineEndpointService.timeline({
untilId, untilId,
sinceId, sinceId,
limit: ps.limit, limit: ps.limit,
allowPartial: ps.allowPartial,
me,
redisTimelines: timelineConfig,
useDbFallback: serverSettings.enableFanoutTimelineDbFallback,
alwaysIncludeMyNotes: true,
excludePureRenotes: !ps.withRenotes,
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
untilId,
sinceId,
limit,
includeMyRenotes: ps.includeMyRenotes, includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes, includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes, includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles, withFiles: ps.withFiles,
withReplies: ps.withReplies, withReplies: ps.withReplies,
}, me); }, me),
} else { });
return [];
} process.nextTick(() => {
} this.activeUsersChart.read(me);
});
return redisTimeline;
}); });
} }
@ -301,12 +268,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
//#endregion //#endregion
const timeline = await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(timeline, me);
} }
} }

View File

@ -5,7 +5,7 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { MiNote, NotesRepository } from '@/models/_.js'; import type { NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
@ -13,11 +13,10 @@ import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { MiLocalUser } from '@/models/User.js'; import { MiLocalUser } from '@/models/User.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -39,6 +38,12 @@ export const meta = {
code: 'LTL_DISABLED', code: 'LTL_DISABLED',
id: '45a6eb02-7695-4393-b023-dd3be9aaaefd', id: '45a6eb02-7695-4393-b023-dd3be9aaaefd',
}, },
bothWithRepliesAndWithFiles: {
message: 'Specifying both withReplies and withFiles is not supported',
code: 'BOTH_WITH_REPLIES_AND_WITH_FILES',
id: 'dd9c8400-1cb5-4eef-8a31-200c5f933793',
},
}, },
} as const; } as const;
@ -48,10 +53,10 @@ export const paramDef = {
withFiles: { type: 'boolean', default: false }, withFiles: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true },
withReplies: { type: 'boolean', default: false }, withReplies: { type: 'boolean', default: false },
excludeNsfw: { type: 'boolean', default: false },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' }, sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
sinceDate: { type: 'integer' }, sinceDate: { type: 'integer' },
untilDate: { type: 'integer' }, untilDate: { type: 'integer' },
}, },
@ -69,7 +74,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private idService: IdService, private idService: IdService,
private cacheService: CacheService, private cacheService: CacheService,
private fanoutTimelineService: FanoutTimelineService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private queryService: QueryService, private queryService: QueryService,
private metaService: MetaService, private metaService: MetaService,
) { ) {
@ -82,99 +87,59 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.ltlDisabled); throw new ApiError(meta.errors.ltlDisabled);
} }
if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles);
const serverSettings = await this.metaService.fetch(); const serverSettings = await this.metaService.fetch();
if (!serverSettings.enableFanoutTimeline) { if (!serverSettings.enableFanoutTimeline) {
return await this.getFromDb({ const timeline = await this.getFromDb({
untilId, untilId,
sinceId, sinceId,
limit: ps.limit, limit: ps.limit,
withFiles: ps.withFiles, withFiles: ps.withFiles,
withReplies: ps.withReplies, withReplies: ps.withReplies,
}, me); }, me);
}
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>(), new Set<string>()];
let noteIds: string[];
if (ps.withFiles) {
noteIds = await this.fanoutTimelineService.get('localTimelineWithFiles', untilId, sinceId);
} else {
const [nonReplyNoteIds, replyNoteIds] = await this.fanoutTimelineService.getMulti([
'localTimeline',
'localTimelineWithReplies',
], untilId, sinceId);
noteIds = Array.from(new Set([...nonReplyNoteIds, ...replyNoteIds]));
noteIds.sort((a, b) => a > b ? -1 : 1);
}
noteIds = noteIds.slice(0, ps.limit);
let redisTimeline: MiNote[] = [];
if (noteIds.length > 0) {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
redisTimeline = await query.getMany();
redisTimeline = redisTimeline.filter(note => {
if (me && (note.userId === me.id)) {
return true;
}
if (!ps.withReplies && note.replyId && note.replyUserId !== note.userId && (me == null || note.replyUserId !== me.id)) return false;
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
return true;
});
redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1);
}
if (redisTimeline.length > 0) {
process.nextTick(() => { process.nextTick(() => {
if (me) { if (me) {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);
} }
}); });
return await this.noteEntityService.packMany(redisTimeline, me); return await this.noteEntityService.packMany(timeline, me);
} else { }
if (serverSettings.enableFanoutTimelineDbFallback) { // fallback to db
return await this.getFromDb({ const timeline = await this.fanoutTimelineEndpointService.timeline({
untilId, untilId,
sinceId, sinceId,
limit: ps.limit, limit: ps.limit,
allowPartial: ps.allowPartial,
me,
useDbFallback: serverSettings.enableFanoutTimelineDbFallback,
redisTimelines:
ps.withFiles ? ['localTimelineWithFiles']
: ps.withReplies ? ['localTimeline', 'localTimelineWithReplies']
: me ? ['localTimeline', `localTimelineWithReplyTo:${me.id}`]
: ['localTimeline'],
alwaysIncludeMyNotes: true,
excludePureRenotes: !ps.withRenotes,
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
untilId,
sinceId,
limit,
withFiles: ps.withFiles, withFiles: ps.withFiles,
withReplies: ps.withReplies, withReplies: ps.withReplies,
}, me); }, me),
} else { });
return [];
} process.nextTick(() => {
if (me) {
this.activeUsersChart.read(me);
} }
}); });
return timeline;
});
} }
private async getFromDb(ps: { private async getFromDb(ps: {
@ -214,14 +179,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
})); }));
} }
const timeline = await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();
process.nextTick(() => {
if (me) {
this.activeUsersChart.read(me);
}
});
return await this.noteEntityService.packMany(timeline, me);
} }
} }

View File

@ -5,7 +5,7 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { MiNote, NotesRepository, ChannelFollowingsRepository } from '@/models/_.js'; import type { NotesRepository, ChannelFollowingsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
@ -13,11 +13,10 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js';
import { MiLocalUser } from '@/models/User.js'; import { MiLocalUser } from '@/models/User.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@ -43,6 +42,7 @@ export const paramDef = {
untilId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' }, sinceDate: { type: 'integer' },
untilDate: { type: 'integer' }, untilDate: { type: 'integer' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
includeMyRenotes: { type: 'boolean', default: true }, includeMyRenotes: { type: 'boolean', default: true },
includeRenotedMyNotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true },
@ -65,7 +65,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private idService: IdService, private idService: IdService,
private cacheService: CacheService, private cacheService: CacheService,
private fanoutTimelineService: FanoutTimelineService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private userFollowingService: UserFollowingService, private userFollowingService: UserFollowingService,
private queryService: QueryService, private queryService: QueryService,
private metaService: MetaService, private metaService: MetaService,
@ -77,7 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const serverSettings = await this.metaService.fetch(); const serverSettings = await this.metaService.fetch();
if (!serverSettings.enableFanoutTimeline) { if (!serverSettings.enableFanoutTimeline) {
return await this.getFromDb({ const timeline = await this.getFromDb({
untilId, untilId,
sinceId, sinceId,
limit: ps.limit, limit: ps.limit,
@ -87,81 +87,54 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withFiles: ps.withFiles, withFiles: ps.withFiles,
withRenotes: ps.withRenotes, withRenotes: ps.withRenotes,
}, me); }, me);
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(timeline, me);
} }
const [ const [
followings, followings,
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = await Promise.all([ ] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(me.id), this.cacheService.userFollowingsCache.fetch(me.id),
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]); ]);
let noteIds = await this.fanoutTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId); const timeline = this.fanoutTimelineEndpointService.timeline({
noteIds = noteIds.slice(0, ps.limit); untilId,
sinceId,
let redisTimeline: MiNote[] = []; limit: ps.limit,
allowPartial: ps.allowPartial,
if (noteIds.length > 0) { me,
const query = this.notesRepository.createQueryBuilder('note') useDbFallback: serverSettings.enableFanoutTimelineDbFallback,
.where('note.id IN (:...noteIds)', { noteIds: noteIds }) redisTimelines: ps.withFiles ? [`homeTimelineWithFiles:${me.id}`] : [`homeTimeline:${me.id}`],
.innerJoinAndSelect('note.user', 'user') alwaysIncludeMyNotes: true,
.leftJoinAndSelect('note.reply', 'reply') excludePureRenotes: !ps.withRenotes,
.leftJoinAndSelect('note.renote', 'renote') noteFilter: note => {
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
redisTimeline = await query.getMany();
redisTimeline = redisTimeline.filter(note => {
if (note.userId === me.id) {
return true;
}
if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
if (note.reply && note.reply.visibility === 'followers') { if (note.reply && note.reply.visibility === 'followers') {
if (!Object.hasOwn(followings, note.reply.userId)) return false; if (!Object.hasOwn(followings, note.reply.userId)) return false;
} }
return true; return true;
}); },
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1);
}
if (redisTimeline.length > 0) {
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(redisTimeline, me);
} else {
if (serverSettings.enableFanoutTimelineDbFallback) { // fallback to db
return await this.getFromDb({
untilId, untilId,
sinceId, sinceId,
limit: ps.limit, limit,
includeMyRenotes: ps.includeMyRenotes, includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes, includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes, includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles, withFiles: ps.withFiles,
withRenotes: ps.withRenotes, withRenotes: ps.withRenotes,
}, me); }, me),
} else { });
return [];
} process.nextTick(() => {
} this.activeUsersChart.read(me);
});
return timeline;
}); });
} }
@ -269,12 +242,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
//#endregion //#endregion
const timeline = await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(timeline, me);
} }
} }

View File

@ -5,18 +5,17 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import type { MiNote, MiUserList, NotesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import type { MiUserList, NotesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { MiLocalUser } from '@/models/User.js'; import { MiLocalUser } from '@/models/User.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -52,6 +51,7 @@ export const paramDef = {
untilId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' }, sinceDate: { type: 'integer' },
untilDate: { type: 'integer' }, untilDate: { type: 'integer' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
includeMyRenotes: { type: 'boolean', default: true }, includeMyRenotes: { type: 'boolean', default: true },
includeRenotedMyNotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true },
@ -81,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private cacheService: CacheService, private cacheService: CacheService,
private idService: IdService, private idService: IdService,
private fanoutTimelineService: FanoutTimelineService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private queryService: QueryService, private queryService: QueryService,
private metaService: MetaService, private metaService: MetaService,
) { ) {
@ -101,7 +101,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const serverSettings = await this.metaService.fetch(); const serverSettings = await this.metaService.fetch();
if (!serverSettings.enableFanoutTimeline) { if (!serverSettings.enableFanoutTimeline) {
return await this.getFromDb(list, { const timeline = await this.getFromDb(list, {
untilId, untilId,
sinceId, sinceId,
limit: ps.limit, limit: ps.limit,
@ -111,73 +111,37 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withFiles: ps.withFiles, withFiles: ps.withFiles,
withRenotes: ps.withRenotes, withRenotes: ps.withRenotes,
}, me); }, me);
this.activeUsersChart.read(me);
await this.noteEntityService.packMany(timeline, me);
} }
const [ const timeline = await this.fanoutTimelineEndpointService.timeline({
userIdsWhoMeMuting, untilId,
userIdsWhoMeMutingRenotes, sinceId,
userIdsWhoBlockingMe, limit: ps.limit,
] = await Promise.all([ allowPartial: ps.allowPartial,
this.cacheService.userMutingsCache.fetch(me.id), me,
this.cacheService.renoteMutingsCache.fetch(me.id), useDbFallback: serverSettings.enableFanoutTimelineDbFallback,
this.cacheService.userBlockedCache.fetch(me.id), redisTimelines: ps.withFiles ? [`userListTimelineWithFiles:${list.id}`] : [`userListTimeline:${list.id}`],
]); alwaysIncludeMyNotes: true,
excludePureRenotes: !ps.withRenotes,
let noteIds = await this.fanoutTimelineService.get(ps.withFiles ? `userListTimelineWithFiles:${list.id}` : `userListTimeline:${list.id}`, untilId, sinceId); dbFallback: async (untilId, sinceId, limit) => await this.getFromDb(list, {
noteIds = noteIds.slice(0, ps.limit); untilId,
sinceId,
let redisTimeline: MiNote[] = []; limit,
includeMyRenotes: ps.includeMyRenotes,
if (noteIds.length > 0) { includeRenotedMyNotes: ps.includeRenotedMyNotes,
const query = this.notesRepository.createQueryBuilder('note') includeLocalRenotes: ps.includeLocalRenotes,
.where('note.id IN (:...noteIds)', { noteIds: noteIds }) withFiles: ps.withFiles,
.innerJoinAndSelect('note.user', 'user') withRenotes: ps.withRenotes,
.leftJoinAndSelect('note.reply', 'reply') }, me),
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
redisTimeline = await query.getMany();
redisTimeline = redisTimeline.filter(note => {
if (note.userId === me.id) {
return true;
}
if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
return true;
}); });
redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1);
}
if (redisTimeline.length > 0) {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);
return await this.noteEntityService.packMany(redisTimeline, me);
} else { return timeline;
if (serverSettings.enableFanoutTimelineDbFallback) { // fallback to db
return await this.getFromDb(list, {
untilId,
sinceId,
limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles,
withRenotes: ps.withRenotes,
}, me);
} else {
return [];
}
}
}); });
} }
@ -271,10 +235,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
//#endregion //#endregion
const timeline = await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();
this.activeUsersChart.read(me);
return await this.noteEntityService.packMany(timeline, me);
} }
} }

View File

@ -13,6 +13,16 @@ export const meta = {
tags: ['role'], tags: ['role'],
requireCredential: true, requireCredential: true,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'Role',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {

View File

@ -22,6 +22,12 @@ export const meta = {
id: 'de5502bf-009a-4639-86c1-fec349e46dcb', id: 'de5502bf-009a-4639-86c1-fec349e46dcb',
}, },
}, },
res: {
type: 'object',
optional: false, nullable: false,
ref: 'Role',
},
} as const; } as const;
export const paramDef = { export const paramDef = {

View File

@ -9,6 +9,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { FeaturedService } from '@/core/FeaturedService.js'; import { FeaturedService } from '@/core/FeaturedService.js';
import { CacheService } from '@/core/CacheService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@ -46,6 +48,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private featuredService: FeaturedService, private featuredService: FeaturedService,
private cacheService: CacheService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
let noteIds = await this.featuredService.getPerUserNotesRanking(ps.userId, 50); let noteIds = await this.featuredService.getPerUserNotesRanking(ps.userId, 50);
@ -60,6 +63,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return []; return [];
} }
const [
userIdsWhoMeMuting,
userIdsWhoBlockingMe,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>()];
const query = this.notesRepository.createQueryBuilder('note') const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds }) .where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
@ -69,10 +80,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel'); .leftJoinAndSelect('note.channel', 'channel');
const notes = await query.getMany(); const notes = (await query.getMany()).filter(note => {
notes.sort((a, b) => a.id > b.id ? -1 : 1); if (me && isUserRelated(note, userIdsWhoBlockingMe, false)) return false;
if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false;
// TODO: ミュート等考慮 return true;
});
notes.sort((a, b) => a.id > b.id ? -1 : 1);
return await this.noteEntityService.packMany(notes, me); return await this.noteEntityService.packMany(notes, me);
}); });

View File

@ -5,18 +5,18 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; import type { NotesRepository } from '@/models/_.js';
import type { MiNote, NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { ApiError } from '../../error.js'; import { MiLocalUser } from '@/models/User.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { FanoutTimelineName } from '@/core/FanoutTimelineService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = { export const meta = {
tags: ['users', 'notes'], tags: ['users', 'notes'],
@ -37,6 +37,12 @@ export const meta = {
code: 'NO_SUCH_USER', code: 'NO_SUCH_USER',
id: '27e494ba-2ac2-48e8-893b-10d4d8c2387b', id: '27e494ba-2ac2-48e8-893b-10d4d8c2387b',
}, },
bothWithRepliesAndWithFiles: {
message: 'Specifying both withReplies and withFiles is not supported',
code: 'BOTH_WITH_REPLIES_AND_WITH_FILES',
id: '91c8cb9f-36ed-46e7-9ca2-7df96ed6e222',
},
}, },
} as const; } as const;
@ -52,8 +58,8 @@ export const paramDef = {
untilId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' }, sinceDate: { type: 'integer' },
untilDate: { type: 'integer' }, untilDate: { type: 'integer' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
withFiles: { type: 'boolean', default: false }, withFiles: { type: 'boolean', default: false },
excludeNsfw: { type: 'boolean', default: false },
}, },
required: ['userId'], required: ['userId'],
} as const; } as const;
@ -61,9 +67,6 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@ -71,80 +74,85 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService, private queryService: QueryService,
private cacheService: CacheService, private cacheService: CacheService,
private idService: IdService, private idService: IdService,
private fanoutTimelineService: FanoutTimelineService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private metaService: MetaService, private metaService: MetaService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
const isRangeSpecified = untilId != null && sinceId != null;
const isSelf = me && (me.id === ps.userId); const isSelf = me && (me.id === ps.userId);
const serverSettings = await this.metaService.fetch(); const serverSettings = await this.metaService.fetch();
if (serverSettings.enableFanoutTimeline && (isRangeSpecified || sinceId == null)) { if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles);
const [
userIdsWhoMeMuting,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
]) : [new Set<string>()];
const [noteIdsRes, repliesNoteIdsRes, channelNoteIdsRes] = await Promise.all([ if (!serverSettings.enableFanoutTimeline) {
this.fanoutTimelineService.get(ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`, untilId, sinceId), const timeline = await this.getFromDb({
ps.withReplies ? this.fanoutTimelineService.get(`userTimelineWithReplies:${ps.userId}`, untilId, sinceId) : Promise.resolve([]), untilId,
ps.withChannelNotes ? this.fanoutTimelineService.get(`userTimelineWithChannel:${ps.userId}`, untilId, sinceId) : Promise.resolve([]), sinceId,
]); limit: ps.limit,
userId: ps.userId,
withChannelNotes: ps.withChannelNotes,
withFiles: ps.withFiles,
withRenotes: ps.withRenotes,
}, me);
let noteIds = Array.from(new Set([ return await this.noteEntityService.packMany(timeline, me);
...noteIdsRes, }
...repliesNoteIdsRes,
...channelNoteIdsRes, const redisTimelines: FanoutTimelineName[] = [ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`];
]));
noteIds.sort((a, b) => a > b ? -1 : 1); if (ps.withReplies) redisTimelines.push(`userTimelineWithReplies:${ps.userId}`);
noteIds = noteIds.slice(0, ps.limit); if (ps.withChannelNotes) redisTimelines.push(`userTimelineWithChannel:${ps.userId}`);
if (noteIds.length > 0) {
const isFollowing = me && Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId); const isFollowing = me && Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId);
const query = this.notesRepository.createQueryBuilder('note') const timeline = await this.fanoutTimelineEndpointService.timeline({
.where('note.id IN (:...noteIds)', { noteIds: noteIds }) untilId,
.innerJoinAndSelect('note.user', 'user') sinceId,
.leftJoinAndSelect('note.reply', 'reply') limit: ps.limit,
.leftJoinAndSelect('note.renote', 'renote') allowPartial: ps.allowPartial,
.leftJoinAndSelect('reply.user', 'replyUser') me,
.leftJoinAndSelect('renote.user', 'renoteUser') redisTimelines,
.leftJoinAndSelect('note.channel', 'channel'); useDbFallback: true,
ignoreAuthorFromMute: true,
let timeline = await query.getMany(); excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies
excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files
timeline = timeline.filter(note => { excludePureRenotes: !ps.withRenotes,
if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false; noteFilter: note => {
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (ps.withRenotes === false) return false;
}
}
if (note.channel?.isSensitive && !isSelf) return false; if (note.channel?.isSensitive && !isSelf) return false;
if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false; if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false;
if (note.visibility === 'followers' && !isFollowing && !isSelf) return false; if (note.visibility === 'followers' && !isFollowing && !isSelf) return false;
return true; return true;
},
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
untilId,
sinceId,
limit,
userId: ps.userId,
withChannelNotes: ps.withChannelNotes,
withFiles: ps.withFiles,
withRenotes: ps.withRenotes,
}, me),
}); });
// TODO: フィルタで件数が減った場合の埋め合わせ処理 return timeline;
});
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
if (timeline.length > 0) {
return await this.noteEntityService.packMany(timeline, me);
}
}
} }
//#region fallback to database private async getFromDb(ps: {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) untilId: string | null,
sinceId: string | null,
limit: number,
userId: string,
withChannelNotes: boolean,
withFiles: boolean,
withRenotes: boolean,
}, me: MiLocalUser | null) {
const isSelf = me && (me.id === ps.userId);
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere('note.userId = :userId', { userId: ps.userId }) .andWhere('note.userId = :userId', { userId: ps.userId })
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
@ -182,10 +190,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
})); }));
} }
const timeline = await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();
return await this.noteEntityService.packMany(timeline, me);
//#endregion
});
} }
} }

View File

@ -43,7 +43,7 @@ export function genOpenapiSpec(config: Config) {
// 書き換えたりするのでディープコピーしておく。そのまま編集するとメモリ上の値が汚れて次回以降の出力に影響する // 書き換えたりするのでディープコピーしておく。そのまま編集するとメモリ上の値が汚れて次回以降の出力に影響する
const copiedEndpoints = JSON.parse(JSON.stringify(endpoints)) as IEndpoint[]; const copiedEndpoints = JSON.parse(JSON.stringify(endpoints)) as IEndpoint[];
for (const endpoint of copiedEndpoints.filter(ep => !ep.meta.secure)) { for (const endpoint of copiedEndpoints) {
const errors = {} as any; const errors = {} as any;
if (endpoint.meta.errors) { if (endpoint.meta.errors) {
@ -59,6 +59,11 @@ export function genOpenapiSpec(config: Config) {
const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res) : {}; const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res) : {};
let desc = (endpoint.meta.description ? endpoint.meta.description : 'No description provided.') + '\n\n'; let desc = (endpoint.meta.description ? endpoint.meta.description : 'No description provided.') + '\n\n';
if (endpoint.meta.secure) {
desc += '**Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.\n';
}
desc += `**Credential required**: *${endpoint.meta.requireCredential ? 'Yes' : 'No'}*`; desc += `**Credential required**: *${endpoint.meta.requireCredential ? 'Yes' : 'No'}*`;
if (endpoint.meta.kind) { if (endpoint.meta.kind) {
const kind = endpoint.meta.kind; const kind = endpoint.meta.kind;

View File

@ -36,6 +36,7 @@ export default class Connection {
public userIdsWhoMeMuting: Set<string> = new Set(); public userIdsWhoMeMuting: Set<string> = new Set();
public userIdsWhoBlockingMe: Set<string> = new Set(); public userIdsWhoBlockingMe: Set<string> = new Set();
public userIdsWhoMeMutingRenotes: Set<string> = new Set(); public userIdsWhoMeMutingRenotes: Set<string> = new Set();
public userMutedInstances: Set<string> = new Set();
private fetchIntervalId: NodeJS.Timeout | null = null; private fetchIntervalId: NodeJS.Timeout | null = null;
constructor( constructor(
@ -69,6 +70,7 @@ export default class Connection {
this.userIdsWhoMeMuting = userIdsWhoMeMuting; this.userIdsWhoMeMuting = userIdsWhoMeMuting;
this.userIdsWhoBlockingMe = userIdsWhoBlockingMe; this.userIdsWhoBlockingMe = userIdsWhoBlockingMe;
this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes; this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes;
this.userMutedInstances = new Set(userProfile.mutedInstances);
} }
@bindThis @bindThis

View File

@ -41,6 +41,10 @@ export default abstract class Channel {
return this.connection.userIdsWhoBlockingMe; return this.connection.userIdsWhoBlockingMe;
} }
protected get userMutedInstances() {
return this.connection.userMutedInstances;
}
protected get followingChannels() { protected get followingChannels() {
return this.connection.followingChannels; return this.connection.followingChannels;
} }

View File

@ -5,12 +5,12 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import { isUserRelated } from '@/misc/is-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import Channel from '../channel.js'; import Channel from '../channel.js';
class UserListChannel extends Channel { class UserListChannel extends Channel {
@ -80,6 +80,9 @@ class UserListChannel extends Channel {
private async onNote(note: Packed<'Note'>) { private async onNote(note: Packed<'Note'>) {
const isMe = this.user!.id === note.userId; const isMe = this.user!.id === note.userId;
// チャンネル投稿は無視する
if (note.channelId) return;
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (!Object.hasOwn(this.membershipsMap, note.userId)) return; if (!Object.hasOwn(this.membershipsMap, note.userId)) return;
@ -115,6 +118,9 @@ class UserListChannel extends Channel {
} }
} }
// 流れてきたNoteがミュートしているインスタンスに関わるものだったら無視する
if (isInstanceMuted(note, this.userMutedInstances)) return;
this.connection.cacheNote(note); this.connection.cacheNote(note);
this.send('note', note); this.send('note', note);

View File

@ -7,7 +7,7 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import { MiFollowing } from '@/models/Following.js'; import { MiFollowing } from '@/models/Following.js';
import { connectStream, signup, api, post, startServer, initTestDb, waitFire } from '../utils.js'; import { signup, api, post, startServer, initTestDb, waitFire } from '../utils.js';
import type { INestApplicationContext } from '@nestjs/common'; import type { INestApplicationContext } from '@nestjs/common';
import type * as misskey from 'misskey-js'; import type * as misskey from 'misskey-js';
@ -34,12 +34,16 @@ describe('Streaming', () => {
let ayano: misskey.entities.MeSignup; let ayano: misskey.entities.MeSignup;
let kyoko: misskey.entities.MeSignup; let kyoko: misskey.entities.MeSignup;
let chitose: misskey.entities.MeSignup; let chitose: misskey.entities.MeSignup;
let kanako: misskey.entities.MeSignup;
// Remote users // Remote users
let akari: misskey.entities.MeSignup; let akari: misskey.entities.MeSignup;
let chinatsu: misskey.entities.MeSignup; let chinatsu: misskey.entities.MeSignup;
let takumi: misskey.entities.MeSignup;
let kyokoNote: any; let kyokoNote: any;
let kanakoNote: any;
let takumiNote: any;
let list: any; let list: any;
beforeAll(async () => { beforeAll(async () => {
@ -50,11 +54,15 @@ describe('Streaming', () => {
ayano = await signup({ username: 'ayano' }); ayano = await signup({ username: 'ayano' });
kyoko = await signup({ username: 'kyoko' }); kyoko = await signup({ username: 'kyoko' });
chitose = await signup({ username: 'chitose' }); chitose = await signup({ username: 'chitose' });
kanako = await signup({ username: 'kanako' });
akari = await signup({ username: 'akari', host: 'example.com' }); akari = await signup({ username: 'akari', host: 'example.com' });
chinatsu = await signup({ username: 'chinatsu', host: 'example.com' }); chinatsu = await signup({ username: 'chinatsu', host: 'example.com' });
takumi = await signup({ username: 'takumi', host: 'example.com' });
kyokoNote = await post(kyoko, { text: 'foo' }); kyokoNote = await post(kyoko, { text: 'foo' });
kanakoNote = await post(kanako, { text: 'hoge' });
takumiNote = await post(takumi, { text: 'piyo' });
// Follow: ayano => kyoko // Follow: ayano => kyoko
await api('following/create', { userId: kyoko.id }, ayano); await api('following/create', { userId: kyoko.id }, ayano);
@ -62,6 +70,9 @@ describe('Streaming', () => {
// Follow: ayano => akari // Follow: ayano => akari
await follow(ayano, akari); await follow(ayano, akari);
// Mute: chitose => kanako
await api('mute/create', { userId: kanako.id }, chitose);
// List: chitose => ayano, kyoko // List: chitose => ayano, kyoko
list = await api('users/lists/create', { list = await api('users/lists/create', {
name: 'my list', name: 'my list',
@ -76,6 +87,11 @@ describe('Streaming', () => {
listId: list.id, listId: list.id,
userId: kyoko.id, userId: kyoko.id,
}, chitose); }, chitose);
await api('users/lists/push', {
listId: list.id,
userId: takumi.id,
}, chitose);
}, 1000 * 60 * 2); }, 1000 * 60 * 2);
afterAll(async () => { afterAll(async () => {
@ -452,6 +468,96 @@ describe('Streaming', () => {
assert.strictEqual(fired, false); assert.strictEqual(fired, false);
}); });
// #10443
test('チャンネル投稿は流れない', async () => {
// リスインしている kyoko が 任意のチャンネルに投降した時の動きを見たい
const fired = await waitFire(
chitose, 'userList',
() => api('notes/create', { text: 'foo', channelId: 'dummy' }, kyoko),
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
{ listId: list.id },
);
assert.strictEqual(fired, false);
});
// #10443
test('ミュートしているユーザへのリプライがリストTLに流れない', async () => {
// chitose が kanako をミュートしている状態で、リスインしている kyoko が kanako にリプライした時の動きを見たい
const fired = await waitFire(
chitose, 'userList',
() => api('notes/create', { text: 'foo', replyId: kanakoNote.id }, kyoko),
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
{ listId: list.id },
);
assert.strictEqual(fired, false);
});
// #10443
test('ミュートしているユーザの投稿をリートしたときリストTLに流れない', async () => {
// chitose が kanako をミュートしている状態で、リスインしている kyoko が kanako のノートをリノートした時の動きを見たい
const fired = await waitFire(
chitose, 'userList',
() => api('notes/create', { renoteId: kanakoNote.id }, kyoko),
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
{ listId: list.id },
);
assert.strictEqual(fired, false);
});
// #10443
test('ミュートしているサーバのートがリストTLに流れない', async () => {
await api('/i/update', {
mutedInstances: ['example.com'],
}, chitose);
// chitose が example.com をミュートしている状態で、リスインしている takumi が ノートした時の動きを見たい
const fired = await waitFire(
chitose, 'userList',
() => api('notes/create', { text: 'foo' }, takumi),
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
{ listId: list.id },
);
assert.strictEqual(fired, false);
});
// #10443
test('ミュートしているサーバのートに対するリプライがリストTLに流れない', async () => {
await api('/i/update', {
mutedInstances: ['example.com'],
}, chitose);
// chitose が example.com をミュートしている状態で、リスインしている kyoko が takumi のノートにリプライした時の動きを見たい
const fired = await waitFire(
chitose, 'userList',
() => api('notes/create', { text: 'foo', replyId: takumiNote.id }, kyoko),
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
{ listId: list.id },
);
assert.strictEqual(fired, false);
});
// #10443
test('ミュートしているサーバのートに対するリートがリストTLに流れない', async () => {
await api('/i/update', {
mutedInstances: ['example.com'],
}, chitose);
// chitose が example.com をミュートしている状態で、リスインしている kyoko が takumi のノートをリノートした時の動きを見たい
const fired = await waitFire(
chitose, 'userList',
() => api('notes/create', { renoteId: takumiNote.id }, kyoko),
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
{ listId: list.id },
);
assert.strictEqual(fired, false);
});
}); });
// XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac" // XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac"

View File

@ -69,12 +69,6 @@ module.exports = {
'require': false, 'require': false,
'__dirname': false, '__dirname': false,
// Vue
'$$': false,
'$ref': false,
'$shallowRef': false,
'$computed': false,
// Misskey // Misskey
'_DEV_': false, '_DEV_': false,
'_LANGS_': false, '_LANGS_': false,

View File

@ -89,7 +89,6 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
api("users/notes", { api("users/notes", {
userId: props.user.id, userId: props.user.id,
fileType: image, fileType: image,
excludeNsfw: defaultStore.state.nsfw !== "ignore",
limit: 10 limit: 10
}).then((notes) => { }).then((notes) => {
for (const note of notes) { for (const note of notes) {
@ -181,7 +180,7 @@ import './photoswipe-!~{003}~.js';
const _hoisted_1 = createBaseVNode("i", { const _hoisted_1 = createBaseVNode("i", {
class: "ti ti-photo" class: "ti ti-photo"
}, null, -1); }, null, -1);
const _sfc_main = defineComponent({ const index_photos = defineComponent({
__name: "index.photos", __name: "index.photos",
props: { props: {
user: {} user: {}
@ -198,7 +197,6 @@ const _sfc_main = defineComponent({
api("users/notes", { api("users/notes", {
userId: props.user.id, userId: props.user.id,
fileType: image, fileType: image,
excludeNsfw: defaultStore.state.nsfw !== "ignore",
limit: 10 limit: 10
}).then(notes => { }).then(notes => {
for (const note of notes) { for (const note of notes) {
@ -263,7 +261,6 @@ const style0 = {
const cssModules = { const cssModules = {
"$style": style0 "$style": style0
}; };
const index_photos = _sfc_main;
export {index_photos as default}; export {index_photos as default};
`.slice(1)); `.slice(1));
}); });

View File

@ -13,13 +13,13 @@ function isFalsyIdentifier(identifier: estree.Identifier): boolean {
return identifier.name === 'undefined' || identifier.name === 'NaN'; return identifier.name === 'undefined' || identifier.name === 'NaN';
} }
function normalizeClassWalker(tree: estree.Node): string | null { function normalizeClassWalker(tree: estree.Node, stack: string | undefined): string | null {
if (tree.type === 'Identifier') return isFalsyIdentifier(tree) ? '' : null; if (tree.type === 'Identifier') return isFalsyIdentifier(tree) ? '' : null;
if (tree.type === 'Literal') return typeof tree.value === 'string' ? tree.value : ''; if (tree.type === 'Literal') return typeof tree.value === 'string' ? tree.value : '';
if (tree.type === 'BinaryExpression') { if (tree.type === 'BinaryExpression') {
if (tree.operator !== '+') return null; if (tree.operator !== '+') return null;
const left = normalizeClassWalker(tree.left); const left = normalizeClassWalker(tree.left, stack);
const right = normalizeClassWalker(tree.right); const right = normalizeClassWalker(tree.right, stack);
if (left === null || right === null) return null; if (left === null || right === null) return null;
return `${left}${right}`; return `${left}${right}`;
} }
@ -33,15 +33,15 @@ function normalizeClassWalker(tree: estree.Node): string | null {
if (tree.type === 'ArrayExpression') { if (tree.type === 'ArrayExpression') {
const values = tree.elements.map((treeNode) => { const values = tree.elements.map((treeNode) => {
if (treeNode === null) return ''; if (treeNode === null) return '';
if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument); if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument, stack);
return normalizeClassWalker(treeNode); return normalizeClassWalker(treeNode, stack);
}); });
if (values.some((x) => x === null)) return null; if (values.some((x) => x === null)) return null;
return values.join(' '); return values.join(' ');
} }
if (tree.type === 'ObjectExpression') { if (tree.type === 'ObjectExpression') {
const values = tree.properties.map((treeNode) => { const values = tree.properties.map((treeNode) => {
if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument); if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument, stack);
let x = treeNode.value; let x = treeNode.value;
let inveted = false; let inveted = false;
while (x.type === 'UnaryExpression' && x.operator === '!') { while (x.type === 'UnaryExpression' && x.operator === '!') {
@ -67,18 +67,26 @@ function normalizeClassWalker(tree: estree.Node): string | null {
if (values.some((x) => x === null)) return null; if (values.some((x) => x === null)) return null;
return values.join(' '); return values.join(' ');
} }
console.error(`Unexpected node type: ${tree.type}`); if (
tree.type !== 'CallExpression' &&
tree.type !== 'ChainExpression' &&
tree.type !== 'ConditionalExpression' &&
tree.type !== 'LogicalExpression' &&
tree.type !== 'MemberExpression') {
console.error(stack ? `Unexpected node type: ${tree.type} (in ${stack})` : `Unexpected node type: ${tree.type}`);
}
return null; return null;
} }
export function normalizeClass(tree: estree.Node): string | null { export function normalizeClass(tree: estree.Node, stack?: string): string | null {
const walked = normalizeClassWalker(tree); const walked = normalizeClassWalker(tree, stack);
return walked && walked.replace(/^\s+|\s+(?=\s)|\s+$/g, ''); return walked && walked.replace(/^\s+|\s+(?=\s)|\s+$/g, '');
} }
export function unwindCssModuleClassName(ast: estree.Node): void { export function unwindCssModuleClassName(ast: estree.Node): void {
(walk as typeof estreeWalker.walk)(ast, { (walk as typeof estreeWalker.walk)(ast, {
enter(node, parent): void { enter(node, parent): void {
//#region
if (parent?.type !== 'Program') return; if (parent?.type !== 'Program') return;
if (node.type !== 'VariableDeclaration') return; if (node.type !== 'VariableDeclaration') return;
if (node.declarations.length !== 1) return; if (node.declarations.length !== 1) return;
@ -102,6 +110,14 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
return true; return true;
}); });
if (!~__cssModulesIndex) return; if (!~__cssModulesIndex) return;
/* This region assumeed that the entered node looks like the following code.
*
* ```ts
* const SomeComponent = _export_sfc(_sfc_main, [["foo", bar], ["__cssModules", cssModules]]);
* ```
*/
//#endregion
//#region
const cssModuleForestName = ((node.declarations[0].init.arguments[1].elements[__cssModulesIndex] as estree.ArrayExpression).elements[1] as estree.Identifier).name; const cssModuleForestName = ((node.declarations[0].init.arguments[1].elements[__cssModulesIndex] as estree.ArrayExpression).elements[1] as estree.Identifier).name;
const cssModuleForestNode = parent.body.find((x) => { const cssModuleForestNode = parent.body.find((x) => {
if (x.type !== 'VariableDeclaration') return false; if (x.type !== 'VariableDeclaration') return false;
@ -117,6 +133,16 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
if (property.value.type !== 'Identifier') return []; if (property.value.type !== 'Identifier') return [];
return [[property.key.value as string, property.value.name as string]]; return [[property.key.value as string, property.value.name as string]];
})); }));
/* This region collected a VariableDeclaration node in the module that looks like the following code.
*
* ```ts
* const cssModules = {
* "$style": style0,
* };
* ```
*/
//#endregion
//#region
const sfcMain = parent.body.find((x) => { const sfcMain = parent.body.find((x) => {
if (x.type !== 'VariableDeclaration') return false; if (x.type !== 'VariableDeclaration') return false;
if (x.declarations.length !== 1) return false; if (x.declarations.length !== 1) return false;
@ -146,7 +172,22 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
if (ctx.type !== 'Identifier') return; if (ctx.type !== 'Identifier') return;
if (ctx.name !== '_ctx') return; if (ctx.name !== '_ctx') return;
if (render.argument.body.type !== 'BlockStatement') return; if (render.argument.body.type !== 'BlockStatement') return;
/* This region assumed that `sfcMain` looks like the following code.
*
* ```ts
* const _sfc_main = defineComponent({
* setup(_props) {
* ...
* return (_ctx, _cache) => {
* ...
* };
* },
* });
* ```
*/
//#endregion
for (const [key, value] of moduleForest) { for (const [key, value] of moduleForest) {
//#region
const cssModuleTreeNode = parent.body.find((x) => { const cssModuleTreeNode = parent.body.find((x) => {
if (x.type !== 'VariableDeclaration') return false; if (x.type !== 'VariableDeclaration') return false;
if (x.declarations.length !== 1) return false; if (x.declarations.length !== 1) return false;
@ -172,6 +213,19 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
if (actualValue.declarations[0].init?.type !== 'Literal') return []; if (actualValue.declarations[0].init?.type !== 'Literal') return [];
return [[actualKey, actualValue.declarations[0].init.value as string]]; return [[actualKey, actualValue.declarations[0].init.value as string]];
})); }));
/* This region collected VariableDeclaration nodes in the module that looks like the following code.
*
* ```ts
* const foo = "bar";
* const baz = "qux";
* const style0 = {
* foo: foo,
* baz: baz,
* };
* ```
*/
//#endregion
//#region
(walk as typeof estreeWalker.walk)(render.argument.body, { (walk as typeof estreeWalker.walk)(render.argument.body, {
enter(childNode) { enter(childNode) {
if (childNode.type !== 'MemberExpression') return; if (childNode.type !== 'MemberExpression') return;
@ -189,6 +243,39 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
}); });
}, },
}); });
/* This region inlined the reference identifier of the class name in the render function into the actual literal, as in the following code.
*
* ```ts
* const _sfc_main = defineComponent({
* setup(_props) {
* ...
* return (_ctx, _cache) => {
* ...
* return openBlock(), createElementBlock("div", {
* class: normalizeClass(_ctx.$style.foo),
* }, null);
* };
* },
* });
* ```
*
*
*
* ```ts
* const _sfc_main = defineComponent({
* setup(_props) {
* ...
* return (_ctx, _cache) => {
* ...
* return openBlock(), createElementBlock("div", {
* class: normalizeClass("bar"),
* }, null);
* };
* },
* });
*/
//#endregion
//#region
(walk as typeof estreeWalker.walk)(render.argument.body, { (walk as typeof estreeWalker.walk)(render.argument.body, {
enter(childNode) { enter(childNode) {
if (childNode.type !== 'MemberExpression') return; if (childNode.type !== 'MemberExpression') return;
@ -205,13 +292,47 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
}); });
}, },
}); });
/* This region replaced the reference identifier of missing class names in the render function with `undefined`, as in the following code.
*
* ```ts
* const _sfc_main = defineComponent({
* setup(_props) {
* ...
* return (_ctx, _cache) => {
* ...
* return openBlock(), createElementBlock("div", {
* class: normalizeClass(_ctx.$style.hoge),
* }, null);
* };
* },
* });
* ```
*
*
*
* ```ts
* const _sfc_main = defineComponent({
* setup(_props) {
* ...
* return (_ctx, _cache) => {
* ...
* return openBlock(), createElementBlock("div", {
* class: normalizeClass(undefined),
* }, null);
* };
* },
* });
* ```
*/
//#endregion
//#region
(walk as typeof estreeWalker.walk)(render.argument.body, { (walk as typeof estreeWalker.walk)(render.argument.body, {
enter(childNode) { enter(childNode) {
if (childNode.type !== 'CallExpression') return; if (childNode.type !== 'CallExpression') return;
if (childNode.callee.type !== 'Identifier') return; if (childNode.callee.type !== 'Identifier') return;
if (childNode.callee.name !== 'normalizeClass') return; if (childNode.callee.name !== 'normalizeClass') return;
if (childNode.arguments.length !== 1) return; if (childNode.arguments.length !== 1) return;
const normalized = normalizeClass(childNode.arguments[0]); const normalized = normalizeClass(childNode.arguments[0], name);
if (normalized === null) return; if (normalized === null) return;
this.replace({ this.replace({
type: 'Literal', type: 'Literal',
@ -219,8 +340,60 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
}); });
}, },
}); });
/* This region compiled the `normalizeClass` call into a pseudo-AOT compilation, as in the following code.
*
* ```ts
* const _sfc_main = defineComponent({
* setup(_props) {
* ...
* return (_ctx, _cache) => {
* ...
* return openBlock(), createElementBlock("div", {
* class: normalizeClass("bar"),
* }, null);
* };
* },
* });
* ```
*
*
*
* ```ts
* const _sfc_main = defineComponent({
* setup(_props) {
* ...
* return (_ctx, _cache) => {
* ...
* return openBlock(), createElementBlock("div", {
* class: "bar",
* }, null);
* };
* },
* });
* ```
*/
//#endregion
} }
//#region
if (node.declarations[0].init.arguments[1].elements.length === 1) { if (node.declarations[0].init.arguments[1].elements.length === 1) {
(walk as typeof estreeWalker.walk)(ast, {
enter(childNode) {
if (childNode.type !== 'Identifier') return;
if (childNode.name !== ident) return;
this.replace({
type: 'Identifier',
name: node.declarations[0].id.name,
});
},
});
this.remove();
/* NOTE: The above logic is valid as long as the following two conditions are met.
*
* - the uniqueness of `ident` is kept throughout the module
* - `_export_sfc` is noop when the second argument is an empty array
*
* Otherwise, the below logic should be used instead.
this.replace({ this.replace({
type: 'VariableDeclaration', type: 'VariableDeclaration',
declarations: [{ declarations: [{
@ -236,6 +409,7 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
}], }],
kind: 'const', kind: 'const',
}); });
*/
} else { } else {
this.replace({ this.replace({
type: 'VariableDeclaration', type: 'VariableDeclaration',
@ -263,6 +437,35 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
kind: 'const', kind: 'const',
}); });
} }
/* This region removed the `__cssModules` reference from the second argument of `_export_sfc`, as in the following code.
*
* ```ts
* const SomeComponent = _export_sfc(_sfc_main, [["foo", bar], ["__cssModules", cssModules]]);
* ```
*
*
*
* ```ts
* const SomeComponent = _export_sfc(_sfc_main, [["foo", bar]]);
* ```
*
* When the declaration becomes noop, it is removed as follows.
*
* ```ts
* const _sfc_main = defineComponent({
* ...
* });
* const SomeComponent = _export_sfc(_sfc_main, []);
* ```
*
*
*
* ```ts
* const SomeComponent = defineComponent({
* ...
* });
*/
//#endregion
}, },
}); });
} }

View File

@ -4,6 +4,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"watch": "vite", "watch": "vite",
"dev": "vite --config vite.config.local-dev.ts",
"build": "vite build", "build": "vite build",
"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\"", "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-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
@ -21,11 +22,10 @@
"@rollup/plugin-alias": "5.1.0", "@rollup/plugin-alias": "5.1.0",
"@rollup/plugin-json": "6.0.1", "@rollup/plugin-json": "6.0.1",
"@rollup/plugin-replace": "5.0.5", "@rollup/plugin-replace": "5.0.5",
"@rollup/pluginutils": "5.0.5", "@rollup/pluginutils": "5.1.0",
"@syuilo/aiscript": "0.16.0", "@syuilo/aiscript": "0.16.0",
"@tabler/icons-webfont": "2.37.0", "@tabler/icons-webfont": "2.37.0",
"@vitejs/plugin-vue": "4.5.0", "@vitejs/plugin-vue": "4.5.1",
"@vue-macros/reactivity-transform": "0.4.0",
"@vue/compiler-sfc": "3.3.9", "@vue/compiler-sfc": "3.3.9",
"astring": "1.8.6", "astring": "1.8.6",
"autosize": "6.0.1", "autosize": "6.0.1",
@ -34,19 +34,19 @@
"browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3", "browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
"buraha": "0.0.1", "buraha": "0.0.1",
"canvas-confetti": "1.6.1", "canvas-confetti": "1.6.1",
"chart.js": "4.4.0", "chart.js": "4.4.1",
"chartjs-adapter-date-fns": "3.0.0", "chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-matrix": "2.0.1", "chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.0.1", "chartjs-plugin-zoom": "2.0.1",
"chromatic": "9.1.0", "chromatic": "10.1.0",
"compare-versions": "6.1.0", "compare-versions": "6.1.0",
"cropperjs": "2.0.0-beta.4", "cropperjs": "2.0.0-beta.4",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"escape-regexp": "0.0.1", "escape-regexp": "0.0.1",
"estree-walker": "3.0.3", "estree-walker": "3.0.3",
"eventemitter3": "5.0.1", "eventemitter3": "5.0.1",
"gsap": "3.12.2", "gsap": "3.12.3",
"idb-keyval": "6.2.1", "idb-keyval": "6.2.1",
"insert-text-at-cursor": "0.3.0", "insert-text-at-cursor": "0.3.0",
"is-file-animated": "1.0.2", "is-file-animated": "1.0.2",
@ -54,54 +54,54 @@
"matter-js": "0.19.0", "matter-js": "0.19.0",
"mfm-js": "0.23.3", "mfm-js": "0.23.3",
"misskey-js": "workspace:*", "misskey-js": "workspace:*",
"photoswipe": "5.4.2", "photoswipe": "5.4.3",
"punycode": "2.3.1", "punycode": "2.3.1",
"querystring": "0.2.1", "querystring": "0.2.1",
"rollup": "4.6.0", "rollup": "4.7.0",
"sanitize-html": "2.11.0", "sanitize-html": "2.11.0",
"shiki": "^0.14.5", "shiki": "0.14.6",
"sass": "1.69.5", "sass": "1.69.5",
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0", "textarea-caret": "3.1.0",
"three": "0.158.0", "three": "0.159.0",
"throttle-debounce": "5.0.0", "throttle-debounce": "5.0.0",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tsc-alias": "1.8.8", "tsc-alias": "1.8.8",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"twemoji-parser": "14.0.0", "twemoji-parser": "14.0.0",
"typescript": "5.3.2", "typescript": "5.3.3",
"uuid": "9.0.1", "uuid": "9.0.1",
"v-code-diff": "1.7.2", "v-code-diff": "1.7.2",
"vanilla-tilt": "1.8.1", "vanilla-tilt": "1.8.1",
"vite": "5.0.2", "vite": "5.0.7",
"vue": "3.3.9", "vue": "3.3.11",
"vuedraggable": "next" "vuedraggable": "next"
}, },
"devDependencies": { "devDependencies": {
"@storybook/addon-actions": "7.5.3", "@storybook/addon-actions": "7.6.4",
"@storybook/addon-essentials": "7.5.3", "@storybook/addon-essentials": "7.6.4",
"@storybook/addon-interactions": "7.5.3", "@storybook/addon-interactions": "7.6.4",
"@storybook/addon-links": "7.5.3", "@storybook/addon-links": "7.6.4",
"@storybook/addon-storysource": "7.5.3", "@storybook/addon-storysource": "7.6.4",
"@storybook/addons": "7.5.3", "@storybook/addons": "7.6.4",
"@storybook/blocks": "7.5.3", "@storybook/blocks": "7.6.4",
"@storybook/core-events": "7.5.3", "@storybook/core-events": "7.6.4",
"@storybook/jest": "0.2.3", "@storybook/jest": "0.2.3",
"@storybook/manager-api": "7.5.3", "@storybook/manager-api": "7.6.4",
"@storybook/preview-api": "7.5.3", "@storybook/preview-api": "7.6.4",
"@storybook/react": "7.5.3", "@storybook/react": "7.6.4",
"@storybook/react-vite": "7.5.3", "@storybook/react-vite": "7.6.4",
"@storybook/testing-library": "0.2.2", "@storybook/testing-library": "0.2.2",
"@storybook/theming": "7.5.3", "@storybook/theming": "7.6.4",
"@storybook/types": "7.5.3", "@storybook/types": "7.6.4",
"@storybook/vue3": "7.5.3", "@storybook/vue3": "7.6.4",
"@storybook/vue3-vite": "7.5.3", "@storybook/vue3-vite": "7.6.4",
"@testing-library/vue": "8.0.1", "@testing-library/vue": "8.0.1",
"@types/escape-regexp": "0.0.3", "@types/escape-regexp": "0.0.3",
"@types/estree": "1.0.5", "@types/estree": "1.0.5",
"@types/matter-js": "0.19.5", "@types/matter-js": "0.19.5",
"@types/micromatch": "4.0.6", "@types/micromatch": "4.0.6",
"@types/node": "20.10.0", "@types/node": "20.10.4",
"@types/punycode": "2.1.3", "@types/punycode": "2.1.3",
"@types/sanitize-html": "2.9.5", "@types/sanitize-html": "2.9.5",
"@types/throttle-debounce": "5.0.2", "@types/throttle-debounce": "5.0.2",
@ -109,33 +109,33 @@
"@types/uuid": "9.0.7", "@types/uuid": "9.0.7",
"@types/websocket": "1.0.10", "@types/websocket": "1.0.10",
"@types/ws": "8.5.10", "@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "6.12.0", "@typescript-eslint/eslint-plugin": "6.13.2",
"@typescript-eslint/parser": "6.12.0", "@typescript-eslint/parser": "6.13.2",
"@vitest/coverage-v8": "0.34.6", "@vitest/coverage-v8": "0.34.6",
"@vue/runtime-core": "3.3.9", "@vue/runtime-core": "3.3.11",
"acorn": "8.11.2", "acorn": "8.11.2",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "13.6.0", "cypress": "13.6.1",
"eslint": "8.54.0", "eslint": "8.55.0",
"eslint-plugin-import": "2.29.0", "eslint-plugin-import": "2.29.0",
"eslint-plugin-vue": "9.18.1", "eslint-plugin-vue": "9.19.2",
"fast-glob": "3.3.2", "fast-glob": "3.3.2",
"happy-dom": "10.0.3", "happy-dom": "10.0.3",
"micromatch": "4.0.5", "micromatch": "4.0.5",
"msw": "1.3.2", "msw": "1.3.2",
"msw-storybook-addon": "1.10.0", "msw-storybook-addon": "1.10.0",
"nodemon": "3.0.1", "nodemon": "3.0.2",
"prettier": "3.1.0", "prettier": "3.1.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"start-server-and-test": "2.0.3", "start-server-and-test": "2.0.3",
"storybook": "7.5.3", "storybook": "7.6.4",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"summaly": "github:misskey-dev/summaly", "summaly": "github:misskey-dev/summaly",
"vite-plugin-turbosnap": "1.0.3", "vite-plugin-turbosnap": "1.0.3",
"vitest": "0.34.6", "vitest": "0.34.6",
"vitest-fetch-mock": "0.2.2", "vitest-fetch-mock": "0.2.2",
"vue-eslint-parser": "9.3.2", "vue-eslint-parser": "9.3.2",
"vue-tsc": "1.8.22" "vue-tsc": "1.8.25"
} }
} }

View File

@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
// devモードで起動される際index.htmlを使うときはrouterが暴発してしまってうまく読み込めない。
// よって、devモードとして起動されるときはビルド時に組み込む形としておく。
// (pnpm start時はpugファイルの中で静的リソースとして読み込むようになっており、この問題は起こっていない)
import '@tabler/icons-webfont/tabler-icons.scss';
import('@/_boot_.js');

View File

@ -187,6 +187,12 @@ export async function common(createVue: () => App<Element>) {
if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme)); if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme));
if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme)); if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme));
defaultStore.set('themeInitial', false); defaultStore.set('themeInitial', false);
} else {
if (defaultStore.state.darkMode) {
applyTheme(darkTheme.value);
} else {
applyTheme(lightTheme.value);
}
} }
}); });

View File

@ -19,6 +19,7 @@ import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js
import { mainRouter } from '@/router.js'; import { mainRouter } from '@/router.js';
import { initializeSw } from '@/scripts/initialize-sw.js'; import { initializeSw } from '@/scripts/initialize-sw.js';
import { deckStore } from '@/ui/deck/deck-store.js'; import { deckStore } from '@/ui/deck/deck-store.js';
import { emojiPicker } from '@/scripts/emoji-picker.js';
export async function mainBoot() { export async function mainBoot() {
const { isClientUpdated } = await common(() => createApp( const { isClientUpdated } = await common(() => createApp(
@ -30,6 +31,7 @@ export async function mainBoot() {
)); ));
reactionPicker.init(); reactionPicker.init();
emojiPicker.init();
if (isClientUpdated && $i) { if (isClientUpdated && $i) {
popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed'); popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed');

View File

@ -41,6 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import MkKeyValue from '@/components/MkKeyValue.vue'; import MkKeyValue from '@/components/MkKeyValue.vue';
@ -56,11 +57,11 @@ const emit = defineEmits<{
(ev: 'resolved', reportId: string): void; (ev: 'resolved', reportId: string): void;
}>(); }>();
let forward = $ref(props.report.forwarded); const forward = ref(props.report.forwarded);
function resolve() { function resolve() {
os.apiWithDialog('admin/resolve-abuse-user-report', { os.apiWithDialog('admin/resolve-abuse-user-report', {
forward: forward, forward: forward.value,
reportId: props.report.id, reportId: props.report.id,
}).then(() => { }).then(() => {
emit('resolved', props.report.id); emit('resolved', props.report.id);

View File

@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { onMounted } from 'vue'; import { onMounted, ref, computed } from 'vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/scripts/achievements.js'; import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/scripts/achievements.js';
@ -67,15 +67,15 @@ const props = withDefaults(defineProps<{
withDescription: true, withDescription: true,
}); });
let achievements = $ref(); const achievements = ref();
const lockedAchievements = $computed(() => ACHIEVEMENT_TYPES.filter(x => !(achievements ?? []).some(a => a.name === x))); const lockedAchievements = computed(() => ACHIEVEMENT_TYPES.filter(x => !(achievements.value ?? []).some(a => a.name === x)));
function fetch() { function fetch() {
os.api('users/achievements', { userId: props.user.id }).then(res => { os.api('users/achievements', { userId: props.user.id }).then(res => {
achievements = []; achievements.value = [];
for (const t of ACHIEVEMENT_TYPES) { for (const t of ACHIEVEMENT_TYPES) {
const a = res.find(x => x.name === t); const a = res.find(x => x.name === t);
if (a) achievements.push(a); if (a) achievements.value.push(a);
} }
//achievements = res.sort((a, b) => b.unlockedAt - a.unlockedAt); //achievements = res.sort((a, b) => b.unlockedAt - a.unlockedAt);
}); });

View File

@ -138,45 +138,45 @@ const texts = computed(() => {
}); });
let enabled = true; let enabled = true;
let majorGraduationColor = $ref<string>(); const majorGraduationColor = ref<string>();
//let minorGraduationColor = $ref<string>(); //let minorGraduationColor = $ref<string>();
let sHandColor = $ref<string>(); const sHandColor = ref<string>();
let mHandColor = $ref<string>(); const mHandColor = ref<string>();
let hHandColor = $ref<string>(); const hHandColor = ref<string>();
let nowColor = $ref<string>(); const nowColor = ref<string>();
let h = $ref<number>(0); const h = ref<number>(0);
let m = $ref<number>(0); const m = ref<number>(0);
let s = $ref<number>(0); const s = ref<number>(0);
let hAngle = $ref<number>(0); const hAngle = ref<number>(0);
let mAngle = $ref<number>(0); const mAngle = ref<number>(0);
let sAngle = $ref<number>(0); const sAngle = ref<number>(0);
let disableSAnimate = $ref(false); const disableSAnimate = ref(false);
let sOneRound = false; let sOneRound = false;
const sLine = ref<SVGPathElement>(); const sLine = ref<SVGPathElement>();
function tick() { function tick() {
const now = props.now(); const now = props.now();
now.setMinutes(now.getMinutes() + now.getTimezoneOffset() + props.offset); now.setMinutes(now.getMinutes() + now.getTimezoneOffset() + props.offset);
const previousS = s; const previousS = s.value;
const previousM = m; const previousM = m.value;
const previousH = h; const previousH = h.value;
s = now.getSeconds(); s.value = now.getSeconds();
m = now.getMinutes(); m.value = now.getMinutes();
h = now.getHours(); h.value = now.getHours();
if (previousS === s && previousM === m && previousH === h) { if (previousS === s.value && previousM === m.value && previousH === h.value) {
return; return;
} }
hAngle = Math.PI * (h % (props.twentyfour ? 24 : 12) + (m + s / 60) / 60) / (props.twentyfour ? 12 : 6); hAngle.value = Math.PI * (h.value % (props.twentyfour ? 24 : 12) + (m.value + s.value / 60) / 60) / (props.twentyfour ? 12 : 6);
mAngle = Math.PI * (m + s / 60) / 30; mAngle.value = Math.PI * (m.value + s.value / 60) / 30;
if (sOneRound && sLine.value) { // (59->0) if (sOneRound && sLine.value) { // (59->0)
sAngle = Math.PI * 60 / 30; sAngle.value = Math.PI * 60 / 30;
defaultIdlingRenderScheduler.delete(tick); defaultIdlingRenderScheduler.delete(tick);
sLine.value.addEventListener('transitionend', () => { sLine.value.addEventListener('transitionend', () => {
disableSAnimate = true; disableSAnimate.value = true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
sAngle = 0; sAngle.value = 0;
requestAnimationFrame(() => { requestAnimationFrame(() => {
disableSAnimate = false; disableSAnimate.value = false;
if (enabled) { if (enabled) {
defaultIdlingRenderScheduler.add(tick); defaultIdlingRenderScheduler.add(tick);
} }
@ -184,9 +184,9 @@ function tick() {
}); });
}, { once: true }); }, { once: true });
} else { } else {
sAngle = Math.PI * s / 30; sAngle.value = Math.PI * s.value / 30;
} }
sOneRound = s === 59; sOneRound = s.value === 59;
} }
tick(); tick();
@ -195,12 +195,12 @@ function calcColors() {
const computedStyle = getComputedStyle(document.documentElement); const computedStyle = getComputedStyle(document.documentElement);
const dark = tinycolor(computedStyle.getPropertyValue('--bg')).isDark(); const dark = tinycolor(computedStyle.getPropertyValue('--bg')).isDark();
const accent = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(); const accent = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString();
majorGraduationColor = dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'; majorGraduationColor.value = dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)';
//minorGraduationColor = dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; //minorGraduationColor = dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
sHandColor = dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)'; sHandColor.value = dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)';
mHandColor = tinycolor(computedStyle.getPropertyValue('--fg')).toHexString(); mHandColor.value = tinycolor(computedStyle.getPropertyValue('--fg')).toHexString();
hHandColor = accent; hHandColor.value = accent;
nowColor = accent; nowColor.value = accent;
} }
calcColors(); calcColors();

View File

@ -43,6 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
fixed fixed
:instant="true" :instant="true"
:initialText="c.form.text" :initialText="c.form.text"
:initialCw="c.form.cw"
/> />
</div> </div>
<MkFolder v-else-if="c.type === 'folder'" :defaultOpen="c.opened"> <MkFolder v-else-if="c.type === 'folder'" :defaultOpen="c.opened">
@ -60,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Ref } from 'vue'; import { Ref, ref } from 'vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
@ -87,16 +88,17 @@ function g(id) {
return props.components.find(x => x.value.id === id).value; return props.components.find(x => x.value.id === id).value;
} }
let valueForSwitch = $ref(c.default ?? false); const valueForSwitch = ref(c.default ?? false);
function onSwitchUpdate(v) { function onSwitchUpdate(v) {
valueForSwitch = v; valueForSwitch.value = v;
if (c.onChange) c.onChange(v); if (c.onChange) c.onChange(v);
} }
function openPostForm() { function openPostForm() {
os.post({ os.post({
initialText: c.form.text, initialText: c.form.text,
initialCw: c.form.cw,
instant: true, instant: true,
}); });
} }

View File

@ -65,9 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import {computed, nextTick, onMounted, reactive, ref, watch} from 'vue'; import { nextTick, onMounted, shallowRef } from 'vue';
import {bannerDark, bannerLight, defaultStore, iconDark, iconLight} from "@/store.js";
import {unisonReload} from "@/scripts/unison-reload.js";
const props = defineProps<{ const props = defineProps<{
type?: 'button' | 'submit' | 'reset'; type?: 'button' | 'submit' | 'reset';
@ -128,13 +126,13 @@ const emit = defineEmits<{
(ev: 'click', payload: MouseEvent): void; (ev: 'click', payload: MouseEvent): void;
}>(); }>();
let el = $shallowRef<HTMLElement | null>(null); const el = shallowRef<HTMLElement | null>(null);
let ripples = $shallowRef<HTMLElement | null>(null); const ripples = shallowRef<HTMLElement | null>(null);
onMounted(() => { onMounted(() => {
if (props.autofocus) { if (props.autofocus) {
nextTick(() => { nextTick(() => {
el!.focus(); el.value!.focus();
}); });
} }
}); });
@ -157,11 +155,11 @@ function onMousedown(evt: MouseEvent): void {
const rect = target.getBoundingClientRect(); const rect = target.getBoundingClientRect();
const ripple = document.createElement('div'); const ripple = document.createElement('div');
ripple.classList.add(ripples!.dataset.childrenClass!); ripple.classList.add(ripples.value!.dataset.childrenClass!);
ripple.style.top = (evt.clientY - rect.top - 1).toString() + 'px'; ripple.style.top = (evt.clientY - rect.top - 1).toString() + 'px';
ripple.style.left = (evt.clientX - rect.left - 1).toString() + 'px'; ripple.style.left = (evt.clientX - rect.left - 1).toString() + 'px';
ripples!.appendChild(ripple); ripples.value!.appendChild(ripple);
const circleCenterX = evt.clientX - rect.left; const circleCenterX = evt.clientX - rect.left;
const circleCenterY = evt.clientY - rect.top; const circleCenterY = evt.clientY - rect.top;
@ -172,11 +170,11 @@ function onMousedown(evt: MouseEvent): void {
ripple.style.transform = 'scale(' + (scale / 2) + ')'; ripple.style.transform = 'scale(' + (scale / 2) + ')';
}, 1); }, 1);
window.setTimeout(() => { window.setTimeout(() => {
ripple.style.transition = 'all 1s cubic-bezier(0, 0.45, 0.30, 1)'; ripple.style.transition = 'all 1s ease';
ripple.style.opacity = '0'; ripple.style.opacity = '0';
}, 1000); }, 1000);
window.setTimeout(() => { window.setTimeout(() => {
if (ripples) ripples.removeChild(ripple); if (ripples.value) ripples.value.removeChild(ripple);
}, 2000); }, 2000);
} }
</script> </script>
@ -198,7 +196,7 @@ function onMousedown(evt: MouseEvent): void {
border-radius: 5px; border-radius: 5px;
overflow: clip; overflow: clip;
box-sizing: border-box; box-sizing: border-box;
transition: background 0.1s cubic-bezier(0, 0.45, 0.30, 1); transition: background 0.1s ease;
&:not(:disabled):hover { &:not(:disabled):hover {
background: var(--buttonHoverBg); background: var(--buttonHoverBg);
@ -289,60 +287,6 @@ function onMousedown(evt: MouseEvent): void {
} }
} }
&.gamingLight {
background: linear-gradient(270deg, #c06161, #c0a567, #b6ba69, #81bc72, #63c3be, #8bacd6, #9f8bd6, #d18bd6, #d883b4);
background-size: 1800% 1800%;
color: white !important;
-webkit-animation: AnimationLight var(--gamingspeed) cubic-bezier(0, 0.2, 0.90, 1) infinite;
-moz-animation: AnimationLight var(--gamingspeed) cubic-bezier(0, 0.2, 0.90, 1) infinite;
animation: AnimationLight var(--gamingspeed) cubic-bezier(0, 0.2, 0.90, 1) infinite;
&:not(:disabled):hover {
background: linear-gradient(270deg, #c06161, #c0a567, #b6ba69, #81bc72, #63c3be, #8bacd6, #9f8bd6, #d18bd6, #d883b4);
background-size: 1800% 1800%;
color: white !important;
-webkit-animation: AnimationLight var(--gamingspeed) cubic-bezier(0, 0.2, 0.90, 1) infinite;
-moz-animation: AnimationLight var(--gamingspeed) cubic-bezier(0, 0.2, 0.90, 1) infinite;
animation: AnimationLight var(--gamingspeed) cubic-bezier(0, 0.2, 0.90, 1) infinite;
}
&:not(:disabled):active {
background: linear-gradient(270deg, #c06161, #c0a567, #b6ba69, #81bc72, #63c3be, #8bacd6, #9f8bd6, #d18bd6, #d883b4);
background-size: 1800% 1800% !important;
color: white !important;
-webkit-animation: AnimationLight var(--gamingspeed) cubic-bezier(0, 0.2, 0.90, 1) infinite;
-moz-animation: AnimationLight var(--gamingspeed) cubic-bezier(0, 0.2, 0.90, 1) infinite ;
animation: AnimationLight var(--gamingspeed) cubic-bezier(0, 0.2, 0.90, 1) infinite ;
}
}
&.gamingDark {
background: linear-gradient(270deg, #e7a2a2, #e3cfa2, #ebefa1, #b3e7a6, #a6ebe7, #aec5e3, #cabded, #e0b9e3, #f4bddd);
background-size: 1800% 1800%;
color: black;
-webkit-animation: AnimationDark var(--gamingspeed) cubic-bezier(0, 0.45, 0.30, 1) infinite;
-moz-animation: AnimationDark var(--gamingspeed) cubic-bezier(0, 0.45, 0.30, 1) infinite;
animation: AnimationDark var(--gamingspeed) cubic-bezier(0, 0.45, 0.30, 1) infinite;
&:not(:disabled):hover {
background: linear-gradient(270deg, #e7a2a2, #e3cfa2, #ebefa1, #b3e7a6, #a6ebe7, #aec5e3, #cabded, #e0b9e3, #f4bddd);
background-size: 1800% 1800% ;
color: black;
-webkit-animation: AnimationDark var(--gamingspeed) cubic-bezier(0, 0.45, 0.30, 1) infinite ;
-moz-animation: AnimationDark var(--gamingspeed) cubic-bezier(0, 0.45, 0.30, 1) infinite;
animation: AnimationDark var(--gamingspeed) cubic-bezier(0, 0.45, 0.30, 1) infinite ;
}
&:not(:disabled):active {
background: linear-gradient(270deg, #e7a2a2, #e3cfa2, #ebefa1, #b3e7a6, #a6ebe7, #aec5e3, #cabded, #e0b9e3, #f4bddd);
background-size: 1800% 1800% !important;
color: black;
-webkit-animation: AnimationDark var(--gamingspeed) cubic-bezier(0, 0.45, 0.30, 1) infinite ;
-moz-animation: AnimationDark var(--gamingspeed) cubic-bezier(0, 0.45, 0.30, 1) infinite;
animation: AnimationDark var(--gamingspeed) cubic-bezier(0, 0.45, 0.30, 1) infinite;
}
}
&.danger { &.danger {
color: #ff2a2a; color: #ff2a2a;
@ -408,36 +352,4 @@ function onMousedown(evt: MouseEvent): void {
z-index: 1; z-index: 1;
pointer-events: none; pointer-events: none;
} }
@-webkit-keyframes AnimationLight {
0%{background-position:0% 50%}
50%{background-position:100% 50%}
100%{background-position:0% 50%}
}
@-moz-keyframes AnimationLight {
0%{background-position:0% 50%}
50%{background-position:100% 50%}
100%{background-position:0% 50%}
}
@keyframes AnimationLight {
0%{background-position:0% 50%}
50%{background-position:100% 50%}
100%{background-position:0% 50%}
}
@-webkit-keyframes AnimationDark {
0%{background-position:0% 50%}
50%{background-position:100% 50%}
100%{background-position:0% 50%}
}
@-moz-keyframes AnimationDark {
0%{background-position:0% 50%}
50%{background-position:100% 50%}
100%{background-position:0% 50%}
}
@keyframes AnimationDark {
0%{background-position:0% 50%}
50%{background-position:100% 50%}
100%{background-position:0% 50%}
}
</style> </style>

View File

@ -74,7 +74,7 @@ const props = defineProps({
}, },
}); });
let legendEl = $shallowRef<InstanceType<typeof MkChartLegend>>(); const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>();
const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
const negate = arr => arr.map(x => -x); const negate = arr => arr.map(x => -x);
@ -268,7 +268,7 @@ const render = () => {
gradient, gradient,
}, },
}, },
plugins: [chartVLine(vLineColor), ...(props.detailed ? [chartLegend(legendEl)] : [])], plugins: [chartVLine(vLineColor), ...(props.detailed ? [chartLegend(legendEl.value)] : [])],
}); });
}; };

View File

@ -13,29 +13,30 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { shallowRef } from 'vue';
import { Chart, LegendItem } from 'chart.js'; import { Chart, LegendItem } from 'chart.js';
const props = defineProps({ const props = defineProps({
}); });
let chart = $shallowRef<Chart>(); const chart = shallowRef<Chart>();
let items = $shallowRef<LegendItem[]>([]); const items = shallowRef<LegendItem[]>([]);
function update(_chart: Chart, _items: LegendItem[]) { function update(_chart: Chart, _items: LegendItem[]) {
chart = _chart, chart.value = _chart,
items = _items; items.value = _items;
} }
function onClick(item: LegendItem) { function onClick(item: LegendItem) {
if (chart == null) return; if (chart.value == null) return;
const { type } = chart.config; const { type } = chart.value.config;
if (type === 'pie' || type === 'doughnut') { if (type === 'pie' || type === 'doughnut') {
// Pie and doughnut charts only have a single dataset and visibility is per item // Pie and doughnut charts only have a single dataset and visibility is per item
chart.toggleDataVisibility(item.index); chart.value.toggleDataVisibility(item.index);
} else { } else {
chart.setDatasetVisibility(item.datasetIndex, !chart.isDatasetVisible(item.datasetIndex)); chart.value.setDatasetVisibility(item.datasetIndex, !chart.value.isDatasetVisible(item.datasetIndex));
} }
chart.update(); chart.value.update();
} }
defineExpose({ defineExpose({

View File

@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import {computed, onMounted, onUnmounted} from 'vue'; import {computed, onMounted, onUnmounted, ref } from 'vue';
import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue'; import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { useInterval } from '@/scripts/use-interval.js'; import { useInterval } from '@/scripts/use-interval.js';
@ -35,8 +35,8 @@ import {defaultStore} from "@/store.js";
const darkMode = computed(defaultStore.makeGetterSetter('darkMode')); const darkMode = computed(defaultStore.makeGetterSetter('darkMode'));
const saveData = game.saveData; const saveData = game.saveData;
const cookies = computed(() => saveData.value?.cookies); const cookies = computed(() => saveData.value?.cookies);
let cps = $ref(0); const cps = ref(0);
let prevCookies = $ref(0); const prevCookies = ref(0);
function onClick(ev: MouseEvent) { function onClick(ev: MouseEvent) {
const x = ev.clientX; const x = ev.clientX;
@ -54,9 +54,9 @@ function onClick(ev: MouseEvent) {
} }
useInterval(() => { useInterval(() => {
const diff = saveData.value!.cookies - prevCookies; const diff = saveData.value!.cookies - prevCookies.value;
cps = diff; cps.value = diff;
prevCookies = saveData.value!.cookies; prevCookies.value = saveData.value!.cookies;
}, 1000, { }, 1000, {
immediate: false, immediate: false,
afterMounted: true, afterMounted: true,
@ -69,7 +69,7 @@ useInterval(game.save, 1000 * 5, {
onMounted(async () => { onMounted(async () => {
await game.load(); await game.load();
prevCookies = saveData.value!.cookies; prevCookies.value = saveData.value!.cookies;
}); });
onUnmounted(() => { onUnmounted(() => {

View File

@ -54,7 +54,7 @@ watch(() => props.lang, (to) => {
return new Promise((resolve) => { return new Promise((resolve) => {
fetchLanguage(to).then(() => resolve); fetchLanguage(to).then(() => resolve);
}); });
}, { immediate: true, }); }, { immediate: true });
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@ -62,7 +62,7 @@ watch(() => props.lang, (to) => {
padding: 1em; padding: 1em;
margin: .5em 0; margin: .5em 0;
overflow: auto; overflow: auto;
border-radius: .3em; border-radius: 8px;
& pre, & pre,
& code { & code {

View File

@ -9,13 +9,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkLoading v-if="!inline ?? true"/> <MkLoading v-if="!inline ?? true"/>
</template> </template>
<code v-if="inline" :class="$style.codeInlineRoot">{{ code }}</code> <code v-if="inline" :class="$style.codeInlineRoot">{{ code }}</code>
<XCode v-else :code="code" :lang="lang"/> <XCode v-else-if="show" :code="code" :lang="lang"/>
<button v-else :class="$style.codePlaceholderRoot" @click="show = true">
<div :class="$style.codePlaceholderContainer">
<div><i class="ti ti-code"></i> {{ i18n.ts.code }}</div>
<div>{{ i18n.ts.clickToShow }}</div>
</div>
</button>
</Suspense> </Suspense>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent } from 'vue'; import { defineAsyncComponent, ref } from 'vue';
import MkLoading from '@/components/global/MkLoading.vue'; import MkLoading from '@/components/global/MkLoading.vue';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
defineProps<{ defineProps<{
code: string; code: string;
@ -23,6 +31,8 @@ defineProps<{
inline?: boolean; inline?: boolean;
}>(); }>();
const show = ref(!defaultStore.state.dataSaver.code);
const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue')); const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue'));
</script> </script>
@ -36,4 +46,27 @@ const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue'))
padding: .1em; padding: .1em;
border-radius: .3em; border-radius: .3em;
} }
.codePlaceholderRoot {
display: block;
width: 100%;
background: none;
border: none;
outline: none;
font: inherit;
color: inherit;
cursor: pointer;
box-sizing: border-box;
border-radius: 8px;
padding: 24px;
margin-top: 4px;
color: #D4D4D4;
background: #1E1E1E;
}
.codePlaceholderContainer {
text-align: center;
font-size: 0.8em;
}
</style> </style>

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<div :class="[$style.codeEditorRoot, { [$style.disabled]: disabled, [$style.focused]: focused }]"> <div :class="[$style.codeEditorRoot, { [$style.focused]: focused }]">
<div :class="$style.codeEditorScroller"> <div :class="$style.codeEditorScroller">
<textarea <textarea
ref="inputEl" ref="inputEl"

View File

@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, onBeforeUnmount } from 'vue'; import { onMounted, onBeforeUnmount, shallowRef, ref } from 'vue';
import MkMenu from './MkMenu.vue'; import MkMenu from './MkMenu.vue';
import { MenuItem } from './types/menu.vue'; import { MenuItem } from './types/menu.vue';
import contains from '@/scripts/contains.js'; import contains from '@/scripts/contains.js';
@ -34,9 +34,9 @@ const emit = defineEmits<{
(ev: 'closed'): void; (ev: 'closed'): void;
}>(); }>();
let rootEl = $shallowRef<HTMLDivElement>(); const rootEl = shallowRef<HTMLDivElement>();
let zIndex = $ref<number>(os.claimZIndex('high')); const zIndex = ref<number>(os.claimZIndex('high'));
const SCROLLBAR_THICKNESS = 16; const SCROLLBAR_THICKNESS = 16;
@ -44,8 +44,8 @@ onMounted(() => {
let left = props.ev.pageX + 1; // + 1 let left = props.ev.pageX + 1; // + 1
let top = props.ev.pageY + 1; // + 1 let top = props.ev.pageY + 1; // + 1
const width = rootEl.offsetWidth; const width = rootEl.value.offsetWidth;
const height = rootEl.offsetHeight; const height = rootEl.value.offsetHeight;
if (left + width - window.pageXOffset >= (window.innerWidth - SCROLLBAR_THICKNESS)) { if (left + width - window.pageXOffset >= (window.innerWidth - SCROLLBAR_THICKNESS)) {
left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset; left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset;
@ -63,8 +63,8 @@ onMounted(() => {
left = 0; left = 0;
} }
rootEl.style.top = `${top}px`; rootEl.value.style.top = `${top}px`;
rootEl.style.left = `${left}px`; rootEl.value.style.left = `${left}px`;
document.body.addEventListener('mousedown', onMousedown); document.body.addEventListener('mousedown', onMousedown);
}); });
@ -74,7 +74,7 @@ onBeforeUnmount(() => {
}); });
function onMousedown(evt: Event) { function onMousedown(evt: Event) {
if (!contains(rootEl, evt.target) && (rootEl !== evt.target)) emit('closed'); if (!contains(rootEl.value, evt.target) && (rootEl.value !== evt.target)) emit('closed');
} }
</script> </script>

View File

@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted } from 'vue'; import { onMounted, shallowRef, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import Cropper from 'cropperjs'; import Cropper from 'cropperjs';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
@ -56,10 +56,10 @@ const props = defineProps<{
}>(); }>();
const imgUrl = getProxiedImageUrl(props.file.url, undefined, true); const imgUrl = getProxiedImageUrl(props.file.url, undefined, true);
let dialogEl = $shallowRef<InstanceType<typeof MkModalWindow>>(); const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
let imgEl = $shallowRef<HTMLImageElement>(); const imgEl = shallowRef<HTMLImageElement>();
let cropper: Cropper | null = null; let cropper: Cropper | null = null;
let loading = $ref(true); const loading = ref(true);
const ok = async () => { const ok = async () => {
const promise = new Promise<Misskey.entities.DriveFile>(async (res) => { const promise = new Promise<Misskey.entities.DriveFile>(async (res) => {
@ -94,16 +94,16 @@ const ok = async () => {
const f = await promise; const f = await promise;
emit('ok', f); emit('ok', f);
dialogEl!.close(); dialogEl.value!.close();
}; };
const cancel = () => { const cancel = () => {
emit('cancel'); emit('cancel');
dialogEl!.close(); dialogEl.value!.close();
}; };
const onImageLoad = () => { const onImageLoad = () => {
loading = false; loading.value = false;
if (cropper) { if (cropper) {
cropper.getCropperImage()!.$center('contain'); cropper.getCropperImage()!.$center('contain');
@ -112,7 +112,7 @@ const onImageLoad = () => {
}; };
onMounted(() => { onMounted(() => {
cropper = new Cropper(imgEl!, { cropper = new Cropper(imgEl.value!, {
}); });
const computedStyle = getComputedStyle(document.documentElement); const computedStyle = getComputedStyle(document.documentElement);

View File

@ -28,12 +28,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<header v-if="title" :class="$style.title"><Mfm :text="title"/></header> <header v-if="title" :class="$style.title"><Mfm :text="title"/></header>
<div v-if="text" :class="$style.text"><Mfm :text="text"/></div> <div v-if="text" :class="$style.text"><Mfm :text="text"/></div>
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown"> <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown">
<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template> <template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template>
<template #caption> <template #caption>
<span v-if="okButtonDisabled && disabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })"/> <span v-if="okButtonDisabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })"/>
<span v-else-if="okButtonDisabled && disabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })"/> <span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })"/>
</template> </template>
</MkInput> </MkInput>
<MkSelect v-if="select" v-model="selectedValue" autofocus> <MkSelect v-if="select" v-model="selectedValue" autofocus>
@ -47,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
</MkSelect> </MkSelect>
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons"> <div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
<MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> <MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
<MkButton v-if="showCancelButton || input || select" data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton> <MkButton v-if="showCancelButton || input || select" data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
</div> </div>
<div v-if="actions" :class="$style.buttons"> <div v-if="actions" :class="$style.buttons">
@ -58,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'; import { onBeforeUnmount, onMounted, ref, shallowRef, computed } from 'vue';
import MkModal from '@/components/MkModal.vue'; import MkModal from '@/components/MkModal.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
@ -126,24 +125,21 @@ const modal = shallowRef<InstanceType<typeof MkModal>>();
const inputValue = ref<string | number | null>(props.input?.default ?? null); const inputValue = ref<string | number | null>(props.input?.default ?? null);
const selectedValue = ref(props.select?.default ?? null); const selectedValue = ref(props.select?.default ?? null);
const mkresult= ref(false) const mkresult= ref(false)
let disabledReason = $ref<null | 'charactersExceeded' | 'charactersBelow'>(null); const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'charactersBelow'>(() => {
const okButtonDisabled = $computed<boolean>(() => {
if (props.input) { if (props.input) {
if (props.input.minLength) { if (props.input.minLength) {
if ((inputValue.value || inputValue.value === '') && (inputValue.value as string).length < props.input.minLength) { if ((inputValue.value || inputValue.value === '') && (inputValue.value as string).length < props.input.minLength) {
disabledReason = 'charactersBelow'; return 'charactersBelow';
return true;
} }
} }
if (props.input.maxLength) { if (props.input.maxLength) {
if (inputValue.value && (inputValue.value as string).length > props.input.maxLength) { if (inputValue.value && (inputValue.value as string).length > props.input.maxLength) {
disabledReason = 'charactersExceeded'; return 'charactersExceeded';
return true;
} }
} }
} }
return false; return null;
}); });
function done(canceled: boolean, result?) { function done(canceled: boolean, result?) {

View File

@ -645,7 +645,7 @@ function getMenu() {
type: 'switch', type: 'switch',
text: i18n.ts.keepOriginalUploading, text: i18n.ts.keepOriginalUploading,
ref: keepOriginal, ref: keepOriginal,
}, null, { }, { type: 'divider' }, {
text: i18n.ts.addFile, text: i18n.ts.addFile,
type: 'label', type: 'label',
}, { }, {
@ -660,7 +660,7 @@ function getMenu() {
action: () => { action: () => {
urlUpload(); urlUpload();
}, },
}, null, { }, { type: 'divider' }, {
text: folder.value ? folder.value.name : i18n.ts.drive, text: folder.value ? folder.value.name : i18n.ts.drive,
type: 'label', type: 'label',
}, folder.value ? { }, folder.value ? {

View File

@ -87,7 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, watch } from 'vue'; import { computed, watch, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { DriveFile } from 'misskey-js/built/entities.js'; import { DriveFile } from 'misskey-js/built/entities.js';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
@ -107,29 +107,28 @@ const props = defineProps<{
isRequest: boolean, isRequest: boolean,
}>(); }>();
let dialog = $ref(null); const dialog = ref(null);
let name: string = $ref(props.emoji ? props.emoji.name : ''); const name = ref<string>(props.emoji ? props.emoji.name : '');
let category: string = $ref(props.emoji ? props.emoji.category : ''); const category = ref<string>(props.emoji ? props.emoji.category : '');
let aliases: string = $ref(props.emoji ? props.emoji.aliases.join(' ') : ''); const aliases = ref<string>(props.emoji ? props.emoji.aliases.join(' ') : '');
let license: string = $ref(props.emoji ? (props.emoji.license ?? '') : ''); const license = ref<string>(props.emoji ? (props.emoji.license ?? '') : '');
let isSensitive = $ref(props.emoji ? props.emoji.isSensitive : false); const isSensitive = ref(props.emoji ? props.emoji.isSensitive : false);
let localOnly = $ref(props.emoji ? props.emoji.localOnly : false); const localOnly = ref(props.emoji ? props.emoji.localOnly : false);
let roleIdsThatCanBeUsedThisEmojiAsReaction = $ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []); const roleIdsThatCanBeUsedThisEmojiAsReaction = ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []);
let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]); const rolesThatCanBeUsedThisEmojiAsReaction = ref([]);
let file = $ref<Misskey.entities.DriveFile>(); const file = ref<Misskey.entities.DriveFile>();
let chooseFile: DriveFile|null = $ref(null); let chooseFile = ref(null);
let draft = $ref(props.emoji ? props.emoji.draft : false); let draft = ref(props.emoji ? props.emoji.draft : false);
let isRequest = $ref(props.isRequest); let isRequest = ref(props.isRequest);
let isNotifyIsHome = $ref(false); let isNotifyIsHome = ref(false);
let url; let url;
watch(roleIdsThatCanBeUsedThisEmojiAsReaction, async () => {
watch($$(roleIdsThatCanBeUsedThisEmojiAsReaction), async () => { rolesThatCanBeUsedThisEmojiAsReaction.value = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.value.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
rolesThatCanBeUsedThisEmojiAsReaction = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
}, { immediate: true }); }, { immediate: true });
const imgUrl = computed(() => file ? file.url : props.emoji ? `/emoji/${props.emoji.name}.webp` : null); const imgUrl = computed(() => file.value ? file.value.url : props.emoji ? `/emoji/${props.emoji.name}.webp` : null);
const validation = computed(() => { const validation = computed(() => {
return name.match(/^[a-zA-Z0-9_]+$/) && imgUrl.value != null; return name.value.match(/^[a-zA-Z0-9_]+$/) && imgUrl.value != null;
}); });
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void, (ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void,
@ -138,7 +137,7 @@ const emit = defineEmits<{
function ok() { function ok() {
if (isRequest) { if (isRequest) {
if (chooseFile !== null && name.match(/^[a-zA-Z0-9_]+$/)) { if (chooseFile.value !== null && name.value.match(/^[a-zA-Z0-9_]+$/)) {
add(); add();
} }
} else { } else {
@ -150,87 +149,87 @@ async function add() {
const ret = await os.api('admin/emoji/add-draft', { const ret = await os.api('admin/emoji/add-draft', {
name: name, name: name,
category: category, category: category,
aliases: aliases.split(' '), aliases: aliases.value.split(' '),
license: license === '' ? null : license, license: license.value === '' ? null : license.value,
fileId: chooseFile.id, fileId: chooseFile.value.id,
isNotifyIsHome: isNotifyIsHome, isNotifyIsHome: isNotifyIsHome,
}); });
emit('done', { emit('done', {
updated: { updated: {
id: ret.id, id: ret.value.id,
name, name,
category, category,
aliases: aliases.split(' '), aliases: aliases.value.split(' '),
license: license === '' ? null : license, license: license.value === '' ? null : license,
draft: true, draft: true,
}, },
}); });
dialog.close(); dialog.value.close();
} }
async function changeImage(ev) { async function changeImage(ev) {
file = await selectFile(ev.currentTarget ?? ev.target, null); file.value = await selectFile(ev.currentTarget ?? ev.target, null);
const candidate = file.name.replace(/\.(.+)$/, ''); const candidate = file.value.name.replace(/\.(.+)$/, '');
if (candidate.match(/^[a-z0-9_]+$/)) { if (candidate.match(/^[a-z0-9_]+$/)) {
name = candidate; name.value = candidate;
} }
} }
async function addRole() { async function addRole() {
const roles = await os.api('admin/roles/list'); const roles = await os.api('admin/roles/list');
const currentRoleIds = rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id); const currentRoleIds = rolesThatCanBeUsedThisEmojiAsReaction.value.map(x => x.id);
const { canceled, result: role } = await os.select({ const { canceled, result: role } = await os.select({
items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })), items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })),
}); });
if (canceled) return; if (canceled) return;
rolesThatCanBeUsedThisEmojiAsReaction.push(role); rolesThatCanBeUsedThisEmojiAsReaction.value.push(role);
} }
async function removeRole(role, ev) { async function removeRole(role, ev) {
rolesThatCanBeUsedThisEmojiAsReaction = rolesThatCanBeUsedThisEmojiAsReaction.filter(x => x.id !== role.id); rolesThatCanBeUsedThisEmojiAsReaction.value = rolesThatCanBeUsedThisEmojiAsReaction.value.filter(x => x.id !== role.id);
} }
async function update() { async function update() {
await os.apiWithDialog('admin/emoji/update', { await os.apiWithDialog('admin/emoji/update', {
id: props.emoji.id, id: props.emoji.id,
name, name: name.value,
category, category: category.value === '' ? null : category.value,
aliases: aliases.split(' '), aliases: aliases.value.split(' ').filter(x => x !== ''),
license: license === '' ? null : license, license: license.value === '' ? null : license.value,
fileId: chooseFile?.id, fileId: chooseFile.value?.id,
draft: draft, draft: draft,
}); });
emit('done', { emit('done', {
updated: { updated: {
id: props.emoji.id, id: props.emoji.id,
name, name: name.value,
category, category: category.value === '' ? null : category.value,
aliases: aliases.split(' '), aliases: aliases.value.split(' ').filter(x => x !== ''),
license: license === '' ? null : license, license: license.value === '' ? null : license.value,
draft: draft, draft: draft.value,
}, },
}); });
dialog.close(); dialog.value.close();
} }
async function done() { async function done() {
const params = { const params = {
name, name: name.value,
category: category === '' ? null : category, category: category.value === '' ? null : category.value,
aliases: aliases.replace(' ', ' ').split(' ').filter(x => x !== ''), aliases: aliases.value.split(' ').filter(x => x !== ''),
license: license === '' ? null : license, license: license.value === '' ? null : license.value,
isSensitive: isSensitive.value,
draft: draft, draft: draft,
isSensitive, localOnly: localOnly.value,
localOnly, roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.value.map(x => x.id),
roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id),
isNotifyIsHome, isNotifyIsHome,
}; };
if (file) { if (file.value) {
params.fileId = file.id; params.fileId = file.value.id;
} }
console.log(props.emoji); console.log(props.emoji);
if (props.emoji) { if (props.emoji) {
@ -246,7 +245,7 @@ async function done() {
}, },
}); });
dialog.close(); dialog.value.close();
} else { } else {
const created = isRequest const created = isRequest
? await os.apiWithDialog('admin/emoji/add-draft', params) ? await os.apiWithDialog('admin/emoji/add-draft', params)
@ -256,21 +255,21 @@ async function done() {
created: created, created: created,
}); });
dialog.close(); dialog.value.close();
} }
} }
function chooseFileFrom(ev) { function chooseFileFrom(ev) {
selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => { selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => {
chooseFile = files_[0]; chooseFile.value = files_[0];
url = chooseFile.url; url = chooseFile.value.url;
}); });
} }
async function del() { async function del() {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'warning', type: 'warning',
text: i18n.t('removeAreYouSure', { x: name }), text: i18n.t('removeAreYouSure', { x: name.value }),
}); });
if (canceled) return; if (canceled) return;
@ -280,7 +279,7 @@ async function del() {
emit('done', { emit('done', {
deleted: true, deleted: true,
}); });
dialog.close(); dialog.value.close();
}); });
} }
</script> </script>

View File

@ -5,75 +5,44 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<!-- このコンポーネントの要素のclassは親から利用されるのでむやみに弄らないこと --> <!-- このコンポーネントの要素のclassは親から利用されるのでむやみに弄らないこと -->
<section> <!-- フォルダの中にはカスタム絵文字だけUnicode絵文字もこっち -->
<section v-if="!hasChildSection" v-panel style="border-radius: 6px; border-bottom: 0.5px solid var(--divider);">
<header class="_acrylic" @click="shown = !shown">
<header v-if="!category" class="_acrylic" @click="shown = !shown"> <i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> <slot></slot> (<i class="ti ti-icons"></i>:{{ emojis.length }})
<i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i>
<slot></slot>
({{ emojis.length }})
</header> </header>
<header v-else-if="category.length === 1" class="_acrylic" @click="shown = !shown"> <div v-if="shown" class="body">
<i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> <button
{{ category[0] }} v-for="emoji in emojis"
({{ :key="emoji"
emojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category[0]).length :data-emoji="emoji"
}}) class="_button item"
</header> @pointerenter="computeButtonTitle"
<header v-else class="_acrylic" style="top:unset;" @click="toggleShown_fol"> @click="emit('chosen', emoji, $event)"
<i class="toggle ti-fw" :class="shown_fol? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> >
{{ category[0] || i18n.ts.other }} <MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
<template v-if="category.length !== 1"> <MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
({{ i18n.ts.Folder }}) </button>
</template> </div>
<template v-else> </section>
({{ <!-- フォルダの中にはカスタム絵文字やフォルダがある -->
emojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category[0]).length <section v-else v-panel style="border-radius: 6px; border-bottom: 0.5px solid var(--divider);">
}}) 2 <header class="_acrylic" @click="shown = !shown">
</template> <i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> <slot></slot> (<i class="ti ti-folder ti-fw"></i>:{{ customEmojiTree.length }} <i class="ti ti-icons ti-fw"></i>:{{ emojis.length }})
</header> </header>
<template v-for="(n, index) in category" v-if="shown_fol"> <div v-if="shown" style="padding-left: 9px;">
<header <MkEmojiPickerSection
v-if="emojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category[0]).length !== 0 || index!==0" v-for="child in customEmojiTree"
style="top:unset;padding-left: 18px;" :key="`custom:${child.value}`"
class="_acrylic" :initialShown="initialShown"
@click="toggleShown(index)" :emojis="computed(() => customEmojis.filter(e => e.category === child.category).map(e => `:${e.name}:`))"
> :hasChildSection="child.children.length !== 0"
<i class="toggle ti-fw" :class="shown_fold[index] ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> :customEmojiTree="child.children"
{{ n || i18n.ts.other }} @chosen="nestedChosen"
({{ >
emojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === (index === 0 && category !== undefined ? category[0] : `${category[0]}/${n}`)).length {{ child.value || i18n.ts.other }}
}}) </MkEmojiPickerSection>
</header> </div>
<div v-if="shown_fold[index]" class="body"> <div v-if="shown" class="body">
<button
v-for="emoji in emojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category ===( index === 0 && category !== undefined ? category[0] : `${category[0]}/${n}`)).map(e => `:${e.name}:`)"
:key="emoji"
:data-emoji="emoji"
class="_button item"
@pointerenter="computeButtonTitle"
@click="emit('chosen', emoji, $event)"
>
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
</button>
</div>
</template>
<div v-if="shown && category" class="body">
<button
v-for="emoji in emojis.filter(e => e.category === category[0]).map(e => `:${e.name}:`)"
:key="emoji"
:data-emoji="emoji"
class="_button item"
@pointerenter="computeButtonTitle"
@click="emit('chosen', emoji, $event)"
>
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
</button>
</div>
<div v-else-if="shown && !category" class="body">
<button <button
v-for="emoji in emojis" v-for="emoji in emojis"
:key="emoji" :key="emoji"
@ -86,19 +55,22 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/> <MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
</button> </button>
</div> </div>
</section> </section>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, Ref } from 'vue'; import { ref, computed, Ref } from 'vue';
import {getEmojiName} from '@/scripts/emojilist.js'; import { CustomEmojiFolderTree, getEmojiName } from '@/scripts/emojilist.js';
import {i18n} from "../i18n.js"; import { i18n } from '../i18n.js';
import { customEmojis } from '@/custom-emojis.js';
import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue';
const props = defineProps<{ const props = defineProps<{
category?: string[];
emojis: string[] | Ref<string[]>; emojis: string[] | Ref<string[]>;
initialShown?: boolean; initialShown?: boolean;
category?: string[]; hasChildSection?: boolean;
customEmojiTree?: CustomEmojiFolderTree[];
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -129,4 +101,7 @@ function computeButtonTitle(ev: MouseEvent): void {
elm.title = getEmojiName(emoji) ?? emoji; elm.title = getEmojiName(emoji) ?? emoji;
} }
function nestedChosen(emoji: any, ev?: MouseEvent) {
emit('chosen', emoji, ev);
}
</script> </script>

View File

@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</section> </section>
<div v-if="tab === 'index'" class="group index"> <div v-if="tab === 'index'" class="group index">
<section v-if="showPinned"> <section v-if="showPinned && pinned.length > 0">
<div class="body"> <div class="body">
<button <button
v-for="emoji in pinned" v-for="emoji in pinned"
@ -72,26 +72,21 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div v-once class="group"> <div v-once class="group">
<header class="_acrylic">{{ i18n.ts.customEmojis }}</header> <header class="_acrylic">{{ i18n.ts.customEmojis }}</header>
<XSection <XSection
v-for="category in groupedData" v-for="child in customEmojiFolderRoot.children"
:key="`custom:${category}`" :key="`custom:${child.value}`"
:initialShown="false" :initialShown="false"
:emojis="computed(() => customEmojis.filter(emoji => !emoji.draft).filter(filterAvailable))" :emojis="computed(() => customEmojis.filter(e => child.value === '' ? (e.category === 'null' || !e.category) : e.category === child.value).filter(filterAvailable).map(e => `:${e.name}:`))"
:category="category" :hasChildSection="child.children.length !== 0"
:customEmojiTree="child.children"
@chosen="chosen" @chosen="chosen"
/> >
{{ child.value || i18n.ts.other }}
</XSection>
</div> </div>
<div v-once class="group"> <div v-once class="group">
<header class="_acrylic">{{ i18n.ts.emoji }}</header> <header class="_acrylic">{{ i18n.ts.emoji }}</header>
<XSection <XSection v-for="category in categories" :key="category" :emojis="emojiCharByCategory.get(category) ?? []" :hasChildSection="false" @chosen="chosen">{{ category }}</XSection>
v-for="category in categories"
:key="category"
:emojis="emojiCharByCategory.get(category) ?? []"
@chosen="chosen"
>{{ category }}
</XSection>
</div> </div>
</div> </div>
<div class="tabs"> <div class="tabs">
@ -107,7 +102,14 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref, shallowRef, computed, watch, onMounted } from 'vue'; import { ref, shallowRef, computed, watch, onMounted } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import XSection from '@/components/MkEmojiPicker.section.vue'; import XSection from '@/components/MkEmojiPicker.section.vue';
import { emojilist, emojiCharByCategory, UnicodeEmojiDef, unicodeEmojiCategories as categories, getEmojiName } from '@/scripts/emojilist.js'; import {
emojilist,
emojiCharByCategory,
UnicodeEmojiDef,
unicodeEmojiCategories as categories,
getEmojiName,
CustomEmojiFolderTree,
} from '@/scripts/emojilist.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { isTouchUsing } from '@/scripts/touch.js'; import { isTouchUsing } from '@/scripts/touch.js';
@ -135,7 +137,7 @@ const searchEl = shallowRef<HTMLInputElement>();
const emojisEl = shallowRef<HTMLDivElement>(); const emojisEl = shallowRef<HTMLDivElement>();
const { const {
reactions: pinned, reactions: pinnedReactions,
reactionPickerSize, reactionPickerSize,
reactionPickerWidth, reactionPickerWidth,
reactionPickerHeight, reactionPickerHeight,
@ -143,35 +145,44 @@ const {
recentlyUsedEmojis, recentlyUsedEmojis,
} = defaultStore.reactiveState; } = defaultStore.reactiveState;
const pinned = computed(() => props.asReactionPicker ? pinnedReactions.value : []); // TODO: pinned
const size = computed(() => props.asReactionPicker ? reactionPickerSize.value : 1); const size = computed(() => props.asReactionPicker ? reactionPickerSize.value : 1);
const width = computed(() => props.asReactionPicker ? reactionPickerWidth.value : 3); const width = computed(() => props.asReactionPicker ? reactionPickerWidth.value : 3);
const height = computed(() => props.asReactionPicker ? reactionPickerHeight.value : 2); const height = computed(() => props.asReactionPicker ? reactionPickerHeight.value : 2);
const q = ref<string>(''); const q = ref<string>('');
const searchResultCustom = ref<Misskey.entities.CustomEmoji[]>([]); const searchResultCustom = ref<Misskey.entities.EmojiSimple[]>([]);
const searchResultUnicode = ref<UnicodeEmojiDef[]>([]); const searchResultUnicode = ref<UnicodeEmojiDef[]>([]);
const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index'); const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index');
let split_categories = [];
customEmojiCategories.value.forEach(e => { const customEmojiFolderRoot: CustomEmojiFolderTree = { value: "", category: "", children: [] };
if (e !== null){
split_categories.push(e.split('/')) function parseAndMergeCategories(input: string, root: CustomEmojiFolderTree): CustomEmojiFolderTree {
const parts = input.split('/').map(p => p.trim());
let currentNode: CustomEmojiFolderTree = root;
for (const part of parts) {
let existingNode = currentNode.children.find((node) => node.value === part);
if (!existingNode) {
const newNode: CustomEmojiFolderTree = { value: part, category: input, children: [] };
currentNode.children.push(newNode);
existingNode = newNode;
}
currentNode = existingNode;
}
return currentNode;
}
customEmojiCategories.value.forEach(ec => {
if (ec !== null) {
parseAndMergeCategories(ec, customEmojiFolderRoot);
} }
}); });
const groupedData = {}; parseAndMergeCategories('', customEmojiFolderRoot);
split_categories.forEach((item) => {
const key = item[0];
if (!groupedData[key]) {
groupedData[key] = [];
groupedData[key].push(item[0]);
}
if (item[1] !== undefined){
groupedData[key].push(item[1]);
}
});
console.log(groupedData)
watch(q, () => { watch(q, () => {
if (emojisEl.value) emojisEl.value.scrollTop = 0; if (emojisEl.value) emojisEl.value.scrollTop = 0;
@ -185,8 +196,8 @@ watch(q, () => {
const searchCustom = () => { const searchCustom = () => {
const max = 100; const max = 100;
const emojis = customEmojis.value.filter(emoji => !emoji.draft); const emojis = customEmojis.value;
const matches = new Set<Misskey.entities.CustomEmoji>(); const matches = new Set<Misskey.entities.EmojiSimple>();
const exactMatch = emojis.find(emoji => emoji.name === newQ); const exactMatch = emojis.find(emoji => emoji.name === newQ);
if (exactMatch) matches.add(exactMatch); if (exactMatch) matches.add(exactMatch);
@ -316,7 +327,7 @@ watch(q, () => {
searchResultUnicode.value = Array.from(searchUnicode()); searchResultUnicode.value = Array.from(searchUnicode());
}); });
function filterAvailable(emoji: Misskey.entities.CustomEmoji): boolean { function filterAvailable(emoji: Misskey.entities.EmojiSimple): boolean {
return (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction == null || emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0) || ($i && $i.roles.some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id))); return (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction == null || emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0) || ($i && $i.roles.some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id)));
} }
@ -333,7 +344,7 @@ function reset() {
q.value = ''; q.value = '';
} }
function getKey(emoji: string | Misskey.entities.CustomEmoji | UnicodeEmojiDef): string { function getKey(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef): string {
return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`; return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`;
} }

View File

@ -36,15 +36,17 @@ import MkModal from '@/components/MkModal.vue';
import MkEmojiPicker from '@/components/MkEmojiPicker.vue'; import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
withDefaults(defineProps<{ const props = withDefaults(defineProps<{
manualShowing?: boolean | null; manualShowing?: boolean | null;
src?: HTMLElement; src?: HTMLElement;
showPinned?: boolean; showPinned?: boolean;
asReactionPicker?: boolean; asReactionPicker?: boolean;
choseAndClose?: boolean;
}>(), { }>(), {
manualShowing: null, manualShowing: null,
showPinned: true, showPinned: true,
asReactionPicker: false, asReactionPicker: false,
choseAndClose: true,
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@ -58,8 +60,10 @@ const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>();
function chosen(emoji: any) { function chosen(emoji: any) {
emit('done', emoji); emit('done', emoji);
if (props.choseAndClose) {
modal.value?.close(); modal.value?.close();
} }
}
function opening() { function opening() {
picker.value?.reset(); picker.value?.reset();

View File

@ -12,7 +12,7 @@ import { ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import * as os from '@/os.js'; import * as os from '@/os.js';
const meta = ref<Misskey.entities.DetailedInstanceMetadata>(); const meta = ref<Misskey.entities.MetaResponse>();
os.api('meta', { detail: true }).then(gotMeta => { os.api('meta', { detail: true }).then(gotMeta => {
meta.value = gotMeta; meta.value = gotMeta;

View File

@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { shallowRef, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import MkTextarea from '@/components/MkTextarea.vue'; import MkTextarea from '@/components/MkTextarea.vue';
@ -42,12 +42,12 @@ const emit = defineEmits<{
(ev: 'closed'): void; (ev: 'closed'): void;
}>(); }>();
const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
let caption = $ref(props.default); const caption = ref(props.default);
async function ok() { async function ok() {
emit('done', caption); emit('done', caption.value);
dialog.close(); dialog.value.close();
} }
</script> </script>

View File

@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { nextTick, onMounted } from 'vue'; import { nextTick, onMounted, shallowRef, ref } from 'vue';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@ -70,10 +70,10 @@ const getBgColor = (el: HTMLElement) => {
} }
}; };
let rootEl = $shallowRef<HTMLElement>(); const rootEl = shallowRef<HTMLElement>();
let bgSame = $ref(false); const bgSame = ref(false);
let opened = $ref(props.defaultOpen); const opened = ref(props.defaultOpen);
let openedAtLeastOnce = $ref(props.defaultOpen); const openedAtLeastOnce = ref(props.defaultOpen);
function enter(el) { function enter(el) {
const elementHeight = el.getBoundingClientRect().height; const elementHeight = el.getBoundingClientRect().height;
@ -98,20 +98,20 @@ function afterLeave(el) {
} }
function toggle() { function toggle() {
if (!opened) { if (!opened.value) {
openedAtLeastOnce = true; openedAtLeastOnce.value = true;
} }
nextTick(() => { nextTick(() => {
opened = !opened; opened.value = !opened.value;
}); });
} }
onMounted(() => { onMounted(() => {
const computedStyle = getComputedStyle(document.documentElement); const computedStyle = getComputedStyle(document.documentElement);
const parentBg = getBgColor(rootEl.parentElement); const parentBg = getBgColor(rootEl.value.parentElement);
const myBg = computedStyle.getPropertyValue('--panel'); const myBg = computedStyle.getPropertyValue('--panel');
bgSame = parentBg === myBg; bgSame.value = parentBg === myBg;
}); });
</script> </script>

View File

@ -79,9 +79,9 @@ const emit = defineEmits<{
(_: 'update:user', value: Misskey.entities.UserDetailed): void (_: 'update:user', value: Misskey.entities.UserDetailed): void
}>(); }>();
let isFollowing = $ref(props.user.isFollowing); const isFollowing = ref(props.user.isFollowing);
let hasPendingFollowRequestFromYou = $ref(props.user.hasPendingFollowRequestFromYou); const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFromYou);
let wait = $ref(false); const wait = ref(false);
const connection = useStream().useChannel('main'); const connection = useStream().useChannel('main');
if (props.user.isFollowing == null) { if (props.user.isFollowing == null) {
@ -93,16 +93,16 @@ if (props.user.isFollowing == null) {
function onFollowChange(user: Misskey.entities.UserDetailed) { function onFollowChange(user: Misskey.entities.UserDetailed) {
if (user.id === props.user.id) { if (user.id === props.user.id) {
isFollowing = user.isFollowing; isFollowing.value = user.isFollowing;
hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; hasPendingFollowRequestFromYou.value = user.hasPendingFollowRequestFromYou;
} }
} }
async function onClick() { async function onClick() {
wait = true; wait.value = true;
try { try {
if (isFollowing) { if (isFollowing.value) {
const {canceled} = await os.confirm({ const {canceled} = await os.confirm({
type: 'warning', type: 'warning',
text: i18n.t('unfollowConfirm', {name: props.user.name || props.user.username}), text: i18n.t('unfollowConfirm', {name: props.user.name || props.user.username}),
@ -114,11 +114,11 @@ async function onClick() {
userId: props.user.id, userId: props.user.id,
}); });
} else { } else {
if (hasPendingFollowRequestFromYou) { if (hasPendingFollowRequestFromYou.value) {
await os.api('following/requests/cancel', { await os.api('following/requests/cancel', {
userId: props.user.id, userId: props.user.id,
}); });
hasPendingFollowRequestFromYou = false; hasPendingFollowRequestFromYou.value = false;
} else { } else {
await os.api('following/create', { await os.api('following/create', {
userId: props.user.id, userId: props.user.id,
@ -126,8 +126,9 @@ async function onClick() {
}); });
emit('update:user', { emit('update:user', {
...props.user, ...props.user,
withReplies: defaultStore.state.defaultWithReplies}); withReplies: defaultStore.state.defaultWithReplies,
hasPendingFollowRequestFromYou = true; });
hasPendingFollowRequestFromYou.value = true;
claimAchievement('following1'); claimAchievement('following1');
@ -148,7 +149,7 @@ async function onClick() {
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally { } finally {
wait = false; wait.value = false;
} }
} }

View File

@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { ref } from 'vue';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
@ -53,19 +53,19 @@ const emit = defineEmits<{
(ev: 'closed'): void; (ev: 'closed'): void;
}>(); }>();
let dialog: InstanceType<typeof MkModalWindow> = $ref(); const dialog = ref<InstanceType<typeof MkModalWindow>>();
let username = $ref(''); const username = ref('');
let email = $ref(''); const email = ref('');
let processing = $ref(false); const processing = ref(false);
async function onSubmit() { async function onSubmit() {
processing = true; processing.value = true;
await os.apiWithDialog('request-reset-password', { await os.apiWithDialog('request-reset-password', {
username, username: username.value,
email, email: email.value,
}); });
emit('done'); emit('done');
dialog.close(); dialog.value.close();
} }
</script> </script>

View File

@ -23,7 +23,7 @@ const query = ref(props.q);
const search = () => { const search = () => {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
sp.append('q', query.value); sp.append('q', query.value);
window.open(`https://www.google.com/search?${sp.toString()}`, '_blank'); window.open(`https://www.google.com/search?${sp.toString()}`, '_blank', 'noopener');
}; };
</script> </script>

View File

@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, nextTick, watch } from 'vue'; import { onMounted, nextTick, watch, shallowRef, ref } from 'vue';
import { Chart } from 'chart.js'; import { Chart } from 'chart.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
@ -27,11 +27,11 @@ const props = defineProps<{
src: string; src: string;
}>(); }>();
const rootEl = $shallowRef<HTMLDivElement>(null); const rootEl = shallowRef<HTMLDivElement>(null);
const chartEl = $shallowRef<HTMLCanvasElement>(null); const chartEl = shallowRef<HTMLCanvasElement>(null);
const now = new Date(); const now = new Date();
let chartInstance: Chart = null; let chartInstance: Chart = null;
let fetching = $ref(true); const fetching = ref(true);
const { handler: externalTooltipHandler } = useChartTooltip({ const { handler: externalTooltipHandler } = useChartTooltip({
position: 'middle', position: 'middle',
@ -42,8 +42,8 @@ async function renderChart() {
chartInstance.destroy(); chartInstance.destroy();
} }
const wide = rootEl.offsetWidth > 700; const wide = rootEl.value.offsetWidth > 700;
const narrow = rootEl.offsetWidth < 400; const narrow = rootEl.value.offsetWidth < 400;
const weeks = wide ? 50 : narrow ? 10 : 25; const weeks = wide ? 50 : narrow ? 10 : 25;
const chartLimit = 7 * weeks; const chartLimit = 7 * weeks;
@ -88,7 +88,7 @@ async function renderChart() {
values = raw.deliverFailed; values = raw.deliverFailed;
} }
fetching = false; fetching.value = false;
await nextTick(); await nextTick();
@ -101,7 +101,7 @@ async function renderChart() {
const marginEachCell = 4; const marginEachCell = 4;
chartInstance = new Chart(chartEl, { chartInstance = new Chart(chartEl.value, {
type: 'matrix', type: 'matrix',
data: { data: {
datasets: [{ datasets: [{
@ -210,7 +210,7 @@ async function renderChart() {
} }
watch(() => props.src, () => { watch(() => props.src, () => {
fetching = true; fetching.value = true;
renderChart(); renderChart();
}); });

View File

@ -21,7 +21,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts"> <script lang="ts">
import { $ref } from 'vue/macros';
import DrawBlurhash from '@/workers/draw-blurhash?worker'; import DrawBlurhash from '@/workers/draw-blurhash?worker';
import TestWebGL2 from '@/workers/test-webgl2?worker'; import TestWebGL2 from '@/workers/test-webgl2?worker';
import { WorkerMultiDispatch } from '@/scripts/worker-multi-dispatch.js'; import { WorkerMultiDispatch } from '@/scripts/worker-multi-dispatch.js';
@ -58,7 +57,7 @@ const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resol
</script> </script>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, shallowRef, watch, ref } from 'vue';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { render } from 'buraha'; import { render } from 'buraha';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
@ -98,41 +97,41 @@ const viewId = uuid();
const canvas = shallowRef<HTMLCanvasElement>(); const canvas = shallowRef<HTMLCanvasElement>();
const root = shallowRef<HTMLDivElement>(); const root = shallowRef<HTMLDivElement>();
const img = shallowRef<HTMLImageElement>(); const img = shallowRef<HTMLImageElement>();
let loaded = $ref(false); const loaded = ref(false);
let canvasWidth = $ref(64); const canvasWidth = ref(64);
let canvasHeight = $ref(64); const canvasHeight = ref(64);
let imgWidth = $ref(props.width); const imgWidth = ref(props.width);
let imgHeight = $ref(props.height); const imgHeight = ref(props.height);
let bitmapTmp = $ref<CanvasImageSource | undefined>(); const bitmapTmp = ref<CanvasImageSource | undefined>();
const hide = computed(() => !loaded || props.forceBlurhash); const hide = computed(() => !loaded.value || props.forceBlurhash);
function waitForDecode() { function waitForDecode() {
if (props.src != null && props.src !== '') { if (props.src != null && props.src !== '') {
nextTick() nextTick()
.then(() => img.value?.decode()) .then(() => img.value?.decode())
.then(() => { .then(() => {
loaded = true; loaded.value = true;
}, error => { }, error => {
console.log('Error occurred during decoding image', img.value, error); console.log('Error occurred during decoding image', img.value, error);
}); });
} else { } else {
loaded = false; loaded.value = false;
} }
} }
watch([() => props.width, () => props.height, root], () => { watch([() => props.width, () => props.height, root], () => {
const ratio = props.width / props.height; const ratio = props.width / props.height;
if (ratio > 1) { if (ratio > 1) {
canvasWidth = Math.round(64 * ratio); canvasWidth.value = Math.round(64 * ratio);
canvasHeight = 64; canvasHeight.value = 64;
} else { } else {
canvasWidth = 64; canvasWidth.value = 64;
canvasHeight = Math.round(64 / ratio); canvasHeight.value = Math.round(64 / ratio);
} }
const clientWidth = root.value?.clientWidth ?? 300; const clientWidth = root.value?.clientWidth ?? 300;
imgWidth = clientWidth; imgWidth.value = clientWidth;
imgHeight = Math.round(clientWidth / ratio); imgHeight.value = Math.round(clientWidth / ratio);
}, { }, {
immediate: true, immediate: true,
}); });
@ -140,15 +139,15 @@ watch([() => props.width, () => props.height, root], () => {
function drawImage(bitmap: CanvasImageSource) { function drawImage(bitmap: CanvasImageSource) {
// canvasmountedTmp // canvasmountedTmp
if (!canvas.value) { if (!canvas.value) {
bitmapTmp = bitmap; bitmapTmp.value = bitmap;
return; return;
} }
// canvas // canvas
bitmapTmp = undefined; bitmapTmp.value = undefined;
const ctx = canvas.value.getContext('2d'); const ctx = canvas.value.getContext('2d');
if (!ctx) return; if (!ctx) return;
ctx.drawImage(bitmap, 0, 0, canvasWidth, canvasHeight); ctx.drawImage(bitmap, 0, 0, canvasWidth.value, canvasHeight.value);
} }
function drawAvg() { function drawAvg() {
@ -160,7 +159,7 @@ function drawAvg() {
// avgColor // avgColor
ctx.beginPath(); ctx.beginPath();
ctx.fillStyle = extractAvgColorFromBlurhash(props.hash) ?? '#888'; ctx.fillStyle = extractAvgColorFromBlurhash(props.hash) ?? '#888';
ctx.fillRect(0, 0, canvasWidth, canvasHeight); ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
} }
async function draw() { async function draw() {
@ -212,8 +211,8 @@ watch(() => props.hash, () => {
onMounted(() => { onMounted(() => {
// drawImagemounted // drawImagemounted
if (bitmapTmp) { if (bitmapTmp.value) {
drawImage(bitmapTmp); drawImage(bitmapTmp.value);
} }
waitForDecode(); waitForDecode();
}); });

View File

@ -15,21 +15,22 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import MkMiniChart from '@/components/MkMiniChart.vue'; import MkMiniChart from '@/components/MkMiniChart.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
const props = defineProps<{ const props = defineProps<{
instance: Misskey.entities.Instance; instance: Misskey.entities.FederationInstance;
}>(); }>();
let chartValues = $ref<number[] | null>(null); const chartValues = ref<number[] | null>(null);
os.apiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => { os.apiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => {
// //
res.requests.received.splice(0, 1); res['requests.received'].splice(0, 1);
chartValues = res.requests.received; chartValues.value = res['requests.received'];
}); });
function getInstanceIcon(instance): string { function getInstanceIcon(instance): string {

View File

@ -84,7 +84,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted } from 'vue'; import { onMounted, ref, shallowRef } from 'vue';
import { Chart } from 'chart.js'; import { Chart } from 'chart.js';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import MkChart from '@/components/MkChart.vue'; import MkChart from '@/components/MkChart.vue';
@ -100,11 +100,11 @@ import { initChart } from '@/scripts/init-chart.js';
initChart(); initChart();
const chartLimit = 500; const chartLimit = 500;
let chartSpan = $ref<'hour' | 'day'>('hour'); const chartSpan = ref<'hour' | 'day'>('hour');
let chartSrc = $ref('active-users'); const chartSrc = ref('active-users');
let heatmapSrc = $ref('active-users'); const heatmapSrc = ref('active-users');
let subDoughnutEl = $shallowRef<HTMLCanvasElement>(); const subDoughnutEl = shallowRef<HTMLCanvasElement>();
let pubDoughnutEl = $shallowRef<HTMLCanvasElement>(); const pubDoughnutEl = shallowRef<HTMLCanvasElement>();
const { handler: externalTooltipHandler1 } = useChartTooltip({ const { handler: externalTooltipHandler1 } = useChartTooltip({
position: 'middle', position: 'middle',
@ -163,7 +163,7 @@ function createDoughnut(chartEl, tooltip, data) {
onMounted(() => { onMounted(() => {
os.apiGet('federation/stats', { limit: 30 }).then(fedStats => { os.apiGet('federation/stats', { limit: 30 }).then(fedStats => {
createDoughnut(subDoughnutEl, externalTooltipHandler1, fedStats.topSubInstances.map(x => ({ createDoughnut(subDoughnutEl.value, externalTooltipHandler1, fedStats.topSubInstances.map(x => ({
name: x.host, name: x.host,
color: x.themeColor, color: x.themeColor,
value: x.followersCount, value: x.followersCount,
@ -172,7 +172,7 @@ onMounted(() => {
}, },
})).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowersCount }])); })).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowersCount }]));
createDoughnut(pubDoughnutEl, externalTooltipHandler2, fedStats.topPubInstances.map(x => ({ createDoughnut(pubDoughnutEl.value, externalTooltipHandler2, fedStats.topPubInstances.map(x => ({
name: x.host, name: x.host,
color: x.themeColor, color: x.themeColor,
value: x.followingCount, value: x.followingCount,

View File

@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { computed } from 'vue';
import { instanceName } from '@/config'; import { instanceName } from '@/config';
import { instance as Instance } from '@/instance'; import { instance as Instance } from '@/instance';
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy'; import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
@ -31,7 +31,7 @@ const instance = props.instance ?? {
themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content, themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content,
}; };
const faviconUrl = $computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(Instance.faviconUrl, 'preview') ?? '/favicon.ico'); const faviconUrl = computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(Instance.faviconUrl, 'preview') ?? '/favicon.ico');
const themeColor = instance.themeColor ?? '#777777'; const themeColor = instance.themeColor ?? '#777777';

View File

@ -67,7 +67,7 @@ import { i18n } from '@/i18n.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
const props = defineProps<{ const props = defineProps<{
invite: Misskey.entities.Invite; invite: Misskey.entities.InviteCode;
moderator?: boolean; moderator?: boolean;
}>(); }>();

View File

@ -28,6 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { ref , computed , watch} from 'vue'; import { ref , computed , watch} from 'vue';
import { shallowRef } from 'vue';
import MkModal from '@/components/MkModal.vue'; import MkModal from '@/components/MkModal.vue';
import { navbarItemDef } from '@/navbar.js'; import { navbarItemDef } from '@/navbar.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
@ -51,7 +52,7 @@ const preferedModalType = (deviceKind === 'desktop' && props.src != null) ? 'pop
deviceKind === 'smartphone' ? 'drawer' : deviceKind === 'smartphone' ? 'drawer' :
'dialog'; 'dialog';
const modal = $shallowRef<InstanceType<typeof MkModal>>(); const modal = shallowRef<InstanceType<typeof MkModal>>();
const menu = defaultStore.state.menu; const menu = defaultStore.state.menu;
@ -66,7 +67,7 @@ const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k =>
})); }));
function close() { function close() {
modal.close(); modal.value.close();
} }
</script> </script>
@ -118,6 +119,9 @@ function close() {
animation: AnimationLight var(--gamingspeed) cubic-bezier(0, 0.2, 0.90, 1) infinite !important; animation: AnimationLight var(--gamingspeed) cubic-bezier(0, 0.2, 0.90, 1) infinite !important;
color: white; color: white;
} }
padding: 10px;
box-sizing: border-box;
&:hover { &:hover {
color: var(--accent); color: var(--accent);
background: var(--accentedBg); background: var(--accentedBg);

View File

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<component <component
:is="self ? 'MkA' : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substring(local.length) : url" :rel="rel" :target="target" :is="self ? 'MkA' : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substring(local.length) : url" :rel="rel ?? 'nofollow noopener'" :target="target"
:title="url" :title="url"
> >
<slot></slot> <slot></slot>
@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent } from 'vue'; import { defineAsyncComponent, ref } from 'vue';
import { url as local } from '@/config.js'; import { url as local } from '@/config.js';
import { useTooltip } from '@/scripts/use-tooltip.js'; import { useTooltip } from '@/scripts/use-tooltip.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
@ -29,13 +29,13 @@ const self = props.url.startsWith(local);
const attr = self ? 'to' : 'href'; const attr = self ? 'to' : 'href';
const target = self ? null : '_blank'; const target = self ? null : '_blank';
const el = $ref(); const el = ref();
useTooltip($$(el), (showing) => { useTooltip(el, (showing) => {
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
showing, showing,
url: props.url, url: props.url,
source: el, source: el.value,
}, {}, 'closed'); }, {}, 'closed');
}); });
</script> </script>

View File

@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, shallowRef, watch } from 'vue'; import { onMounted, shallowRef, watch, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
@ -42,7 +42,7 @@ const props = withDefaults(defineProps<{
}); });
const audioEl = shallowRef<HTMLAudioElement>(); const audioEl = shallowRef<HTMLAudioElement>();
let hide = $ref(true); const hide = ref(true);
watch(audioEl, () => { watch(audioEl, () => {
if (audioEl.value) { if (audioEl.value) {

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