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:
commit
8c4a08c383
|
@ -106,12 +106,16 @@ redis:
|
|||
# ┌───────────────────────────┐
|
||||
#───┘ MeiliSearch configuration └─────────────────────────────
|
||||
|
||||
# You can set scope to local (default value) or global
|
||||
# (include notes from remote).
|
||||
|
||||
#meilisearch:
|
||||
# host: meilisearch
|
||||
# port: 7700
|
||||
# apiKey: ''
|
||||
# ssl: true
|
||||
# index: ''
|
||||
# scope: local
|
||||
|
||||
# ┌───────────────┐
|
||||
#───┘ ID generation └───────────────────────────────────────────
|
||||
|
@ -180,6 +184,9 @@ proxyRemoteFiles: true
|
|||
# Sign to ActivityPub GET request (default: 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: [
|
||||
# '127.0.0.1/32'
|
||||
#]
|
||||
|
|
|
@ -118,6 +118,9 @@ redis:
|
|||
# ┌───────────────────────────┐
|
||||
#───┘ MeiliSearch configuration └─────────────────────────────
|
||||
|
||||
# You can set scope to local (default value) or global
|
||||
# (include notes from remote).
|
||||
|
||||
#meilisearch:
|
||||
# host: localhost
|
||||
# port: 7700
|
||||
|
@ -210,6 +213,9 @@ proxyRemoteFiles: true
|
|||
# Sign to ActivityPub GET request (default: 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: [
|
||||
# '127.0.0.1/32'
|
||||
#]
|
||||
|
|
|
@ -1,21 +1,34 @@
|
|||
'packages/backend':
|
||||
- packages/backend/**/*
|
||||
- any:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['packages/backend/**/*']
|
||||
|
||||
'packages/backend:test':
|
||||
- packages/backend/test/**/*
|
||||
- any:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['packages/backend/test/**/*']
|
||||
|
||||
'packages/frontend':
|
||||
- packages/frontend/**/*
|
||||
- any:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['packages/frontend/**/*']
|
||||
|
||||
'packages/frontend:test':
|
||||
- cypress/**/*
|
||||
- any:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['cypress/**/*']
|
||||
|
||||
'packages/sw':
|
||||
- packages/sw/**/*
|
||||
- any:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['packages/sw/**/*']
|
||||
|
||||
'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-d/**/*
|
||||
- any:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['packages/misskey-js/test/**/*', 'packages/misskey-js/test-d/**/*']
|
||||
|
|
|
@ -11,6 +11,6 @@ jobs:
|
|||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v4
|
||||
- uses: actions/labeler@v5
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
|
19
CHANGELOG.md
19
CHANGELOG.md
|
@ -6,6 +6,7 @@
|
|||
|
||||
### Client
|
||||
- Fix: ページ一覧ページの表示がモバイル環境において崩れているのを修正
|
||||
- Fix: MFMでルビの中のテキストがnyaizeされない問題を修正
|
||||
|
||||
### Server
|
||||
-
|
||||
|
@ -22,6 +23,8 @@
|
|||
|
||||
### Client
|
||||
- Feat: 今日誕生日のフォロー中のユーザーを一覧表示できるウィジェットを追加
|
||||
- Feat: データセーバーでコードハイライトの読み込みを削減できるように
|
||||
- Enhance: 投稿フォームの絵文字ピッカーをリアクション時に使用するものと同じのを使用するように #12336
|
||||
- Enhance: 絵文字のオートコンプリート機能強化 #12364
|
||||
- Enhance: ユーザーのRawデータを表示するページが復活
|
||||
- Enhance: リアクション選択時に音を鳴らせるように
|
||||
|
@ -30,6 +33,11 @@
|
|||
- Enhance: Shareページで投稿を完了すると、親ウィンドウ(親フレーム)にpostMessageするように
|
||||
- Enhance: チャンネル、クリップ、ページ、Play、ギャラリーにURLのコピーボタンを設置 #11305
|
||||
- Enhance: ノートプレビューに「内容を隠す」が反映されるように
|
||||
- Enhance: データセーバーの適用範囲を個別で設定できるように
|
||||
- 従来のデータセーバーの設定はリセットされます
|
||||
- Enhance: タイムライン上のタブからリスト、アンテナ、チャンネルの管理ページにジャンプできるように
|
||||
- Feat: センシティブと判断されたウェブサイトのサムネイルをぼかすように
|
||||
- ウェブサイトをセンシティブと判断する仕組みが動いていないため、summalyProxyを使用しないと機能しません。
|
||||
- fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正
|
||||
- Fix: ウィジェットのジョブキューにて音声の発音方法変更に追従できていなかったのを修正 #12367
|
||||
- Enhance: 絵文字の詳細ページに記載される情報を追加
|
||||
|
@ -38,15 +46,25 @@
|
|||
- Fix: 一度に大量の通知が入った際に通知音が音割れする問題を修正
|
||||
- Fix: 共有機能をサポートしていないブラウザの場合は共有ボタンを非表示にする #11305
|
||||
- Fix: 通知のグルーピング設定を変更してもリロードされるまで表示が変わらない問題を修正 #12470
|
||||
- Fix: 長い名前のチャンネルにおける投稿フォームの表示が崩れる問題を修正
|
||||
- Fix: セキュリティ向上のためAiScriptの`Mk:apiExternal`を無効化
|
||||
|
||||
### Server
|
||||
- Enhance: MFM `$[ruby ]` が他ソフトウェアと連合されるように
|
||||
- Enhance: Meilisearchを有効にした検索で、ユーザーのミュートやブロックを考慮するように
|
||||
- Fix: 時間経過により無効化されたアンテナを再有効化したとき、サーバ再起動までその状況が反映されないのを修正 #12303
|
||||
- Fix: ロールタイムラインが保存されない問題を修正
|
||||
- Fix: api.jsonの生成ロジックを改善 #12402
|
||||
- Fix: 招待コードが使い回せる問題を修正
|
||||
- Fix: 特定の条件下でチャンネルやユーザーのノート一覧に最新のノートが表示されなくなる問題を修正
|
||||
- Fix: 何もノートしていないユーザーのフィードにアクセスするとエラーになる問題を修正
|
||||
- Fix: リストタイムラインにてミュートが機能しないケースがある問題と、チャンネル投稿がストリーミングで流れてきてしまう問題を修正 #10443
|
||||
- Fix: 「みつける」のなかにミュートしたユーザが現れてしまう問題を修正 #12383
|
||||
- Fix: Social/Local/Home Timelineにてインスタンスミュートが効かない問題
|
||||
- Fix: ユーザのノート一覧にてインスタンスミュートが効かない問題
|
||||
- Fix: チャンネルのノート一覧にてインスタンスミュートが効かない問題
|
||||
- Fix: 「みつける」が年越し時に壊れる問題を修正
|
||||
- Fix: アカウントをブロックした際に、自身のユーザーのページでノートが相手に表示される問題を修正
|
||||
|
||||
## 2023.11.1
|
||||
|
||||
|
@ -176,6 +194,7 @@
|
|||
### Client
|
||||
- Enhance: TLの返信表示オプションを記憶するように
|
||||
- Enhance: 投稿されてから時間が経過しているノートであることを視覚的に分かりやすく
|
||||
- Feat: 絵文字ピッカーのカテゴリに「/」を入れることでフォルダ分け表示できるように
|
||||
|
||||
### Server
|
||||
- Enhance: タイムライン取得時のパフォーマンスを向上
|
||||
|
|
|
@ -117,6 +117,10 @@ command.
|
|||
- 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.
|
||||
- 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
|
||||
Instead of running `pnpm` locally, you can use Dev Container to set up your development environment.
|
||||
|
|
|
@ -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
|
||||
|
|
@ -333,6 +333,7 @@ export interface Locale {
|
|||
"renameFolder": string;
|
||||
"deleteFolder": string;
|
||||
"Folder": string;
|
||||
"folder": string;
|
||||
"addFile": string;
|
||||
"emptyDrive": string;
|
||||
"emptyFolder": string;
|
||||
|
@ -459,7 +460,6 @@ export interface Locale {
|
|||
"notFound": string;
|
||||
"notFoundDescription": string;
|
||||
"uploadFolder": string;
|
||||
"cacheClear": string;
|
||||
"markAsReadAllNotifications": string;
|
||||
"markAsReadAllUnreadNotes": string;
|
||||
"markAsReadAllTalkMessages": string;
|
||||
|
@ -1210,6 +1210,8 @@ export interface Locale {
|
|||
"signupPendingError": string;
|
||||
"cwNotationRequired": string;
|
||||
"doReaction": string;
|
||||
"code": string;
|
||||
"reloadRequiredToApplySettings": string;
|
||||
"_announcement": {
|
||||
"forExistingUsers": 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": {
|
||||
"list": string;
|
||||
"postDate": string;
|
||||
|
|
|
@ -330,6 +330,7 @@ createFolder: "フォルダーを作成"
|
|||
renameFolder: "フォルダー名を変更"
|
||||
deleteFolder: "フォルダーを削除"
|
||||
Folder: "フォルダー"
|
||||
folder: "フォルダー"
|
||||
addFile: "ファイルを追加"
|
||||
emptyDrive: "ドライブは空です"
|
||||
emptyFolder: "フォルダーは空です"
|
||||
|
@ -456,7 +457,6 @@ share: "共有"
|
|||
notFound: "見つかりません"
|
||||
notFoundDescription: "指定されたURLに該当するページはありませんでした。"
|
||||
uploadFolder: "既定アップロード先"
|
||||
cacheClear: "キャッシュを削除"
|
||||
markAsReadAllNotifications: "すべての通知を既読にする"
|
||||
markAsReadAllUnreadNotes: "すべての投稿を既読にする"
|
||||
markAsReadAllTalkMessages: "すべてのチャットを既読にする"
|
||||
|
@ -1207,6 +1207,8 @@ useGroupedNotifications: "通知をグルーピングして表示する"
|
|||
signupPendingError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。"
|
||||
cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。"
|
||||
doReaction: "リアクションする"
|
||||
code: "コード"
|
||||
reloadRequiredToApplySettings: "設定の反映にはリロードが必要です。"
|
||||
|
||||
_announcement:
|
||||
forExistingUsers: "既存ユーザーのみ"
|
||||
|
@ -2458,3 +2460,17 @@ _schedulePost:
|
|||
willBePostedAtX: "{date}に投稿予約しました。"
|
||||
deleteAreYouSure: "予約投稿を削除しますか?"
|
||||
deleteAndEditConfirm: "予約投稿を削除して編集しますか?"
|
||||
|
||||
_dataSaver:
|
||||
_media:
|
||||
title: "メディアの読み込み"
|
||||
description: "画像・動画が自動で読み込まれるのを防止します。隠れている画像・動画はタップすると読み込まれます。"
|
||||
_avatar:
|
||||
title: "アイコン画像"
|
||||
description: "アイコン画像のアニメーションが停止します。アニメーション画像は通常の画像よりファイルサイズが大きいことがあるので、データ通信量をさらに削減できます。"
|
||||
_urlPreview:
|
||||
title: "URLプレビューのサムネイル"
|
||||
description: "URLプレビューのサムネイル画像が読み込まれなくなります。"
|
||||
_code:
|
||||
title: "コードハイライト"
|
||||
description: "MFMなどでコードハイライト記法が使われている場合、タップするまで読み込まれなくなります。コードハイライトではハイライトする言語ごとにその定義ファイルを読み込む必要がありますが、それらが自動で読み込まれなくなるため、通信量の削減が見込めます。"
|
||||
|
|
20
package.json
20
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "misskey",
|
||||
"version": "2023.12.0-beta.1-PrisMisskey.2",
|
||||
"version": "2023.12.0-beta.3",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -18,6 +18,7 @@
|
|||
"build-assets": "node ./scripts/build-assets.mjs",
|
||||
"build": "pnpm build-pre && pnpm -r build && pnpm build-assets",
|
||||
"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:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js",
|
||||
"init": "pnpm migrate",
|
||||
|
@ -26,7 +27,7 @@
|
|||
"check:connect": "cd packages/backend && pnpm check:connect",
|
||||
"migrateandstart": "pnpm migrate && pnpm start",
|
||||
"watch": "pnpm dev",
|
||||
"dev": "node ./scripts/dev.mjs",
|
||||
"dev": "pnpm -r dev",
|
||||
"lint": "pnpm -r lint",
|
||||
"cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts",
|
||||
"cy:run": "pnpm cypress run",
|
||||
|
@ -47,17 +48,18 @@
|
|||
"execa": "8.0.1",
|
||||
"cssnano": "6.0.1",
|
||||
"js-yaml": "4.1.0",
|
||||
"postcss": "8.4.31",
|
||||
"postcss": "8.4.32",
|
||||
"terser": "5.24.0",
|
||||
"typescript": "5.3.2"
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "6.12.0",
|
||||
"@typescript-eslint/parser": "6.12.0",
|
||||
"@typescript-eslint/eslint-plugin": "6.13.2",
|
||||
"@typescript-eslint/parser": "6.13.2",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "13.6.0",
|
||||
"eslint": "8.54.0",
|
||||
"start-server-and-test": "2.0.3"
|
||||
"cypress": "13.6.1",
|
||||
"eslint": "8.55.0",
|
||||
"start-server-and-test": "2.0.3",
|
||||
"ncp": "2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tensorflow/tfjs-core": "4.4.0"
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
"watch:swc": "swc src -d built -D -w",
|
||||
"build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
|
||||
"watch": "node watch.mjs",
|
||||
"dev": "node ./built/boot/entry.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"eslint": "eslint --quiet \"src/**/*.ts\"",
|
||||
"lint": "pnpm typecheck && pnpm eslint",
|
||||
|
@ -60,13 +61,13 @@
|
|||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.412.0",
|
||||
"@aws-sdk/lib-storage": "3.412.0",
|
||||
"@bull-board/api": "5.9.2",
|
||||
"@bull-board/fastify": "5.9.2",
|
||||
"@bull-board/ui": "5.9.2",
|
||||
"@bull-board/api": "5.10.2",
|
||||
"@bull-board/fastify": "5.10.2",
|
||||
"@bull-board/ui": "5.10.2",
|
||||
"@discordapp/twemoji": "14.1.2",
|
||||
"@fastify/accepts": "4.2.0",
|
||||
"@fastify/accepts": "4.3.0",
|
||||
"@fastify/cookie": "9.2.0",
|
||||
"@fastify/cors": "8.4.1",
|
||||
"@fastify/cors": "8.4.2",
|
||||
"@fastify/express": "2.3.0",
|
||||
"@fastify/http-proxy": "9.3.0",
|
||||
"@fastify/multipart": "8.0.0",
|
||||
|
@ -80,7 +81,7 @@
|
|||
"@sinonjs/fake-timers": "11.2.2",
|
||||
"@smithy/node-http-handler": "2.1.10",
|
||||
"@swc/cli": "0.1.63",
|
||||
"@swc/core": "1.3.99",
|
||||
"@swc/core": "1.3.100",
|
||||
"accepts": "1.3.8",
|
||||
"ajv": "8.12.0",
|
||||
"archiver": "6.0.1",
|
||||
|
@ -88,7 +89,7 @@
|
|||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.5",
|
||||
"body-parser": "1.20.2",
|
||||
"bullmq": "4.14.2",
|
||||
"bullmq": "4.15.2",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"cbor": "9.0.1",
|
||||
"chalk": "5.3.0",
|
||||
|
@ -114,9 +115,9 @@
|
|||
"ipaddr.js": "2.1.0",
|
||||
"is-svg": "5.0.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"jsdom": "23.0.0",
|
||||
"jsdom": "23.0.1",
|
||||
"json5": "2.2.3",
|
||||
"jsonld": "8.3.1",
|
||||
"jsonld": "8.3.2",
|
||||
"jsrsasign": "10.9.0",
|
||||
"meilisearch": "0.36.0",
|
||||
"mfm-js": "0.23.3",
|
||||
|
@ -124,7 +125,7 @@
|
|||
"mime-types": "2.1.35",
|
||||
"misskey-js": "workspace:*",
|
||||
"ms": "3.0.0-canary.1",
|
||||
"nanoid": "5.0.3",
|
||||
"nanoid": "5.0.4",
|
||||
"nested-property": "4.0.0",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "6.9.7",
|
||||
|
@ -133,7 +134,7 @@
|
|||
"oauth2orize": "1.12.0",
|
||||
"oauth2orize-pkce": "0.1.2",
|
||||
"os-utils": "0.0.14",
|
||||
"otpauth": "9.2.0",
|
||||
"otpauth": "9.2.1",
|
||||
"parse5": "7.1.2",
|
||||
"pg": "8.11.3",
|
||||
"pkce-challenge": "4.0.1",
|
||||
|
@ -147,7 +148,7 @@
|
|||
"ratelimiter": "3.4.1",
|
||||
"re2": "1.20.9",
|
||||
"redis-lock": "0.1.4",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"reflect-metadata": "0.1.14",
|
||||
"rename": "1.0.4",
|
||||
"rss-parser": "3.13.0",
|
||||
"rxjs": "7.8.1",
|
||||
|
@ -159,14 +160,14 @@
|
|||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"summaly": "github:misskey-dev/summaly",
|
||||
"systeminformation": "5.21.18",
|
||||
"systeminformation": "5.21.20",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.1",
|
||||
"tsc-alias": "1.8.8",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"twemoji-parser": "14.0.0",
|
||||
"typeorm": "0.3.17",
|
||||
"typescript": "5.3.2",
|
||||
"typescript": "5.3.3",
|
||||
"ulid": "2.3.0",
|
||||
"vary": "1.1.2",
|
||||
"web-push": "3.6.6",
|
||||
|
@ -186,14 +187,14 @@
|
|||
"@types/content-disposition": "0.5.8",
|
||||
"@types/fluent-ffmpeg": "2.1.24",
|
||||
"@types/http-link-header": "1.0.5",
|
||||
"@types/jest": "29.5.10",
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsdom": "21.1.6",
|
||||
"@types/jsonld": "1.5.13",
|
||||
"@types/jsrsasign": "10.5.12",
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@types/ms": "0.7.34",
|
||||
"@types/node": "20.10.0",
|
||||
"@types/node": "20.10.4",
|
||||
"@types/node-fetch": "3.0.3",
|
||||
"@types/nodemailer": "6.4.14",
|
||||
"@types/oauth": "0.9.4",
|
||||
|
@ -216,11 +217,11 @@
|
|||
"@types/vary": "1.1.3",
|
||||
"@types/web-push": "3.6.3",
|
||||
"@types/ws": "8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "6.12.0",
|
||||
"@typescript-eslint/parser": "6.12.0",
|
||||
"@typescript-eslint/eslint-plugin": "6.13.2",
|
||||
"@typescript-eslint/parser": "6.13.2",
|
||||
"aws-sdk-client-mock": "3.0.0",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.54.0",
|
||||
"eslint": "8.55.0",
|
||||
"eslint-plugin-import": "2.29.0",
|
||||
"execa": "8.0.1",
|
||||
"jest": "29.7.0",
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { AccountMoveService } from './AccountMoveService.js';
|
||||
import { AccountUpdateService } from './AccountUpdateService.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 $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService };
|
||||
const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService };
|
||||
const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService };
|
||||
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
|
||||
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
|
||||
|
||||
|
@ -331,6 +333,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
ClipService,
|
||||
FeaturedService,
|
||||
FanoutTimelineService,
|
||||
FanoutTimelineEndpointService,
|
||||
ChannelFollowingService,
|
||||
RegistryApiService,
|
||||
ChartLoggerService,
|
||||
|
@ -460,6 +463,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$ClipService,
|
||||
$FeaturedService,
|
||||
$FanoutTimelineService,
|
||||
$FanoutTimelineEndpointService,
|
||||
$ChannelFollowingService,
|
||||
$RegistryApiService,
|
||||
$ChartLoggerService,
|
||||
|
@ -590,6 +594,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
ClipService,
|
||||
FeaturedService,
|
||||
FanoutTimelineService,
|
||||
FanoutTimelineEndpointService,
|
||||
ChannelFollowingService,
|
||||
RegistryApiService,
|
||||
FederationChart,
|
||||
|
@ -718,6 +723,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$ClipService,
|
||||
$FeaturedService,
|
||||
$FanoutTimelineService,
|
||||
$FanoutTimelineEndpointService,
|
||||
$ChannelFollowingService,
|
||||
$RegistryApiService,
|
||||
$FederationChart,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -9,6 +9,35 @@ import { DI } from '@/di-symbols.js';
|
|||
import { bindThis } from '@/decorators.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()
|
||||
export class FanoutTimelineService {
|
||||
constructor(
|
||||
|
@ -20,7 +49,7 @@ export class FanoutTimelineService {
|
|||
}
|
||||
|
||||
@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より新しい場合のみ追加する
|
||||
if (this.idService.parse(id).date.getTime() > Date.now() - 1000 * 60 * 3) {
|
||||
|
@ -41,7 +70,7 @@ export class FanoutTimelineService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public get(name: string, untilId?: string | null, sinceId?: string | null) {
|
||||
public get(name: FanoutTimelineName, untilId?: string | null, sinceId?: string | null) {
|
||||
if (untilId && sinceId) {
|
||||
return this.redisForTimelines.lrange('list:' + name, 0, -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
|
||||
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();
|
||||
for (const n of name) {
|
||||
pipeline.lrange('list:' + n, 0, -1);
|
||||
|
@ -79,7 +108,7 @@ export class FanoutTimelineService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public purge(name: string) {
|
||||
public purge(name: FanoutTimelineName) {
|
||||
return this.redisForTimelines.del('list:' + name);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 HASHTAG_RANKING_WINDOW = 1000 * 60 * 60; // 1時間ごと
|
||||
|
||||
const featuredEpoc = new Date('2023-01-01T00:00:00Z').getTime();
|
||||
|
||||
@Injectable()
|
||||
export class FeaturedService {
|
||||
constructor(
|
||||
|
@ -24,7 +26,7 @@ export class FeaturedService {
|
|||
|
||||
@bindThis
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
@ -7,11 +7,11 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import { ulid } from 'ulid';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { genAid, parseAid } from '@/misc/id/aid.js';
|
||||
import { genAidx, parseAidx } from '@/misc/id/aidx.js';
|
||||
import { genMeid, parseMeid } from '@/misc/id/meid.js';
|
||||
import { genMeidg, parseMeidg } from '@/misc/id/meidg.js';
|
||||
import { genObjectId, parseObjectId } from '@/misc/id/object-id.js';
|
||||
import { genAid, isSafeAidT, parseAid } from '@/misc/id/aid.js';
|
||||
import { genAidx, isSafeAidxT, parseAidx } from '@/misc/id/aidx.js';
|
||||
import { genMeid, isSafeMeidT, parseMeid } from '@/misc/id/meid.js';
|
||||
import { genMeidg, isSafeMeidgT, parseMeidg } from '@/misc/id/meidg.js';
|
||||
import { genObjectId, isSafeObjectIdT, parseObjectId } from '@/misc/id/object-id.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { parseUlid } from '@/misc/id/ulid.js';
|
||||
|
||||
|
@ -26,6 +26,19 @@ export class IdService {
|
|||
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を生成します(省略時は現在日時)
|
||||
* @param time 日時
|
||||
|
|
|
@ -54,6 +54,7 @@ import { FeaturedService } from '@/core/FeaturedService.js';
|
|||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { isReply } from '@/misc/is-reply.js';
|
||||
|
||||
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;
|
||||
|
||||
// 「自分自身への返信 or そのフォロワーへの返信」のどちらでもない場合
|
||||
if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === following.followerId)) {
|
||||
if (isReply(note, following.followerId)) {
|
||||
if (!following.withReplies) continue;
|
||||
}
|
||||
|
||||
|
@ -877,7 +878,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
) continue;
|
||||
|
||||
// 「自分自身への返信 or そのリストの作成者への返信」のどちらでもない場合
|
||||
if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === userListMembership.userListUserId)) {
|
||||
if (isReply(note, userListMembership.userListUserId)) {
|
||||
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);
|
||||
|
||||
if (note.visibility === 'public' && note.userHost == null) {
|
||||
this.fanoutTimelineService.push('localTimelineWithReplies', note.id, 300, r);
|
||||
if (note.replyUserHost == null) {
|
||||
this.fanoutTimelineService.push(`localTimelineWithReplyTo:${note.replyUserId}`, note.id, 300 / 10, r);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
|
||||
|
|
|
@ -77,7 +77,7 @@ export class NotePiningService {
|
|||
} as MiUserNotePining);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
@ -105,7 +105,7 @@ export class NotePiningService {
|
|||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,8 @@ import { MiNote } from '@/models/Note.js';
|
|||
import { MiUser } from '@/models/_.js';
|
||||
import type { NotesRepository } from '@/models/_.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 { IdService } from '@/core/IdService.js';
|
||||
import type { Index, MeiliSearch } from 'meilisearch';
|
||||
|
@ -74,6 +76,7 @@ export class SearchService {
|
|||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
private cacheService: CacheService,
|
||||
private queryService: QueryService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
|
@ -187,8 +190,19 @@ export class SearchService {
|
|||
limit: pagination.limit,
|
||||
});
|
||||
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)),
|
||||
})).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);
|
||||
} else {
|
||||
|
|
|
@ -304,8 +304,6 @@ export class UserFollowingService implements OnModuleInit {
|
|||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.fanoutTimelineService.purge(`homeTimeline:${follower.id}`);
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
|
|
|
@ -306,9 +306,15 @@ export class ApInboxService {
|
|||
this.logger.info(`Creating the (Re)Note: ${uri}`);
|
||||
|
||||
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, {
|
||||
createdAt: activity.published ? new Date(activity.published) : null,
|
||||
createdAt,
|
||||
renote,
|
||||
visibility: activityAudience.visibility,
|
||||
visibleUsers: activityAudience.visibleUsers,
|
||||
|
|
|
@ -92,6 +92,10 @@ export class ApNoteService {
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
|
@ -34,3 +34,7 @@ export function parseAid(id: string): { date: Date; } {
|
|||
const time = parseInt(id.slice(0, 8), 36) + TIME2000;
|
||||
return { date: new Date(time) };
|
||||
}
|
||||
|
||||
export function isSafeAidT(t: number): boolean {
|
||||
return t > TIME2000;
|
||||
}
|
||||
|
|
|
@ -41,3 +41,7 @@ export function parseAidx(id: string): { date: Date; } {
|
|||
const time = parseInt(id.slice(0, TIME_LENGTH), 36) + TIME2000;
|
||||
return { date: new Date(time) };
|
||||
}
|
||||
|
||||
export function isSafeAidxT(t: number): boolean {
|
||||
return t > TIME2000;
|
||||
}
|
||||
|
|
|
@ -38,3 +38,7 @@ export function parseMeid(id: string): { date: Date; } {
|
|||
date: new Date(parseInt(id.slice(0, 12), 16) - 0x800000000000),
|
||||
};
|
||||
}
|
||||
|
||||
export function isSafeMeidT(t: number): boolean {
|
||||
return t > 0;
|
||||
}
|
||||
|
|
|
@ -38,3 +38,7 @@ export function parseMeidg(id: string): { date: Date; } {
|
|||
date: new Date(parseInt(id.slice(1, 12), 16)),
|
||||
};
|
||||
}
|
||||
|
||||
export function isSafeMeidgT(t: number): boolean {
|
||||
return t > 0;
|
||||
}
|
||||
|
|
|
@ -38,3 +38,7 @@ export function parseObjectId(id: string): { date: Date; } {
|
|||
date: new Date(parseInt(id.slice(0, 8), 16) * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
export function isSafeObjectIdT(t: number): boolean {
|
||||
return t > 0;
|
||||
}
|
||||
|
|
|
@ -3,12 +3,13 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import type { Packed } from './json-schema.js';
|
||||
|
||||
export function isInstanceMuted(note: Packed<'Note'>, mutedInstances: Set<string>): boolean {
|
||||
if (mutedInstances.has(note.user.host ?? '')) return true;
|
||||
if (mutedInstances.has(note.reply?.user.host ?? '')) return true;
|
||||
if (mutedInstances.has(note.renote?.user.host ?? '')) return true;
|
||||
export function isInstanceMuted(note: Packed<'Note'> | MiNote, mutedInstances: Set<string>): boolean {
|
||||
if (mutedInstances.has(note.user?.host ?? '')) return true;
|
||||
if (mutedInstances.has(note.reply?.user?.host ?? '')) return true;
|
||||
if (mutedInstances.has(note.renote?.user?.host ?? '')) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -36,6 +36,8 @@ import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js';
|
|||
import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js';
|
||||
import { packedFlashSchema } from '@/models/json-schema/flash.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 = {
|
||||
UserLite: packedUserLiteSchema,
|
||||
|
@ -71,6 +73,9 @@ export const refs = {
|
|||
EmojiSimple: packedEmojiSimpleSchema,
|
||||
EmojiDetailed: packedEmojiDetailedSchema,
|
||||
Flash: packedFlashSchema,
|
||||
Signin: packedSigninSchema,
|
||||
RoleLite: packedRoleLiteSchema,
|
||||
Role: packedRoleSchema,
|
||||
};
|
||||
|
||||
export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -3,6 +3,18 @@
|
|||
* 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 = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
@ -326,41 +338,7 @@ export const packedUserDetailedNotMeOnlySchema = {
|
|||
items: {
|
||||
type: 'object',
|
||||
nullable: false, optional: false,
|
||||
properties: {
|
||||
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,
|
||||
},
|
||||
},
|
||||
ref: 'RoleLite',
|
||||
},
|
||||
},
|
||||
memo: {
|
||||
|
@ -407,6 +385,7 @@ export const packedUserDetailedNotMeOnlySchema = {
|
|||
notify: {
|
||||
type: 'string',
|
||||
nullable: false, optional: true,
|
||||
enum: ['normal', 'none'],
|
||||
},
|
||||
withReplies: {
|
||||
type: 'boolean',
|
||||
|
@ -562,6 +541,19 @@ export const packedMeDetailedOnlySchema = {
|
|||
notificationRecieveConfig: {
|
||||
type: 'object',
|
||||
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: {
|
||||
type: 'array',
|
||||
|
@ -706,6 +698,23 @@ export const packedMeDetailedOnlySchema = {
|
|||
items: {
|
||||
type: 'object',
|
||||
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
|
||||
|
|
|
@ -370,8 +370,9 @@ export class ActivityPubServerService {
|
|||
order: { id: 'DESC' },
|
||||
});
|
||||
|
||||
const pinnedNotes = await Promise.all(pinings.map(pining =>
|
||||
this.notesRepository.findOneByOrFail({ id: pining.noteId })));
|
||||
const pinnedNotes = (await Promise.all(pinings.map(pining =>
|
||||
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)));
|
||||
|
||||
|
|
|
@ -96,6 +96,11 @@ export class NodeinfoServerService {
|
|||
metadata: {
|
||||
nodeName: meta.name,
|
||||
nodeDescription: meta.description,
|
||||
nodeAdmins: [{
|
||||
name: meta.maintainerName,
|
||||
email: meta.maintainerEmail,
|
||||
}],
|
||||
// deprecated
|
||||
maintainer: {
|
||||
name: meta.maintainerName,
|
||||
email: meta.maintainerEmail,
|
||||
|
|
|
@ -119,8 +119,8 @@ export class ServerService implements OnApplicationShutdown {
|
|||
return;
|
||||
}
|
||||
|
||||
const name = path.split('@')[0].replace('.webp', '');
|
||||
const host = path.split('@')[1]?.replace('.webp', '');
|
||||
const name = path.split('@')[0].replace(/\.webp$/i, '');
|
||||
const host = path.split('@')[1]?.replace(/\.webp$/i, '');
|
||||
|
||||
const emoji = await this.emojisRepository.findOneBy({
|
||||
// `@.` is the spec of ReactionService.decodeReaction
|
||||
|
|
|
@ -6,14 +6,12 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { EmojisRepository } from '@/models/_.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import { DI } from '@/di-symbols.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 { ApiError } from '../../../error.js';
|
||||
import {IsNull} from "typeorm";
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
@ -27,11 +25,11 @@ export const meta = {
|
|||
code: 'NO_SUCH_EMOJI',
|
||||
id: 'e2785b66-dca3-4087-9cac-b93c541cc425',
|
||||
},
|
||||
duplicationEmojiAdd: {
|
||||
message: 'This emoji is already added.',
|
||||
code: 'DUPLICATION_EMOJI_ADD',
|
||||
id: 'mattyaski_emoji_duplication_error',
|
||||
}
|
||||
duplicateName: {
|
||||
message: 'Duplicate name.',
|
||||
code: 'DUPLICATE_NAME',
|
||||
id: 'f7a3462c-4e6e-4069-8421-b9bd4f4c3975',
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
|
@ -62,63 +60,43 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
constructor(
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
|
||||
private emojiEntityService: EmojiEntityService,
|
||||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private customEmojiService: CustomEmojiService,
|
||||
private driveService: DriveService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const emoji = await this.emojisRepository.findOneBy({ id: ps.emojiId });
|
||||
|
||||
if (emoji == null) {
|
||||
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;
|
||||
|
||||
try {
|
||||
// Create file
|
||||
driveFile = await this.driveService.uploadFromUrl({ url: emoji.originalUrl, user: null, force: true });
|
||||
} catch (e) {
|
||||
// TODO: need to return Drive Error
|
||||
throw new ApiError();
|
||||
}
|
||||
|
||||
const copied = await this.emojisRepository.insert({
|
||||
id: this.idService.gen(),
|
||||
updatedAt: new Date(),
|
||||
// Duplication Check
|
||||
const isDuplicate = await this.customEmojiService.checkDuplicate(emoji.name);
|
||||
if (isDuplicate) throw new ApiError(meta.errors.duplicateName);
|
||||
|
||||
const addedEmoji = await this.customEmojiService.add({
|
||||
driveFile,
|
||||
name: emoji.name,
|
||||
category: emoji.category,
|
||||
aliases: emoji.aliases,
|
||||
host: null,
|
||||
aliases: [],
|
||||
originalUrl: driveFile.url,
|
||||
publicUrl: driveFile.webpublicUrl ?? driveFile.url,
|
||||
type: driveFile.webpublicType ?? driveFile.type,
|
||||
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', {
|
||||
emoji: await this.emojiEntityService.packDetailed(copied.id),
|
||||
});
|
||||
|
||||
return {
|
||||
id: copied.id,
|
||||
};
|
||||
return this.emojiEntityService.packDetailed(addedEmoji);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -327,6 +327,82 @@ export const meta = {
|
|||
type: 'number',
|
||||
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: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
|
|
|
@ -13,6 +13,12 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
requireAdmin: true,
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'Role',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
@ -14,6 +14,16 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'Role',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
@ -23,6 +23,12 @@ export const meta = {
|
|||
id: '07dc7d34-c0d8-49b7-96c6-db3ce64ee0b3',
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'Role',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
@ -4,18 +4,17 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
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 { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { DI } from '@/di-symbols.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 { MetaService } from '@/core/MetaService.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -51,6 +50,7 @@ export const paramDef = {
|
|||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
|
||||
},
|
||||
required: ['channelId'],
|
||||
} as const;
|
||||
|
@ -58,9 +58,6 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.redisForTimelines)
|
||||
private redisForTimelines: Redis.Redis,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
|
@ -70,7 +67,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private idService: IdService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private cacheService: CacheService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private metaService: MetaService,
|
||||
|
@ -78,7 +75,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
super(meta, paramDef, async (ps, me) => {
|
||||
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 isRangeSpecified = untilId != null && sinceId != null;
|
||||
|
||||
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 (serverSettings.enableFanoutTimeline && (isRangeSpecified || sinceId == null)) {
|
||||
const [
|
||||
userIdsWhoMeMuting,
|
||||
] = me ? await Promise.all([
|
||||
this.cacheService.userMutingsCache.fetch(me.id),
|
||||
]) : [new Set<string>()];
|
||||
if (!serverSettings.enableFanoutTimeline) {
|
||||
return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id }, me), me);
|
||||
}
|
||||
|
||||
let noteIds = await this.fanoutTimelineService.get(`channelTimeline:${channel.id}`, untilId, sinceId);
|
||||
noteIds = noteIds.slice(0, ps.limit);
|
||||
|
||||
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');
|
||||
|
||||
let timeline = await query.getMany();
|
||||
|
||||
timeline = timeline.filter(note => {
|
||||
return !isUserRelated(note, userIdsWhoMeMuting);
|
||||
return await this.fanoutTimelineEndpointService.timeline({
|
||||
untilId,
|
||||
sinceId,
|
||||
limit: ps.limit,
|
||||
allowPartial: ps.allowPartial,
|
||||
me,
|
||||
useDbFallback: true,
|
||||
redisTimelines: [`channelTimeline:${channel.id}`],
|
||||
excludePureRenotes: false,
|
||||
dbFallback: async (untilId, sinceId, limit) => {
|
||||
return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// 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
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('note.channelId = :channelId', { channelId: channel.id })
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.andWhere('note.channelId = :channelId', { channelId: ps.channelId })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
|
@ -144,10 +130,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
//#endregion
|
||||
|
||||
const timeline = await query.limit(ps.limit).getMany();
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
//#endregion
|
||||
});
|
||||
return await query.limit(ps.limit).getMany();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,13 +36,32 @@ export const paramDef = {
|
|||
blocked: { type: 'boolean', nullable: true },
|
||||
notResponding: { type: 'boolean', nullable: true },
|
||||
suspended: { type: 'boolean', nullable: true },
|
||||
silenced: { type: "boolean", nullable: true },
|
||||
silenced: { type: 'boolean', nullable: true },
|
||||
federating: { type: 'boolean', nullable: true },
|
||||
subscribing: { type: 'boolean', nullable: true },
|
||||
publishing: { type: 'boolean', nullable: true },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
|
||||
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: [],
|
||||
} 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);
|
||||
|
||||
if (ps.silenced) {
|
||||
if (meta.silencedHosts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
query.andWhere("instance.host IN (:...silences)", {
|
||||
query.andWhere('instance.host IN (:...silences)', {
|
||||
silences: meta.silencedHosts,
|
||||
});
|
||||
} else if (meta.silencedHosts.length > 0) {
|
||||
query.andWhere("instance.host NOT IN (:...silences)", {
|
||||
query.andWhere('instance.host NOT IN (:...silences)', {
|
||||
silences: meta.silencedHosts,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -12,8 +12,17 @@ import { DI } from '@/di-symbols.js';
|
|||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'Signin',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -9,9 +9,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
|
|||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
import {isUserRelated} from "@/misc/is-user-related.js";
|
||||
import {CacheService} from "@/core/CacheService.js";
|
||||
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
@ -67,11 +66,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
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);
|
||||
if (ps.untilId) {
|
||||
|
@ -83,6 +77,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
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')
|
||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
|
@ -92,13 +94,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
let notes = await query.getMany();
|
||||
notes.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
notes = notes.filter(note => {
|
||||
return !isUserRelated(note, userIdsWhoMeMuting);
|
||||
const notes = (await query.getMany()).filter(note => {
|
||||
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
|
||||
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
notes.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
// TODO: ミュート等考慮
|
||||
|
||||
|
||||
return await this.noteEntityService.packMany(notes, me);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -5,20 +5,20 @@
|
|||
|
||||
import { Brackets } from 'typeorm';
|
||||
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 ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.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 { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -42,6 +42,12 @@ export const meta = {
|
|||
code: 'STL_DISABLED',
|
||||
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;
|
||||
|
||||
|
@ -53,6 +59,7 @@ export const paramDef = {
|
|||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
|
||||
includeMyRenotes: { type: 'boolean', default: true },
|
||||
includeRenotedMyNotes: { 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 idService: IdService,
|
||||
private cacheService: CacheService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private queryService: QueryService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private metaService: MetaService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
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);
|
||||
}
|
||||
|
||||
if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles);
|
||||
|
||||
const serverSettings = await this.metaService.fetch();
|
||||
|
||||
if (!serverSettings.enableFanoutTimeline) {
|
||||
return await this.getFromDb({
|
||||
const timeline = await this.getFromDb({
|
||||
untilId,
|
||||
sinceId,
|
||||
limit: ps.limit,
|
||||
|
@ -104,103 +113,61 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
withFiles: ps.withFiles,
|
||||
withReplies: ps.withReplies,
|
||||
}, 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(() => {
|
||||
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 {
|
||||
if (serverSettings.enableFanoutTimelineDbFallback) { // fallback to db
|
||||
return await this.getFromDb({
|
||||
timelineConfig = [
|
||||
`homeTimeline:${me.id}`,
|
||||
'localTimeline',
|
||||
];
|
||||
}
|
||||
|
||||
const redisTimeline = await this.fanoutTimelineEndpointService.timeline({
|
||||
untilId,
|
||||
sinceId,
|
||||
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,
|
||||
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
||||
includeLocalRenotes: ps.includeLocalRenotes,
|
||||
withFiles: ps.withFiles,
|
||||
withReplies: ps.withReplies,
|
||||
}, me);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}, me),
|
||||
});
|
||||
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
});
|
||||
|
||||
return redisTimeline;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -301,12 +268,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
//#endregion
|
||||
|
||||
const timeline = await query.limit(ps.limit).getMany();
|
||||
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
});
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
return await query.limit(ps.limit).getMany();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { Brackets } from 'typeorm';
|
||||
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 { NoteEntityService } from '@/core/entities/NoteEntityService.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 { IdService } from '@/core/IdService.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 { MetaService } from '@/core/MetaService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -39,6 +38,12 @@ export const meta = {
|
|||
code: 'LTL_DISABLED',
|
||||
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;
|
||||
|
||||
|
@ -48,10 +53,10 @@ export const paramDef = {
|
|||
withFiles: { type: 'boolean', default: false },
|
||||
withRenotes: { type: 'boolean', default: true },
|
||||
withReplies: { type: 'boolean', default: false },
|
||||
excludeNsfw: { type: 'boolean', default: false },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
sinceId: { 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' },
|
||||
untilDate: { type: 'integer' },
|
||||
},
|
||||
|
@ -69,7 +74,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private activeUsersChart: ActiveUsersChart,
|
||||
private idService: IdService,
|
||||
private cacheService: CacheService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private queryService: QueryService,
|
||||
private metaService: MetaService,
|
||||
) {
|
||||
|
@ -82,99 +87,59 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.ltlDisabled);
|
||||
}
|
||||
|
||||
if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles);
|
||||
|
||||
const serverSettings = await this.metaService.fetch();
|
||||
|
||||
if (!serverSettings.enableFanoutTimeline) {
|
||||
return await this.getFromDb({
|
||||
const timeline = await this.getFromDb({
|
||||
untilId,
|
||||
sinceId,
|
||||
limit: ps.limit,
|
||||
withFiles: ps.withFiles,
|
||||
withReplies: ps.withReplies,
|
||||
}, 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(() => {
|
||||
if (me) {
|
||||
this.activeUsersChart.read(me);
|
||||
}
|
||||
});
|
||||
|
||||
return await this.noteEntityService.packMany(redisTimeline, me);
|
||||
} else {
|
||||
if (serverSettings.enableFanoutTimelineDbFallback) { // fallback to db
|
||||
return await this.getFromDb({
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
}
|
||||
|
||||
const timeline = await this.fanoutTimelineEndpointService.timeline({
|
||||
untilId,
|
||||
sinceId,
|
||||
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,
|
||||
withReplies: ps.withReplies,
|
||||
}, me);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}, me),
|
||||
});
|
||||
|
||||
process.nextTick(() => {
|
||||
if (me) {
|
||||
this.activeUsersChart.read(me);
|
||||
}
|
||||
});
|
||||
|
||||
return timeline;
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
process.nextTick(() => {
|
||||
if (me) {
|
||||
this.activeUsersChart.read(me);
|
||||
}
|
||||
});
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
return await query.limit(ps.limit).getMany();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { Brackets } from 'typeorm';
|
||||
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 { QueryService } from '@/core/QueryService.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 { IdService } from '@/core/IdService.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 { MiLocalUser } from '@/models/User.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
@ -43,6 +42,7 @@ export const paramDef = {
|
|||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
|
||||
includeMyRenotes: { type: 'boolean', default: true },
|
||||
includeRenotedMyNotes: { 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 idService: IdService,
|
||||
private cacheService: CacheService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private queryService: QueryService,
|
||||
private metaService: MetaService,
|
||||
|
@ -77,7 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
const serverSettings = await this.metaService.fetch();
|
||||
|
||||
if (!serverSettings.enableFanoutTimeline) {
|
||||
return await this.getFromDb({
|
||||
const timeline = await this.getFromDb({
|
||||
untilId,
|
||||
sinceId,
|
||||
limit: ps.limit,
|
||||
|
@ -87,81 +87,54 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
withFiles: ps.withFiles,
|
||||
withRenotes: ps.withRenotes,
|
||||
}, me);
|
||||
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
});
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
}
|
||||
|
||||
const [
|
||||
followings,
|
||||
userIdsWhoMeMuting,
|
||||
userIdsWhoMeMutingRenotes,
|
||||
userIdsWhoBlockingMe,
|
||||
] = await Promise.all([
|
||||
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);
|
||||
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 (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;
|
||||
}
|
||||
}
|
||||
const timeline = this.fanoutTimelineEndpointService.timeline({
|
||||
untilId,
|
||||
sinceId,
|
||||
limit: ps.limit,
|
||||
allowPartial: ps.allowPartial,
|
||||
me,
|
||||
useDbFallback: serverSettings.enableFanoutTimelineDbFallback,
|
||||
redisTimelines: ps.withFiles ? [`homeTimelineWithFiles:${me.id}`] : [`homeTimeline:${me.id}`],
|
||||
alwaysIncludeMyNotes: true,
|
||||
excludePureRenotes: !ps.withRenotes,
|
||||
noteFilter: note => {
|
||||
if (note.reply && note.reply.visibility === 'followers') {
|
||||
if (!Object.hasOwn(followings, note.reply.userId)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
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({
|
||||
},
|
||||
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
|
||||
untilId,
|
||||
sinceId,
|
||||
limit: ps.limit,
|
||||
limit,
|
||||
includeMyRenotes: ps.includeMyRenotes,
|
||||
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
||||
includeLocalRenotes: ps.includeLocalRenotes,
|
||||
withFiles: ps.withFiles,
|
||||
withRenotes: ps.withRenotes,
|
||||
}, me);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}, me),
|
||||
});
|
||||
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
});
|
||||
|
||||
return timeline;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -269,12 +242,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
//#endregion
|
||||
|
||||
const timeline = await query.limit(ps.limit).getMany();
|
||||
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
});
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
return await query.limit(ps.limit).getMany();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,18 +5,17 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
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 { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { CacheService } from '@/core/CacheService.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 { MiLocalUser } from '@/models/User.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -52,6 +51,7 @@ export const paramDef = {
|
|||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
|
||||
includeMyRenotes: { type: 'boolean', default: true },
|
||||
includeRenotedMyNotes: { 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 cacheService: CacheService,
|
||||
private idService: IdService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private queryService: QueryService,
|
||||
private metaService: MetaService,
|
||||
) {
|
||||
|
@ -101,7 +101,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
const serverSettings = await this.metaService.fetch();
|
||||
|
||||
if (!serverSettings.enableFanoutTimeline) {
|
||||
return await this.getFromDb(list, {
|
||||
const timeline = await this.getFromDb(list, {
|
||||
untilId,
|
||||
sinceId,
|
||||
limit: ps.limit,
|
||||
|
@ -111,73 +111,37 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
withFiles: ps.withFiles,
|
||||
withRenotes: ps.withRenotes,
|
||||
}, me);
|
||||
|
||||
this.activeUsersChart.read(me);
|
||||
|
||||
await this.noteEntityService.packMany(timeline, 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 = await this.fanoutTimelineService.get(ps.withFiles ? `userListTimelineWithFiles:${list.id}` : `userListTimeline:${list.id}`, untilId, sinceId);
|
||||
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 (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;
|
||||
const timeline = await this.fanoutTimelineEndpointService.timeline({
|
||||
untilId,
|
||||
sinceId,
|
||||
limit: ps.limit,
|
||||
allowPartial: ps.allowPartial,
|
||||
me,
|
||||
useDbFallback: serverSettings.enableFanoutTimelineDbFallback,
|
||||
redisTimelines: ps.withFiles ? [`userListTimelineWithFiles:${list.id}`] : [`userListTimeline:${list.id}`],
|
||||
alwaysIncludeMyNotes: true,
|
||||
excludePureRenotes: !ps.withRenotes,
|
||||
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb(list, {
|
||||
untilId,
|
||||
sinceId,
|
||||
limit,
|
||||
includeMyRenotes: ps.includeMyRenotes,
|
||||
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
||||
includeLocalRenotes: ps.includeLocalRenotes,
|
||||
withFiles: ps.withFiles,
|
||||
withRenotes: ps.withRenotes,
|
||||
}, me),
|
||||
});
|
||||
|
||||
redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
}
|
||||
|
||||
if (redisTimeline.length > 0) {
|
||||
this.activeUsersChart.read(me);
|
||||
return await this.noteEntityService.packMany(redisTimeline, me);
|
||||
} else {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
return timeline;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -271,10 +235,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
//#endregion
|
||||
|
||||
const timeline = await query.limit(ps.limit).getMany();
|
||||
|
||||
this.activeUsersChart.read(me);
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
return await query.limit(ps.limit).getMany();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,16 @@ export const meta = {
|
|||
tags: ['role'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'Role',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
@ -22,6 +22,12 @@ export const meta = {
|
|||
id: 'de5502bf-009a-4639-86c1-fec349e46dcb',
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'Role',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
@ -9,6 +9,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
|
|||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
@ -46,6 +48,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private featuredService: FeaturedService,
|
||||
private cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
let noteIds = await this.featuredService.getPerUserNotesRanking(ps.userId, 50);
|
||||
|
@ -60,6 +63,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
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')
|
||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
|
@ -69,10 +80,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
const notes = await query.getMany();
|
||||
notes.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
const notes = (await query.getMany()).filter(note => {
|
||||
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);
|
||||
});
|
||||
|
|
|
@ -5,18 +5,18 @@
|
|||
|
||||
import { Brackets } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import type { MiNote, NotesRepository } from '@/models/_.js';
|
||||
import type { NotesRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.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 = {
|
||||
tags: ['users', 'notes'],
|
||||
|
@ -37,6 +37,12 @@ export const meta = {
|
|||
code: 'NO_SUCH_USER',
|
||||
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;
|
||||
|
||||
|
@ -52,8 +58,8 @@ export const paramDef = {
|
|||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
|
||||
withFiles: { type: 'boolean', default: false },
|
||||
excludeNsfw: { type: 'boolean', default: false },
|
||||
},
|
||||
required: ['userId'],
|
||||
} as const;
|
||||
|
@ -61,9 +67,6 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.redisForTimelines)
|
||||
private redisForTimelines: Redis.Redis,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
|
@ -71,80 +74,85 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private queryService: QueryService,
|
||||
private cacheService: CacheService,
|
||||
private idService: IdService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private metaService: MetaService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
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 isRangeSpecified = untilId != null && sinceId != null;
|
||||
const isSelf = me && (me.id === ps.userId);
|
||||
|
||||
const serverSettings = await this.metaService.fetch();
|
||||
|
||||
if (serverSettings.enableFanoutTimeline && (isRangeSpecified || sinceId == null)) {
|
||||
const [
|
||||
userIdsWhoMeMuting,
|
||||
] = me ? await Promise.all([
|
||||
this.cacheService.userMutingsCache.fetch(me.id),
|
||||
]) : [new Set<string>()];
|
||||
if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles);
|
||||
|
||||
const [noteIdsRes, repliesNoteIdsRes, channelNoteIdsRes] = await Promise.all([
|
||||
this.fanoutTimelineService.get(ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`, untilId, sinceId),
|
||||
ps.withReplies ? this.fanoutTimelineService.get(`userTimelineWithReplies:${ps.userId}`, untilId, sinceId) : Promise.resolve([]),
|
||||
ps.withChannelNotes ? this.fanoutTimelineService.get(`userTimelineWithChannel:${ps.userId}`, untilId, sinceId) : Promise.resolve([]),
|
||||
]);
|
||||
if (!serverSettings.enableFanoutTimeline) {
|
||||
const timeline = await this.getFromDb({
|
||||
untilId,
|
||||
sinceId,
|
||||
limit: ps.limit,
|
||||
userId: ps.userId,
|
||||
withChannelNotes: ps.withChannelNotes,
|
||||
withFiles: ps.withFiles,
|
||||
withRenotes: ps.withRenotes,
|
||||
}, me);
|
||||
|
||||
let noteIds = Array.from(new Set([
|
||||
...noteIdsRes,
|
||||
...repliesNoteIdsRes,
|
||||
...channelNoteIdsRes,
|
||||
]));
|
||||
noteIds.sort((a, b) => a > b ? -1 : 1);
|
||||
noteIds = noteIds.slice(0, ps.limit);
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
}
|
||||
|
||||
const redisTimelines: FanoutTimelineName[] = [ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`];
|
||||
|
||||
if (ps.withReplies) redisTimelines.push(`userTimelineWithReplies:${ps.userId}`);
|
||||
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 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');
|
||||
|
||||
let timeline = await query.getMany();
|
||||
|
||||
timeline = timeline.filter(note => {
|
||||
if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false;
|
||||
|
||||
if (note.renoteId) {
|
||||
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
|
||||
if (ps.withRenotes === false) return false;
|
||||
}
|
||||
}
|
||||
|
||||
const timeline = await this.fanoutTimelineEndpointService.timeline({
|
||||
untilId,
|
||||
sinceId,
|
||||
limit: ps.limit,
|
||||
allowPartial: ps.allowPartial,
|
||||
me,
|
||||
redisTimelines,
|
||||
useDbFallback: true,
|
||||
ignoreAuthorFromMute: true,
|
||||
excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies
|
||||
excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files
|
||||
excludePureRenotes: !ps.withRenotes,
|
||||
noteFilter: note => {
|
||||
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 === 'followers' && !isFollowing && !isSelf) return false;
|
||||
|
||||
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: フィルタで件数が減った場合の埋め合わせ処理
|
||||
|
||||
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
|
||||
if (timeline.length > 0) {
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
}
|
||||
}
|
||||
return timeline;
|
||||
});
|
||||
}
|
||||
|
||||
//#region fallback to database
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
private async getFromDb(ps: {
|
||||
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 })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.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 this.noteEntityService.packMany(timeline, me);
|
||||
//#endregion
|
||||
});
|
||||
return await query.limit(ps.limit).getMany();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,7 +43,7 @@ export function genOpenapiSpec(config: Config) {
|
|||
|
||||
// 書き換えたりするのでディープコピーしておく。そのまま編集するとメモリ上の値が汚れて次回以降の出力に影響する
|
||||
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;
|
||||
|
||||
if (endpoint.meta.errors) {
|
||||
|
@ -59,6 +59,11 @@ export function genOpenapiSpec(config: Config) {
|
|||
const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res) : {};
|
||||
|
||||
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'}*`;
|
||||
if (endpoint.meta.kind) {
|
||||
const kind = endpoint.meta.kind;
|
||||
|
|
|
@ -36,6 +36,7 @@ export default class Connection {
|
|||
public userIdsWhoMeMuting: Set<string> = new Set();
|
||||
public userIdsWhoBlockingMe: Set<string> = new Set();
|
||||
public userIdsWhoMeMutingRenotes: Set<string> = new Set();
|
||||
public userMutedInstances: Set<string> = new Set();
|
||||
private fetchIntervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
|
@ -69,6 +70,7 @@ export default class Connection {
|
|||
this.userIdsWhoMeMuting = userIdsWhoMeMuting;
|
||||
this.userIdsWhoBlockingMe = userIdsWhoBlockingMe;
|
||||
this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes;
|
||||
this.userMutedInstances = new Set(userProfile.mutedInstances);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
@ -41,6 +41,10 @@ export default abstract class Channel {
|
|||
return this.connection.userIdsWhoBlockingMe;
|
||||
}
|
||||
|
||||
protected get userMutedInstances() {
|
||||
return this.connection.userMutedInstances;
|
||||
}
|
||||
|
||||
protected get followingChannels() {
|
||||
return this.connection.followingChannels;
|
||||
}
|
||||
|
|
|
@ -5,12 +5,12 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
|
||||
import Channel from '../channel.js';
|
||||
|
||||
class UserListChannel extends Channel {
|
||||
|
@ -80,6 +80,9 @@ class UserListChannel extends Channel {
|
|||
private async onNote(note: Packed<'Note'>) {
|
||||
const isMe = this.user!.id === note.userId;
|
||||
|
||||
// チャンネル投稿は無視する
|
||||
if (note.channelId) return;
|
||||
|
||||
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) 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.send('note', note);
|
||||
|
|
|
@ -7,7 +7,7 @@ process.env.NODE_ENV = 'test';
|
|||
|
||||
import * as assert from 'assert';
|
||||
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 * as misskey from 'misskey-js';
|
||||
|
||||
|
@ -34,12 +34,16 @@ describe('Streaming', () => {
|
|||
let ayano: misskey.entities.MeSignup;
|
||||
let kyoko: misskey.entities.MeSignup;
|
||||
let chitose: misskey.entities.MeSignup;
|
||||
let kanako: misskey.entities.MeSignup;
|
||||
|
||||
// Remote users
|
||||
let akari: misskey.entities.MeSignup;
|
||||
let chinatsu: misskey.entities.MeSignup;
|
||||
let takumi: misskey.entities.MeSignup;
|
||||
|
||||
let kyokoNote: any;
|
||||
let kanakoNote: any;
|
||||
let takumiNote: any;
|
||||
let list: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
|
@ -50,11 +54,15 @@ describe('Streaming', () => {
|
|||
ayano = await signup({ username: 'ayano' });
|
||||
kyoko = await signup({ username: 'kyoko' });
|
||||
chitose = await signup({ username: 'chitose' });
|
||||
kanako = await signup({ username: 'kanako' });
|
||||
|
||||
akari = await signup({ username: 'akari', 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' });
|
||||
kanakoNote = await post(kanako, { text: 'hoge' });
|
||||
takumiNote = await post(takumi, { text: 'piyo' });
|
||||
|
||||
// Follow: ayano => kyoko
|
||||
await api('following/create', { userId: kyoko.id }, ayano);
|
||||
|
@ -62,6 +70,9 @@ describe('Streaming', () => {
|
|||
// Follow: ayano => akari
|
||||
await follow(ayano, akari);
|
||||
|
||||
// Mute: chitose => kanako
|
||||
await api('mute/create', { userId: kanako.id }, chitose);
|
||||
|
||||
// List: chitose => ayano, kyoko
|
||||
list = await api('users/lists/create', {
|
||||
name: 'my list',
|
||||
|
@ -76,6 +87,11 @@ describe('Streaming', () => {
|
|||
listId: list.id,
|
||||
userId: kyoko.id,
|
||||
}, chitose);
|
||||
|
||||
await api('users/lists/push', {
|
||||
listId: list.id,
|
||||
userId: takumi.id,
|
||||
}, chitose);
|
||||
}, 1000 * 60 * 2);
|
||||
|
||||
afterAll(async () => {
|
||||
|
@ -452,6 +468,96 @@ describe('Streaming', () => {
|
|||
|
||||
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"
|
||||
|
|
|
@ -69,12 +69,6 @@ module.exports = {
|
|||
'require': false,
|
||||
'__dirname': false,
|
||||
|
||||
// Vue
|
||||
'$$': false,
|
||||
'$ref': false,
|
||||
'$shallowRef': false,
|
||||
'$computed': false,
|
||||
|
||||
// Misskey
|
||||
'_DEV_': false,
|
||||
'_LANGS_': false,
|
||||
|
|
|
@ -89,7 +89,6 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|||
api("users/notes", {
|
||||
userId: props.user.id,
|
||||
fileType: image,
|
||||
excludeNsfw: defaultStore.state.nsfw !== "ignore",
|
||||
limit: 10
|
||||
}).then((notes) => {
|
||||
for (const note of notes) {
|
||||
|
@ -181,7 +180,7 @@ import './photoswipe-!~{003}~.js';
|
|||
const _hoisted_1 = createBaseVNode("i", {
|
||||
class: "ti ti-photo"
|
||||
}, null, -1);
|
||||
const _sfc_main = defineComponent({
|
||||
const index_photos = defineComponent({
|
||||
__name: "index.photos",
|
||||
props: {
|
||||
user: {}
|
||||
|
@ -198,7 +197,6 @@ const _sfc_main = defineComponent({
|
|||
api("users/notes", {
|
||||
userId: props.user.id,
|
||||
fileType: image,
|
||||
excludeNsfw: defaultStore.state.nsfw !== "ignore",
|
||||
limit: 10
|
||||
}).then(notes => {
|
||||
for (const note of notes) {
|
||||
|
@ -263,7 +261,6 @@ const style0 = {
|
|||
const cssModules = {
|
||||
"$style": style0
|
||||
};
|
||||
const index_photos = _sfc_main;
|
||||
export {index_photos as default};
|
||||
`.slice(1));
|
||||
});
|
||||
|
|
|
@ -13,13 +13,13 @@ function isFalsyIdentifier(identifier: estree.Identifier): boolean {
|
|||
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 === 'Literal') return typeof tree.value === 'string' ? tree.value : '';
|
||||
if (tree.type === 'BinaryExpression') {
|
||||
if (tree.operator !== '+') return null;
|
||||
const left = normalizeClassWalker(tree.left);
|
||||
const right = normalizeClassWalker(tree.right);
|
||||
const left = normalizeClassWalker(tree.left, stack);
|
||||
const right = normalizeClassWalker(tree.right, stack);
|
||||
if (left === null || right === null) return null;
|
||||
return `${left}${right}`;
|
||||
}
|
||||
|
@ -33,15 +33,15 @@ function normalizeClassWalker(tree: estree.Node): string | null {
|
|||
if (tree.type === 'ArrayExpression') {
|
||||
const values = tree.elements.map((treeNode) => {
|
||||
if (treeNode === null) return '';
|
||||
if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument);
|
||||
return normalizeClassWalker(treeNode);
|
||||
if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument, stack);
|
||||
return normalizeClassWalker(treeNode, stack);
|
||||
});
|
||||
if (values.some((x) => x === null)) return null;
|
||||
return values.join(' ');
|
||||
}
|
||||
if (tree.type === 'ObjectExpression') {
|
||||
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 inveted = false;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
export function normalizeClass(tree: estree.Node): string | null {
|
||||
const walked = normalizeClassWalker(tree);
|
||||
export function normalizeClass(tree: estree.Node, stack?: string): string | null {
|
||||
const walked = normalizeClassWalker(tree, stack);
|
||||
return walked && walked.replace(/^\s+|\s+(?=\s)|\s+$/g, '');
|
||||
}
|
||||
|
||||
export function unwindCssModuleClassName(ast: estree.Node): void {
|
||||
(walk as typeof estreeWalker.walk)(ast, {
|
||||
enter(node, parent): void {
|
||||
//#region
|
||||
if (parent?.type !== 'Program') return;
|
||||
if (node.type !== 'VariableDeclaration') return;
|
||||
if (node.declarations.length !== 1) return;
|
||||
|
@ -102,6 +110,14 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
|
|||
return true;
|
||||
});
|
||||
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 cssModuleForestNode = parent.body.find((x) => {
|
||||
if (x.type !== 'VariableDeclaration') return false;
|
||||
|
@ -117,6 +133,16 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
|
|||
if (property.value.type !== 'Identifier') return [];
|
||||
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) => {
|
||||
if (x.type !== 'VariableDeclaration') 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.name !== '_ctx') 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) {
|
||||
//#region
|
||||
const cssModuleTreeNode = parent.body.find((x) => {
|
||||
if (x.type !== 'VariableDeclaration') 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 [];
|
||||
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, {
|
||||
enter(childNode) {
|
||||
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, {
|
||||
enter(childNode) {
|
||||
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, {
|
||||
enter(childNode) {
|
||||
if (childNode.type !== 'CallExpression') return;
|
||||
if (childNode.callee.type !== 'Identifier') return;
|
||||
if (childNode.callee.name !== 'normalizeClass') return;
|
||||
if (childNode.arguments.length !== 1) return;
|
||||
const normalized = normalizeClass(childNode.arguments[0]);
|
||||
const normalized = normalizeClass(childNode.arguments[0], name);
|
||||
if (normalized === null) return;
|
||||
this.replace({
|
||||
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) {
|
||||
(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({
|
||||
type: 'VariableDeclaration',
|
||||
declarations: [{
|
||||
|
@ -236,6 +409,7 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
|
|||
}],
|
||||
kind: 'const',
|
||||
});
|
||||
*/
|
||||
} else {
|
||||
this.replace({
|
||||
type: 'VariableDeclaration',
|
||||
|
@ -263,6 +437,35 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
|
|||
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
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"watch": "vite",
|
||||
"dev": "vite --config vite.config.local-dev.ts",
|
||||
"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\"",
|
||||
"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-json": "6.0.1",
|
||||
"@rollup/plugin-replace": "5.0.5",
|
||||
"@rollup/pluginutils": "5.0.5",
|
||||
"@rollup/pluginutils": "5.1.0",
|
||||
"@syuilo/aiscript": "0.16.0",
|
||||
"@tabler/icons-webfont": "2.37.0",
|
||||
"@vitejs/plugin-vue": "4.5.0",
|
||||
"@vue-macros/reactivity-transform": "0.4.0",
|
||||
"@vitejs/plugin-vue": "4.5.1",
|
||||
"@vue/compiler-sfc": "3.3.9",
|
||||
"astring": "1.8.6",
|
||||
"autosize": "6.0.1",
|
||||
|
@ -34,19 +34,19 @@
|
|||
"browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
|
||||
"buraha": "0.0.1",
|
||||
"canvas-confetti": "1.6.1",
|
||||
"chart.js": "4.4.0",
|
||||
"chart.js": "4.4.1",
|
||||
"chartjs-adapter-date-fns": "3.0.0",
|
||||
"chartjs-chart-matrix": "2.0.1",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
"chartjs-plugin-zoom": "2.0.1",
|
||||
"chromatic": "9.1.0",
|
||||
"chromatic": "10.1.0",
|
||||
"compare-versions": "6.1.0",
|
||||
"cropperjs": "2.0.0-beta.4",
|
||||
"date-fns": "2.30.0",
|
||||
"escape-regexp": "0.0.1",
|
||||
"estree-walker": "3.0.3",
|
||||
"eventemitter3": "5.0.1",
|
||||
"gsap": "3.12.2",
|
||||
"gsap": "3.12.3",
|
||||
"idb-keyval": "6.2.1",
|
||||
"insert-text-at-cursor": "0.3.0",
|
||||
"is-file-animated": "1.0.2",
|
||||
|
@ -54,54 +54,54 @@
|
|||
"matter-js": "0.19.0",
|
||||
"mfm-js": "0.23.3",
|
||||
"misskey-js": "workspace:*",
|
||||
"photoswipe": "5.4.2",
|
||||
"photoswipe": "5.4.3",
|
||||
"punycode": "2.3.1",
|
||||
"querystring": "0.2.1",
|
||||
"rollup": "4.6.0",
|
||||
"rollup": "4.7.0",
|
||||
"sanitize-html": "2.11.0",
|
||||
"shiki": "^0.14.5",
|
||||
"shiki": "0.14.6",
|
||||
"sass": "1.69.5",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.158.0",
|
||||
"three": "0.159.0",
|
||||
"throttle-debounce": "5.0.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tsc-alias": "1.8.8",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"twemoji-parser": "14.0.0",
|
||||
"typescript": "5.3.2",
|
||||
"typescript": "5.3.3",
|
||||
"uuid": "9.0.1",
|
||||
"v-code-diff": "1.7.2",
|
||||
"vanilla-tilt": "1.8.1",
|
||||
"vite": "5.0.2",
|
||||
"vue": "3.3.9",
|
||||
"vite": "5.0.7",
|
||||
"vue": "3.3.11",
|
||||
"vuedraggable": "next"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-actions": "7.5.3",
|
||||
"@storybook/addon-essentials": "7.5.3",
|
||||
"@storybook/addon-interactions": "7.5.3",
|
||||
"@storybook/addon-links": "7.5.3",
|
||||
"@storybook/addon-storysource": "7.5.3",
|
||||
"@storybook/addons": "7.5.3",
|
||||
"@storybook/blocks": "7.5.3",
|
||||
"@storybook/core-events": "7.5.3",
|
||||
"@storybook/addon-actions": "7.6.4",
|
||||
"@storybook/addon-essentials": "7.6.4",
|
||||
"@storybook/addon-interactions": "7.6.4",
|
||||
"@storybook/addon-links": "7.6.4",
|
||||
"@storybook/addon-storysource": "7.6.4",
|
||||
"@storybook/addons": "7.6.4",
|
||||
"@storybook/blocks": "7.6.4",
|
||||
"@storybook/core-events": "7.6.4",
|
||||
"@storybook/jest": "0.2.3",
|
||||
"@storybook/manager-api": "7.5.3",
|
||||
"@storybook/preview-api": "7.5.3",
|
||||
"@storybook/react": "7.5.3",
|
||||
"@storybook/react-vite": "7.5.3",
|
||||
"@storybook/manager-api": "7.6.4",
|
||||
"@storybook/preview-api": "7.6.4",
|
||||
"@storybook/react": "7.6.4",
|
||||
"@storybook/react-vite": "7.6.4",
|
||||
"@storybook/testing-library": "0.2.2",
|
||||
"@storybook/theming": "7.5.3",
|
||||
"@storybook/types": "7.5.3",
|
||||
"@storybook/vue3": "7.5.3",
|
||||
"@storybook/vue3-vite": "7.5.3",
|
||||
"@storybook/theming": "7.6.4",
|
||||
"@storybook/types": "7.6.4",
|
||||
"@storybook/vue3": "7.6.4",
|
||||
"@storybook/vue3-vite": "7.6.4",
|
||||
"@testing-library/vue": "8.0.1",
|
||||
"@types/escape-regexp": "0.0.3",
|
||||
"@types/estree": "1.0.5",
|
||||
"@types/matter-js": "0.19.5",
|
||||
"@types/micromatch": "4.0.6",
|
||||
"@types/node": "20.10.0",
|
||||
"@types/node": "20.10.4",
|
||||
"@types/punycode": "2.1.3",
|
||||
"@types/sanitize-html": "2.9.5",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
|
@ -109,33 +109,33 @@
|
|||
"@types/uuid": "9.0.7",
|
||||
"@types/websocket": "1.0.10",
|
||||
"@types/ws": "8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "6.12.0",
|
||||
"@typescript-eslint/parser": "6.12.0",
|
||||
"@typescript-eslint/eslint-plugin": "6.13.2",
|
||||
"@typescript-eslint/parser": "6.13.2",
|
||||
"@vitest/coverage-v8": "0.34.6",
|
||||
"@vue/runtime-core": "3.3.9",
|
||||
"@vue/runtime-core": "3.3.11",
|
||||
"acorn": "8.11.2",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "13.6.0",
|
||||
"eslint": "8.54.0",
|
||||
"cypress": "13.6.1",
|
||||
"eslint": "8.55.0",
|
||||
"eslint-plugin-import": "2.29.0",
|
||||
"eslint-plugin-vue": "9.18.1",
|
||||
"eslint-plugin-vue": "9.19.2",
|
||||
"fast-glob": "3.3.2",
|
||||
"happy-dom": "10.0.3",
|
||||
"micromatch": "4.0.5",
|
||||
"msw": "1.3.2",
|
||||
"msw-storybook-addon": "1.10.0",
|
||||
"nodemon": "3.0.1",
|
||||
"nodemon": "3.0.2",
|
||||
"prettier": "3.1.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"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",
|
||||
"summaly": "github:misskey-dev/summaly",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "0.34.6",
|
||||
"vitest-fetch-mock": "0.2.2",
|
||||
"vue-eslint-parser": "9.3.2",
|
||||
"vue-tsc": "1.8.22"
|
||||
"vue-tsc": "1.8.25"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
|
@ -187,6 +187,12 @@ export async function common(createVue: () => App<Element>) {
|
|||
if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme));
|
||||
if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme));
|
||||
defaultStore.set('themeInitial', false);
|
||||
} else {
|
||||
if (defaultStore.state.darkMode) {
|
||||
applyTheme(darkTheme.value);
|
||||
} else {
|
||||
applyTheme(lightTheme.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js
|
|||
import { mainRouter } from '@/router.js';
|
||||
import { initializeSw } from '@/scripts/initialize-sw.js';
|
||||
import { deckStore } from '@/ui/deck/deck-store.js';
|
||||
import { emojiPicker } from '@/scripts/emoji-picker.js';
|
||||
|
||||
export async function mainBoot() {
|
||||
const { isClientUpdated } = await common(() => createApp(
|
||||
|
@ -30,6 +31,7 @@ export async function mainBoot() {
|
|||
));
|
||||
|
||||
reactionPicker.init();
|
||||
emojiPicker.init();
|
||||
|
||||
if (isClientUpdated && $i) {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed');
|
||||
|
|
|
@ -41,6 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||
|
@ -56,11 +57,11 @@ const emit = defineEmits<{
|
|||
(ev: 'resolved', reportId: string): void;
|
||||
}>();
|
||||
|
||||
let forward = $ref(props.report.forwarded);
|
||||
const forward = ref(props.report.forwarded);
|
||||
|
||||
function resolve() {
|
||||
os.apiWithDialog('admin/resolve-abuse-user-report', {
|
||||
forward: forward,
|
||||
forward: forward.value,
|
||||
reportId: props.report.id,
|
||||
}).then(() => {
|
||||
emit('resolved', props.report.id);
|
||||
|
|
|
@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { onMounted } from 'vue';
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/scripts/achievements.js';
|
||||
|
@ -67,15 +67,15 @@ const props = withDefaults(defineProps<{
|
|||
withDescription: true,
|
||||
});
|
||||
|
||||
let achievements = $ref();
|
||||
const lockedAchievements = $computed(() => ACHIEVEMENT_TYPES.filter(x => !(achievements ?? []).some(a => a.name === x)));
|
||||
const achievements = ref();
|
||||
const lockedAchievements = computed(() => ACHIEVEMENT_TYPES.filter(x => !(achievements.value ?? []).some(a => a.name === x)));
|
||||
|
||||
function fetch() {
|
||||
os.api('users/achievements', { userId: props.user.id }).then(res => {
|
||||
achievements = [];
|
||||
achievements.value = [];
|
||||
for (const t of ACHIEVEMENT_TYPES) {
|
||||
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);
|
||||
});
|
||||
|
|
|
@ -138,45 +138,45 @@ const texts = computed(() => {
|
|||
});
|
||||
|
||||
let enabled = true;
|
||||
let majorGraduationColor = $ref<string>();
|
||||
const majorGraduationColor = ref<string>();
|
||||
//let minorGraduationColor = $ref<string>();
|
||||
let sHandColor = $ref<string>();
|
||||
let mHandColor = $ref<string>();
|
||||
let hHandColor = $ref<string>();
|
||||
let nowColor = $ref<string>();
|
||||
let h = $ref<number>(0);
|
||||
let m = $ref<number>(0);
|
||||
let s = $ref<number>(0);
|
||||
let hAngle = $ref<number>(0);
|
||||
let mAngle = $ref<number>(0);
|
||||
let sAngle = $ref<number>(0);
|
||||
let disableSAnimate = $ref(false);
|
||||
const sHandColor = ref<string>();
|
||||
const mHandColor = ref<string>();
|
||||
const hHandColor = ref<string>();
|
||||
const nowColor = ref<string>();
|
||||
const h = ref<number>(0);
|
||||
const m = ref<number>(0);
|
||||
const s = ref<number>(0);
|
||||
const hAngle = ref<number>(0);
|
||||
const mAngle = ref<number>(0);
|
||||
const sAngle = ref<number>(0);
|
||||
const disableSAnimate = ref(false);
|
||||
let sOneRound = false;
|
||||
const sLine = ref<SVGPathElement>();
|
||||
|
||||
function tick() {
|
||||
const now = props.now();
|
||||
now.setMinutes(now.getMinutes() + now.getTimezoneOffset() + props.offset);
|
||||
const previousS = s;
|
||||
const previousM = m;
|
||||
const previousH = h;
|
||||
s = now.getSeconds();
|
||||
m = now.getMinutes();
|
||||
h = now.getHours();
|
||||
if (previousS === s && previousM === m && previousH === h) {
|
||||
const previousS = s.value;
|
||||
const previousM = m.value;
|
||||
const previousH = h.value;
|
||||
s.value = now.getSeconds();
|
||||
m.value = now.getMinutes();
|
||||
h.value = now.getHours();
|
||||
if (previousS === s.value && previousM === m.value && previousH === h.value) {
|
||||
return;
|
||||
}
|
||||
hAngle = Math.PI * (h % (props.twentyfour ? 24 : 12) + (m + s / 60) / 60) / (props.twentyfour ? 12 : 6);
|
||||
mAngle = Math.PI * (m + s / 60) / 30;
|
||||
hAngle.value = Math.PI * (h.value % (props.twentyfour ? 24 : 12) + (m.value + s.value / 60) / 60) / (props.twentyfour ? 12 : 6);
|
||||
mAngle.value = Math.PI * (m.value + s.value / 60) / 30;
|
||||
if (sOneRound && sLine.value) { // 秒針が一周した際のアニメーションをよしなに処理する(これが無いと秒が59->0になったときに期待したアニメーションにならない)
|
||||
sAngle = Math.PI * 60 / 30;
|
||||
sAngle.value = Math.PI * 60 / 30;
|
||||
defaultIdlingRenderScheduler.delete(tick);
|
||||
sLine.value.addEventListener('transitionend', () => {
|
||||
disableSAnimate = true;
|
||||
disableSAnimate.value = true;
|
||||
requestAnimationFrame(() => {
|
||||
sAngle = 0;
|
||||
sAngle.value = 0;
|
||||
requestAnimationFrame(() => {
|
||||
disableSAnimate = false;
|
||||
disableSAnimate.value = false;
|
||||
if (enabled) {
|
||||
defaultIdlingRenderScheduler.add(tick);
|
||||
}
|
||||
|
@ -184,9 +184,9 @@ function tick() {
|
|||
});
|
||||
}, { once: true });
|
||||
} else {
|
||||
sAngle = Math.PI * s / 30;
|
||||
sAngle.value = Math.PI * s.value / 30;
|
||||
}
|
||||
sOneRound = s === 59;
|
||||
sOneRound = s.value === 59;
|
||||
}
|
||||
|
||||
tick();
|
||||
|
@ -195,12 +195,12 @@ function calcColors() {
|
|||
const computedStyle = getComputedStyle(document.documentElement);
|
||||
const dark = tinycolor(computedStyle.getPropertyValue('--bg')).isDark();
|
||||
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)';
|
||||
sHandColor = dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)';
|
||||
mHandColor = tinycolor(computedStyle.getPropertyValue('--fg')).toHexString();
|
||||
hHandColor = accent;
|
||||
nowColor = accent;
|
||||
sHandColor.value = dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)';
|
||||
mHandColor.value = tinycolor(computedStyle.getPropertyValue('--fg')).toHexString();
|
||||
hHandColor.value = accent;
|
||||
nowColor.value = accent;
|
||||
}
|
||||
|
||||
calcColors();
|
||||
|
|
|
@ -43,6 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
fixed
|
||||
:instant="true"
|
||||
:initialText="c.form.text"
|
||||
:initialCw="c.form.cw"
|
||||
/>
|
||||
</div>
|
||||
<MkFolder v-else-if="c.type === 'folder'" :defaultOpen="c.opened">
|
||||
|
@ -60,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Ref } from 'vue';
|
||||
import { Ref, ref } from 'vue';
|
||||
import * as os from '@/os.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
|
@ -87,16 +88,17 @@ function g(id) {
|
|||
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) {
|
||||
valueForSwitch = v;
|
||||
valueForSwitch.value = v;
|
||||
if (c.onChange) c.onChange(v);
|
||||
}
|
||||
|
||||
function openPostForm() {
|
||||
os.post({
|
||||
initialText: c.form.text,
|
||||
initialCw: c.form.cw,
|
||||
instant: true,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -65,9 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, nextTick, onMounted, reactive, ref, watch} from 'vue';
|
||||
import {bannerDark, bannerLight, defaultStore, iconDark, iconLight} from "@/store.js";
|
||||
import {unisonReload} from "@/scripts/unison-reload.js";
|
||||
import { nextTick, onMounted, shallowRef } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
|
@ -128,13 +126,13 @@ const emit = defineEmits<{
|
|||
(ev: 'click', payload: MouseEvent): void;
|
||||
}>();
|
||||
|
||||
let el = $shallowRef<HTMLElement | null>(null);
|
||||
let ripples = $shallowRef<HTMLElement | null>(null);
|
||||
const el = shallowRef<HTMLElement | null>(null);
|
||||
const ripples = shallowRef<HTMLElement | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autofocus) {
|
||||
nextTick(() => {
|
||||
el!.focus();
|
||||
el.value!.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -144,11 +142,11 @@ function distance(p, q): number {
|
|||
}
|
||||
|
||||
function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY): number {
|
||||
const origin = {x: circleCenterX, y: circleCenterY};
|
||||
const dist1 = distance({x: 0, y: 0}, origin);
|
||||
const dist2 = distance({x: boxW, y: 0}, origin);
|
||||
const dist3 = distance({x: 0, y: boxH}, origin);
|
||||
const dist4 = distance({x: boxW, y: boxH}, origin);
|
||||
const origin = { x: circleCenterX, y: circleCenterY };
|
||||
const dist1 = distance({ x: 0, y: 0 }, origin);
|
||||
const dist2 = distance({ x: boxW, y: 0 }, origin);
|
||||
const dist3 = distance({ x: 0, y: boxH }, origin);
|
||||
const dist4 = distance({ x: boxW, y: boxH }, origin);
|
||||
return Math.max(dist1, dist2, dist3, dist4) * 2;
|
||||
}
|
||||
|
||||
|
@ -157,11 +155,11 @@ function onMousedown(evt: MouseEvent): void {
|
|||
const rect = target.getBoundingClientRect();
|
||||
|
||||
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.left = (evt.clientX - rect.left - 1).toString() + 'px';
|
||||
|
||||
ripples!.appendChild(ripple);
|
||||
ripples.value!.appendChild(ripple);
|
||||
|
||||
const circleCenterX = evt.clientX - rect.left;
|
||||
const circleCenterY = evt.clientY - rect.top;
|
||||
|
@ -172,11 +170,11 @@ function onMousedown(evt: MouseEvent): void {
|
|||
ripple.style.transform = 'scale(' + (scale / 2) + ')';
|
||||
}, 1);
|
||||
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';
|
||||
}, 1000);
|
||||
window.setTimeout(() => {
|
||||
if (ripples) ripples.removeChild(ripple);
|
||||
if (ripples.value) ripples.value.removeChild(ripple);
|
||||
}, 2000);
|
||||
}
|
||||
</script>
|
||||
|
@ -198,7 +196,7 @@ function onMousedown(evt: MouseEvent): void {
|
|||
border-radius: 5px;
|
||||
overflow: clip;
|
||||
box-sizing: border-box;
|
||||
transition: background 0.1s cubic-bezier(0, 0.45, 0.30, 1);
|
||||
transition: background 0.1s ease;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
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 {
|
||||
color: #ff2a2a;
|
||||
|
||||
|
@ -400,7 +344,7 @@ function onMousedown(evt: MouseEvent): void {
|
|||
background: rgba(0, 0, 0, 0.1);
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transition: all 0.5s cubic-bezier(0, .5, 0, 1);
|
||||
transition: all 0.5s cubic-bezier(0,.5,0,1);
|
||||
}
|
||||
|
||||
.content {
|
||||
|
@ -408,36 +352,4 @@ function onMousedown(evt: MouseEvent): void {
|
|||
z-index: 1;
|
||||
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>
|
||||
|
|
|
@ -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 negate = arr => arr.map(x => -x);
|
||||
|
@ -268,7 +268,7 @@ const render = () => {
|
|||
gradient,
|
||||
},
|
||||
},
|
||||
plugins: [chartVLine(vLineColor), ...(props.detailed ? [chartLegend(legendEl)] : [])],
|
||||
plugins: [chartVLine(vLineColor), ...(props.detailed ? [chartLegend(legendEl.value)] : [])],
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -13,29 +13,30 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { shallowRef } from 'vue';
|
||||
import { Chart, LegendItem } from 'chart.js';
|
||||
|
||||
const props = defineProps({
|
||||
});
|
||||
|
||||
let chart = $shallowRef<Chart>();
|
||||
let items = $shallowRef<LegendItem[]>([]);
|
||||
const chart = shallowRef<Chart>();
|
||||
const items = shallowRef<LegendItem[]>([]);
|
||||
|
||||
function update(_chart: Chart, _items: LegendItem[]) {
|
||||
chart = _chart,
|
||||
items = _items;
|
||||
chart.value = _chart,
|
||||
items.value = _items;
|
||||
}
|
||||
|
||||
function onClick(item: LegendItem) {
|
||||
if (chart == null) return;
|
||||
const { type } = chart.config;
|
||||
if (chart.value == null) return;
|
||||
const { type } = chart.value.config;
|
||||
if (type === 'pie' || type === 'doughnut') {
|
||||
// Pie and doughnut charts only have a single dataset and visibility is per item
|
||||
chart.toggleDataVisibility(item.index);
|
||||
chart.value.toggleDataVisibility(item.index);
|
||||
} 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({
|
||||
|
|
|
@ -23,10 +23,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, onMounted, onUnmounted} from 'vue';
|
||||
import {computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue';
|
||||
import * as os from '@/os.js';
|
||||
import {useInterval} from '@/scripts/use-interval.js';
|
||||
import { useInterval } from '@/scripts/use-interval.js';
|
||||
import * as game from '@/scripts/clicker-game.js';
|
||||
import number from '@/filters/number.js';
|
||||
import {claimAchievement} from '@/scripts/achievements.js';
|
||||
|
@ -35,13 +35,13 @@ import {defaultStore} from "@/store.js";
|
|||
const darkMode = computed(defaultStore.makeGetterSetter('darkMode'));
|
||||
const saveData = game.saveData;
|
||||
const cookies = computed(() => saveData.value?.cookies);
|
||||
let cps = $ref(0);
|
||||
let prevCookies = $ref(0);
|
||||
const cps = ref(0);
|
||||
const prevCookies = ref(0);
|
||||
|
||||
function onClick(ev: MouseEvent) {
|
||||
const x = ev.clientX;
|
||||
const y = ev.clientY;
|
||||
os.popup(MkPlusOneEffect, {x, y}, {}, 'end');
|
||||
os.popup(MkPlusOneEffect, { x, y }, {}, 'end');
|
||||
|
||||
saveData.value!.cookies++;
|
||||
saveData.value!.totalCookies++;
|
||||
|
@ -54,9 +54,9 @@ function onClick(ev: MouseEvent) {
|
|||
}
|
||||
|
||||
useInterval(() => {
|
||||
const diff = saveData.value!.cookies - prevCookies;
|
||||
cps = diff;
|
||||
prevCookies = saveData.value!.cookies;
|
||||
const diff = saveData.value!.cookies - prevCookies.value;
|
||||
cps.value = diff;
|
||||
prevCookies.value = saveData.value!.cookies;
|
||||
}, 1000, {
|
||||
immediate: false,
|
||||
afterMounted: true,
|
||||
|
@ -69,7 +69,7 @@ useInterval(game.save, 1000 * 5, {
|
|||
|
||||
onMounted(async () => {
|
||||
await game.load();
|
||||
prevCookies = saveData.value!.cookies;
|
||||
prevCookies.value = saveData.value!.cookies;
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
|
|
@ -54,7 +54,7 @@ watch(() => props.lang, (to) => {
|
|||
return new Promise((resolve) => {
|
||||
fetchLanguage(to).then(() => resolve);
|
||||
});
|
||||
}, { immediate: true, });
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
@ -62,7 +62,7 @@ watch(() => props.lang, (to) => {
|
|||
padding: 1em;
|
||||
margin: .5em 0;
|
||||
overflow: auto;
|
||||
border-radius: .3em;
|
||||
border-radius: 8px;
|
||||
|
||||
& pre,
|
||||
& code {
|
||||
|
|
|
@ -4,18 +4,26 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<Suspense>
|
||||
<Suspense>
|
||||
<template #fallback>
|
||||
<MkLoading v-if="!inline ?? true" />
|
||||
<MkLoading v-if="!inline ?? true"/>
|
||||
</template>
|
||||
<code v-if="inline" :class="$style.codeInlineRoot">{{ code }}</code>
|
||||
<XCode v-else :code="code" :lang="lang"/>
|
||||
</Suspense>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
import { defineAsyncComponent, ref } from 'vue';
|
||||
import MkLoading from '@/components/global/MkLoading.vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
defineProps<{
|
||||
code: string;
|
||||
|
@ -23,6 +31,8 @@ defineProps<{
|
|||
inline?: boolean;
|
||||
}>();
|
||||
|
||||
const show = ref(!defaultStore.state.dataSaver.code);
|
||||
|
||||
const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue'));
|
||||
</script>
|
||||
|
||||
|
@ -36,4 +46,27 @@ const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue'))
|
|||
padding: .1em;
|
||||
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>
|
||||
|
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div :class="[$style.codeEditorRoot, { [$style.disabled]: disabled, [$style.focused]: focused }]">
|
||||
<div :class="[$style.codeEditorRoot, { [$style.focused]: focused }]">
|
||||
<div :class="$style.codeEditorScroller">
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
|
|
|
@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onBeforeUnmount } from 'vue';
|
||||
import { onMounted, onBeforeUnmount, shallowRef, ref } from 'vue';
|
||||
import MkMenu from './MkMenu.vue';
|
||||
import { MenuItem } from './types/menu.vue';
|
||||
import contains from '@/scripts/contains.js';
|
||||
|
@ -34,9 +34,9 @@ const emit = defineEmits<{
|
|||
(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;
|
||||
|
||||
|
@ -44,8 +44,8 @@ onMounted(() => {
|
|||
let left = props.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
|
||||
let top = props.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
|
||||
|
||||
const width = rootEl.offsetWidth;
|
||||
const height = rootEl.offsetHeight;
|
||||
const width = rootEl.value.offsetWidth;
|
||||
const height = rootEl.value.offsetHeight;
|
||||
|
||||
if (left + width - window.pageXOffset >= (window.innerWidth - SCROLLBAR_THICKNESS)) {
|
||||
left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset;
|
||||
|
@ -63,8 +63,8 @@ onMounted(() => {
|
|||
left = 0;
|
||||
}
|
||||
|
||||
rootEl.style.top = `${top}px`;
|
||||
rootEl.style.left = `${left}px`;
|
||||
rootEl.value.style.top = `${top}px`;
|
||||
rootEl.value.style.left = `${left}px`;
|
||||
|
||||
document.body.addEventListener('mousedown', onMousedown);
|
||||
});
|
||||
|
@ -74,7 +74,7 @@ onBeforeUnmount(() => {
|
|||
});
|
||||
|
||||
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>
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { onMounted, shallowRef, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import Cropper from 'cropperjs';
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
@ -56,10 +56,10 @@ const props = defineProps<{
|
|||
}>();
|
||||
|
||||
const imgUrl = getProxiedImageUrl(props.file.url, undefined, true);
|
||||
let dialogEl = $shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
let imgEl = $shallowRef<HTMLImageElement>();
|
||||
const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
const imgEl = shallowRef<HTMLImageElement>();
|
||||
let cropper: Cropper | null = null;
|
||||
let loading = $ref(true);
|
||||
const loading = ref(true);
|
||||
|
||||
const ok = async () => {
|
||||
const promise = new Promise<Misskey.entities.DriveFile>(async (res) => {
|
||||
|
@ -94,16 +94,16 @@ const ok = async () => {
|
|||
const f = await promise;
|
||||
|
||||
emit('ok', f);
|
||||
dialogEl!.close();
|
||||
dialogEl.value!.close();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emit('cancel');
|
||||
dialogEl!.close();
|
||||
dialogEl.value!.close();
|
||||
};
|
||||
|
||||
const onImageLoad = () => {
|
||||
loading = false;
|
||||
loading.value = false;
|
||||
|
||||
if (cropper) {
|
||||
cropper.getCropperImage()!.$center('contain');
|
||||
|
@ -112,7 +112,7 @@ const onImageLoad = () => {
|
|||
};
|
||||
|
||||
onMounted(() => {
|
||||
cropper = new Cropper(imgEl!, {
|
||||
cropper = new Cropper(imgEl.value!, {
|
||||
});
|
||||
|
||||
const computedStyle = getComputedStyle(document.documentElement);
|
||||
|
|
|
@ -28,12 +28,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<header v-if="title" :class="$style.title"><Mfm :text="title"/></header>
|
||||
<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">
|
||||
<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template>
|
||||
<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-else-if="okButtonDisabled && disabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? '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="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })"/>
|
||||
</template>
|
||||
</MkInput>
|
||||
<MkSelect v-if="select" v-model="selectedValue" autofocus>
|
||||
|
@ -47,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
</MkSelect>
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="actions" :class="$style.buttons">
|
||||
|
@ -58,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<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 MkButton from '@/components/MkButton.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 selectedValue = ref(props.select?.default ?? null);
|
||||
const mkresult= ref(false)
|
||||
let disabledReason = $ref<null | 'charactersExceeded' | 'charactersBelow'>(null);
|
||||
const okButtonDisabled = $computed<boolean>(() => {
|
||||
const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'charactersBelow'>(() => {
|
||||
if (props.input) {
|
||||
if (props.input.minLength) {
|
||||
if ((inputValue.value || inputValue.value === '') && (inputValue.value as string).length < props.input.minLength) {
|
||||
disabledReason = 'charactersBelow';
|
||||
return true;
|
||||
return 'charactersBelow';
|
||||
}
|
||||
}
|
||||
if (props.input.maxLength) {
|
||||
if (inputValue.value && (inputValue.value as string).length > props.input.maxLength) {
|
||||
disabledReason = 'charactersExceeded';
|
||||
return true;
|
||||
return 'charactersExceeded';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return null;
|
||||
});
|
||||
|
||||
function done(canceled: boolean, result?) {
|
||||
|
|
|
@ -645,7 +645,7 @@ function getMenu() {
|
|||
type: 'switch',
|
||||
text: i18n.ts.keepOriginalUploading,
|
||||
ref: keepOriginal,
|
||||
}, null, {
|
||||
}, { type: 'divider' }, {
|
||||
text: i18n.ts.addFile,
|
||||
type: 'label',
|
||||
}, {
|
||||
|
@ -660,7 +660,7 @@ function getMenu() {
|
|||
action: () => {
|
||||
urlUpload();
|
||||
},
|
||||
}, null, {
|
||||
}, { type: 'divider' }, {
|
||||
text: folder.value ? folder.value.name : i18n.ts.drive,
|
||||
type: 'label',
|
||||
}, folder.value ? {
|
||||
|
|
|
@ -87,7 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch } from 'vue';
|
||||
import { computed, watch, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { DriveFile } from 'misskey-js/built/entities.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
|
@ -107,29 +107,28 @@ const props = defineProps<{
|
|||
isRequest: boolean,
|
||||
}>();
|
||||
|
||||
let dialog = $ref(null);
|
||||
let name: string = $ref(props.emoji ? props.emoji.name : '');
|
||||
let category: string = $ref(props.emoji ? props.emoji.category : '');
|
||||
let aliases: string = $ref(props.emoji ? props.emoji.aliases.join(' ') : '');
|
||||
let license: string = $ref(props.emoji ? (props.emoji.license ?? '') : '');
|
||||
let isSensitive = $ref(props.emoji ? props.emoji.isSensitive : false);
|
||||
let localOnly = $ref(props.emoji ? props.emoji.localOnly : false);
|
||||
let roleIdsThatCanBeUsedThisEmojiAsReaction = $ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []);
|
||||
let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]);
|
||||
let file = $ref<Misskey.entities.DriveFile>();
|
||||
let chooseFile: DriveFile|null = $ref(null);
|
||||
let draft = $ref(props.emoji ? props.emoji.draft : false);
|
||||
let isRequest = $ref(props.isRequest);
|
||||
let isNotifyIsHome = $ref(false);
|
||||
const dialog = ref(null);
|
||||
const name = ref<string>(props.emoji ? props.emoji.name : '');
|
||||
const category = ref<string>(props.emoji ? props.emoji.category : '');
|
||||
const aliases = ref<string>(props.emoji ? props.emoji.aliases.join(' ') : '');
|
||||
const license = ref<string>(props.emoji ? (props.emoji.license ?? '') : '');
|
||||
const isSensitive = ref(props.emoji ? props.emoji.isSensitive : false);
|
||||
const localOnly = ref(props.emoji ? props.emoji.localOnly : false);
|
||||
const roleIdsThatCanBeUsedThisEmojiAsReaction = ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []);
|
||||
const rolesThatCanBeUsedThisEmojiAsReaction = ref([]);
|
||||
const file = ref<Misskey.entities.DriveFile>();
|
||||
let chooseFile = ref(null);
|
||||
let draft = ref(props.emoji ? props.emoji.draft : false);
|
||||
let isRequest = ref(props.isRequest);
|
||||
let isNotifyIsHome = ref(false);
|
||||
let url;
|
||||
|
||||
watch($$(roleIdsThatCanBeUsedThisEmojiAsReaction), async () => {
|
||||
rolesThatCanBeUsedThisEmojiAsReaction = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
|
||||
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);
|
||||
}, { 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(() => {
|
||||
return name.match(/^[a-zA-Z0-9_]+$/) && imgUrl.value != null;
|
||||
return name.value.match(/^[a-zA-Z0-9_]+$/) && imgUrl.value != null;
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void,
|
||||
|
@ -138,7 +137,7 @@ const emit = defineEmits<{
|
|||
|
||||
function ok() {
|
||||
if (isRequest) {
|
||||
if (chooseFile !== null && name.match(/^[a-zA-Z0-9_]+$/)) {
|
||||
if (chooseFile.value !== null && name.value.match(/^[a-zA-Z0-9_]+$/)) {
|
||||
add();
|
||||
}
|
||||
} else {
|
||||
|
@ -150,87 +149,87 @@ async function add() {
|
|||
const ret = await os.api('admin/emoji/add-draft', {
|
||||
name: name,
|
||||
category: category,
|
||||
aliases: aliases.split(' '),
|
||||
license: license === '' ? null : license,
|
||||
fileId: chooseFile.id,
|
||||
aliases: aliases.value.split(' '),
|
||||
license: license.value === '' ? null : license.value,
|
||||
fileId: chooseFile.value.id,
|
||||
isNotifyIsHome: isNotifyIsHome,
|
||||
});
|
||||
|
||||
emit('done', {
|
||||
updated: {
|
||||
id: ret.id,
|
||||
id: ret.value.id,
|
||||
name,
|
||||
category,
|
||||
aliases: aliases.split(' '),
|
||||
license: license === '' ? null : license,
|
||||
aliases: aliases.value.split(' '),
|
||||
license: license.value === '' ? null : license,
|
||||
draft: true,
|
||||
},
|
||||
});
|
||||
|
||||
dialog.close();
|
||||
dialog.value.close();
|
||||
}
|
||||
async function changeImage(ev) {
|
||||
file = await selectFile(ev.currentTarget ?? ev.target, null);
|
||||
const candidate = file.name.replace(/\.(.+)$/, '');
|
||||
file.value = await selectFile(ev.currentTarget ?? ev.target, null);
|
||||
const candidate = file.value.name.replace(/\.(.+)$/, '');
|
||||
if (candidate.match(/^[a-z0-9_]+$/)) {
|
||||
name = candidate;
|
||||
name.value = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
async function addRole() {
|
||||
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({
|
||||
items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })),
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
rolesThatCanBeUsedThisEmojiAsReaction.push(role);
|
||||
rolesThatCanBeUsedThisEmojiAsReaction.value.push(role);
|
||||
}
|
||||
|
||||
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() {
|
||||
await os.apiWithDialog('admin/emoji/update', {
|
||||
id: props.emoji.id,
|
||||
name,
|
||||
category,
|
||||
aliases: aliases.split(' '),
|
||||
license: license === '' ? null : license,
|
||||
fileId: chooseFile?.id,
|
||||
name: name.value,
|
||||
category: category.value === '' ? null : category.value,
|
||||
aliases: aliases.value.split(' ').filter(x => x !== ''),
|
||||
license: license.value === '' ? null : license.value,
|
||||
fileId: chooseFile.value?.id,
|
||||
draft: draft,
|
||||
});
|
||||
|
||||
emit('done', {
|
||||
updated: {
|
||||
id: props.emoji.id,
|
||||
name,
|
||||
category,
|
||||
aliases: aliases.split(' '),
|
||||
license: license === '' ? null : license,
|
||||
draft: draft,
|
||||
name: name.value,
|
||||
category: category.value === '' ? null : category.value,
|
||||
aliases: aliases.value.split(' ').filter(x => x !== ''),
|
||||
license: license.value === '' ? null : license.value,
|
||||
draft: draft.value,
|
||||
},
|
||||
});
|
||||
|
||||
dialog.close();
|
||||
dialog.value.close();
|
||||
}
|
||||
async function done() {
|
||||
const params = {
|
||||
name,
|
||||
category: category === '' ? null : category,
|
||||
aliases: aliases.replace(' ', ' ').split(' ').filter(x => x !== ''),
|
||||
license: license === '' ? null : license,
|
||||
name: name.value,
|
||||
category: category.value === '' ? null : category.value,
|
||||
aliases: aliases.value.split(' ').filter(x => x !== ''),
|
||||
license: license.value === '' ? null : license.value,
|
||||
isSensitive: isSensitive.value,
|
||||
draft: draft,
|
||||
isSensitive,
|
||||
localOnly,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id),
|
||||
localOnly: localOnly.value,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.value.map(x => x.id),
|
||||
isNotifyIsHome,
|
||||
};
|
||||
|
||||
if (file) {
|
||||
params.fileId = file.id;
|
||||
if (file.value) {
|
||||
params.fileId = file.value.id;
|
||||
}
|
||||
console.log(props.emoji);
|
||||
if (props.emoji) {
|
||||
|
@ -246,7 +245,7 @@ async function done() {
|
|||
},
|
||||
});
|
||||
|
||||
dialog.close();
|
||||
dialog.value.close();
|
||||
} else {
|
||||
const created = isRequest
|
||||
? await os.apiWithDialog('admin/emoji/add-draft', params)
|
||||
|
@ -256,21 +255,21 @@ async function done() {
|
|||
created: created,
|
||||
});
|
||||
|
||||
dialog.close();
|
||||
dialog.value.close();
|
||||
}
|
||||
}
|
||||
|
||||
function chooseFileFrom(ev) {
|
||||
selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => {
|
||||
chooseFile = files_[0];
|
||||
url = chooseFile.url;
|
||||
chooseFile.value = files_[0];
|
||||
url = chooseFile.value.url;
|
||||
});
|
||||
}
|
||||
|
||||
async function del() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.t('removeAreYouSure', { x: name }),
|
||||
text: i18n.t('removeAreYouSure', { x: name.value }),
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
|
@ -280,7 +279,7 @@ async function del() {
|
|||
emit('done', {
|
||||
deleted: true,
|
||||
});
|
||||
dialog.close();
|
||||
dialog.value.close();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -4,76 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<!-- このコンポーネントの要素のclassは親から利用されるのでむやみに弄らないこと -->
|
||||
<section>
|
||||
|
||||
|
||||
<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>
|
||||
({{ emojis.length }})
|
||||
<!-- このコンポーネントの要素のclassは親から利用されるのでむやみに弄らないこと -->
|
||||
<!-- フォルダの中にはカスタム絵文字だけ(Unicode絵文字もこっち) -->
|
||||
<section v-if="!hasChildSection" v-panel style="border-radius: 6px; border-bottom: 0.5px solid var(--divider);">
|
||||
<header 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 }})
|
||||
</header>
|
||||
<header v-else-if="category.length === 1" class="_acrylic" @click="shown = !shown">
|
||||
<i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i>
|
||||
{{ category[0] }}
|
||||
({{
|
||||
emojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category[0]).length
|
||||
}})
|
||||
</header>
|
||||
<header v-else class="_acrylic" style="top:unset;" @click="toggleShown_fol">
|
||||
<i class="toggle ti-fw" :class="shown_fol? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i>
|
||||
{{ category[0] || i18n.ts.other }}
|
||||
<template v-if="category.length !== 1">
|
||||
({{ i18n.ts.Folder }})
|
||||
</template>
|
||||
<template v-else>
|
||||
({{
|
||||
emojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category[0]).length
|
||||
}}) 2
|
||||
</template>
|
||||
</header>
|
||||
<template v-for="(n, index) in category" v-if="shown_fol">
|
||||
<header
|
||||
v-if="emojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category[0]).length !== 0 || index!==0"
|
||||
style="top:unset;padding-left: 18px;"
|
||||
class="_acrylic"
|
||||
@click="toggleShown(index)"
|
||||
>
|
||||
<i class="toggle ti-fw" :class="shown_fold[index] ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i>
|
||||
{{ n || i18n.ts.other }}
|
||||
({{
|
||||
emojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === (index === 0 && category !== undefined ? category[0] : `${category[0]}/${n}`)).length
|
||||
}})
|
||||
</header>
|
||||
<div v-if="shown_fold[index]" 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">
|
||||
<div v-if="shown" class="body">
|
||||
<button
|
||||
v-for="emoji in emojis"
|
||||
:key="emoji"
|
||||
|
@ -86,19 +23,54 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</section>
|
||||
<!-- フォルダの中にはカスタム絵文字やフォルダがある -->
|
||||
<section v-else v-panel style="border-radius: 6px; border-bottom: 0.5px solid var(--divider);">
|
||||
<header 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-folder ti-fw"></i>:{{ customEmojiTree.length }} <i class="ti ti-icons ti-fw"></i>:{{ emojis.length }})
|
||||
</header>
|
||||
<div v-if="shown" style="padding-left: 9px;">
|
||||
<MkEmojiPickerSection
|
||||
v-for="child in customEmojiTree"
|
||||
:key="`custom:${child.value}`"
|
||||
:initialShown="initialShown"
|
||||
:emojis="computed(() => customEmojis.filter(e => e.category === child.category).map(e => `:${e.name}:`))"
|
||||
:hasChildSection="child.children.length !== 0"
|
||||
:customEmojiTree="child.children"
|
||||
@chosen="nestedChosen"
|
||||
>
|
||||
{{ child.value || i18n.ts.other }}
|
||||
</MkEmojiPickerSection>
|
||||
</div>
|
||||
<div v-if="shown" class="body">
|
||||
<button
|
||||
v-for="emoji in emojis"
|
||||
: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>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed, Ref} from 'vue';
|
||||
import {getEmojiName} from '@/scripts/emojilist.js';
|
||||
import {i18n} from "../i18n.js";
|
||||
import { ref, computed, Ref } from 'vue';
|
||||
import { CustomEmojiFolderTree, getEmojiName } from '@/scripts/emojilist.js';
|
||||
import { i18n } from '../i18n.js';
|
||||
import { customEmojis } from '@/custom-emojis.js';
|
||||
import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
category?: string[];
|
||||
emojis: string[] | Ref<string[]>;
|
||||
initialShown?: boolean;
|
||||
category?: string[];
|
||||
hasChildSection?: boolean;
|
||||
customEmojiTree?: CustomEmojiFolderTree[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
@ -129,4 +101,7 @@ function computeButtonTitle(ev: MouseEvent): void {
|
|||
elm.title = getEmojiName(emoji) ?? emoji;
|
||||
}
|
||||
|
||||
function nestedChosen(emoji: any, ev?: MouseEvent) {
|
||||
emit('chosen', emoji, ev);
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer, asWindow }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }">
|
||||
<div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer, asWindow }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }">
|
||||
<input ref="searchEl" :value="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" autocapitalize="off" @input="input()" @paste.stop="paste" @keydown.stop.prevent.enter="onEnter">
|
||||
<!-- FirefoxのTabフォーカスが想定外の挙動となるためtabindex="-1"を追加 https://github.com/misskey-dev/misskey/issues/10744 -->
|
||||
<div ref="emojisEl" class="emojis" tabindex="-1">
|
||||
|
@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</section>
|
||||
|
||||
<div v-if="tab === 'index'" class="group index">
|
||||
<section v-if="showPinned">
|
||||
<section v-if="showPinned && pinned.length > 0">
|
||||
<div class="body">
|
||||
<button
|
||||
v-for="emoji in pinned"
|
||||
|
@ -72,26 +72,21 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<div v-once class="group">
|
||||
<header class="_acrylic">{{ i18n.ts.customEmojis }}</header>
|
||||
|
||||
<XSection
|
||||
v-for="category in groupedData"
|
||||
:key="`custom:${category}`"
|
||||
v-for="child in customEmojiFolderRoot.children"
|
||||
:key="`custom:${child.value}`"
|
||||
:initialShown="false"
|
||||
:emojis="computed(() => customEmojis.filter(emoji => !emoji.draft).filter(filterAvailable))"
|
||||
:category="category"
|
||||
:emojis="computed(() => customEmojis.filter(e => child.value === '' ? (e.category === 'null' || !e.category) : e.category === child.value).filter(filterAvailable).map(e => `:${e.name}:`))"
|
||||
:hasChildSection="child.children.length !== 0"
|
||||
:customEmojiTree="child.children"
|
||||
@chosen="chosen"
|
||||
/>
|
||||
>
|
||||
{{ child.value || i18n.ts.other }}
|
||||
</XSection>
|
||||
</div>
|
||||
<div v-once class="group">
|
||||
<header class="_acrylic">{{ i18n.ts.emoji }}</header>
|
||||
<XSection
|
||||
v-for="category in categories"
|
||||
:key="category"
|
||||
:emojis="emojiCharByCategory.get(category) ?? []"
|
||||
@chosen="chosen"
|
||||
>{{ category }}
|
||||
</XSection>
|
||||
|
||||
<XSection v-for="category in categories" :key="category" :emojis="emojiCharByCategory.get(category) ?? []" :hasChildSection="false" @chosen="chosen">{{ category }}</XSection>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
|
@ -100,21 +95,28 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<button class="_button tab" :class="{ active: tab === 'unicode' }" @click="tab = 'unicode'"><i class="ti ti-leaf ti-fw"></i></button>
|
||||
<button class="_button tab" :class="{ active: tab === 'tags' }" @click="tab = 'tags'"><i class="ti ti-hash ti-fw"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, shallowRef, computed, watch, onMounted } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
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 * as os from '@/os.js';
|
||||
import { isTouchUsing } from '@/scripts/touch.js';
|
||||
import { deviceKind } from '@/scripts/device-kind.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import {customEmojiCategories, customEmojis, customEmojisMap} from '@/custom-emojis.js';
|
||||
import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-emojis.js';
|
||||
import { $i } from '@/account.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
|
@ -135,7 +137,7 @@ const searchEl = shallowRef<HTMLInputElement>();
|
|||
const emojisEl = shallowRef<HTMLDivElement>();
|
||||
|
||||
const {
|
||||
reactions: pinned,
|
||||
reactions: pinnedReactions,
|
||||
reactionPickerSize,
|
||||
reactionPickerWidth,
|
||||
reactionPickerHeight,
|
||||
|
@ -143,35 +145,44 @@ const {
|
|||
recentlyUsedEmojis,
|
||||
} = defaultStore.reactiveState;
|
||||
|
||||
const pinned = computed(() => props.asReactionPicker ? pinnedReactions.value : []); // TODO: 非リアクションの絵文字ピッカー用のpinned絵文字を設定可能にする?
|
||||
const size = computed(() => props.asReactionPicker ? reactionPickerSize.value : 1);
|
||||
const width = computed(() => props.asReactionPicker ? reactionPickerWidth.value : 3);
|
||||
const height = computed(() => props.asReactionPicker ? reactionPickerHeight.value : 2);
|
||||
const q = ref<string>('');
|
||||
const searchResultCustom = ref<Misskey.entities.CustomEmoji[]>([]);
|
||||
const searchResultCustom = ref<Misskey.entities.EmojiSimple[]>([]);
|
||||
const searchResultUnicode = ref<UnicodeEmojiDef[]>([]);
|
||||
const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index');
|
||||
let split_categories = [];
|
||||
customEmojiCategories.value.forEach(e => {
|
||||
if (e !== null){
|
||||
split_categories.push(e.split('/'))
|
||||
|
||||
const customEmojiFolderRoot: CustomEmojiFolderTree = { value: "", category: "", children: [] };
|
||||
|
||||
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 = {};
|
||||
split_categories.forEach((item) => {
|
||||
const key = item[0];
|
||||
parseAndMergeCategories('', customEmojiFolderRoot);
|
||||
|
||||
if (!groupedData[key]) {
|
||||
groupedData[key] = [];
|
||||
groupedData[key].push(item[0]);
|
||||
}
|
||||
if (item[1] !== undefined){
|
||||
groupedData[key].push(item[1]);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
console.log(groupedData)
|
||||
watch(q, () => {
|
||||
if (emojisEl.value) emojisEl.value.scrollTop = 0;
|
||||
|
||||
|
@ -185,8 +196,8 @@ watch(q, () => {
|
|||
|
||||
const searchCustom = () => {
|
||||
const max = 100;
|
||||
const emojis = customEmojis.value.filter(emoji => !emoji.draft);
|
||||
const matches = new Set<Misskey.entities.CustomEmoji>();
|
||||
const emojis = customEmojis.value;
|
||||
const matches = new Set<Misskey.entities.EmojiSimple>();
|
||||
|
||||
const exactMatch = emojis.find(emoji => emoji.name === newQ);
|
||||
if (exactMatch) matches.add(exactMatch);
|
||||
|
@ -316,7 +327,7 @@ watch(q, () => {
|
|||
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)));
|
||||
}
|
||||
|
||||
|
@ -333,7 +344,7 @@ function reset() {
|
|||
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}:`;
|
||||
}
|
||||
|
||||
|
@ -585,8 +596,8 @@ defineExpose({
|
|||
|
||||
> header {
|
||||
/*position: sticky;
|
||||
top: 0;
|
||||
left: 0;*/
|
||||
top: 0;
|
||||
left: 0;*/
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
z-index: 2;
|
||||
|
|
|
@ -36,15 +36,17 @@ import MkModal from '@/components/MkModal.vue';
|
|||
import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
withDefaults(defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
manualShowing?: boolean | null;
|
||||
src?: HTMLElement;
|
||||
showPinned?: boolean;
|
||||
asReactionPicker?: boolean;
|
||||
choseAndClose?: boolean;
|
||||
}>(), {
|
||||
manualShowing: null,
|
||||
showPinned: true,
|
||||
asReactionPicker: false,
|
||||
choseAndClose: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
@ -58,7 +60,9 @@ const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>();
|
|||
|
||||
function chosen(emoji: any) {
|
||||
emit('done', emoji);
|
||||
if (props.choseAndClose) {
|
||||
modal.value?.close();
|
||||
}
|
||||
}
|
||||
|
||||
function opening() {
|
||||
|
|
|
@ -12,7 +12,7 @@ import { ref } from 'vue';
|
|||
import * as Misskey from 'misskey-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 => {
|
||||
meta.value = gotMeta;
|
||||
|
|
|
@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import { shallowRef, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
|
@ -42,12 +42,12 @@ const emit = defineEmits<{
|
|||
(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() {
|
||||
emit('done', caption);
|
||||
dialog.close();
|
||||
emit('done', caption.value);
|
||||
dialog.value.close();
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted } from 'vue';
|
||||
import { nextTick, onMounted, shallowRef, ref } from 'vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
|
@ -70,10 +70,10 @@ const getBgColor = (el: HTMLElement) => {
|
|||
}
|
||||
};
|
||||
|
||||
let rootEl = $shallowRef<HTMLElement>();
|
||||
let bgSame = $ref(false);
|
||||
let opened = $ref(props.defaultOpen);
|
||||
let openedAtLeastOnce = $ref(props.defaultOpen);
|
||||
const rootEl = shallowRef<HTMLElement>();
|
||||
const bgSame = ref(false);
|
||||
const opened = ref(props.defaultOpen);
|
||||
const openedAtLeastOnce = ref(props.defaultOpen);
|
||||
|
||||
function enter(el) {
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
|
@ -98,20 +98,20 @@ function afterLeave(el) {
|
|||
}
|
||||
|
||||
function toggle() {
|
||||
if (!opened) {
|
||||
openedAtLeastOnce = true;
|
||||
if (!opened.value) {
|
||||
openedAtLeastOnce.value = true;
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
opened = !opened;
|
||||
opened.value = !opened.value;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const computedStyle = getComputedStyle(document.documentElement);
|
||||
const parentBg = getBgColor(rootEl.parentElement);
|
||||
const parentBg = getBgColor(rootEl.value.parentElement);
|
||||
const myBg = computedStyle.getPropertyValue('--panel');
|
||||
bgSame = parentBg === myBg;
|
||||
bgSame.value = parentBg === myBg;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -79,9 +79,9 @@ const emit = defineEmits<{
|
|||
(_: 'update:user', value: Misskey.entities.UserDetailed): void
|
||||
}>();
|
||||
|
||||
let isFollowing = $ref(props.user.isFollowing);
|
||||
let hasPendingFollowRequestFromYou = $ref(props.user.hasPendingFollowRequestFromYou);
|
||||
let wait = $ref(false);
|
||||
const isFollowing = ref(props.user.isFollowing);
|
||||
const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFromYou);
|
||||
const wait = ref(false);
|
||||
const connection = useStream().useChannel('main');
|
||||
|
||||
if (props.user.isFollowing == null) {
|
||||
|
@ -93,16 +93,16 @@ if (props.user.isFollowing == null) {
|
|||
|
||||
function onFollowChange(user: Misskey.entities.UserDetailed) {
|
||||
if (user.id === props.user.id) {
|
||||
isFollowing = user.isFollowing;
|
||||
hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou;
|
||||
isFollowing.value = user.isFollowing;
|
||||
hasPendingFollowRequestFromYou.value = user.hasPendingFollowRequestFromYou;
|
||||
}
|
||||
}
|
||||
|
||||
async function onClick() {
|
||||
wait = true;
|
||||
wait.value = true;
|
||||
|
||||
try {
|
||||
if (isFollowing) {
|
||||
if (isFollowing.value) {
|
||||
const {canceled} = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.t('unfollowConfirm', {name: props.user.name || props.user.username}),
|
||||
|
@ -114,11 +114,11 @@ async function onClick() {
|
|||
userId: props.user.id,
|
||||
});
|
||||
} else {
|
||||
if (hasPendingFollowRequestFromYou) {
|
||||
if (hasPendingFollowRequestFromYou.value) {
|
||||
await os.api('following/requests/cancel', {
|
||||
userId: props.user.id,
|
||||
});
|
||||
hasPendingFollowRequestFromYou = false;
|
||||
hasPendingFollowRequestFromYou.value = false;
|
||||
} else {
|
||||
await os.api('following/create', {
|
||||
userId: props.user.id,
|
||||
|
@ -126,8 +126,9 @@ async function onClick() {
|
|||
});
|
||||
emit('update:user', {
|
||||
...props.user,
|
||||
withReplies: defaultStore.state.defaultWithReplies});
|
||||
hasPendingFollowRequestFromYou = true;
|
||||
withReplies: defaultStore.state.defaultWithReplies,
|
||||
});
|
||||
hasPendingFollowRequestFromYou.value = true;
|
||||
|
||||
claimAchievement('following1');
|
||||
|
||||
|
@ -148,7 +149,7 @@ async function onClick() {
|
|||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
wait = false;
|
||||
wait.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
|
@ -53,19 +53,19 @@ const emit = defineEmits<{
|
|||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
let dialog: InstanceType<typeof MkModalWindow> = $ref();
|
||||
const dialog = ref<InstanceType<typeof MkModalWindow>>();
|
||||
|
||||
let username = $ref('');
|
||||
let email = $ref('');
|
||||
let processing = $ref(false);
|
||||
const username = ref('');
|
||||
const email = ref('');
|
||||
const processing = ref(false);
|
||||
|
||||
async function onSubmit() {
|
||||
processing = true;
|
||||
processing.value = true;
|
||||
await os.apiWithDialog('request-reset-password', {
|
||||
username,
|
||||
email,
|
||||
username: username.value,
|
||||
email: email.value,
|
||||
});
|
||||
emit('done');
|
||||
dialog.close();
|
||||
dialog.value.close();
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -23,7 +23,7 @@ const query = ref(props.q);
|
|||
const search = () => {
|
||||
const sp = new URLSearchParams();
|
||||
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>
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, nextTick, watch } from 'vue';
|
||||
import { onMounted, nextTick, watch, shallowRef, ref } from 'vue';
|
||||
import { Chart } from 'chart.js';
|
||||
import * as os from '@/os.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
@ -27,11 +27,11 @@ const props = defineProps<{
|
|||
src: string;
|
||||
}>();
|
||||
|
||||
const rootEl = $shallowRef<HTMLDivElement>(null);
|
||||
const chartEl = $shallowRef<HTMLCanvasElement>(null);
|
||||
const rootEl = shallowRef<HTMLDivElement>(null);
|
||||
const chartEl = shallowRef<HTMLCanvasElement>(null);
|
||||
const now = new Date();
|
||||
let chartInstance: Chart = null;
|
||||
let fetching = $ref(true);
|
||||
const fetching = ref(true);
|
||||
|
||||
const { handler: externalTooltipHandler } = useChartTooltip({
|
||||
position: 'middle',
|
||||
|
@ -42,8 +42,8 @@ async function renderChart() {
|
|||
chartInstance.destroy();
|
||||
}
|
||||
|
||||
const wide = rootEl.offsetWidth > 700;
|
||||
const narrow = rootEl.offsetWidth < 400;
|
||||
const wide = rootEl.value.offsetWidth > 700;
|
||||
const narrow = rootEl.value.offsetWidth < 400;
|
||||
|
||||
const weeks = wide ? 50 : narrow ? 10 : 25;
|
||||
const chartLimit = 7 * weeks;
|
||||
|
@ -88,7 +88,7 @@ async function renderChart() {
|
|||
values = raw.deliverFailed;
|
||||
}
|
||||
|
||||
fetching = false;
|
||||
fetching.value = false;
|
||||
|
||||
await nextTick();
|
||||
|
||||
|
@ -101,7 +101,7 @@ async function renderChart() {
|
|||
|
||||
const marginEachCell = 4;
|
||||
|
||||
chartInstance = new Chart(chartEl, {
|
||||
chartInstance = new Chart(chartEl.value, {
|
||||
type: 'matrix',
|
||||
data: {
|
||||
datasets: [{
|
||||
|
@ -210,7 +210,7 @@ async function renderChart() {
|
|||
}
|
||||
|
||||
watch(() => props.src, () => {
|
||||
fetching = true;
|
||||
fetching.value = true;
|
||||
renderChart();
|
||||
});
|
||||
|
||||
|
|
|
@ -21,7 +21,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { $ref } from 'vue/macros';
|
||||
import DrawBlurhash from '@/workers/draw-blurhash?worker';
|
||||
import TestWebGL2 from '@/workers/test-webgl2?worker';
|
||||
import { WorkerMultiDispatch } from '@/scripts/worker-multi-dispatch.js';
|
||||
|
@ -58,7 +57,7 @@ const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resol
|
|||
</script>
|
||||
|
||||
<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 { render } from 'buraha';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
@ -98,41 +97,41 @@ const viewId = uuid();
|
|||
const canvas = shallowRef<HTMLCanvasElement>();
|
||||
const root = shallowRef<HTMLDivElement>();
|
||||
const img = shallowRef<HTMLImageElement>();
|
||||
let loaded = $ref(false);
|
||||
let canvasWidth = $ref(64);
|
||||
let canvasHeight = $ref(64);
|
||||
let imgWidth = $ref(props.width);
|
||||
let imgHeight = $ref(props.height);
|
||||
let bitmapTmp = $ref<CanvasImageSource | undefined>();
|
||||
const hide = computed(() => !loaded || props.forceBlurhash);
|
||||
const loaded = ref(false);
|
||||
const canvasWidth = ref(64);
|
||||
const canvasHeight = ref(64);
|
||||
const imgWidth = ref(props.width);
|
||||
const imgHeight = ref(props.height);
|
||||
const bitmapTmp = ref<CanvasImageSource | undefined>();
|
||||
const hide = computed(() => !loaded.value || props.forceBlurhash);
|
||||
|
||||
function waitForDecode() {
|
||||
if (props.src != null && props.src !== '') {
|
||||
nextTick()
|
||||
.then(() => img.value?.decode())
|
||||
.then(() => {
|
||||
loaded = true;
|
||||
loaded.value = true;
|
||||
}, error => {
|
||||
console.log('Error occurred during decoding image', img.value, error);
|
||||
});
|
||||
} else {
|
||||
loaded = false;
|
||||
loaded.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => props.width, () => props.height, root], () => {
|
||||
const ratio = props.width / props.height;
|
||||
if (ratio > 1) {
|
||||
canvasWidth = Math.round(64 * ratio);
|
||||
canvasHeight = 64;
|
||||
canvasWidth.value = Math.round(64 * ratio);
|
||||
canvasHeight.value = 64;
|
||||
} else {
|
||||
canvasWidth = 64;
|
||||
canvasHeight = Math.round(64 / ratio);
|
||||
canvasWidth.value = 64;
|
||||
canvasHeight.value = Math.round(64 / ratio);
|
||||
}
|
||||
|
||||
const clientWidth = root.value?.clientWidth ?? 300;
|
||||
imgWidth = clientWidth;
|
||||
imgHeight = Math.round(clientWidth / ratio);
|
||||
imgWidth.value = clientWidth;
|
||||
imgHeight.value = Math.round(clientWidth / ratio);
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
|
@ -140,15 +139,15 @@ watch([() => props.width, () => props.height, root], () => {
|
|||
function drawImage(bitmap: CanvasImageSource) {
|
||||
// canvasがない(mountedされていない)場合はTmpに保存しておく
|
||||
if (!canvas.value) {
|
||||
bitmapTmp = bitmap;
|
||||
bitmapTmp.value = bitmap;
|
||||
return;
|
||||
}
|
||||
|
||||
// canvasがあれば描画する
|
||||
bitmapTmp = undefined;
|
||||
bitmapTmp.value = undefined;
|
||||
const ctx = canvas.value.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(bitmap, 0, 0, canvasWidth, canvasHeight);
|
||||
ctx.drawImage(bitmap, 0, 0, canvasWidth.value, canvasHeight.value);
|
||||
}
|
||||
|
||||
function drawAvg() {
|
||||
|
@ -160,7 +159,7 @@ function drawAvg() {
|
|||
// avgColorでお茶をにごす
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = extractAvgColorFromBlurhash(props.hash) ?? '#888';
|
||||
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
|
||||
}
|
||||
|
||||
async function draw() {
|
||||
|
@ -212,8 +211,8 @@ watch(() => props.hash, () => {
|
|||
|
||||
onMounted(() => {
|
||||
// drawImageがmountedより先に呼ばれている場合はここで描画する
|
||||
if (bitmapTmp) {
|
||||
drawImage(bitmapTmp);
|
||||
if (bitmapTmp.value) {
|
||||
drawImage(bitmapTmp.value);
|
||||
}
|
||||
waitForDecode();
|
||||
});
|
||||
|
|
|
@ -15,21 +15,22 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkMiniChart from '@/components/MkMiniChart.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
|
||||
|
||||
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 => {
|
||||
// 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く
|
||||
res.requests.received.splice(0, 1);
|
||||
chartValues = res.requests.received;
|
||||
res['requests.received'].splice(0, 1);
|
||||
chartValues.value = res['requests.received'];
|
||||
});
|
||||
|
||||
function getInstanceIcon(instance): string {
|
||||
|
|
|
@ -84,7 +84,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { onMounted, ref, shallowRef } from 'vue';
|
||||
import { Chart } from 'chart.js';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkChart from '@/components/MkChart.vue';
|
||||
|
@ -100,11 +100,11 @@ import { initChart } from '@/scripts/init-chart.js';
|
|||
initChart();
|
||||
|
||||
const chartLimit = 500;
|
||||
let chartSpan = $ref<'hour' | 'day'>('hour');
|
||||
let chartSrc = $ref('active-users');
|
||||
let heatmapSrc = $ref('active-users');
|
||||
let subDoughnutEl = $shallowRef<HTMLCanvasElement>();
|
||||
let pubDoughnutEl = $shallowRef<HTMLCanvasElement>();
|
||||
const chartSpan = ref<'hour' | 'day'>('hour');
|
||||
const chartSrc = ref('active-users');
|
||||
const heatmapSrc = ref('active-users');
|
||||
const subDoughnutEl = shallowRef<HTMLCanvasElement>();
|
||||
const pubDoughnutEl = shallowRef<HTMLCanvasElement>();
|
||||
|
||||
const { handler: externalTooltipHandler1 } = useChartTooltip({
|
||||
position: 'middle',
|
||||
|
@ -163,7 +163,7 @@ function createDoughnut(chartEl, tooltip, data) {
|
|||
|
||||
onMounted(() => {
|
||||
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,
|
||||
color: x.themeColor,
|
||||
value: x.followersCount,
|
||||
|
@ -172,7 +172,7 @@ onMounted(() => {
|
|||
},
|
||||
})).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,
|
||||
color: x.themeColor,
|
||||
value: x.followingCount,
|
||||
|
|
|
@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { instanceName } from '@/config';
|
||||
import { instance as Instance } from '@/instance';
|
||||
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,
|
||||
};
|
||||
|
||||
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';
|
||||
|
||||
|
|
|
@ -67,7 +67,7 @@ import { i18n } from '@/i18n.js';
|
|||
import * as os from '@/os.js';
|
||||
|
||||
const props = defineProps<{
|
||||
invite: Misskey.entities.Invite;
|
||||
invite: Misskey.entities.InviteCode;
|
||||
moderator?: boolean;
|
||||
}>();
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { ref , computed , watch} from 'vue';
|
||||
import { shallowRef } from 'vue';
|
||||
import MkModal from '@/components/MkModal.vue';
|
||||
import { navbarItemDef } from '@/navbar.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
@ -51,7 +52,7 @@ const preferedModalType = (deviceKind === 'desktop' && props.src != null) ? 'pop
|
|||
deviceKind === 'smartphone' ? 'drawer' :
|
||||
'dialog';
|
||||
|
||||
const modal = $shallowRef<InstanceType<typeof MkModal>>();
|
||||
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||
|
||||
const menu = defaultStore.state.menu;
|
||||
|
||||
|
@ -66,7 +67,7 @@ const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k =>
|
|||
}));
|
||||
|
||||
function close() {
|
||||
modal.close();
|
||||
modal.value.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -118,6 +119,9 @@ function close() {
|
|||
animation: AnimationLight var(--gamingspeed) cubic-bezier(0, 0.2, 0.90, 1) infinite !important;
|
||||
color: white;
|
||||
}
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent);
|
||||
background: var(--accentedBg);
|
||||
|
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<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"
|
||||
>
|
||||
<slot></slot>
|
||||
|
@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
import { defineAsyncComponent, ref } from 'vue';
|
||||
import { url as local } from '@/config.js';
|
||||
import { useTooltip } from '@/scripts/use-tooltip.js';
|
||||
import * as os from '@/os.js';
|
||||
|
@ -29,13 +29,13 @@ const self = props.url.startsWith(local);
|
|||
const attr = self ? 'to' : 'href';
|
||||
const target = self ? null : '_blank';
|
||||
|
||||
const el = $ref();
|
||||
const el = ref();
|
||||
|
||||
useTooltip($$(el), (showing) => {
|
||||
useTooltip(el, (showing) => {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
|
||||
showing,
|
||||
url: props.url,
|
||||
source: el,
|
||||
source: el.value,
|
||||
}, {}, 'closed');
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, shallowRef, watch } from 'vue';
|
||||
import { onMounted, shallowRef, watch, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
|
@ -42,7 +42,7 @@ const props = withDefaults(defineProps<{
|
|||
});
|
||||
|
||||
const audioEl = shallowRef<HTMLAudioElement>();
|
||||
let hide = $ref(true);
|
||||
const hide = ref(true);
|
||||
|
||||
watch(audioEl, () => {
|
||||
if (audioEl.value) {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue