Merge remote-tracking branch 'msky/develop' into feat-mijs-expose-error-types

This commit is contained in:
kakkokari-gtyih 2024-10-16 18:33:38 +09:00
commit 8e4914ddb7
95 changed files with 4109 additions and 299 deletions

2
.github/labeler.yml vendored
View File

@ -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
View 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
View File

@ -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

View File

@ -1,18 +1,35 @@
## 2024.10.1
### Note
- 悪質なユーザからサーバを守る措置の一環として、モデレータ権限を持つユーザの最終アクティブ日時を確認し、
7日間活動していない場合は自動的に招待制へと移行コントロールパネル -> モデレーション -> "誰でも新規登録できるようにする"をオフに変更)するようになりました。
詳細な経緯は https://github.com/misskey-dev/misskey/issues/13437 をご確認ください。
## Unreleased
### General
-
### Client
-
### Server
-
## 2024.10.1
### Note
- スパム対策として、モデレータ権限を持つユーザのアクティビティが7日以上確認できない場合は自動的に招待制へと切り替えコントロールパネル -> モデレーション -> "誰でも新規登録できるようにする"をオフに変更)るようになりました。 ( #13437 )
- 切り替わった際はモデレーターへお知らせとして通知されます。登録をオープンな状態で継続したい場合は、コントロールパネルから再度設定を行ってください。
### General
- Feat: ユーザーの名前に禁止ワードを設定できるように
### Client
- Enhance: タイムライン表示時のパフォーマンスを向上
- Enhance: アーカイブした個人宛のお知らせを表示・編集できるように
- Enhance: l10nの更新
- Fix: メールアドレス不要でCaptchaが有効な場合にアカウント登録完了後自動でのログインに失敗する問題を修正
### Server
- Feat: モデレータ権限を持つユーザが全員7日間活動しなかった場合は自動的に招待制へと移行するように ( #13437 )
- Feat: モデレータ権限を持つユーザが全員7日間活動しなかった場合は自動的に招待制へと切り替えるように ( #13437 )
- Enhance: 個人宛のお知らせは「わかった」を押すと自動的にアーカイブされるように
- Fix: `admin/emoji/update`エンドポイントのidのみ指定した時不正なエラーが発生するバグを修正
### Server
- Fix: RBT有効時、リートのリアクションが反映されない問題を修正
- Fix: キューのエラーログを簡略化するように
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/649)

View File

@ -181,31 +181,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

View File

@ -1286,6 +1286,7 @@ passkeyVerificationFailed: "La verificació a fallat"
passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verificació de la passkey a estat correcta, però s'ha deshabilitat l'inici de sessió sense contrasenya."
messageToFollower: "Missatge als meus seguidors"
target: "Assumpte "
testCaptchaWarning: "És una característica dissenyada per a la prova de CAPTCHA. <strong>No l'utilitzes en l'entorn real.</strong>"
_abuseUserReport:
forward: "Reenviar "
forwardDescription: "Reenvia l'informe a una altra instància com un compte del sistema anònima."
@ -1430,6 +1431,7 @@ _serverSettings:
reactionsBufferingDescription: "Quan s'activa aquesta opció millora bastant el rendiment en recuperar les línies de temps reduint la càrrega de la base. Com a contrapunt, augmentarà l'ús de memòria de Redís. Desactiva aquesta opció en cas de tenir un servidor amb poca memòria o si tens problemes d'inestabilitat."
inquiryUrl: "URL de consulta "
inquiryUrlDescription: "Escriu adreça URL per al formulari de consulta per al mantenidor del servidor o una pàgina web amb el contacte d'informació."
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Si no es detecta activitat per part del moderador durant un període de temps, aquesta opció es desactiva automàticament per evitar el correu brossa."
_accountMigration:
moveFrom: "Migrar un altre compte a aquest"
moveFromSub: "Crear un àlies per un altre compte"

28
locales/index.d.ts vendored
View File

@ -4366,6 +4366,10 @@ export interface Locale extends ILocale {
*
*/
"enableChartsForFederatedInstances": string;
/**
*
*/
"enableStatsForFederatedInstances": string;
/**
*
*/
@ -5170,6 +5174,22 @@ export interface Locale extends ILocale {
* CAPTCHAのテストを目的とした機能です<strong>使</strong>
*/
"testCaptchaWarning": string;
/**
*
*/
"prohibitedWordsForNameOfUser": string;
/**
*
*/
"prohibitedWordsForNameOfUserDescription": string;
/**
*
*/
"yourNameContainsProhibitedWords": string;
/**
* 使
*/
"yourNameContainsProhibitedWordsDescription": string;
"_abuseUserReport": {
/**
*
@ -9641,6 +9661,14 @@ export interface Locale extends ILocale {
*
*/
"userCreated": string;
/**
*
*/
"inactiveModeratorsWarning": string;
/**
*
*/
"inactiveModeratorsInvitationOnlyChanged": string;
};
/**
* Webhookを削除しますか

View File

@ -454,6 +454,7 @@ totpDescription: "Puoi autenticarti inserendo un codice OTP tramite la tua App d
moderator: "Moderatore"
moderation: "moderazione"
moderationNote: "Promemoria di moderazione"
moderationNoteDescription: "Puoi scrivere promemoria condivisi solo tra moderatori."
addModerationNote: "Aggiungi promemoria di moderazione"
moderationLogs: "Cronologia di moderazione"
nUsersMentioned: "{n} profili ne parlano"
@ -841,7 +842,7 @@ onlineStatus: "Stato di connessione"
hideOnlineStatus: "Modalità invisibile"
hideOnlineStatusDescription: "Attivando questa opzione potresti ridurre l'usabilità di alcune funzioni, come la ricerca."
online: "Online"
active: "Attività"
active: "Attivo"
offline: "Offline"
notRecommended: "Sconsigliato"
botProtection: "Protezione contro i bot"
@ -1086,6 +1087,7 @@ retryAllQueuesConfirmTitle: "Vuoi ritentare adesso?"
retryAllQueuesConfirmText: "Potrebbe sovraccaricare il server temporaneamente."
enableChartsForRemoteUser: "Abilita i grafici per i profili remoti"
enableChartsForFederatedInstances: "Abilita i grafici per le istanze federate"
enableStatsForFederatedInstances: "Informazioni statistiche sui server federati"
showClipButtonInNoteFooter: "Aggiungi il bottone Clip tra le azioni delle Note"
reactionsDisplaySize: "Grandezza delle reazioni"
limitWidthOfReaction: "Limita la larghezza delle reazioni e ridimensionale"
@ -1285,6 +1287,19 @@ unknownWebAuthnKey: "Questa è una passkey sconosciuta."
passkeyVerificationFailed: "La verifica della passkey non è riuscita."
passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verifica della passkey è riuscita, ma l'accesso senza password è disabilitato."
messageToFollower: "Messaggio ai follower"
target: "Riferimento"
testCaptchaWarning: "Questa funzione è destinata al test CAPTCHA. <strong>Da non utilizzare in ambiente di produzione.</strong>"
prohibitedWordsForNameOfUser: "Parole proibite (nome utente)"
prohibitedWordsForNameOfUserDescription: "Il sistema rifiuta di rinominare un utente, se il nome contiene qualsiasi parola nell'elenco. Sono esenti i profili con privilegi di moderazione."
yourNameContainsProhibitedWords: "Il nome che hai scelto contiene una o più parole vietate"
yourNameContainsProhibitedWordsDescription: "Se desideri comunque utilizzare questo nome, contatta l''amministrazione."
_abuseUserReport:
forward: "Inoltra"
forwardDescription: "Inoltra il report al server remoto, per mezzo di account di sistema, anonimo."
resolve: "Risolvi"
accept: "Approva"
reject: "Rifiuta"
resolveTutorial: "Se moderi una segnalazione legittima, scegli \"Approva\" per risolvere positivamente.\nSe la segnalazione non è legittima, seleziona \"Rifiuta\" per risolvere negativamente."
_delivery:
status: "Stato della consegna"
stop: "Sospensione"
@ -1312,16 +1327,16 @@ _bubbleGame:
_announcement:
forExistingUsers: "Solo ai profili attuali"
forExistingUsersDescription: "L'annuncio sarà visibile solo ai profili esistenti in questo momento. Se disabilitato, sarà visibile anche ai profili che verranno creati dopo la pubblicazione di questo annuncio."
needConfirmationToRead: "Richiede la conferma di lettura"
needConfirmationToReadDescription: "Sarà visualizzata una finestra di dialogo che richiede la conferma di lettura. Inoltre, non è soggetto a conferme di lettura massicce."
needConfirmationToRead: "Conferma di lettura obbligatoria"
needConfirmationToReadDescription: "I profili riceveranno una finestra di dialogo che richiede di accettare obbligatoriamente per procedere. Tale richiesta è esente da \"conferma tutte\"."
end: "Archivia l'annuncio"
tooManyActiveAnnouncementDescription: "L'esperienza delle persone può peggiorare se ci sono troppi annunci attivi. Considera anche l'archiviazione degli annunci conclusi."
readConfirmTitle: "Segnare come già letto?"
readConfirmText: "Hai già letto \"{title}˝?"
shouldNotBeUsedToPresentPermanentInfo: "Ti consigliamo di utilizzare gli annunci per pubblicare informazioni tempestive e limitate nel tempo, anziché informazioni importanti a lungo andare nel tempo, poiché potrebbero risultare difficili da ritrovare e peggiorare la fruibilità del servizio, specialmente alle nuove persone iscritte."
dialogAnnouncementUxWarn: "Ti consigliamo di usarli con cautela, poiché è molto probabile che avere più di un annuncio in stile \"finestra di dialogo\" peggiori sensibilmente la fruibilità del servizio, specialmente alle nuove persone iscritte."
silence: "Silenziare gli annunci"
silenceDescription: "Se attivi questa opzione, non riceverai notifiche sugli annunci, evitando di contrassegnarle come già lette."
silence: "Annuncio silenzioso"
silenceDescription: "Attivando questa opzione, non invierai la notifica, evitando che debba essere contrassegnata come già letta."
_initialAccountSetting:
accountCreated: "Il tuo profilo è stato creato!"
letsStartAccountSetup: "Per iniziare, impostiamo il tuo profilo."
@ -1422,6 +1437,7 @@ _serverSettings:
reactionsBufferingDescription: "Attivando questa opzione, puoi migliorare significativamente le prestazioni durante la creazione delle reazioni e ridurre il carico sul database. Tuttavia, aumenterà l'impiego di memoria Redis."
inquiryUrl: "URL di contatto"
inquiryUrlDescription: "Specificare l'URL al modulo di contatto, oppure le informazioni con i dati di contatto dell'amministrazione."
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Per prevenire SPAM, questa impostazione verrà disattivata automaticamente, se non si rileva alcuna attività di moderazione durante un certo periodo di tempo."
_accountMigration:
moveFrom: "Migra un altro profilo dentro a questo"
moveFromSub: "Crea un alias verso un altro profilo remoto"
@ -2187,7 +2203,7 @@ _widgets:
_userList:
chooseList: "Seleziona una lista"
clicker: "Cliccaggio"
birthdayFollowings: "Chi nacque oggi"
birthdayFollowings: "Compleanni del giorno"
_cw:
hide: "Nascondere"
show: "Continua la lettura..."
@ -2476,6 +2492,8 @@ _webhookSettings:
abuseReport: "Quando arriva una segnalazione"
abuseReportResolved: "Quando una segnalazione è risolta"
userCreated: "Quando viene creato un profilo"
inactiveModeratorsWarning: "Quando un profilo moderatore rimane inattivo per un determinato periodo"
inactiveModeratorsInvitationOnlyChanged: "Quando la moderazione è rimasta inattiva per un determinato periodo e il sistema è cambiato in modalità \"solo inviti\""
deleteConfirm: "Vuoi davvero eliminare il Webhook?"
testRemarks: "Clicca il bottone a destra dell'interruttore, per provare l'invio di un webhook con dati fittizi."
_abuseReport:
@ -2521,6 +2539,8 @@ _moderationLogTypes:
markSensitiveDriveFile: "File nel Drive segnato come esplicito"
unmarkSensitiveDriveFile: "File nel Drive segnato come non esplicito"
resolveAbuseReport: "Segnalazione risolta"
forwardAbuseReport: "Segnalazione inoltrata"
updateAbuseReportNote: "Ha aggiornato la segnalazione"
createInvitation: "Genera codice di invito"
createAd: "Banner creato"
deleteAd: "Banner eliminato"

View File

@ -1087,6 +1087,7 @@ retryAllQueuesConfirmTitle: "今すぐ再試行しますか?"
retryAllQueuesConfirmText: "一時的にサーバーの負荷が増大することがあります。"
enableChartsForRemoteUser: "リモートユーザーのチャートを生成"
enableChartsForFederatedInstances: "リモートサーバーのチャートを生成"
enableStatsForFederatedInstances: "リモートサーバーの情報を取得"
showClipButtonInNoteFooter: "ノートのアクションにクリップを追加"
reactionsDisplaySize: "リアクションの表示サイズ"
limitWidthOfReaction: "リアクションの最大横幅を制限し、縮小して表示する"
@ -1288,6 +1289,10 @@ passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証
messageToFollower: "フォロワーへのメッセージ"
target: "対象"
testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。<strong>本番環境で使用しないでください。</strong>"
prohibitedWordsForNameOfUser: "禁止ワード(ユーザーの名前)"
prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。"
yourNameContainsProhibitedWords: "変更しようとした名前に禁止された文字列が含まれています"
yourNameContainsProhibitedWordsDescription: "名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。"
_abuseUserReport:
forward: "転送"
@ -2554,6 +2559,8 @@ _webhookSettings:
abuseReport: "ユーザーから通報があったとき"
abuseReportResolved: "ユーザーからの通報を処理したとき"
userCreated: "ユーザーが作成されたとき"
inactiveModeratorsWarning: "モデレーターが一定期間非アクティブになったとき"
inactiveModeratorsInvitationOnlyChanged: "モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき"
deleteConfirm: "Webhookを削除しますか"
testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。"

View File

@ -1087,6 +1087,7 @@ retryAllQueuesConfirmTitle: "지금 다시 시도하시겠습니까?"
retryAllQueuesConfirmText: "일시적으로 서버의 부하가 증가할 수 있습니다."
enableChartsForRemoteUser: "리모트 유저의 차트를 생성"
enableChartsForFederatedInstances: "리모트 서버의 차트를 생성"
enableStatsForFederatedInstances: "리모트 서버 정보 받아오기"
showClipButtonInNoteFooter: "노트 동작에 클립을 추가"
reactionsDisplaySize: "리액션 표시 크기"
limitWidthOfReaction: "리액션의 최대 폭을 제한하고 작게 표시하기"
@ -1287,6 +1288,11 @@ passkeyVerificationFailed: "패스키 검증을 실패했습니다."
passkeyVerificationSucceededButPasswordlessLoginDisabled: "패스키를 검증했으나, 비밀번호 없이 로그인하기가 꺼져 있습니다."
messageToFollower: "팔로워에 보낼 메시지"
target: "대상"
testCaptchaWarning: "CAPTCHA를 테스트하기 위한 기능입니다. <strong>실제 환경에서는 사용하지 마세요.</strong>"
prohibitedWordsForNameOfUser: "금지 단어 (사용자 이름)"
prohibitedWordsForNameOfUserDescription: "이 목록에 포함되는 키워드가 사용자 이름에 있는 경우, 일반 사용자는 이름을 바꿀 수 없습니다. 모더레이터 권한을 가진 사용자는 제한 대상에서 제외됩니다."
yourNameContainsProhibitedWords: "바꾸려는 이름에 금지된 키워드가 포함되어 있습니다."
yourNameContainsProhibitedWordsDescription: "이름에 금지된 키워드가 있습니다. 이름을 사용해야 하는 경우, 서버 관리자에 문의하세요."
_abuseUserReport:
forward: "전달"
forwardDescription: "익명 시스템 계정을 사용하여 리모트 서버에 신고 내용을 전달할 수 있습니다."
@ -1431,6 +1437,7 @@ _serverSettings:
reactionsBufferingDescription: "활성화 한 경우, 리액션 작성 퍼포먼스가 대폭 향상되어 DB의 부하를 줄일 수 있으나, Redis의 메모리 사용량이 많아집니다."
inquiryUrl: "문의처 URL"
inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정합니다."
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터의 활동이 감지되지 않는 경우, 스팸 방지를 위해 이 설정은 자동으로 꺼집니다."
_accountMigration:
moveFrom: "다른 계정에서 이 계정으로 이사"
moveFromSub: "다른 계정에 대한 별칭을 생성"
@ -2485,6 +2492,8 @@ _webhookSettings:
abuseReport: "유저롭"
abuseReportResolved: "받은 신고를 처리했을 때"
userCreated: "유저가 생성되었을 때"
inactiveModeratorsWarning: "모더레이터가 일정 기간동안 활동하지 않은 경우"
inactiveModeratorsInvitationOnlyChanged: "모더레이터가 일정 기간 활동하지 않아 시스템에 의해 초대제로 바뀐 경우"
deleteConfirm: "Webhook을 삭제할까요?"
testRemarks: "스위치 오른쪽에 있는 버튼을 클릭하여 더미 데이터를 사용한 테스트용 웹 훅을 보낼 수 있습니다."
_abuseReport:

View File

@ -1087,6 +1087,7 @@ retryAllQueuesConfirmTitle: "要再尝试一次吗?"
retryAllQueuesConfirmText: "可能会使服务器负荷在一定时间内增加"
enableChartsForRemoteUser: "生成远程用户的图表"
enableChartsForFederatedInstances: "生成远程服务器的图表"
enableStatsForFederatedInstances: "获取远程服务器的信息"
showClipButtonInNoteFooter: "在贴文下方显示便签按钮"
reactionsDisplaySize: "回应显示大小"
limitWidthOfReaction: "限制回应的最大宽度,并将其缩小显示"
@ -1287,6 +1288,11 @@ passkeyVerificationFailed: "验证通行密钥失败。"
passkeyVerificationSucceededButPasswordlessLoginDisabled: "通行密钥验证成功,但账户未开启无密码登录。"
messageToFollower: "给关注者的消息"
target: "对象"
testCaptchaWarning: "此功能为测试 CAPTCHA 用。<strong>请勿在正式环境中使用。</strong>"
prohibitedWordsForNameOfUser: "用户名中禁止的词"
prohibitedWordsForNameOfUserDescription: "更改用户名时,如果用户名中包含此列表里的词汇,用户的改名请求将被拒绝。持有管理员权限的用户不受此限制。"
yourNameContainsProhibitedWords: "目标用户名包含违禁词"
yourNameContainsProhibitedWordsDescription: "用户名内含有违禁词。若想使用此用户名,请联系服务器管理员。"
_abuseUserReport:
forward: "转发"
forwardDescription: "目标是匿名系统账户,将把举报转发给远程服务器。"
@ -1431,6 +1437,7 @@ _serverSettings:
reactionsBufferingDescription: "开启时可显著提高发送回应时的性能,及减轻数据库负荷。但 Redis 的内存用量会相应增加。"
inquiryUrl: "联络地址"
inquiryUrlDescription: "用来指定诸如向服务运营商咨询的论坛地址,或记载了运营商联系方式之类的网页地址。"
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "若在一段时间内没有检测到管理活动,为防止垃圾信息,此设定将自动关闭。"
_accountMigration:
moveFrom: "从别的账号迁移到此账户"
moveFromSub: "为另一个账户建立别名"
@ -2262,7 +2269,7 @@ _profile:
avatarDecorationMax: "最多可添加 {max} 个挂件"
followedMessage: "被关注时显示的消息"
followedMessageDescription: "可以设置被关注时向对方显示的短消息。"
followedMessageDescriptionForLockedAccount: "需要批准才能关注的情况下,消息是在请求被批准后显示。"
followedMessageDescriptionForLockedAccount: "需要批准才能关注的情况下,消息是在请求被批准后显示。"
_exportOrImport:
allNotes: "所有帖子"
favoritedNotes: "收藏的帖子"
@ -2485,6 +2492,8 @@ _webhookSettings:
abuseReport: "当收到举报时"
abuseReportResolved: "当举报被处理时"
userCreated: "当用户被创建时"
inactiveModeratorsWarning: "当管理员在一段时间内不活跃时"
inactiveModeratorsInvitationOnlyChanged: "当因为管理员在一段时间内不活跃,导致服务器变为邀请制时"
deleteConfirm: "要删除 webhook 吗?"
testRemarks: "点击开关右侧的按钮,可以发送使用假数据的测试 Webhook。"
_abuseReport:

View File

@ -454,6 +454,7 @@ totpDescription: "以驗證應用程式輸入一次性密碼"
moderator: "審查員"
moderation: "審查"
moderationNote: "管理筆記"
moderationNoteDescription: "您可以編寫僅在審查員之間共用的註解。"
addModerationNote: "新增管理筆記"
moderationLogs: "管理日誌"
nUsersMentioned: "被 {n} 個人提及"
@ -519,7 +520,7 @@ menuStyle: "選單風格"
style: "風格"
drawer: "側邊欄"
popup: "彈出式視窗"
showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的操作選項"
showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的"
showReactionsCount: "顯示貼文的反應數目"
noHistory: "沒有歷史紀錄"
signinHistory: "登入歷史"
@ -1018,7 +1019,7 @@ show: "檢視"
neverShow: "不再顯示"
remindMeLater: "以後再說"
didYouLikeMisskey: "您喜歡 Misskey 嗎?"
pleaseDonate: "Misskey 是由 {host} 使用的免費軟體。請贊助我們,讓開發得以持續!"
pleaseDonate: "Misskey是由{host}使用的免費軟體。請贊助我們,讓開發的工作能夠持續!"
correspondingSourceIsAvailable: "對應的原始碼可以在 {anchor} 處找到。"
roles: "角色"
role: "角色"
@ -1086,6 +1087,7 @@ retryAllQueuesConfirmTitle: "要現在重試嗎?"
retryAllQueuesConfirmText: "伺服器的負荷可能會暫時增加。"
enableChartsForRemoteUser: "生成遠端使用者的圖表"
enableChartsForFederatedInstances: "生成遠端伺服器的圖表"
enableStatsForFederatedInstances: "取得遠端伺服器資訊"
showClipButtonInNoteFooter: "新增摘錄按鈕至貼文"
reactionsDisplaySize: "反應的顯示尺寸"
limitWidthOfReaction: "限制反應的最大寬度,並縮小顯示尺寸。"
@ -1194,7 +1196,7 @@ showRenotes: "顯示其他人的轉發貼文"
edited: "已編輯"
notificationRecieveConfig: "接受通知的設定"
mutualFollow: "互相追隨"
followingOrFollower: "追隨中或追隨者"
followingOrFollower: "追隨中或追隨者"
fileAttachedOnly: "只顯示包含附件的貼文"
showRepliesToOthersInTimeline: "顯示給其他人的回覆"
hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆"
@ -1265,7 +1267,7 @@ useNativeUIForVideoAudioPlayer: "使用瀏覽器的 UI 播放影片與音訊"
keepOriginalFilename: "保留原始檔名"
keepOriginalFilenameDescription: "如果關閉此設置,上傳時檔案名稱會自動替換為隨機字串。"
noDescription: "沒有說明文字"
alwaysConfirmFollow: "點擊追隨時總是顯示確認訊息"
alwaysConfirmFollow: "跟隨時總是確認"
inquiry: "聯絡我們"
tryAgain: "請再試一次。"
confirmWhenRevealingSensitiveMedia: "要顯示敏感媒體時需確認"
@ -1285,6 +1287,19 @@ unknownWebAuthnKey: "未註冊的金鑰。"
passkeyVerificationFailed: "驗證金鑰失敗。"
passkeyVerificationSucceededButPasswordlessLoginDisabled: "雖然驗證金鑰成功,但是無密碼登入的方式是停用的。"
messageToFollower: "給追隨者的訊息"
target: "目標 "
testCaptchaWarning: "此功能用於 CAPTCHA 的測試。<strong>請勿在正式環境中使用。</strong>"
prohibitedWordsForNameOfUser: "禁止使用的字詞(使用者名稱)"
prohibitedWordsForNameOfUserDescription: "如果使用者名稱包含此清單中的任何字串,則拒絕重新命名使用者。 具有審查員權限的使用者不受此限制的影響。"
yourNameContainsProhibitedWords: "您嘗試更改的名稱包含禁止的字串"
yourNameContainsProhibitedWordsDescription: "名稱中包含禁止使用的字串。 如果您想使用此名稱,請聯絡您的伺服器管理員。"
_abuseUserReport:
forward: "轉發"
forwardDescription: "以匿名系統帳戶將檢舉轉發至遠端伺服器。"
resolve: "解決"
accept: "接受"
reject: "拒絕"
resolveTutorial: "如果您已回覆正當的檢舉,請選擇「接受」以將案件標記為已解決。\n 如果檢舉的內容不正當,請選擇「拒絕」將案件標記為已解決。"
_delivery:
status: "傳送狀態"
stop: "停止發送"
@ -1422,6 +1437,7 @@ _serverSettings:
reactionsBufferingDescription: "啟用時,可以顯著提高建立反應時的效能並減少資料庫的負載。 但是Redis 記憶體使用量會增加。"
inquiryUrl: "聯絡表單網址"
inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址,或包含運營者聯絡資訊網頁的網址。"
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "為了防止 spam如果一段期間內沒有偵測到審查員的活動此設定將自動關閉。"
_accountMigration:
moveFrom: "從其他帳戶遷移到這個帳戶"
moveFromSub: "為另一個帳戶建立別名"
@ -1435,7 +1451,7 @@ _accountMigration:
startMigration: "遷移"
migrationConfirm: "確定要將這個帳戶遷移至 {account} 嗎?一旦遷移就無法撤銷,也就無法以原來的狀態使用這個帳戶。\n另外請確認在要遷移到的帳戶已經建立了一個別名。"
movedAndCannotBeUndone: "帳戶已遷移。\n遷移無法撤消。"
postMigrationNote: "在完成遷移的 24 小時後解除此帳戶的追隨。此帳戶的追隨中、追隨者數量變為 0。由於不會解除追隨者你的追隨者仍然可以繼續檢視這個帳戶發布給追隨者的貼文。"
postMigrationNote: "取消追蹤此帳戶將在遷移操作後 24 小時執行。\n 此帳戶有 0 個關注者/關注者。 您的關注者仍然可以看到此帳戶的關注者帖子,因為您不會被取消關注。"
movedTo: "要遷移到的帳戶:"
_achievements:
earnedAt: "獲得日期"
@ -1555,7 +1571,7 @@ _achievements:
_markedAsCat:
title: "我是貓"
description: "已將帳戶設定為貓"
flavor: "沒有名字。"
flavor: "沒有名字。"
_following1:
title: "首次追隨"
description: "首次追隨了"
@ -1569,7 +1585,7 @@ _achievements:
title: "一百位朋友"
description: "追隨超過100人了"
_following300:
title: "朋友多"
title: "朋友多"
description: "追隨超過300人了"
_followers1:
title: "第一個追隨者"
@ -1895,7 +1911,7 @@ _channel:
following: "追隨中"
usersCount: "有 {n} 人參與"
notesCount: "有 {n} 篇貼文"
nameAndDescription: "名稱與說明"
nameAndDescription: "名稱"
nameOnly: "僅名稱"
allowRenoteToExternal: "允許在頻道外轉發和引用"
_menuDisplay:
@ -2476,6 +2492,8 @@ _webhookSettings:
abuseReport: "當使用者檢舉時"
abuseReportResolved: "當處理了使用者的檢舉時"
userCreated: "使用者被新增時"
inactiveModeratorsWarning: "當審查員在一段時間內沒有活動時"
inactiveModeratorsInvitationOnlyChanged: "當審查員在一段時間內不活動時,系統會將模式變更為邀請制"
deleteConfirm: "請問是否要刪除 Webhook"
testRemarks: "按下切換開關右側的按鈕,就會將假資料發送至 Webhook。"
_abuseReport:
@ -2490,7 +2508,7 @@ _abuseReport:
mail: "寄送到擁有監察員權限的使用者電子郵件地址(僅在收到檢舉時)"
webhook: "向指定的 SystemWebhook 發送通知(在收到檢舉和解決檢舉時發送)"
keywords: "關鍵字"
notifiedUser: "通知的使用者"
notifiedUser: "通知的使用者"
notifiedWebhook: "使用的 Webhook"
deleteConfirm: "確定要刪除通知對象嗎?"
_moderationLogTypes:
@ -2521,6 +2539,8 @@ _moderationLogTypes:
markSensitiveDriveFile: "標記為敏感檔案"
unmarkSensitiveDriveFile: "撤銷標記為敏感檔案"
resolveAbuseReport: "解決檢舉"
forwardAbuseReport: "轉發檢舉"
updateAbuseReportNote: "更新檢舉的審查備註"
createInvitation: "建立邀請碼"
createAd: "建立廣告"
deleteAd: "刪除廣告"

View File

@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2024.10.1-beta.3",
"version": "2024.10.1",
"codename": "nasubi",
"repository": {
"type": "git",

View File

@ -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,
},

View 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',
],
};

View File

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class EnableStatsForFederatedInstances1727318020265 {
name = 'EnableStatsForFederatedInstances1727318020265'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "enableStatsForFederatedInstances" boolean NOT NULL DEFAULT true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableStatsForFederatedInstances"`);
}
}

View File

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class ProhibitedWordsForNameOfUser1728634286056 {
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "prohibitedWordsForNameOfUser" character varying(1024) array NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "prohibitedWordsForNameOfUser"`);
}
}

View File

@ -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"

View File

@ -288,8 +288,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
.log(updater, 'createAbuseReportNotificationRecipient', {
recipientId: id,
recipient: created,
})
.then();
});
return created;
}
@ -327,8 +326,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
recipientId: params.id,
before: beforeEntity,
after: afterEntity,
})
.then();
});
return afterEntity;
}
@ -349,8 +347,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
.log(updater, 'deleteAbuseReportNotificationRecipient', {
recipientId: id,
recipient: entity,
})
.then();
});
}
/**

View File

@ -110,8 +110,7 @@ export class AbuseReportService {
reportId: report.id,
report: report,
resolvedAs: ps.resolvedAs,
})
.then();
});
}
return this.abuseUserReportsRepository.findBy({ id: In(reports.map(it => it.id)) })
@ -148,8 +147,7 @@ export class AbuseReportService {
.log(moderator, 'forwardAbuseReport', {
reportId: report.id,
report: report,
})
.then();
});
}
@bindThis

View File

@ -274,13 +274,15 @@ export class AccountMoveService {
}
// Update instance stats by decreasing remote followers count by the number of local followers who were following the old account.
if (this.userEntityService.isRemoteUser(oldAccount)) {
this.federatedInstanceService.fetch(oldAccount.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false);
}
});
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(oldAccount)) {
this.federatedInstanceService.fetchOrRegister(oldAccount.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false);
}
});
}
}
// FIXME: expensive?

View File

@ -209,6 +209,13 @@ export class AnnouncementService {
return;
}
const announcement = await this.announcementsRepository.findOneBy({ id: announcementId });
if (announcement != null && announcement.userId === user.id) {
await this.announcementsRepository.update(announcementId, {
isActive: false,
});
}
if ((await this.getUnreadAnnouncements(user)).length === 0) {
this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements');
}

View File

@ -47,7 +47,7 @@ export class FederatedInstanceService implements OnApplicationShutdown {
}
@bindThis
public async fetch(host: string): Promise<MiInstance> {
public async fetchOrRegister(host: string): Promise<MiInstance> {
host = this.utilityService.toPuny(host);
const cached = await this.federatedInstanceCache.get(host);
@ -70,6 +70,24 @@ export class FederatedInstanceService implements OnApplicationShutdown {
}
}
@bindThis
public async fetch(host: string): Promise<MiInstance | null> {
host = this.utilityService.toPuny(host);
const cached = await this.federatedInstanceCache.get(host);
if (cached !== undefined) return cached;
const index = await this.instancesRepository.findOneBy({ host });
if (index == null) {
this.federatedInstanceCache.set(host, null);
return null;
} else {
this.federatedInstanceCache.set(host, index);
return index;
}
}
@bindThis
public async update(id: MiInstance['id'], data: Partial<MiInstance>): Promise<void> {
const result = await this.instancesRepository.createQueryBuilder().update()

View File

@ -82,7 +82,7 @@ export class FetchInstanceMetadataService {
try {
if (!force) {
const _instance = await this.federatedInstanceService.fetch(host);
const _instance = await this.federatedInstanceService.fetchOrRegister(host);
const now = Date.now();
if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) {
// unlock at the finally caluse

View File

@ -511,13 +511,15 @@ export class NoteCreateService implements OnApplicationShutdown {
}
// Register host
if (this.userEntityService.isRemoteUser(user)) {
this.federatedInstanceService.fetch(user.host).then(async i => {
this.updateNotesCountQueue.enqueue(i.id, 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, true);
}
});
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(user)) {
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
this.updateNotesCountQueue.enqueue(i.id, 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, true);
}
});
}
}
// ハッシュタグ更新

View File

@ -106,13 +106,15 @@ export class NoteDeleteService {
this.perUserNotesChart.update(user, note, false);
}
if (this.userEntityService.isRemoteUser(user)) {
this.federatedInstanceService.fetch(user.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, false);
}
});
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(user)) {
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, false);
}
});
}
}
}

View File

@ -150,8 +150,8 @@ export class SignupService {
}));
});
this.usersChart.update(account, true).then();
this.userService.notifySystemWebhook(account, 'userCreated').then();
this.usersChart.update(account, true);
this.userService.notifySystemWebhook(account, 'userCreated');
return { account, secret };
}

View File

@ -101,8 +101,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
.log(updater, 'createSystemWebhook', {
systemWebhookId: webhook.id,
webhook: webhook,
})
.then();
});
return webhook;
}
@ -139,8 +138,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
systemWebhookId: beforeEntity.id,
before: beforeEntity,
after: afterEntity,
})
.then();
});
return afterEntity;
}
@ -158,8 +156,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
.log(updater, 'deleteSystemWebhook', {
systemWebhookId: webhook.id,
webhook,
})
.then();
});
}
/**

View File

@ -305,20 +305,22 @@ export class UserFollowingService implements OnModuleInit {
//#endregion
//#region Update instance stats
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetch(follower.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, true);
}
});
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.fetch(followee.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, true);
}
});
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, true);
}
});
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, true);
}
});
}
}
//#endregion
@ -437,20 +439,22 @@ export class UserFollowingService implements OnModuleInit {
//#endregion
//#region Update instance stats
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetch(follower.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, false);
}
});
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.fetch(followee.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false);
}
});
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, false);
}
});
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false);
}
});
}
}
//#endregion

View File

@ -12,6 +12,7 @@ import { Packed } from '@/misc/json-schema.js';
import { type WebhookEventTypes } from '@/models/Webhook.js';
import { UserWebhookService } from '@/core/UserWebhookService.js';
import { QueueService } from '@/core/QueueService.js';
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
const oneDayMillis = 24 * 60 * 60 * 1000;
@ -446,6 +447,22 @@ export class WebhookTestService {
send(toPackedUserLite(dummyUser1));
break;
}
case 'inactiveModeratorsWarning': {
const dummyTime: ModeratorInactivityRemainingTime = {
time: 100000,
asDays: 1,
asHours: 24,
};
send({
remainingTime: dummyTime,
});
break;
}
case 'inactiveModeratorsInvitationOnlyChanged': {
send({});
break;
}
}
}
}

View File

@ -408,13 +408,15 @@ export class ApPersonService implements OnModuleInit {
this.cacheService.uriPersonCache.set(user.uri, user);
// Register host
this.federatedInstanceService.fetch(host).then(i => {
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.newUser(i.host);
}
});
if (this.meta.enableStatsForFederatedInstances) {
this.federatedInstanceService.fetchOrRegister(host).then(i => {
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.newUser(i.host);
}
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
});
}
this.usersChart.update(user, true);

View File

@ -22,6 +22,30 @@ 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 &&
note.reply == null &&
note.text == null &&
note.cw == null &&
(note.fileIds == null || note.fileIds.length === 0) &&
!note.hasPoll
);
}
function getAppearNoteIds(notes: MiNote[]): Set<string> {
const appearNoteIds = new Set<string>();
for (const note of notes) {
if (isPureRenote(note)) {
appearNoteIds.add(note.renoteId);
} else {
appearNoteIds.add(note.id);
}
}
return appearNoteIds;
}
@Injectable()
export class NoteEntityService implements OnModuleInit {
private userEntityService: UserEntityService;
@ -90,7 +114,7 @@ export class NoteEntityService implements OnModuleInit {
hide = false;
} else {
// 指定されているかどうか
const specified = packedNote.visibleUserIds!.some((id: any) => meId === id);
const specified = packedNote.visibleUserIds!.some(id => meId === id);
if (specified) {
hide = false;
@ -227,7 +251,7 @@ export class NoteEntityService implements OnModuleInit {
return true;
} else {
// 指定されているかどうか
return note.visibleUserIds.some((id: any) => meId === id);
return note.visibleUserIds.some(id => meId === id);
}
}
@ -421,7 +445,7 @@ export class NoteEntityService implements OnModuleInit {
) {
if (notes.length === 0) return [];
const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany(notes.map(x => x.id)) : null;
const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null;
const meId = me ? me.id : null;
const myReactionsMap = new Map<MiNote['id'], string | null>();
@ -432,7 +456,7 @@ export class NoteEntityService implements OnModuleInit {
const oldId = this.idService.gen(Date.now() - 2000);
for (const note of notes) {
if (note.renote && (note.text == null && note.fileIds.length === 0)) { // pure renote
if (isPureRenote(note)) {
const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.renote.reactions, bufferedReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0);
if (reactionsCount === 0) {
myReactionsMap.set(note.renote.id, null);

View File

@ -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']>

View File

@ -81,6 +81,11 @@ export class MiMeta {
})
public prohibitedWords: string[];
@Column('varchar', {
length: 1024, array: true, default: '{}',
})
public prohibitedWordsForNameOfUser: string[];
@Column('varchar', {
length: 1024, array: true, default: '{}',
})
@ -524,6 +529,11 @@ export class MiMeta {
})
public enableChartsForFederatedInstances: boolean;
@Column('boolean', {
default: true,
})
public enableStatsForFederatedInstances: boolean;
@Column('boolean', {
default: false,
})

View File

@ -14,6 +14,10 @@ export const systemWebhookEventTypes = [
'abuseReportResolved',
// ユーザが作成された時
'userCreated',
// モデレータが一定期間不在である警告
'inactiveModeratorsWarning',
// モデレータが一定期間不在のためシステムにより招待制へと変更された
'inactiveModeratorsInvitationOnlyChanged',
] as const;
export type SystemWebhookEventType = typeof systemWebhookEventTypes[number];

View File

@ -3,24 +3,110 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { RoleService } from '@/core/RoleService.js';
import { EmailService } from '@/core/EmailService.js';
import { MiUser, type UserProfilesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
// モデレーターが不在と判断する日付の閾値
const MODERATOR_INACTIVITY_LIMIT_DAYS = 7;
const ONE_DAY_MILLI_SEC = 1000 * 60 * 60 * 24;
// 警告通知やログ出力を行う残日数の閾値
const MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS = 2;
// 期限から6時間ごとに通知を行う
const MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS = 6;
const ONE_HOUR_MILLI_SEC = 1000 * 60 * 60;
const ONE_DAY_MILLI_SEC = ONE_HOUR_MILLI_SEC * 24;
export type ModeratorInactivityEvaluationResult = {
isModeratorsInactive: boolean;
inactiveModerators: MiUser[];
remainingTime: ModeratorInactivityRemainingTime;
}
export type ModeratorInactivityRemainingTime = {
time: number;
asHours: number;
asDays: number;
};
function generateModeratorInactivityMail(remainingTime: ModeratorInactivityRemainingTime) {
const subject = 'Moderator Inactivity Warning / モデレーター不在の通知';
const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`;
const timeVariantJa = remainingTime.asDays === 0 ? `${remainingTime.asHours} 時間` : `${remainingTime.asDays} 日間`;
const message = [
'To Moderators,',
'',
`A moderator has been inactive for a period of time. If there are ${timeVariant} of inactivity left, it will switch to invitation only.`,
'If you do not wish to move to invitation only, you must log into Misskey and update your last active date and time.',
'',
'---------------',
'',
'To モデレーター各位',
'',
`モデレーターが一定期間活動していないようです。あと${timeVariantJa}活動していない状態が続くと招待制に切り替わります。`,
'招待制に切り替わることを望まない場合は、Misskeyにログインして最終アクティブ日時を更新してください。',
'',
];
const html = message.join('<br>');
const text = message.join('\n');
return {
subject,
html,
text,
};
}
function generateInvitationOnlyChangedMail() {
const subject = 'Change to Invitation-Only / 招待制に変更されました';
const message = [
'To Moderators,',
'',
`Changed to invitation only because no moderator activity was detected for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days.`,
'To cancel the invitation only, you need to access the control panel.',
'',
'---------------',
'',
'To モデレーター各位',
'',
`モデレーターの活動が${MODERATOR_INACTIVITY_LIMIT_DAYS}日間検出されなかったため、招待制に変更されました。`,
'招待制を解除するには、コントロールパネルにアクセスする必要があります。',
'',
];
const html = message.join('<br>');
const text = message.join('\n');
return {
subject,
html,
text,
};
}
@Injectable()
export class CheckModeratorsActivityProcessorService {
private logger: Logger;
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private metaService: MetaService,
private roleService: RoleService,
private emailService: EmailService,
private announcementService: AnnouncementService,
private systemWebhookService: SystemWebhookService,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('check-moderators-activity');
@ -42,18 +128,23 @@ export class CheckModeratorsActivityProcessorService {
@bindThis
private async processImpl() {
const { isModeratorsInactive, inactivityLimitCountdown } = await this.evaluateModeratorsInactiveDays();
if (isModeratorsInactive) {
const evaluateResult = await this.evaluateModeratorsInactiveDays();
if (evaluateResult.isModeratorsInactive) {
this.logger.warn(`The moderator has been inactive for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days. We will move to invitation only.`);
await this.changeToInvitationOnly();
// TODO: モデレータに通知メールMisskey通知
// TODO: SystemWebhook通知
await this.notifyChangeToInvitationOnly();
} else {
if (inactivityLimitCountdown <= 2) {
this.logger.warn(`A moderator has been inactive for a period of time. If you are inactive for an additional ${inactivityLimitCountdown} days, it will switch to invitation only.`);
const remainingTime = evaluateResult.remainingTime;
if (remainingTime.asDays <= MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS) {
const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`;
this.logger.warn(`A moderator has been inactive for a period of time. If you are inactive for an additional ${timeVariant}, it will switch to invitation only.`);
// TODO: 警告メール
if (remainingTime.asHours % MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS === 0) {
// ジョブの実行頻度と同等だと通知が多すぎるため期限から6時間ごとに通知する
// つまり、のこり2日を切ったら6時間ごとに通知が送られる
await this.notifyInactiveModeratorsWarning(remainingTime);
}
}
}
}
@ -87,7 +178,7 @@ export class CheckModeratorsActivityProcessorService {
* A, B, Cのアクティビティは判定基準日よりも古いため
*/
@bindThis
public async evaluateModeratorsInactiveDays() {
public async evaluateModeratorsInactiveDays(): Promise<ModeratorInactivityEvaluationResult> {
const today = new Date();
const inactivePeriod = new Date(today);
inactivePeriod.setDate(today.getDate() - MODERATOR_INACTIVITY_LIMIT_DAYS);
@ -101,12 +192,18 @@ export class CheckModeratorsActivityProcessorService {
// 残りの猶予を示したいので、最終アクティブ日時が一番若いモデレータの日数を基準に猶予を計算する
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const newestLastActiveDate = new Date(Math.max(...moderators.map(it => it.lastActiveDate!.getTime())));
const inactivityLimitCountdown = Math.floor((newestLastActiveDate.getTime() - inactivePeriod.getTime()) / ONE_DAY_MILLI_SEC);
const remainingTime = newestLastActiveDate.getTime() - inactivePeriod.getTime();
const remainingTimeAsDays = Math.floor(remainingTime / ONE_DAY_MILLI_SEC);
const remainingTimeAsHours = Math.floor((remainingTime / ONE_HOUR_MILLI_SEC));
return {
isModeratorsInactive: inactiveModerators.length === moderators.length,
inactiveModerators,
inactivityLimitCountdown,
remainingTime: {
time: remainingTime,
asHours: remainingTimeAsHours,
asDays: remainingTimeAsDays,
},
};
}
@ -115,6 +212,74 @@ export class CheckModeratorsActivityProcessorService {
await this.metaService.update({ disableRegistration: true });
}
@bindThis
public async notifyInactiveModeratorsWarning(remainingTime: ModeratorInactivityRemainingTime) {
// -- モデレータへのメール送信
const moderators = await this.fetchModerators();
const moderatorProfiles = await this.userProfilesRepository
.findBy({ userId: In(moderators.map(it => it.id)) })
.then(it => new Map(it.map(it => [it.userId, it])));
const mail = generateModeratorInactivityMail(remainingTime);
for (const moderator of moderators) {
const profile = moderatorProfiles.get(moderator.id);
if (profile && profile.email && profile.emailVerified) {
this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text);
}
}
// -- SystemWebhook
const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks()
.then(it => it.filter(it => it.on.includes('inactiveModeratorsWarning')));
for (const systemWebhook of systemWebhooks) {
this.systemWebhookService.enqueueSystemWebhook(
systemWebhook,
'inactiveModeratorsWarning',
{ remainingTime: remainingTime },
);
}
}
@bindThis
public async notifyChangeToInvitationOnly() {
// -- モデレータへのメールとお知らせ(個人向け)送信
const moderators = await this.fetchModerators();
const moderatorProfiles = await this.userProfilesRepository
.findBy({ userId: In(moderators.map(it => it.id)) })
.then(it => new Map(it.map(it => [it.userId, it])));
const mail = generateInvitationOnlyChangedMail();
for (const moderator of moderators) {
this.announcementService.create({
title: mail.subject,
text: mail.text,
forExistingUsers: true,
needConfirmationToRead: true,
userId: moderator.id,
});
const profile = moderatorProfiles.get(moderator.id);
if (profile && profile.email && profile.emailVerified) {
this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text);
}
}
// -- SystemWebhook
const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks()
.then(it => it.filter(it => it.on.includes('inactiveModeratorsInvitationOnlyChanged')));
for (const systemWebhook of systemWebhooks) {
this.systemWebhookService.enqueueSystemWebhook(
systemWebhook,
'inactiveModeratorsInvitationOnlyChanged',
{},
);
}
}
@bindThis
private async fetchModerators() {
// TODO: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する

View File

@ -74,8 +74,17 @@ export class DeliverProcessorService {
try {
await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content, job.data.digest);
// Update stats
this.federatedInstanceService.fetch(host).then(i => {
this.apRequestChart.deliverSucc();
this.federationChart.deliverd(host, true);
// Update instance stats
process.nextTick(async () => {
const i = await (this.meta.enableStatsForFederatedInstances
? this.federatedInstanceService.fetchOrRegister(host)
: this.federatedInstanceService.fetch(host));
if (i == null) return;
if (i.isNotResponding) {
this.federatedInstanceService.update(i.id, {
isNotResponding: false,
@ -83,9 +92,9 @@ export class DeliverProcessorService {
});
}
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
this.apRequestChart.deliverSucc();
this.federationChart.deliverd(i.host, true);
if (this.meta.enableStatsForFederatedInstances) {
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
}
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.requestSent(i.host, true);
@ -94,8 +103,11 @@ export class DeliverProcessorService {
return 'Success';
} catch (res) {
// Update stats
this.federatedInstanceService.fetch(host).then(i => {
this.apRequestChart.deliverFail();
this.federationChart.deliverd(host, false);
// Update instance stats
this.federatedInstanceService.fetchOrRegister(host).then(i => {
if (!i.isNotResponding) {
this.federatedInstanceService.update(i.id, {
isNotResponding: true,
@ -116,9 +128,6 @@ export class DeliverProcessorService {
});
}
this.apRequestChart.deliverFail();
this.federationChart.deliverd(i.host, false);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.requestSent(i.host, false);
}
@ -129,7 +138,7 @@ export class DeliverProcessorService {
if (!res.isRetryable) {
// 相手が閉鎖していることを明示しているため、配送停止する
if (job.data.isSharedInbox && res.statusCode === 410) {
this.federatedInstanceService.fetch(host).then(i => {
this.federatedInstanceService.fetchOrRegister(host).then(i => {
this.federatedInstanceService.update(i.id, {
suspensionState: 'goneSuspended',
});

View File

@ -192,21 +192,27 @@ export class InboxProcessorService implements OnApplicationShutdown {
}
}
// Update stats
this.federatedInstanceService.fetch(authUser.user.host).then(i => {
this.apRequestChart.inbox();
this.federationChart.inbox(authUser.user.host);
// Update instance stats
process.nextTick(async () => {
const i = await (this.meta.enableStatsForFederatedInstances
? this.federatedInstanceService.fetchOrRegister(authUser.user.host)
: this.federatedInstanceService.fetch(authUser.user.host));
if (i == null) return;
this.updateInstanceQueue.enqueue(i.id, {
latestRequestReceivedAt: new Date(),
shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding',
});
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
this.apRequestChart.inbox();
this.federationChart.inbox(i.host);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.requestReceived(i.host);
}
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
});
// アクティビティを処理

View File

@ -177,6 +177,13 @@ export const meta = {
type: 'string',
},
},
prohibitedWordsForNameOfUser: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
},
},
bannedEmailDomains: {
type: 'array',
optional: true, nullable: false,
@ -341,6 +348,10 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
enableStatsForFederatedInstances: {
type: 'boolean',
optional: false, nullable: false,
},
enableServerMachineStats: {
type: 'boolean',
optional: false, nullable: false,
@ -586,6 +597,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
mediaSilencedHosts: instance.mediaSilencedHosts,
sensitiveWords: instance.sensitiveWords,
prohibitedWords: instance.prohibitedWords,
prohibitedWordsForNameOfUser: instance.prohibitedWordsForNameOfUser,
preservedUsernames: instance.preservedUsernames,
hcaptchaSecretKey: instance.hcaptchaSecretKey,
mcaptchaSecretKey: instance.mcaptchaSecretKey,
@ -627,6 +639,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
truemailAuthKey: instance.truemailAuthKey,
enableChartsForRemoteUser: instance.enableChartsForRemoteUser,
enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances,
enableStatsForFederatedInstances: instance.enableStatsForFederatedInstances,
enableServerMachineStats: instance.enableServerMachineStats,
enableIdenticonGeneration: instance.enableIdenticonGeneration,
bannedEmailDomains: instance.bannedEmailDomains,

View File

@ -46,6 +46,11 @@ export const paramDef = {
type: 'string',
},
},
prohibitedWordsForNameOfUser: {
type: 'array', nullable: true, items: {
type: 'string',
},
},
themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' },
mascotImageUrl: { type: 'string', nullable: true },
bannerUrl: { type: 'string', nullable: true },
@ -131,6 +136,7 @@ export const paramDef = {
truemailAuthKey: { type: 'string', nullable: true },
enableChartsForRemoteUser: { type: 'boolean' },
enableChartsForFederatedInstances: { type: 'boolean' },
enableStatsForFederatedInstances: { type: 'boolean' },
enableServerMachineStats: { type: 'boolean' },
enableIdenticonGeneration: { type: 'boolean' },
serverRules: { type: 'array', items: { type: 'string' } },
@ -214,6 +220,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (Array.isArray(ps.prohibitedWords)) {
set.prohibitedWords = ps.prohibitedWords.filter(Boolean);
}
if (Array.isArray(ps.prohibitedWordsForNameOfUser)) {
set.prohibitedWordsForNameOfUser = ps.prohibitedWordsForNameOfUser.filter(Boolean);
}
if (Array.isArray(ps.silencedHosts)) {
let lastValue = '';
set.silencedHosts = ps.silencedHosts.sort().filter((h) => {
@ -570,6 +579,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances;
}
if (ps.enableStatsForFederatedInstances !== undefined) {
set.enableStatsForFederatedInstances = ps.enableStatsForFederatedInstances;
}
if (ps.enableServerMachineStats !== undefined) {
set.enableServerMachineStats = ps.enableServerMachineStats;
}

View File

@ -11,7 +11,7 @@ import { JSDOM } from 'jsdom';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js';
import * as Acct from '@/misc/acct.js';
import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/_.js';
import type { UsersRepository, DriveFilesRepository, MiMeta, UserProfilesRepository, PagesRepository } from '@/models/_.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
import { birthdaySchema, descriptionSchema, followedMessageSchema, locationSchema, nameSchema } from '@/models/User.js';
import type { MiUserProfile } from '@/models/UserProfile.js';
@ -22,6 +22,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { HashtagService } from '@/core/HashtagService.js';
import { DI } from '@/di-symbols.js';
import { RolePolicies, RoleService } from '@/core/RoleService.js';
@ -114,6 +115,13 @@ export const meta = {
code: 'RESTRICTED_BY_ROLE',
id: '8feff0ba-5ab5-585b-31f4-4df816663fad',
},
nameContainsProhibitedWords: {
message: 'Your new name contains prohibited words.',
code: 'YOUR_NAME_CONTAINS_PROHIBITED_WORDS',
id: '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191',
httpStatusCode: 422,
},
},
res: {
@ -223,6 +231,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.config)
private config: Config,
@Inject(DI.meta)
private instanceMeta: MiMeta,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@ -247,6 +258,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private cacheService: CacheService,
private httpRequestService: HttpRequestService,
private avatarDecorationService: AvatarDecorationService,
private utilityService: UtilityService,
) {
super(meta, paramDef, async (ps, _user, token) => {
const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser;
@ -449,6 +461,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const newFields = profileUpdates.fields === undefined ? profile.fields : profileUpdates.fields;
if (newName != null) {
let hasProhibitedWords = false;
if (!await this.roleService.isModerator(user)) {
hasProhibitedWords = this.utilityService.isKeyWordIncluded(newName, this.instanceMeta.prohibitedWordsForNameOfUser);
}
if (hasProhibitedWords) {
throw new ApiError(meta.errors.nameContainsProhibitedWords);
}
const tokens = mfm.parseSimple(newName);
emojis = emojis.concat(extractCustomEmojisFromMfm(tokens));
}

View 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;
}
}

View 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'
]

View File

@ -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

View File

@ -0,0 +1,6 @@
certificates
volumes
.env
docker.env
*.test.conf
*.test.default.yml

View 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
```

View 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

View 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

View 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:

View 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

View 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

View 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);
}

View 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,
},
},
},
];

View 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

View 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;
},
);
});
});
});

View 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,
);
});
});
});

View 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);
});
});
});
});

View 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 }, {});
});
});

View 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}`);
});
});
});

View 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);
});
});
});
});

View 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,
);
});
});
});

View 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 });
});
});
});

View 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;
},
);
});
});
});
});

View 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);
}

View 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"
]
}

View File

@ -8,6 +8,7 @@ process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';
import { Test } from '@nestjs/testing';
import { Redis } from 'ioredis';
import type { TestingModule } from '@nestjs/testing';
import { GlobalModule } from '@/GlobalModule.js';
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
@ -16,7 +17,6 @@ import { LoggerService } from '@/core/LoggerService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import type { TestingModule } from '@nestjs/testing';
function mockRedis() {
const hash = {} as any;
@ -52,7 +52,7 @@ describe('FetchInstanceMetadataService', () => {
if (token === HttpRequestService) {
return { getJson: jest.fn(), getHtml: jest.fn(), send: jest.fn() };
} else if (token === FederatedInstanceService) {
return { fetch: jest.fn() };
return { fetchOrRegister: jest.fn() };
} else if (token === DI.redis) {
return mockRedis;
}
@ -75,7 +75,7 @@ describe('FetchInstanceMetadataService', () => {
test('Lock and update', async () => {
redisClient.set = mockRedis();
const now = Date.now();
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any);
federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
@ -83,14 +83,14 @@ describe('FetchInstanceMetadataService', () => {
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1);
expect(httpRequestService.getJson).toHaveBeenCalled();
});
test('Lock and don\'t update', async () => {
redisClient.set = mockRedis();
const now = Date.now();
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any);
federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
@ -98,14 +98,14 @@ describe('FetchInstanceMetadataService', () => {
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1);
expect(httpRequestService.getJson).toHaveBeenCalledTimes(0);
});
test('Do nothing when lock not acquired', async () => {
redisClient.set = mockRedis();
const now = Date.now();
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
await fetchInstanceMetadataService.tryLock('example.com');
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
@ -114,14 +114,14 @@ describe('FetchInstanceMetadataService', () => {
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(0);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);
expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(0);
expect(httpRequestService.getJson).toHaveBeenCalledTimes(0);
});
test('Do when lock not acquired but forced', async () => {
redisClient.set = mockRedis();
const now = Date.now();
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
await fetchInstanceMetadataService.tryLock('example.com');
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
@ -130,7 +130,7 @@ describe('FetchInstanceMetadataService', () => {
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any, true);
expect(tryLockSpy).toHaveBeenCalledTimes(0);
expect(unlockSpy).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);
expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(0);
expect(httpRequestService.getJson).toHaveBeenCalled();
});
});

View File

@ -8,13 +8,16 @@ import { Test, TestingModule } from '@nestjs/testing';
import * as lolex from '@sinonjs/fake-timers';
import { addHours, addSeconds, subDays, subHours, subSeconds } from 'date-fns';
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
import { MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { MiSystemWebhook, MiUser, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { RoleService } from '@/core/RoleService.js';
import { GlobalModule } from '@/GlobalModule.js';
import { MetaService } from '@/core/MetaService.js';
import { DI } from '@/di-symbols.js';
import { QueueLoggerService } from '@/queue/QueueLoggerService.js';
import { EmailService } from '@/core/EmailService.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
const baseDate = new Date(Date.UTC(2000, 11, 15, 12, 0, 0));
@ -29,10 +32,17 @@ describe('CheckModeratorsActivityProcessorService', () => {
let userProfilesRepository: UserProfilesRepository;
let idService: IdService;
let roleService: jest.Mocked<RoleService>;
let announcementService: jest.Mocked<AnnouncementService>;
let emailService: jest.Mocked<EmailService>;
let systemWebhookService: jest.Mocked<SystemWebhookService>;
let systemWebhook1: MiSystemWebhook;
let systemWebhook2: MiSystemWebhook;
let systemWebhook3: MiSystemWebhook;
// --------------------------------------------------------------------------------------
async function createUser(data: Partial<MiUser> = {}) {
async function createUser(data: Partial<MiUser> = {}, profile: Partial<MiUserProfile> = {}): Promise<MiUser> {
const id = idService.gen();
const user = await usersRepository
.insert({
@ -45,11 +55,27 @@ describe('CheckModeratorsActivityProcessorService', () => {
await userProfilesRepository.insert({
userId: user.id,
...profile,
});
return user;
}
function crateSystemWebhook(data: Partial<MiSystemWebhook> = {}): MiSystemWebhook {
return {
id: idService.gen(),
isActive: true,
updatedAt: new Date(),
latestSentAt: null,
latestStatus: null,
name: 'test',
url: 'https://example.com',
secret: 'test',
on: [],
...data,
};
}
function mockModeratorRole(users: MiUser[]) {
roleService.getModerators.mockReset();
roleService.getModerators.mockResolvedValue(users);
@ -72,6 +98,18 @@ describe('CheckModeratorsActivityProcessorService', () => {
{
provide: MetaService, useFactory: () => ({ fetch: jest.fn() }),
},
{
provide: AnnouncementService, useFactory: () => ({ create: jest.fn() }),
},
{
provide: EmailService, useFactory: () => ({ sendEmail: jest.fn() }),
},
{
provide: SystemWebhookService, useFactory: () => ({
fetchActiveSystemWebhooks: jest.fn(),
enqueueSystemWebhook: jest.fn(),
}),
},
{
provide: QueueLoggerService, useFactory: () => ({
logger: ({
@ -93,6 +131,9 @@ describe('CheckModeratorsActivityProcessorService', () => {
service = app.get(CheckModeratorsActivityProcessorService);
idService = app.get(IdService);
roleService = app.get(RoleService) as jest.Mocked<RoleService>;
announcementService = app.get(AnnouncementService) as jest.Mocked<AnnouncementService>;
emailService = app.get(EmailService) as jest.Mocked<EmailService>;
systemWebhookService = app.get(SystemWebhookService) as jest.Mocked<SystemWebhookService>;
app.enableShutdownHooks();
});
@ -102,6 +143,15 @@ describe('CheckModeratorsActivityProcessorService', () => {
now: new Date(baseDate),
shouldClearNativeTimers: true,
});
systemWebhook1 = crateSystemWebhook({ on: ['inactiveModeratorsWarning'] });
systemWebhook2 = crateSystemWebhook({ on: ['inactiveModeratorsWarning', 'inactiveModeratorsInvitationOnlyChanged'] });
systemWebhook3 = crateSystemWebhook({ on: ['abuseReport'] });
emailService.sendEmail.mockReturnValue(Promise.resolve());
announcementService.create.mockReturnValue(Promise.resolve({} as never));
systemWebhookService.fetchActiveSystemWebhooks.mockResolvedValue([systemWebhook1, systemWebhook2, systemWebhook3]);
systemWebhookService.enqueueSystemWebhook.mockReturnValue(Promise.resolve({} as never));
});
afterEach(async () => {
@ -109,6 +159,9 @@ describe('CheckModeratorsActivityProcessorService', () => {
await usersRepository.delete({});
await userProfilesRepository.delete({});
roleService.getModerators.mockReset();
announcementService.create.mockReset();
emailService.sendEmail.mockReset();
systemWebhookService.enqueueSystemWebhook.mockReset();
});
afterAll(async () => {
@ -152,7 +205,7 @@ describe('CheckModeratorsActivityProcessorService', () => {
expect(result.inactiveModerators).toEqual([user1]);
});
test('[countdown] 猶予まで24時間ある場合、猶予1日として計算される', async () => {
test('[remainingTime] 猶予まで24時間ある場合、猶予1日として計算される', async () => {
const [user1, user2] = await Promise.all([
createUser({ lastActiveDate: subDays(baseDate, 8) }),
// 猶予はこのユーザ基準で計算される想定。
@ -165,10 +218,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
const result = await service.evaluateModeratorsInactiveDays();
expect(result.isModeratorsInactive).toBe(false);
expect(result.inactiveModerators).toEqual([user1]);
expect(result.inactivityLimitCountdown).toBe(1);
expect(result.remainingTime.asDays).toBe(1);
expect(result.remainingTime.asHours).toBe(24);
});
test('[countdown] 猶予まで25時間ある場合、猶予1日として計算される', async () => {
test('[remainingTime] 猶予まで25時間ある場合、猶予1日として計算される', async () => {
const [user1, user2] = await Promise.all([
createUser({ lastActiveDate: subDays(baseDate, 8) }),
// 猶予はこのユーザ基準で計算される想定。
@ -181,10 +235,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
const result = await service.evaluateModeratorsInactiveDays();
expect(result.isModeratorsInactive).toBe(false);
expect(result.inactiveModerators).toEqual([user1]);
expect(result.inactivityLimitCountdown).toBe(1);
expect(result.remainingTime.asDays).toBe(1);
expect(result.remainingTime.asHours).toBe(25);
});
test('[countdown] 猶予まで23時間ある場合、猶予0日として計算される', async () => {
test('[remainingTime] 猶予まで23時間ある場合、猶予0日として計算される', async () => {
const [user1, user2] = await Promise.all([
createUser({ lastActiveDate: subDays(baseDate, 8) }),
// 猶予はこのユーザ基準で計算される想定。
@ -197,10 +252,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
const result = await service.evaluateModeratorsInactiveDays();
expect(result.isModeratorsInactive).toBe(false);
expect(result.inactiveModerators).toEqual([user1]);
expect(result.inactivityLimitCountdown).toBe(0);
expect(result.remainingTime.asDays).toBe(0);
expect(result.remainingTime.asHours).toBe(23);
});
test('[countdown] 期限ちょうどの場合、猶予0日として計算される', async () => {
test('[remainingTime] 期限ちょうどの場合、猶予0日として計算される', async () => {
const [user1, user2] = await Promise.all([
createUser({ lastActiveDate: subDays(baseDate, 8) }),
// 猶予はこのユーザ基準で計算される想定。
@ -213,10 +269,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
const result = await service.evaluateModeratorsInactiveDays();
expect(result.isModeratorsInactive).toBe(false);
expect(result.inactiveModerators).toEqual([user1]);
expect(result.inactivityLimitCountdown).toBe(0);
expect(result.remainingTime.asDays).toBe(0);
expect(result.remainingTime.asHours).toBe(0);
});
test('[countdown] 期限より1時間超過している場合、猶予-1日として計算される', async () => {
test('[remainingTime] 期限より1時間超過している場合、猶予-1日として計算される', async () => {
const [user1, user2] = await Promise.all([
createUser({ lastActiveDate: subDays(baseDate, 8) }),
// 猶予はこのユーザ基準で計算される想定。
@ -229,7 +286,94 @@ describe('CheckModeratorsActivityProcessorService', () => {
const result = await service.evaluateModeratorsInactiveDays();
expect(result.isModeratorsInactive).toBe(true);
expect(result.inactiveModerators).toEqual([user1, user2]);
expect(result.inactivityLimitCountdown).toBe(-1);
expect(result.remainingTime.asDays).toBe(-1);
expect(result.remainingTime.asHours).toBe(-1);
});
test('[remainingTime] 期限より25時間超過している場合、猶予-2日として計算される', async () => {
const [user1, user2] = await Promise.all([
createUser({ lastActiveDate: subDays(baseDate, 10) }),
// 猶予はこのユーザ基準で計算される想定。
// 期限より1時間超過->猶予-1日として計算されるはずである
createUser({ lastActiveDate: subDays(subHours(baseDate, 25), 7) }),
]);
mockModeratorRole([user1, user2]);
const result = await service.evaluateModeratorsInactiveDays();
expect(result.isModeratorsInactive).toBe(true);
expect(result.inactiveModerators).toEqual([user1, user2]);
expect(result.remainingTime.asDays).toBe(-2);
expect(result.remainingTime.asHours).toBe(-25);
});
});
describe('notifyInactiveModeratorsWarning', () => {
test('[notification + mail] 通知はモデレータ全員に発信され、メールはメールアドレスが存在+認証済みの場合のみ', async () => {
const [user1, user2, user3, user4, root] = await Promise.all([
createUser({}, { email: 'user1@example.com', emailVerified: true }),
createUser({}, { email: 'user2@example.com', emailVerified: false }),
createUser({}, { email: null, emailVerified: false }),
createUser({}, { email: 'user4@example.com', emailVerified: true }),
createUser({ isRoot: true }, { email: 'root@example.com', emailVerified: true }),
]);
mockModeratorRole([user1, user2, user3, root]);
await service.notifyInactiveModeratorsWarning({ time: 1, asDays: 0, asHours: 0 });
expect(emailService.sendEmail).toHaveBeenCalledTimes(2);
expect(emailService.sendEmail.mock.calls[0][0]).toBe('user1@example.com');
expect(emailService.sendEmail.mock.calls[1][0]).toBe('root@example.com');
});
test('[systemWebhook] "inactiveModeratorsWarning"が有効なSystemWebhookに対して送信される', async () => {
const [user1] = await Promise.all([
createUser({}, { email: 'user1@example.com', emailVerified: true }),
]);
mockModeratorRole([user1]);
await service.notifyInactiveModeratorsWarning({ time: 1, asDays: 0, asHours: 0 });
expect(systemWebhookService.enqueueSystemWebhook).toHaveBeenCalledTimes(2);
expect(systemWebhookService.enqueueSystemWebhook.mock.calls[0][0]).toEqual(systemWebhook1);
expect(systemWebhookService.enqueueSystemWebhook.mock.calls[1][0]).toEqual(systemWebhook2);
});
});
describe('notifyChangeToInvitationOnly', () => {
test('[notification + mail] 通知はモデレータ全員に発信され、メールはメールアドレスが存在+認証済みの場合のみ', async () => {
const [user1, user2, user3, user4, root] = await Promise.all([
createUser({}, { email: 'user1@example.com', emailVerified: true }),
createUser({}, { email: 'user2@example.com', emailVerified: false }),
createUser({}, { email: null, emailVerified: false }),
createUser({}, { email: 'user4@example.com', emailVerified: true }),
createUser({ isRoot: true }, { email: 'root@example.com', emailVerified: true }),
]);
mockModeratorRole([user1, user2, user3, root]);
await service.notifyChangeToInvitationOnly();
expect(announcementService.create).toHaveBeenCalledTimes(4);
expect(announcementService.create.mock.calls[0][0].userId).toBe(user1.id);
expect(announcementService.create.mock.calls[1][0].userId).toBe(user2.id);
expect(announcementService.create.mock.calls[2][0].userId).toBe(user3.id);
expect(announcementService.create.mock.calls[3][0].userId).toBe(root.id);
expect(emailService.sendEmail).toHaveBeenCalledTimes(2);
expect(emailService.sendEmail.mock.calls[0][0]).toBe('user1@example.com');
expect(emailService.sendEmail.mock.calls[1][0]).toBe('root@example.com');
});
test('[systemWebhook] "inactiveModeratorsInvitationOnlyChanged"が有効なSystemWebhookに対して送信される', async () => {
const [user1] = await Promise.all([
createUser({}, { email: 'user1@example.com', emailVerified: true }),
]);
mockModeratorRole([user1]);
await service.notifyChangeToInvitationOnly();
expect(systemWebhookService.enqueueSystemWebhook).toHaveBeenCalledTimes(1);
expect(systemWebhookService.enqueueSystemWebhook.mock.calls[0][0]).toEqual(systemWebhook2);
});
});
});

View File

@ -108,6 +108,8 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, inject, ref, shallowRef } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { shouldCollapsed } from '@@/js/collapsed.js';
import { url } from '@@/js/config.js';
import I18n from '@/components/I18n.vue';
import EmNoteSub from '@/components/EmNoteSub.vue';
import EmNoteHeader from '@/components/EmNoteHeader.vue';
@ -123,8 +125,6 @@ import EmUserName from '@/components/EmUserName.vue';
import EmTime from '@/components/EmTime.vue';
import { userPage } from '@/utils.js';
import { i18n } from '@/i18n.js';
import { shouldCollapsed } from '@@/js/collapsed.js';
import { url } from '@@/js/config.js';
function getAppearNote(note: Misskey.entities.Note) {
return Misskey.note.isPureRenote(note) ? note.renote : note;
@ -164,14 +164,8 @@ const isDeleted = ref(false);
font-size: 1.05em;
overflow: clip;
contain: content;
//
//
// contain-intrinsic-size
//
// ()
//content-visibility: auto;
//contain-intrinsic-size: 0 128px;
content-visibility: auto;
contain-intrinsic-size: 0 150px;
&:focus-visible {
outline: none;

View File

@ -9,6 +9,7 @@ import MkAd from '@/components/global/MkAd.vue';
import { isDebuggerEnabled, stackTraceInstances } from '@/debug.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { instance } from '@/instance.js';
import { defaultStore } from '@/store.js';
import { MisskeyEntity } from '@/types/date-separated-list.js';
@ -99,11 +100,13 @@ export default defineComponent({
return [el, separator];
} else {
if (props.ad && item._shouldInsertAd_) {
return [h(MkAd, {
if (props.ad && instance.ads.length > 0 && item._shouldInsertAd_) {
return [h('div', {
key: item.id + ':ad',
class: $style['ad-wrapper'],
}, [h(MkAd, {
prefer: ['horizontal', 'horizontal-big'],
}), el];
})]), el];
} else {
return el;
}
@ -253,5 +256,11 @@ export default defineComponent({
.date-2-icon {
margin-left: 8px;
}
.ad-wrapper {
padding: 8px;
background-size: auto auto;
background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px);
}
</style>

View File

@ -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;

View File

@ -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 {

View File

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
v-show="!isDeleted"
ref="rootEl"
v-hotkey="keymap"
:class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]"
:class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover, [$style.skipRender]: defaultStore.state.skipNoteRender }]"
:tabindex="isDeleted ? '-1' : '0'"
>
<MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
@ -171,6 +171,9 @@ import { computed, inject, onMounted, ref, shallowRef, Ref, watch, provide } fro
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import { shouldCollapsed } from '@@/js/collapsed.js';
import { host } from '@@/js/config.js';
import type { MenuItem } from '@/types/menu.js';
import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
@ -200,11 +203,8 @@ import { deepClone } from '@/scripts/clone.js';
import { useTooltip } from '@/scripts/use-tooltip.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { getNoteSummary } from '@/scripts/get-note-summary.js';
import type { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { shouldCollapsed } from '@@/js/collapsed.js';
import { host } from '@@/js/config.js';
import { isEnabledUrlPreview } from '@/instance.js';
import { type Keymap } from '@/scripts/hotkey.js';
import { focusPrev, focusNext } from '@/scripts/focus.js';
@ -619,14 +619,6 @@ function emitUpdReaction(emoji: string, delta: number) {
overflow: clip;
contain: content;
//
//
// contain-intrinsic-size
//
// ()
//content-visibility: auto;
//contain-intrinsic-size: 0 128px;
&:focus-visible {
outline: none;
@ -687,6 +679,11 @@ function emitUpdReaction(emoji: string, delta: number) {
}
}
.skipRender {
content-visibility: auto;
contain-intrinsic-size: 0 150px;
}
.tip {
display: flex;
align-items: center;

View File

@ -220,6 +220,8 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
overflow-wrap: break-word;
display: flex;
contain: content;
content-visibility: auto;
contain-intrinsic-size: 0 100px;
--eventFollow: #36aed2;
--eventRenote: #36d298;

View File

@ -55,6 +55,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
<MkButton v-show="mode === 'edit'" transparent :class="$style.testButton" :disabled="!(isActive && events.userCreated)" @click="test('userCreated')"><i class="ti ti-send"></i></MkButton>
</div>
<div :class="$style.switchBox">
<MkSwitch v-model="events.inactiveModeratorsWarning" :disabled="disabledEvents.inactiveModeratorsWarning">
<template #label>{{ i18n.ts._webhookSettings._systemEvents.inactiveModeratorsWarning }}</template>
</MkSwitch>
<MkButton v-show="mode === 'edit'" transparent :class="$style.testButton" :disabled="!(isActive && events.inactiveModeratorsWarning)" @click="test('inactiveModeratorsWarning')"><i class="ti ti-send"></i></MkButton>
</div>
<div :class="$style.switchBox">
<MkSwitch v-model="events.inactiveModeratorsInvitationOnlyChanged" :disabled="disabledEvents.inactiveModeratorsInvitationOnlyChanged">
<template #label>{{ i18n.ts._webhookSettings._systemEvents.inactiveModeratorsInvitationOnlyChanged }}</template>
</MkSwitch>
<MkButton v-show="mode === 'edit'" transparent :class="$style.testButton" :disabled="!(isActive && events.inactiveModeratorsInvitationOnlyChanged)" @click="test('inactiveModeratorsInvitationOnlyChanged')"><i class="ti ti-send"></i></MkButton>
</div>
</div>
<div v-show="mode === 'edit'" :class="$style.description">
@ -100,6 +112,8 @@ type EventType = {
abuseReport: boolean;
abuseReportResolved: boolean;
userCreated: boolean;
inactiveModeratorsWarning: boolean;
inactiveModeratorsInvitationOnlyChanged: boolean;
}
const emit = defineEmits<{
@ -123,6 +137,8 @@ const events = ref<EventType>({
abuseReport: true,
abuseReportResolved: true,
userCreated: true,
inactiveModeratorsWarning: true,
inactiveModeratorsInvitationOnlyChanged: true,
});
const isActive = ref<boolean>(true);
@ -130,6 +146,8 @@ const disabledEvents = ref<EventType>({
abuseReport: false,
abuseReportResolved: false,
userCreated: false,
inactiveModeratorsWarning: false,
inactiveModeratorsInvitationOnlyChanged: false,
});
const disableSubmitButton = computed(() => {

View File

@ -51,6 +51,11 @@ watch(name, () => {
// null??使
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
name: name.value || null,
}, undefined, {
'0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191': {
title: i18n.ts.yourNameContainsProhibitedWords,
text: i18n.ts.yourNameContainsProhibitedWordsDescription,
},
});
});

View File

@ -30,12 +30,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</component>
</div>
<div v-else :class="$style.menu">
<div :class="$style.menuContainer">
<div>Ads by {{ host }}</div>
<!--<MkButton class="button" primary>{{ i18n.ts._ad.like }}</MkButton>-->
<MkButton v-if="chosen.ratio !== 0" :class="$style.menuButton" @click="reduceFrequency">{{ i18n.ts._ad.reduceFrequencyOfThisAd }}</MkButton>
<button class="_textButton" @click="toggleMenu">{{ i18n.ts._ad.back }}</button>
</div>
<div>Ads by {{ host }}</div>
<!--<MkButton class="button" primary>{{ i18n.ts._ad.like }}</MkButton>-->
<MkButton v-if="chosen.ratio !== 0" :class="$style.menuButton" @click="reduceFrequency">{{ i18n.ts._ad.reduceFrequencyOfThisAd }}</MkButton>
<button class="_textButton" @click="toggleMenu">{{ i18n.ts._ad.back }}</button>
</div>
</div>
<div v-else></div>
@ -123,8 +121,7 @@ function reduceFrequency(): void {
<style lang="scss" module>
.root {
background-size: auto auto;
background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--bg) 8px, var(--bg) 14px );
}
.main {
@ -139,8 +136,6 @@ function reduceFrequency(): void {
}
&.form_horizontal {
padding: 8px;
> .link,
> .link > .img {
max-width: min(600px, 100%);
@ -149,8 +144,6 @@ function reduceFrequency(): void {
}
&.form_horizontalBig {
padding: 8px;
> .link,
> .link > .img {
max-width: min(600px, 100%);
@ -202,14 +195,11 @@ function reduceFrequency(): void {
}
.menu {
padding: 8px;
text-align: center;
}
.menuContainer {
padding: 8px;
margin: 0 auto;
max-width: 400px;
background: var(--MI_THEME-panel);
border: solid 1px var(--MI_THEME-divider);
}

View File

@ -5,32 +5,30 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div ref="rootEl">
<div ref="headerEl">
<div ref="headerEl" :class="$style.header">
<slot name="header"></slot>
</div>
<div
ref="bodyEl"
:class="$style.body"
:data-sticky-container-header-height="headerHeight"
:data-sticky-container-footer-height="footerHeight"
style="position: relative; z-index: 0;"
>
<slot></slot>
</div>
<div ref="footerEl">
<div ref="footerEl" :class="$style.footer">
<slot name="footer"></slot>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, provide, inject, Ref, ref, watch, shallowRef } from 'vue';
import { onMounted, onUnmounted, provide, inject, Ref, ref, watch, useTemplateRef } from 'vue';
import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@@/js/const.js';
const rootEl = shallowRef<HTMLElement>();
const headerEl = shallowRef<HTMLElement>();
const footerEl = shallowRef<HTMLElement>();
const bodyEl = shallowRef<HTMLElement>();
const rootEl = useTemplateRef('rootEl');
const headerEl = useTemplateRef('headerEl');
const footerEl = useTemplateRef('footerEl');
const headerHeight = ref<string | undefined>();
const childStickyTop = ref(0);
@ -67,31 +65,11 @@ onMounted(() => {
watch([parentStickyTop, parentStickyBottom], calc);
watch(childStickyTop, () => {
if (bodyEl.value == null) return;
bodyEl.value.style.setProperty('--MI-stickyTop', `${childStickyTop.value}px`);
}, {
immediate: true,
});
watch(childStickyBottom, () => {
if (bodyEl.value == null) return;
bodyEl.value.style.setProperty('--MI-stickyBottom', `${childStickyBottom.value}px`);
}, {
immediate: true,
});
if (headerEl.value != null) {
headerEl.value.style.position = 'sticky';
headerEl.value.style.top = 'var(--MI-stickyTop, 0)';
headerEl.value.style.zIndex = '1';
observer.observe(headerEl.value);
}
if (footerEl.value != null) {
footerEl.value.style.position = 'sticky';
footerEl.value.style.bottom = 'var(--MI-stickyBottom, 0)';
footerEl.value.style.zIndex = '1';
observer.observe(footerEl.value);
}
});
@ -101,6 +79,27 @@ onUnmounted(() => {
});
defineExpose({
rootEl: rootEl,
rootEl,
});
</script>
<style lang='scss' module>
.body {
position: relative;
z-index: 0;
--MI-stickyTop: v-bind("childStickyTop + 'px'");
--MI-stickyBottom: v-bind("childStickyBottom + 'px'");
}
.header {
position: sticky;
top: var(--MI-stickyTop, 0);
z-index: 1;
}
.footer {
position: sticky;
bottom: var(--MI-stickyBottom, 0);
z-index: 1;
}
</style>

View File

@ -10,6 +10,7 @@ import { EventEmitter } from 'eventemitter3';
import * as Misskey from 'misskey-js';
import type { ComponentProps as CP } from 'vue-component-type-helpers';
import type { Form, GetFormResultType } from '@/scripts/form.js';
import type { MenuItem } from '@/types/menu.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
@ -22,7 +23,6 @@ import MkPasswordDialog from '@/components/MkPasswordDialog.vue';
import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue';
import MkPopupMenu from '@/components/MkPopupMenu.vue';
import MkContextMenu from '@/components/MkContextMenu.vue';
import type { MenuItem } from '@/types/menu.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { pleaseLogin } from '@/scripts/please-login.js';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';

View File

@ -153,6 +153,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else-if="tab === 'announcements'" class="_gaps">
<MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.new }}</MkButton>
<MkSelect v-model="announcementsStatus">
<template #label>{{ i18n.ts.filter }}</template>
<option value="active">{{ i18n.ts.active }}</option>
<option value="archived">{{ i18n.ts.archived }}</option>
</MkSelect>
<MkPagination :pagination="announcementsPagination">
<template #default="{ items }">
<div class="_gaps_s">
@ -254,11 +260,15 @@ const filesPagination = {
userId: props.userId,
})),
};
const announcementsStatus = ref<'active' | 'archived'>('active');
const announcementsPagination = {
endpoint: 'admin/announcements/list' as const,
limit: 10,
params: computed(() => ({
userId: props.userId,
status: announcementsStatus.value,
})),
};
const expandedRoles = ref([]);

View File

@ -57,6 +57,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-user-x"></i></template>
<template #label>{{ i18n.ts.prohibitedWordsForNameOfUser }}</template>
<div class="_gaps">
<MkTextarea v-model="prohibitedWordsForNameOfUser">
<template #caption>{{ i18n.ts.prohibitedWordsForNameOfUserDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
</MkTextarea>
<MkButton primary @click="save_prohibitedWordsForNameOfUser">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-eye-off"></i></template>
<template #label>{{ i18n.ts.hiddenTags }}</template>
@ -131,6 +143,7 @@ const enableRegistration = ref<boolean>(false);
const emailRequiredForSignup = ref<boolean>(false);
const sensitiveWords = ref<string>('');
const prohibitedWords = ref<string>('');
const prohibitedWordsForNameOfUser = ref<string>('');
const hiddenTags = ref<string>('');
const preservedUsernames = ref<string>('');
const blockedHosts = ref<string>('');
@ -143,10 +156,11 @@ async function init() {
emailRequiredForSignup.value = meta.emailRequiredForSignup;
sensitiveWords.value = meta.sensitiveWords.join('\n');
prohibitedWords.value = meta.prohibitedWords.join('\n');
prohibitedWordsForNameOfUser.value = meta.prohibitedWordsForNameOfUser.join('\n');
hiddenTags.value = meta.hiddenTags.join('\n');
preservedUsernames.value = meta.preservedUsernames.join('\n');
blockedHosts.value = meta.blockedHosts.join('\n');
silencedHosts.value = meta.silencedHosts.join('\n');
silencedHosts.value = meta.silencedHosts?.join('\n') ?? '';
mediaSilencedHosts.value = meta.mediaSilencedHosts.join('\n');
}
@ -190,6 +204,14 @@ function save_prohibitedWords() {
});
}
function save_prohibitedWordsForNameOfUser() {
os.apiWithDialog('admin/update-meta', {
prohibitedWordsForNameOfUser: prohibitedWordsForNameOfUser.value.split('\n'),
}).then(() => {
fetchInstance(true);
});
}
function save_hiddenTags() {
os.apiWithDialog('admin/update-meta', {
hiddenTags: hiddenTags.value.split('\n'),

View File

@ -29,6 +29,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableStatsForFederatedInstances" @change="onChange_enableStatsForFederatedInstances">
<template #label>{{ i18n.ts.enableStatsForFederatedInstances }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableChartsForFederatedInstances" @change="onChange_enableChartsForFederatedInstances">
<template #label>{{ i18n.ts.enableChartsForFederatedInstances }}</template>
@ -120,6 +127,7 @@ const meta = await misskeyApi('admin/meta');
const enableServerMachineStats = ref(meta.enableServerMachineStats);
const enableIdenticonGeneration = ref(meta.enableIdenticonGeneration);
const enableChartsForRemoteUser = ref(meta.enableChartsForRemoteUser);
const enableStatsForFederatedInstances = ref(meta.enableStatsForFederatedInstances);
const enableChartsForFederatedInstances = ref(meta.enableChartsForFederatedInstances);
function onChange_enableServerMachineStats(value: boolean) {
@ -146,6 +154,14 @@ function onChange_enableChartsForRemoteUser(value: boolean) {
});
}
function onChange_enableStatsForFederatedInstances(value: boolean) {
os.apiWithDialog('admin/update-meta', {
enableStatsForFederatedInstances: value,
}).then(() => {
fetchInstance(true);
});
}
function onChange_enableChartsForFederatedInstances(value: boolean) {
os.apiWithDialog('admin/update-meta', {
enableChartsForFederatedInstances: value,

View File

@ -54,6 +54,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="enableCondensedLine">
<template #label>Enable condensed line</template>
</MkSwitch>
<MkSwitch v-model="skipNoteRender">
<template #label>Enable note render skipping</template>
</MkSwitch>
</div>
</MkFolder>
@ -105,9 +108,14 @@ const $i = signinRequired();
const reportError = computed(defaultStore.makeGetterSetter('reportError'));
const enableCondensedLine = computed(defaultStore.makeGetterSetter('enableCondensedLine'));
const skipNoteRender = computed(defaultStore.makeGetterSetter('skipNoteRender'));
const devMode = computed(defaultStore.makeGetterSetter('devMode'));
const defaultWithReplies = computed(defaultStore.makeGetterSetter('defaultWithReplies'));
watch(skipNoteRender, async () => {
await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
});
async function deleteAccount() {
{
const { canceled } = await os.confirm({

View File

@ -142,13 +142,17 @@ const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.d
const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance'));
function assertVaildLang(lang: string | null): lang is keyof typeof langmap {
return lang != null && lang in langmap;
}
const profile = reactive({
name: $i.name,
description: $i.description,
followedMessage: $i.followedMessage,
location: $i.location,
birthday: $i.birthday,
lang: $i.lang,
lang: assertVaildLang($i.lang) ? $i.lang : null,
isBot: $i.isBot ?? false,
isCat: $i.isCat ?? false,
});
@ -202,6 +206,11 @@ function save() {
lang: profile.lang || null,
isBot: !!profile.isBot,
isCat: !!profile.isCat,
}, undefined, {
'0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191': {
title: i18n.ts.yourNameContainsProhibitedWords,
text: i18n.ts.yourNameContainsProhibitedWordsDescription,
},
});
globalEvents.emit('requestClearPageCache');
claimAchievement('profileFilled');

View File

@ -245,13 +245,10 @@ export function getNoteMenu(props: {
function togglePin(pin: boolean): void {
os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
noteId: appearNote.id,
}, undefined, null, res => {
if (res.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
os.alert({
type: 'error',
text: i18n.ts.pinLimitExceeded,
});
}
}, undefined, {
'72dab508-c64d-498f-8740-a8eec1ba385a': {
text: i18n.ts.pinLimitExceeded,
},
});
}

View File

@ -468,6 +468,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: 'app' as 'app' | 'appWithShift' | 'native',
},
skipNoteRender: {
where: 'device',
default: true,
},
sound_masterVolume: {
where: 'device',

View File

@ -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); }

View File

@ -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;

View File

@ -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;

View File

@ -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 {

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2024.10.1-beta.3",
"version": "2024.10.1",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",

View File

@ -5048,7 +5048,7 @@ export type components = {
latestSentAt: string | null;
latestStatus: number | null;
name: string;
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
url: string;
secret: string;
};
@ -5124,6 +5124,7 @@ export type operations = {
blockedHosts: string[];
sensitiveWords: string[];
prohibitedWords: string[];
prohibitedWordsForNameOfUser: string[];
bannedEmailDomains?: string[];
preservedUsernames: string[];
hcaptchaSecretKey: string | null;
@ -5164,6 +5165,7 @@ export type operations = {
truemailAuthKey: string | null;
enableChartsForRemoteUser: boolean;
enableChartsForFederatedInstances: boolean;
enableStatsForFederatedInstances: boolean;
enableServerMachineStats: boolean;
enableIdenticonGeneration: boolean;
manifestJsonOverride: string;
@ -9461,6 +9463,7 @@ export type operations = {
blockedHosts?: string[] | null;
sensitiveWords?: string[] | null;
prohibitedWords?: string[] | null;
prohibitedWordsForNameOfUser?: string[] | null;
themeColor?: string | null;
mascotImageUrl?: string | null;
bannerUrl?: string | null;
@ -9545,6 +9548,7 @@ export type operations = {
truemailAuthKey?: string | null;
enableChartsForRemoteUser?: boolean;
enableChartsForFederatedInstances?: boolean;
enableStatsForFederatedInstances?: boolean;
enableServerMachineStats?: boolean;
enableIdenticonGeneration?: boolean;
serverRules?: string[];
@ -10245,7 +10249,7 @@ export type operations = {
'application/json': {
isActive: boolean;
name: string;
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
url: string;
secret: string;
};
@ -10355,7 +10359,7 @@ export type operations = {
content: {
'application/json': {
isActive?: boolean;
on?: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
on?: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
};
};
};
@ -10468,7 +10472,7 @@ export type operations = {
id: string;
isActive: boolean;
name: string;
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
url: string;
secret: string;
};
@ -10527,7 +10531,7 @@ export type operations = {
/** Format: misskey:id */
webhookId: string;
/** @enum {string} */
type: 'abuseReport' | 'abuseReportResolved' | 'userCreated';
type: 'abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged';
override?: {
url?: string;
secret?: string;

View File

@ -6,6 +6,7 @@ export default [
{
files: ['**/*.cjs'],
languageOptions: {
sourceType: 'commonjs',
parserOptions: {
sourceType: 'commonjs',
},
@ -25,4 +26,10 @@ export default [
globals: globals.node,
},
},
{
files: ['**/*.js', '**/*.cjs'],
rules: {
'@typescript-eslint/no-var-requires': 'off',
},
},
];