Merge remote-tracking branch 'upstream/develop' into fix-issue-12116
This commit is contained in:
commit
47052793a7
|
|
@ -20,7 +20,7 @@ jobs:
|
||||||
sudo dpkg -i dockle.deb
|
sudo dpkg -i dockle.deb
|
||||||
- run: |
|
- run: |
|
||||||
cp .config/docker_example.env .config/docker.env
|
cp .config/docker_example.env .config/docker.env
|
||||||
cp ./docker-compose.yml.example ./docker-compose.yml
|
cp ./docker-compose_example.yml ./docker-compose.yml
|
||||||
- run: |
|
- run: |
|
||||||
docker compose up -d web
|
docker compose up -d web
|
||||||
docker tag "$(docker compose images web | awk 'OFS=":" {print $4}' | tail -n +2)" misskey-web:latest
|
docker tag "$(docker compose images web | awk 'OFS=":" {print $4}' | tail -n +2)" misskey-web:latest
|
||||||
|
|
|
||||||
|
|
@ -22,16 +22,13 @@ jobs:
|
||||||
api-json-name: [api-base.json, api-head.json]
|
api-json-name: [api-base.json, api-head.json]
|
||||||
include:
|
include:
|
||||||
- api-json-name: api-base.json
|
- api-json-name: api-base.json
|
||||||
repo-name: ${{ github.event.pull_request.base.repo.full_name }}
|
|
||||||
ref: ${{ github.base_ref }}
|
ref: ${{ github.base_ref }}
|
||||||
- api-json-name: api-head.json
|
- api-json-name: api-head.json
|
||||||
repo-name: ${{ github.event.pull_request.head.repo.full_name }}
|
ref: refs/pull/${{ github.event.number }}/merge
|
||||||
ref: ${{ github.head_ref }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4.1.1
|
- uses: actions/checkout@v4.1.1
|
||||||
with:
|
with:
|
||||||
repository: ${{ matrix.repo-name }}
|
|
||||||
ref: ${{ matrix.ref }}
|
ref: ${{ matrix.ref }}
|
||||||
submodules: true
|
submodules: true
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
|
|
|
||||||
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
- Fix: ページ一覧ページの表示がモバイル環境において崩れているのを修正
|
- Fix: ページ一覧ページの表示がモバイル環境において崩れているのを修正
|
||||||
|
- Fix: MFMでルビの中のテキストがnyaizeされない問題を修正
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
-
|
-
|
||||||
|
|
@ -21,15 +22,28 @@
|
||||||
- Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正
|
- Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
|
- Feat: 今日誕生日のフォロー中のユーザーを一覧表示できるウィジェットを追加
|
||||||
|
- Feat: データセーバーでコードハイライトの読み込みを削減できるように
|
||||||
|
- Enhance: 投稿フォームの絵文字ピッカーをリアクション時に使用するものと同じのを使用するように #12336
|
||||||
- Enhance: 絵文字のオートコンプリート機能強化 #12364
|
- Enhance: 絵文字のオートコンプリート機能強化 #12364
|
||||||
- Enhance: ユーザーのRawデータを表示するページが復活
|
- Enhance: ユーザーのRawデータを表示するページが復活
|
||||||
- Enhance: リアクション選択時に音を鳴らせるように
|
- Enhance: リアクション選択時に音を鳴らせるように
|
||||||
- Enhance: サウンドにドライブのファイルを使用できるように
|
- Enhance: サウンドにドライブのファイルを使用できるように
|
||||||
|
- Enhance: ナビゲーションバーに項目「キャッシュを削除」を追加
|
||||||
|
- Enhance: Shareページで投稿を完了すると、親ウィンドウ(親フレーム)にpostMessageするように
|
||||||
|
- Enhance: チャンネル、クリップ、ページ、Play、ギャラリーにURLのコピーボタンを設置 #11305
|
||||||
|
- Enhance: ノートプレビューに「内容を隠す」が反映されるように
|
||||||
|
- Enhance: データセーバーの適用範囲を個別で設定できるように
|
||||||
|
- 従来のデータセーバーの設定はリセットされます
|
||||||
- fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正
|
- fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正
|
||||||
- Fix: ウィジェットのジョブキューにて音声の発音方法変更に追従できていなかったのを修正 #12367
|
- Fix: ウィジェットのジョブキューにて音声の発音方法変更に追従できていなかったのを修正 #12367
|
||||||
|
- Enhance: 絵文字の詳細ページに記載される情報を追加
|
||||||
- Fix: コードエディタが正しく表示されない問題を修正
|
- Fix: コードエディタが正しく表示されない問題を修正
|
||||||
- Fix: プロフィールの「ファイル」にセンシティブな画像がある際のデザインを修正
|
- Fix: プロフィールの「ファイル」にセンシティブな画像がある際のデザインを修正
|
||||||
- Fix: 一度に大量の通知が入った際に通知音が音割れする問題を修正
|
- Fix: 一度に大量の通知が入った際に通知音が音割れする問題を修正
|
||||||
|
- Fix: 共有機能をサポートしていないブラウザの場合は共有ボタンを非表示にする #11305
|
||||||
|
- Fix: 通知のグルーピング設定を変更してもリロードされるまで表示が変わらない問題を修正 #12470
|
||||||
|
- Fix: 長い名前のチャンネルにおける投稿フォームの表示が崩れる問題を修正
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
- Enhance: MFM `$[ruby ]` が他ソフトウェアと連合されるように
|
- Enhance: MFM `$[ruby ]` が他ソフトウェアと連合されるように
|
||||||
|
|
@ -39,6 +53,7 @@
|
||||||
- Fix: 招待コードが使い回せる問題を修正
|
- Fix: 招待コードが使い回せる問題を修正
|
||||||
- Fix: 特定の条件下でチャンネルやユーザーのノート一覧に最新のノートが表示されなくなる問題を修正
|
- Fix: 特定の条件下でチャンネルやユーザーのノート一覧に最新のノートが表示されなくなる問題を修正
|
||||||
- Fix: 何もノートしていないユーザーのフィードにアクセスするとエラーになる問題を修正
|
- Fix: 何もノートしていないユーザーのフィードにアクセスするとエラーになる問題を修正
|
||||||
|
- Fix: リストタイムラインにてミュートが機能しないケースがある問題と、チャンネル投稿がストリーミングで流れてきてしまう問題を修正 #10443
|
||||||
|
|
||||||
## 2023.11.1
|
## 2023.11.1
|
||||||
|
|
||||||
|
|
@ -168,6 +183,7 @@
|
||||||
### Client
|
### Client
|
||||||
- Enhance: TLの返信表示オプションを記憶するように
|
- Enhance: TLの返信表示オプションを記憶するように
|
||||||
- Enhance: 投稿されてから時間が経過しているノートであることを視覚的に分かりやすく
|
- Enhance: 投稿されてから時間が経過しているノートであることを視覚的に分かりやすく
|
||||||
|
- Feat: 絵文字ピッカーのカテゴリに「/」を入れることでフォルダ分け表示できるように
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
- Enhance: タイムライン取得時のパフォーマンスを向上
|
- Enhance: タイムライン取得時のパフォーマンスを向上
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,7 @@ export interface Locale {
|
||||||
"createFolder": string;
|
"createFolder": string;
|
||||||
"renameFolder": string;
|
"renameFolder": string;
|
||||||
"deleteFolder": string;
|
"deleteFolder": string;
|
||||||
|
"folder": string;
|
||||||
"addFile": string;
|
"addFile": string;
|
||||||
"emptyDrive": string;
|
"emptyDrive": string;
|
||||||
"emptyFolder": string;
|
"emptyFolder": string;
|
||||||
|
|
@ -440,7 +441,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;
|
||||||
|
|
@ -1030,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;
|
||||||
|
|
@ -1170,6 +1172,8 @@ export interface Locale {
|
||||||
"signupPendingError": string;
|
"signupPendingError": string;
|
||||||
"cwNotationRequired": string;
|
"cwNotationRequired": string;
|
||||||
"doReaction": string;
|
"doReaction": string;
|
||||||
|
"code": string;
|
||||||
|
"reloadRequiredToApplySettings": string;
|
||||||
"_announcement": {
|
"_announcement": {
|
||||||
"forExistingUsers": string;
|
"forExistingUsers": string;
|
||||||
"forExistingUsersDescription": string;
|
"forExistingUsersDescription": string;
|
||||||
|
|
@ -2109,6 +2113,7 @@ export interface Locale {
|
||||||
"chooseList": string;
|
"chooseList": string;
|
||||||
};
|
};
|
||||||
"clicker": string;
|
"clicker": string;
|
||||||
|
"birthdayFollowings": string;
|
||||||
};
|
};
|
||||||
"_cw": {
|
"_cw": {
|
||||||
"hide": string;
|
"hide": string;
|
||||||
|
|
@ -2500,8 +2505,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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,7 @@ folderName: "フォルダー名"
|
||||||
createFolder: "フォルダーを作成"
|
createFolder: "フォルダーを作成"
|
||||||
renameFolder: "フォルダー名を変更"
|
renameFolder: "フォルダー名を変更"
|
||||||
deleteFolder: "フォルダーを削除"
|
deleteFolder: "フォルダーを削除"
|
||||||
|
folder: "フォルダー"
|
||||||
addFile: "ファイルを追加"
|
addFile: "ファイルを追加"
|
||||||
emptyDrive: "ドライブは空です"
|
emptyDrive: "ドライブは空です"
|
||||||
emptyFolder: "フォルダーは空です"
|
emptyFolder: "フォルダーは空です"
|
||||||
|
|
@ -437,7 +438,6 @@ share: "共有"
|
||||||
notFound: "見つかりません"
|
notFound: "見つかりません"
|
||||||
notFoundDescription: "指定されたURLに該当するページはありませんでした。"
|
notFoundDescription: "指定されたURLに該当するページはありませんでした。"
|
||||||
uploadFolder: "既定アップロード先"
|
uploadFolder: "既定アップロード先"
|
||||||
cacheClear: "キャッシュを削除"
|
|
||||||
markAsReadAllNotifications: "すべての通知を既読にする"
|
markAsReadAllNotifications: "すべての通知を既読にする"
|
||||||
markAsReadAllUnreadNotes: "すべての投稿を既読にする"
|
markAsReadAllUnreadNotes: "すべての投稿を既読にする"
|
||||||
markAsReadAllTalkMessages: "すべてのチャットを既読にする"
|
markAsReadAllTalkMessages: "すべてのチャットを既読にする"
|
||||||
|
|
@ -1027,6 +1027,8 @@ resetPasswordConfirm: "パスワードリセットしますか?"
|
||||||
sensitiveWords: "センシティブワード"
|
sensitiveWords: "センシティブワード"
|
||||||
sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。"
|
sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。"
|
||||||
sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。"
|
sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。"
|
||||||
|
hiddenTags: "非表示ハッシュタグ"
|
||||||
|
hiddenTagsDescription: "設定したタグをトレンドに表示させないようにします。改行で区切って複数設定できます。"
|
||||||
notesSearchNotAvailable: "ノート検索は利用できません。"
|
notesSearchNotAvailable: "ノート検索は利用できません。"
|
||||||
license: "ライセンス"
|
license: "ライセンス"
|
||||||
unfavoriteConfirm: "お気に入り解除しますか?"
|
unfavoriteConfirm: "お気に入り解除しますか?"
|
||||||
|
|
@ -1167,6 +1169,8 @@ useGroupedNotifications: "通知をグルーピングして表示する"
|
||||||
signupPendingError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。"
|
signupPendingError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。"
|
||||||
cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。"
|
cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。"
|
||||||
doReaction: "リアクションする"
|
doReaction: "リアクションする"
|
||||||
|
code: "コード"
|
||||||
|
reloadRequiredToApplySettings: "設定の反映にはリロードが必要です。"
|
||||||
|
|
||||||
_announcement:
|
_announcement:
|
||||||
forExistingUsers: "既存ユーザーのみ"
|
forExistingUsers: "既存ユーザーのみ"
|
||||||
|
|
@ -2013,6 +2017,7 @@ _widgets:
|
||||||
_userList:
|
_userList:
|
||||||
chooseList: "リストを選択"
|
chooseList: "リストを選択"
|
||||||
clicker: "クリッカー"
|
clicker: "クリッカー"
|
||||||
|
birthdayFollowings: "今日誕生日のユーザー"
|
||||||
|
|
||||||
_cw:
|
_cw:
|
||||||
hide: "隠す"
|
hide: "隠す"
|
||||||
|
|
@ -2387,3 +2392,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などでコードハイライト記法が使われている場合、タップするまで読み込まれなくなります。コードハイライトではハイライトする言語ごとにその定義ファイルを読み込む必要がありますが、それらが自動で読み込まれなくなるため、通信量の削減が見込めます。"
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"build-assets": "node ./scripts/build-assets.mjs",
|
"build-assets": "node ./scripts/build-assets.mjs",
|
||||||
"build": "pnpm build-pre && pnpm -r build && pnpm build-assets",
|
"build": "pnpm build-pre && pnpm -r build && pnpm build-assets",
|
||||||
"build-storybook": "pnpm --filter frontend build-storybook",
|
"build-storybook": "pnpm --filter frontend build-storybook",
|
||||||
|
"build-misskey-js-with-types": "pnpm --filter backend build && pnpm --filter backend generate-api-json && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build",
|
||||||
"start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js",
|
"start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js",
|
||||||
"start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js",
|
"start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js",
|
||||||
"init": "pnpm migrate",
|
"init": "pnpm migrate",
|
||||||
|
|
@ -57,7 +58,8 @@
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"cypress": "13.6.0",
|
"cypress": "13.6.0",
|
||||||
"eslint": "8.54.0",
|
"eslint": "8.54.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"
|
||||||
|
|
|
||||||
|
|
@ -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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||||
import { AccountMoveService } from './AccountMoveService.js';
|
import { AccountMoveService } from './AccountMoveService.js';
|
||||||
import { AccountUpdateService } from './AccountUpdateService.js';
|
import { AccountUpdateService } from './AccountUpdateService.js';
|
||||||
import { AiService } from './AiService.js';
|
import { AiService } from './AiService.js';
|
||||||
|
|
@ -195,6 +196,7 @@ const $SearchService: Provider = { provide: 'SearchService', useExisting: Search
|
||||||
const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService };
|
const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService };
|
||||||
const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService };
|
const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService };
|
||||||
const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService };
|
const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService };
|
||||||
|
const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService };
|
||||||
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
|
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
|
||||||
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
|
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
|
||||||
|
|
||||||
|
|
@ -331,6 +333,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
ClipService,
|
ClipService,
|
||||||
FeaturedService,
|
FeaturedService,
|
||||||
FanoutTimelineService,
|
FanoutTimelineService,
|
||||||
|
FanoutTimelineEndpointService,
|
||||||
ChannelFollowingService,
|
ChannelFollowingService,
|
||||||
RegistryApiService,
|
RegistryApiService,
|
||||||
ChartLoggerService,
|
ChartLoggerService,
|
||||||
|
|
@ -460,6 +463,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
$ClipService,
|
$ClipService,
|
||||||
$FeaturedService,
|
$FeaturedService,
|
||||||
$FanoutTimelineService,
|
$FanoutTimelineService,
|
||||||
|
$FanoutTimelineEndpointService,
|
||||||
$ChannelFollowingService,
|
$ChannelFollowingService,
|
||||||
$RegistryApiService,
|
$RegistryApiService,
|
||||||
$ChartLoggerService,
|
$ChartLoggerService,
|
||||||
|
|
@ -590,6 +594,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
ClipService,
|
ClipService,
|
||||||
FeaturedService,
|
FeaturedService,
|
||||||
FanoutTimelineService,
|
FanoutTimelineService,
|
||||||
|
FanoutTimelineEndpointService,
|
||||||
ChannelFollowingService,
|
ChannelFollowingService,
|
||||||
RegistryApiService,
|
RegistryApiService,
|
||||||
FederationChart,
|
FederationChart,
|
||||||
|
|
@ -718,6 +723,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
$ClipService,
|
$ClipService,
|
||||||
$FeaturedService,
|
$FeaturedService,
|
||||||
$FanoutTimelineService,
|
$FanoutTimelineService,
|
||||||
|
$FanoutTimelineEndpointService,
|
||||||
$ChannelFollowingService,
|
$ChannelFollowingService,
|
||||||
$RegistryApiService,
|
$RegistryApiService,
|
||||||
$FederationChart,
|
$FederationChart,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
/*
|
||||||
|
* 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 { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FanoutTimelineEndpointService {
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.notesRepository)
|
||||||
|
private notesRepository: NotesRepository,
|
||||||
|
|
||||||
|
private noteEntityService: NoteEntityService,
|
||||||
|
private fanoutTimelineService: FanoutTimelineService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
async timeline(ps: {
|
||||||
|
untilId: string | null,
|
||||||
|
sinceId: string | null,
|
||||||
|
limit: number,
|
||||||
|
allowPartial: boolean,
|
||||||
|
me?: { id: MiUser['id'] } | undefined | null,
|
||||||
|
useDbFallback: boolean,
|
||||||
|
redisTimelines: string[],
|
||||||
|
noteFilter: (note: MiNote) => boolean,
|
||||||
|
dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise<MiNote[]>,
|
||||||
|
}): Promise<Packed<'Note'>[]> {
|
||||||
|
return await this.noteEntityService.packMany(await this.getMiNotes(ps), ps.me);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async getMiNotes(ps: {
|
||||||
|
untilId: string | null,
|
||||||
|
sinceId: string | null,
|
||||||
|
limit: number,
|
||||||
|
allowPartial: boolean,
|
||||||
|
me?: { id: MiUser['id'] } | undefined | null,
|
||||||
|
useDbFallback: boolean,
|
||||||
|
redisTimelines: string[],
|
||||||
|
noteFilter: (note: MiNote) => boolean,
|
||||||
|
dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise<MiNote[]>,
|
||||||
|
}): Promise<MiNote[]> {
|
||||||
|
let noteIds: string[];
|
||||||
|
let shouldFallbackToDb = false;
|
||||||
|
|
||||||
|
// 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える
|
||||||
|
if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]);
|
||||||
|
|
||||||
|
const redisResult = await this.fanoutTimelineService.getMulti(ps.redisTimelines, ps.untilId, ps.sinceId);
|
||||||
|
|
||||||
|
const redisResultIds = Array.from(new Set(redisResult.flat(1)));
|
||||||
|
|
||||||
|
redisResultIds.sort((a, b) => a > b ? -1 : 1);
|
||||||
|
noteIds = redisResultIds.slice(0, ps.limit);
|
||||||
|
|
||||||
|
shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0);
|
||||||
|
|
||||||
|
if (!shouldFallbackToDb) {
|
||||||
|
const redisTimeline: MiNote[] = [];
|
||||||
|
let readFromRedis = 0;
|
||||||
|
let lastSuccessfulRate = 1; // rateをキャッシュする?
|
||||||
|
let trialCount = 1;
|
||||||
|
|
||||||
|
while ((redisResultIds.length - readFromRedis) !== 0) {
|
||||||
|
const remainingToRead = ps.limit - redisTimeline.length;
|
||||||
|
|
||||||
|
// DBからの取り直しを減らす初回と同じ割合以上で成功すると仮定するが、クエリの長さを考えて三倍まで
|
||||||
|
const countToGet = remainingToRead * Math.ceil(Math.min(1.1 / lastSuccessfulRate, 3));
|
||||||
|
noteIds = redisResultIds.slice(readFromRedis, readFromRedis + countToGet);
|
||||||
|
|
||||||
|
readFromRedis += noteIds.length;
|
||||||
|
|
||||||
|
const gotFromDb = await this.getAndFilterFromDb(noteIds, ps.noteFilter);
|
||||||
|
redisTimeline.push(...gotFromDb);
|
||||||
|
lastSuccessfulRate = gotFromDb.length / noteIds.length;
|
||||||
|
|
||||||
|
console.log(`fanoutTimelineTrial#${trialCount++}: req: ${ps.limit}, tried: ${noteIds.length}, got: ${gotFromDb.length}, rate: ${lastSuccessfulRate}, total: ${redisTimeline.length}, fromRedis: ${redisResultIds.length}`);
|
||||||
|
|
||||||
|
if (ps.allowPartial ? redisTimeline.length !== 0 : redisTimeline.length >= ps.limit) {
|
||||||
|
// 十分Redisからとれた
|
||||||
|
return redisTimeline.slice(0, ps.limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// まだ足りない分はDBにフォールバック
|
||||||
|
const remainingToRead = ps.limit - redisTimeline.length;
|
||||||
|
const gotFromDb = await ps.dbFallback(noteIds[noteIds.length - 1], ps.sinceId, remainingToRead);
|
||||||
|
redisTimeline.push(...gotFromDb);
|
||||||
|
console.log(`fanoutTimelineTrial#db: req: ${ps.limit}, tried: ${remainingToRead}, got: ${gotFromDb.length}, since: ${noteIds[noteIds.length - 1]}, until: ${ps.untilId}, total: ${redisTimeline.length}`);
|
||||||
|
return redisTimeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAndFilterFromDb(noteIds: string[], noteFilter: (note: MiNote) => boolean): 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) => a.id > b.id ? -1 : 1);
|
||||||
|
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 日時
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ export class NotePiningService {
|
||||||
} as MiUserNotePining);
|
} as MiUserNotePining);
|
||||||
|
|
||||||
// Deliver to remote followers
|
// Deliver to remote followers
|
||||||
if (this.userEntityService.isLocalUser(user)) {
|
if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) {
|
||||||
this.deliverPinnedChange(user.id, note.id, true);
|
this.deliverPinnedChange(user.id, note.id, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -105,7 +105,7 @@ export class NotePiningService {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Deliver to remote followers
|
// Deliver to remote followers
|
||||||
if (this.userEntityService.isLocalUser(user)) {
|
if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) {
|
||||||
this.deliverPinnedChange(user.id, noteId, false);
|
this.deliverPinnedChange(user.id, noteId, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -304,8 +304,6 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.fanoutTimelineService.purge(`homeTimeline:${follower.id}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish followed event
|
// Publish followed event
|
||||||
|
|
@ -373,8 +371,6 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.fanoutTimelineService.purge(`homeTimeline:${follower.id}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js';
|
||||||
import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js';
|
import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js';
|
||||||
import { packedFlashSchema } from '@/models/json-schema/flash.js';
|
import { packedFlashSchema } from '@/models/json-schema/flash.js';
|
||||||
import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js';
|
import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js';
|
||||||
|
import { packedSigninSchema } from '@/models/json-schema/signin.js';
|
||||||
|
|
||||||
export const refs = {
|
export const refs = {
|
||||||
UserLite: packedUserLiteSchema,
|
UserLite: packedUserLiteSchema,
|
||||||
|
|
@ -71,6 +72,7 @@ export const refs = {
|
||||||
EmojiSimple: packedEmojiSimpleSchema,
|
EmojiSimple: packedEmojiSimpleSchema,
|
||||||
EmojiDetailed: packedEmojiDetailedSchema,
|
EmojiDetailed: packedEmojiDetailedSchema,
|
||||||
Flash: packedFlashSchema,
|
Flash: packedFlashSchema,
|
||||||
|
Signin: packedSigninSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;
|
export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -370,8 +370,9 @@ export class ActivityPubServerService {
|
||||||
order: { id: 'DESC' },
|
order: { id: 'DESC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const pinnedNotes = await Promise.all(pinings.map(pining =>
|
const pinnedNotes = (await Promise.all(pinings.map(pining =>
|
||||||
this.notesRepository.findOneByOrFail({ id: pining.noteId })));
|
this.notesRepository.findOneByOrFail({ id: pining.noteId }))))
|
||||||
|
.filter(note => !note.localOnly && ['public', 'home'].includes(note.visibility));
|
||||||
|
|
||||||
const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note)));
|
const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note)));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -119,8 +119,8 @@ export class ServerService implements OnApplicationShutdown {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = path.split('@')[0].replace('.webp', '');
|
const name = path.split('@')[0].replace(/\.webp$/i, '');
|
||||||
const host = path.split('@')[1]?.replace('.webp', '');
|
const host = path.split('@')[1]?.replace(/\.webp$/i, '');
|
||||||
|
|
||||||
const emoji = await this.emojisRepository.findOneBy({
|
const emoji = await this.emojisRepository.findOneBy({
|
||||||
// `@.` is the spec of ReactionService.decodeReaction
|
// `@.` is the spec of ReactionService.decodeReaction
|
||||||
|
|
|
||||||
|
|
@ -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,27 @@ 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,
|
||||||
|
}, me);
|
||||||
|
|
||||||
this.globalEventService.publishBroadcastStream('emojiAdded', {
|
return this.emojiEntityService.packDetailed(addedEmoji);
|
||||||
emoji: await this.emojiEntityService.packDetailed(copied.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: copied.id,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -327,6 +327,82 @@ export const meta = {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
},
|
},
|
||||||
|
backgroundImageUrl: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
|
deeplAuthKey: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
|
deeplIsPro: {
|
||||||
|
type: 'boolean',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
defaultDarkTheme: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
|
defaultLightTheme: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
|
disableRegistration: {
|
||||||
|
type: 'boolean',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
impressumUrl: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
|
maintainerEmail: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
|
maintainerName: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
|
objectStorageS3ForcePathStyle: {
|
||||||
|
type: 'boolean',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
privacyPolicyUrl: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
|
repositoryUrl: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
summalyProxy: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
|
themeColor: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
|
tosUrl: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
|
uri: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,11 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
|
||||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
import { 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 +52,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 +60,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 +69,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private noteEntityService: NoteEntityService,
|
private noteEntityService: NoteEntityService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
private fanoutTimelineService: FanoutTimelineService,
|
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private activeUsersChart: ActiveUsersChart,
|
private activeUsersChart: ActiveUsersChart,
|
||||||
private metaService: MetaService,
|
private metaService: MetaService,
|
||||||
|
|
@ -78,7 +77,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 +90,58 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
if (me) this.activeUsersChart.read(me);
|
if (me) this.activeUsersChart.read(me);
|
||||||
|
|
||||||
if (serverSettings.enableFanoutTimeline && (isRangeSpecified || sinceId == null)) {
|
if (!serverSettings.enableFanoutTimeline) {
|
||||||
const [
|
return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id }, me), me);
|
||||||
userIdsWhoMeMuting,
|
|
||||||
] = me ? await Promise.all([
|
|
||||||
this.cacheService.userMutingsCache.fetch(me.id),
|
|
||||||
]) : [new Set<string>()];
|
|
||||||
|
|
||||||
let noteIds = await this.fanoutTimelineService.get(`channelTimeline:${channel.id}`, untilId, sinceId);
|
|
||||||
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
|
const [
|
||||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
userIdsWhoMeMuting,
|
||||||
.andWhere('note.channelId = :channelId', { channelId: channel.id })
|
] = me ? await Promise.all([
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
this.cacheService.userMutingsCache.fetch(me.id),
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
]) : [new Set<string>()];
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
|
||||||
.leftJoinAndSelect('note.channel', 'channel');
|
|
||||||
|
|
||||||
if (me) {
|
return await this.fanoutTimelineEndpointService.timeline({
|
||||||
this.queryService.generateMutedUserQuery(query, me);
|
untilId,
|
||||||
this.queryService.generateBlockedUserQuery(query, me);
|
sinceId,
|
||||||
}
|
limit: ps.limit,
|
||||||
//#endregion
|
allowPartial: ps.allowPartial,
|
||||||
|
me,
|
||||||
|
useDbFallback: true,
|
||||||
|
redisTimelines: [`channelTimeline:${channel.id}`],
|
||||||
|
noteFilter: note => {
|
||||||
|
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
|
||||||
|
|
||||||
const timeline = await query.limit(ps.limit).getMany();
|
return true;
|
||||||
|
},
|
||||||
return await this.noteEntityService.packMany(timeline, me);
|
dbFallback: async (untilId, sinceId, limit) => {
|
||||||
//#endregion
|
return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me);
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getFromDb(ps: {
|
||||||
|
untilId: string | null,
|
||||||
|
sinceId: string | null,
|
||||||
|
limit: number,
|
||||||
|
channelId: string
|
||||||
|
}, me: MiLocalUser | null) {
|
||||||
|
//#region fallback to database
|
||||||
|
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||||
|
.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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 { 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';
|
||||||
|
|
@ -19,6 +19,7 @@ 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 = {
|
||||||
|
|
@ -53,6 +54,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 +79,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private activeUsersChart: ActiveUsersChart,
|
private activeUsersChart: ActiveUsersChart,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private fanoutTimelineService: FanoutTimelineService,
|
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
private userFollowingService: UserFollowingService,
|
private userFollowingService: UserFollowingService,
|
||||||
private metaService: MetaService,
|
private metaService: MetaService,
|
||||||
|
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||||
|
|
@ -94,7 +96,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,
|
||||||
|
|
@ -104,6 +106,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
withFiles: ps.withFiles,
|
withFiles: ps.withFiles,
|
||||||
withReplies: ps.withReplies,
|
withReplies: ps.withReplies,
|
||||||
}, me);
|
}, me);
|
||||||
|
|
||||||
|
process.nextTick(() => {
|
||||||
|
this.activeUsersChart.read(me);
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.noteEntityService.packMany(timeline, me);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [
|
const [
|
||||||
|
|
@ -116,51 +124,35 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
this.cacheService.userBlockedCache.fetch(me.id),
|
this.cacheService.userBlockedCache.fetch(me.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let noteIds: string[];
|
let timelineConfig: string[];
|
||||||
let shouldFallbackToDb = false;
|
|
||||||
|
|
||||||
if (ps.withFiles) {
|
if (ps.withFiles) {
|
||||||
const [htlNoteIds, ltlNoteIds] = await this.fanoutTimelineService.getMulti([
|
timelineConfig = [
|
||||||
`homeTimelineWithFiles:${me.id}`,
|
`homeTimelineWithFiles:${me.id}`,
|
||||||
'localTimelineWithFiles',
|
'localTimelineWithFiles',
|
||||||
], untilId, sinceId);
|
];
|
||||||
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
|
|
||||||
} else if (ps.withReplies) {
|
} else if (ps.withReplies) {
|
||||||
const [htlNoteIds, ltlNoteIds, ltlReplyNoteIds] = await this.fanoutTimelineService.getMulti([
|
timelineConfig = [
|
||||||
`homeTimeline:${me.id}`,
|
`homeTimeline:${me.id}`,
|
||||||
'localTimeline',
|
'localTimeline',
|
||||||
'localTimelineWithReplies',
|
'localTimelineWithReplies',
|
||||||
], untilId, sinceId);
|
];
|
||||||
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds, ...ltlReplyNoteIds]));
|
|
||||||
} else {
|
} else {
|
||||||
const [htlNoteIds, ltlNoteIds] = await this.fanoutTimelineService.getMulti([
|
timelineConfig = [
|
||||||
`homeTimeline:${me.id}`,
|
`homeTimeline:${me.id}`,
|
||||||
'localTimeline',
|
'localTimeline',
|
||||||
], untilId, sinceId);
|
];
|
||||||
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
|
|
||||||
shouldFallbackToDb = htlNoteIds.length === 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
noteIds.sort((a, b) => a > b ? -1 : 1);
|
const redisTimeline = await this.fanoutTimelineEndpointService.timeline({
|
||||||
noteIds = noteIds.slice(0, ps.limit);
|
untilId,
|
||||||
|
sinceId,
|
||||||
shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0);
|
limit: ps.limit,
|
||||||
|
allowPartial: ps.allowPartial,
|
||||||
let redisTimeline: MiNote[] = [];
|
me,
|
||||||
|
redisTimelines: timelineConfig,
|
||||||
if (!shouldFallbackToDb) {
|
useDbFallback: serverSettings.enableFanoutTimelineDbFallback,
|
||||||
const query = this.notesRepository.createQueryBuilder('note')
|
noteFilter: (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) {
|
if (note.userId === me.id) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -174,33 +166,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
withReplies: ps.withReplies,
|
||||||
|
}, me),
|
||||||
|
});
|
||||||
|
|
||||||
redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
process.nextTick(() => {
|
||||||
}
|
this.activeUsersChart.read(me);
|
||||||
|
});
|
||||||
|
|
||||||
if (redisTimeline.length > 0) {
|
return redisTimeline;
|
||||||
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,
|
|
||||||
withReplies: ps.withReplies,
|
|
||||||
}, me);
|
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -301,12 +284,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,10 @@ 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 { isUserRelated } from '@/misc/is-user-related.js';
|
||||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
import { MiLocalUser } from '@/models/User.js';
|
import { MiLocalUser } from '@/models/User.js';
|
||||||
|
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
|
|
@ -48,10 +48,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 +69,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private activeUsersChart: ActiveUsersChart,
|
private activeUsersChart: ActiveUsersChart,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private fanoutTimelineService: FanoutTimelineService,
|
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
private metaService: MetaService,
|
private metaService: MetaService,
|
||||||
) {
|
) {
|
||||||
|
|
@ -85,13 +85,21 @@ 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,
|
||||||
withFiles: ps.withFiles,
|
withFiles: ps.withFiles,
|
||||||
withReplies: ps.withReplies,
|
withReplies: ps.withReplies,
|
||||||
}, me);
|
}, me);
|
||||||
|
|
||||||
|
process.nextTick(() => {
|
||||||
|
if (me) {
|
||||||
|
this.activeUsersChart.read(me);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.noteEntityService.packMany(timeline, me);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [
|
const [
|
||||||
|
|
@ -104,36 +112,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
this.cacheService.userBlockedCache.fetch(me.id),
|
this.cacheService.userBlockedCache.fetch(me.id),
|
||||||
]) : [new Set<string>(), new Set<string>(), new Set<string>()];
|
]) : [new Set<string>(), new Set<string>(), new Set<string>()];
|
||||||
|
|
||||||
let noteIds: string[];
|
const timeline = await this.fanoutTimelineEndpointService.timeline({
|
||||||
|
untilId,
|
||||||
if (ps.withFiles) {
|
sinceId,
|
||||||
noteIds = await this.fanoutTimelineService.get('localTimelineWithFiles', untilId, sinceId);
|
limit: ps.limit,
|
||||||
} else {
|
allowPartial: ps.allowPartial,
|
||||||
const [nonReplyNoteIds, replyNoteIds] = await this.fanoutTimelineService.getMulti([
|
me,
|
||||||
'localTimeline',
|
useDbFallback: serverSettings.enableFanoutTimelineDbFallback,
|
||||||
'localTimelineWithReplies',
|
redisTimelines: ps.withFiles ? ['localTimelineWithFiles'] : ['localTimeline', 'localTimelineWithReplies'],
|
||||||
], untilId, sinceId);
|
noteFilter: note => {
|
||||||
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)) {
|
if (me && (note.userId === me.id)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -148,32 +135,23 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
},
|
||||||
|
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
|
||||||
|
untilId,
|
||||||
|
sinceId,
|
||||||
|
limit,
|
||||||
|
withFiles: ps.withFiles,
|
||||||
|
withReplies: ps.withReplies,
|
||||||
|
}, me),
|
||||||
|
});
|
||||||
|
|
||||||
redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
process.nextTick(() => {
|
||||||
}
|
if (me) {
|
||||||
|
this.activeUsersChart.read(me);
|
||||||
if (redisTimeline.length > 0) {
|
|
||||||
process.nextTick(() => {
|
|
||||||
if (me) {
|
|
||||||
this.activeUsersChart.read(me);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return await this.noteEntityService.packMany(redisTimeline, me);
|
|
||||||
} else {
|
|
||||||
if (serverSettings.enableFanoutTimelineDbFallback) { // fallback to db
|
|
||||||
return await this.getFromDb({
|
|
||||||
untilId,
|
|
||||||
sinceId,
|
|
||||||
limit: ps.limit,
|
|
||||||
withFiles: ps.withFiles,
|
|
||||||
withReplies: ps.withReplies,
|
|
||||||
}, me);
|
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
|
return timeline;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,14 +192,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -14,10 +14,10 @@ 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 { isUserRelated } from '@/misc/is-user-related.js';
|
||||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
|
||||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||||
import { MiLocalUser } from '@/models/User.js';
|
import { MiLocalUser } from '@/models/User.js';
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
|
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['notes'],
|
tags: ['notes'],
|
||||||
|
|
@ -43,6 +43,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 +66,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private activeUsersChart: ActiveUsersChart,
|
private activeUsersChart: ActiveUsersChart,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private fanoutTimelineService: FanoutTimelineService,
|
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||||
private userFollowingService: UserFollowingService,
|
private userFollowingService: UserFollowingService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
private metaService: MetaService,
|
private metaService: MetaService,
|
||||||
|
|
@ -77,7 +78,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,6 +88,12 @@ 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 [
|
||||||
|
|
@ -101,24 +108,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
this.cacheService.userBlockedCache.fetch(me.id),
|
this.cacheService.userBlockedCache.fetch(me.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let noteIds = await this.fanoutTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId);
|
const timeline = this.fanoutTimelineEndpointService.timeline({
|
||||||
noteIds = noteIds.slice(0, ps.limit);
|
untilId,
|
||||||
|
sinceId,
|
||||||
let redisTimeline: MiNote[] = [];
|
limit: ps.limit,
|
||||||
|
allowPartial: ps.allowPartial,
|
||||||
if (noteIds.length > 0) {
|
me,
|
||||||
const query = this.notesRepository.createQueryBuilder('note')
|
useDbFallback: serverSettings.enableFanoutTimelineDbFallback,
|
||||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
redisTimelines: ps.withFiles ? [`homeTimelineWithFiles:${me.id}`] : [`homeTimeline:${me.id}`],
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
noteFilter: note => {
|
||||||
.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) {
|
if (note.userId === me.id) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -135,33 +133,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +258,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
import { MiLocalUser } from '@/models/User.js';
|
import { MiLocalUser } from '@/models/User.js';
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
|
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||||
|
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
|
|
@ -52,6 +54,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 },
|
||||||
|
|
@ -82,6 +85,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private fanoutTimelineService: FanoutTimelineService,
|
private fanoutTimelineService: FanoutTimelineService,
|
||||||
|
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
private metaService: MetaService,
|
private metaService: MetaService,
|
||||||
) {
|
) {
|
||||||
|
|
@ -101,7 +105,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,36 +115,33 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
withFiles: ps.withFiles,
|
withFiles: ps.withFiles,
|
||||||
withRenotes: ps.withRenotes,
|
withRenotes: ps.withRenotes,
|
||||||
}, me);
|
}, me);
|
||||||
|
|
||||||
|
this.activeUsersChart.read(me);
|
||||||
|
|
||||||
|
await this.noteEntityService.packMany(timeline, me);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [
|
const [
|
||||||
userIdsWhoMeMuting,
|
userIdsWhoMeMuting,
|
||||||
userIdsWhoMeMutingRenotes,
|
userIdsWhoMeMutingRenotes,
|
||||||
userIdsWhoBlockingMe,
|
userIdsWhoBlockingMe,
|
||||||
|
userMutedInstances,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.cacheService.userMutingsCache.fetch(me.id),
|
this.cacheService.userMutingsCache.fetch(me.id),
|
||||||
this.cacheService.renoteMutingsCache.fetch(me.id),
|
this.cacheService.renoteMutingsCache.fetch(me.id),
|
||||||
this.cacheService.userBlockedCache.fetch(me.id),
|
this.cacheService.userBlockedCache.fetch(me.id),
|
||||||
|
this.cacheService.userProfileCache.fetch(me.id).then(p => new Set(p.mutedInstances)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let noteIds = await this.fanoutTimelineService.get(ps.withFiles ? `userListTimelineWithFiles:${list.id}` : `userListTimeline:${list.id}`, untilId, sinceId);
|
const timeline = await 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 ? [`userListTimelineWithFiles:${list.id}`] : [`userListTimeline:${list.id}`],
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
noteFilter: note => {
|
||||||
.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) {
|
if (note.userId === me.id) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -152,32 +153,25 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
if (ps.withRenotes === false) return false;
|
if (ps.withRenotes === false) return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isInstanceMuted(note, userMutedInstances)) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
},
|
||||||
|
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb(list, {
|
||||||
|
untilId,
|
||||||
|
sinceId,
|
||||||
|
limit,
|
||||||
|
includeMyRenotes: ps.includeMyRenotes,
|
||||||
|
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
||||||
|
includeLocalRenotes: ps.includeLocalRenotes,
|
||||||
|
withFiles: ps.withFiles,
|
||||||
|
withRenotes: ps.withRenotes,
|
||||||
|
}, me),
|
||||||
|
});
|
||||||
|
|
||||||
redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
this.activeUsersChart.read(me);
|
||||||
}
|
|
||||||
|
|
||||||
if (redisTimeline.length > 0) {
|
return timeline;
|
||||||
this.activeUsersChart.read(me);
|
|
||||||
return await this.noteEntityService.packMany(redisTimeline, me);
|
|
||||||
} else {
|
|
||||||
if (serverSettings.enableFanoutTimelineDbFallback) { // fallback to db
|
|
||||||
return await this.getFromDb(list, {
|
|
||||||
untilId,
|
|
||||||
sinceId,
|
|
||||||
limit: ps.limit,
|
|
||||||
includeMyRenotes: ps.includeMyRenotes,
|
|
||||||
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
|
||||||
includeLocalRenotes: ps.includeLocalRenotes,
|
|
||||||
withFiles: ps.withFiles,
|
|
||||||
withRenotes: ps.withRenotes,
|
|
||||||
}, me);
|
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,10 +265,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@
|
||||||
|
|
||||||
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';
|
||||||
|
|
@ -14,9 +13,9 @@ 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 { isUserRelated } from '@/misc/is-user-related.js';
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { MiLocalUser } from '@/models/User.js';
|
||||||
|
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['users', 'notes'],
|
tags: ['users', 'notes'],
|
||||||
|
|
@ -52,8 +51,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 +60,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,121 +67,133 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private fanoutTimelineService: FanoutTimelineService,
|
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||||
private metaService: MetaService,
|
private metaService: MetaService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||||
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
|
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
|
||||||
const isRangeSpecified = untilId != null && sinceId != null;
|
|
||||||
const isSelf = me && (me.id === ps.userId);
|
const isSelf = me && (me.id === ps.userId);
|
||||||
|
|
||||||
const serverSettings = await this.metaService.fetch();
|
const serverSettings = await this.metaService.fetch();
|
||||||
|
|
||||||
if (serverSettings.enableFanoutTimeline && (isRangeSpecified || sinceId == null)) {
|
if (!serverSettings.enableFanoutTimeline) {
|
||||||
const [
|
const timeline = await this.getFromDb({
|
||||||
userIdsWhoMeMuting,
|
untilId,
|
||||||
] = me ? await Promise.all([
|
sinceId,
|
||||||
this.cacheService.userMutingsCache.fetch(me.id),
|
limit: ps.limit,
|
||||||
]) : [new Set<string>()];
|
userId: ps.userId,
|
||||||
|
withChannelNotes: ps.withChannelNotes,
|
||||||
|
withFiles: ps.withFiles,
|
||||||
|
withRenotes: ps.withRenotes,
|
||||||
|
}, me);
|
||||||
|
|
||||||
const [noteIdsRes, repliesNoteIdsRes, channelNoteIdsRes] = await Promise.all([
|
return await this.noteEntityService.packMany(timeline, me);
|
||||||
this.fanoutTimelineService.get(ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`, untilId, sinceId),
|
}
|
||||||
ps.withReplies ? this.fanoutTimelineService.get(`userTimelineWithReplies:${ps.userId}`, untilId, sinceId) : Promise.resolve([]),
|
|
||||||
ps.withChannelNotes ? this.fanoutTimelineService.get(`userTimelineWithChannel:${ps.userId}`, untilId, sinceId) : Promise.resolve([]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let noteIds = Array.from(new Set([
|
const [
|
||||||
...noteIdsRes,
|
userIdsWhoMeMuting,
|
||||||
...repliesNoteIdsRes,
|
] = me ? await Promise.all([
|
||||||
...channelNoteIdsRes,
|
this.cacheService.userMutingsCache.fetch(me.id),
|
||||||
]));
|
]) : [new Set<string>()];
|
||||||
noteIds.sort((a, b) => a > b ? -1 : 1);
|
|
||||||
noteIds = noteIds.slice(0, ps.limit);
|
|
||||||
|
|
||||||
if (noteIds.length > 0) {
|
const redisTimelines = [ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`];
|
||||||
const isFollowing = me && Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId);
|
|
||||||
|
|
||||||
const query = this.notesRepository.createQueryBuilder('note')
|
if (ps.withReplies) redisTimelines.push(`userTimelineWithReplies:${ps.userId}`);
|
||||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
if (ps.withChannelNotes) redisTimelines.push(`userTimelineWithChannel:${ps.userId}`);
|
||||||
.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();
|
const isFollowing = me && Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId);
|
||||||
|
|
||||||
timeline = timeline.filter(note => {
|
const timeline = await this.fanoutTimelineEndpointService.timeline({
|
||||||
if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false;
|
untilId,
|
||||||
|
sinceId,
|
||||||
if (note.renoteId) {
|
limit: ps.limit,
|
||||||
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
|
allowPartial: ps.allowPartial,
|
||||||
if (ps.withRenotes === false) return false;
|
me,
|
||||||
}
|
redisTimelines,
|
||||||
}
|
useDbFallback: true,
|
||||||
|
noteFilter: note => {
|
||||||
if (note.channel?.isSensitive && !isSelf) return false;
|
if (ps.withFiles && note.fileIds.length === 0) {
|
||||||
if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false;
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false;
|
||||||
}
|
|
||||||
|
|
||||||
//#region fallback to database
|
if (note.renoteId) {
|
||||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
|
||||||
.andWhere('note.userId = :userId', { userId: ps.userId })
|
if (ps.withRenotes === false) return false;
|
||||||
.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 (note.channel?.isSensitive && !isSelf) return false;
|
||||||
if (!isSelf) query.andWhere(new Brackets(qb => {
|
if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false;
|
||||||
qb.orWhere('note.channelId IS NULL');
|
if (note.visibility === 'followers' && !isFollowing && !isSelf) return false;
|
||||||
qb.orWhere('channel.isSensitive = false');
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
query.andWhere('note.channelId IS NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.queryService.generateVisibilityQuery(query, me);
|
return true;
|
||||||
if (me) {
|
},
|
||||||
this.queryService.generateMutedUserQuery(query, me, { id: ps.userId });
|
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
|
||||||
this.queryService.generateBlockedUserQuery(query, me);
|
untilId,
|
||||||
}
|
sinceId,
|
||||||
|
limit,
|
||||||
|
userId: ps.userId,
|
||||||
|
withChannelNotes: ps.withChannelNotes,
|
||||||
|
withFiles: ps.withFiles,
|
||||||
|
withRenotes: ps.withRenotes,
|
||||||
|
}, me),
|
||||||
|
});
|
||||||
|
|
||||||
if (ps.withFiles) {
|
return timeline;
|
||||||
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)');
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -202,20 +202,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', () => {
|
||||||
navigator.wakeLock.request('screen')
|
if (document.visibilityState === 'visible') {
|
||||||
.then(() => {
|
navigator.wakeLock.request('screen');
|
||||||
document.addEventListener('visibilitychange', async () => {
|
|
||||||
if (document.visibilityState === 'visible') {
|
|
||||||
navigator.wakeLock.request('screen');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// If Permission fails on an AppleDevice such as Safari
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(' / ');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- このコンポーネントの要素のclassは親から利用されるのでむやみに弄らないこと -->
|
<!-- このコンポーネントの要素のclassは親から利用されるのでむやみに弄らないこと -->
|
||||||
<section>
|
<!-- フォルダの中にはカスタム絵文字だけ(Unicode絵文字もこっち) -->
|
||||||
|
<section v-if="!hasChildSection" v-panel style="border-radius: 6px; border-bottom: 0.5px solid var(--divider);">
|
||||||
<header class="_acrylic" @click="shown = !shown">
|
<header class="_acrylic" @click="shown = !shown">
|
||||||
<i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> <slot></slot> ({{ emojis.length }})
|
<i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> <slot></slot> (<i class="ti ti-icons"></i>:{{ emojis.length }})
|
||||||
</header>
|
</header>
|
||||||
<div v-if="shown" class="body">
|
<div v-if="shown" class="body">
|
||||||
<button
|
<button
|
||||||
|
|
@ -23,15 +24,52 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<!-- フォルダの中にはカスタム絵文字やフォルダがある -->
|
||||||
|
<section v-else v-panel style="border-radius: 6px; border-bottom: 0.5px solid var(--divider);">
|
||||||
|
<header class="_acrylic" @click="shown = !shown">
|
||||||
|
<i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> <slot></slot> (<i class="ti ti-folder ti-fw"></i>:{{ customEmojiTree.length }} <i class="ti ti-icons ti-fw"></i>:{{ emojis.length }})
|
||||||
|
</header>
|
||||||
|
<div v-if="shown" style="padding-left: 9px;">
|
||||||
|
<MkEmojiPickerSection
|
||||||
|
v-for="child in customEmojiTree"
|
||||||
|
:key="`custom:${child.value}`"
|
||||||
|
:initialShown="initialShown"
|
||||||
|
:emojis="computed(() => customEmojis.filter(e => e.category === child.category).map(e => `:${e.name}:`))"
|
||||||
|
:hasChildSection="child.children.length !== 0"
|
||||||
|
:customEmojiTree="child.children"
|
||||||
|
@chosen="nestedChosen"
|
||||||
|
>
|
||||||
|
{{ child.value || i18n.ts.other }}
|
||||||
|
</MkEmojiPickerSection>
|
||||||
|
</div>
|
||||||
|
<div v-if="shown" class="body">
|
||||||
|
<button
|
||||||
|
v-for="emoji in emojis"
|
||||||
|
:key="emoji"
|
||||||
|
:data-emoji="emoji"
|
||||||
|
class="_button item"
|
||||||
|
@pointerenter="computeButtonTitle"
|
||||||
|
@click="emit('chosen', emoji, $event)"
|
||||||
|
>
|
||||||
|
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
|
||||||
|
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, computed, Ref } from 'vue';
|
import { ref, computed, Ref } from 'vue';
|
||||||
import { getEmojiName } from '@/scripts/emojilist.js';
|
import { CustomEmojiFolderTree, getEmojiName } from '@/scripts/emojilist.js';
|
||||||
|
import { i18n } from '../i18n.js';
|
||||||
|
import { customEmojis } from '@/custom-emojis.js';
|
||||||
|
import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
emojis: string[] | Ref<string[]>;
|
emojis: string[] | Ref<string[]>;
|
||||||
initialShown?: boolean;
|
initialShown?: boolean;
|
||||||
|
hasChildSection?: boolean;
|
||||||
|
customEmojiTree?: CustomEmojiFolderTree[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -49,4 +87,7 @@ function computeButtonTitle(ev: MouseEvent): void {
|
||||||
elm.title = getEmojiName(emoji) ?? emoji;
|
elm.title = getEmojiName(emoji) ?? emoji;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nestedChosen(emoji: any, ev?: MouseEvent) {
|
||||||
|
emit('chosen', emoji, ev);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="tab === 'index'" class="group index">
|
<div v-if="tab === 'index'" class="group index">
|
||||||
<section v-if="showPinned">
|
<section v-if="showPinned && pinned.length > 0">
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<button
|
<button
|
||||||
v-for="emoji in pinned"
|
v-for="emoji in pinned"
|
||||||
|
|
@ -73,18 +73,20 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div v-once class="group">
|
<div v-once class="group">
|
||||||
<header class="_acrylic">{{ i18n.ts.customEmojis }}</header>
|
<header class="_acrylic">{{ i18n.ts.customEmojis }}</header>
|
||||||
<XSection
|
<XSection
|
||||||
v-for="category in customEmojiCategories"
|
v-for="child in customEmojiFolderRoot.children"
|
||||||
:key="`custom:${category}`"
|
:key="`custom:${child.value}`"
|
||||||
:initialShown="false"
|
:initialShown="false"
|
||||||
:emojis="computed(() => customEmojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category).filter(filterAvailable).map(e => `:${e.name}:`))"
|
:emojis="computed(() => customEmojis.filter(e => child.value === '' ? (e.category === 'null' || !e.category) : e.category === child.value).filter(filterAvailable).map(e => `:${e.name}:`))"
|
||||||
|
:hasChildSection="child.children.length !== 0"
|
||||||
|
:customEmojiTree="child.children"
|
||||||
@chosen="chosen"
|
@chosen="chosen"
|
||||||
>
|
>
|
||||||
{{ category || i18n.ts.other }}
|
{{ child.value || i18n.ts.other }}
|
||||||
</XSection>
|
</XSection>
|
||||||
</div>
|
</div>
|
||||||
<div v-once class="group">
|
<div v-once class="group">
|
||||||
<header class="_acrylic">{{ i18n.ts.emoji }}</header>
|
<header class="_acrylic">{{ i18n.ts.emoji }}</header>
|
||||||
<XSection v-for="category in categories" :key="category" :emojis="emojiCharByCategory.get(category) ?? []" @chosen="chosen">{{ category }}</XSection>
|
<XSection v-for="category in categories" :key="category" :emojis="emojiCharByCategory.get(category) ?? []" :hasChildSection="false" @chosen="chosen">{{ category }}</XSection>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
|
|
@ -100,7 +102,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import { ref, shallowRef, computed, watch, onMounted } from 'vue';
|
import { ref, shallowRef, computed, watch, onMounted } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import XSection from '@/components/MkEmojiPicker.section.vue';
|
import XSection from '@/components/MkEmojiPicker.section.vue';
|
||||||
import { emojilist, emojiCharByCategory, UnicodeEmojiDef, unicodeEmojiCategories as categories, getEmojiName } from '@/scripts/emojilist.js';
|
import {
|
||||||
|
emojilist,
|
||||||
|
emojiCharByCategory,
|
||||||
|
UnicodeEmojiDef,
|
||||||
|
unicodeEmojiCategories as categories,
|
||||||
|
getEmojiName,
|
||||||
|
CustomEmojiFolderTree
|
||||||
|
} from '@/scripts/emojilist.js';
|
||||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { isTouchUsing } from '@/scripts/touch.js';
|
import { isTouchUsing } from '@/scripts/touch.js';
|
||||||
|
|
@ -128,7 +137,7 @@ const searchEl = shallowRef<HTMLInputElement>();
|
||||||
const emojisEl = shallowRef<HTMLDivElement>();
|
const emojisEl = shallowRef<HTMLDivElement>();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
reactions: pinned,
|
reactions: pinnedReactions,
|
||||||
reactionPickerSize,
|
reactionPickerSize,
|
||||||
reactionPickerWidth,
|
reactionPickerWidth,
|
||||||
reactionPickerHeight,
|
reactionPickerHeight,
|
||||||
|
|
@ -136,14 +145,44 @@ const {
|
||||||
recentlyUsedEmojis,
|
recentlyUsedEmojis,
|
||||||
} = defaultStore.reactiveState;
|
} = defaultStore.reactiveState;
|
||||||
|
|
||||||
|
const pinned = computed(() => props.asReactionPicker ? pinnedReactions.value : []); // TODO: 非リアクションの絵文字ピッカー用のpinned絵文字を設定可能にする?
|
||||||
const size = computed(() => props.asReactionPicker ? reactionPickerSize.value : 1);
|
const size = computed(() => props.asReactionPicker ? reactionPickerSize.value : 1);
|
||||||
const width = computed(() => props.asReactionPicker ? reactionPickerWidth.value : 3);
|
const width = computed(() => props.asReactionPicker ? reactionPickerWidth.value : 3);
|
||||||
const height = computed(() => props.asReactionPicker ? reactionPickerHeight.value : 2);
|
const height = computed(() => props.asReactionPicker ? reactionPickerHeight.value : 2);
|
||||||
const q = ref<string>('');
|
const q = ref<string>('');
|
||||||
const searchResultCustom = ref<Misskey.entities.CustomEmoji[]>([]);
|
const searchResultCustom = ref<Misskey.entities.EmojiSimple[]>([]);
|
||||||
const searchResultUnicode = ref<UnicodeEmojiDef[]>([]);
|
const searchResultUnicode = ref<UnicodeEmojiDef[]>([]);
|
||||||
const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index');
|
const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index');
|
||||||
|
|
||||||
|
const customEmojiFolderRoot: CustomEmojiFolderTree = { value: "", category: "", children: [] };
|
||||||
|
|
||||||
|
function parseAndMergeCategories(input: string, root: CustomEmojiFolderTree): CustomEmojiFolderTree {
|
||||||
|
const parts = input.split('/').map(p => p.trim());
|
||||||
|
let currentNode: CustomEmojiFolderTree = root;
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
let existingNode = currentNode.children.find((node) => node.value === part);
|
||||||
|
|
||||||
|
if (!existingNode) {
|
||||||
|
const newNode: CustomEmojiFolderTree = { value: part, category: input, children: [] };
|
||||||
|
currentNode.children.push(newNode);
|
||||||
|
existingNode = newNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = existingNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
customEmojiCategories.value.forEach(ec => {
|
||||||
|
if (ec !== null) {
|
||||||
|
parseAndMergeCategories(ec, customEmojiFolderRoot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
parseAndMergeCategories('', customEmojiFolderRoot);
|
||||||
|
|
||||||
watch(q, () => {
|
watch(q, () => {
|
||||||
if (emojisEl.value) emojisEl.value.scrollTop = 0;
|
if (emojisEl.value) emojisEl.value.scrollTop = 0;
|
||||||
|
|
||||||
|
|
@ -158,7 +197,7 @@ watch(q, () => {
|
||||||
const searchCustom = () => {
|
const searchCustom = () => {
|
||||||
const max = 100;
|
const max = 100;
|
||||||
const emojis = customEmojis.value;
|
const emojis = customEmojis.value;
|
||||||
const matches = new Set<Misskey.entities.CustomEmoji>();
|
const matches = new Set<Misskey.entities.EmojiSimple>();
|
||||||
|
|
||||||
const exactMatch = emojis.find(emoji => emoji.name === newQ);
|
const exactMatch = emojis.find(emoji => emoji.name === newQ);
|
||||||
if (exactMatch) matches.add(exactMatch);
|
if (exactMatch) matches.add(exactMatch);
|
||||||
|
|
@ -288,7 +327,7 @@ watch(q, () => {
|
||||||
searchResultUnicode.value = Array.from(searchUnicode());
|
searchResultUnicode.value = Array.from(searchUnicode());
|
||||||
});
|
});
|
||||||
|
|
||||||
function filterAvailable(emoji: Misskey.entities.CustomEmoji): boolean {
|
function filterAvailable(emoji: Misskey.entities.EmojiSimple): boolean {
|
||||||
return (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction == null || emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0) || ($i && $i.roles.some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id)));
|
return (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction == null || emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0) || ($i && $i.roles.some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,7 +344,7 @@ function reset() {
|
||||||
q.value = '';
|
q.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getKey(emoji: string | Misskey.entities.CustomEmoji | UnicodeEmojiDef): string {
|
function getKey(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef): string {
|
||||||
return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`;
|
return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -572,8 +611,7 @@ defineExpose({
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
height: 32px;
|
line-height: 28px;
|
||||||
line-height: 32px;
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
|
||||||
|
|
@ -31,20 +31,21 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { shallowRef } from 'vue';
|
|
||||||
import MkModal from '@/components/MkModal.vue';
|
import MkModal from '@/components/MkModal.vue';
|
||||||
import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
|
import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
manualShowing?: boolean | null;
|
manualShowing?: boolean | null;
|
||||||
src?: HTMLElement;
|
src?: HTMLElement;
|
||||||
showPinned?: boolean;
|
showPinned?: boolean;
|
||||||
asReactionPicker?: boolean;
|
asReactionPicker?: boolean;
|
||||||
|
choseAndClose?: boolean;
|
||||||
}>(), {
|
}>(), {
|
||||||
manualShowing: null,
|
manualShowing: null,
|
||||||
showPinned: true,
|
showPinned: true,
|
||||||
asReactionPicker: false,
|
asReactionPicker: false,
|
||||||
|
choseAndClose: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -53,21 +54,23 @@ const emit = defineEmits<{
|
||||||
(ev: 'closed'): void;
|
(ev: 'closed'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
const modal = $shallowRef<InstanceType<typeof MkModal>>();
|
||||||
const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>();
|
const picker = $shallowRef<InstanceType<typeof MkEmojiPicker>>();
|
||||||
|
|
||||||
function chosen(emoji: any) {
|
function chosen(emoji: any) {
|
||||||
emit('done', emoji);
|
emit('done', emoji);
|
||||||
modal.value?.close();
|
if (props.choseAndClose) {
|
||||||
|
modal?.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function opening() {
|
function opening() {
|
||||||
picker.value?.reset();
|
picker?.reset();
|
||||||
picker.value?.focus();
|
picker?.focus();
|
||||||
|
|
||||||
// 何故かちょっと待たないとフォーカスされない
|
// 何故かちょっと待たないとフォーカスされない
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
picker.value?.focus();
|
picker?.focus();
|
||||||
}, 10);
|
}, 10);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { ref } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
|
|
||||||
const meta = ref<Misskey.entities.DetailedInstanceMetadata>();
|
const meta = ref<Misskey.entities.MetaResponse>();
|
||||||
|
|
||||||
os.api('meta', { detail: true }).then(gotMeta => {
|
os.api('meta', { detail: true }).then(gotMeta => {
|
||||||
meta.value = gotMeta;
|
meta.value = gotMeta;
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,15 @@ import * as os from '@/os.js';
|
||||||
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
|
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
instance: Misskey.entities.Instance;
|
instance: Misskey.entities.FederationInstance;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let chartValues = $ref<number[] | null>(null);
|
let chartValues = $ref<number[] | null>(null);
|
||||||
|
|
||||||
os.apiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => {
|
os.apiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => {
|
||||||
// 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く
|
// 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く
|
||||||
res.requests.received.splice(0, 1);
|
res['requests.received'].splice(0, 1);
|
||||||
chartValues = res.requests.received;
|
chartValues = res['requests.received'];
|
||||||
});
|
});
|
||||||
|
|
||||||
function getInstanceIcon(instance): string {
|
function getInstanceIcon(instance): string {
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ import { i18n } from '@/i18n.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
invite: Misskey.entities.Invite;
|
invite: Misskey.entities.InviteCode;
|
||||||
moderator?: boolean;
|
moderator?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,8 @@ function close() {
|
||||||
vertical-align: bottom;
|
vertical-align: bottom;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
>
|
>
|
||||||
<ImgWithBlurhash
|
<ImgWithBlurhash
|
||||||
:hash="image.blurhash"
|
:hash="image.blurhash"
|
||||||
:src="(defaultStore.state.enableDataSaverMode && hide) ? null : url"
|
:src="(defaultStore.state.dataSaver.media && hide) ? null : url"
|
||||||
:forceBlurhash="hide"
|
:forceBlurhash="hide"
|
||||||
:cover="hide || cover"
|
:cover="hide || cover"
|
||||||
:alt="image.comment || image.name"
|
:alt="image.comment || image.name"
|
||||||
|
|
@ -32,8 +32,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template v-if="hide">
|
<template v-if="hide">
|
||||||
<div :class="$style.hiddenText">
|
<div :class="$style.hiddenText">
|
||||||
<div :class="$style.hiddenTextWrapper">
|
<div :class="$style.hiddenTextWrapper">
|
||||||
<b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
|
<b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
|
||||||
<b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b>
|
<b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && image.size ? bytes(image.size) : i18n.ts.image }}</b>
|
||||||
<span v-if="controls" style="display: block;">{{ i18n.ts.clickToShow }}</span>
|
<span v-if="controls" style="display: block;">{{ i18n.ts.clickToShow }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -94,7 +94,7 @@ function onclick() {
|
||||||
|
|
||||||
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
|
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
|
||||||
watch(() => props.image, () => {
|
watch(() => props.image, () => {
|
||||||
hide = (defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore');
|
hide = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore');
|
||||||
}, {
|
}, {
|
||||||
deep: true,
|
deep: true,
|
||||||
immediate: true,
|
immediate: true,
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div v-if="hide" :class="[$style.hidden, (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitiveContainer]" @click="hide = false">
|
<div v-if="hide" :class="[$style.hidden, (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitiveContainer]" @click="hide = false">
|
||||||
<!-- 【注意】dataSaverMode が有効になっている際には、hide が false になるまでサムネイルや動画を読み込まないようにすること -->
|
<!-- 【注意】dataSaverMode が有効になっている際には、hide が false になるまでサムネイルや動画を読み込まないようにすること -->
|
||||||
<div :class="$style.sensitive">
|
<div :class="$style.sensitive">
|
||||||
<b v-if="video.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b>
|
<b v-if="video.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b>
|
||||||
<b v-else style="display: block;"><i class="ti ti-movie"></i> {{ defaultStore.state.enableDataSaverMode && video.size ? bytes(video.size) : i18n.ts.video }}</b>
|
<b v-else style="display: block;"><i class="ti ti-movie"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b>
|
||||||
<span>{{ i18n.ts.clickToShow }}</span>
|
<span>{{ i18n.ts.clickToShow }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -43,7 +43,7 @@ const props = defineProps<{
|
||||||
video: Misskey.entities.DriveFile;
|
video: Misskey.entities.DriveFile;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'));
|
const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'));
|
||||||
|
|
||||||
const videoEl = shallowRef<HTMLVideoElement>();
|
const videoEl = shallowRef<HTMLVideoElement>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div style="container-type: inline-size;">
|
<div style="container-type: inline-size;">
|
||||||
<p v-if="appearNote.cw != null" :class="$style.cw">
|
<p v-if="appearNote.cw != null" :class="$style.cw">
|
||||||
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
|
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
|
||||||
<MkCwButton v-model="showContent" :note="appearNote" style="margin: 4px 0;"/>
|
<MkCwButton v-model="showContent" :text="appearNote.text" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;"/>
|
||||||
</p>
|
</p>
|
||||||
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
|
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
|
||||||
<div :class="$style.text">
|
<div :class="$style.text">
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div :class="$style.noteContent">
|
<div :class="$style.noteContent">
|
||||||
<p v-if="appearNote.cw != null" :class="$style.cw">
|
<p v-if="appearNote.cw != null" :class="$style.cw">
|
||||||
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
|
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
|
||||||
<MkCwButton v-model="showContent" :note="appearNote"/>
|
<MkCwButton v-model="showContent" :text="appearNote.text" :files="appearNote.files" :poll="appearNote.poll"/>
|
||||||
</p>
|
</p>
|
||||||
<div v-show="appearNote.cw == null || showContent">
|
<div v-show="appearNote.cw == null || showContent">
|
||||||
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
|
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkUserName :user="user" :nowrap="true"/>
|
<MkUserName :user="user" :nowrap="true"/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<p v-if="useCw" :class="$style.cw">
|
||||||
|
<Mfm v-if="cw != ''" :text="cw" :author="user" :nyaize="'respect'" :i="user" style="margin-right: 8px;"/>
|
||||||
|
<MkCwButton v-model="showContent" :text="text.trim()" :files="files" :poll="poll" style="margin: 4px 0;"/>
|
||||||
|
</p>
|
||||||
|
<div v-show="!useCw || showContent">
|
||||||
<Mfm :text="text.trim()" :author="user" :nyaize="'respect'" :i="user"/>
|
<Mfm :text="text.trim()" :author="user" :nyaize="'respect'" :i="user"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -20,11 +24,23 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { } from 'vue';
|
import { ref } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
|
import MkCwButton from '@/components/MkCwButton.vue';
|
||||||
|
|
||||||
|
const showContent = ref(false);
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
text: string;
|
text: string;
|
||||||
|
files: Misskey.entities.DriveFile[];
|
||||||
|
poll?: {
|
||||||
|
choices: string[];
|
||||||
|
multiple: boolean;
|
||||||
|
expiresAt: string | null;
|
||||||
|
expiredAfter: string | null;
|
||||||
|
};
|
||||||
|
useCw: boolean;
|
||||||
|
cw: string | null;
|
||||||
user: Misskey.entities.User;
|
user: Misskey.entities.User;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -53,6 +69,14 @@ const props = defineProps<{
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cw {
|
||||||
|
cursor: default;
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div>
|
<div>
|
||||||
<p v-if="note.cw != null" :class="$style.cw">
|
<p v-if="note.cw != null" :class="$style.cw">
|
||||||
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
|
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
|
||||||
<MkCwButton v-model="showContent" :note="note"/>
|
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
|
||||||
</p>
|
</p>
|
||||||
<div v-show="note.cw == null || showContent">
|
<div v-show="note.cw == null || showContent">
|
||||||
<MkSubNoteContent :class="$style.text" :note="note"/>
|
<MkSubNoteContent :class="$style.text" :note="note"/>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div>
|
<div>
|
||||||
<p v-if="note.cw != null" :class="$style.cw">
|
<p v-if="note.cw != null" :class="$style.cw">
|
||||||
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'"/>
|
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'"/>
|
||||||
<MkCwButton v-model="showContent" :note="note"/>
|
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
|
||||||
</p>
|
</p>
|
||||||
<div v-show="note.cw == null || showContent">
|
<div v-show="note.cw == null || showContent">
|
||||||
<MkSubNoteContent :class="$style.text" :note="note"/>
|
<MkSubNoteContent :class="$style.text" :note="note"/>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onUnmounted, onDeactivated, onMounted, computed, shallowRef, onActivated } from 'vue';
|
import { onUnmounted, onDeactivated, onMounted, computed, shallowRef, onActivated, watch } from 'vue';
|
||||||
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||||
import XNotification from '@/components/MkNotification.vue';
|
import XNotification from '@/components/MkNotification.vue';
|
||||||
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
|
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
|
||||||
|
|
@ -43,7 +43,7 @@ const props = defineProps<{
|
||||||
|
|
||||||
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
|
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
|
||||||
|
|
||||||
const pagination: Paging = defaultStore.state.useGroupedNotifications ? {
|
let pagination = $computed(() => defaultStore.reactiveState.useGroupedNotifications.value ? {
|
||||||
endpoint: 'i/notifications-grouped' as const,
|
endpoint: 'i/notifications-grouped' as const,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
params: computed(() => ({
|
params: computed(() => ({
|
||||||
|
|
@ -55,7 +55,7 @@ const pagination: Paging = defaultStore.state.useGroupedNotifications ? {
|
||||||
params: computed(() => ({
|
params: computed(() => ({
|
||||||
excludeTypes: props.excludeTypes ?? undefined,
|
excludeTypes: props.excludeTypes ?? undefined,
|
||||||
})),
|
})),
|
||||||
};
|
});
|
||||||
|
|
||||||
function onNotification(notification) {
|
function onNotification(notification) {
|
||||||
const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false;
|
const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false;
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ watch([$$(backed), $$(contentEl)], () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// パラメータに何らかの変更があった際、再読込したい(チャンネル等のIDが変わったなど)
|
// パラメータに何らかの変更があった際、再読込したい(チャンネル等のIDが変わったなど)
|
||||||
watch(() => props.pagination.params, init, { deep: true });
|
watch(() => [props.pagination.endpoint, props.pagination.params], init, { deep: true });
|
||||||
|
|
||||||
watch(queue, (a, b) => {
|
watch(queue, (a, b) => {
|
||||||
if (a.size === 0 && b.size === 0) return;
|
if (a.size === 0 && b.size === 0) return;
|
||||||
|
|
@ -206,6 +206,7 @@ async function init(): Promise<void> {
|
||||||
await os.api(props.pagination.endpoint, {
|
await os.api(props.pagination.endpoint, {
|
||||||
...params,
|
...params,
|
||||||
limit: props.pagination.limit ?? 10,
|
limit: props.pagination.limit ?? 10,
|
||||||
|
allowPartial: true,
|
||||||
}).then(res => {
|
}).then(res => {
|
||||||
for (let i = 0; i < res.length; i++) {
|
for (let i = 0; i < res.length; i++) {
|
||||||
const item = res[i];
|
const item = res[i];
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
|
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
|
||||||
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
|
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
|
||||||
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
|
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
|
||||||
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :user="postAccount ?? $i"/>
|
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/>
|
||||||
<div v-if="showingOptions" style="padding: 8px 16px;">
|
<div v-if="showingOptions" style="padding: 8px 16px;">
|
||||||
</div>
|
</div>
|
||||||
<footer :class="$style.footer">
|
<footer :class="$style.footer">
|
||||||
|
|
@ -124,6 +124,7 @@ import { deepClone } from '@/scripts/clone.js';
|
||||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||||
import { miLocalStorage } from '@/local-storage.js';
|
import { miLocalStorage } from '@/local-storage.js';
|
||||||
import { claimAchievement } from '@/scripts/achievements.js';
|
import { claimAchievement } from '@/scripts/achievements.js';
|
||||||
|
import { emojiPicker } from '@/scripts/emoji-picker.js';
|
||||||
|
|
||||||
const modal = inject('modal');
|
const modal = inject('modal');
|
||||||
|
|
||||||
|
|
@ -366,8 +367,8 @@ function checkMissingMention() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hasNotSpecifiedMentions = false;
|
|
||||||
}
|
}
|
||||||
|
hasNotSpecifiedMentions = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMissingMention() {
|
function addMissingMention() {
|
||||||
|
|
@ -845,7 +846,15 @@ function insertMention() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function insertEmoji(ev: MouseEvent) {
|
async function insertEmoji(ev: MouseEvent) {
|
||||||
os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textareaEl);
|
emojiPicker.show(
|
||||||
|
ev.currentTarget ?? ev.target,
|
||||||
|
emoji => {
|
||||||
|
insertTextAtCursor(textareaEl, emoji);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
focus();
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showActions(ev) {
|
function showActions(ev) {
|
||||||
|
|
@ -1059,8 +1068,9 @@ defineExpose({
|
||||||
|
|
||||||
.visibility {
|
.visibility {
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
max-width: 210px;
|
||||||
|
|
||||||
&:enabled {
|
&:enabled {
|
||||||
> .headerRightButtonText {
|
> .headerRightButtonText {
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substring(local.length) : url" rel="nofollow noopener" :target="target" :title="url">
|
<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substring(local.length) : url" rel="nofollow noopener" :target="target" :title="url">
|
||||||
<div v-if="thumbnail" :class="$style.thumbnail" :style="defaultStore.state.enableDataSaverMode ? '' : `background-image: url('${thumbnail}')`">
|
<div v-if="thumbnail" :class="$style.thumbnail" :style="defaultStore.state.dataSaver.urlPreview ? '' : `background-image: url('${thumbnail}')`">
|
||||||
</div>
|
</div>
|
||||||
<article :class="$style.body">
|
<article :class="$style.body">
|
||||||
<header :class="$style.header">
|
<header :class="$style.header">
|
||||||
|
|
|
||||||
|
|
@ -67,15 +67,14 @@ import number from '@/filters/number.js';
|
||||||
import MkNumber from '@/components/MkNumber.vue';
|
import MkNumber from '@/components/MkNumber.vue';
|
||||||
import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart.vue';
|
import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart.vue';
|
||||||
|
|
||||||
let meta = $ref<Misskey.entities.Instance>();
|
let meta = $ref<Misskey.entities.MetaResponse | null>(null);
|
||||||
let stats = $ref(null);
|
let stats = $ref<Misskey.entities.StatsResponse | null>(null);
|
||||||
|
|
||||||
os.api('meta', { detail: true }).then(_meta => {
|
os.api('meta', { detail: true }).then(_meta => {
|
||||||
meta = _meta;
|
meta = _meta;
|
||||||
});
|
});
|
||||||
|
|
||||||
os.api('stats', {
|
os.api('stats', {}).then((res) => {
|
||||||
}).then((res) => {
|
|
||||||
stats = res;
|
stats = res;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ const bound = $computed(() => props.link
|
||||||
? { to: userPage(props.user), target: props.target }
|
? { to: userPage(props.user), target: props.target }
|
||||||
: {});
|
: {});
|
||||||
|
|
||||||
const url = $computed(() => (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.enableDataSaverMode)
|
const url = $computed(() => (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar)
|
||||||
? getStaticImageUrl(props.user.avatarUrl)
|
? getStaticImageUrl(props.user.avatarUrl)
|
||||||
: props.user.avatarUrl);
|
: props.user.avatarUrl);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ export default function(props: MfmProps) {
|
||||||
|
|
||||||
case 'fn': {
|
case 'fn': {
|
||||||
// TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
|
// TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
|
||||||
let style;
|
let style: string | undefined;
|
||||||
switch (token.props.name) {
|
switch (token.props.name) {
|
||||||
case 'tada': {
|
case 'tada': {
|
||||||
const speed = validTime(token.props.args.speed) ?? '1s';
|
const speed = validTime(token.props.args.speed) ?? '1s';
|
||||||
|
|
@ -242,11 +242,17 @@ export default function(props: MfmProps) {
|
||||||
case 'ruby': {
|
case 'ruby': {
|
||||||
if (token.children.length === 1) {
|
if (token.children.length === 1) {
|
||||||
const child = token.children[0];
|
const child = token.children[0];
|
||||||
const text = child.type === 'text' ? child.props.text : '';
|
let text = child.type === 'text' ? child.props.text : '';
|
||||||
|
if (!disableNyaize && shouldNyaize) {
|
||||||
|
text = doNyaize(text);
|
||||||
|
}
|
||||||
return h('ruby', {}, [text.split(' ')[0], h('rt', text.split(' ')[1])]);
|
return h('ruby', {}, [text.split(' ')[0], h('rt', text.split(' ')[1])]);
|
||||||
} else {
|
} else {
|
||||||
const rt = token.children.at(-1)!;
|
const rt = token.children.at(-1)!;
|
||||||
const text = rt.type === 'text' ? rt.props.text : '';
|
let text = rt.type === 'text' ? rt.props.text : '';
|
||||||
|
if (!disableNyaize && shouldNyaize) {
|
||||||
|
text = doNyaize(text);
|
||||||
|
}
|
||||||
return h('ruby', {}, [...genEl(token.children.slice(0, token.children.length - 1), scale), h('rt', text.trim())]);
|
return h('ruby', {}, [...genEl(token.children.slice(0, token.children.length - 1), scale), h('rt', text.trim())]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -268,7 +274,7 @@ export default function(props: MfmProps) {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (style == null) {
|
if (style === undefined) {
|
||||||
return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']);
|
return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']);
|
||||||
} else {
|
} else {
|
||||||
return h('span', {
|
return h('span', {
|
||||||
|
|
|
||||||
|
|
@ -48,16 +48,12 @@ import { scrollToTop } from '@/scripts/scroll.js';
|
||||||
import { globalEvents } from '@/events.js';
|
import { globalEvents } from '@/events.js';
|
||||||
import { injectPageMetadata } from '@/scripts/page-metadata.js';
|
import { injectPageMetadata } from '@/scripts/page-metadata.js';
|
||||||
import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
|
import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
|
||||||
|
import { PageHeaderItem } from '@/types/page-header.js';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
tabs?: Tab[];
|
tabs?: Tab[];
|
||||||
tab?: string;
|
tab?: string;
|
||||||
actions?: {
|
actions?: PageHeaderItem[];
|
||||||
text: string;
|
|
||||||
icon: string;
|
|
||||||
highlighted?: boolean;
|
|
||||||
handler: (ev: MouseEvent) => void;
|
|
||||||
}[];
|
|
||||||
thin?: boolean;
|
thin?: boolean;
|
||||||
displayMyAvatar?: boolean;
|
displayMyAvatar?: boolean;
|
||||||
}>(), {
|
}>(), {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { useStream } from '@/stream.js';
|
||||||
import { get, set } from '@/scripts/idb-proxy.js';
|
import { get, set } from '@/scripts/idb-proxy.js';
|
||||||
|
|
||||||
const storageCache = await get('emojis');
|
const storageCache = await get('emojis');
|
||||||
export const customEmojis = shallowRef<Misskey.entities.CustomEmoji[]>(Array.isArray(storageCache) ? storageCache : []);
|
export const customEmojis = shallowRef<Misskey.entities.EmojiSimple[]>(Array.isArray(storageCache) ? storageCache : []);
|
||||||
export const customEmojiCategories = computed<[ ...string[], null ]>(() => {
|
export const customEmojiCategories = computed<[ ...string[], null ]>(() => {
|
||||||
const categories = new Set<string>();
|
const categories = new Set<string>();
|
||||||
for (const emoji of customEmojis.value) {
|
for (const emoji of customEmojis.value) {
|
||||||
|
|
@ -21,7 +21,7 @@ export const customEmojiCategories = computed<[ ...string[], null ]>(() => {
|
||||||
return markRaw([...Array.from(categories), null]);
|
return markRaw([...Array.from(categories), null]);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const customEmojisMap = new Map<string, Misskey.entities.CustomEmoji>();
|
export const customEmojisMap = new Map<string, Misskey.entities.EmojiSimple>();
|
||||||
watch(customEmojis, emojis => {
|
watch(customEmojis, emojis => {
|
||||||
customEmojisMap.clear();
|
customEmojisMap.clear();
|
||||||
for (const emoji of emojis) {
|
for (const emoji of emojis) {
|
||||||
|
|
@ -38,7 +38,7 @@ stream.on('emojiAdded', emojiData => {
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on('emojiUpdated', emojiData => {
|
stream.on('emojiUpdated', emojiData => {
|
||||||
customEmojis.value = customEmojis.value.map(item => emojiData.emojis.find(search => search.name === item.name) as Misskey.entities.CustomEmoji ?? item);
|
customEmojis.value = customEmojis.value.map(item => emojiData.emojis.find(search => search.name === item.name) as Misskey.entities.EmojiSimple ?? item);
|
||||||
set('emojis', customEmojis.value);
|
set('emojis', customEmojis.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as Misskey from 'misskey-js';
|
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { url } from '@/config.js';
|
import { url } from '@/config.js';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const cached = miLocalStorage.getItem('instance');
|
||||||
|
|
||||||
// TODO: instanceをリアクティブにするかは再考の余地あり
|
// TODO: instanceをリアクティブにするかは再考の余地あり
|
||||||
|
|
||||||
export const instance: Misskey.entities.InstanceMetadata = reactive(cached ? JSON.parse(cached) : {
|
export const instance: Misskey.entities.MetaResponse = reactive(cached ? JSON.parse(cached) : {
|
||||||
// TODO: set default values
|
// TODO: set default values
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import * as os from '@/os.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { ui } from '@/config.js';
|
import { ui } from '@/config.js';
|
||||||
import { unisonReload } from '@/scripts/unison-reload.js';
|
import { unisonReload } from '@/scripts/unison-reload.js';
|
||||||
|
import { clearCache } from './scripts/clear-cache.js';
|
||||||
|
|
||||||
export const navbarItemDef = reactive({
|
export const navbarItemDef = reactive({
|
||||||
notifications: {
|
notifications: {
|
||||||
|
|
@ -171,4 +172,11 @@ export const navbarItemDef = reactive({
|
||||||
show: computed(() => $i != null),
|
show: computed(() => $i != null),
|
||||||
to: `/@${$i?.username}`,
|
to: `/@${$i?.username}`,
|
||||||
},
|
},
|
||||||
|
cacheClear: {
|
||||||
|
title: i18n.ts.clearCache,
|
||||||
|
icon: 'ti ti-trash',
|
||||||
|
action: (ev) => {
|
||||||
|
clearCache();
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ const props = withDefaults(defineProps<{
|
||||||
|
|
||||||
let loaded = $ref(false);
|
let loaded = $ref(false);
|
||||||
let serverIsDead = $ref(false);
|
let serverIsDead = $ref(false);
|
||||||
let meta = $ref<Misskey.entities.LiteInstanceMetadata | null>(null);
|
let meta = $ref<Misskey.entities.MetaResponse | null>(null);
|
||||||
|
|
||||||
os.api('meta', {
|
os.api('meta', {
|
||||||
detail: false,
|
detail: false,
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ import { $i } from '@/account.js';
|
||||||
|
|
||||||
const customEmojiTags = getCustomEmojiTags();
|
const customEmojiTags = getCustomEmojiTags();
|
||||||
let q = $ref('');
|
let q = $ref('');
|
||||||
let searchEmojis = $ref<Misskey.entities.CustomEmoji[]>(null);
|
let searchEmojis = $ref<Misskey.entities.EmojiSimple[]>(null);
|
||||||
let selectedTags = $ref(new Set());
|
let selectedTags = $ref(new Set());
|
||||||
|
|
||||||
function search() {
|
function search() {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template #label>{{ i18n.ts.sensitiveWords }}</template>
|
<template #label>{{ i18n.ts.sensitiveWords }}</template>
|
||||||
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
|
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
|
||||||
</MkTextarea>
|
</MkTextarea>
|
||||||
|
|
||||||
|
<MkTextarea v-model="hiddenTags">
|
||||||
|
<template #label>{{ i18n.ts.hiddenTags }}</template>
|
||||||
|
<template #caption>{{ i18n.ts.hiddenTagsDescription }}</template>
|
||||||
|
</MkTextarea>
|
||||||
</div>
|
</div>
|
||||||
</FormSuspense>
|
</FormSuspense>
|
||||||
</MkSpacer>
|
</MkSpacer>
|
||||||
|
|
@ -72,6 +77,7 @@ import FormLink from '@/components/form/link.vue';
|
||||||
let enableRegistration: boolean = $ref(false);
|
let enableRegistration: boolean = $ref(false);
|
||||||
let emailRequiredForSignup: boolean = $ref(false);
|
let emailRequiredForSignup: boolean = $ref(false);
|
||||||
let sensitiveWords: string = $ref('');
|
let sensitiveWords: string = $ref('');
|
||||||
|
let hiddenTags: string = $ref('');
|
||||||
let preservedUsernames: string = $ref('');
|
let preservedUsernames: string = $ref('');
|
||||||
let tosUrl: string | null = $ref(null);
|
let tosUrl: string | null = $ref(null);
|
||||||
let privacyPolicyUrl: string | null = $ref(null);
|
let privacyPolicyUrl: string | null = $ref(null);
|
||||||
|
|
@ -81,6 +87,7 @@ async function init() {
|
||||||
enableRegistration = !meta.disableRegistration;
|
enableRegistration = !meta.disableRegistration;
|
||||||
emailRequiredForSignup = meta.emailRequiredForSignup;
|
emailRequiredForSignup = meta.emailRequiredForSignup;
|
||||||
sensitiveWords = meta.sensitiveWords.join('\n');
|
sensitiveWords = meta.sensitiveWords.join('\n');
|
||||||
|
hiddenTags = meta.hiddenTags.join('\n');
|
||||||
preservedUsernames = meta.preservedUsernames.join('\n');
|
preservedUsernames = meta.preservedUsernames.join('\n');
|
||||||
tosUrl = meta.tosUrl;
|
tosUrl = meta.tosUrl;
|
||||||
privacyPolicyUrl = meta.privacyPolicyUrl;
|
privacyPolicyUrl = meta.privacyPolicyUrl;
|
||||||
|
|
@ -93,6 +100,7 @@ function save() {
|
||||||
tosUrl,
|
tosUrl,
|
||||||
privacyPolicyUrl,
|
privacyPolicyUrl,
|
||||||
sensitiveWords: sensitiveWords.split('\n'),
|
sensitiveWords: sensitiveWords.split('\n'),
|
||||||
|
hiddenTags: hiddenTags.split('\n'),
|
||||||
preservedUsernames: preservedUsernames.split('\n'),
|
preservedUsernames: preservedUsernames.split('\n'),
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
fetchInstance();
|
fetchInstance();
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import * as os from '@/os.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
session: Misskey.entities.AuthSession;
|
session: Misskey.entities.AuthSessionShowResponse;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ const props = defineProps<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let state = $ref<'waiting' | 'accepted' | 'fetch-session-error' | 'denied' | null>(null);
|
let state = $ref<'waiting' | 'accepted' | 'fetch-session-error' | 'denied' | null>(null);
|
||||||
let session = $ref<Misskey.entities.AuthSession | null>(null);
|
let session = $ref<Misskey.entities.AuthSessionShowResponse | null>(null);
|
||||||
|
|
||||||
function accepted() {
|
function accepted() {
|
||||||
state = 'accepted';
|
state = 'accepted';
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,9 @@ import { defaultStore } from '@/store.js';
|
||||||
import MkNote from '@/components/MkNote.vue';
|
import MkNote from '@/components/MkNote.vue';
|
||||||
import MkInfo from '@/components/MkInfo.vue';
|
import MkInfo from '@/components/MkInfo.vue';
|
||||||
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
||||||
|
import { PageHeaderItem } from '@/types/page-header.js';
|
||||||
|
import { isSupportShare } from '@/scripts/navigator.js';
|
||||||
|
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|
@ -167,24 +170,40 @@ async function search() {
|
||||||
|
|
||||||
const headerActions = $computed(() => {
|
const headerActions = $computed(() => {
|
||||||
if (channel && channel.userId) {
|
if (channel && channel.userId) {
|
||||||
const share = {
|
const headerItems: PageHeaderItem[] = [];
|
||||||
icon: 'ti ti-share',
|
|
||||||
text: i18n.ts.share,
|
|
||||||
handler: async (): Promise<void> => {
|
|
||||||
navigator.share({
|
|
||||||
title: channel.name,
|
|
||||||
text: channel.description,
|
|
||||||
url: `${url}/channels/${channel.id}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const canEdit = ($i && $i.id === channel.userId) || iAmModerator;
|
headerItems.push({
|
||||||
return canEdit ? [share, {
|
icon: 'ti ti-link',
|
||||||
icon: 'ti ti-settings',
|
text: i18n.ts.copyUrl,
|
||||||
text: i18n.ts.edit,
|
handler: async (): Promise<void> => {
|
||||||
handler: edit,
|
copyToClipboard(`${url}/channels/${channel.id}`);
|
||||||
}] : [share];
|
os.success();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isSupportShare()) {
|
||||||
|
headerItems.push({
|
||||||
|
icon: 'ti ti-share',
|
||||||
|
text: i18n.ts.share,
|
||||||
|
handler: async (): Promise<void> => {
|
||||||
|
navigator.share({
|
||||||
|
title: channel.name,
|
||||||
|
text: channel.description,
|
||||||
|
url: `${url}/channels/${channel.id}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($i && $i.id === channel.userId) || iAmModerator) {
|
||||||
|
headerItems.push({
|
||||||
|
icon: 'ti ti-settings',
|
||||||
|
text: i18n.ts.edit,
|
||||||
|
handler: edit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return headerItems.length > 0 ? headerItems : null;
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
import { url } from '@/config.js';
|
import { url } from '@/config.js';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import { clipsCache } from '@/cache';
|
import { clipsCache } from '@/cache';
|
||||||
|
import { isSupportShare } from '@/scripts/navigator.js';
|
||||||
|
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
clipId: string,
|
clipId: string,
|
||||||
|
|
@ -118,6 +120,13 @@ const headerActions = $computed(() => clip && isOwned ? [{
|
||||||
clipsCache.delete();
|
clipsCache.delete();
|
||||||
},
|
},
|
||||||
}, ...(clip.isPublic ? [{
|
}, ...(clip.isPublic ? [{
|
||||||
|
icon: 'ti ti-link',
|
||||||
|
text: i18n.ts.copyUrl,
|
||||||
|
handler: async (): Promise<void> => {
|
||||||
|
copyToClipboard(`${url}/clips/${clip.id}`);
|
||||||
|
os.success();
|
||||||
|
},
|
||||||
|
}] : []), ...(clip.isPublic && isSupportShare() ? [{
|
||||||
icon: 'ti ti-share',
|
icon: 'ti ti-share',
|
||||||
text: i18n.ts.share,
|
text: i18n.ts.share,
|
||||||
handler: async (): Promise<void> => {
|
handler: async (): Promise<void> => {
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@ const edit = (emoji) => {
|
||||||
}, 'closed');
|
}, 'closed');
|
||||||
};
|
};
|
||||||
|
|
||||||
const im = (emoji) => {
|
const importEmoji = (emoji) => {
|
||||||
os.apiWithDialog('admin/emoji/copy', {
|
os.apiWithDialog('admin/emoji/copy', {
|
||||||
emojiId: emoji.id,
|
emojiId: emoji.id,
|
||||||
});
|
});
|
||||||
|
|
@ -168,7 +168,7 @@ const remoteMenu = (emoji, ev: MouseEvent) => {
|
||||||
}, {
|
}, {
|
||||||
text: i18n.ts.import,
|
text: i18n.ts.import,
|
||||||
icon: 'ti ti-plus',
|
icon: 'ti ti-plus',
|
||||||
action: () => { im(emoji); },
|
action: () => { importEmoji(emoji); },
|
||||||
}], ev.currentTarget ?? ev.target);
|
}], ev.currentTarget ?? ev.target);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ function menu(ev) {
|
||||||
os.apiGet('emoji', { name: props.emoji.name }).then(res => {
|
os.apiGet('emoji', { name: props.emoji.name }).then(res => {
|
||||||
os.alert({
|
os.alert({
|
||||||
type: 'info',
|
type: 'info',
|
||||||
text: `License: ${res.license}`,
|
text: `Name: ${res.name}\nAliases: ${res.aliases.join(' ')}\nCategory: ${res.category}\nisSensitive: ${res.isSensitive}\nlocalOnly: ${res.localOnly}\nLicense: ${res.license}\nURL: ${res.url}`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" asLike class="button" rounded primary @click="unlike()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
|
<MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" asLike class="button" rounded primary @click="unlike()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
|
||||||
<MkButton v-else v-tooltip="i18n.ts.like" asLike class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
|
<MkButton v-else v-tooltip="i18n.ts.like" asLike class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
|
||||||
<MkButton v-tooltip="i18n.ts.shareWithNote" class="button" rounded @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></MkButton>
|
<MkButton v-tooltip="i18n.ts.shareWithNote" class="button" rounded @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></MkButton>
|
||||||
<MkButton v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton>
|
<MkButton v-tooltip="i18n.ts.copyLink" class="button" rounded @click="copyLink"><i class="ti ti-link ti-fw"></i></MkButton>
|
||||||
|
<MkButton v-if="isSupportShare()" v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else :class="$style.ready">
|
<div v-else :class="$style.ready">
|
||||||
|
|
@ -70,6 +71,8 @@ import MkFolder from '@/components/MkFolder.vue';
|
||||||
import MkCode from '@/components/MkCode.vue';
|
import MkCode from '@/components/MkCode.vue';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
import { $i } from '@/account.js';
|
import { $i } from '@/account.js';
|
||||||
|
import { isSupportShare } from '@/scripts/navigator.js';
|
||||||
|
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -89,6 +92,11 @@ function fetchFlash() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyLink() {
|
||||||
|
copyToClipboard(`${url}/play/${flash.id}`);
|
||||||
|
os.success();
|
||||||
|
}
|
||||||
|
|
||||||
function share() {
|
function share() {
|
||||||
navigator.share({
|
navigator.share({
|
||||||
title: flash.title,
|
title: flash.title,
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div class="other">
|
<div class="other">
|
||||||
<button v-if="$i && $i.id === post.user.id" v-tooltip="i18n.ts.edit" v-click-anime class="_button" @click="edit"><i class="ti ti-pencil ti-fw"></i></button>
|
<button v-if="$i && $i.id === post.user.id" v-tooltip="i18n.ts.edit" v-click-anime class="_button" @click="edit"><i class="ti ti-pencil ti-fw"></i></button>
|
||||||
<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
|
<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
|
||||||
<button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
|
<button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ti ti-link ti-fw"></i></button>
|
||||||
|
<button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="user">
|
<div class="user">
|
||||||
|
|
@ -74,6 +75,8 @@ import { i18n } from '@/i18n.js';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
import { $i } from '@/account.js';
|
import { $i } from '@/account.js';
|
||||||
|
import { isSupportShare } from '@/scripts/navigator.js';
|
||||||
|
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|
@ -102,6 +105,11 @@ function fetchPost() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyLink() {
|
||||||
|
copyToClipboard(`${url}/gallery/${post.id}`);
|
||||||
|
os.success();
|
||||||
|
}
|
||||||
|
|
||||||
function share() {
|
function share() {
|
||||||
navigator.share({
|
navigator.share({
|
||||||
title: post.title,
|
title: post.title,
|
||||||
|
|
|
||||||
|
|
@ -144,8 +144,8 @@ const props = defineProps<{
|
||||||
|
|
||||||
let tab = $ref('overview');
|
let tab = $ref('overview');
|
||||||
let chartSrc = $ref('instance-requests');
|
let chartSrc = $ref('instance-requests');
|
||||||
let meta = $ref<Misskey.entities.AdminInstanceMetadata | null>(null);
|
let meta = $ref<Misskey.entities.AdminMetaResponse | null>(null);
|
||||||
let instance = $ref<Misskey.entities.Instance | null>(null);
|
let instance = $ref<Misskey.entities.FederationInstance | null>(null);
|
||||||
let suspended = $ref(false);
|
let suspended = $ref(false);
|
||||||
let isBlocked = $ref(false);
|
let isBlocked = $ref(false);
|
||||||
let isSilenced = $ref(false);
|
let isSilenced = $ref(false);
|
||||||
|
|
@ -169,10 +169,10 @@ async function fetch(): Promise<void> {
|
||||||
instance = await os.api('federation/show-instance', {
|
instance = await os.api('federation/show-instance', {
|
||||||
host: props.host,
|
host: props.host,
|
||||||
});
|
});
|
||||||
suspended = instance.isSuspended;
|
suspended = instance?.isSuspended ?? false;
|
||||||
isBlocked = instance.isBlocked;
|
isBlocked = instance?.isBlocked ?? false;
|
||||||
isSilenced = instance.isSilenced;
|
isSilenced = instance?.isSilenced ?? false;
|
||||||
faviconUrl = getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.iconUrl, 'preview');
|
faviconUrl = getProxiedImageUrlNullable(instance?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance?.iconUrl, 'preview');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleBlock(): Promise<void> {
|
async function toggleBlock(): Promise<void> {
|
||||||
|
|
@ -188,8 +188,9 @@ async function toggleSilenced(): Promise<void> {
|
||||||
if (!meta) throw new Error('No meta?');
|
if (!meta) throw new Error('No meta?');
|
||||||
if (!instance) throw new Error('No instance?');
|
if (!instance) throw new Error('No instance?');
|
||||||
const { host } = instance;
|
const { host } = instance;
|
||||||
|
const silencedHosts = meta.silencedHosts ?? [];
|
||||||
await os.api('admin/update-meta', {
|
await os.api('admin/update-meta', {
|
||||||
silencedHosts: isSilenced ? meta.silencedHosts.concat([host]) : meta.silencedHosts.filter(x => x !== host),
|
silencedHosts: isSilenced ? silencedHosts.concat([host]) : silencedHosts.filter(x => x !== host),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkPagination ref="pagingComponent" :pagination="pagination">
|
<MkPagination ref="pagingComponent" :pagination="pagination">
|
||||||
<template #default="{ items }">
|
<template #default="{ items }">
|
||||||
<div class="_gaps_s">
|
<div class="_gaps_s">
|
||||||
<MkInviteCode v-for="item in (items as Misskey.entities.Invite[])" :key="item.id" :invite="item" :onDeleted="deleted"/>
|
<MkInviteCode v-for="item in (items as Misskey.entities.InviteCode[])" :key="item.id" :invite="item" :onDeleted="deleted"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</MkPagination>
|
</MkPagination>
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
<div class="other">
|
<div class="other">
|
||||||
<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
|
<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
|
||||||
<button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
|
<button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ti ti-link ti-fw"></i></button>
|
||||||
|
<button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="user">
|
<div class="user">
|
||||||
|
|
@ -90,6 +91,8 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
import { pageViewInterruptors, defaultStore } from '@/store.js';
|
import { pageViewInterruptors, defaultStore } from '@/store.js';
|
||||||
import { deepClone } from '@/scripts/clone.js';
|
import { deepClone } from '@/scripts/clone.js';
|
||||||
import { $i } from '@/account.js';
|
import { $i } from '@/account.js';
|
||||||
|
import { isSupportShare } from '@/scripts/navigator.js';
|
||||||
|
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
pageName: string;
|
pageName: string;
|
||||||
|
|
@ -136,6 +139,11 @@ function share() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyLink() {
|
||||||
|
copyToClipboard(`${url}/@${page.user.username}/pages/${page.name}`);
|
||||||
|
os.success();
|
||||||
|
}
|
||||||
|
|
||||||
function shareWithNote() {
|
function shareWithNote() {
|
||||||
os.post({
|
os.post({
|
||||||
initialText: `${page.title || page.name} ${url}/@${page.user.username}/pages/${page.name}`,
|
initialText: `${page.title || page.name} ${url}/@${page.user.username}/pages/${page.name}`,
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch>
|
<MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch>
|
||||||
<MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch>
|
<MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch>
|
||||||
<MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch>
|
<MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch>
|
||||||
<MkSwitch v-model="enableDataSaverMode">{{ i18n.ts.dataSaver }}</MkSwitch>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<MkRadios v-model="emojiStyle">
|
<MkRadios v-model="emojiStyle">
|
||||||
|
|
@ -165,6 +164,37 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template #label>{{ i18n.ts.numberOfPageCache }}</template>
|
<template #label>{{ i18n.ts.numberOfPageCache }}</template>
|
||||||
<template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template>
|
<template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template>
|
||||||
</MkRange>
|
</MkRange>
|
||||||
|
|
||||||
|
<MkFolder>
|
||||||
|
<template #label>{{ i18n.ts.dataSaver }}</template>
|
||||||
|
|
||||||
|
<div class="_gaps_m">
|
||||||
|
<MkInfo>{{ i18n.ts.reloadRequiredToApplySettings }}</MkInfo>
|
||||||
|
|
||||||
|
<div class="_buttons">
|
||||||
|
<MkButton inline @click="enableAllDataSaver">{{ i18n.ts.enableAll }}</MkButton>
|
||||||
|
<MkButton inline @click="disableAllDataSaver">{{ i18n.ts.disableAll }}</MkButton>
|
||||||
|
</div>
|
||||||
|
<div class="_gaps_m">
|
||||||
|
<MkSwitch v-model="dataSaver.media">
|
||||||
|
{{ i18n.ts._dataSaver._media.title }}
|
||||||
|
<template #caption>{{ i18n.ts._dataSaver._media.description }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
<MkSwitch v-model="dataSaver.avatar">
|
||||||
|
{{ i18n.ts._dataSaver._avatar.title }}
|
||||||
|
<template #caption>{{ i18n.ts._dataSaver._avatar.description }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
<MkSwitch v-model="dataSaver.urlPreview">
|
||||||
|
{{ i18n.ts._dataSaver._urlPreview.title }}
|
||||||
|
<template #caption>{{ i18n.ts._dataSaver._urlPreview.description }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
<MkSwitch v-model="dataSaver.code">
|
||||||
|
{{ i18n.ts._dataSaver._code.title }}
|
||||||
|
<template #caption>{{ i18n.ts._dataSaver._code.description }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MkFolder>
|
||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
|
|
@ -198,6 +228,7 @@ import MkButton from '@/components/MkButton.vue';
|
||||||
import FormSection from '@/components/form/section.vue';
|
import FormSection from '@/components/form/section.vue';
|
||||||
import FormLink from '@/components/form/link.vue';
|
import FormLink from '@/components/form/link.vue';
|
||||||
import MkLink from '@/components/MkLink.vue';
|
import MkLink from '@/components/MkLink.vue';
|
||||||
|
import MkInfo from '@/components/MkInfo.vue';
|
||||||
import { langs } from '@/config.js';
|
import { langs } from '@/config.js';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
|
|
@ -211,6 +242,7 @@ import { claimAchievement } from '@/scripts/achievements.js';
|
||||||
const lang = ref(miLocalStorage.getItem('lang'));
|
const lang = ref(miLocalStorage.getItem('lang'));
|
||||||
const fontSize = ref(miLocalStorage.getItem('fontSize'));
|
const fontSize = ref(miLocalStorage.getItem('fontSize'));
|
||||||
const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null);
|
const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null);
|
||||||
|
const dataSaver = ref(defaultStore.state.dataSaver);
|
||||||
|
|
||||||
async function reloadAsk() {
|
async function reloadAsk() {
|
||||||
const { canceled } = await os.confirm({
|
const { canceled } = await os.confirm({
|
||||||
|
|
@ -241,7 +273,6 @@ const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('dis
|
||||||
const forceShowAds = computed(defaultStore.makeGetterSetter('forceShowAds'));
|
const forceShowAds = computed(defaultStore.makeGetterSetter('forceShowAds'));
|
||||||
const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
|
const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
|
||||||
const highlightSensitiveMedia = computed(defaultStore.makeGetterSetter('highlightSensitiveMedia'));
|
const highlightSensitiveMedia = computed(defaultStore.makeGetterSetter('highlightSensitiveMedia'));
|
||||||
const enableDataSaverMode = computed(defaultStore.makeGetterSetter('enableDataSaverMode'));
|
|
||||||
const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
|
const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
|
||||||
const nsfw = computed(defaultStore.makeGetterSetter('nsfw'));
|
const nsfw = computed(defaultStore.makeGetterSetter('nsfw'));
|
||||||
const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm'));
|
const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm'));
|
||||||
|
|
@ -374,6 +405,28 @@ function testNotification(): void {
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enableAllDataSaver() {
|
||||||
|
const g = { ...defaultStore.state.dataSaver };
|
||||||
|
|
||||||
|
Object.keys(g).forEach((key) => { g[key] = true; });
|
||||||
|
|
||||||
|
dataSaver.value = g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableAllDataSaver() {
|
||||||
|
const g = { ...defaultStore.state.dataSaver };
|
||||||
|
|
||||||
|
Object.keys(g).forEach((key) => { g[key] = false; });
|
||||||
|
|
||||||
|
dataSaver.value = g;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(dataSaver, (to) => {
|
||||||
|
defaultStore.set('dataSaver', to);
|
||||||
|
}, {
|
||||||
|
deep: true,
|
||||||
|
});
|
||||||
|
|
||||||
const headerActions = $computed(() => []);
|
const headerActions = $computed(() => []);
|
||||||
|
|
||||||
const headerTabs = $computed(() => []);
|
const headerTabs = $computed(() => []);
|
||||||
|
|
|
||||||
|
|
@ -33,13 +33,11 @@ import { i18n } from '@/i18n.js';
|
||||||
import MkInfo from '@/components/MkInfo.vue';
|
import MkInfo from '@/components/MkInfo.vue';
|
||||||
import MkSuperMenu from '@/components/MkSuperMenu.vue';
|
import MkSuperMenu from '@/components/MkSuperMenu.vue';
|
||||||
import { signout, $i } from '@/account.js';
|
import { signout, $i } from '@/account.js';
|
||||||
import { unisonReload } from '@/scripts/unison-reload.js';
|
import { clearCache } from '@/scripts/clear-cache.js';
|
||||||
import { instance } from '@/instance.js';
|
import { instance } from '@/instance.js';
|
||||||
import { useRouter } from '@/router.js';
|
import { useRouter } from '@/router.js';
|
||||||
import { definePageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js';
|
import { definePageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { miLocalStorage } from '@/local-storage.js';
|
|
||||||
import { fetchCustomEmojis } from '@/custom-emojis.js';
|
|
||||||
|
|
||||||
const indexInfo = {
|
const indexInfo = {
|
||||||
title: i18n.ts.settings,
|
title: i18n.ts.settings,
|
||||||
|
|
@ -182,13 +180,7 @@ const menuDef = computed(() => [{
|
||||||
icon: 'ti ti-trash',
|
icon: 'ti ti-trash',
|
||||||
text: i18n.ts.clearCache,
|
text: i18n.ts.clearCache,
|
||||||
action: async () => {
|
action: async () => {
|
||||||
os.waiting();
|
await clearCache();
|
||||||
miLocalStorage.removeItem('locale');
|
|
||||||
miLocalStorage.removeItem('theme');
|
|
||||||
miLocalStorage.removeItem('emojis');
|
|
||||||
miLocalStorage.removeItem('lastEmojisFetchedAt');
|
|
||||||
await fetchCustomEmojis(true);
|
|
||||||
unisonReload();
|
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
type: 'button',
|
type: 'button',
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
|
||||||
'advancedMfm',
|
'advancedMfm',
|
||||||
'loadRawImages',
|
'loadRawImages',
|
||||||
'imageNewTab',
|
'imageNewTab',
|
||||||
'enableDataSaverMode',
|
'dataSaver',
|
||||||
'disableShowingAnimatedImages',
|
'disableShowingAnimatedImages',
|
||||||
'emojiStyle',
|
'emojiStyle',
|
||||||
'disableDrawer',
|
'disableDrawer',
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue