Merge tag '2023.12.0-beta.3' into merge-upstream

This commit is contained in:
riku6460 2023-12-10 08:58:57 +09:00
commit e77ddfce91
No known key found for this signature in database
GPG Key ID: 27414FA27DB94CF6
423 changed files with 42868 additions and 10814 deletions

View File

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

View File

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

29
.github/labeler.yml vendored
View File

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

View File

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

View File

@ -6,6 +6,7 @@
### Client ### Client
- Fix: ページ一覧ページの表示がモバイル環境において崩れているのを修正 - Fix: ページ一覧ページの表示がモバイル環境において崩れているのを修正
- Fix: MFMでルビの中のテキストがnyaizeされない問題を修正
### Server ### Server
- -
@ -21,23 +22,54 @@
- Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正 - Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正
### Client ### Client
- Feat: 今日誕生日のフォロー中のユーザーを一覧表示できるウィジェットを追加
- Feat: データセーバーでコードハイライトの読み込みを削減できるように
- Enhance: 投稿フォームの絵文字ピッカーをリアクション時に使用するものと同じのを使用するように #12336
- Enhance: 絵文字のオートコンプリート機能強化 #12364 - Enhance: 絵文字のオートコンプリート機能強化 #12364
- Enhance: ユーザーのRawデータを表示するページが復活 - Enhance: ユーザーのRawデータを表示するページが復活
- Enhance: リアクション選択時に音を鳴らせるように
- Enhance: サウンドにドライブのファイルを使用できるように
- Enhance: ナビゲーションバーに項目「キャッシュを削除」を追加
- Enhance: Shareページで投稿を完了すると、親ウィンドウ親フレームにpostMessageするように
- Enhance: チャンネル、クリップ、ページ、Play、ギャラリーにURLのコピーボタンを設置 #11305
- Enhance: ノートプレビューに「内容を隠す」が反映されるように
- Enhance: データセーバーの適用範囲を個別で設定できるように
- 従来のデータセーバーの設定はリセットされます
- Feat: センシティブと判断されたウェブサイトのサムネイルをぼかすように
- ウェブサイトをセンシティブと判断する仕組みが動いていないため、summalyProxyを使用しないと機能しません。
- fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正 - fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正
- Fix: ウィジェットのジョブキューにて音声の発音方法変更に追従できていなかったのを修正 #12367 - Fix: ウィジェットのジョブキューにて音声の発音方法変更に追従できていなかったのを修正 #12367
- Enhance: 絵文字の詳細ページに記載される情報を追加
- Fix: コードエディタが正しく表示されない問題を修正 - Fix: コードエディタが正しく表示されない問題を修正
- Fix: プロフィールの「ファイル」にセンシティブな画像がある際のデザインを修正 - Fix: プロフィールの「ファイル」にセンシティブな画像がある際のデザインを修正
- Fix: 一度に大量の通知が入った際に通知音が音割れする問題を修正
- Fix: 共有機能をサポートしていないブラウザの場合は共有ボタンを非表示にする #11305
- Fix: 通知のグルーピング設定を変更してもリロードされるまで表示が変わらない問題を修正 #12470
- Fix: 長い名前のチャンネルにおける投稿フォームの表示が崩れる問題を修正
- Fix: セキュリティ向上のためAiScriptの`Mk:apiExternal`を無効化
### Server ### Server
- Enhance: MFM `$[ruby ]` が他ソフトウェアと連合されるように - Enhance: MFM `$[ruby ]` が他ソフトウェアと連合されるように
- Enhance: Meilisearchを有効にした検索で、ユーザーのミュートやブロックを考慮するように
- Fix: 時間経過により無効化されたアンテナを再有効化したとき、サーバ再起動までその状況が反映されないのを修正 #12303 - Fix: 時間経過により無効化されたアンテナを再有効化したとき、サーバ再起動までその状況が反映されないのを修正 #12303
- Fix: ロールタイムラインが保存されない問題を修正 - Fix: ロールタイムラインが保存されない問題を修正
- Fix: api.jsonの生成ロジックを改善 #12402 - Fix: api.jsonの生成ロジックを改善 #12402
- Fix: 招待コードが使い回せる問題を修正 - Fix: 招待コードが使い回せる問題を修正
- Fix: 特定の条件下でチャンネルやユーザーのノート一覧に最新のノートが表示されなくなる問題を修正 - Fix: 特定の条件下でチャンネルやユーザーのノート一覧に最新のノートが表示されなくなる問題を修正
- Fix: 何もノートしていないユーザーのフィードにアクセスするとエラーになる問題を修正
- Fix: リストタイムラインにてミュートが機能しないケースがある問題と、チャンネル投稿がストリーミングで流れてきてしまう問題を修正 #10443
- Fix: 「みつける」のなかにミュートしたユーザが現れてしまう問題を修正 #12383
- Fix: Social/Local/Home Timelineにてインスタンスミュートが効かない問題
- Fix: ユーザのノート一覧にてインスタンスミュートが効かない問題
- Fix: チャンネルのノート一覧にてインスタンスミュートが効かない問題
- Fix: 「みつける」が年越し時に壊れる問題を修正
- Fix: アカウントをブロックした際に、自身のユーザーのページでノートが相手に表示される問題を修正
## 2023.11.1 ## 2023.11.1
### Note
- 悪意のある第三者がリモートユーザーになりすました任意のアクティビティを受け取れてしまう問題を修正しました。詳しくは[GitHub security advisory](https://github.com/misskey-dev/misskey/security/advisories/GHSA-3f39-6537-3cgc)をご覧ください。
### General ### General
- Feat: 管理者がコントロールパネルからメールアドレスの照会を行えるようになりました - Feat: 管理者がコントロールパネルからメールアドレスの照会を行えるようになりました
- Enhance: ローカリゼーションの更新 - Enhance: ローカリゼーションの更新
@ -51,6 +83,7 @@
- 例: `$[unixtime 1701356400]` - 例: `$[unixtime 1701356400]`
- Enhance: プラグインでエラーが発生した場合のハンドリングを強化 - Enhance: プラグインでエラーが発生した場合のハンドリングを強化
- Enhance: 細かなUIのブラッシュアップ - Enhance: 細かなUIのブラッシュアップ
- Enhance: サウンド設定に「サウンドを出力しない」と「Misskeyがアクティブな時のみサウンドを出力する」を追加
- Fix: 効果音が再生されるとデバイスで再生している動画や音声が停止する問題を修正 #12339 - Fix: 効果音が再生されるとデバイスで再生している動画や音声が停止する問題を修正 #12339
- Fix: デッキに表示されたチャンネルの表示先チャンネルを切り替えた際、即座に反映されない問題を修正 #12236 - Fix: デッキに表示されたチャンネルの表示先チャンネルを切り替えた際、即座に反映されない問題を修正 #12236
- Fix: プラグインでノートの表示を書き換えられない問題を修正 - Fix: プラグインでノートの表示を書き換えられない問題を修正
@ -160,6 +193,7 @@
### Client ### Client
- Enhance: TLの返信表示オプションを記憶するように - Enhance: TLの返信表示オプションを記憶するように
- Enhance: 投稿されてから時間が経過しているノートであることを視覚的に分かりやすく - Enhance: 投稿されてから時間が経過しているノートであることを視覚的に分かりやすく
- Feat: 絵文字ピッカーのカテゴリに「/」を入れることでフォルダ分け表示できるように
### Server ### Server
- Enhance: タイムライン取得時のパフォーマンスを向上 - Enhance: タイムライン取得時のパフォーマンスを向上

View File

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

View File

@ -73,8 +73,8 @@ RUN apt-get update \
&& corepack enable \ && corepack enable \
&& groupadd -g "${GID}" misskey \ && groupadd -g "${GID}" misskey \
&& useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey \ && useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey \
&& find / -type d -path /proc -prune -o -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \ && find / -type d -path /sys -prune -o -type d -path /proc -prune -o -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \
&& find / -type d -path /proc -prune -o -type f -perm /g+s -ignore_readdir_race -exec chmod g-s {} \; \ && find / -type d -path /sys -prune -o -type d -path /proc -prune -o -type f -perm /g+s -ignore_readdir_race -exec chmod g-s {} \; \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists && rm -rf /var/lib/apt/lists

View File

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

View File

@ -56,6 +56,18 @@ export default function generateDTS() {
ts.NodeFlags.Const | ts.NodeFlags.Ambient | ts.NodeFlags.ContextFlags, ts.NodeFlags.Const | ts.NodeFlags.Ambient | ts.NodeFlags.ContextFlags,
), ),
), ),
ts.factory.createFunctionDeclaration(
[ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
undefined,
ts.factory.createIdentifier('build'),
undefined,
[],
ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier('Locale'),
undefined,
),
undefined,
),
ts.factory.createExportDefault(ts.factory.createIdentifier('locales')), ts.factory.createExportDefault(ts.factory.createIdentifier('locales')),
]; ];
const printed = ts.createPrinter({ const printed = ts.createPrinter({

36
locales/index.d.ts vendored
View File

@ -442,7 +442,6 @@ export interface Locale {
"notFound": string; "notFound": string;
"notFoundDescription": string; "notFoundDescription": string;
"uploadFolder": string; "uploadFolder": string;
"cacheClear": string;
"markAsReadAllNotifications": string; "markAsReadAllNotifications": string;
"markAsReadAllUnreadNotes": string; "markAsReadAllUnreadNotes": string;
"markAsReadAllTalkMessages": string; "markAsReadAllTalkMessages": string;
@ -549,6 +548,8 @@ export interface Locale {
"popout": string; "popout": string;
"volume": string; "volume": string;
"masterVolume": string; "masterVolume": string;
"notUseSound": string;
"useSoundOnlyWhenActive": string;
"details": string; "details": string;
"chooseEmoji": string; "chooseEmoji": string;
"unableToProcess": string; "unableToProcess": string;
@ -1029,6 +1030,8 @@ export interface Locale {
"sensitiveWords": string; "sensitiveWords": string;
"sensitiveWordsDescription": string; "sensitiveWordsDescription": string;
"sensitiveWordsDescription2": string; "sensitiveWordsDescription2": string;
"hiddenTags": string;
"hiddenTagsDescription": string;
"notesSearchNotAvailable": string; "notesSearchNotAvailable": string;
"license": string; "license": string;
"unfavoriteConfirm": string; "unfavoriteConfirm": string;
@ -1172,6 +1175,8 @@ export interface Locale {
"doReaction": string; "doReaction": string;
"urlPreviewDenyList": string; "urlPreviewDenyList": string;
"urlPreviewDenyListDescription": string; "urlPreviewDenyListDescription": string;
"code": string;
"reloadRequiredToApplySettings": string;
"_announcement": { "_announcement": {
"forExistingUsers": string; "forExistingUsers": string;
"forExistingUsersDescription": string; "forExistingUsersDescription": string;
@ -1952,6 +1957,15 @@ export interface Locale {
"notification": string; "notification": string;
"antenna": string; "antenna": string;
"channel": string; "channel": string;
"reaction": string;
};
"_soundSettings": {
"driveFile": string;
"driveFileWarn": string;
"driveFileTypeWarn": string;
"driveFileTypeWarnDescription": string;
"driveFileDurationWarn": string;
"driveFileDurationWarnDescription": string;
}; };
"_ago": { "_ago": {
"future": string; "future": string;
@ -2106,6 +2120,7 @@ export interface Locale {
"chooseList": string; "chooseList": string;
}; };
"clicker": string; "clicker": string;
"birthdayFollowings": string;
}; };
"_cw": { "_cw": {
"hide": string; "hide": string;
@ -2518,8 +2533,27 @@ export interface Locale {
}; };
}; };
}; };
"_dataSaver": {
"_media": {
"title": string;
"description": string;
};
"_avatar": {
"title": string;
"description": string;
};
"_urlPreview": {
"title": string;
"description": string;
};
"_code": {
"title": string;
"description": string;
};
};
} }
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;
}; };
export function build(): Locale;
export default locales; export default locales;

View File

@ -51,33 +51,37 @@ const primaries = {
// 何故か文字列にバックスペース文字が混入することがあり、YAMLが壊れるので取り除く // 何故か文字列にバックスペース文字が混入することがあり、YAMLが壊れるので取り除く
const clean = (text) => text.replace(new RegExp(String.fromCodePoint(0x08), 'g'), ''); const clean = (text) => text.replace(new RegExp(String.fromCodePoint(0x08), 'g'), '');
const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(new URL(`${c}.yml`, import.meta.url), 'utf-8'))) || {}, a), {}); export function build() {
const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(new URL(`${c}.yml`, import.meta.url), 'utf-8'))) || {}, a), {});
// 空文字列が入ることがあり、フォールバックが動作しなくなるのでプロパティごと消す // 空文字列が入ることがあり、フォールバックが動作しなくなるのでプロパティごと消す
const removeEmpty = (obj) => { const removeEmpty = (obj) => {
for (const [k, v] of Object.entries(obj)) { for (const [k, v] of Object.entries(obj)) {
if (v === '') { if (v === '') {
delete obj[k]; delete obj[k];
} else if (typeof v === 'object') { } else if (typeof v === 'object') {
removeEmpty(v); removeEmpty(v);
}
} }
} return obj;
return obj; };
}; removeEmpty(locales);
removeEmpty(locales);
export default Object.entries(locales) return Object.entries(locales)
.reduce((a, [k ,v]) => (a[k] = (() => { .reduce((a, [k, v]) => (a[k] = (() => {
const [lang] = k.split('-'); const [lang] = k.split('-');
switch (k) { switch (k) {
case 'ja-JP': return v; case 'ja-JP': return v;
case 'ja-KS': case 'ja-KS':
case 'en-US': return merge(locales['ja-JP'], v); case 'en-US': return merge(locales['ja-JP'], v);
default: return merge( default: return merge(
locales['ja-JP'], locales['ja-JP'],
locales['en-US'], locales['en-US'],
locales[`${lang}-${primaries[lang]}`] ?? {}, locales[`${lang}-${primaries[lang]}`] ?? {},
v v
); );
} }
})(), a), {}); })(), a), {});
}
export default build();

View File

@ -439,7 +439,6 @@ share: "共有"
notFound: "見つかりません" notFound: "見つかりません"
notFoundDescription: "指定されたURLに該当するページはありませんでした。" notFoundDescription: "指定されたURLに該当するページはありませんでした。"
uploadFolder: "既定アップロード先" uploadFolder: "既定アップロード先"
cacheClear: "キャッシュを削除"
markAsReadAllNotifications: "すべての通知を既読にする" markAsReadAllNotifications: "すべての通知を既読にする"
markAsReadAllUnreadNotes: "すべての投稿を既読にする" markAsReadAllUnreadNotes: "すべての投稿を既読にする"
markAsReadAllTalkMessages: "すべてのチャットを既読にする" markAsReadAllTalkMessages: "すべてのチャットを既読にする"
@ -546,6 +545,8 @@ showInPage: "ページで表示"
popout: "ポップアウト" popout: "ポップアウト"
volume: "音量" volume: "音量"
masterVolume: "マスター音量" masterVolume: "マスター音量"
notUseSound: "サウンドを出力しない"
useSoundOnlyWhenActive: "Misskeyがアクティブな時のみサウンドを出力する"
details: "詳細" details: "詳細"
chooseEmoji: "絵文字を選択" chooseEmoji: "絵文字を選択"
unableToProcess: "操作を完了できません" unableToProcess: "操作を完了できません"
@ -1026,6 +1027,8 @@ resetPasswordConfirm: "パスワードリセットしますか?"
sensitiveWords: "センシティブワード" sensitiveWords: "センシティブワード"
sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。" sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。"
sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。" sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。"
hiddenTags: "非表示ハッシュタグ"
hiddenTagsDescription: "設定したタグをトレンドに表示させないようにします。改行で区切って複数設定できます。"
notesSearchNotAvailable: "ノート検索は利用できません。" notesSearchNotAvailable: "ノート検索は利用できません。"
license: "ライセンス" license: "ライセンス"
unfavoriteConfirm: "お気に入り解除しますか?" unfavoriteConfirm: "お気に入り解除しますか?"
@ -1169,6 +1172,8 @@ cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述
doReaction: "リアクションする" doReaction: "リアクションする"
urlPreviewDenyList: "サムネイルの表示を制限するURL" urlPreviewDenyList: "サムネイルの表示を制限するURL"
urlPreviewDenyListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルがぼかされて表示されます。" urlPreviewDenyListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルがぼかされて表示されます。"
code: "コード"
reloadRequiredToApplySettings: "設定の反映にはリロードが必要です。"
_announcement: _announcement:
forExistingUsers: "既存ユーザーのみ" forExistingUsers: "既存ユーザーのみ"
@ -1857,6 +1862,15 @@ _sfx:
notification: "通知" notification: "通知"
antenna: "アンテナ受信" antenna: "アンテナ受信"
channel: "チャンネル通知" channel: "チャンネル通知"
reaction: "リアクション選択時"
_soundSettings:
driveFile: "ドライブの音声を使用"
driveFileWarn: "ドライブのファイルを選択してください"
driveFileTypeWarn: "このファイルは対応していません"
driveFileTypeWarnDescription: "音声ファイルを選択してください"
driveFileDurationWarn: "音声が長すぎます"
driveFileDurationWarnDescription: "長い音声を使用するとMisskeyの使用に支障をきたす可能性があります。それでも続行しますか"
_ago: _ago:
future: "未来" future: "未来"
@ -2010,6 +2024,7 @@ _widgets:
_userList: _userList:
chooseList: "リストを選択" chooseList: "リストを選択"
clicker: "クリッカー" clicker: "クリッカー"
birthdayFollowings: "今日誕生日のユーザー"
_cw: _cw:
hide: "隠す" hide: "隠す"
@ -2404,3 +2419,17 @@ _externalResourceInstaller:
_themeInstallFailed: _themeInstallFailed:
title: "テーマのインストールに失敗しました" title: "テーマのインストールに失敗しました"
description: "テーマのインストール中に問題が発生しました。もう一度お試しください。エラーの詳細はJavascriptコンソールをご覧ください。" description: "テーマのインストール中に問題が発生しました。もう一度お試しください。エラーの詳細はJavascriptコンソールをご覧ください。"
_dataSaver:
_media:
title: "メディアの読み込み"
description: "画像・動画が自動で読み込まれるのを防止します。隠れている画像・動画はタップすると読み込まれます。"
_avatar:
title: "アイコン画像"
description: "アイコン画像のアニメーションが停止します。アニメーション画像は通常の画像よりファイルサイズが大きいことがあるので、データ通信量をさらに削減できます。"
_urlPreview:
title: "URLプレビューのサムネイル"
description: "URLプレビューのサムネイル画像が読み込まれなくなります。"
_code:
title: "コードハイライト"
description: "MFMなどでコードハイライト記法が使われている場合、タップするまで読み込まれなくなります。コードハイライトではハイライトする言語ごとにその定義ファイルを読み込む必要がありますが、それらが自動で読み込まれなくなるため、通信量の削減が見込めます。"

View File

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

View File

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddBdayIndex1700902349231 {
name = 'AddBdayIndex1700902349231'
async up(queryRunner) {
await queryRunner.query(`CREATE INDEX "IDX_de22cd2b445eee31ae51cdbe99" ON "user_profile" (SUBSTR("birthday", 6, 5))`);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_de22cd2b445eee31ae51cdbe99"`);
}
}

View File

@ -16,6 +16,7 @@
"watch:swc": "swc src -d built -D -w", "watch:swc": "swc src -d built -D -w",
"build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", "build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
"watch": "node watch.mjs", "watch": "node watch.mjs",
"dev": "node ./built/boot/entry.js",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"eslint": "eslint --quiet \"src/**/*.ts\"", "eslint": "eslint --quiet \"src/**/*.ts\"",
"lint": "pnpm typecheck && pnpm eslint", "lint": "pnpm typecheck && pnpm eslint",
@ -60,27 +61,27 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.412.0", "@aws-sdk/client-s3": "3.412.0",
"@aws-sdk/lib-storage": "3.412.0", "@aws-sdk/lib-storage": "3.412.0",
"@bull-board/api": "5.9.1", "@bull-board/api": "5.10.2",
"@bull-board/fastify": "5.9.1", "@bull-board/fastify": "5.10.2",
"@bull-board/ui": "5.9.1", "@bull-board/ui": "5.10.2",
"@discordapp/twemoji": "14.1.2", "@discordapp/twemoji": "14.1.2",
"@fastify/accepts": "4.2.0", "@fastify/accepts": "4.3.0",
"@fastify/cookie": "9.2.0", "@fastify/cookie": "9.2.0",
"@fastify/cors": "8.4.1", "@fastify/cors": "8.4.2",
"@fastify/express": "2.3.0", "@fastify/express": "2.3.0",
"@fastify/http-proxy": "9.3.0", "@fastify/http-proxy": "9.3.0",
"@fastify/multipart": "8.0.0", "@fastify/multipart": "8.0.0",
"@fastify/static": "6.12.0", "@fastify/static": "6.12.0",
"@fastify/view": "8.2.0", "@fastify/view": "8.2.0",
"@nestjs/common": "10.2.8", "@nestjs/common": "10.2.10",
"@nestjs/core": "10.2.8", "@nestjs/core": "10.2.10",
"@nestjs/testing": "10.2.8", "@nestjs/testing": "10.2.10",
"@peertube/http-signature": "1.7.0", "@peertube/http-signature": "1.7.0",
"@simplewebauthn/server": "8.3.5", "@simplewebauthn/server": "8.3.5",
"@sinonjs/fake-timers": "11.2.2", "@sinonjs/fake-timers": "11.2.2",
"@smithy/node-http-handler": "2.1.5", "@smithy/node-http-handler": "2.1.10",
"@swc/cli": "0.1.63", "@swc/cli": "0.1.63",
"@swc/core": "1.3.96", "@swc/core": "1.3.100",
"accepts": "1.3.8", "accepts": "1.3.8",
"ajv": "8.12.0", "ajv": "8.12.0",
"archiver": "6.0.1", "archiver": "6.0.1",
@ -88,7 +89,7 @@
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"blurhash": "2.0.5", "blurhash": "2.0.5",
"body-parser": "1.20.2", "body-parser": "1.20.2",
"bullmq": "4.13.3", "bullmq": "4.15.2",
"cacheable-lookup": "7.0.0", "cacheable-lookup": "7.0.0",
"cbor": "9.0.1", "cbor": "9.0.1",
"chalk": "5.3.0", "chalk": "5.3.0",
@ -114,17 +115,17 @@
"ipaddr.js": "2.1.0", "ipaddr.js": "2.1.0",
"is-svg": "5.0.0", "is-svg": "5.0.0",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"jsdom": "22.1.0", "jsdom": "23.0.1",
"json5": "2.2.3", "json5": "2.2.3",
"jsonld": "8.3.1", "jsonld": "8.3.2",
"jsrsasign": "10.8.6", "jsrsasign": "10.9.0",
"meilisearch": "0.35.0", "meilisearch": "0.36.0",
"mfm-js": "0.23.3", "mfm-js": "0.23.3",
"microformats-parser": "1.5.2", "microformats-parser": "1.5.2",
"mime-types": "2.1.35", "mime-types": "2.1.35",
"misskey-js": "workspace:*", "misskey-js": "workspace:*",
"ms": "3.0.0-canary.1", "ms": "3.0.0-canary.1",
"nanoid": "5.0.3", "nanoid": "5.0.4",
"nested-property": "4.0.0", "nested-property": "4.0.0",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"nodemailer": "6.9.7", "nodemailer": "6.9.7",
@ -133,7 +134,7 @@
"oauth2orize": "1.12.0", "oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2", "oauth2orize-pkce": "0.1.2",
"os-utils": "0.0.14", "os-utils": "0.0.14",
"otpauth": "9.2.0", "otpauth": "9.2.1",
"parse5": "7.1.2", "parse5": "7.1.2",
"pg": "8.11.3", "pg": "8.11.3",
"pkce-challenge": "4.0.1", "pkce-challenge": "4.0.1",
@ -145,9 +146,9 @@
"qrcode": "1.5.3", "qrcode": "1.5.3",
"random-seed": "0.3.0", "random-seed": "0.3.0",
"ratelimiter": "3.4.1", "ratelimiter": "3.4.1",
"re2": "1.20.8", "re2": "1.20.9",
"redis-lock": "0.1.4", "redis-lock": "0.1.4",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.14",
"rename": "1.0.4", "rename": "1.0.4",
"rss-parser": "3.13.0", "rss-parser": "3.13.0",
"rxjs": "7.8.1", "rxjs": "7.8.1",
@ -159,14 +160,14 @@
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0", "stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly", "summaly": "github:misskey-dev/summaly",
"systeminformation": "5.21.17", "systeminformation": "5.21.20",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tmp": "0.2.1", "tmp": "0.2.1",
"tsc-alias": "1.8.8", "tsc-alias": "1.8.8",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"twemoji-parser": "14.0.0", "twemoji-parser": "14.0.0",
"typeorm": "0.3.17", "typeorm": "0.3.17",
"typescript": "5.3.2", "typescript": "5.3.3",
"ulid": "2.3.0", "ulid": "2.3.0",
"vary": "1.1.2", "vary": "1.1.2",
"web-push": "3.6.6", "web-push": "3.6.6",
@ -178,7 +179,7 @@
"@simplewebauthn/typescript-types": "8.3.4", "@simplewebauthn/typescript-types": "8.3.4",
"@swc/jest": "0.2.29", "@swc/jest": "0.2.29",
"@types/accepts": "1.3.7", "@types/accepts": "1.3.7",
"@types/archiver": "6.0.1", "@types/archiver": "6.0.2",
"@types/bcryptjs": "2.4.6", "@types/bcryptjs": "2.4.6",
"@types/body-parser": "1.19.5", "@types/body-parser": "1.19.5",
"@types/cbor": "6.0.0", "@types/cbor": "6.0.0",
@ -186,28 +187,28 @@
"@types/content-disposition": "0.5.8", "@types/content-disposition": "0.5.8",
"@types/fluent-ffmpeg": "2.1.24", "@types/fluent-ffmpeg": "2.1.24",
"@types/http-link-header": "1.0.5", "@types/http-link-header": "1.0.5",
"@types/jest": "29.5.8", "@types/jest": "29.5.11",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/jsdom": "21.1.5", "@types/jsdom": "21.1.6",
"@types/jsonld": "1.5.12", "@types/jsonld": "1.5.13",
"@types/jsrsasign": "10.5.12", "@types/jsrsasign": "10.5.12",
"@types/mime-types": "2.1.4", "@types/mime-types": "2.1.4",
"@types/ms": "0.7.34", "@types/ms": "0.7.34",
"@types/node": "20.9.1", "@types/node": "20.10.4",
"@types/node-fetch": "3.0.3", "@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.14", "@types/nodemailer": "6.4.14",
"@types/oauth": "0.9.4", "@types/oauth": "0.9.4",
"@types/oauth2orize": "1.11.3", "@types/oauth2orize": "1.11.3",
"@types/oauth2orize-pkce": "0.1.2", "@types/oauth2orize-pkce": "0.1.2",
"@types/pg": "8.10.9", "@types/pg": "8.10.9",
"@types/pug": "2.0.9", "@types/pug": "2.0.10",
"@types/punycode": "2.1.2", "@types/punycode": "2.1.3",
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
"@types/random-seed": "0.3.5", "@types/random-seed": "0.3.5",
"@types/ratelimiter": "3.4.6", "@types/ratelimiter": "3.4.6",
"@types/rename": "1.0.7", "@types/rename": "1.0.7",
"@types/sanitize-html": "2.9.4", "@types/sanitize-html": "2.9.5",
"@types/semver": "7.5.5", "@types/semver": "7.5.6",
"@types/sharp": "0.32.0", "@types/sharp": "0.32.0",
"@types/simple-oauth2": "5.0.7", "@types/simple-oauth2": "5.0.7",
"@types/sinonjs__fake-timers": "8.1.5", "@types/sinonjs__fake-timers": "8.1.5",
@ -215,12 +216,12 @@
"@types/tmp": "0.2.6", "@types/tmp": "0.2.6",
"@types/vary": "1.1.3", "@types/vary": "1.1.3",
"@types/web-push": "3.6.3", "@types/web-push": "3.6.3",
"@types/ws": "8.5.9", "@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "6.11.0", "@typescript-eslint/eslint-plugin": "6.13.2",
"@typescript-eslint/parser": "6.11.0", "@typescript-eslint/parser": "6.13.2",
"aws-sdk-client-mock": "3.0.0", "aws-sdk-client-mock": "3.0.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint": "8.53.0", "eslint": "8.55.0",
"eslint-plugin-import": "2.29.0", "eslint-plugin-import": "2.29.0",
"execa": "8.0.1", "execa": "8.0.1",
"jest": "29.7.0", "jest": "29.7.0",

View File

@ -16,7 +16,7 @@ import type { AntennasRepository, UserListMembershipsRepository } from '@/models
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import type { OnApplicationShutdown } from '@nestjs/common'; import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable() @Injectable()
@ -39,7 +39,7 @@ export class AntennaService implements OnApplicationShutdown {
private utilityService: UtilityService, private utilityService: UtilityService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private funoutTimelineService: FunoutTimelineService, private fanoutTimelineService: FanoutTimelineService,
) { ) {
this.antennasFetched = false; this.antennasFetched = false;
this.antennas = []; this.antennas = [];
@ -97,7 +97,7 @@ export class AntennaService implements OnApplicationShutdown {
const redisPipeline = this.redisForTimelines.pipeline(); const redisPipeline = this.redisForTimelines.pipeline();
for (const antenna of matchedAntennas) { for (const antenna of matchedAntennas) {
this.funoutTimelineService.push(`antennaTimeline:${antenna.id}`, note.id, 200, redisPipeline); this.fanoutTimelineService.push(`antennaTimeline:${antenna.id}`, note.id, 200, redisPipeline);
this.globalEventService.publishAntennaStream(antenna.id, 'note', note); this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
} }

View File

@ -4,6 +4,7 @@
*/ */
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { AccountMoveService } from './AccountMoveService.js'; import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js'; import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js'; import { AiService } from './AiService.js';
@ -62,7 +63,7 @@ import { FileInfoService } from './FileInfoService.js';
import { SearchService } from './SearchService.js'; import { SearchService } from './SearchService.js';
import { ClipService } from './ClipService.js'; import { ClipService } from './ClipService.js';
import { FeaturedService } from './FeaturedService.js'; import { FeaturedService } from './FeaturedService.js';
import { FunoutTimelineService } from './FunoutTimelineService.js'; import { FanoutTimelineService } from './FanoutTimelineService.js';
import { ChannelFollowingService } from './ChannelFollowingService.js'; import { ChannelFollowingService } from './ChannelFollowingService.js';
import { RegistryApiService } from './RegistryApiService.js'; import { RegistryApiService } from './RegistryApiService.js';
import { ChartLoggerService } from './chart/ChartLoggerService.js'; import { ChartLoggerService } from './chart/ChartLoggerService.js';
@ -195,7 +196,8 @@ const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: Fi
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService }; const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService }; const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService };
const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService }; const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService };
const $FunoutTimelineService: Provider = { provide: 'FunoutTimelineService', useExisting: FunoutTimelineService }; const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService };
const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService };
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService }; const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService }; const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
@ -332,7 +334,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
SearchService, SearchService,
ClipService, ClipService,
FeaturedService, FeaturedService,
FunoutTimelineService, FanoutTimelineService,
FanoutTimelineEndpointService,
ChannelFollowingService, ChannelFollowingService,
RegistryApiService, RegistryApiService,
ChartLoggerService, ChartLoggerService,
@ -462,7 +465,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$SearchService, $SearchService,
$ClipService, $ClipService,
$FeaturedService, $FeaturedService,
$FunoutTimelineService, $FanoutTimelineService,
$FanoutTimelineEndpointService,
$ChannelFollowingService, $ChannelFollowingService,
$RegistryApiService, $RegistryApiService,
$ChartLoggerService, $ChartLoggerService,
@ -593,7 +597,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
SearchService, SearchService,
ClipService, ClipService,
FeaturedService, FeaturedService,
FunoutTimelineService, FanoutTimelineService,
FanoutTimelineEndpointService,
ChannelFollowingService, ChannelFollowingService,
RegistryApiService, RegistryApiService,
FederationChart, FederationChart,
@ -722,7 +727,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$SearchService, $SearchService,
$ClipService, $ClipService,
$FeaturedService, $FeaturedService,
$FunoutTimelineService, $FanoutTimelineService,
$FanoutTimelineEndpointService,
$ChannelFollowingService, $ChannelFollowingService,
$RegistryApiService, $RegistryApiService,
$FederationChart, $FederationChart,

View File

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

View File

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

View File

@ -5,14 +5,17 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import type { MiNote, MiUser } from '@/models/_.js'; import type { MiGalleryPost, MiNote, MiUser } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
export const GALLERY_POSTS_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
const PER_USER_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 7; // 1週間ごと const PER_USER_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 7; // 1週間ごと
const HASHTAG_RANKING_WINDOW = 1000 * 60 * 60; // 1時間ごと const HASHTAG_RANKING_WINDOW = 1000 * 60 * 60; // 1時間ごと
const featuredEpoc = new Date('2023-01-01T00:00:00Z').getTime();
@Injectable() @Injectable()
export class FeaturedService { export class FeaturedService {
constructor( constructor(
@ -23,7 +26,7 @@ export class FeaturedService {
@bindThis @bindThis
private getCurrentWindow(windowRange: number): number { private getCurrentWindow(windowRange: number): number {
const passed = new Date().getTime() - new Date(new Date().getFullYear(), 0, 1).getTime(); const passed = new Date().getTime() - featuredEpoc;
return Math.floor(passed / windowRange); return Math.floor(passed / windowRange);
} }
@ -79,6 +82,11 @@ export class FeaturedService {
return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score); return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
} }
@bindThis
public updateGalleryPostsRanking(galleryPostId: MiGalleryPost['id'], score = 1): Promise<void> {
return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPostId, score);
}
@bindThis @bindThis
public updateInChannelNotesRanking(channelId: MiNote['channelId'], noteId: MiNote['id'], score = 1): Promise<void> { public updateInChannelNotesRanking(channelId: MiNote['channelId'], noteId: MiNote['id'], score = 1): Promise<void> {
return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score); return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
@ -99,6 +107,11 @@ export class FeaturedService {
return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, threshold); return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, threshold);
} }
@bindThis
public getGalleryPostsRanking(threshold: number): Promise<MiGalleryPost['id'][]> {
return this.getRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, threshold);
}
@bindThis @bindThis
public getInChannelNotesRanking(channelId: MiNote['channelId'], threshold: number): Promise<MiNote['id'][]> { public getInChannelNotesRanking(channelId: MiNote['channelId'], threshold: number): Promise<MiNote['id'][]> {
return this.getRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, threshold); return this.getRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, threshold);

