Compare commits
28 Commits
2024.10.1-
...
2024.10.2-
Author | SHA1 | Date | |
---|---|---|---|
![]() |
8b6d321a76 | ||
![]() |
952fec5665 | ||
![]() |
70b2a8f72e | ||
![]() |
c4f1ca2fd9 | ||
![]() |
9d0f7eeb9c | ||
![]() |
bc1fce9af6 | ||
![]() |
5f12bc515d | ||
![]() |
2f9c04b23b | ||
![]() |
5c79d8db20 | ||
![]() |
bc0c53b92b | ||
![]() |
d6caa4d9c4 | ||
![]() |
041c9caf31 | ||
![]() |
1d106b3ae8 | ||
![]() |
58419e1621 | ||
![]() |
2250e521e4 | ||
![]() |
a3a99467f0 | ||
![]() |
b1aac6acc3 | ||
![]() |
d2e8dc4fe3 | ||
![]() |
b990ae6b23 | ||
![]() |
3cea890eba | ||
![]() |
21a2aa5243 | ||
![]() |
825d218692 | ||
![]() |
b5de525548 | ||
![]() |
5005cc8ae3 | ||
![]() |
f13c3909a0 | ||
![]() |
77ebabb3dc | ||
![]() |
7fd8ef344b | ||
![]() |
b0a251d231 |
2
.github/labeler.yml
vendored
2
.github/labeler.yml
vendored
@@ -6,7 +6,7 @@
|
||||
'packages/backend:test':
|
||||
- any:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['packages/backend/test/**/*']
|
||||
- any-glob-to-any-file: ['packages/backend/test/**/*', 'packages/backend/test-federation/**/*']
|
||||
|
||||
'packages/frontend':
|
||||
- any:
|
||||
|
59
.github/workflows/test-federation.yml
vendored
Normal file
59
.github/workflows/test-federation.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: Test (federation)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
paths:
|
||||
- packages/backend/**
|
||||
- packages/misskey-js/**
|
||||
- .github/workflows/test-federation.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- packages/backend/**
|
||||
- packages/misskey-js/**
|
||||
- .github/workflows/test-federation.yml
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.16.0]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Install FFmpeg
|
||||
uses: FedericoCarboni/setup-ffmpeg@v3
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4.0.3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
- name: Build Misskey
|
||||
run: |
|
||||
corepack enable && corepack prepare
|
||||
pnpm i --frozen-lockfile
|
||||
pnpm build
|
||||
- name: Setup
|
||||
run: |
|
||||
cd packages/backend/test-federation
|
||||
bash ./setup.sh
|
||||
sudo chmod 644 ./certificates/*.test.key
|
||||
- name: Start servers
|
||||
# https://github.com/docker/compose/issues/1294#issuecomment-374847206
|
||||
run: |
|
||||
cd packages/backend/test-federation
|
||||
docker compose up -d --scale tester=0
|
||||
- name: Test
|
||||
run: |
|
||||
cd packages/backend/test-federation
|
||||
docker compose run --no-deps tester
|
||||
- name: Stop servers
|
||||
run: |
|
||||
cd packages/backend/test-federation
|
||||
docker compose down
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -37,7 +37,7 @@ coverage
|
||||
!/.config/docker_example.env
|
||||
!/.config/cypress-devcontainer.yml
|
||||
docker-compose.yml
|
||||
compose.yml
|
||||
./compose.yml
|
||||
.devcontainer/compose.yml
|
||||
!/.devcontainer/compose.yml
|
||||
|
||||
|
30
CHANGELOG.md
30
CHANGELOG.md
@@ -1,8 +1,28 @@
|
||||
## 2024.10.2
|
||||
|
||||
### General
|
||||
- Feat: コンテンツの表示にログインを必須にできるように
|
||||
- Feat: 過去のノートを非公開化/フォロワーのみ表示可能にできるように
|
||||
|
||||
### Client
|
||||
- Enhance: Bull DashboardでRelationship Queueの状態も確認できるように
|
||||
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/751)
|
||||
- Enhance: ドライブでソートができるように
|
||||
- Enhance: 投稿フォームでEscキーを押したときIME入力中ならフォームを閉じないように( #10866 )
|
||||
- Fix: 通知の範囲指定の設定項目が必要ない通知設定でも範囲指定の設定がでている問題を修正
|
||||
- Fix: Turnstileが失敗・期限切れした際にも成功扱いとなってしまう問題を修正
|
||||
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/768)
|
||||
- Fix: デッキのタイムラインカラムで「センシティブなファイルを含むノートを表示」設定が使用できなかった問題を修正
|
||||
|
||||
### Server
|
||||
-
|
||||
|
||||
|
||||
## 2024.10.1
|
||||
|
||||
### Note
|
||||
- 悪質なユーザからサーバを守る措置の一環として、モデレータ権限を持つユーザの最終アクティブ日時を確認し、
|
||||
7日間活動していない場合は自動的に招待制へと移行(コントロールパネル -> モデレーション -> "誰でも新規登録できるようにする"をオフに変更)するようになりました。
|
||||
詳細な経緯は https://github.com/misskey-dev/misskey/issues/13437 をご確認ください。
|
||||
- スパム対策として、モデレータ権限を持つユーザのアクティビティが7日以上確認できない場合は自動的に招待制へと切り替え(コントロールパネル -> モデレーション -> "誰でも新規登録できるようにする"をオフに変更)るようになりました。 ( #13437 )
|
||||
- 切り替わった際はモデレーターへお知らせとして通知されます。登録をオープンな状態で継続したい場合は、コントロールパネルから再度設定を行ってください。
|
||||
|
||||
### General
|
||||
- Feat: ユーザーの名前に禁止ワードを設定できるように
|
||||
@@ -14,12 +34,10 @@
|
||||
- Fix: メールアドレス不要でCaptchaが有効な場合にアカウント登録完了後自動でのログインに失敗する問題を修正
|
||||
|
||||
### Server
|
||||
- Feat: モデレータ権限を持つユーザが全員7日間活動しなかった場合は自動的に招待制へと移行するように ( #13437 )
|
||||
- Feat: モデレータ権限を持つユーザが全員7日間活動しなかった場合は自動的に招待制へと切り替えるように ( #13437 )
|
||||
- Enhance: 個人宛のお知らせは「わかった」を押すと自動的にアーカイブされるように
|
||||
- Fix: `admin/emoji/update`エンドポイントのidのみ指定した時不正なエラーが発生するバグを修正
|
||||
- Fix: RBT有効時、リノートのリアクションが反映されない問題を修正
|
||||
|
||||
### Server
|
||||
- Fix: キューのエラーログを簡略化するように
|
||||
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/649)
|
||||
|
||||
|
@@ -64,6 +64,22 @@ Thank you for your PR! Before creating a PR, please check the following:
|
||||
|
||||
Thanks for your cooperation 🤗
|
||||
|
||||
### Additional things for ActivityPub payload changes
|
||||
*This section is specific to misskey-dev implementation. Other fork or implementation may take different way. A significant difference is that non-"misskey-dev" extension is not described in the misskey-hub's document.*
|
||||
|
||||
If PR includes changes to ActivityPub payload, please reflect it in [misskey-hub's document](https://github.com/misskey-dev/misskey-hub-next/blob/master/content/ns.md) by sending PR.
|
||||
|
||||
The name of purporsed extension property (referred as "extended property" in later) to ActivityPub shall be prefixed by `_misskey_`. (i.e. `_misskey_quote`)
|
||||
|
||||
The extended property in `packages/backend/src/core/activitypub/type.ts` **must** be declared as optional because ActivityPub payloads that comes from older Misskey or other implementation may not contain it.
|
||||
|
||||
The extended property must be included in the context definition. Context is defined in `packages/backend/src/core/activitypub/misc/contexts.ts`.
|
||||
The key shall be same as the name of extended property, and the value shall be same as "short IRI".
|
||||
|
||||
"Short IRI" is defined in misskey-hub's document, but usually takes form of `misskey:<name of extended property>`. (i.e. `misskey:_misskey_quote`)
|
||||
|
||||
One should not add property that has defined before by other implementation, or add custom variant value to "well-known" property.
|
||||
|
||||
## Reviewers guide
|
||||
Be willing to comment on the good points and not just the things you want fixed 💯
|
||||
|
||||
@@ -181,31 +197,45 @@ MK_DEV_PREFER=backend pnpm dev
|
||||
- HMR may not work in some environments such as Windows.
|
||||
|
||||
## Testing
|
||||
- Test codes are located in [`/packages/backend/test`](/packages/backend/test).
|
||||
|
||||
### Run test
|
||||
Create a config file.
|
||||
You can run non-backend tests by executing following commands:
|
||||
```sh
|
||||
pnpm --filter frontend test
|
||||
pnpm --filter misskey-js test
|
||||
```
|
||||
|
||||
Backend tests require manual preparation of servers. See the next section for more on this.
|
||||
|
||||
### Backend
|
||||
There are three types of test codes for the backend:
|
||||
- Unit tests: [`/packages/backend/test/unit`](/packages/backend/test/unit)
|
||||
- Single-server E2E tests: [`/packages/backend/test/e2e`](/packages/backend/test/e2e)
|
||||
- Multiple-server E2E tests: [`/packages/backend/test-federation`](/packages/backend/test-federation)
|
||||
|
||||
#### Running Unit Tests or Single-server E2E Tests
|
||||
1. Create a config file:
|
||||
```sh
|
||||
cp .github/misskey/test.yml .config/
|
||||
```
|
||||
Prepare DB/Redis for testing.
|
||||
```
|
||||
|
||||
2. Start DB and Redis servers for testing:
|
||||
```sh
|
||||
docker compose -f packages/backend/test/compose.yml up
|
||||
```
|
||||
Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.yml`.
|
||||
Instead, you can prepare an empty (data can be erased) DB and edit `.config/test.yml` appropriately.
|
||||
|
||||
Run all test.
|
||||
3. Run all tests:
|
||||
```sh
|
||||
pnpm --filter backend test # unit tests
|
||||
pnpm --filter backend test:e2e # single-server E2E tests
|
||||
```
|
||||
pnpm test
|
||||
If you want to run a specific test, run as a following command:
|
||||
```sh
|
||||
pnpm --filter backend test -- packages/backend/test/unit/activitypub.ts
|
||||
pnpm --filter backend test:e2e -- packages/backend/test/e2e/nodeinfo.ts
|
||||
```
|
||||
|
||||
#### Run specify test
|
||||
```
|
||||
pnpm jest -- foo.ts
|
||||
```
|
||||
|
||||
### e2e tests
|
||||
TODO
|
||||
#### Running Multiple-server E2E Tests
|
||||
See [`/packages/backend/test-federation/README.md`](/packages/backend/test-federation/README.md).
|
||||
|
||||
## Environment Variable
|
||||
|
||||
|
72
locales/index.d.ts
vendored
72
locales/index.d.ts
vendored
@@ -3806,6 +3806,18 @@ export interface Locale extends ILocale {
|
||||
* 1ヶ月
|
||||
*/
|
||||
"oneMonth": string;
|
||||
/**
|
||||
* 3ヶ月
|
||||
*/
|
||||
"threeMonths": string;
|
||||
/**
|
||||
* 1年
|
||||
*/
|
||||
"oneYear": string;
|
||||
/**
|
||||
* 3日
|
||||
*/
|
||||
"threeDays": string;
|
||||
/**
|
||||
* 反映されるまで時間がかかる場合があります。
|
||||
*/
|
||||
@@ -5190,6 +5202,60 @@ export interface Locale extends ILocale {
|
||||
* 名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。
|
||||
*/
|
||||
"yourNameContainsProhibitedWordsDescription": string;
|
||||
/**
|
||||
* 投稿者により、表示にはログインが必要と設定されています
|
||||
*/
|
||||
"thisContentsAreMarkedAsSigninRequiredByAuthor": string;
|
||||
/**
|
||||
* ロックダウン
|
||||
*/
|
||||
"lockdown": string;
|
||||
"_accountSettings": {
|
||||
/**
|
||||
* コンテンツの表示にログインを必須にする
|
||||
*/
|
||||
"requireSigninToViewContents": string;
|
||||
/**
|
||||
* あなたが作成した全てのノートなどのコンテンツを表示するのにログインを必須にします。クローラーに情報が収集されるのを防ぐ効果が期待できます。
|
||||
*/
|
||||
"requireSigninToViewContentsDescription1": string;
|
||||
/**
|
||||
* URLプレビュー(OGP)、Webページへの埋め込み、ノートの引用に対応していないサーバーからの表示も不可になります。
|
||||
*/
|
||||
"requireSigninToViewContentsDescription2": string;
|
||||
/**
|
||||
* リモートサーバーに連合されたコンテンツでは、これらの制限が適用されない場合があります。
|
||||
*/
|
||||
"requireSigninToViewContentsDescription3": string;
|
||||
/**
|
||||
* 過去のノートをフォロワーのみ表示可能にする
|
||||
*/
|
||||
"makeNotesFollowersOnlyBefore": string;
|
||||
/**
|
||||
* この機能が有効になっている間、設定された日時より過去、または設定された時間を経過しているノートがフォロワーのみ表示可能になります。無効に戻すと、ノートの公開状態も元に戻ります。
|
||||
*/
|
||||
"makeNotesFollowersOnlyBeforeDescription": string;
|
||||
/**
|
||||
* 過去のノートを非公開化する
|
||||
*/
|
||||
"makeNotesHiddenBefore": string;
|
||||
/**
|
||||
* この機能が有効になっている間、設定された日時より過去、または設定された時間を経過しているノートが自分のみ表示可能(非公開化)になります。無効に戻すと、ノートの公開状態も元に戻ります。
|
||||
*/
|
||||
"makeNotesHiddenBeforeDescription": string;
|
||||
/**
|
||||
* リモートサーバーに連合されたノートには効果が及ばない場合があります。
|
||||
*/
|
||||
"mayNotEffectForFederatedNotes": string;
|
||||
/**
|
||||
* 指定した時間を経過しているノート
|
||||
*/
|
||||
"notesHavePassedSpecifiedPeriod": string;
|
||||
/**
|
||||
* 指定した日時より前のノート
|
||||
*/
|
||||
"notesOlderThanSpecifiedDateAndTime": string;
|
||||
};
|
||||
"_abuseUserReport": {
|
||||
/**
|
||||
* 転送
|
||||
@@ -9271,7 +9337,7 @@ export interface Locale extends ILocale {
|
||||
*/
|
||||
"youGotQuote": ParameterizedString<"name">;
|
||||
/**
|
||||
* {name}がRenoteしました
|
||||
* {name}がリノートしました
|
||||
*/
|
||||
"youRenoted": ParameterizedString<"name">;
|
||||
/**
|
||||
@@ -9376,7 +9442,7 @@ export interface Locale extends ILocale {
|
||||
*/
|
||||
"reply": string;
|
||||
/**
|
||||
* Renote
|
||||
* リノート
|
||||
*/
|
||||
"renote": string;
|
||||
/**
|
||||
@@ -9434,7 +9500,7 @@ export interface Locale extends ILocale {
|
||||
*/
|
||||
"reply": string;
|
||||
/**
|
||||
* Renote
|
||||
* リノート
|
||||
*/
|
||||
"renote": string;
|
||||
};
|
||||
|
@@ -947,6 +947,9 @@ oneHour: "1時間"
|
||||
oneDay: "1日"
|
||||
oneWeek: "1週間"
|
||||
oneMonth: "1ヶ月"
|
||||
threeMonths: "3ヶ月"
|
||||
oneYear: "1年"
|
||||
threeDays: "3日"
|
||||
reflectMayTakeTime: "反映されるまで時間がかかる場合があります。"
|
||||
failedToFetchAccountInformation: "アカウント情報の取得に失敗しました"
|
||||
rateLimitExceeded: "レート制限を超えました"
|
||||
@@ -1293,6 +1296,21 @@ prohibitedWordsForNameOfUser: "禁止ワード(ユーザーの名前)"
|
||||
prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。"
|
||||
yourNameContainsProhibitedWords: "変更しようとした名前に禁止された文字列が含まれています"
|
||||
yourNameContainsProhibitedWordsDescription: "名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。"
|
||||
thisContentsAreMarkedAsSigninRequiredByAuthor: "投稿者により、表示にはログインが必要と設定されています"
|
||||
lockdown: "ロックダウン"
|
||||
|
||||
_accountSettings:
|
||||
requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
|
||||
requireSigninToViewContentsDescription1: "あなたが作成した全てのノートなどのコンテンツを表示するのにログインを必須にします。クローラーに情報が収集されるのを防ぐ効果が期待できます。"
|
||||
requireSigninToViewContentsDescription2: "URLプレビュー(OGP)、Webページへの埋め込み、ノートの引用に対応していないサーバーからの表示も不可になります。"
|
||||
requireSigninToViewContentsDescription3: "リモートサーバーに連合されたコンテンツでは、これらの制限が適用されない場合があります。"
|
||||
makeNotesFollowersOnlyBefore: "過去のノートをフォロワーのみ表示可能にする"
|
||||
makeNotesFollowersOnlyBeforeDescription: "この機能が有効になっている間、設定された日時より過去、または設定された時間を経過しているノートがフォロワーのみ表示可能になります。無効に戻すと、ノートの公開状態も元に戻ります。"
|
||||
makeNotesHiddenBefore: "過去のノートを非公開化する"
|
||||
makeNotesHiddenBeforeDescription: "この機能が有効になっている間、設定された日時より過去、または設定された時間を経過しているノートが自分のみ表示可能(非公開化)になります。無効に戻すと、ノートの公開状態も元に戻ります。"
|
||||
mayNotEffectForFederatedNotes: "リモートサーバーに連合されたノートには効果が及ばない場合があります。"
|
||||
notesHavePassedSpecifiedPeriod: "指定した時間を経過しているノート"
|
||||
notesOlderThanSpecifiedDateAndTime: "指定した日時より前のノート"
|
||||
|
||||
_abuseUserReport:
|
||||
forward: "転送"
|
||||
@@ -2448,7 +2466,7 @@ _notification:
|
||||
youGotMention: "{name}からのメンション"
|
||||
youGotReply: "{name}からのリプライ"
|
||||
youGotQuote: "{name}による引用"
|
||||
youRenoted: "{name}がRenoteしました"
|
||||
youRenoted: "{name}がリノートしました"
|
||||
youWereFollowed: "フォローされました"
|
||||
youReceivedFollowRequest: "フォローリクエストが来ました"
|
||||
yourFollowRequestAccepted: "フォローリクエストが承認されました"
|
||||
@@ -2476,7 +2494,7 @@ _notification:
|
||||
follow: "フォロー"
|
||||
mention: "メンション"
|
||||
reply: "リプライ"
|
||||
renote: "Renote"
|
||||
renote: "リノート"
|
||||
quote: "引用"
|
||||
reaction: "リアクション"
|
||||
pollEnded: "アンケートが終了"
|
||||
@@ -2492,7 +2510,7 @@ _notification:
|
||||
_actions:
|
||||
followBack: "フォローバック"
|
||||
reply: "返信"
|
||||
renote: "Renote"
|
||||
renote: "リノート"
|
||||
|
||||
_deck:
|
||||
alwaysShowMainColumn: "常にメインカラムを表示"
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "2024.10.1-beta.6",
|
||||
"version": "2024.10.2-alpha.0",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@@ -11,7 +11,7 @@ export default [
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
project: ['./tsconfig.json', './test/tsconfig.json'],
|
||||
project: ['./tsconfig.json', './test/tsconfig.json', './test-federation/tsconfig.json'],
|
||||
sourceType: 'module',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
|
13
packages/backend/jest.config.fed.cjs
Normal file
13
packages/backend/jest.config.fed.cjs
Normal file
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* For a detailed explanation regarding each configuration property and type check, visit:
|
||||
* https://jestjs.io/docs/en/configuration.html
|
||||
*/
|
||||
|
||||
const base = require('./jest.config.cjs');
|
||||
|
||||
module.exports = {
|
||||
...base,
|
||||
testMatch: [
|
||||
'<rootDir>/test-federation/test/**/*.test.ts',
|
||||
],
|
||||
};
|
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class SigninRequiredForShowContents1729333924409 {
|
||||
name = 'SigninRequiredForShowContents1729333924409'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "requireSigninToViewContents" boolean NOT NULL DEFAULT false`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "requireSigninToViewContents"`);
|
||||
}
|
||||
}
|
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class MakeNotesHiddenBefore1729486255072 {
|
||||
name = 'MakeNotesHiddenBefore1729486255072'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "makeNotesFollowersOnlyBefore" integer`);
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "makeNotesHiddenBefore" integer`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "makeNotesHiddenBefore"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "makeNotesFollowersOnlyBefore"`);
|
||||
}
|
||||
}
|
@@ -19,16 +19,18 @@
|
||||
"watch": "node ./scripts/watch.mjs",
|
||||
"restart": "pnpm build && pnpm start",
|
||||
"dev": "node ./scripts/dev.mjs",
|
||||
"typecheck": "tsc --noEmit && tsc -p test --noEmit",
|
||||
"eslint": "eslint --quiet \"src/**/*.ts\"",
|
||||
"typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit",
|
||||
"eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"",
|
||||
"lint": "pnpm typecheck && pnpm eslint",
|
||||
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs",
|
||||
"jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs",
|
||||
"jest:fed": "node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.fed.cjs",
|
||||
"jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.unit.cjs",
|
||||
"jest-and-coverage:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.e2e.cjs",
|
||||
"jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache",
|
||||
"test": "pnpm jest",
|
||||
"test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e",
|
||||
"test:fed": "pnpm jest:fed",
|
||||
"test-and-coverage": "pnpm jest-and-coverage",
|
||||
"test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e",
|
||||
"generate-api-json": "node ./scripts/generate_api_json.js"
|
||||
|
@@ -83,6 +83,9 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
|
||||
isExplorable: true,
|
||||
isHibernated: false,
|
||||
isDeleted: false,
|
||||
requireSigninToViewContents: false,
|
||||
makeNotesFollowersOnlyBefore: null,
|
||||
makeNotesHiddenBefore: null,
|
||||
emojis: [],
|
||||
score: 0,
|
||||
host: null,
|
||||
|
@@ -495,6 +495,9 @@ export class ApRendererService {
|
||||
summary: profile.description ? this.mfmService.toHtml(mfm.parse(profile.description)) : null,
|
||||
_misskey_summary: profile.description,
|
||||
_misskey_followedMessage: profile.followedMessage,
|
||||
_misskey_requireSigninToViewContents: user.requireSigninToViewContents,
|
||||
_misskey_makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore,
|
||||
_misskey_makeNotesHiddenBefore: user.makeNotesHiddenBefore,
|
||||
icon: avatar ? this.renderImage(avatar) : null,
|
||||
image: banner ? this.renderImage(banner) : null,
|
||||
tag,
|
||||
|
@@ -555,6 +555,9 @@ const extension_context_definition = {
|
||||
'_misskey_votes': 'misskey:_misskey_votes',
|
||||
'_misskey_summary': 'misskey:_misskey_summary',
|
||||
'_misskey_followedMessage': 'misskey:_misskey_followedMessage',
|
||||
'_misskey_requireSigninToViewContents': 'misskey:_misskey_requireSigninToViewContents',
|
||||
'_misskey_makeNotesFollowersOnlyBefore': 'misskey:_misskey_makeNotesFollowersOnlyBefore',
|
||||
'_misskey_makeNotesHiddenBefore': 'misskey:_misskey_makeNotesHiddenBefore',
|
||||
'isCat': 'misskey:isCat',
|
||||
// vcard
|
||||
vcard: 'http://www.w3.org/2006/vcard/ns#',
|
||||
|
@@ -356,6 +356,9 @@ export class ApPersonService implements OnModuleInit {
|
||||
tags,
|
||||
isBot,
|
||||
isCat: (person as any).isCat === true,
|
||||
requireSigninToViewContents: (person as any).requireSigninToViewContents === true,
|
||||
makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null,
|
||||
makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null,
|
||||
emojis,
|
||||
})) as MiRemoteUser;
|
||||
|
||||
|
@@ -14,6 +14,9 @@ export interface IObject {
|
||||
summary?: string;
|
||||
_misskey_summary?: string;
|
||||
_misskey_followedMessage?: string | null;
|
||||
_misskey_requireSigninToViewContents?: boolean;
|
||||
_misskey_makeNotesFollowersOnlyBefore?: number | null;
|
||||
_misskey_makeNotesHiddenBefore?: number | null;
|
||||
published?: string;
|
||||
cc?: ApObject;
|
||||
to?: ApObject;
|
||||
|
@@ -22,6 +22,7 @@ import type { ReactionService } from '../ReactionService.js';
|
||||
import type { UserEntityService } from './UserEntityService.js';
|
||||
import type { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||
|
||||
// is-renote.tsとよしなにリンク
|
||||
function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } {
|
||||
return (
|
||||
note.renote != null &&
|
||||
@@ -101,50 +102,80 @@ export class NoteEntityService implements OnModuleInit {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null) {
|
||||
private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null): Promise<void> {
|
||||
// FIXME: このvisibility変更処理が当関数にあるのは若干不自然かもしれない(関数名を treatVisibility とかに変える手もある)
|
||||
if (packedNote.visibility === 'public' || packedNote.visibility === 'home') {
|
||||
const followersOnlyBefore = packedNote.user.makeNotesFollowersOnlyBefore;
|
||||
if ((followersOnlyBefore != null)
|
||||
&& (
|
||||
(followersOnlyBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (followersOnlyBefore * 1000)))
|
||||
|| (followersOnlyBefore > 0 && (new Date(packedNote.createdAt).getTime() < followersOnlyBefore * 1000))
|
||||
)
|
||||
) {
|
||||
packedNote.visibility = 'followers';
|
||||
}
|
||||
}
|
||||
|
||||
if (meId === packedNote.userId) return;
|
||||
|
||||
// TODO: isVisibleForMe を使うようにしても良さそう(型違うけど)
|
||||
let hide = false;
|
||||
|
||||
// visibility が specified かつ自分が指定されていなかったら非表示
|
||||
if (packedNote.visibility === 'specified') {
|
||||
if (meId == null) {
|
||||
hide = true;
|
||||
} else if (meId === packedNote.userId) {
|
||||
hide = false;
|
||||
} else {
|
||||
// 指定されているかどうか
|
||||
const specified = packedNote.visibleUserIds!.some((id: any) => meId === id);
|
||||
if (packedNote.user.requireSigninToViewContents && meId == null) {
|
||||
hide = true;
|
||||
}
|
||||
|
||||
if (specified) {
|
||||
hide = false;
|
||||
} else {
|
||||
if (!hide) {
|
||||
const hiddenBefore = packedNote.user.makeNotesHiddenBefore;
|
||||
if ((hiddenBefore != null)
|
||||
&& (
|
||||
(hiddenBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (hiddenBefore * 1000)))
|
||||
|| (hiddenBefore > 0 && (new Date(packedNote.createdAt).getTime() < hiddenBefore * 1000))
|
||||
)
|
||||
) {
|
||||
hide = true;
|
||||
}
|
||||
}
|
||||
|
||||
// visibility が specified かつ自分が指定されていなかったら非表示
|
||||
if (!hide) {
|
||||
if (packedNote.visibility === 'specified') {
|
||||
if (meId == null) {
|
||||
hide = true;
|
||||
} else {
|
||||
// 指定されているかどうか
|
||||
const specified = packedNote.visibleUserIds!.some(id => meId === id);
|
||||
|
||||
if (!specified) {
|
||||
hide = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示
|
||||
if (packedNote.visibility === 'followers') {
|
||||
if (meId == null) {
|
||||
hide = true;
|
||||
} else if (meId === packedNote.userId) {
|
||||
hide = false;
|
||||
} else if (packedNote.reply && (meId === packedNote.reply.userId)) {
|
||||
// 自分の投稿に対するリプライ
|
||||
hide = false;
|
||||
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
|
||||
// 自分へのメンション
|
||||
hide = false;
|
||||
} else {
|
||||
// フォロワーかどうか
|
||||
const isFollowing = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followeeId: packedNote.userId,
|
||||
followerId: meId,
|
||||
},
|
||||
});
|
||||
if (!hide) {
|
||||
if (packedNote.visibility === 'followers') {
|
||||
if (meId == null) {
|
||||
hide = true;
|
||||
} else if (packedNote.reply && (meId === packedNote.reply.userId)) {
|
||||
// 自分の投稿に対するリプライ
|
||||
hide = false;
|
||||
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
|
||||
// 自分へのメンション
|
||||
hide = false;
|
||||
} else {
|
||||
// フォロワーかどうか
|
||||
// TODO: 当関数呼び出しごとにクエリが走るのは重そうだからなんとかする
|
||||
const isFollowing = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followeeId: packedNote.userId,
|
||||
followerId: meId,
|
||||
},
|
||||
});
|
||||
|
||||
hide = !isFollowing;
|
||||
hide = !isFollowing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +187,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||
packedNote.poll = undefined;
|
||||
packedNote.cw = null;
|
||||
packedNote.isHidden = true;
|
||||
// TODO: hiddenReason みたいなのを提供しても良さそう
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +282,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||
return true;
|
||||
} else {
|
||||
// 指定されているかどうか
|
||||
return note.visibleUserIds.some((id: any) => meId === id);
|
||||
return note.visibleUserIds.some(id => meId === id);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -490,6 +490,9 @@ export class UserEntityService implements OnModuleInit {
|
||||
}))) : [],
|
||||
isBot: user.isBot,
|
||||
isCat: user.isCat,
|
||||
requireSigninToViewContents: user.requireSigninToViewContents === false ? undefined : true,
|
||||
makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore ?? undefined,
|
||||
makeNotesHiddenBefore: user.makeNotesHiddenBefore ?? undefined,
|
||||
instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? {
|
||||
name: instance.name,
|
||||
softwareName: instance.softwareName,
|
||||
|
@@ -6,6 +6,8 @@
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
|
||||
// NoteEntityService.isPureRenote とよしなにリンク
|
||||
|
||||
type Renote =
|
||||
MiNote & {
|
||||
renoteId: NonNullable<MiNote['renoteId']>
|
||||
|
@@ -202,6 +202,23 @@ export class MiUser {
|
||||
})
|
||||
public isHibernated: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public requireSigninToViewContents: boolean;
|
||||
|
||||
// in sec, マイナスで相対時間
|
||||
@Column('integer', {
|
||||
nullable: true,
|
||||
})
|
||||
public makeNotesFollowersOnlyBefore: number | null;
|
||||
|
||||
// in sec, マイナスで相対時間
|
||||
@Column('integer', {
|
||||
nullable: true,
|
||||
})
|
||||
public makeNotesHiddenBefore: number | null;
|
||||
|
||||
// アカウントが削除されたかどうかのフラグだが、完全に削除される際は物理削除なので実質削除されるまでの「削除が進行しているかどうか」のフラグ
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
|
@@ -115,6 +115,18 @@ export const packedUserLiteSchema = {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
requireSigninToViewContents: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
makeNotesFollowersOnlyBefore: {
|
||||
type: 'number',
|
||||
nullable: true, optional: true,
|
||||
},
|
||||
makeNotesHiddenBefore: {
|
||||
type: 'number',
|
||||
nullable: true, optional: true,
|
||||
},
|
||||
instance: {
|
||||
type: 'object',
|
||||
nullable: false, optional: true,
|
||||
|
@@ -39,6 +39,17 @@ export class GetterService {
|
||||
return note;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getNoteWithUser(noteId: MiNote['id']) {
|
||||
const note = await this.notesRepository.findOne({ where: { id: noteId }, relations: ['user'] });
|
||||
|
||||
if (note == null) {
|
||||
throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.');
|
||||
}
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user for API processing
|
||||
*/
|
||||
|
@@ -179,6 +179,9 @@ export const paramDef = {
|
||||
autoAcceptFollowed: { type: 'boolean' },
|
||||
noCrawle: { type: 'boolean' },
|
||||
preventAiLearning: { type: 'boolean' },
|
||||
requireSigninToViewContents: { type: 'boolean' },
|
||||
makeNotesFollowersOnlyBefore: { type: 'integer', nullable: true },
|
||||
makeNotesHiddenBefore: { type: 'integer', nullable: true },
|
||||
isBot: { type: 'boolean' },
|
||||
isCat: { type: 'boolean' },
|
||||
injectFeaturedNote: { type: 'boolean' },
|
||||
@@ -334,6 +337,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed;
|
||||
if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle;
|
||||
if (typeof ps.preventAiLearning === 'boolean') profileUpdates.preventAiLearning = ps.preventAiLearning;
|
||||
if (typeof ps.requireSigninToViewContents === 'boolean') updates.requireSigninToViewContents = ps.requireSigninToViewContents;
|
||||
if ((typeof ps.makeNotesFollowersOnlyBefore === 'number') || (ps.makeNotesFollowersOnlyBefore === null)) updates.makeNotesFollowersOnlyBefore = ps.makeNotesFollowersOnlyBefore;
|
||||
if ((typeof ps.makeNotesHiddenBefore === 'number') || (ps.makeNotesHiddenBefore === null)) updates.makeNotesHiddenBefore = ps.makeNotesHiddenBefore;
|
||||
if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat;
|
||||
if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote;
|
||||
if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail;
|
||||
|
@@ -26,6 +26,12 @@ export const meta = {
|
||||
code: 'NO_SUCH_NOTE',
|
||||
id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d',
|
||||
},
|
||||
|
||||
signinRequired: {
|
||||
message: 'Signin required.',
|
||||
code: 'SIGNIN_REQUIRED',
|
||||
id: '8e75455b-738c-471d-9f80-62693f33372e',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -44,11 +50,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private getterService: GetterService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const note = await this.getterService.getNote(ps.noteId).catch(err => {
|
||||
const note = await this.getterService.getNoteWithUser(ps.noteId).catch(err => {
|
||||
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (note.user!.requireSigninToViewContents && me == null) {
|
||||
throw new ApiError(meta.errors.signinRequired);
|
||||
}
|
||||
|
||||
return await this.noteEntityService.pack(note, me, {
|
||||
detail: true,
|
||||
});
|
||||
|
@@ -42,6 +42,12 @@ export const meta = {
|
||||
code: 'BOTH_WITH_REPLIES_AND_WITH_FILES',
|
||||
id: '91c8cb9f-36ed-46e7-9ca2-7df96ed6e222',
|
||||
},
|
||||
|
||||
signinRequired: {
|
||||
message: 'Signin required.',
|
||||
code: 'SIGNIN_REQUIRED',
|
||||
id: 'd1588a9e-4b4d-4c07-807f-16f1486577a2',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@@ -30,6 +30,7 @@ import type {
|
||||
EndedPollNotificationQueue,
|
||||
InboxQueue,
|
||||
ObjectStorageQueue,
|
||||
RelationshipQueue,
|
||||
SystemQueue,
|
||||
UserWebhookDeliverQueue,
|
||||
SystemWebhookDeliverQueue,
|
||||
@@ -121,6 +122,7 @@ export class ClientServerService {
|
||||
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
|
||||
@Inject('queue:inbox') public inboxQueue: InboxQueue,
|
||||
@Inject('queue:db') public dbQueue: DbQueue,
|
||||
@Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
|
||||
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
|
||||
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
||||
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
||||
@@ -248,6 +250,7 @@ export class ClientServerService {
|
||||
this.deliverQueue,
|
||||
this.inboxQueue,
|
||||
this.dbQueue,
|
||||
this.relationshipQueue,
|
||||
this.objectStorageQueue,
|
||||
this.userWebhookDeliverQueue,
|
||||
this.systemWebhookDeliverQueue,
|
||||
@@ -598,12 +601,15 @@ export class ClientServerService {
|
||||
fastify.get<{ Params: { note: string; } }>('/notes/:note', async (request, reply) => {
|
||||
vary(reply.raw, 'Accept');
|
||||
|
||||
const note = await this.notesRepository.findOneBy({
|
||||
id: request.params.note,
|
||||
visibility: In(['public', 'home']),
|
||||
const note = await this.notesRepository.findOne({
|
||||
where: {
|
||||
id: request.params.note,
|
||||
visibility: In(['public', 'home']),
|
||||
},
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (note) {
|
||||
if (note && !note.user!.requireSigninToViewContents) {
|
||||
const _note = await this.noteEntityService.pack(note);
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId });
|
||||
reply.header('Cache-Control', 'public, max-age=15');
|
||||
|
70
packages/backend/test-federation/.config/example.conf
Normal file
70
packages/backend/test-federation/.config/example.conf
Normal file
@@ -0,0 +1,70 @@
|
||||
# based on https://github.com/misskey-dev/misskey-hub/blob/7071f63a1c80ee35c71f0fd8a6d8722c118c7574/src/docs/admin/nginx.md
|
||||
|
||||
# For WebSocket
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
proxy_cache_path /tmp/nginx_cache levels=1:2 keys_zone=cache1:16m max_size=1g inactive=720m use_temp_path=off;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${HOST};
|
||||
|
||||
# For SSL domain validation
|
||||
root /var/www/html;
|
||||
location /.well-known/acme-challenge/ { allow all; }
|
||||
location /.well-known/pki-validation/ { allow all; }
|
||||
location / { return 301 https://$server_name$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name ${HOST};
|
||||
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:ssl_session_cache:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
ssl_trusted_certificate /etc/nginx/certificates/rootCA.crt;
|
||||
ssl_certificate /etc/nginx/certificates/$server_name.crt;
|
||||
ssl_certificate_key /etc/nginx/certificates/$server_name.key;
|
||||
|
||||
# SSL protocol settings
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
|
||||
# Change to your upload limit
|
||||
client_max_body_size 80m;
|
||||
|
||||
# Proxy to Node
|
||||
location / {
|
||||
proxy_pass http://misskey.${HOST}:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_http_version 1.1;
|
||||
proxy_redirect off;
|
||||
|
||||
# If it's behind another reverse proxy or CDN, remove the following.
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
|
||||
# For WebSocket
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Cache settings
|
||||
proxy_cache cache1;
|
||||
proxy_cache_lock on;
|
||||
proxy_cache_use_stale updating;
|
||||
proxy_force_ranges on;
|
||||
add_header X-Cache $upstream_cache_status;
|
||||
}
|
||||
}
|
25
packages/backend/test-federation/.config/example.default.yml
Normal file
25
packages/backend/test-federation/.config/example.default.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
url: https://${HOST}/
|
||||
port: 3000
|
||||
db:
|
||||
host: db.${HOST}
|
||||
port: 5432
|
||||
db: misskey
|
||||
user: postgres
|
||||
pass: postgres
|
||||
dbReplications: false
|
||||
redis:
|
||||
host: redis.test
|
||||
port: 6379
|
||||
id: 'aidx'
|
||||
proxyBypassHosts:
|
||||
- api.deepl.com
|
||||
- api-free.deepl.com
|
||||
- www.recaptcha.net
|
||||
- hcaptcha.com
|
||||
- challenges.cloudflare.com
|
||||
proxyRemoteFiles: true
|
||||
signToActivityPubGet: true
|
||||
allowedPrivateNetworks: [
|
||||
'127.0.0.1/32',
|
||||
'172.20.0.0/16'
|
||||
]
|
@@ -0,0 +1,5 @@
|
||||
NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/rootCA.crt
|
||||
POSTGRES_DB=misskey
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
MK_VERBOSE=true
|
6
packages/backend/test-federation/.gitignore
vendored
Normal file
6
packages/backend/test-federation/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
certificates
|
||||
volumes
|
||||
.env
|
||||
docker.env
|
||||
*.test.conf
|
||||
*.test.default.yml
|
24
packages/backend/test-federation/README.md
Normal file
24
packages/backend/test-federation/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
## test-federation
|
||||
Test federation between two Misskey servers: `a.test` and `b.test`.
|
||||
|
||||
Before testing, you need to build the entire project, and change working directory to here:
|
||||
```sh
|
||||
pnpm build
|
||||
cd packages/backend/test-federation
|
||||
```
|
||||
|
||||
First, you need to start servers by executing following commands:
|
||||
```sh
|
||||
bash ./setup.sh
|
||||
docker compose up --scale tester=0
|
||||
```
|
||||
|
||||
Then you can run all tests by a following command:
|
||||
```sh
|
||||
docker compose run --no-deps --rm tester
|
||||
```
|
||||
|
||||
For testing a specific file, run a following command:
|
||||
```sh
|
||||
docker compose run --no-deps --rm tester -- pnpm -F backend test:fed packages/backend/test-federation/test/user.test.ts
|
||||
```
|
64
packages/backend/test-federation/compose.a.yml
Normal file
64
packages/backend/test-federation/compose.a.yml
Normal file
@@ -0,0 +1,64 @@
|
||||
services:
|
||||
a.test:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: nginx
|
||||
depends_on:
|
||||
misskey.a.test:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal_network_a
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./.config/a.test.conf
|
||||
target: /etc/nginx/conf.d/a.test.conf
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/a.test.crt
|
||||
target: /etc/nginx/certificates/a.test.crt
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/a.test.key
|
||||
target: /etc/nginx/certificates/a.test.key
|
||||
read_only: true
|
||||
|
||||
misskey.a.test:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: misskey
|
||||
depends_on:
|
||||
db.a.test:
|
||||
condition: service_healthy
|
||||
redis.test:
|
||||
condition: service_healthy
|
||||
setup:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- internal_network_a
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./.config/a.test.default.yml
|
||||
target: /misskey/.config/default.yml
|
||||
read_only: true
|
||||
|
||||
db.a.test:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: db
|
||||
networks:
|
||||
- internal_network_a
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./volumes/db.a
|
||||
target: /var/lib/postgresql/data
|
||||
bind:
|
||||
create_host_path: true
|
||||
|
||||
networks:
|
||||
internal_network_a:
|
||||
internal: true
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.21.0.0/16
|
||||
ip_range: 172.21.0.0/24
|
64
packages/backend/test-federation/compose.b.yml
Normal file
64
packages/backend/test-federation/compose.b.yml
Normal file
@@ -0,0 +1,64 @@
|
||||
services:
|
||||
b.test:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: nginx
|
||||
depends_on:
|
||||
misskey.b.test:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal_network_b
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./.config/b.test.conf
|
||||
target: /etc/nginx/conf.d/b.test.conf
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/b.test.crt
|
||||
target: /etc/nginx/certificates/b.test.crt
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/b.test.key
|
||||
target: /etc/nginx/certificates/b.test.key
|
||||
read_only: true
|
||||
|
||||
misskey.b.test:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: misskey
|
||||
depends_on:
|
||||
db.b.test:
|
||||
condition: service_healthy
|
||||
redis.test:
|
||||
condition: service_healthy
|
||||
setup:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- internal_network_b
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./.config/b.test.default.yml
|
||||
target: /misskey/.config/default.yml
|
||||
read_only: true
|
||||
|
||||
db.b.test:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: db
|
||||
networks:
|
||||
- internal_network_b
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./volumes/db.b
|
||||
target: /var/lib/postgresql/data
|
||||
bind:
|
||||
create_host_path: true
|
||||
|
||||
networks:
|
||||
internal_network_b:
|
||||
internal: true
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.22.0.0/16
|
||||
ip_range: 172.22.0.0/24
|
117
packages/backend/test-federation/compose.override.yaml
Normal file
117
packages/backend/test-federation/compose.override.yaml
Normal file
@@ -0,0 +1,117 @@
|
||||
services:
|
||||
setup:
|
||||
volumes:
|
||||
- type: volume
|
||||
source: node_modules
|
||||
target: /misskey/node_modules
|
||||
- type: volume
|
||||
source: node_modules_backend
|
||||
target: /misskey/packages/backend/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-js
|
||||
target: /misskey/packages/misskey-js/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-reversi
|
||||
target: /misskey/packages/misskey-reversi/node_modules
|
||||
|
||||
tester:
|
||||
networks:
|
||||
external_network:
|
||||
internal_network:
|
||||
ipv4_address: 172.20.1.1
|
||||
volumes:
|
||||
- type: volume
|
||||
source: node_modules_dev
|
||||
target: /misskey/node_modules
|
||||
- type: volume
|
||||
source: node_modules_backend_dev
|
||||
target: /misskey/packages/backend/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-js_dev
|
||||
target: /misskey/packages/misskey-js/node_modules
|
||||
|
||||
daemon:
|
||||
networks:
|
||||
- external_network
|
||||
- internal_network_a
|
||||
- internal_network_b
|
||||
volumes:
|
||||
- type: volume
|
||||
source: node_modules_dev
|
||||
target: /misskey/node_modules
|
||||
- type: volume
|
||||
source: node_modules_backend_dev
|
||||
target: /misskey/packages/backend/node_modules
|
||||
|
||||
redis.test:
|
||||
networks:
|
||||
- internal_network_a
|
||||
- internal_network_b
|
||||
|
||||
a.test:
|
||||
networks:
|
||||
- internal_network
|
||||
|
||||
misskey.a.test:
|
||||
networks:
|
||||
- external_network
|
||||
- internal_network
|
||||
volumes:
|
||||
- type: volume
|
||||
source: node_modules
|
||||
target: /misskey/node_modules
|
||||
- type: volume
|
||||
source: node_modules_backend
|
||||
target: /misskey/packages/backend/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-js
|
||||
target: /misskey/packages/misskey-js/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-reversi
|
||||
target: /misskey/packages/misskey-reversi/node_modules
|
||||
|
||||
b.test:
|
||||
networks:
|
||||
- internal_network
|
||||
|
||||
misskey.b.test:
|
||||
networks:
|
||||
- external_network
|
||||
- internal_network
|
||||
volumes:
|
||||
- type: volume
|
||||
source: node_modules
|
||||
target: /misskey/node_modules
|
||||
- type: volume
|
||||
source: node_modules_backend
|
||||
target: /misskey/packages/backend/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-js
|
||||
target: /misskey/packages/misskey-js/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-reversi
|
||||
target: /misskey/packages/misskey-reversi/node_modules
|
||||
|
||||
networks:
|
||||
external_network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.23.0.0/16
|
||||
ip_range: 172.23.0.0/24
|
||||
internal_network:
|
||||
internal: true
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
ip_range: 172.20.0.0/24
|
||||
|
||||
volumes:
|
||||
node_modules:
|
||||
node_modules_dev:
|
||||
node_modules_backend:
|
||||
node_modules_backend_dev:
|
||||
node_modules_misskey-js:
|
||||
node_modules_misskey-js_dev:
|
||||
node_modules_misskey-reversi:
|
101
packages/backend/test-federation/compose.tpl.yml
Normal file
101
packages/backend/test-federation/compose.tpl.yml
Normal file
@@ -0,0 +1,101 @@
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:1.27
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./certificates/rootCA.crt
|
||||
target: /etc/nginx/certificates/rootCA.crt
|
||||
read_only: true
|
||||
healthcheck:
|
||||
test: service nginx status
|
||||
interval: 5s
|
||||
retries: 20
|
||||
|
||||
misskey:
|
||||
image: node:20
|
||||
env_file:
|
||||
- ./.config/docker.env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ../../../built
|
||||
target: /misskey/built
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../assets
|
||||
target: /misskey/packages/backend/assets
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../built
|
||||
target: /misskey/packages/backend/built
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../migration
|
||||
target: /misskey/packages/backend/migration
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../ormconfig.js
|
||||
target: /misskey/packages/backend/ormconfig.js
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../package.json
|
||||
target: /misskey/packages/backend/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-js/built
|
||||
target: /misskey/packages/misskey-js/built
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-js/package.json
|
||||
target: /misskey/packages/misskey-js/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-reversi/built
|
||||
target: /misskey/packages/misskey-reversi/built
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-reversi/package.json
|
||||
target: /misskey/packages/misskey-reversi/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../healthcheck.sh
|
||||
target: /misskey/healthcheck.sh
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../package.json
|
||||
target: /misskey/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../pnpm-lock.yaml
|
||||
target: /misskey/pnpm-lock.yaml
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../pnpm-workspace.yaml
|
||||
target: /misskey/pnpm-workspace.yaml
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/rootCA.crt
|
||||
target: /usr/local/share/ca-certificates/rootCA.crt
|
||||
read_only: true
|
||||
working_dir: /misskey
|
||||
command: >
|
||||
bash -c "
|
||||
corepack enable && corepack prepare
|
||||
pnpm -F backend migrate
|
||||
pnpm -F backend start
|
||||
"
|
||||
healthcheck:
|
||||
test: bash /misskey/healthcheck.sh
|
||||
interval: 5s
|
||||
retries: 20
|
||||
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
env_file:
|
||||
- ./.config/docker.env
|
||||
volumes:
|
||||
healthcheck:
|
||||
test: pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB
|
||||
interval: 5s
|
||||
retries: 20
|
133
packages/backend/test-federation/compose.yml
Normal file
133
packages/backend/test-federation/compose.yml
Normal file
@@ -0,0 +1,133 @@
|
||||
include:
|
||||
- ./compose.a.yml
|
||||
- ./compose.b.yml
|
||||
|
||||
services:
|
||||
setup:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: misskey
|
||||
command: >
|
||||
bash -c "
|
||||
corepack enable && corepack prepare
|
||||
pnpm -F backend i
|
||||
pnpm -F misskey-js i
|
||||
pnpm -F misskey-reversi i
|
||||
"
|
||||
|
||||
tester:
|
||||
image: node:20
|
||||
depends_on:
|
||||
a.test:
|
||||
condition: service_healthy
|
||||
b.test:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/rootCA.crt
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ../package.json
|
||||
target: /misskey/packages/backend/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../test/resources
|
||||
target: /misskey/packages/backend/test/resources
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./test
|
||||
target: /misskey/packages/backend/test-federation/test
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../jest.config.cjs
|
||||
target: /misskey/packages/backend/jest.config.cjs
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../jest.config.fed.cjs
|
||||
target: /misskey/packages/backend/jest.config.fed.cjs
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-js/built
|
||||
target: /misskey/packages/misskey-js/built
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-js/package.json
|
||||
target: /misskey/packages/misskey-js/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../package.json
|
||||
target: /misskey/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../pnpm-lock.yaml
|
||||
target: /misskey/pnpm-lock.yaml
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../pnpm-workspace.yaml
|
||||
target: /misskey/pnpm-workspace.yaml
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/rootCA.crt
|
||||
target: /usr/local/share/ca-certificates/rootCA.crt
|
||||
read_only: true
|
||||
working_dir: /misskey
|
||||
entrypoint: >
|
||||
bash -c '
|
||||
corepack enable && corepack prepare
|
||||
pnpm -F misskey-js i --frozen-lockfile
|
||||
pnpm -F backend i --frozen-lockfile
|
||||
exec "$0" "$@"
|
||||
'
|
||||
command: pnpm -F backend test:fed
|
||||
|
||||
daemon:
|
||||
image: node:20
|
||||
depends_on:
|
||||
redis.test:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ../package.json
|
||||
target: /misskey/packages/backend/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./daemon.ts
|
||||
target: /misskey/packages/backend/test-federation/daemon.ts
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./tsconfig.json
|
||||
target: /misskey/packages/backend/test-federation/tsconfig.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../package.json
|
||||
target: /misskey/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../pnpm-lock.yaml
|
||||
target: /misskey/pnpm-lock.yaml
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../pnpm-workspace.yaml
|
||||
target: /misskey/pnpm-workspace.yaml
|
||||
read_only: true
|
||||
working_dir: /misskey
|
||||
command: >
|
||||
bash -c "
|
||||
corepack enable && corepack prepare
|
||||
pnpm -F backend i --frozen-lockfile
|
||||
pnpm exec tsc -p ./packages/backend/test-federation
|
||||
node ./packages/backend/test-federation/built/daemon.js
|
||||
"
|
||||
|
||||
redis.test:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./volumes/redis
|
||||
target: /data
|
||||
bind:
|
||||
create_host_path: true
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
retries: 20
|
38
packages/backend/test-federation/daemon.ts
Normal file
38
packages/backend/test-federation/daemon.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import IPCIDR from 'ip-cidr';
|
||||
import { Redis } from 'ioredis';
|
||||
|
||||
const TESTER_IP_ADDRESS = '172.20.1.1';
|
||||
|
||||
/**
|
||||
* This should be same as {@link file://./../src/misc/get-ip-hash.ts}.
|
||||
*/
|
||||
function getIpHash(ip: string) {
|
||||
const prefix = IPCIDR.createAddress(ip).mask(64);
|
||||
return `ip-${BigInt('0b' + prefix).toString(36)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* This prevents hitting rate limit when login.
|
||||
*/
|
||||
export async function purgeLimit(host: string, client: Redis) {
|
||||
const ipHash = getIpHash(TESTER_IP_ADDRESS);
|
||||
const key = `${host}:limit:${ipHash}:signin`;
|
||||
const res = await client.zrange(key, 0, -1);
|
||||
if (res.length !== 0) {
|
||||
console.log(`${key} - ${JSON.stringify(res)}`);
|
||||
await client.del(key);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Daemon started running');
|
||||
|
||||
{
|
||||
const redisClient = new Redis({
|
||||
host: 'redis.test',
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
purgeLimit('a.test', redisClient);
|
||||
purgeLimit('b.test', redisClient);
|
||||
}, 200);
|
||||
}
|
21
packages/backend/test-federation/eslint.config.js
Normal file
21
packages/backend/test-federation/eslint.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import globals from 'globals';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import sharedConfig from '../../shared/eslint.config.js';
|
||||
|
||||
export default [
|
||||
...sharedConfig,
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
project: ['./tsconfig.json'],
|
||||
sourceType: 'module',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
35
packages/backend/test-federation/setup.sh
Normal file
35
packages/backend/test-federation/setup.sh
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
mkdir certificates
|
||||
|
||||
# rootCA
|
||||
openssl genrsa -des3 \
|
||||
-passout pass:rootCA \
|
||||
-out certificates/rootCA.key 4096
|
||||
openssl req -x509 -new -nodes -batch \
|
||||
-key certificates/rootCA.key \
|
||||
-sha256 \
|
||||
-days 1024 \
|
||||
-passin pass:rootCA \
|
||||
-out certificates/rootCA.crt
|
||||
|
||||
# domain
|
||||
function generate {
|
||||
openssl req -new -newkey rsa:2048 -sha256 -nodes \
|
||||
-keyout certificates/$1.key \
|
||||
-subj "/CN=$1/emailAddress=admin@$1/C=JP/ST=/L=/O=Misskey Tester/OU=Some Unit" \
|
||||
-out certificates/$1.csr
|
||||
openssl x509 -req -sha256 \
|
||||
-in certificates/$1.csr \
|
||||
-CA certificates/rootCA.crt \
|
||||
-CAkey certificates/rootCA.key \
|
||||
-CAcreateserial \
|
||||
-passin pass:rootCA \
|
||||
-out certificates/$1.crt \
|
||||
-days 500
|
||||
if [ ! -f .config/docker.env ]; then cp .config/example.docker.env .config/docker.env; fi
|
||||
if [ ! -f .config/$1.conf ]; then sed "s/\${HOST}/$1/g" .config/example.conf > .config/$1.conf; fi
|
||||
if [ ! -f .config/$1.default.yml ]; then sed "s/\${HOST}/$1/g" .config/example.default.yml > .config/$1.default.yml; fi
|
||||
}
|
||||
|
||||
generate a.test
|
||||
generate b.test
|
52
packages/backend/test-federation/test/abuse-report.test.ts
Normal file
52
packages/backend/test-federation/test/abuse-report.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { rejects, strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { createAccount, createModerator, resolveRemoteUser, sleep, type LoginUser } from './utils.js';
|
||||
|
||||
describe('Abuse report', () => {
|
||||
describe('Forwarding report', () => {
|
||||
let alice: LoginUser, bob: LoginUser, aModerator: LoginUser, bModerator: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[aModerator, bModerator] = await Promise.all([
|
||||
createModerator('a.test'),
|
||||
createModerator('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Alice reports Bob, moderator in A forwards it, and B moderator receives it', async () => {
|
||||
const comment = crypto.randomUUID();
|
||||
await alice.client.request('users/report-abuse', { userId: bobInA.id, comment });
|
||||
const reports = await aModerator.client.request('admin/abuse-user-reports', {});
|
||||
const report = reports.filter(report => report.comment === comment)[0];
|
||||
await aModerator.client.request('admin/forward-abuse-user-report', { reportId: report.id });
|
||||
await sleep();
|
||||
|
||||
const reportsInB = await bModerator.client.request('admin/abuse-user-reports', {});
|
||||
const reportInB = reportsInB.filter(report => report.comment.includes(comment))[0];
|
||||
// NOTE: reporter is not Alice, and is not moderator in A
|
||||
strictEqual(reportInB.reporter.url, 'https://a.test/@instance.actor');
|
||||
strictEqual(reportInB.targetUserId, bob.id);
|
||||
|
||||
// NOTE: cannot forward multiple times
|
||||
await rejects(
|
||||
async () => await aModerator.client.request('admin/forward-abuse-user-report', { reportId: report.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'INTERNAL_ERROR');
|
||||
strictEqual(err.info.e.message, 'The report has already been forwarded.');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
224
packages/backend/test-federation/test/block.test.ts
Normal file
224
packages/backend/test-federation/test/block.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { deepStrictEqual, rejects, strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
|
||||
|
||||
describe('Block', () => {
|
||||
describe('Check follow', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Cannot follow if blocked', async () => {
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'BLOCKED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0);
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 0);
|
||||
});
|
||||
|
||||
// FIXME: this is invalid case
|
||||
test('Cannot follow even if unblocked', async () => {
|
||||
// unblock here
|
||||
await alice.client.request('blocking/delete', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
// TODO: why still being blocked?
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'BLOCKED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.skip('Can follow if unblocked', async () => {
|
||||
await alice.client.request('blocking/delete', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 1);
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1);
|
||||
});
|
||||
|
||||
test.skip('Remove follower when block them', async () => {
|
||||
test('before block', async () => {
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 1);
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1);
|
||||
});
|
||||
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
test('after block', async () => {
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0);
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Check reply', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Cannot reply if blocked', async () => {
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
await rejects(
|
||||
async () => await bob.client.request('notes/create', { text: 'b', replyId: resolvedNote.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('Can reply if unblocked', async () => {
|
||||
await alice.client.request('blocking/delete', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const reply = (await bob.client.request('notes/create', { text: 'b', replyId: resolvedNote.id })).createdNote;
|
||||
|
||||
await resolveRemoteNote('b.test', reply.id, alice);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Check reaction', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Cannot reaction if blocked', async () => {
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
await rejects(
|
||||
async () => await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// FIXME: this is invalid case
|
||||
test('Cannot reaction even if unblocked', async () => {
|
||||
// unblock here
|
||||
await alice.client.request('blocking/delete', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
|
||||
// TODO: why still being blocked?
|
||||
await rejects(
|
||||
async () => await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.skip('Can reaction if unblocked', async () => {
|
||||
await alice.client.request('blocking/delete', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' });
|
||||
|
||||
const _note = await alice.client.request('notes/show', { noteId: note.id });
|
||||
deepStrictEqual(_note.reactions, { '😅': 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Check mention', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
/** NOTE: You should mute the target to stop receiving notifications */
|
||||
test('Can mention and notified even if blocked', async () => {
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const text = `@${alice.username}@a.test plz unblock me!`;
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('notes/create', { text }),
|
||||
notification => notification.type === 'mention' && notification.userId === bobInA.id && notification.note.text === text,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
175
packages/backend/test-federation/test/drive.test.ts
Normal file
175
packages/backend/test-federation/test/drive.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import assert, { strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js';
|
||||
|
||||
const bAdmin = await fetchAdmin('b.test');
|
||||
|
||||
describe('Drive', () => {
|
||||
describe('Upload image in a.test and resolve from b.test', () => {
|
||||
let uploader: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
uploader = await createAccount('a.test');
|
||||
});
|
||||
|
||||
let image: Misskey.entities.DriveFile, imageInB: Misskey.entities.DriveFile;
|
||||
|
||||
describe('Upload', () => {
|
||||
beforeAll(async () => {
|
||||
image = await uploadFile('a.test', uploader);
|
||||
const noteWithImage = (await uploader.client.request('notes/create', { fileIds: [image.id] })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', noteWithImage.id, bAdmin);
|
||||
assert(noteInB.files != null);
|
||||
strictEqual(noteInB.files.length, 1);
|
||||
imageInB = noteInB.files[0];
|
||||
});
|
||||
|
||||
test('Check consistency of DriveFile', () => {
|
||||
// console.log(`a.test: ${JSON.stringify(image, null, '\t')}`);
|
||||
// console.log(`b.test: ${JSON.stringify(imageInB, null, '\t')}`);
|
||||
|
||||
deepStrictEqualWithExcludedFields(image, imageInB, [
|
||||
'id',
|
||||
'createdAt',
|
||||
'size',
|
||||
'url',
|
||||
'thumbnailUrl',
|
||||
'userId',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
let updatedImage: Misskey.entities.DriveFile, updatedImageInB: Misskey.entities.DriveFile;
|
||||
|
||||
describe('Update', () => {
|
||||
beforeAll(async () => {
|
||||
updatedImage = await uploader.client.request('drive/files/update', {
|
||||
fileId: image.id,
|
||||
name: 'updated_192.jpg',
|
||||
isSensitive: true,
|
||||
});
|
||||
|
||||
updatedImageInB = await bAdmin.client.request('drive/files/show', {
|
||||
fileId: imageInB.id,
|
||||
});
|
||||
});
|
||||
|
||||
test('Check consistency', () => {
|
||||
// console.log(`a.test: ${JSON.stringify(updatedImage, null, '\t')}`);
|
||||
// console.log(`b.test: ${JSON.stringify(updatedImageInB, null, '\t')}`);
|
||||
|
||||
// FIXME: not updated with `drive/files/update`
|
||||
strictEqual(updatedImage.isSensitive, true);
|
||||
strictEqual(updatedImage.name, 'updated_192.jpg');
|
||||
strictEqual(updatedImageInB.isSensitive, false);
|
||||
strictEqual(updatedImageInB.name, '192.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
let reupdatedImageInB: Misskey.entities.DriveFile;
|
||||
|
||||
describe('Re-update with attaching to Note', () => {
|
||||
beforeAll(async () => {
|
||||
const noteWithUpdatedImage = (await uploader.client.request('notes/create', { fileIds: [updatedImage.id] })).createdNote;
|
||||
const noteWithUpdatedImageInB = await resolveRemoteNote('a.test', noteWithUpdatedImage.id, bAdmin);
|
||||
assert(noteWithUpdatedImageInB.files != null);
|
||||
strictEqual(noteWithUpdatedImageInB.files.length, 1);
|
||||
reupdatedImageInB = noteWithUpdatedImageInB.files[0];
|
||||
});
|
||||
|
||||
test('Check consistency', () => {
|
||||
// console.log(`b.test: ${JSON.stringify(reupdatedImageInB, null, '\t')}`);
|
||||
|
||||
// `isSensitive` is updated
|
||||
strictEqual(reupdatedImageInB.isSensitive, true);
|
||||
// FIXME: but `name` is not updated
|
||||
strictEqual(reupdatedImageInB.name, '192.jpg');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sensitive flag', () => {
|
||||
describe('isSensitive is federated in delivering to followers', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => {
|
||||
const file = await uploadFile('a.test', alice);
|
||||
await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true });
|
||||
await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id] });
|
||||
await sleep();
|
||||
|
||||
const notes = await bob.client.request('notes/timeline', {});
|
||||
strictEqual(notes.length, 1);
|
||||
const noteInB = notes[0];
|
||||
assert(noteInB.files != null);
|
||||
strictEqual(noteInB.files.length, 1);
|
||||
strictEqual(noteInB.files[0].isSensitive, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSensitive is federated in resolving', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => {
|
||||
const file = await uploadFile('a.test', alice);
|
||||
await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true });
|
||||
const note = (await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id] })).createdNote;
|
||||
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
assert(noteInB.files != null);
|
||||
strictEqual(noteInB.files.length, 1);
|
||||
strictEqual(noteInB.files[0].isSensitive, true);
|
||||
});
|
||||
});
|
||||
|
||||
/** @see https://github.com/misskey-dev/misskey/issues/12208 */
|
||||
describe('isSensitive is federated in replying', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => {
|
||||
const bobNote = (await bob.client.request('notes/create', { text: 'I\'m Bob' })).createdNote;
|
||||
|
||||
const file = await uploadFile('a.test', alice);
|
||||
await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true });
|
||||
const bobNoteInA = await resolveRemoteNote('b.test', bobNote.id, alice);
|
||||
const note = (await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id], replyId: bobNoteInA.id })).createdNote;
|
||||
await sleep();
|
||||
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
assert(noteInB.files != null);
|
||||
strictEqual(noteInB.files.length, 1);
|
||||
strictEqual(noteInB.files[0].isSensitive, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
97
packages/backend/test-federation/test/emoji.test.ts
Normal file
97
packages/backend/test-federation/test/emoji.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import assert, { deepStrictEqual, strictEqual } from 'assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { addCustomEmoji, createAccount, type LoginUser, resolveRemoteUser, sleep } from './utils.js';
|
||||
|
||||
describe('Emoji', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Custom emoji are delivered with Note delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test');
|
||||
await alice.client.request('notes/create', { text: `I love :${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const notes = await bob.client.request('notes/timeline', {});
|
||||
const noteInB = notes[0];
|
||||
|
||||
strictEqual(noteInB.text, `I love \u200b:${emoji.name}:\u200b`);
|
||||
assert(noteInB.emojis != null);
|
||||
assert(emoji.name in noteInB.emojis);
|
||||
strictEqual(noteInB.emojis[emoji.name], emoji.url);
|
||||
});
|
||||
|
||||
test('Custom emoji are delivered with Reaction delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test');
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
await sleep();
|
||||
|
||||
await alice.client.request('notes/reactions/create', { noteId: note.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const noteInB = (await bob.client.request('notes/timeline', {}))[0];
|
||||
deepStrictEqual(noteInB.reactions[`:${emoji.name}@a.test:`], 1);
|
||||
deepStrictEqual(noteInB.reactionEmojis[`${emoji.name}@a.test`], emoji.url);
|
||||
});
|
||||
|
||||
test('Custom emoji are delivered with Profile delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test');
|
||||
const renewedAlice = await alice.client.request('i/update', { name: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(renewedaliceInB.name, renewedAlice.name);
|
||||
assert(emoji.name in renewedaliceInB.emojis);
|
||||
strictEqual(renewedaliceInB.emojis[emoji.name], emoji.url);
|
||||
});
|
||||
|
||||
test('Local-only custom emoji aren\'t delivered with Note delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test', { localOnly: true });
|
||||
await alice.client.request('notes/create', { text: `I love :${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const notes = await bob.client.request('notes/timeline', {});
|
||||
const noteInB = notes[0];
|
||||
|
||||
strictEqual(noteInB.text, `I love \u200b:${emoji.name}:\u200b`);
|
||||
// deepStrictEqual(noteInB.emojis, {}); // TODO: this fails (why?)
|
||||
deepStrictEqual({ ...noteInB.emojis }, {});
|
||||
});
|
||||
|
||||
test('Local-only custom emoji aren\'t delivered with Reaction delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test', { localOnly: true });
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
await sleep();
|
||||
|
||||
await alice.client.request('notes/reactions/create', { noteId: note.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const noteInB = (await bob.client.request('notes/timeline', {}))[0];
|
||||
deepStrictEqual({ ...noteInB.reactions }, { '❤': 1 });
|
||||
deepStrictEqual({ ...noteInB.reactionEmojis }, {});
|
||||
});
|
||||
|
||||
test('Local-only custom emoji aren\'t delivered with Profile delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test', { localOnly: true });
|
||||
const renewedAlice = await alice.client.request('i/update', { name: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(renewedaliceInB.name, renewedAlice.name);
|
||||
deepStrictEqual({ ...renewedaliceInB.emojis }, {});
|
||||
});
|
||||
});
|
52
packages/backend/test-federation/test/move.test.ts
Normal file
52
packages/backend/test-federation/test/move.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import assert, { strictEqual } from 'node:assert';
|
||||
import { createAccount, type LoginUser, sleep } from './utils.js';
|
||||
|
||||
describe('Move', () => {
|
||||
test('Minimum move', async () => {
|
||||
const [alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
await bob.client.request('i/update', { alsoKnownAs: [`@${alice.username}@a.test`] });
|
||||
await alice.client.request('i/move', { moveToAccount: `@${bob.username}@b.test` });
|
||||
});
|
||||
|
||||
/** @see https://github.com/misskey-dev/misskey/issues/11320 */
|
||||
describe('Following relation is transferred after move', () => {
|
||||
let alice: LoginUser, bob: LoginUser, carol: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
carol = await createAccount('a.test');
|
||||
|
||||
// Follow @carol@a.test ==> @alice@a.test
|
||||
await carol.client.request('following/create', { userId: alice.id });
|
||||
|
||||
// Move @alice@a.test ==> @bob@b.test
|
||||
await bob.client.request('i/update', { alsoKnownAs: [`@${alice.username}@a.test`] });
|
||||
await alice.client.request('i/move', { moveToAccount: `@${bob.username}@b.test` });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Check from follower', async () => {
|
||||
const following = await carol.client.request('users/following', { userId: carol.id });
|
||||
strictEqual(following.length, 2);
|
||||
const followees = following.map(({ followee }) => followee);
|
||||
assert(followees.every(followee => followee != null));
|
||||
assert(followees.some(({ id, url }) => id === alice.id && url === null));
|
||||
assert(followees.some(({ url }) => url === `https://b.test/@${bob.username}`));
|
||||
});
|
||||
|
||||
test('Check from followee', async () => {
|
||||
const followers = await bob.client.request('users/followers', { userId: bob.id });
|
||||
strictEqual(followers.length, 1);
|
||||
const follower = followers[0].follower;
|
||||
assert(follower != null);
|
||||
strictEqual(follower.url, `https://a.test/@${carol.username}`);
|
||||
});
|
||||
});
|
||||
});
|
317
packages/backend/test-federation/test/note.test.ts
Normal file
317
packages/backend/test-federation/test/note.test.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import assert, { rejects, strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { addCustomEmoji, createAccount, createModerator, deepStrictEqualWithExcludedFields, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js';
|
||||
|
||||
describe('Note', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('Note content', () => {
|
||||
test('Consistency of Public Note', async () => {
|
||||
const image = await uploadFile('a.test', alice);
|
||||
const note = (await alice.client.request('notes/create', {
|
||||
text: 'I am Alice!',
|
||||
fileIds: [image.id],
|
||||
poll: {
|
||||
choices: ['neko', 'inu'],
|
||||
multiple: false,
|
||||
expiredAfter: 60 * 60 * 1000,
|
||||
},
|
||||
})).createdNote;
|
||||
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
deepStrictEqualWithExcludedFields(note, resolvedNote, [
|
||||
'id',
|
||||
'emojis',
|
||||
/** Consistency of files is checked at {@link file://./drive.test.ts}, so let's skip. */
|
||||
'fileIds',
|
||||
'files',
|
||||
/** @see https://github.com/misskey-dev/misskey/issues/12409 */
|
||||
'reactionAcceptance',
|
||||
'userId',
|
||||
'user',
|
||||
'uri',
|
||||
]);
|
||||
strictEqual(aliceInB.id, resolvedNote.userId);
|
||||
});
|
||||
|
||||
test('Consistency of reply', async () => {
|
||||
const _replyedNote = (await alice.client.request('notes/create', {
|
||||
text: 'a',
|
||||
})).createdNote;
|
||||
const note = (await alice.client.request('notes/create', {
|
||||
text: 'b',
|
||||
replyId: _replyedNote.id,
|
||||
})).createdNote;
|
||||
// NOTE: the repliedCount is incremented, so fetch again
|
||||
const replyedNote = await alice.client.request('notes/show', { noteId: _replyedNote.id });
|
||||
strictEqual(replyedNote.repliesCount, 1);
|
||||
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
deepStrictEqualWithExcludedFields(note, resolvedNote, [
|
||||
'id',
|
||||
'emojis',
|
||||
'reactionAcceptance',
|
||||
'replyId',
|
||||
'reply',
|
||||
'userId',
|
||||
'user',
|
||||
'uri',
|
||||
]);
|
||||
assert(resolvedNote.replyId != null);
|
||||
assert(resolvedNote.reply != null);
|
||||
deepStrictEqualWithExcludedFields(replyedNote, resolvedNote.reply, [
|
||||
'id',
|
||||
// TODO: why clippedCount loses consistency?
|
||||
'clippedCount',
|
||||
'emojis',
|
||||
'userId',
|
||||
'user',
|
||||
'uri',
|
||||
// flaky because this is parallelly incremented, so let's check it below
|
||||
'repliesCount',
|
||||
]);
|
||||
strictEqual(aliceInB.id, resolvedNote.userId);
|
||||
|
||||
await sleep();
|
||||
|
||||
const resolvedReplyedNote = await bob.client.request('notes/show', { noteId: resolvedNote.replyId });
|
||||
strictEqual(resolvedReplyedNote.repliesCount, 1);
|
||||
});
|
||||
|
||||
test('Consistency of Renote', async () => {
|
||||
// NOTE: the renoteCount is not incremented, so no need to fetch again
|
||||
const renotedNote = (await alice.client.request('notes/create', {
|
||||
text: 'a',
|
||||
})).createdNote;
|
||||
const note = (await alice.client.request('notes/create', {
|
||||
text: 'b',
|
||||
renoteId: renotedNote.id,
|
||||
})).createdNote;
|
||||
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
deepStrictEqualWithExcludedFields(note, resolvedNote, [
|
||||
'id',
|
||||
'emojis',
|
||||
'reactionAcceptance',
|
||||
'renoteId',
|
||||
'renote',
|
||||
'userId',
|
||||
'user',
|
||||
'uri',
|
||||
]);
|
||||
assert(resolvedNote.renoteId != null);
|
||||
assert(resolvedNote.renote != null);
|
||||
deepStrictEqualWithExcludedFields(renotedNote, resolvedNote.renote, [
|
||||
'id',
|
||||
'emojis',
|
||||
'userId',
|
||||
'user',
|
||||
'uri',
|
||||
]);
|
||||
strictEqual(aliceInB.id, resolvedNote.userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Other props', () => {
|
||||
test('localOnly', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a', localOnly: true })).createdNote;
|
||||
rejects(
|
||||
async () => await bob.client.request('ap/show', { uri: `https://a.test/notes/${note.id}` }),
|
||||
(err: any) => {
|
||||
/**
|
||||
* FIXME: this error is not handled
|
||||
* @see https://github.com/misskey-dev/misskey/issues/12736
|
||||
*/
|
||||
strictEqual(err.code, 'INTERNAL_ERROR');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deletion', () => {
|
||||
describe('Check Delete consistency', () => {
|
||||
let carol: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
carol = await createAccount('a.test');
|
||||
|
||||
await carol.client.request('following/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Delete is derivered to followers', async () => {
|
||||
const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, carol);
|
||||
await bob.client.request('notes/delete', { noteId: note.id });
|
||||
await sleep();
|
||||
|
||||
await rejects(
|
||||
async () => await carol.client.request('notes/show', { noteId: noteInA.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deletion of remote user\'s note for moderation', () => {
|
||||
let note: Misskey.entities.Note;
|
||||
|
||||
test('Alice post is deleted in B', async () => {
|
||||
note = (await alice.client.request('notes/create', { text: 'Hello' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const bMod = await createModerator('b.test');
|
||||
await bMod.client.request('notes/delete', { noteId: noteInB.id });
|
||||
await rejects(
|
||||
async () => await bob.client.request('notes/show', { noteId: noteInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* FIXME: implement soft deletion as well as user?
|
||||
* @see https://github.com/misskey-dev/misskey/issues/11437
|
||||
*/
|
||||
test.failing('Not found even if resolve again', async () => {
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
await rejects(
|
||||
async () => await bob.client.request('notes/show', { noteId: noteInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reaction', () => {
|
||||
describe('Consistency', () => {
|
||||
test('Unicode reaction', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const reaction = '😅';
|
||||
await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction });
|
||||
await sleep();
|
||||
|
||||
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
|
||||
strictEqual(reactions.length, 1);
|
||||
strictEqual(reactions[0].type, reaction);
|
||||
strictEqual(reactions[0].user.id, bobInA.id);
|
||||
});
|
||||
|
||||
test('Custom emoji reaction', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const emoji = await addCustomEmoji('b.test');
|
||||
await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
|
||||
strictEqual(reactions.length, 1);
|
||||
strictEqual(reactions[0].type, `:${emoji.name}@b.test:`);
|
||||
strictEqual(reactions[0].user.id, bobInA.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Acceptance', () => {
|
||||
test('Even if likeOnly, remote users can react with custom emoji, but it is converted to like', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a', reactionAcceptance: 'likeOnly' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const emoji = await addCustomEmoji('b.test');
|
||||
await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
|
||||
strictEqual(reactions.length, 1);
|
||||
strictEqual(reactions[0].type, '❤');
|
||||
});
|
||||
|
||||
/**
|
||||
* TODO: this may be unexpected behavior?
|
||||
* @see https://github.com/misskey-dev/misskey/issues/12409
|
||||
*/
|
||||
test('Even if nonSensitiveOnly, remote users can react with sensitive emoji, and it is not converted', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a', reactionAcceptance: 'nonSensitiveOnly' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const emoji = await addCustomEmoji('b.test', { isSensitive: true });
|
||||
await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
|
||||
strictEqual(reactions.length, 1);
|
||||
strictEqual(reactions[0].type, `:${emoji.name}@b.test:`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Poll', () => {
|
||||
describe('Any remote user\'s vote is delivered to the author', () => {
|
||||
let carol: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
carol = await createAccount('a.test');
|
||||
});
|
||||
|
||||
test('Bob creates poll and receives a vote from Carol', async () => {
|
||||
const note = (await bob.client.request('notes/create', { poll: { choices: ['inu', 'neko'] } })).createdNote;
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, carol);
|
||||
await carol.client.request('notes/polls/vote', { noteId: noteInA.id, choice: 0 });
|
||||
await sleep();
|
||||
|
||||
const noteAfterVote = await bob.client.request('notes/show', { noteId: note.id });
|
||||
assert(noteAfterVote.poll != null);
|
||||
strictEqual(noteAfterVote.poll.choices[0].votes, 1);
|
||||
strictEqual(noteAfterVote.poll.choices[1].votes, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Local user\'s vote is delivered to the author\'s remote followers', () => {
|
||||
let bobRemoteFollower: LoginUser, localVoter: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
[
|
||||
bobRemoteFollower,
|
||||
localVoter,
|
||||
] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
await bobRemoteFollower.client.request('following/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('A vote in Bob\'s server is delivered to Bob\'s remote followers', async () => {
|
||||
const note = (await bob.client.request('notes/create', { poll: { choices: ['inu', 'neko'] } })).createdNote;
|
||||
// NOTE: resolve before voting
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, bobRemoteFollower);
|
||||
await localVoter.client.request('notes/polls/vote', { noteId: note.id, choice: 0 });
|
||||
await sleep();
|
||||
|
||||
const noteAfterVote = await bobRemoteFollower.client.request('notes/show', { noteId: noteInA.id });
|
||||
assert(noteAfterVote.poll != null);
|
||||
strictEqual(noteAfterVote.poll.choices[0].votes, 1);
|
||||
strictEqual(noteAfterVote.poll.choices[1].votes, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
107
packages/backend/test-federation/test/notification.test.ts
Normal file
107
packages/backend/test-federation/test/notification.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
|
||||
|
||||
describe('Notification', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('Follow', () => {
|
||||
test('Get notification when follow', async () => {
|
||||
await assertNotificationReceived(
|
||||
'b.test', bob,
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
notification => notification.type === 'followRequestAccepted' && notification.userId === aliceInB.id,
|
||||
true,
|
||||
);
|
||||
|
||||
await bob.client.request('following/delete', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Get notification when get followed', async () => {
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
notification => notification.type === 'follow' && notification.userId === bobInA.id,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => await bob.client.request('following/delete', { userId: aliceInB.id }));
|
||||
});
|
||||
|
||||
describe('Note', () => {
|
||||
test('Get notification when get a reaction', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const reaction = '😅';
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction }),
|
||||
notification =>
|
||||
notification.type === 'reaction' && notification.note.id === note.id && notification.userId === bobInA.id && notification.reaction === reaction,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Get notification when replied', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const text = crypto.randomUUID();
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('notes/create', { text, replyId: noteInB.id }),
|
||||
notification =>
|
||||
notification.type === 'reply' && notification.note.reply!.id === note.id && notification.userId === bobInA.id && notification.note.text === text,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Get notification when renoted', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('notes/create', { renoteId: noteInB.id }),
|
||||
notification =>
|
||||
notification.type === 'renote' && notification.note.renote!.id === note.id && notification.userId === bobInA.id,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Get notification when quoted', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const text = crypto.randomUUID();
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('notes/create', { text, renoteId: noteInB.id }),
|
||||
notification =>
|
||||
notification.type === 'quote' && notification.note.renote!.id === note.id && notification.userId === bobInA.id && notification.note.text === text,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Get notification when mentioned', async () => {
|
||||
const text = `@${alice.username}@a.test`;
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('notes/create', { text }),
|
||||
notification => notification.type === 'mention' && notification.userId === bobInA.id && notification.note.text === text,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
328
packages/backend/test-federation/test/timeline.test.ts
Normal file
328
packages/backend/test-federation/test/timeline.test.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { strictEqual } from 'assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { createAccount, fetchAdmin, isNoteUpdatedEventFired, isFired, type LoginUser, type Request, resolveRemoteUser, sleep, createRole } from './utils.js';
|
||||
|
||||
const bAdmin = await fetchAdmin('b.test');
|
||||
|
||||
describe('Timeline', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
type TimelineChannel = keyof Misskey.Channels & (`${string}Timeline` | 'antenna' | 'userList' | 'hashtag');
|
||||
type TimelineEndpoint = keyof Misskey.Endpoints & (`${string}timeline` | 'antennas/notes' | 'roles/notes' | 'notes/search-by-tag');
|
||||
const timelineMap = new Map<TimelineChannel, TimelineEndpoint>([
|
||||
['antenna', 'antennas/notes'],
|
||||
['globalTimeline', 'notes/global-timeline'],
|
||||
['homeTimeline', 'notes/timeline'],
|
||||
['hybridTimeline', 'notes/hybrid-timeline'],
|
||||
['localTimeline', 'notes/local-timeline'],
|
||||
['roleTimeline', 'roles/notes'],
|
||||
['hashtag', 'notes/search-by-tag'],
|
||||
['userList', 'notes/user-list-timeline'],
|
||||
]);
|
||||
|
||||
async function postAndCheckReception<C extends TimelineChannel>(
|
||||
timelineChannel: C,
|
||||
expect: boolean,
|
||||
noteParams: Misskey.entities.NotesCreateRequest = {},
|
||||
channelParams: Misskey.Channels[C]['params'] = {},
|
||||
) {
|
||||
let note: Misskey.entities.Note | undefined;
|
||||
const text = noteParams.text ?? crypto.randomUUID();
|
||||
const streamingFired = await isFired(
|
||||
'b.test', bob, timelineChannel,
|
||||
async () => {
|
||||
note = (await alice.client.request('notes/create', { text, ...noteParams })).createdNote;
|
||||
},
|
||||
'note', msg => msg.text === text,
|
||||
channelParams,
|
||||
);
|
||||
strictEqual(streamingFired, expect);
|
||||
|
||||
const endpoint = timelineMap.get(timelineChannel)!;
|
||||
const params: Misskey.Endpoints[typeof endpoint]['req'] =
|
||||
endpoint === 'antennas/notes' ? { antennaId: (channelParams as Misskey.Channels['antenna']['params']).antennaId } :
|
||||
endpoint === 'notes/user-list-timeline' ? { listId: (channelParams as Misskey.Channels['userList']['params']).listId } :
|
||||
endpoint === 'notes/search-by-tag' ? { query: (channelParams as Misskey.Channels['hashtag']['params']).q } :
|
||||
endpoint === 'roles/notes' ? { roleId: (channelParams as Misskey.Channels['roleTimeline']['params']).roleId } :
|
||||
{};
|
||||
|
||||
await sleep();
|
||||
const notes = await (bob.client.request as Request)(endpoint, params);
|
||||
const noteInB = notes.filter(({ uri }) => uri === `https://a.test/notes/${note!.id}`).pop();
|
||||
const endpointFired = noteInB != null;
|
||||
strictEqual(endpointFired, expect);
|
||||
|
||||
// Let's check Delete reception
|
||||
if (expect) {
|
||||
const streamingFired = await isNoteUpdatedEventFired(
|
||||
'b.test', bob, noteInB!.id,
|
||||
async () => await alice.client.request('notes/delete', { noteId: note!.id }),
|
||||
msg => msg.type === 'deleted' && msg.id === noteInB!.id,
|
||||
);
|
||||
strictEqual(streamingFired, true);
|
||||
|
||||
await sleep();
|
||||
const notes = await (bob.client.request as Request)(endpoint, params);
|
||||
const endpointFired = notes.every(({ uri }) => uri !== `https://a.test/notes/${note!.id}`);
|
||||
strictEqual(endpointFired, true);
|
||||
}
|
||||
}
|
||||
|
||||
describe('homeTimeline', () => {
|
||||
// NOTE: narrowing scope intentionally to prevent mistakes by copy-and-paste
|
||||
const homeTimeline = 'homeTimeline';
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, true);
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s home-only Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, true, { visibility: 'home' });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s followers-only Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, true, { visibility: 'followers' });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s visible specified-only Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, true, { visibility: 'specified', visibleUserIds: [bobInA.id] });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s localOnly Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, false, { localOnly: true });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s invisible specified-only Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, false, { visibility: 'specified' });
|
||||
});
|
||||
|
||||
/**
|
||||
* FIXME: can receive this
|
||||
* @see https://github.com/misskey-dev/misskey/issues/14083
|
||||
*/
|
||||
test.failing('Don\'t receive remote followee\'s invisible and mentioned specified-only Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, false, { text: `@${bob.username}@b.test Hello`, visibility: 'specified' });
|
||||
});
|
||||
|
||||
/**
|
||||
* FIXME: cannot receive this
|
||||
* @see https://github.com/misskey-dev/misskey/issues/14084
|
||||
*/
|
||||
test.failing('Receive remote followee\'s visible specified-only reply to invisible specified-only Note', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a', visibility: 'specified' })).createdNote;
|
||||
await postAndCheckReception(homeTimeline, true, { replyId: note.id, visibility: 'specified', visibleUserIds: [bobInA.id] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('localTimeline', () => {
|
||||
const localTimeline = 'localTimeline';
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Don\'t receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(localTimeline, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hybridTimeline', () => {
|
||||
const hybridTimeline = 'hybridTimeline';
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(hybridTimeline, true);
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s home-only Note', async () => {
|
||||
await postAndCheckReception(hybridTimeline, true, { visibility: 'home' });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s followers-only Note', async () => {
|
||||
await postAndCheckReception(hybridTimeline, true, { visibility: 'followers' });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s visible specified-only Note', async () => {
|
||||
await postAndCheckReception(hybridTimeline, true, { visibility: 'specified', visibleUserIds: [bobInA.id] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('globalTimeline', () => {
|
||||
const globalTimeline = 'globalTimeline';
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(globalTimeline, true);
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s home-only Note', async () => {
|
||||
await postAndCheckReception(globalTimeline, false, { visibility: 'home' });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s followers-only Note', async () => {
|
||||
await postAndCheckReception(globalTimeline, false, { visibility: 'followers' });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s visible specified-only Note', async () => {
|
||||
await postAndCheckReception(globalTimeline, false, { visibility: 'specified', visibleUserIds: [bobInA.id] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('userList', () => {
|
||||
const userList = 'userList';
|
||||
|
||||
let list: Misskey.entities.UserList;
|
||||
|
||||
beforeAll(async () => {
|
||||
list = await bob.client.request('users/lists/create', { name: 'Bob\'s List' });
|
||||
await bob.client.request('users/lists/push', { listId: list.id, userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(userList, true, {}, { listId: list.id });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s home-only Note', async () => {
|
||||
await postAndCheckReception(userList, true, { visibility: 'home' }, { listId: list.id });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s followers-only Note', async () => {
|
||||
await postAndCheckReception(userList, true, { visibility: 'followers' }, { listId: list.id });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s visible specified-only Note', async () => {
|
||||
await postAndCheckReception(userList, true, { visibility: 'specified', visibleUserIds: [bobInA.id] }, { listId: list.id });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashtag', () => {
|
||||
const hashtag = 'hashtag';
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
const tag = crypto.randomUUID();
|
||||
await postAndCheckReception(hashtag, true, { text: `#${tag}` }, { q: [[tag]] });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s home-only Note', async () => {
|
||||
const tag = crypto.randomUUID();
|
||||
await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'home' }, { q: [[tag]] });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s followers-only Note', async () => {
|
||||
const tag = crypto.randomUUID();
|
||||
await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'followers' }, { q: [[tag]] });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s visible specified-only Note', async () => {
|
||||
const tag = crypto.randomUUID();
|
||||
await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'specified', visibleUserIds: [bobInA.id] }, { q: [[tag]] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('roleTimeline', () => {
|
||||
const roleTimeline = 'roleTimeline';
|
||||
|
||||
let role: Misskey.entities.Role;
|
||||
|
||||
beforeAll(async () => {
|
||||
role = await createRole('b.test', {
|
||||
name: 'Remote Users',
|
||||
description: 'Remote users are assigned to this role.',
|
||||
condFormula: {
|
||||
/** TODO: @see https://github.com/misskey-dev/misskey/issues/14169 */
|
||||
type: 'isRemote' as never,
|
||||
},
|
||||
});
|
||||
await sleep();
|
||||
});
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(roleTimeline, true, {}, { roleId: role.id });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s home-only Note', async () => {
|
||||
await postAndCheckReception(roleTimeline, false, { visibility: 'home' }, { roleId: role.id });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s followers-only Note', async () => {
|
||||
await postAndCheckReception(roleTimeline, false, { visibility: 'followers' }, { roleId: role.id });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s visible specified-only Note', async () => {
|
||||
await postAndCheckReception(roleTimeline, false, { visibility: 'specified', visibleUserIds: [bobInA.id] }, { roleId: role.id });
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await bAdmin.client.request('admin/roles/delete', { roleId: role.id });
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Cannot test
|
||||
describe.skip('antenna', () => {
|
||||
const antenna = 'antenna';
|
||||
|
||||
let bobAntenna: Misskey.entities.Antenna;
|
||||
|
||||
beforeAll(async () => {
|
||||
bobAntenna = await bob.client.request('antennas/create', {
|
||||
name: 'Bob\'s Egosurfing Antenna',
|
||||
src: 'all',
|
||||
keywords: [['Bob']],
|
||||
excludeKeywords: [],
|
||||
users: [],
|
||||
caseSensitive: false,
|
||||
localOnly: false,
|
||||
withReplies: true,
|
||||
withFile: true,
|
||||
});
|
||||
await sleep();
|
||||
});
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(antenna, true, { text: 'I love Bob (1)' }, { antennaId: bobAntenna.id });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s home-only Note', async () => {
|
||||
await postAndCheckReception(antenna, false, { text: 'I love Bob (2)', visibility: 'home' }, { antennaId: bobAntenna.id });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s followers-only Note', async () => {
|
||||
await postAndCheckReception(antenna, false, { text: 'I love Bob (3)', visibility: 'followers' }, { antennaId: bobAntenna.id });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s visible specified-only Note', async () => {
|
||||
await postAndCheckReception(antenna, false, { text: 'I love Bob (4)', visibility: 'specified', visibleUserIds: [bobInA.id] }, { antennaId: bobAntenna.id });
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await bob.client.request('antennas/delete', { antennaId: bobAntenna.id });
|
||||
});
|
||||
});
|
||||
});
|
560
packages/backend/test-federation/test/user.test.ts
Normal file
560
packages/backend/test-federation/test/user.test.ts
Normal file
@@ -0,0 +1,560 @@
|
||||
import assert, { rejects, strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
|
||||
|
||||
const [aAdmin, bAdmin] = await Promise.all([
|
||||
fetchAdmin('a.test'),
|
||||
fetchAdmin('b.test'),
|
||||
]);
|
||||
|
||||
describe('User', () => {
|
||||
describe('Profile', () => {
|
||||
describe('Consistency of profile', () => {
|
||||
let alice: LoginUser;
|
||||
let aliceWatcher: LoginUser;
|
||||
let aliceWatcherInB: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
alice = await createAccount('a.test');
|
||||
[
|
||||
aliceWatcher,
|
||||
aliceWatcherInB,
|
||||
] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Check consistency', async () => {
|
||||
const aliceInA = await aliceWatcher.client.request('users/show', { userId: alice.id });
|
||||
const resolved = await resolveRemoteUser('a.test', aliceInA.id, aliceWatcherInB);
|
||||
const aliceInB = await aliceWatcherInB.client.request('users/show', { userId: resolved.id });
|
||||
|
||||
// console.log(`a.test: ${JSON.stringify(aliceInA, null, '\t')}`);
|
||||
// console.log(`b.test: ${JSON.stringify(aliceInB, null, '\t')}`);
|
||||
|
||||
deepStrictEqualWithExcludedFields(aliceInA, aliceInB, [
|
||||
'id',
|
||||
'host',
|
||||
'avatarUrl',
|
||||
'instance',
|
||||
'badgeRoles',
|
||||
'url',
|
||||
'uri',
|
||||
'createdAt',
|
||||
'lastFetchedAt',
|
||||
'publicReactions',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ffVisibility is federated', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
|
||||
// NOTE: follow each other
|
||||
await Promise.all([
|
||||
alice.client.request('following/create', { userId: bobInA.id }),
|
||||
bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
]);
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Visibility set public by default', async () => {
|
||||
for (const user of await Promise.all([
|
||||
alice.client.request('users/show', { userId: bobInA.id }),
|
||||
bob.client.request('users/show', { userId: aliceInB.id }),
|
||||
])) {
|
||||
strictEqual(user.followersVisibility, 'public');
|
||||
strictEqual(user.followingVisibility, 'public');
|
||||
}
|
||||
});
|
||||
|
||||
/** FIXME: not working */
|
||||
test.skip('Setting private for followersVisibility is federated', async () => {
|
||||
await Promise.all([
|
||||
alice.client.request('i/update', { followersVisibility: 'private' }),
|
||||
bob.client.request('i/update', { followersVisibility: 'private' }),
|
||||
]);
|
||||
await sleep();
|
||||
|
||||
for (const user of await Promise.all([
|
||||
alice.client.request('users/show', { userId: bobInA.id }),
|
||||
bob.client.request('users/show', { userId: aliceInB.id }),
|
||||
])) {
|
||||
strictEqual(user.followersVisibility, 'private');
|
||||
strictEqual(user.followingVisibility, 'public');
|
||||
}
|
||||
});
|
||||
|
||||
test.skip('Setting private for followingVisibility is federated', async () => {
|
||||
await Promise.all([
|
||||
alice.client.request('i/update', { followingVisibility: 'private' }),
|
||||
bob.client.request('i/update', { followingVisibility: 'private' }),
|
||||
]);
|
||||
await sleep();
|
||||
|
||||
for (const user of await Promise.all([
|
||||
alice.client.request('users/show', { userId: bobInA.id }),
|
||||
bob.client.request('users/show', { userId: aliceInB.id }),
|
||||
])) {
|
||||
strictEqual(user.followersVisibility, 'private');
|
||||
strictEqual(user.followingVisibility, 'private');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCat is federated', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Not isCat for default', () => {
|
||||
strictEqual(aliceInB.isCat, false);
|
||||
});
|
||||
|
||||
test('Becoming a cat is sent to their followers', async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
await alice.client.request('i/update', { isCat: true });
|
||||
await sleep();
|
||||
|
||||
const res = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(res.isCat, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pinning Notes', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
aliceInB = await resolveRemoteUser('a.test', alice.id, bob);
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
});
|
||||
|
||||
test('Pinning localOnly Note is not delivered', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a', localOnly: true })).createdNote;
|
||||
await alice.client.request('i/pin', { noteId: note.id });
|
||||
await sleep();
|
||||
|
||||
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(_aliceInB.pinnedNoteIds.length, 0);
|
||||
});
|
||||
|
||||
test('Pinning followers-only Note is not delivered', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a', visibility: 'followers' })).createdNote;
|
||||
await alice.client.request('i/pin', { noteId: note.id });
|
||||
await sleep();
|
||||
|
||||
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(_aliceInB.pinnedNoteIds.length, 0);
|
||||
});
|
||||
|
||||
let pinnedNote: Misskey.entities.Note;
|
||||
|
||||
test('Pinning normal Note is delivered', async () => {
|
||||
pinnedNote = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
await alice.client.request('i/pin', { noteId: pinnedNote.id });
|
||||
await sleep();
|
||||
|
||||
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(_aliceInB.pinnedNoteIds.length, 1);
|
||||
const pinnedNoteInB = await resolveRemoteNote('a.test', pinnedNote.id, bob);
|
||||
strictEqual(_aliceInB.pinnedNotes[0].id, pinnedNoteInB.id);
|
||||
});
|
||||
|
||||
test('Unpinning normal Note is delivered', async () => {
|
||||
await alice.client.request('i/unpin', { noteId: pinnedNote.id });
|
||||
await sleep();
|
||||
|
||||
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(_aliceInB.pinnedNoteIds.length, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Follow / Unfollow', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('Follow a.test ==> b.test', () => {
|
||||
beforeAll(async () => {
|
||||
await alice.client.request('following/create', { userId: bobInA.id });
|
||||
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Check consistency with `users/following` and `users/followers` endpoints', async () => {
|
||||
await Promise.all([
|
||||
strictEqual(
|
||||
(await alice.client.request('users/following', { userId: alice.id }))
|
||||
.some(v => v.followeeId === bobInA.id),
|
||||
true,
|
||||
),
|
||||
strictEqual(
|
||||
(await bob.client.request('users/followers', { userId: bob.id }))
|
||||
.some(v => v.followerId === aliceInB.id),
|
||||
true,
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unfollow a.test ==> b.test', () => {
|
||||
beforeAll(async () => {
|
||||
await alice.client.request('following/delete', { userId: bobInA.id });
|
||||
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Check consistency with `users/following` and `users/followers` endpoints', async () => {
|
||||
await Promise.all([
|
||||
strictEqual(
|
||||
(await alice.client.request('users/following', { userId: alice.id }))
|
||||
.some(v => v.followeeId === bobInA.id),
|
||||
false,
|
||||
),
|
||||
strictEqual(
|
||||
(await bob.client.request('users/followers', { userId: bob.id }))
|
||||
.some(v => v.followerId === aliceInB.id),
|
||||
false,
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Follow requests', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
|
||||
await alice.client.request('i/update', { isLocked: true });
|
||||
});
|
||||
|
||||
describe('Send follow request from Bob to Alice and cancel', () => {
|
||||
describe('Bob sends follow request to Alice', () => {
|
||||
beforeAll(async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Alice should have a request', async () => {
|
||||
const requests = await alice.client.request('following/requests/list', {});
|
||||
strictEqual(requests.length, 1);
|
||||
strictEqual(requests[0].followee.id, alice.id);
|
||||
strictEqual(requests[0].follower.id, bobInA.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alice cancels it', () => {
|
||||
beforeAll(async () => {
|
||||
await bob.client.request('following/requests/cancel', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Alice should have no requests', async () => {
|
||||
const requests = await alice.client.request('following/requests/list', {});
|
||||
strictEqual(requests.length, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Send follow request from Bob to Alice and reject', () => {
|
||||
beforeAll(async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
await alice.client.request('following/requests/reject', { userId: bobInA.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Bob should have no requests', async () => {
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/requests/cancel', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'FOLLOW_REQUEST_NOT_FOUND');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('Bob doesn\'t follow Alice', async () => {
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Send follow request from Bob to Alice and accept', () => {
|
||||
beforeAll(async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
await alice.client.request('following/requests/accept', { userId: bobInA.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Bob follows Alice', async () => {
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 1);
|
||||
strictEqual(following[0].followeeId, aliceInB.id);
|
||||
strictEqual(following[0].followerId, bob.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deletion', () => {
|
||||
describe('Check Delete consistency', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Bob follows Alice, and Alice deleted themself', async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1); // followed by Bob
|
||||
|
||||
await alice.client.request('i/delete-account', { password: alice.password });
|
||||
await sleep();
|
||||
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0); // no following relation
|
||||
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_USER');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deletion of remote user for moderation', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Bob follows Alice, then Alice gets deleted in B server', async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1); // followed by Bob
|
||||
|
||||
await bAdmin.client.request('admin/delete-account', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
/**
|
||||
* FIXME: remote account is not deleted!
|
||||
* @see https://github.com/misskey-dev/misskey/issues/14728
|
||||
*/
|
||||
const deletedAlice = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
assert(deletedAlice.id, aliceInB.id);
|
||||
|
||||
// TODO: why still following relation?
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 1);
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'ALREADY_FOLLOWING');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('Alice tries to follow Bob, but it is not processed', async () => {
|
||||
await alice.client.request('following/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const following = await alice.client.request('users/following', { userId: alice.id });
|
||||
strictEqual(following.length, 0); // Not following Bob because B server doesn't return Accept
|
||||
|
||||
const followers = await bob.client.request('users/followers', { userId: bob.id });
|
||||
strictEqual(followers.length, 0); // Alice's Follow is not processed
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Suspension', () => {
|
||||
describe('Check suspend/unsuspend consistency', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Bob follows Alice, and Alice gets suspended, there is no following relation, and Bob fails to follow again', async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1); // followed by Bob
|
||||
|
||||
await aAdmin.client.request('admin/suspend-user', { userId: alice.id });
|
||||
await sleep();
|
||||
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0); // no following relation
|
||||
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_USER');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('Alice gets unsuspended, Bob succeeds in following Alice', async () => {
|
||||
await aAdmin.client.request('admin/unsuspend-user', { userId: alice.id });
|
||||
await sleep();
|
||||
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1); // FIXME: followers are not deleted??
|
||||
|
||||
/**
|
||||
* FIXME: still rejected!
|
||||
* seems to can't process Undo Delete activity because it is not implemented
|
||||
* related @see https://github.com/misskey-dev/misskey/issues/13273
|
||||
*/
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_USER');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
// FIXME: resolving also fails
|
||||
await rejects(
|
||||
async () => await resolveRemoteUser('a.test', alice.id, bob),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'INTERNAL_ERROR');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* instead of simple unsuspension, let's tell existence by following from Alice
|
||||
*/
|
||||
test('Alice can follow Bob', async () => {
|
||||
await alice.client.request('following/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const bobFollowers = await bob.client.request('users/followers', { userId: bob.id });
|
||||
strictEqual(bobFollowers.length, 1); // followed by Alice
|
||||
assert(bobFollowers[0].follower != null);
|
||||
const renewedaliceInB = bobFollowers[0].follower;
|
||||
assert(aliceInB.username === renewedaliceInB.username);
|
||||
assert(aliceInB.host === renewedaliceInB.host);
|
||||
assert(aliceInB.id !== renewedaliceInB.id); // TODO: Same username and host, but their ids are different! Is it OK?
|
||||
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0); // following are deleted
|
||||
|
||||
// Bob tries to follow Alice
|
||||
await bob.client.request('following/create', { userId: renewedaliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const aliceFollowers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(aliceFollowers.length, 1);
|
||||
|
||||
// FIXME: but resolving still fails ...
|
||||
await rejects(
|
||||
async () => await resolveRemoteUser('a.test', alice.id, bob),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'INTERNAL_ERROR');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
309
packages/backend/test-federation/test/utils.ts
Normal file
309
packages/backend/test-federation/test/utils.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { deepStrictEqual, strictEqual } from 'assert';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
export const ADMIN_PARAMS = { username: 'admin', password: 'admin' };
|
||||
const ADMIN_CACHE = new Map<Host, SigninResponse>();
|
||||
|
||||
await Promise.all([
|
||||
fetchAdmin('a.test'),
|
||||
fetchAdmin('b.test'),
|
||||
]);
|
||||
|
||||
type SigninResponse = Omit<Misskey.entities.SigninFlowResponse & { finished: true }, 'finished'>;
|
||||
|
||||
export type LoginUser = SigninResponse & {
|
||||
client: Misskey.api.APIClient;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** used for avoiding overload and some endpoints */
|
||||
export type Request = <
|
||||
E extends keyof Misskey.Endpoints,
|
||||
P extends Misskey.Endpoints[E]['req'],
|
||||
>(
|
||||
endpoint: E,
|
||||
params: P,
|
||||
credential?: string | null,
|
||||
) => Promise<Misskey.api.SwitchCaseResponseType<E, P>>;
|
||||
|
||||
type Host = 'a.test' | 'b.test';
|
||||
|
||||
export async function sleep(ms = 200): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function signin(
|
||||
host: Host,
|
||||
params: Misskey.entities.SigninFlowRequest,
|
||||
): Promise<SigninResponse> {
|
||||
// wait for a second to prevent hit rate limit
|
||||
await sleep(1000);
|
||||
|
||||
return await (new Misskey.api.APIClient({ origin: `https://${host}` }).request as Request)('signin-flow', params)
|
||||
.then(res => {
|
||||
strictEqual(res.finished, true);
|
||||
if (params.username === ADMIN_PARAMS.username) ADMIN_CACHE.set(host, res);
|
||||
return res;
|
||||
})
|
||||
.then(({ id, i }) => ({ id, i }))
|
||||
.catch(async err => {
|
||||
if (err.code === 'TOO_MANY_AUTHENTICATION_FAILURES') {
|
||||
await sleep(Math.random() * 2000);
|
||||
return await signin(host, params);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
async function createAdmin(host: Host): Promise<Misskey.entities.SignupResponse | undefined> {
|
||||
const client = new Misskey.api.APIClient({ origin: `https://${host}` });
|
||||
return await client.request('admin/accounts/create', ADMIN_PARAMS).then(res => {
|
||||
ADMIN_CACHE.set(host, {
|
||||
id: res.id,
|
||||
// @ts-expect-error FIXME: openapi-typescript generates incorrect response type for this endpoint, so ignore this
|
||||
i: res.token,
|
||||
});
|
||||
return res as Misskey.entities.SignupResponse;
|
||||
}).then(async res => {
|
||||
await client.request('admin/roles/update-default-policies', {
|
||||
policies: {
|
||||
/** TODO: @see https://github.com/misskey-dev/misskey/issues/14169 */
|
||||
rateLimitFactor: 0 as never,
|
||||
},
|
||||
}, res.token);
|
||||
return res;
|
||||
}).catch(err => {
|
||||
if (err.info.e.message === 'access denied') return undefined;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAdmin(host: Host): Promise<LoginUser> {
|
||||
const admin = ADMIN_CACHE.get(host) ?? await signin(host, ADMIN_PARAMS)
|
||||
.catch(async err => {
|
||||
if (err.id === '6cc579cc-885d-43d8-95c2-b8c7fc963280') {
|
||||
await createAdmin(host);
|
||||
return await signin(host, ADMIN_PARAMS);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
return {
|
||||
...admin,
|
||||
client: new Misskey.api.APIClient({ origin: `https://${host}`, credential: admin.i }),
|
||||
...ADMIN_PARAMS,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAccount(host: Host): Promise<LoginUser> {
|
||||
const username = crypto.randomUUID().replaceAll('-', '').substring(0, 20);
|
||||
const password = crypto.randomUUID().replaceAll('-', '');
|
||||
const admin = await fetchAdmin(host);
|
||||
await admin.client.request('admin/accounts/create', { username, password });
|
||||
const signinRes = await signin(host, { username, password });
|
||||
|
||||
return {
|
||||
...signinRes,
|
||||
client: new Misskey.api.APIClient({ origin: `https://${host}`, credential: signinRes.i }),
|
||||
username,
|
||||
password,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createModerator(host: Host): Promise<LoginUser> {
|
||||
const user = await createAccount(host);
|
||||
const role = await createRole(host, {
|
||||
name: 'Moderator',
|
||||
isModerator: true,
|
||||
});
|
||||
const admin = await fetchAdmin(host);
|
||||
await admin.client.request('admin/roles/assign', { roleId: role.id, userId: user.id });
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function createRole(
|
||||
host: Host,
|
||||
params: Partial<Misskey.entities.AdminRolesCreateRequest> = {},
|
||||
): Promise<Misskey.entities.Role> {
|
||||
const admin = await fetchAdmin(host);
|
||||
return await admin.client.request('admin/roles/create', {
|
||||
name: 'Some role',
|
||||
description: 'Role for testing',
|
||||
color: null,
|
||||
iconUrl: null,
|
||||
target: 'conditional',
|
||||
condFormula: {},
|
||||
isPublic: true,
|
||||
isModerator: false,
|
||||
isAdministrator: false,
|
||||
isExplorable: true,
|
||||
asBadge: false,
|
||||
canEditMembersByModerator: false,
|
||||
displayOrder: 0,
|
||||
policies: {},
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveRemoteUser(
|
||||
host: Host,
|
||||
id: string,
|
||||
from: LoginUser,
|
||||
): Promise<Misskey.entities.UserDetailedNotMe> {
|
||||
const uri = `https://${host}/users/${id}`;
|
||||
return await from.client.request('ap/show', { uri })
|
||||
.then(res => {
|
||||
strictEqual(res.type, 'User');
|
||||
strictEqual(res.object.uri, uri);
|
||||
return res.object;
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveRemoteNote(
|
||||
host: Host,
|
||||
id: string,
|
||||
from: LoginUser,
|
||||
): Promise<Misskey.entities.Note> {
|
||||
const uri = `https://${host}/notes/${id}`;
|
||||
return await from.client.request('ap/show', { uri })
|
||||
.then(res => {
|
||||
strictEqual(res.type, 'Note');
|
||||
strictEqual(res.object.uri, uri);
|
||||
return res.object;
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
host: Host,
|
||||
user: { i: string },
|
||||
path = '../../test/resources/192.jpg',
|
||||
): Promise<Misskey.entities.DriveFile> {
|
||||
const filename = path.split('/').pop() ?? 'untitled';
|
||||
const blob = new Blob([await readFile(join(__dirname, path))]);
|
||||
|
||||
const body = new FormData();
|
||||
body.append('i', user.i);
|
||||
body.append('force', 'true');
|
||||
body.append('file', blob);
|
||||
body.append('name', filename);
|
||||
|
||||
return await fetch(`https://${host}/api/drive/files/create`, { method: 'POST', body })
|
||||
.then(async res => await res.json());
|
||||
}
|
||||
|
||||
export async function addCustomEmoji(
|
||||
host: Host,
|
||||
param?: Partial<Misskey.entities.AdminEmojiAddRequest>,
|
||||
path?: string,
|
||||
): Promise<Misskey.entities.EmojiDetailed> {
|
||||
const admin = await fetchAdmin(host);
|
||||
const name = crypto.randomUUID().replaceAll('-', '');
|
||||
const file = await uploadFile(host, admin, path);
|
||||
return await admin.client.request('admin/emoji/add', { name, fileId: file.id, ...param });
|
||||
}
|
||||
|
||||
export function deepStrictEqualWithExcludedFields<T>(actual: T, expected: T, excludedFields: (keyof T)[]) {
|
||||
const _actual = structuredClone(actual);
|
||||
const _expected = structuredClone(expected);
|
||||
for (const obj of [_actual, _expected]) {
|
||||
for (const field of excludedFields) {
|
||||
delete obj[field];
|
||||
}
|
||||
}
|
||||
deepStrictEqual(_actual, _expected);
|
||||
}
|
||||
|
||||
export async function isFired<C extends keyof Misskey.Channels, T extends keyof Misskey.Channels[C]['events']>(
|
||||
host: Host,
|
||||
user: { i: string },
|
||||
channel: C,
|
||||
trigger: () => Promise<unknown>,
|
||||
type: T,
|
||||
// @ts-expect-error TODO: why getting error here?
|
||||
cond: (msg: Parameters<Misskey.Channels[C]['events'][T]>[0]) => boolean,
|
||||
params?: Misskey.Channels[C]['params'],
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>(async (resolve, reject) => {
|
||||
// @ts-expect-error TODO: why?
|
||||
const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket });
|
||||
const connection = stream.useChannel(channel, params);
|
||||
connection.on(type as any, ((msg: any) => {
|
||||
if (cond(msg)) {
|
||||
stream.close();
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
}
|
||||
}) as any);
|
||||
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
await trigger().then(() => {
|
||||
timer = setTimeout(() => {
|
||||
stream.close();
|
||||
resolve(false);
|
||||
}, 500);
|
||||
}).catch(err => {
|
||||
stream.close();
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export async function isNoteUpdatedEventFired(
|
||||
host: Host,
|
||||
user: { i: string },
|
||||
noteId: string,
|
||||
trigger: () => Promise<unknown>,
|
||||
cond: (msg: Parameters<Misskey.StreamEvents['noteUpdated']>[0]) => boolean,
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>(async (resolve, reject) => {
|
||||
// @ts-expect-error TODO: why?
|
||||
const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket });
|
||||
stream.send('s', { id: noteId });
|
||||
stream.on('noteUpdated', msg => {
|
||||
if (cond(msg)) {
|
||||
stream.close();
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
await trigger().then(() => {
|
||||
timer = setTimeout(() => {
|
||||
stream.close();
|
||||
resolve(false);
|
||||
}, 500);
|
||||
}).catch(err => {
|
||||
stream.close();
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export async function assertNotificationReceived(
|
||||
receiverHost: Host,
|
||||
receiver: LoginUser,
|
||||
trigger: () => Promise<unknown>,
|
||||
cond: (notification: Misskey.entities.Notification) => boolean,
|
||||
expect: boolean,
|
||||
) {
|
||||
const streamingFired = await isFired(receiverHost, receiver, 'main', trigger, 'notification', cond);
|
||||
strictEqual(streamingFired, expect);
|
||||
|
||||
const endpointFired = await receiver.client.request('i/notifications', {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
.then(([notification]) => notification != null ? cond(notification) : false);
|
||||
strictEqual(endpointFired, expect);
|
||||
}
|
114
packages/backend/test-federation/tsconfig.json
Normal file
114
packages/backend/test-federation/tsconfig.json
Normal file
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "NodeNext", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
"outDir": "./built", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
},
|
||||
"include": [
|
||||
"daemon.ts",
|
||||
"./test/**/*.ts"
|
||||
]
|
||||
}
|
@@ -5,12 +5,12 @@
|
||||
|
||||
import { defineAsyncComponent, reactive, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { apiUrl } from '@@/js/config.js';
|
||||
import type { MenuItem, MenuButton } from '@/types/menu.js';
|
||||
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import type { MenuItem, MenuButton } from '@/types/menu.js';
|
||||
import { del, get, set } from '@/scripts/idb-proxy.js';
|
||||
import { apiUrl } from '@@/js/config.js';
|
||||
import { waiting, popup, popupMenu, success, alert } from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { unisonReload, reloadChannel } from '@/scripts/unison-reload.js';
|
||||
@@ -165,7 +165,18 @@ function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Pr
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAccount(accountData: Partial<Account>) {
|
||||
export function updateAccount(accountData: Account) {
|
||||
if (!$i) return;
|
||||
for (const key of Object.keys($i)) {
|
||||
delete $i[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(accountData)) {
|
||||
$i[key] = value;
|
||||
}
|
||||
miLocalStorage.setItem('account', JSON.stringify($i));
|
||||
}
|
||||
|
||||
export function updateAccountPartial(accountData: Partial<Account>) {
|
||||
if (!$i) return;
|
||||
for (const [key, value] of Object.entries(accountData)) {
|
||||
$i[key] = value;
|
||||
|
@@ -4,14 +4,14 @@
|
||||
*/
|
||||
|
||||
import { createApp, defineAsyncComponent, markRaw } from 'vue';
|
||||
import { ui } from '@@/js/config.js';
|
||||
import { common } from './common.js';
|
||||
import type * as Misskey from 'misskey-js';
|
||||
import { ui } from '@@/js/config.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { alert, confirm, popup, post, toast } from '@/os.js';
|
||||
import { useStream } from '@/stream.js';
|
||||
import * as sound from '@/scripts/sound.js';
|
||||
import { $i, signout, updateAccount } from '@/account.js';
|
||||
import { $i, signout, updateAccountPartial } from '@/account.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { ColdDeviceStorage, defaultStore } from '@/store.js';
|
||||
import { reactionPicker } from '@/scripts/reaction-picker.js';
|
||||
@@ -291,11 +291,11 @@ export async function mainBoot() {
|
||||
|
||||
// 自分の情報が更新されたとき
|
||||
main.on('meUpdated', i => {
|
||||
updateAccount(i);
|
||||
updateAccountPartial(i);
|
||||
});
|
||||
|
||||
main.on('readAllNotifications', () => {
|
||||
updateAccount({
|
||||
updateAccountPartial({
|
||||
hasUnreadNotification: false,
|
||||
unreadNotificationsCount: 0,
|
||||
});
|
||||
@@ -303,39 +303,39 @@ export async function mainBoot() {
|
||||
|
||||
main.on('unreadNotification', () => {
|
||||
const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1;
|
||||
updateAccount({
|
||||
updateAccountPartial({
|
||||
hasUnreadNotification: true,
|
||||
unreadNotificationsCount,
|
||||
});
|
||||
});
|
||||
|
||||
main.on('unreadMention', () => {
|
||||
updateAccount({ hasUnreadMentions: true });
|
||||
updateAccountPartial({ hasUnreadMentions: true });
|
||||
});
|
||||
|
||||
main.on('readAllUnreadMentions', () => {
|
||||
updateAccount({ hasUnreadMentions: false });
|
||||
updateAccountPartial({ hasUnreadMentions: false });
|
||||
});
|
||||
|
||||
main.on('unreadSpecifiedNote', () => {
|
||||
updateAccount({ hasUnreadSpecifiedNotes: true });
|
||||
updateAccountPartial({ hasUnreadSpecifiedNotes: true });
|
||||
});
|
||||
|
||||
main.on('readAllUnreadSpecifiedNotes', () => {
|
||||
updateAccount({ hasUnreadSpecifiedNotes: false });
|
||||
updateAccountPartial({ hasUnreadSpecifiedNotes: false });
|
||||
});
|
||||
|
||||
main.on('readAllAntennas', () => {
|
||||
updateAccount({ hasUnreadAntenna: false });
|
||||
updateAccountPartial({ hasUnreadAntenna: false });
|
||||
});
|
||||
|
||||
main.on('unreadAntenna', () => {
|
||||
updateAccount({ hasUnreadAntenna: true });
|
||||
updateAccountPartial({ hasUnreadAntenna: true });
|
||||
sound.playMisskeySfx('antenna');
|
||||
});
|
||||
|
||||
main.on('readAllAnnouncements', () => {
|
||||
updateAccount({ hasUnreadAnnouncement: false });
|
||||
updateAccountPartial({ hasUnreadAnnouncement: false });
|
||||
});
|
||||
|
||||
// 個人宛てお知らせが発行されたとき
|
||||
|
@@ -29,7 +29,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import MkModal from '@/components/MkModal.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { $i, updateAccount } from '@/account.js';
|
||||
import { $i, updateAccountPartial } from '@/account.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
announcement: Misskey.entities.Announcement;
|
||||
@@ -51,7 +51,7 @@ async function ok() {
|
||||
|
||||
modal.value?.close();
|
||||
misskeyApi('i/read-announcement', { announcementId: props.announcement.id });
|
||||
updateAccount({
|
||||
updateAccountPartial({
|
||||
unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== props.announcement.id),
|
||||
});
|
||||
}
|
||||
|
@@ -117,8 +117,8 @@ async function requestRender() {
|
||||
sitekey: props.sitekey,
|
||||
theme: defaultStore.state.darkMode ? 'dark' : 'light',
|
||||
callback: callback,
|
||||
'expired-callback': callback,
|
||||
'error-callback': callback,
|
||||
'expired-callback': () => callback(undefined),
|
||||
'error-callback': () => callback(undefined),
|
||||
});
|
||||
} else if (props.provider === 'mcaptcha' && props.instanceUrl && props.sitekey) {
|
||||
const { default: Widget } = await import('@mcaptcha/vanilla-glue');
|
||||
|
@@ -64,26 +64,30 @@ const showBody = ref(props.expanded);
|
||||
const ignoreOmit = ref(false);
|
||||
const omitted = ref(false);
|
||||
|
||||
function enter(el) {
|
||||
function enter(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = 0;
|
||||
el.style.height = '0';
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = Math.min(elementHeight, props.maxHeight ?? Infinity) + 'px';
|
||||
el.style.height = `${Math.min(elementHeight, props.maxHeight ?? Infinity)}px`;
|
||||
}
|
||||
|
||||
function afterEnter(el) {
|
||||
el.style.height = null;
|
||||
function afterEnter(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.height = '';
|
||||
}
|
||||
|
||||
function leave(el) {
|
||||
function leave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = elementHeight + 'px';
|
||||
el.style.height = `${elementHeight}px`;
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = 0;
|
||||
el.style.height = '0';
|
||||
}
|
||||
|
||||
function afterLeave(el) {
|
||||
el.style.height = null;
|
||||
function afterLeave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.height = '';
|
||||
}
|
||||
|
||||
const calcOmit = () => {
|
||||
|
@@ -128,14 +128,14 @@ export default defineComponent({
|
||||
return children;
|
||||
};
|
||||
|
||||
function onBeforeLeave(element: Element) {
|
||||
const el = element as HTMLElement;
|
||||
function onBeforeLeave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.top = `${el.offsetTop}px`;
|
||||
el.style.left = `${el.offsetLeft}px`;
|
||||
}
|
||||
|
||||
function onLeaveCancelled(element: Element) {
|
||||
const el = element as HTMLElement;
|
||||
function onLeaveCancelled(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.top = '';
|
||||
el.style.left = '';
|
||||
}
|
||||
|
@@ -157,7 +157,12 @@ const ilFilesObserver = new IntersectionObserver(
|
||||
(entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles(),
|
||||
);
|
||||
|
||||
const sortModeSelect = ref('+createdAt');
|
||||
|
||||
watch(folder, () => emit('cd', folder.value));
|
||||
watch(sortModeSelect, () => {
|
||||
fetch();
|
||||
});
|
||||
|
||||
function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) {
|
||||
addFile(file, true);
|
||||
@@ -558,6 +563,7 @@ async function fetch() {
|
||||
folderId: folder.value ? folder.value.id : null,
|
||||
type: props.type,
|
||||
limit: filesMax + 1,
|
||||
sort: sortModeSelect.value,
|
||||
}).then(fetchedFiles => {
|
||||
if (fetchedFiles.length === filesMax + 1) {
|
||||
moreFiles.value = true;
|
||||
@@ -607,6 +613,7 @@ function fetchMoreFiles() {
|
||||
type: props.type,
|
||||
untilId: files.value.at(-1)?.id,
|
||||
limit: max + 1,
|
||||
sort: sortModeSelect.value,
|
||||
}).then(files => {
|
||||
if (files.length === max + 1) {
|
||||
moreFiles.value = true;
|
||||
@@ -642,6 +649,43 @@ function getMenu() {
|
||||
type: 'label',
|
||||
});
|
||||
|
||||
menu.push({
|
||||
type: 'parent',
|
||||
text: i18n.ts.sort,
|
||||
icon: 'ti ti-arrows-sort',
|
||||
children: [{
|
||||
text: `${i18n.ts.registeredDate} (${i18n.ts.descendingOrder})`,
|
||||
icon: 'ti ti-sort-descending-letters',
|
||||
action: () => { sortModeSelect.value = '+createdAt'; },
|
||||
active: sortModeSelect.value === '+createdAt',
|
||||
}, {
|
||||
text: `${i18n.ts.registeredDate} (${i18n.ts.ascendingOrder})`,
|
||||
icon: 'ti ti-sort-ascending-letters',
|
||||
action: () => { sortModeSelect.value = '-createdAt'; },
|
||||
active: sortModeSelect.value === '-createdAt',
|
||||
}, {
|
||||
text: `${i18n.ts.size} (${i18n.ts.descendingOrder})`,
|
||||
icon: 'ti ti-sort-descending-letters',
|
||||
action: () => { sortModeSelect.value = '+size'; },
|
||||
active: sortModeSelect.value === '+size',
|
||||
}, {
|
||||
text: `${i18n.ts.size} (${i18n.ts.ascendingOrder})`,
|
||||
icon: 'ti ti-sort-ascending-letters',
|
||||
action: () => { sortModeSelect.value = '-size'; },
|
||||
active: sortModeSelect.value === '-size',
|
||||
}, {
|
||||
text: `${i18n.ts.name} (${i18n.ts.descendingOrder})`,
|
||||
icon: 'ti ti-sort-descending-letters',
|
||||
action: () => { sortModeSelect.value = '+name'; },
|
||||
active: sortModeSelect.value === '+name',
|
||||
}, {
|
||||
text: `${i18n.ts.name} (${i18n.ts.ascendingOrder})`,
|
||||
icon: 'ti ti-sort-ascending-letters',
|
||||
action: () => { sortModeSelect.value = '-name'; },
|
||||
active: sortModeSelect.value === '-name',
|
||||
}],
|
||||
});
|
||||
|
||||
if (folder.value) {
|
||||
menu.push({
|
||||
text: i18n.ts.renameFolder,
|
||||
|
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<template>
|
||||
<div ref="rootEl" :class="$style.root">
|
||||
<header :class="$style.header" class="_button" :style="{ background: bg }" @click="showBody = !showBody">
|
||||
<header :class="$style.header" class="_button" @click="showBody = !showBody">
|
||||
<div :class="$style.title"><div><slot name="header"></slot></div></div>
|
||||
<div :class="$style.divider"></div>
|
||||
<button class="_button" :class="$style.button">
|
||||
@@ -32,21 +32,23 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, shallowRef, watch } from 'vue';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { getBgColor } from '@/scripts/get-bg-color.js';
|
||||
|
||||
const miLocalStoragePrefix = 'ui:folder:' as const;
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
expanded?: boolean;
|
||||
persistKey?: string;
|
||||
persistKey?: string | null;
|
||||
}>(), {
|
||||
expanded: true,
|
||||
persistKey: null,
|
||||
});
|
||||
|
||||
const rootEl = shallowRef<HTMLDivElement>();
|
||||
const bg = ref<string>();
|
||||
const rootEl = shallowRef<HTMLElement>();
|
||||
const parentBg = ref<string | null>(null);
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const showBody = ref((props.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`) === 't') : props.expanded);
|
||||
|
||||
watch(showBody, () => {
|
||||
@@ -55,47 +57,34 @@ watch(showBody, () => {
|
||||
}
|
||||
});
|
||||
|
||||
function enter(element: Element) {
|
||||
const el = element as HTMLElement;
|
||||
function enter(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = '0';
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = elementHeight + 'px';
|
||||
el.style.height = `${elementHeight}px`;
|
||||
}
|
||||
|
||||
function afterEnter(element: Element) {
|
||||
const el = element as HTMLElement;
|
||||
el.style.height = 'unset';
|
||||
function afterEnter(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.height = '';
|
||||
}
|
||||
|
||||
function leave(element: Element) {
|
||||
const el = element as HTMLElement;
|
||||
function leave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = elementHeight + 'px';
|
||||
el.style.height = `${elementHeight}px`;
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = '0';
|
||||
}
|
||||
|
||||
function afterLeave(element: Element) {
|
||||
const el = element as HTMLElement;
|
||||
el.style.height = 'unset';
|
||||
function afterLeave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.height = '';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
function getParentBg(el?: HTMLElement | null): string {
|
||||
if (el == null || el.tagName === 'BODY') return 'var(--MI_THEME-bg)';
|
||||
const background = el.style.background || el.style.backgroundColor;
|
||||
if (background) {
|
||||
return background;
|
||||
} else {
|
||||
return getParentBg(el.parentElement);
|
||||
}
|
||||
}
|
||||
|
||||
const rawBg = getParentBg(rootEl.value);
|
||||
const _bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
|
||||
_bg.setAlpha(0.85);
|
||||
bg.value = _bg.toRgbString();
|
||||
parentBg.value = getBgColor(rootEl.value?.parentElement);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -121,6 +110,7 @@ onMounted(() => {
|
||||
top: var(--MI-stickyTop, 0px);
|
||||
-webkit-backdrop-filter: var(--MI-blur, blur(8px));
|
||||
backdrop-filter: var(--MI-blur, blur(20px));
|
||||
background-color: color(from v-bind("parentBg ?? 'var(--bg)'") srgb r g b / 0.85);
|
||||
}
|
||||
|
||||
.title {
|
||||
|
@@ -56,8 +56,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted, shallowRef, ref } from 'vue';
|
||||
import { nextTick, onMounted, ref, shallowRef } from 'vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { getBgColor } from '@/scripts/get-bg-color.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
defaultOpen?: boolean;
|
||||
@@ -69,40 +70,35 @@ const props = withDefaults(defineProps<{
|
||||
withSpacer: true,
|
||||
});
|
||||
|
||||
const getBgColor = (el: HTMLElement) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) {
|
||||
return style.backgroundColor;
|
||||
} else {
|
||||
return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
|
||||
}
|
||||
};
|
||||
|
||||
const rootEl = shallowRef<HTMLElement>();
|
||||
const bgSame = ref(false);
|
||||
const opened = ref(props.defaultOpen);
|
||||
const openedAtLeastOnce = ref(props.defaultOpen);
|
||||
|
||||
function enter(el) {
|
||||
function enter(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = 0;
|
||||
el.style.height = '0';
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = Math.min(elementHeight, props.maxHeight ?? Infinity) + 'px';
|
||||
el.style.height = `${Math.min(elementHeight, props.maxHeight ?? Infinity)}px`;
|
||||
}
|
||||
|
||||
function afterEnter(el) {
|
||||
el.style.height = null;
|
||||
function afterEnter(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.height = '';
|
||||
}
|
||||
|
||||
function leave(el) {
|
||||
function leave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = elementHeight + 'px';
|
||||
el.style.height = `${elementHeight}px`;
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = 0;
|
||||
el.style.height = '0';
|
||||
}
|
||||
|
||||
function afterLeave(el) {
|
||||
el.style.height = null;
|
||||
function afterLeave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.height = '';
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
@@ -117,7 +113,7 @@ function toggle() {
|
||||
|
||||
onMounted(() => {
|
||||
const computedStyle = getComputedStyle(document.documentElement);
|
||||
const parentBg = getBgColor(rootEl.value!.parentElement!);
|
||||
const parentBg = getBgColor(rootEl.value?.parentElement) ?? 'transparent';
|
||||
const myBg = computedStyle.getPropertyValue('--MI_THEME-panel');
|
||||
bgSame.value = parentBg === myBg;
|
||||
});
|
||||
|
@@ -37,13 +37,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { useStream } from '@/stream.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { claimAchievement } from '@/scripts/achievements.js';
|
||||
import { pleaseLogin } from '@/scripts/please-login.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
@@ -80,7 +80,7 @@ function onFollowChange(user: Misskey.entities.UserDetailed) {
|
||||
}
|
||||
|
||||
async function onClick() {
|
||||
pleaseLogin(undefined, { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` });
|
||||
pleaseLogin({ openOnRemote: { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` } });
|
||||
|
||||
wait.value = true;
|
||||
|
||||
|
@@ -12,13 +12,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<i class="icon" :class="item.icon"></i>
|
||||
<div class="text">{{ item.text }}</div>
|
||||
<span v-if="item.indicate && item.indicateValue" class="_indicateCounter indicatorWithValue">{{ item.indicateValue }}</span>
|
||||
<span v-else-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
|
||||
<span v-else-if="item.indicate" class="indicator _blink"><i class="_indicatorCircle"></i></span>
|
||||
</button>
|
||||
<MkA v-else v-click-anime :to="item.to" class="item" @click.passive="close()">
|
||||
<i class="icon" :class="item.icon"></i>
|
||||
<div class="text">{{ item.text }}</div>
|
||||
<span v-if="item.indicate && item.indicateValue" class="_indicateCounter indicatorWithValue">{{ item.indicateValue }}</span>
|
||||
<span v-else-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
|
||||
<span v-else-if="item.indicate" class="indicator _blink"><i class="_indicatorCircle"></i></span>
|
||||
</MkA>
|
||||
</template>
|
||||
</div>
|
||||
@@ -139,7 +139,6 @@ function close() {
|
||||
left: 32px;
|
||||
color: var(--MI_THEME-indicator);
|
||||
font-size: 8px;
|
||||
animation: global-blink 1s infinite;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
top: 16px;
|
||||
|
@@ -49,7 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
||||
<div :class="$style.item_content">
|
||||
<span :class="$style.item_content_text">{{ item.text }}</span>
|
||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
<span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
|
||||
</div>
|
||||
</MkA>
|
||||
<a
|
||||
@@ -68,7 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||
<div :class="$style.item_content">
|
||||
<span :class="$style.item_content_text">{{ item.text }}</span>
|
||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
<span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
|
||||
</div>
|
||||
</a>
|
||||
<button
|
||||
@@ -82,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
>
|
||||
<MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/>
|
||||
<div v-if="item.indicate" :class="$style.item_content">
|
||||
<span :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
<span :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@@ -161,7 +161,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
||||
<div :class="$style.item_content">
|
||||
<span :class="$style.item_content_text">{{ item.text }}</span>
|
||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
<span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
@@ -639,7 +639,6 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
color: var(--MI_THEME-indicator);
|
||||
font-size: 12px;
|
||||
animation: global-blink 1s infinite;
|
||||
}
|
||||
|
||||
.divider {
|
||||
|
@@ -227,6 +227,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const inTimeline = inject<boolean>('inTimeline', false);
|
||||
const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(false));
|
||||
const inChannel = inject('inChannel', null);
|
||||
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
|
||||
|
||||
@@ -299,7 +300,7 @@ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string
|
||||
|
||||
if (checkOnly) return false;
|
||||
|
||||
if (inTimeline && !defaultStore.state.tl.filter.withSensitive && noteToCheck.files?.some((v) => v.isSensitive)) return 'sensitiveMute';
|
||||
if (inTimeline && !tl_withSensitive.value && noteToCheck.files?.some((v) => v.isSensitive)) return 'sensitiveMute';
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -419,7 +420,7 @@ if (!props.mock) {
|
||||
}
|
||||
|
||||
function renote(viaKeyboard = false) {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
showMovedDialog();
|
||||
|
||||
const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock });
|
||||
@@ -429,7 +430,7 @@ function renote(viaKeyboard = false) {
|
||||
}
|
||||
|
||||
function reply(): void {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
@@ -442,7 +443,7 @@ function reply(): void {
|
||||
}
|
||||
|
||||
function react(): void {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
showMovedDialog();
|
||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
@@ -563,7 +564,7 @@ function showRenoteMenu(): void {
|
||||
}
|
||||
|
||||
if (isMyRenote) {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
os.popupMenu([
|
||||
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
|
||||
{ type: 'divider' },
|
||||
|
@@ -207,6 +207,7 @@ import { computed, inject, onMounted, provide, ref, shallowRef } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import MkNoteSub from '@/components/MkNoteSub.vue';
|
||||
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
|
||||
@@ -230,7 +231,6 @@ import { reactionPicker } from '@/scripts/reaction-picker.js';
|
||||
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js';
|
||||
import { useNoteCapture } from '@/scripts/use-note-capture.js';
|
||||
import { deepClone } from '@/scripts/clone.js';
|
||||
@@ -404,7 +404,7 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||
}
|
||||
|
||||
function renote() {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
showMovedDialog();
|
||||
|
||||
const { menu } = getRenoteMenu({ note: note.value, renoteButton });
|
||||
@@ -412,7 +412,7 @@ function renote() {
|
||||
}
|
||||
|
||||
function reply(): void {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
showMovedDialog();
|
||||
os.post({
|
||||
reply: appearNote.value,
|
||||
@@ -423,7 +423,7 @@ function reply(): void {
|
||||
}
|
||||
|
||||
function react(): void {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
showMovedDialog();
|
||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
@@ -499,7 +499,7 @@ async function clip(): Promise<void> {
|
||||
|
||||
function showRenoteMenu(): void {
|
||||
if (!isMyRenote) return;
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
os.popupMenu([{
|
||||
text: i18n.ts.unrenote,
|
||||
icon: 'ti ti-trash',
|
||||
|
@@ -29,14 +29,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
|
||||
import { sum } from '@/scripts/array.js';
|
||||
import { pleaseLogin } from '@/scripts/please-login.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
|
||||
const props = defineProps<{
|
||||
noteId: string;
|
||||
@@ -85,7 +85,7 @@ if (props.poll.expiresAt) {
|
||||
const vote = async (id) => {
|
||||
if (props.readOnly || closed.value || isVoted.value) return;
|
||||
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
|
@@ -65,10 +65,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</div>
|
||||
<MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
|
||||
<input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown">
|
||||
<input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown" @keyup="onKeyup" @compositionend="onCompositionEnd">
|
||||
<div :class="[$style.textOuter, { [$style.withCw]: useCw }]">
|
||||
<div v-if="channel" :class="$style.colorBar" :style="{ background: channel.color }"></div>
|
||||
<textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
|
||||
<textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @keyup="onKeyup" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
|
||||
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
|
||||
</div>
|
||||
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
|
||||
@@ -201,6 +201,7 @@ const recentHashtags = ref(JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]'
|
||||
const imeText = ref('');
|
||||
const showingOptions = ref(false);
|
||||
const textAreaReadOnly = ref(false);
|
||||
const justEndedComposition = ref(false);
|
||||
|
||||
const draftKey = computed((): string => {
|
||||
let key = props.channel ? `channel:${props.channel.id}` : '';
|
||||
@@ -573,7 +574,13 @@ function clear() {
|
||||
function onKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey) && canPost.value) post();
|
||||
|
||||
if (ev.key === 'Escape') emit('esc');
|
||||
// justEndedComposition.value is for Safari, which keyDown occurs after compositionend.
|
||||
// ev.isComposing is for another browsers.
|
||||
if (ev.key === 'Escape' && !justEndedComposition.value && !ev.isComposing) emit('esc');
|
||||
}
|
||||
|
||||
function onKeyup(ev: KeyboardEvent) {
|
||||
justEndedComposition.value = false;
|
||||
}
|
||||
|
||||
function onCompositionUpdate(ev: CompositionEvent) {
|
||||
@@ -582,6 +589,7 @@ function onCompositionUpdate(ev: CompositionEvent) {
|
||||
|
||||
function onCompositionEnd(ev: CompositionEvent) {
|
||||
imeText.value = '';
|
||||
justEndedComposition.value = true;
|
||||
}
|
||||
|
||||
async function onPaste(ev: ClipboardEvent) {
|
||||
|
@@ -16,9 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
@keydown.space.enter="show"
|
||||
>
|
||||
<div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div>
|
||||
<select
|
||||
<div
|
||||
ref="inputEl"
|
||||
v-model="v"
|
||||
v-adaptive-border
|
||||
tabindex="-1"
|
||||
:class="$style.inputCore"
|
||||
@@ -26,55 +25,48 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:required="required"
|
||||
:readonly="readonly"
|
||||
:placeholder="placeholder"
|
||||
@input="onInput"
|
||||
@mousedown.prevent="() => {}"
|
||||
@keydown.prevent="() => {}"
|
||||
>
|
||||
<slot></slot>
|
||||
</select>
|
||||
<div style="pointer-events: none;">{{ currentValueText ?? '' }}</div>
|
||||
<div style="display: none;">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="suffixEl" :class="$style.suffix"><i class="ti ti-chevron-down" :class="[$style.chevron, { [$style.chevronOpening]: opening }]"></i></div>
|
||||
</div>
|
||||
<div :class="$style.caption"><slot name="caption"></slot></div>
|
||||
|
||||
<MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots, VNodeChild } from 'vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | null;
|
||||
modelValue: string | number | null;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
inline?: boolean;
|
||||
manualSave?: boolean;
|
||||
small?: boolean;
|
||||
large?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'changeByUser', value: string | null): void;
|
||||
(ev: 'update:modelValue', value: string | null): void;
|
||||
(ev: 'update:modelValue', value: string | number | null): void;
|
||||
}>();
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const { modelValue, autofocus } = toRefs(props);
|
||||
const v = ref(modelValue.value);
|
||||
const focused = ref(false);
|
||||
const opening = ref(false);
|
||||
const changed = ref(false);
|
||||
const invalid = ref(false);
|
||||
const filled = computed(() => v.value !== '' && v.value != null);
|
||||
const currentValueText = ref<string | null>(null);
|
||||
const inputEl = ref<HTMLObjectElement | null>(null);
|
||||
const prefixEl = ref<HTMLElement | null>(null);
|
||||
const suffixEl = ref<HTMLElement | null>(null);
|
||||
@@ -85,26 +77,6 @@ const height =
|
||||
36;
|
||||
|
||||
const focus = () => container.value?.focus();
|
||||
const onInput = (ev) => {
|
||||
changed.value = true;
|
||||
};
|
||||
|
||||
const updated = () => {
|
||||
changed.value = false;
|
||||
emit('update:modelValue', v.value);
|
||||
};
|
||||
|
||||
watch(modelValue, newValue => {
|
||||
v.value = newValue;
|
||||
});
|
||||
|
||||
watch(v, () => {
|
||||
if (!props.manualSave) {
|
||||
updated();
|
||||
}
|
||||
|
||||
invalid.value = inputEl.value?.validity.badInput ?? true;
|
||||
});
|
||||
|
||||
// このコンポーネントが作成された時、非表示状態である場合がある
|
||||
// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
|
||||
@@ -134,6 +106,31 @@ onMounted(() => {
|
||||
});
|
||||
});
|
||||
|
||||
watch(modelValue, () => {
|
||||
const scanOptions = (options: VNodeChild[]) => {
|
||||
for (const vnode of options) {
|
||||
if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue;
|
||||
if (vnode.type === 'optgroup') {
|
||||
const optgroup = vnode;
|
||||
if (Array.isArray(optgroup.children)) scanOptions(optgroup.children);
|
||||
} else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある
|
||||
const fragment = vnode;
|
||||
if (Array.isArray(fragment.children)) scanOptions(fragment.children);
|
||||
} else if (vnode.props == null) { // v-if で条件が false のときにこうなる
|
||||
// nop?
|
||||
} else {
|
||||
const option = vnode;
|
||||
if (option.props?.value === modelValue.value) {
|
||||
currentValueText.value = option.children as string;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
scanOptions(slots.default!());
|
||||
}, { immediate: true });
|
||||
|
||||
function show() {
|
||||
if (opening.value) return;
|
||||
focus();
|
||||
@@ -146,11 +143,9 @@ function show() {
|
||||
const pushOption = (option: VNode) => {
|
||||
menu.push({
|
||||
text: option.children as string,
|
||||
active: computed(() => v.value === option.props?.value),
|
||||
active: computed(() => modelValue.value === option.props?.value),
|
||||
action: () => {
|
||||
v.value = option.props?.value;
|
||||
changed.value = true;
|
||||
emit('changeByUser', v.value);
|
||||
emit('update:modelValue', option.props?.value);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -248,7 +243,8 @@ function show() {
|
||||
.inputCore {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: v-bind("height + 'px'");
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
|
@@ -38,6 +38,7 @@ const props = withDefaults(defineProps<{
|
||||
sound?: boolean;
|
||||
withRenotes?: boolean;
|
||||
withReplies?: boolean;
|
||||
withSensitive?: boolean;
|
||||
onlyFiles?: boolean;
|
||||
}>(), {
|
||||
withRenotes: true,
|
||||
@@ -51,6 +52,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
provide('inTimeline', true);
|
||||
provide('tl_withSensitive', computed(() => props.withSensitive));
|
||||
provide('inChannel', computed(() => props.src === 'channel'));
|
||||
|
||||
type TimelineQueryType = {
|
||||
@@ -248,6 +250,9 @@ function refreshEndpointAndChannel() {
|
||||
// IDが切り替わったら切り替え先のTLを表示させたい
|
||||
watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel);
|
||||
|
||||
// withSensitiveはクライアントで完結する処理のため、単にリロードするだけでOK
|
||||
watch(() => props.withSensitive, reloadTimeline);
|
||||
|
||||
// 初回表示用
|
||||
refreshEndpointAndChannel();
|
||||
|
||||
|
@@ -136,8 +136,6 @@ function reduceFrequency(): void {
|
||||
}
|
||||
|
||||
&.form_horizontal {
|
||||
padding: 8px;
|
||||
|
||||
> .link,
|
||||
> .link > .img {
|
||||
max-width: min(600px, 100%);
|
||||
@@ -146,8 +144,6 @@ function reduceFrequency(): void {
|
||||
}
|
||||
|
||||
&.form_horizontalBig {
|
||||
padding: 8px;
|
||||
|
||||
> .link,
|
||||
> .link > .img {
|
||||
max-width: min(600px, 100%);
|
||||
|
@@ -53,7 +53,7 @@ export type Tab = {
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, watch, nextTick, shallowRef } from 'vue';
|
||||
import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -120,14 +120,14 @@ function onTabWheel(ev: WheelEvent) {
|
||||
|
||||
let entering = false;
|
||||
|
||||
async function enter(element: Element) {
|
||||
async function enter(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
entering = true;
|
||||
const el = element as HTMLElement;
|
||||
const elementWidth = el.getBoundingClientRect().width;
|
||||
el.style.width = '0';
|
||||
el.style.paddingLeft = '0';
|
||||
el.offsetWidth; // force reflow
|
||||
el.style.width = elementWidth + 'px';
|
||||
el.offsetWidth; // reflow
|
||||
el.style.width = `${elementWidth}px`;
|
||||
el.style.paddingLeft = '';
|
||||
nextTick(() => {
|
||||
entering = false;
|
||||
@@ -136,22 +136,23 @@ async function enter(element: Element) {
|
||||
setTimeout(renderTab, 170);
|
||||
}
|
||||
|
||||
function afterEnter(element: Element) {
|
||||
//el.style.width = '';
|
||||
function afterEnter(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
// element.style.width = '';
|
||||
}
|
||||
|
||||
async function leave(element: Element) {
|
||||
const el = element as HTMLElement;
|
||||
async function leave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
const elementWidth = el.getBoundingClientRect().width;
|
||||
el.style.width = elementWidth + 'px';
|
||||
el.style.width = `${elementWidth}px`;
|
||||
el.style.paddingLeft = '';
|
||||
el.offsetWidth; // force reflow
|
||||
el.offsetWidth; // reflow
|
||||
el.style.width = '0';
|
||||
el.style.paddingLeft = '0';
|
||||
}
|
||||
|
||||
function afterLeave(element: Element) {
|
||||
const el = element as HTMLElement;
|
||||
function afterLeave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.width = '';
|
||||
}
|
||||
|
||||
|
@@ -4,19 +4,11 @@
|
||||
*/
|
||||
|
||||
import { Directive } from 'vue';
|
||||
import { getBgColor } from '@/scripts/get-bg-color.js';
|
||||
|
||||
export default {
|
||||
mounted(src, binding, vn) {
|
||||
const getBgColor = (el: HTMLElement) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) {
|
||||
return style.backgroundColor;
|
||||
} else {
|
||||
return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
|
||||
}
|
||||
};
|
||||
|
||||
const parentBg = getBgColor(src.parentElement);
|
||||
const parentBg = getBgColor(src.parentElement) ?? 'transparent';
|
||||
|
||||
const myBg = window.getComputedStyle(src).backgroundColor;
|
||||
|
||||
|
@@ -4,19 +4,11 @@
|
||||
*/
|
||||
|
||||
import { Directive } from 'vue';
|
||||
import { getBgColor } from '@/scripts/get-bg-color.js';
|
||||
|
||||
export default {
|
||||
mounted(src, binding, vn) {
|
||||
const getBgColor = (el: HTMLElement) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) {
|
||||
return style.backgroundColor;
|
||||
} else {
|
||||
return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
|
||||
}
|
||||
};
|
||||
|
||||
const parentBg = getBgColor(src.parentElement);
|
||||
const parentBg = getBgColor(src.parentElement) ?? 'transparent';
|
||||
|
||||
const myBg = window.getComputedStyle(src).backgroundColor;
|
||||
|
||||
|
@@ -4,19 +4,11 @@
|
||||
*/
|
||||
|
||||
import { Directive } from 'vue';
|
||||
import { getBgColor } from '@/scripts/get-bg-color.js';
|
||||
|
||||
export default {
|
||||
mounted(src, binding, vn) {
|
||||
const getBgColor = (el: HTMLElement) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) {
|
||||
return style.backgroundColor;
|
||||
} else {
|
||||
return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
|
||||
}
|
||||
};
|
||||
|
||||
const parentBg = getBgColor(src.parentElement);
|
||||
const parentBg = getBgColor(src.parentElement) ?? 'transparent';
|
||||
|
||||
const myBg = getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-panel');
|
||||
|
||||
|
@@ -688,14 +688,16 @@ export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> {
|
||||
}
|
||||
|
||||
export function post(props: Record<string, any> = {}): Promise<void> {
|
||||
pleaseLogin(undefined, (props.initialText || props.initialNote ? {
|
||||
type: 'share',
|
||||
params: {
|
||||
text: props.initialText ?? props.initialNote.text,
|
||||
visibility: props.initialVisibility ?? props.initialNote?.visibility,
|
||||
localOnly: (props.initialLocalOnly || props.initialNote?.localOnly) ? '1' : '0',
|
||||
},
|
||||
} : undefined));
|
||||
pleaseLogin({
|
||||
openOnRemote: (props.initialText || props.initialNote ? {
|
||||
type: 'share',
|
||||
params: {
|
||||
text: props.initialText ?? props.initialNote.text,
|
||||
visibility: props.initialVisibility ?? props.initialNote?.visibility,
|
||||
localOnly: (props.initialLocalOnly || props.initialNote?.localOnly) ? '1' : '0',
|
||||
},
|
||||
} : undefined),
|
||||
});
|
||||
|
||||
showMovedDialog();
|
||||
return new Promise(resolve => {
|
||||
|
@@ -55,7 +55,7 @@ import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { $i, updateAccount } from '@/account.js';
|
||||
import { $i, updateAccountPartial } from '@/account.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -90,7 +90,7 @@ async function read(target: Misskey.entities.Announcement): Promise<void> {
|
||||
target.isRead = true;
|
||||
await misskeyApi('i/read-announcement', { announcementId: target.id });
|
||||
if ($i) {
|
||||
updateAccount({
|
||||
updateAccountPartial({
|
||||
unreadAnnouncements: $i.unreadAnnouncements.filter((a: { id: string; }) => a.id !== target.id),
|
||||
});
|
||||
}
|
||||
|
@@ -56,7 +56,7 @@ import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { $i, updateAccount } from '@/account.js';
|
||||
import { $i, updateAccountPartial } from '@/account.js';
|
||||
|
||||
const paginationCurrent = {
|
||||
endpoint: 'announcements' as const,
|
||||
@@ -94,7 +94,7 @@ async function read(target) {
|
||||
return a;
|
||||
});
|
||||
misskeyApi('i/read-announcement', { announcementId: target.id });
|
||||
updateAccount({
|
||||
updateAccountPartial({
|
||||
unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id),
|
||||
});
|
||||
}
|
||||
|
@@ -5,10 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<MkPageHeader/>
|
||||
</template>
|
||||
<MKSpacer v-if="!instance.disableRegistration || !($i && ($i.isAdmin || $i.policies.canInvite))" :contentMax="1200">
|
||||
<template #header><MkPageHeader/></template>
|
||||
<MkSpacer v-if="!instance.disableRegistration || !($i && ($i.isAdmin || $i.policies.canInvite))" :contentMax="1200">
|
||||
<div :class="$style.root">
|
||||
<img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/>
|
||||
<div :class="$style.text">
|
||||
@@ -16,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
{{ i18n.ts.nothing }}
|
||||
</div>
|
||||
</div>
|
||||
</MKSpacer>
|
||||
</MkSpacer>
|
||||
<MkSpacer v-else :contentMax="800">
|
||||
<div class="_gaps_m" style="text-align: center;">
|
||||
<div v-if="resetCycle && inviteLimit">{{ i18n.tsx.inviteLimitResetCycle({ time: resetCycle, limit: inviteLimit }) }}</div>
|
||||
|
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MKSpacer v-if="!(typeof error === 'undefined')" :contentMax="1200">
|
||||
<MkSpacer v-if="error != null" :contentMax="1200">
|
||||
<div :class="$style.root">
|
||||
<img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/>
|
||||
<p :class="$style.text">
|
||||
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
{{ i18n.ts.nothing }}
|
||||
</p>
|
||||
</div>
|
||||
</MKSpacer>
|
||||
</MkSpacer>
|
||||
<MkSpacer v-else-if="list" :contentMax="700" :class="$style.main">
|
||||
<div v-if="list" class="members _margin">
|
||||
<div :class="$style.member_text">{{ i18n.ts.members }}</div>
|
||||
@@ -50,7 +50,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const list = ref<Misskey.entities.UserList | null>(null);
|
||||
const error = ref();
|
||||
const error = ref<unknown | null>(null);
|
||||
const users = ref<Misskey.entities.UserDetailed[]>([]);
|
||||
|
||||
function fetchList(): void {
|
||||
|
@@ -24,7 +24,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
if (props.showLoginPopup) {
|
||||
pleaseLogin('/');
|
||||
pleaseLogin({ path: '/' });
|
||||
}
|
||||
|
||||
const headerActions = computed(() => []);
|
||||
|
@@ -61,6 +61,7 @@ import { i18n } from '@/i18n.js';
|
||||
import { dateString } from '@/filters/date.js';
|
||||
import MkClipPreview from '@/components/MkClipPreview.vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { pleaseLogin } from '@/scripts/please-login.js';
|
||||
|
||||
const props = defineProps<{
|
||||
noteId: string;
|
||||
@@ -128,6 +129,11 @@ function fetchNote() {
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
if (err.id === '8e75455b-738c-471d-9f80-62693f33372e') {
|
||||
pleaseLogin({
|
||||
message: i18n.ts.thisContentsAreMarkedAsSigninRequiredByAuthor,
|
||||
});
|
||||
}
|
||||
error.value = err;
|
||||
});
|
||||
}
|
||||
|
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader v-model:tab="tab" :tabs="headerTabs"/></template>
|
||||
<MKSpacer v-if="!(typeof error === 'undefined')" :contentMax="1200">
|
||||
<MkSpacer v-if="error != null" :contentMax="1200">
|
||||
<div :class="$style.root">
|
||||
<img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/>
|
||||
<p :class="$style.text">
|
||||
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
</MKSpacer>
|
||||
</MkSpacer>
|
||||
<MkSpacer v-else-if="tab === 'users'" :contentMax="1200">
|
||||
<div class="_gaps_s">
|
||||
<div v-if="role">{{ role.description }}</div>
|
||||
@@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</MkSpacer>
|
||||
<MkSpacer v-else-if="tab === 'timeline'" :contentMax="700">
|
||||
<MkTimeline v-if="visible" ref="timeline" src="role" :role="props.role"/>
|
||||
<MkTimeline v-if="visible" ref="timeline" src="role" :role="props.roleId"/>
|
||||
<div v-else-if="!visible" class="_fullinfo">
|
||||
<img :src="infoImageUrl" class="_ghost"/>
|
||||
<div>{{ i18n.ts.nothing }}</div>
|
||||
@@ -47,23 +47,24 @@ import { instanceName } from '@@/js/config.js';
|
||||
import { serverErrorImageUrl, infoImageUrl } from '@/instance.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
role: string;
|
||||
roleId: string;
|
||||
initialTab?: string;
|
||||
}>(), {
|
||||
initialTab: 'users',
|
||||
});
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const tab = ref(props.initialTab);
|
||||
const role = ref<Misskey.entities.Role>();
|
||||
const error = ref();
|
||||
const role = ref<Misskey.entities.Role | null>(null);
|
||||
const error = ref<string | null>(null);
|
||||
const visible = ref(false);
|
||||
|
||||
watch(() => props.role, () => {
|
||||
watch(() => props.roleId, () => {
|
||||
misskeyApi('roles/show', {
|
||||
roleId: props.role,
|
||||
roleId: props.roleId,
|
||||
}).then(res => {
|
||||
role.value = res;
|
||||
document.title = `${role.value.name} | ${instanceName}`;
|
||||
error.value = null;
|
||||
visible.value = res.isExplorable && res.isPublic;
|
||||
}).catch((err) => {
|
||||
if (err.code === 'NO_SUCH_ROLE') {
|
||||
@@ -71,7 +72,6 @@ watch(() => props.role, () => {
|
||||
} else {
|
||||
error.value = i18n.ts.somethingHappened;
|
||||
}
|
||||
document.title = `${error.value} | ${instanceName}`;
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
@@ -79,7 +79,7 @@ const users = computed(() => ({
|
||||
endpoint: 'roles/users' as const,
|
||||
limit: 30,
|
||||
params: {
|
||||
roleId: props.role,
|
||||
roleId: props.roleId,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -94,7 +94,7 @@ const headerTabs = computed(() => [{
|
||||
}]);
|
||||
|
||||
definePageMetadata(() => ({
|
||||
title: role.value ? role.value.name : i18n.ts.role,
|
||||
title: role.value ? role.value.name : (error.value ?? i18n.ts.role),
|
||||
icon: 'ti ti-badge',
|
||||
}));
|
||||
</script>
|
||||
|
@@ -84,7 +84,7 @@ import FormSection from '@/components/form/section.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkLink from '@/components/MkLink.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { signinRequired, updateAccount } from '@/account.js';
|
||||
import { signinRequired, updateAccountPartial } from '@/account.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const $i = signinRequired();
|
||||
@@ -123,7 +123,7 @@ async function unregisterTOTP(): Promise<void> {
|
||||
password: auth.result.password,
|
||||
token: auth.result.token,
|
||||
}).then(res => {
|
||||
updateAccount({
|
||||
updateAccountPartial({
|
||||
twoFactorEnabled: false,
|
||||
});
|
||||
}).catch(error => {
|
||||
|
@@ -6,13 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template>
|
||||
<div class="_gaps_m">
|
||||
<MkSelect v-model="type">
|
||||
<option value="all">{{ i18n.ts.all }}</option>
|
||||
<option value="following">{{ i18n.ts.following }}</option>
|
||||
<option value="follower">{{ i18n.ts.followers }}</option>
|
||||
<option value="mutualFollow">{{ i18n.ts.mutualFollow }}</option>
|
||||
<option value="followingOrFollower">{{ i18n.ts.followingOrFollower }}</option>
|
||||
<option value="list">{{ i18n.ts.userList }}</option>
|
||||
<option value="never">{{ i18n.ts.none }}</option>
|
||||
<option v-for="type in props.configurableTypes ?? notificationConfigTypes" :key="type" :value="type">{{ notificationConfigTypesI18nMap[type] }}</option>
|
||||
</MkSelect>
|
||||
|
||||
<MkSelect v-if="type === 'list'" v-model="userListId">
|
||||
@@ -21,31 +15,61 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkSelect>
|
||||
|
||||
<div class="_buttons">
|
||||
<MkButton inline primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
|
||||
<MkButton inline primary :disabled="type === 'list' && userListId === null" @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
const notificationConfigTypes = [
|
||||
'all',
|
||||
'following',
|
||||
'follower',
|
||||
'mutualFollow',
|
||||
'followingOrFollower',
|
||||
'list',
|
||||
'never'
|
||||
] as const;
|
||||
|
||||
export type NotificationConfig = {
|
||||
type: Exclude<typeof notificationConfigTypes[number], 'list'>;
|
||||
} | {
|
||||
type: 'list';
|
||||
userListId: string;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { ref } from 'vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = defineProps<{
|
||||
value: any;
|
||||
value: NotificationConfig;
|
||||
userLists: Misskey.entities.UserList[];
|
||||
configurableTypes?: NotificationConfig['type'][]; // If not specified, all types are configurable
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'update', result: any): void;
|
||||
(ev: 'update', result: NotificationConfig): void;
|
||||
}>();
|
||||
|
||||
const notificationConfigTypesI18nMap: Record<typeof notificationConfigTypes[number], string> = {
|
||||
all: i18n.ts.all,
|
||||
following: i18n.ts.following,
|
||||
follower: i18n.ts.followers,
|
||||
mutualFollow: i18n.ts.mutualFollow,
|
||||
followingOrFollower: i18n.ts.followingOrFollower,
|
||||
list: i18n.ts.userList,
|
||||
never: i18n.ts.none,
|
||||
};
|
||||
|
||||
const type = ref(props.value.type);
|
||||
const userListId = ref(props.value.userListId);
|
||||
const userListId = ref(props.value.type === 'list' ? props.value.userListId : null);
|
||||
|
||||
function save() {
|
||||
emit('update', { type: type.value, userListId: userListId.value });
|
||||
emit('update', type.value === 'list' ? { type: type.value, userListId: userListId.value! } : { type: type.value });
|
||||
}
|
||||
</script>
|
||||
|
@@ -22,7 +22,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
}}
|
||||
</template>
|
||||
|
||||
<XNotificationConfig :userLists="userLists" :value="$i.notificationRecieveConfig[type] ?? { type: 'all' }" @update="(res) => updateReceiveConfig(type, res)"/>
|
||||
<XNotificationConfig
|
||||
:userLists="userLists"
|
||||
:value="$i.notificationRecieveConfig[type] ?? { type: 'all' }"
|
||||
:configurableTypes="onlyOnOrOffNotificationTypes.includes(type) ? ['all', 'never'] : undefined"
|
||||
@update="(res) => updateReceiveConfig(type, res)"
|
||||
/>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</FormSection>
|
||||
@@ -58,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { shallowRef, computed } from 'vue';
|
||||
import XNotificationConfig from './notifications.notification-config.vue';
|
||||
import XNotificationConfig, { type NotificationConfig } from './notifications.notification-config.vue';
|
||||
import FormLink from '@/components/form/link.vue';
|
||||
import FormSection from '@/components/form/section.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
@@ -73,7 +78,9 @@ import { notificationTypes } from '@@/js/const.js';
|
||||
|
||||
const $i = signinRequired();
|
||||
|
||||
const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'achievementEarned', 'test', 'exportCompleted'] as const satisfies (typeof notificationTypes[number])[];
|
||||
const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'test', 'exportCompleted'] satisfies (typeof notificationTypes[number])[] as string[];
|
||||
|
||||
const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login'] satisfies (typeof notificationTypes[number])[] as string[];
|
||||
|
||||
const allowButton = shallowRef<InstanceType<typeof MkPushNotificationAllowButton>>();
|
||||
const pushRegistrationInServer = computed(() => allowButton.value?.pushRegistrationInServer);
|
||||
@@ -88,7 +95,7 @@ async function readAllNotifications() {
|
||||
await os.apiWithDialog('notifications/mark-all-as-read');
|
||||
}
|
||||
|
||||
async function updateReceiveConfig(type, value) {
|
||||
async function updateReceiveConfig(type: typeof notificationTypes[number], value: NotificationConfig) {
|
||||
await os.apiWithDialog('i/update', {
|
||||
notificationRecieveConfig: {
|
||||
...$i.notificationRecieveConfig,
|
||||
|
@@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template #caption>{{ i18n.ts.noCrawleDescription }}</template>
|
||||
</MkSwitch>
|
||||
<MkSwitch v-model="preventAiLearning" @update:modelValue="save()">
|
||||
{{ i18n.ts.preventAiLearning }}<span class="_beta">{{ i18n.ts.beta }}</span>
|
||||
{{ i18n.ts.preventAiLearning }}
|
||||
<template #caption>{{ i18n.ts.preventAiLearningDescription }}</template>
|
||||
</MkSwitch>
|
||||
<MkSwitch v-model="isExplorable" @update:modelValue="save()">
|
||||
@@ -44,6 +44,93 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template #caption>{{ i18n.ts.makeExplorableDescription }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<FormSection>
|
||||
<template #label>{{ i18n.ts.lockdown }}<span class="_beta">{{ i18n.ts.beta }}</span></template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<MkSwitch v-model="requireSigninToViewContents" @update:modelValue="save()">
|
||||
{{ i18n.ts._accountSettings.requireSigninToViewContents }}
|
||||
<template #caption>
|
||||
<div>{{ i18n.ts._accountSettings.requireSigninToViewContentsDescription1 }}</div>
|
||||
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription2 }}</div>
|
||||
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription3 }}</div>
|
||||
</template>
|
||||
</MkSwitch>
|
||||
|
||||
<FormSlot>
|
||||
<template #label>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBefore }}</template>
|
||||
|
||||
<div class="_gaps_s">
|
||||
<MkSelect :modelValue="makeNotesFollowersOnlyBefore_type" @update:modelValue="makeNotesFollowersOnlyBefore = $event === 'relative' ? -604800 : $event === 'absolute' ? Math.floor(Date.now() / 1000) : null">
|
||||
<option :value="null">{{ i18n.ts.none }}</option>
|
||||
<option value="relative">{{ i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod }}</option>
|
||||
<option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option>
|
||||
</MkSelect>
|
||||
|
||||
<MkSelect v-if="makeNotesFollowersOnlyBefore_type === 'relative'" v-model="makeNotesFollowersOnlyBefore">
|
||||
<option :value="-3600">{{ i18n.ts.oneHour }}</option>
|
||||
<option :value="-86400">{{ i18n.ts.oneDay }}</option>
|
||||
<option :value="-259200">{{ i18n.ts.threeDays }}</option>
|
||||
<option :value="-604800">{{ i18n.ts.oneWeek }}</option>
|
||||
<option :value="-2592000">{{ i18n.ts.oneMonth }}</option>
|
||||
<option :value="-7776000">{{ i18n.ts.threeMonths }}</option>
|
||||
<option :value="-31104000">{{ i18n.ts.oneYear }}</option>
|
||||
</MkSelect>
|
||||
|
||||
<MkInput
|
||||
v-if="makeNotesFollowersOnlyBefore_type === 'absolute'"
|
||||
:modelValue="formatDateTimeString(new Date(makeNotesFollowersOnlyBefore * 1000), 'yyyy-MM-dd')"
|
||||
type="date"
|
||||
:manualSave="true"
|
||||
@update:modelValue="makeNotesFollowersOnlyBefore = Math.floor(new Date($event).getTime() / 1000)"
|
||||
>
|
||||
</MkInput>
|
||||
</div>
|
||||
|
||||
<template #caption>
|
||||
<div>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription }}</div>
|
||||
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div>
|
||||
</template>
|
||||
</FormSlot>
|
||||
|
||||
<FormSlot>
|
||||
<template #label>{{ i18n.ts._accountSettings.makeNotesHiddenBefore }}</template>
|
||||
|
||||
<div class="_gaps_s">
|
||||
<MkSelect :modelValue="makeNotesHiddenBefore_type" @update:modelValue="makeNotesHiddenBefore = $event === 'relative' ? -604800 : $event === 'absolute' ? Math.floor(Date.now() / 1000) : null">
|
||||
<option :value="null">{{ i18n.ts.none }}</option>
|
||||
<option value="relative">{{ i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod }}</option>
|
||||
<option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option>
|
||||
</MkSelect>
|
||||
|
||||
<MkSelect v-if="makeNotesHiddenBefore_type === 'relative'" v-model="makeNotesHiddenBefore">
|
||||
<option :value="-3600">{{ i18n.ts.oneHour }}</option>
|
||||
<option :value="-86400">{{ i18n.ts.oneDay }}</option>
|
||||
<option :value="-259200">{{ i18n.ts.threeDays }}</option>
|
||||
<option :value="-604800">{{ i18n.ts.oneWeek }}</option>
|
||||
<option :value="-2592000">{{ i18n.ts.oneMonth }}</option>
|
||||
<option :value="-7776000">{{ i18n.ts.threeMonths }}</option>
|
||||
<option :value="-31104000">{{ i18n.ts.oneYear }}</option>
|
||||
</MkSelect>
|
||||
|
||||
<MkInput
|
||||
v-if="makeNotesHiddenBefore_type === 'absolute'"
|
||||
:modelValue="formatDateTimeString(new Date(makeNotesHiddenBefore * 1000), 'yyyy-MM-dd')"
|
||||
type="date"
|
||||
:manualSave="true"
|
||||
@update:modelValue="makeNotesHiddenBefore = Math.floor(new Date($event).getTime() / 1000)"
|
||||
>
|
||||
</MkInput>
|
||||
</div>
|
||||
|
||||
<template #caption>
|
||||
<div>{{ i18n.ts._accountSettings.makeNotesHiddenBeforeDescription }}</div>
|
||||
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div>
|
||||
</template>
|
||||
</FormSlot>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection>
|
||||
<div class="_gaps_m">
|
||||
<MkSwitch v-model="rememberNoteVisibility" @update:modelValue="save()">{{ i18n.ts.rememberNoteVisibility }}</MkSwitch>
|
||||
@@ -72,7 +159,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import FormSection from '@/components/form/section.vue';
|
||||
@@ -82,6 +169,9 @@ import { defaultStore } from '@/store.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { signinRequired } from '@/account.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import FormSlot from '@/components/form/slot.vue';
|
||||
import { formatDateTimeString } from '@/scripts/format-time-string.js';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
|
||||
const $i = signinRequired();
|
||||
|
||||
@@ -90,6 +180,9 @@ const autoAcceptFollowed = ref($i.autoAcceptFollowed);
|
||||
const noCrawle = ref($i.noCrawle);
|
||||
const preventAiLearning = ref($i.preventAiLearning);
|
||||
const isExplorable = ref($i.isExplorable);
|
||||
const requireSigninToViewContents = ref($i.requireSigninToViewContents ?? false);
|
||||
const makeNotesFollowersOnlyBefore = ref($i.makeNotesFollowersOnlyBefore ?? null);
|
||||
const makeNotesHiddenBefore = ref($i.makeNotesHiddenBefore ?? null);
|
||||
const hideOnlineStatus = ref($i.hideOnlineStatus);
|
||||
const publicReactions = ref($i.publicReactions);
|
||||
const followingVisibility = ref($i.followingVisibility);
|
||||
@@ -100,6 +193,30 @@ const defaultNoteLocalOnly = computed(defaultStore.makeGetterSetter('defaultNote
|
||||
const rememberNoteVisibility = computed(defaultStore.makeGetterSetter('rememberNoteVisibility'));
|
||||
const keepCw = computed(defaultStore.makeGetterSetter('keepCw'));
|
||||
|
||||
const makeNotesFollowersOnlyBefore_type = computed(() => {
|
||||
if (makeNotesFollowersOnlyBefore.value == null) {
|
||||
return null;
|
||||
} else if (makeNotesFollowersOnlyBefore.value >= 0) {
|
||||
return 'absolute';
|
||||
} else {
|
||||
return 'relative';
|
||||
}
|
||||
});
|
||||
|
||||
const makeNotesHiddenBefore_type = computed(() => {
|
||||
if (makeNotesHiddenBefore.value == null) {
|
||||
return null;
|
||||
} else if (makeNotesHiddenBefore.value >= 0) {
|
||||
return 'absolute';
|
||||
} else {
|
||||
return 'relative';
|
||||
}
|
||||
});
|
||||
|
||||
watch([makeNotesFollowersOnlyBefore, makeNotesHiddenBefore], () => {
|
||||
save();
|
||||
});
|
||||
|
||||
function save() {
|
||||
misskeyApi('i/update', {
|
||||
isLocked: !!isLocked.value,
|
||||
@@ -107,6 +224,9 @@ function save() {
|
||||
noCrawle: !!noCrawle.value,
|
||||
preventAiLearning: !!preventAiLearning.value,
|
||||
isExplorable: !!isExplorable.value,
|
||||
requireSigninToViewContents: !!requireSigninToViewContents.value,
|
||||
makeNotesFollowersOnlyBefore: makeNotesFollowersOnlyBefore.value,
|
||||
makeNotesHiddenBefore: makeNotesHiddenBefore.value,
|
||||
hideOnlineStatus: !!hideOnlineStatus.value,
|
||||
publicReactions: !!publicReactions.value,
|
||||
followingVisibility: followingVisibility.value,
|
||||
|
@@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:list="src.split(':')[1]"
|
||||
:withRenotes="withRenotes"
|
||||
:withReplies="withReplies"
|
||||
:withSensitive="withSensitive"
|
||||
:onlyFiles="onlyFiles"
|
||||
:sound="true"
|
||||
@queue="queueUpdated"
|
||||
@@ -121,11 +122,6 @@ watch(src, () => {
|
||||
queue.value = 0;
|
||||
});
|
||||
|
||||
watch(withSensitive, () => {
|
||||
// これだけはクライアント側で完結する処理なので手動でリロード
|
||||
tlComponent.value?.reloadTimeline();
|
||||
});
|
||||
|
||||
function queueUpdated(q: number): void {
|
||||
queue.value = q;
|
||||
}
|
||||
|
@@ -217,7 +217,7 @@ const routes: RouteDef[] = [{
|
||||
component: page(() => import('@/pages/theme-editor.vue')),
|
||||
loginRequired: true,
|
||||
}, {
|
||||
path: '/roles/:role',
|
||||
path: '/roles/:roleId',
|
||||
component: page(() => import('@/pages/role.vue')),
|
||||
}, {
|
||||
path: '/user-tags/:tag',
|
||||
|
18
packages/frontend/src/scripts/get-bg-color.ts
Normal file
18
packages/frontend/src/scripts/get-bg-color.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
export const getBgColor = (elem?: Element | null | undefined): string | null => {
|
||||
if (elem == null) return null;
|
||||
|
||||
const { backgroundColor: bg } = window.getComputedStyle(elem);
|
||||
|
||||
if (bg && tinycolor(bg).getAlpha() !== 0) {
|
||||
return bg;
|
||||
}
|
||||
|
||||
return getBgColor(elem.parentElement);
|
||||
};
|
@@ -44,17 +44,21 @@ export type OpenOnRemoteOptions = {
|
||||
params: Record<string, string>;
|
||||
};
|
||||
|
||||
export function pleaseLogin(path?: string, openOnRemote?: OpenOnRemoteOptions) {
|
||||
export function pleaseLogin(opts: {
|
||||
path?: string;
|
||||
message?: string;
|
||||
openOnRemote?: OpenOnRemoteOptions;
|
||||
} = {}) {
|
||||
if ($i) return;
|
||||
|
||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {
|
||||
autoSet: true,
|
||||
message: openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired,
|
||||
openOnRemote,
|
||||
message: opts.message ?? (opts.openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired),
|
||||
openOnRemote: opts.openOnRemote,
|
||||
}, {
|
||||
cancelled: () => {
|
||||
if (path) {
|
||||
window.location.href = path;
|
||||
if (opts.path) {
|
||||
window.location.href = opts.path;
|
||||
}
|
||||
},
|
||||
closed: () => dispose(),
|
||||
|
@@ -480,7 +480,11 @@ html[data-color-scheme=dark] ._woodenFrame {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
@keyframes global-blink {
|
||||
._blink {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% { opacity: 1; transform: scale(1); }
|
||||
30% { opacity: 1; transform: scale(1); }
|
||||
90% { opacity: 0; transform: scale(0.5); }
|
||||
|
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div v-if="item === '-'" :class="$style.divider"></div>
|
||||
<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" class="_button" :class="[$style.item, { [$style.active]: navbarItemDef[item].active }]" :activeClass="$style.active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
|
||||
<i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[item].icon]"></i><span :class="$style.itemText">{{ navbarItemDef[item].title }}</span>
|
||||
<span v-if="navbarItemDef[item].indicated" :class="$style.itemIndicator">
|
||||
<span v-if="navbarItemDef[item].indicated" :class="$style.itemIndicator" class="_blink">
|
||||
<span v-if="navbarItemDef[item].indicateValue" class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ navbarItemDef[item].indicateValue }}</span>
|
||||
<i v-else class="_indicatorCircle"></i>
|
||||
</span>
|
||||
@@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkA>
|
||||
<button :class="$style.item" class="_button" @click="more">
|
||||
<i :class="$style.itemIcon" class="ti ti-grid-dots ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.more }}</span>
|
||||
<span v-if="otherMenuItemIndicated" :class="$style.itemIndicator"><i class="_indicatorCircle"></i></span>
|
||||
<span v-if="otherMenuItemIndicated" :class="$style.itemIndicator" class="_blink"><i class="_indicatorCircle"></i></span>
|
||||
</button>
|
||||
<MkA :class="$style.item" :activeClass="$style.active" to="/settings">
|
||||
<i :class="$style.itemIcon" class="ti ti-settings ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.settings }}</span>
|
||||
@@ -257,7 +257,6 @@ function more() {
|
||||
left: 20px;
|
||||
color: var(--MI_THEME-navIndicator);
|
||||
font-size: 8px;
|
||||
animation: global-blink 1s infinite;
|
||||
|
||||
&:has(.itemIndicateValueIcon) {
|
||||
animation: none;
|
||||
|
@@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"
|
||||
>
|
||||
<i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[item].icon]"></i><span :class="$style.itemText">{{ navbarItemDef[item].title }}</span>
|
||||
<span v-if="navbarItemDef[item].indicated" :class="$style.itemIndicator">
|
||||
<span v-if="navbarItemDef[item].indicated" :class="$style.itemIndicator" class="_blink">
|
||||
<span v-if="navbarItemDef[item].indicateValue" class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ navbarItemDef[item].indicateValue }}</span>
|
||||
<i v-else class="_indicatorCircle"></i>
|
||||
</span>
|
||||
@@ -41,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkA>
|
||||
<button class="_button" :class="$style.item" @click="more">
|
||||
<i :class="$style.itemIcon" class="ti ti-grid-dots ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.more }}</span>
|
||||
<span v-if="otherMenuItemIndicated" :class="$style.itemIndicator"><i class="_indicatorCircle"></i></span>
|
||||
<span v-if="otherMenuItemIndicated" :class="$style.itemIndicator" class="_blink"><i class="_indicatorCircle"></i></span>
|
||||
</button>
|
||||
<MkA v-tooltip.noDelay.right="i18n.ts.settings" :class="$style.item" :activeClass="$style.active" to="/settings">
|
||||
<i :class="$style.itemIcon" class="ti ti-settings ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.settings }}</span>
|
||||
@@ -350,7 +350,6 @@ function more(ev: MouseEvent) {
|
||||
left: 20px;
|
||||
color: var(--MI_THEME-navIndicator);
|
||||
font-size: 8px;
|
||||
animation: global-blink 1s infinite;
|
||||
|
||||
&:has(.itemIndicateValueIcon) {
|
||||
animation: none;
|
||||
@@ -555,7 +554,6 @@ function more(ev: MouseEvent) {
|
||||
left: 24px;
|
||||
color: var(--MI_THEME-navIndicator);
|
||||
font-size: 8px;
|
||||
animation: global-blink 1s infinite;
|
||||
|
||||
&:has(.itemIndicateValueIcon) {
|
||||
animation: none;
|
||||
|
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div v-if="item === '-'" class="divider"></div>
|
||||
<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime v-tooltip="navbarItemDef[item].title" class="item _button" :class="item" activeClass="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
|
||||
<i class="ti-fw" :class="navbarItemDef[item].icon"></i>
|
||||
<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="_indicatorCircle"></i></span>
|
||||
<span v-if="navbarItemDef[item].indicated" class="indicator _blink"><i class="_indicatorCircle"></i></span>
|
||||
</component>
|
||||
</template>
|
||||
<div class="divider"></div>
|
||||
@@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkA>
|
||||
<button v-click-anime class="item _button" @click="more">
|
||||
<i class="ti ti-dots ti-fw"></i>
|
||||
<span v-if="otherNavItemIndicated" class="indicator"><i class="_indicatorCircle"></i></span>
|
||||
<span v-if="otherNavItemIndicated" class="indicator _blink"><i class="_indicatorCircle"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="right">
|
||||
@@ -142,7 +142,6 @@ onMounted(() => {
|
||||
left: 0;
|
||||
color: var(--MI_THEME-navIndicator);
|
||||
font-size: 8px;
|
||||
animation: global-blink 1s infinite;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
@@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div v-if="item === '-'" class="divider"></div>
|
||||
<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="item" activeClass="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
|
||||
<i class="ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ navbarItemDef[item].title }}</span>
|
||||
<span v-if="navbarItemDef[item].indicated" class="indicator">
|
||||
<span v-if="navbarItemDef[item].indicated" class="indicator _blink">
|
||||
<span v-if="navbarItemDef[item].indicateValue" class="_indicateCounter itemIndicateValueIcon">{{ navbarItemDef[item].indicateValue }}</span>
|
||||
<i v-else class="_indicatorCircle"></i>
|
||||
</span>
|
||||
@@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkA>
|
||||
<button v-click-anime class="item _button" @click="more">
|
||||
<i class="ti ti-dots ti-fw"></i><span class="text">{{ i18n.ts.more }}</span>
|
||||
<span v-if="otherNavItemIndicated" class="indicator"><i class="_indicatorCircle"></i></span>
|
||||
<span v-if="otherNavItemIndicated" class="indicator _blink"><i class="_indicatorCircle"></i></span>
|
||||
</button>
|
||||
<MkA v-click-anime class="item" activeClass="active" to="/settings" :behavior="settingsWindowed ? 'window' : null">
|
||||
<i class="ti ti-settings ti-fw"></i><span class="text">{{ i18n.ts.settings }}</span>
|
||||
@@ -222,7 +222,6 @@ watch(defaultStore.reactiveState.menuDisplay, () => {
|
||||
left: 0;
|
||||
color: var(--MI_THEME-navIndicator);
|
||||
font-size: 8px;
|
||||
animation: global-blink 1s infinite;
|
||||
|
||||
&:has(.itemIndicateValueIcon) {
|
||||
animation: none;
|
||||
|
@@ -50,11 +50,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile" :class="$style.nav">
|
||||
<button :class="$style.navButton" class="_button" @click="drawerMenuShowing = true"><i :class="$style.navButtonIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated" :class="$style.navButtonIndicator"><i class="_indicatorCircle"></i></span></button>
|
||||
<button :class="$style.navButton" class="_button" @click="drawerMenuShowing = true"><i :class="$style.navButtonIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated" :class="$style.navButtonIndicator" class="_blink"><i class="_indicatorCircle"></i></span></button>
|
||||
<button :class="$style.navButton" class="_button" @click="mainRouter.push('/')"><i :class="$style.navButtonIcon" class="ti ti-home"></i></button>
|
||||
<button :class="$style.navButton" class="_button" @click="mainRouter.push('/my/notifications')">
|
||||
<i :class="$style.navButtonIcon" class="ti ti-bell"></i>
|
||||
<span v-if="$i?.hasUnreadNotification" :class="$style.navButtonIndicator">
|
||||
<span v-if="$i?.hasUnreadNotification" :class="$style.navButtonIndicator" class="_blink">
|
||||
<span class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ $i.unreadNotificationsCount > 99 ? '99+' : $i.unreadNotificationsCount }}</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -97,6 +97,7 @@ import { v4 as uuid } from 'uuid';
|
||||
import XCommon from './_common_/common.vue';
|
||||
import { deckStore, columnTypes, addColumn as addColumnToStore, loadDeck, getProfiles, deleteProfile as deleteProfile_ } from './deck/deck-store.js';
|
||||
import type { ColumnType } from './deck/deck-store.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import XSidebar from '@/ui/_common_/navbar.vue';
|
||||
import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
@@ -118,7 +119,6 @@ import XMentionsColumn from '@/ui/deck/mentions-column.vue';
|
||||
import XDirectColumn from '@/ui/deck/direct-column.vue';
|
||||
import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue';
|
||||
import { mainRouter } from '@/router/main.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
|
||||
const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue'));
|
||||
|
||||
@@ -479,7 +479,6 @@ body {
|
||||
left: 0;
|
||||
color: var(--MI_THEME-indicator);
|
||||
font-size: 16px;
|
||||
animation: global-blink 1s infinite;
|
||||
|
||||
&:has(.itemIndicateValueIcon) {
|
||||
animation: none;
|
||||
|
@@ -49,6 +49,7 @@ export type Column = {
|
||||
tl?: BasicTimelineType;
|
||||
withRenotes?: boolean;
|
||||
withReplies?: boolean;
|
||||
withSensitive?: boolean;
|
||||
onlyFiles?: boolean;
|
||||
soundSetting: SoundStore;
|
||||
};
|
||||
|
@@ -24,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:src="column.tl"
|
||||
:withRenotes="withRenotes"
|
||||
:withReplies="withReplies"
|
||||
:withSensitive="withSensitive"
|
||||
:onlyFiles="onlyFiles"
|
||||
@note="onNote"
|
||||
/>
|
||||
@@ -54,6 +55,7 @@ const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
|
||||
const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
|
||||
const withRenotes = ref(props.column.withRenotes ?? true);
|
||||
const withReplies = ref(props.column.withReplies ?? false);
|
||||
const withSensitive = ref(props.column.withSensitive ?? true);
|
||||
const onlyFiles = ref(props.column.onlyFiles ?? false);
|
||||
|
||||
watch(withRenotes, v => {
|
||||
@@ -68,6 +70,12 @@ watch(withReplies, v => {
|
||||
});
|
||||
});
|
||||
|
||||
watch(withSensitive, v => {
|
||||
updateColumn(props.column.id, {
|
||||
withSensitive: v,
|
||||
});
|
||||
});
|
||||
|
||||
watch(onlyFiles, v => {
|
||||
updateColumn(props.column.id, {
|
||||
onlyFiles: v,
|
||||
@@ -144,6 +152,10 @@ const menu = computed<MenuItem[]>(() => {
|
||||
text: i18n.ts.fileAttachedOnly,
|
||||
ref: onlyFiles,
|
||||
disabled: hasWithReplies(props.column.tl) ? withReplies : false,
|
||||
}, {
|
||||
type: 'switch',
|
||||
text: i18n.ts.withSensitive,
|
||||
ref: withSensitive,
|
||||
});
|
||||
|
||||
return menuItems;
|
||||
|
@@ -25,11 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<button v-if="(!isDesktop || pageMetadata?.needWideArea) && !isMobile" :class="$style.widgetButton" class="_button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button>
|
||||
|
||||
<div v-if="isMobile" ref="navFooter" :class="$style.nav">
|
||||
<button :class="$style.navButton" class="_button" @click="drawerMenuShowing = true"><i :class="$style.navButtonIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated" :class="$style.navButtonIndicator"><i class="_indicatorCircle"></i></span></button>
|
||||
<button :class="$style.navButton" class="_button" @click="drawerMenuShowing = true"><i :class="$style.navButtonIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated" :class="$style.navButtonIndicator" class="_blink"><i class="_indicatorCircle"></i></span></button>
|
||||
<button :class="$style.navButton" class="_button" @click="isRoot ? top() : mainRouter.push('/')"><i :class="$style.navButtonIcon" class="ti ti-home"></i></button>
|
||||
<button :class="$style.navButton" class="_button" @click="mainRouter.push('/my/notifications')">
|
||||
<i :class="$style.navButtonIcon" class="ti ti-bell"></i>
|
||||
<span v-if="$i?.hasUnreadNotification" :class="$style.navButtonIndicator">
|
||||
<span v-if="$i?.hasUnreadNotification" :class="$style.navButtonIndicator" class="_blink">
|
||||
<span class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ $i.unreadNotificationsCount > 99 ? '99+' : $i.unreadNotificationsCount }}</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -96,9 +96,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, provide, onMounted, computed, ref, watch, shallowRef, Ref } from 'vue';
|
||||
import { instanceName } from '@@/js/config.js';
|
||||
import { CURRENT_STICKY_BOTTOM } from '@@/js/const.js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
import XCommon from './_common_/common.vue';
|
||||
import type MkStickyContainer from '@/components/global/MkStickyContainer.vue';
|
||||
import { instanceName } from '@@/js/config.js';
|
||||
import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
@@ -108,10 +110,8 @@ import { $i } from '@/account.js';
|
||||
import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
|
||||
import { deviceKind } from '@/scripts/device-kind.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { CURRENT_STICKY_BOTTOM } from '@@/js/const.js';
|
||||
import { useScrollPositionManager } from '@/nirax.js';
|
||||
import { mainRouter } from '@/router/main.js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
|
||||
const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue'));
|
||||
const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue'));
|
||||
@@ -451,7 +451,6 @@ $widgets-hide-threshold: 1090px;
|
||||
left: 0;
|
||||
color: var(--MI_THEME-indicator);
|
||||
font-size: 16px;
|
||||
animation: global-blink 1s infinite;
|
||||
|
||||
&:has(.itemIndicateValueIcon) {
|
||||
animation: none;
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user