View File

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

View File

@ -54,9 +54,10 @@ import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js'; import { SearchService } from '@/core/SearchService.js';
import { FeaturedService } from '@/core/FeaturedService.js'; import { FeaturedService } from '@/core/FeaturedService.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js';
import { isReply } from '@/misc/is-reply.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -194,7 +195,7 @@ export class NoteCreateService implements OnApplicationShutdown {
private idService: IdService, private idService: IdService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private queueService: QueueService, private queueService: QueueService,
private funoutTimelineService: FunoutTimelineService, private fanoutTimelineService: FanoutTimelineService,
private noteReadService: NoteReadService, private noteReadService: NoteReadService,
private notificationService: NotificationService, private notificationService: NotificationService,
private relayService: RelayService, private relayService: RelayService,
@ -876,22 +877,22 @@ export class NoteCreateService implements OnApplicationShutdown {
) continue; ) continue;
// 「自分自身への返信 or そのリストの作成者への返信」のどちらでもない場合 // 「自分自身への返信 or そのリストの作成者への返信」のどちらでもない場合
if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === userListMembership.userListUserId)) { if (isReply(note, userListMembership.userListUserId)) {
if (!userListMembership.withReplies) continue; if (!userListMembership.withReplies) continue;
} }
this.funoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r); this.fanoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r);
if (note.fileIds.length > 0) { if (note.fileIds.length > 0) {
this.funoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r); this.fanoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r);
} }
} }
if (note.channelId) { if (note.channelId) {
this.funoutTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r); this.fanoutTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r);
this.funoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); this.fanoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
if (note.fileIds.length > 0) { if (note.fileIds.length > 0) {
this.funoutTimelineService.push(`userTimelineWithChannelWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r); this.fanoutTimelineService.push(`userTimelineWithChannelWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r);
} }
const channelFollowings = await this.channelFollowingsRepository.find({ const channelFollowings = await this.channelFollowingsRepository.find({
@ -902,9 +903,9 @@ export class NoteCreateService implements OnApplicationShutdown {
}); });
for (const channelFollowing of channelFollowings) { for (const channelFollowing of channelFollowings) {
this.funoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r); this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
if (note.fileIds.length > 0) { if (note.fileIds.length > 0) {
this.funoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
} }
} }
} else { } else {
@ -914,43 +915,46 @@ export class NoteCreateService implements OnApplicationShutdown {
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue; if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
// 「自分自身への返信 or そのフォロワーへの返信」のどちらでもない場合 // 「自分自身への返信 or そのフォロワーへの返信」のどちらでもない場合
if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === following.followerId)) { if (isReply(note, following.followerId)) {
if (!following.withReplies) continue; if (!following.withReplies) continue;
} }
this.funoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r); this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
if (note.fileIds.length > 0) { if (note.fileIds.length > 0) {
this.funoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
} }
} }
if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { // 自分自身のHTL if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { // 自分自身のHTL
this.funoutTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r); this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r);
if (note.fileIds.length > 0) { if (note.fileIds.length > 0) {
this.funoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
} }
} }
// 自分自身以外への返信 // 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) { if (isReply(note)) {
this.funoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
if (note.fileIds.length > 0) { if (note.fileIds.length > 0) {
this.funoutTimelineService.push(`userTimelineWithRepliesWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r); this.fanoutTimelineService.push(`userTimelineWithRepliesWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r);
} }
if (note.visibility === 'public' && note.userHost == null) { if (note.visibility === 'public' && note.userHost == null) {
this.funoutTimelineService.push('localTimelineWithReplies', note.id, 300, r); this.fanoutTimelineService.push('localTimelineWithReplies', note.id, 300, r);
if (note.replyUserHost == null) {
this.fanoutTimelineService.push(`localTimelineWithReplyTo:${note.replyUserId}`, note.id, 300 / 10, r);
}
} }
} else { } else {
this.funoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
if (note.fileIds.length > 0) { if (note.fileIds.length > 0) {
this.funoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r); this.fanoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r);
} }
if (note.visibility === 'public' && note.userHost == null) { if (note.visibility === 'public' && note.userHost == null) {
this.funoutTimelineService.push('localTimeline', note.id, 1000, r); this.fanoutTimelineService.push('localTimeline', note.id, 1000, r);
if (note.fileIds.length > 0) { if (note.fileIds.length > 0) {
this.funoutTimelineService.push('localTimelineWithFiles', note.id, 500, r); this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r);
} }
} }
} }

View File

@ -21,7 +21,7 @@ import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import type { OnApplicationShutdown } from '@nestjs/common'; import type { OnApplicationShutdown } from '@nestjs/common';
export type RolePolicies = { export type RolePolicies = {
@ -109,7 +109,7 @@ export class RoleService implements OnApplicationShutdown {
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private idService: IdService, private idService: IdService,
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private funoutTimelineService: FunoutTimelineService, private fanoutTimelineService: FanoutTimelineService,
) { ) {
//this.onMessage = this.onMessage.bind(this); //this.onMessage = this.onMessage.bind(this);
@ -496,7 +496,7 @@ export class RoleService implements OnApplicationShutdown {
const redisPipeline = this.redisForTimelines.pipeline(); const redisPipeline = this.redisForTimelines.pipeline();
for (const role of roles) { for (const role of roles) {
this.funoutTimelineService.push(`roleTimeline:${role.id}`, note.id, 1000, redisPipeline); this.fanoutTimelineService.push(`roleTimeline:${role.id}`, note.id, 1000, redisPipeline);
this.globalEventService.publishRoleTimelineStream(role.id, 'note', note); this.globalEventService.publishRoleTimelineStream(role.id, 'note', note);
} }

View File

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

View File

@ -29,7 +29,7 @@ import { CacheService } from '@/core/CacheService.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { AccountMoveService } from '@/core/AccountMoveService.js'; import { AccountMoveService } from '@/core/AccountMoveService.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import Logger from '../logger.js'; import Logger from '../logger.js';
const logger = new Logger('following/create'); const logger = new Logger('following/create');
@ -84,7 +84,7 @@ export class UserFollowingService implements OnModuleInit {
private webhookService: WebhookService, private webhookService: WebhookService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private accountMoveService: AccountMoveService, private accountMoveService: AccountMoveService,
private funoutTimelineService: FunoutTimelineService, private fanoutTimelineService: FanoutTimelineService,
private perUserFollowingChart: PerUserFollowingChart, private perUserFollowingChart: PerUserFollowingChart,
private instanceChart: InstanceChart, private instanceChart: InstanceChart,
) { ) {
@ -304,8 +304,6 @@ export class UserFollowingService implements OnModuleInit {
}); });
} }
}); });
this.funoutTimelineService.purge(`homeTimeline:${follower.id}`);
} }
// Publish followed event // Publish followed event
@ -373,8 +371,6 @@ export class UserFollowingService implements OnModuleInit {
}); });
} }
}); });
this.funoutTimelineService.purge(`homeTimeline:${follower.id}`);
} }
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,9 +36,10 @@ import { packedPageLikeSchema, packedPageSchema } from '@/models/json-schema/pag
import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; import { packedQueueCountSchema } from '@/models/json-schema/queue.js';
import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js'; import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js';
import { packedRenoteMutingSchema } from '@/models/json-schema/renote-muting.js'; import { packedRenoteMutingSchema } from '@/models/json-schema/renote-muting.js';
import { packedRoleSchema } from '@/models/json-schema/role.js';
import { packedUserListMembershipSchema, packedUserListSchema } from '@/models/json-schema/user-list.js'; import { packedUserListMembershipSchema, packedUserListSchema } from '@/models/json-schema/user-list.js';
import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js';
import { packedSigninSchema } from '@/models/json-schema/signin.js';
import { packedRoleLiteSchema, packedRoleSchema } from '@/models/json-schema/role.js';
export const refs = { export const refs = {
UserLite: packedUserLiteSchema, UserLite: packedUserLiteSchema,
@ -80,6 +81,8 @@ export const refs = {
Flash: packedFlashSchema, Flash: packedFlashSchema,
FlashLike: packedFlashLikeSchema, FlashLike: packedFlashLikeSchema,
Signin: packedSigninSchema,
RoleLite: packedRoleLiteSchema,
Role: packedRoleSchema, Role: packedRoleSchema,
AbuseUserReport: packedAbuseUserReportSchema, AbuseUserReport: packedAbuseUserReportSchema,
ModerationLog: packedModerationLogSchema, ModerationLog: packedModerationLogSchema,

View File

@ -29,6 +29,7 @@ export class MiUserProfile {
}) })
public location: string | null; public location: string | null;
@Index()
@Column('char', { @Column('char', {
length: 10, nullable: true, length: 10, nullable: true,
comment: 'The birthday (YYYY-MM-DD) of the User.', comment: 'The birthday (YYYY-MM-DD) of the User.',

View File

@ -24,12 +24,6 @@ export const packedModerationLogSchema = {
info: { info: {
type: 'object', type: 'object',
optional: false, nullable: false, optional: false, nullable: false,
patternProperties: {
'^': {
type: 'object',
nullable: false, optional: false,
},
},
}, },
userId: { userId: {
type: 'string', type: 'string',

View File

@ -186,6 +186,10 @@ export const packedNoteSchema = {
optional: false, nullable: false, optional: false, nullable: false,
}, },
}, },
clippedCount: {
type: 'number',
optional: true, nullable: false,
},
myReaction: { myReaction: {
type: 'object', type: 'object',

View File

@ -1,9 +1,30 @@
/* const rolePolicyValue = {
* SPDX-FileCopyrightText: syuilo and other misskey contributors type: 'object',
* SPDX-License-Identifier: AGPL-3.0-only 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 packedRoleSchema = { export const packedRoleLiteSchema = {
type: 'object', type: 'object',
properties: { properties: {
id: { id: {
@ -12,97 +33,125 @@ export const packedRoleSchema = {
format: 'id', format: 'id',
example: 'xxxxxxxxxx', example: 'xxxxxxxxxx',
}, },
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
updatedAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
name: { name: {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
example: 'New Role',
},
color: {
type: 'string',
optional: false, nullable: true,
example: '#000000',
},
iconUrl: {
type: 'string',
optional: false, nullable: true,
}, },
description: { description: {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
}, },
color: { isModerator: {
type: 'string',
optional: false, nullable: true,
},
iconUrl: {
type: 'string',
format: 'url',
optional: false, nullable: true,
},
target: {
type: 'string',
optional: false, nullable: false,
enum: ['manual', 'conditional'],
},
condFormula: {
type: 'object',
optional: false, nullable: false,
},
isPublic: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
example: false,
}, },
isAdministrator: { isAdministrator: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, example: false,
isModerator: {
type: 'boolean',
optional: false, nullable: false,
},
isExplorable: {
type: 'boolean',
optional: false, nullable: false,
},
asBadge: {
type: 'boolean',
optional: false, nullable: false,
},
canEditMembersByModerator: {
type: 'boolean',
optional: false, nullable: false,
}, },
displayOrder: { displayOrder: {
type: 'number', type: 'integer',
optional: false, nullable: false,
},
policies: {
type: 'object',
optional: false, nullable: false,
patternProperties: {
'^': {
type: 'object',
nullable: false, optional: false,
properties: {
useDefault: {
type: 'boolean',
nullable: false, optional: false,
},
priority: {
type: 'number',
nullable: false, optional: false,
},
value: {
type: 'object',
nullable: false, optional: false,
},
},
},
},
},
usersCount: {
type: 'number',
optional: false, nullable: false, optional: false, nullable: false,
example: 0,
}, },
}, },
} as const; } as const;
export const packedRoleSchema = {
type: 'object',
allOf: [
{
type: 'object',
ref: 'RoleLite',
},
{
type: 'object',
properties: {
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
updatedAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
target: {
type: 'string',
optional: false, nullable: false,
enum: ['manual', 'conditional'],
},
condFormula: {
type: 'object',
optional: false, nullable: false,
},
isPublic: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
isExplorable: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
asBadge: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
canEditMembersByModerator: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
policies: {
type: 'object',
optional: false, nullable: false,
properties: {
pinLimit: rolePolicyValue,
canInvite: rolePolicyValue,
clipLimit: rolePolicyValue,
canHideAds: rolePolicyValue,
inviteLimit: rolePolicyValue,
antennaLimit: rolePolicyValue,
gtlAvailable: rolePolicyValue,
ltlAvailable: rolePolicyValue,
webhookLimit: rolePolicyValue,
canPublicNote: rolePolicyValue,
userListLimit: rolePolicyValue,
wordMuteLimit: rolePolicyValue,
alwaysMarkNsfw: rolePolicyValue,
canSearchNotes: rolePolicyValue,
driveCapacityMb: rolePolicyValue,
rateLimitFactor: rolePolicyValue,
inviteLimitCycle: rolePolicyValue,
noteEachClipsLimit: rolePolicyValue,
inviteExpirationTime: rolePolicyValue,
canManageCustomEmojis: rolePolicyValue,
userEachUserListsLimit: rolePolicyValue,
canManageAvatarDecorations: rolePolicyValue,
canUseTranslator: rolePolicyValue,
},
},
usersCount: {
type: 'integer',
optional: false, nullable: false,
},
},
},
],
} as const;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,13 +33,7 @@ export const meta = {
items: { items: {
type: 'object', type: 'object',
optional: false, nullable: false, optional: false, nullable: false,
properties: { ref: 'InviteCode',
code: {
type: 'string',
optional: false, nullable: false,
example: 'GR6S02ERUA5VR',
},
},
}, },
}, },
} as const; } as const;

View File

@ -21,6 +21,7 @@ export const meta = {
items: { items: {
type: 'object', type: 'object',
optional: false, nullable: false, optional: false, nullable: false,
ref: 'InviteCode',
}, },
}, },
} as const; } as const;

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@ import { NoteReadService } from '@/core/NoteReadService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
@ -71,7 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService, private queryService: QueryService,
private noteReadService: NoteReadService, private noteReadService: NoteReadService,
private funoutTimelineService: FunoutTimelineService, private fanoutTimelineService: FanoutTimelineService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
@ -98,7 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.globalEventService.publishInternalEvent('antennaUpdated', antenna); this.globalEventService.publishInternalEvent('antennaUpdated', antenna);
} }
let noteIds = await this.funoutTimelineService.get(`antennaTimeline:${antenna.id}`, untilId, sinceId); let noteIds = await this.fanoutTimelineService.get(`antennaTimeline:${antenna.id}`, untilId, sinceId);
noteIds = noteIds.slice(0, ps.limit); noteIds = noteIds.slice(0, ps.limit);
if (noteIds.length === 0) { if (noteIds.length === 0) {
return []; return [];

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import type { GalleryPostsRepository } from '@/models/_.js'; import type { GalleryPostsRepository } from '@/models/_.js';
import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { FeaturedService } from '@/core/FeaturedService.js';
export const meta = { export const meta = {
tags: ['gallery'], tags: ['gallery'],
@ -27,25 +28,49 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: {}, properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
untilId: { type: 'string', format: 'misskey:id' },
},
required: [], required: [],
} as const; } as const;
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
private galleryPostsRankingCache: string[] = [];
private galleryPostsRankingCacheLastFetchedAt = 0;
constructor( constructor(
@Inject(DI.galleryPostsRepository) @Inject(DI.galleryPostsRepository)
private galleryPostsRepository: GalleryPostsRepository, private galleryPostsRepository: GalleryPostsRepository,
private galleryPostEntityService: GalleryPostEntityService, private galleryPostEntityService: GalleryPostEntityService,
private featuredService: FeaturedService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const query = this.galleryPostsRepository.createQueryBuilder('post') let postIds: string[];
.andWhere('post.createdAt > :date', { date: new Date(Date.now() - (1000 * 60 * 60 * 24 * 3)) }) if (this.galleryPostsRankingCacheLastFetchedAt !== 0 && (Date.now() - this.galleryPostsRankingCacheLastFetchedAt < 1000 * 60 * 30)) {
.andWhere('post.likedCount > 0') postIds = this.galleryPostsRankingCache;
.orderBy('post.likedCount', 'DESC'); } else {
postIds = await this.featuredService.getGalleryPostsRanking(100);
this.galleryPostsRankingCache = postIds;
this.galleryPostsRankingCacheLastFetchedAt = Date.now();
}
const posts = await query.limit(10).getMany(); postIds.sort((a, b) => a > b ? -1 : 1);
if (ps.untilId) {
postIds = postIds.filter(id => id < ps.untilId!);
}
postIds = postIds.slice(0, ps.limit);
if (postIds.length === 0) {
return [];
}
const query = this.galleryPostsRepository.createQueryBuilder('post')
.where('post.id IN (:...postIds)', { postIds: postIds });
const posts = await query.getMany();
return await this.galleryPostEntityService.packMany(posts, me); return await this.galleryPostEntityService.packMany(posts, me);
}); });

View File

@ -6,6 +6,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { GalleryLikesRepository, GalleryPostsRepository } from '@/models/_.js'; import type { GalleryLikesRepository, GalleryPostsRepository } from '@/models/_.js';
import { FeaturedService, GALLERY_POSTS_RANKING_WINDOW } from '@/core/FeaturedService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js'; import { ApiError } from '../../../error.js';
@ -58,6 +59,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.galleryLikesRepository) @Inject(DI.galleryLikesRepository)
private galleryLikesRepository: GalleryLikesRepository, private galleryLikesRepository: GalleryLikesRepository,
private featuredService: FeaturedService,
private idService: IdService, private idService: IdService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
@ -89,6 +91,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
userId: me.id, userId: me.id,
}); });
// ランキング更新
if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) {
await this.featuredService.updateGalleryPostsRanking(post.id, 1);
}
this.galleryPostsRepository.increment({ id: post.id }, 'likedCount', 1); this.galleryPostsRepository.increment({ id: post.id }, 'likedCount', 1);
}); });
} }

View File

@ -6,6 +6,8 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { GalleryPostsRepository, GalleryLikesRepository } from '@/models/_.js'; import type { GalleryPostsRepository, GalleryLikesRepository } from '@/models/_.js';
import { FeaturedService, GALLERY_POSTS_RANKING_WINDOW } from '@/core/FeaturedService.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js'; import { ApiError } from '../../../error.js';
@ -50,6 +52,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.galleryLikesRepository) @Inject(DI.galleryLikesRepository)
private galleryLikesRepository: GalleryLikesRepository, private galleryLikesRepository: GalleryLikesRepository,
private featuredService: FeaturedService,
private idService: IdService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const post = await this.galleryPostsRepository.findOneBy({ id: ps.postId }); const post = await this.galleryPostsRepository.findOneBy({ id: ps.postId });
@ -69,6 +74,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Delete like // Delete like
await this.galleryLikesRepository.delete(exist.id); await this.galleryLikesRepository.delete(exist.id);
// ランキング更新
if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) {
await this.featuredService.updateGalleryPostsRanking(post.id, -1);
}
this.galleryPostsRepository.decrement({ id: post.id }, 'likedCount', 1); this.galleryPostsRepository.decrement({ id: post.id }, 'likedCount', 1);
}); });
} }

View File

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

View File

@ -31,13 +31,7 @@ export const meta = {
res: { res: {
type: 'object', type: 'object',
optional: false, nullable: false, optional: false, nullable: false,
properties: { ref: 'InviteCode',
code: {
type: 'string',
optional: false, nullable: false,
example: 'GR6S02ERUA5VR',
},
},
}, },
} as const; } as const;

View File

@ -9,7 +9,6 @@ import type { RegistrationTicketsRepository } from '@/models/_.js';
import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js'; import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
export const meta = { export const meta = {
tags: ['meta'], tags: ['meta'],
@ -23,6 +22,7 @@ export const meta = {
items: { items: {
type: 'object', type: 'object',
optional: false, nullable: false, optional: false, nullable: false,
ref: 'InviteCode',
}, },
}, },
} as const; } as const;

View File

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

View File

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

View File

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

View File

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

View File

@ -5,18 +5,17 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import type { MiNote, MiUserList, NotesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import type { MiUserList, NotesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { MiLocalUser } from '@/models/User.js'; import { MiLocalUser } from '@/models/User.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -52,6 +51,7 @@ export const paramDef = {
untilId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' }, sinceDate: { type: 'integer' },
untilDate: { type: 'integer' }, untilDate: { type: 'integer' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
includeMyRenotes: { type: 'boolean', default: true }, includeMyRenotes: { type: 'boolean', default: true },
includeRenotedMyNotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true },
@ -81,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private cacheService: CacheService, private cacheService: CacheService,
private idService: IdService, private idService: IdService,
private funoutTimelineService: FunoutTimelineService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private queryService: QueryService, private queryService: QueryService,
private metaService: MetaService, private metaService: MetaService,
) { ) {
@ -101,7 +101,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const serverSettings = await this.metaService.fetch(); const serverSettings = await this.metaService.fetch();
if (!serverSettings.enableFanoutTimeline) { if (!serverSettings.enableFanoutTimeline) {
return await this.getFromDb(list, { const timeline = await this.getFromDb(list, {
untilId, untilId,
sinceId, sinceId,
limit: ps.limit, limit: ps.limit,
@ -111,73 +111,37 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withFiles: ps.withFiles, withFiles: ps.withFiles,
withRenotes: ps.withRenotes, withRenotes: ps.withRenotes,
}, me); }, me);
}
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.funoutTimelineService.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;
});
redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1);
}
if (redisTimeline.length > 0) {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);
return await this.noteEntityService.packMany(redisTimeline, me);
} else { await this.noteEntityService.packMany(timeline, me);
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 [];
}
} }
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),
});
this.activeUsersChart.read(me);
return timeline;
}); });
} }
@ -270,10 +234,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
//#endregion //#endregion
const timeline = await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();
this.activeUsersChart.read(me);
return await this.noteEntityService.packMany(timeline, me);
} }
} }

View File

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

View File

@ -11,7 +11,7 @@ import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -66,7 +66,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService, private idService: IdService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService, private queryService: QueryService,
private funoutTimelineService: FunoutTimelineService, private fanoutTimelineService: FanoutTimelineService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@ -84,7 +84,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return []; return [];
} }
let noteIds = await this.funoutTimelineService.get(`roleTimeline:${role.id}`, untilId, sinceId); let noteIds = await this.fanoutTimelineService.get(`roleTimeline:${role.id}`, untilId, sinceId);
noteIds = noteIds.slice(0, ps.limit); noteIds = noteIds.slice(0, ps.limit);
if (noteIds.length === 0) { if (noteIds.length === 0) {

View File

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

View File

@ -42,6 +42,12 @@ export const meta = {
code: 'FORBIDDEN', code: 'FORBIDDEN',
id: 'f6cdb0df-c19f-ec5c-7dbb-0ba84a1f92ba', id: 'f6cdb0df-c19f-ec5c-7dbb-0ba84a1f92ba',
}, },
birthdayInvalid: {
message: 'Birthday date format is invalid.',
code: 'BIRTHDAY_DATE_FORMAT_INVALID',
id: 'a2b007b9-4782-4eba-abd3-93b05ed4130d',
},
}, },
} as const; } as const;
@ -59,6 +65,8 @@ export const paramDef = {
nullable: true, nullable: true,
description: 'The local host is represented with `null`.', description: 'The local host is represented with `null`.',
}, },
birthday: { type: 'string', nullable: true },
}, },
anyOf: [ anyOf: [
{ required: ['userId'] }, { required: ['userId'] },
@ -117,6 +125,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.andWhere('following.followerId = :userId', { userId: user.id }) .andWhere('following.followerId = :userId', { userId: user.id })
.innerJoinAndSelect('following.followee', 'followee'); .innerJoinAndSelect('following.followee', 'followee');
if (ps.birthday) {
try {
const d = new Date(ps.birthday);
d.setHours(0, 0, 0, 0);
const birthday = `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`;
const birthdayUserQuery = this.userProfilesRepository.createQueryBuilder('user_profile');
birthdayUserQuery.select('user_profile.userId')
.where(`SUBSTR(user_profile.birthday, 6, 5) = '${birthday}'`);
query.andWhere(`following.followeeId IN (${ birthdayUserQuery.getQuery() })`);
} catch (err) {
throw new ApiError(meta.errors.birthdayInvalid);
}
}
const followings = await query const followings = await query
.limit(ps.limit) .limit(ps.limit)
.getMany(); .getMany();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -58,7 +58,7 @@ export class FeedService {
const feed = new Feed({ const feed = new Feed({
id: author.link, id: author.link,
title: `${author.name} (@${user.username}@${this.config.host})`, title: `${author.name} (@${user.username}@${this.config.host})`,
updated: this.idService.parse(notes[0].id).date, updated: notes.length !== 0 ? this.idService.parse(notes[0].id).date : undefined,
generator: 'Misskey', generator: 'Misskey',
description: `${user.notesCount} Notes, ${profile.ffVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.ffVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`, description: `${user.notesCount} Notes, ${profile.ffVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.ffVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`,
link: author.link, link: author.link,

View File

@ -93,7 +93,7 @@ describe('Webリソース', () => {
}); });
aliceChannel = await channel(alice, {}); aliceChannel = await channel(alice, {});
bob = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' });
}, 1000 * 60 * 2); }, 1000 * 60 * 2);
afterAll(async () => { afterAll(async () => {
@ -152,6 +152,11 @@ describe('Webリソース', () => {
type, type,
})); }));
test('がGETできる。(ノートが存在しない場合でも。)', async () => await ok({
path: path(bob.username),
type,
}));
test('は存在しないユーザーはGETできない。', async () => await notOk({ test('は存在しないユーザーはGETできない。', async () => await notOk({
path: path('nonexisting'), path: path('nonexisting'),
status: 404, status: 404,

View File

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

View File

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

Binary file not shown.

Binary file not shown.

View File

@ -89,7 +89,6 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
api("users/notes", { api("users/notes", {
userId: props.user.id, userId: props.user.id,
fileType: image, fileType: image,
excludeNsfw: defaultStore.state.nsfw !== "ignore",
limit: 10 limit: 10
}).then((notes) => { }).then((notes) => {
for (const note of notes) { for (const note of notes) {
@ -198,7 +197,6 @@ const _sfc_main = defineComponent({
api("users/notes", { api("users/notes", {
userId: props.user.id, userId: props.user.id,
fileType: image, fileType: image,
excludeNsfw: defaultStore.state.nsfw !== "ignore",
limit: 10 limit: 10
}).then(notes => { }).then(notes => {
for (const note of notes) { for (const note of notes) {

View File

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

View File

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

View File

@ -187,6 +187,12 @@ export async function common(createVue: () => App<Element>) {
if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme)); if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme));
if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme)); if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme));
defaultStore.set('themeInitial', false); defaultStore.set('themeInitial', false);
} else {
if (defaultStore.state.darkMode) {
applyTheme(darkTheme.value);
} else {
applyTheme(lightTheme.value);
}
} }
}); });
@ -202,16 +208,24 @@ export async function common(createVue: () => App<Element>) {
} }
}, { immediate: true }); }, { immediate: true });
if (defaultStore.state.keepScreenOn) { // Keep screen on
if ('wakeLock' in navigator) { const onVisibilityChange = () => document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
navigator.wakeLock.request('screen'); navigator.wakeLock.request('screen');
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible') {
navigator.wakeLock.request('screen');
}
});
} }
});
if (defaultStore.state.keepScreenOn && 'wakeLock' in navigator) {
navigator.wakeLock.request('screen')
.then(onVisibilityChange)
.catch(() => {
// On WebKit-based browsers, user activation is required to send wake lock request
// https://webkit.org/blog/13862/the-user-activation-api/
document.addEventListener(
'click',
() => navigator.wakeLock.request('screen').then(onVisibilityChange),
{ once: true },
);
});
} }
//#region Fetch user //#region Fetch user

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { nextTick, onMounted } from 'vue'; import { nextTick, onMounted, shallowRef } from 'vue';
const props = defineProps<{ const props = defineProps<{
type?: 'button' | 'submit' | 'reset'; type?: 'button' | 'submit' | 'reset';
@ -59,13 +59,13 @@ const emit = defineEmits<{
(ev: 'click', payload: MouseEvent): void; (ev: 'click', payload: MouseEvent): void;
}>(); }>();
let el = $shallowRef<HTMLElement | null>(null); const el = shallowRef<HTMLElement | null>(null);
let ripples = $shallowRef<HTMLElement | null>(null); const ripples = shallowRef<HTMLElement | null>(null);
onMounted(() => { onMounted(() => {
if (props.autofocus) { if (props.autofocus) {
nextTick(() => { nextTick(() => {
el!.focus(); el.value!.focus();
}); });
} }
}); });
@ -88,11 +88,11 @@ function onMousedown(evt: MouseEvent): void {
const rect = target.getBoundingClientRect(); const rect = target.getBoundingClientRect();
const ripple = document.createElement('div'); const ripple = document.createElement('div');
ripple.classList.add(ripples!.dataset.childrenClass!); ripple.classList.add(ripples.value!.dataset.childrenClass!);
ripple.style.top = (evt.clientY - rect.top - 1).toString() + 'px'; ripple.style.top = (evt.clientY - rect.top - 1).toString() + 'px';
ripple.style.left = (evt.clientX - rect.left - 1).toString() + 'px'; ripple.style.left = (evt.clientX - rect.left - 1).toString() + 'px';
ripples!.appendChild(ripple); ripples.value!.appendChild(ripple);
const circleCenterX = evt.clientX - rect.left; const circleCenterX = evt.clientX - rect.left;
const circleCenterY = evt.clientY - rect.top; const circleCenterY = evt.clientY - rect.top;
@ -107,7 +107,7 @@ function onMousedown(evt: MouseEvent): void {
ripple.style.opacity = '0'; ripple.style.opacity = '0';
}, 1000); }, 1000);
window.setTimeout(() => { window.setTimeout(() => {
if (ripples) ripples.removeChild(ripple); if (ripples.value) ripples.value.removeChild(ripple);
}, 2000); }, 2000);
} }
</script> </script>

View File

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

View File

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

View File

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

View File

@ -62,7 +62,7 @@ watch(() => props.lang, (to) => {
padding: 1em; padding: 1em;
margin: .5em 0; margin: .5em 0;
overflow: auto; overflow: auto;
border-radius: .3em; border-radius: 8px;
& pre, & pre,
& code { & code {

View File

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

View File

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

View File

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

View File

@ -16,7 +16,22 @@ import MkButton from '@/components/MkButton.vue';
const props = defineProps<{ const props = defineProps<{
modelValue: boolean; modelValue: boolean;
note: Misskey.entities.Note; text: string | null;
files: Misskey.entities.DriveFile[];
poll?: {
expiresAt: string | null;
multiple: boolean;
choices: {
isVoted: boolean;
text: string;
votes: number;
}[];
} | {
choices: string[];
multiple: boolean;
expiresAt: string | null;
expiredAfter: string | null;
};
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -25,9 +40,9 @@ const emit = defineEmits<{
const label = computed(() => { const label = computed(() => {
return concat([ return concat([
props.note.text ? [i18n.t('_cw.chars', { count: props.note.text.length })] : [], props.text ? [i18n.t('_cw.chars', { count: props.text.length })] : [],
props.note.files && props.note.files.length !== 0 ? [i18n.t('_cw.files', { count: props.note.files.length })] : [], props.files.length !== 0 ? [i18n.t('_cw.files', { count: props.files.length })] : [],
props.note.poll != null ? [i18n.ts.poll] : [], props.poll != null ? [i18n.ts.poll] : [],
] as string[][]).join(' / '); ] as string[][]).join(' / ');
}); });

View File

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

View File

@ -27,20 +27,20 @@ SPDX-License-Identifier: AGPL-3.0-only
<!-- フォルダの中にはカスタム絵文字やフォルダがある --> <!-- フォルダの中にはカスタム絵文字やフォルダがある -->
<section v-else v-panel style="border-radius: 6px; border-bottom: 0.5px solid var(--divider);"> <section v-else v-panel style="border-radius: 6px; border-bottom: 0.5px solid var(--divider);">
<header class="_acrylic" @click="shown = !shown"> <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"></i>:{{ customEmojiTree.length }} <i class="ti ti-icons"></i>:{{ emojis.length }}) <i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> <slot></slot> (<i class="ti ti-folder ti-fw"></i>:{{ customEmojiTree.length }} <i class="ti ti-icons ti-fw"></i>:{{ emojis.length }})
</header> </header>
<div v-if="shown" style="padding-left: 9px;"> <div v-if="shown" style="padding-left: 9px;">
<MkEmojiPickerSection <MkEmojiPickerSection
v-for="child in customEmojiTree" v-for="child in customEmojiTree"
:key="`custom:${child.category}`" :key="`custom:${child.category}`"
:initialShown="initialShown" :initialShown="initialShown"
:emojis="computed(() => customEmojis.filter(e => e.category === child.category).map(e => `:${e.name}:`))" :emojis="computed(() => customEmojis.filter(e => e.category === child.category).map(e => `:${e.name}:`))"
:hasChildSection="child.children.length !== 0" :hasChildSection="child.children.length !== 0"
:customEmojiTree="child.children" :customEmojiTree="child.children"
@chosen="nestedChosen" @chosen="nestedChosen"
> >
{{ child.category || i18n.ts.other }} {{ child.category || i18n.ts.other }}
</MkEmojiPickerSection> </MkEmojiPickerSection>
</div> </div>
<div v-if="shown" class="body"> <div v-if="shown" class="body">
<button <button

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