Compare commits

..

66 Commits

Author SHA1 Message Date
syuilo
77498f84d8 wip 2023-10-03 20:16:00 +09:00
syuilo
0575207463 Merge branch 'develop' into tl-push 2023-10-03 18:34:26 +09:00
syuilo
5ee93dc4a2 Update about-misskey.vue 2023-10-03 18:34:04 +09:00
syuilo
10ae0b329a enhance(frontend): tweak ui 2023-10-03 18:33:22 +09:00
syuilo
7a3dd400d8 Update NoteCreateService.ts 2023-10-03 18:17:06 +09:00
syuilo
e840544dd2 refactor: UserListJoining -> UserListMembership 2023-10-03 18:07:58 +09:00
syuilo
0e58f515fd Update activitypub.ts 2023-10-03 17:08:05 +09:00
syuilo
c3714c02ba test 2023-10-03 16:26:58 +09:00
syuilo
9de11da170 Merge branch 'develop' into tl-push 2023-10-03 15:33:33 +09:00
syuilo
e12943c15b wip 2023-10-03 15:32:29 +09:00
syuilo
7022b16bce Update timelines.ts 2023-10-03 14:10:49 +09:00
syuilo
ee3d40bc1b wip 2023-10-03 14:07:49 +09:00
syuilo
878e73cd37 Update timelines.ts 2023-10-03 13:56:10 +09:00
syuilo
96da6e28ea wip 2023-10-03 13:47:50 +09:00
syuilo
45c3ab2142 wip 2023-10-03 13:35:31 +09:00
syuilo
000abcd2f0 Update CHANGELOG.md 2023-10-03 11:28:26 +09:00
YAVIIGI
e00fdc2d59 fix(frontend): use-tooltip の呼び出し元の UI が無くなったら自動的に削除されるようにする (#11949)
* Update use-tooltip.ts

* Update CHANGELOG.md
2023-10-03 11:27:51 +09:00
syuilo
58eec94250 wip 2023-10-03 11:03:23 +09:00
syuilo
b66df850e5 Update timelines.ts 2023-10-03 10:58:10 +09:00
syuilo
72d5b1f4ae Update timelines.ts 2023-10-03 10:44:09 +09:00
syuilo
15caa375a5 Update misskey-js.api.md 2023-10-03 10:37:50 +09:00
syuilo
0e302c69bd Update timelines.ts 2023-10-03 10:36:10 +09:00
syuilo
236eed94bb wip 2023-10-03 10:28:01 +09:00
syuilo
6d68cfd1e3 wip 2023-10-03 10:22:57 +09:00
syuilo
ea0d050b71 wip 2023-10-03 10:13:26 +09:00
syuilo
d6ff810560 Update timelines.ts 2023-10-03 09:55:44 +09:00
syuilo
aad48b4b24 Update timelines.ts 2023-10-03 09:42:37 +09:00
syuilo
2f00e4b2b1 Update timelines.ts 2023-10-03 09:38:32 +09:00
syuilo
152047ca14 wip 2023-10-03 09:29:20 +09:00
syuilo
58d2512d0e Update timeline.ts 2023-10-03 09:28:37 +09:00
syuilo
bbcda73af8 Update timeline.ts 2023-10-03 09:24:11 +09:00
syuilo
880448d068 Update timeline.ts 2023-10-02 21:39:40 +09:00
syuilo
55e5056216 Update timeline.ts 2023-10-02 21:34:02 +09:00
syuilo
d40f35b3ad Update timeline.ts 2023-10-02 21:25:57 +09:00
syuilo
8843669684 wip 2023-10-02 21:18:05 +09:00
syuilo
c7c4c7807a Update NoteCreateService.ts 2023-10-02 18:04:34 +09:00
syuilo
79e5075564 wip 2023-10-02 18:02:25 +09:00
syuilo
caca0da912 wip 2023-10-02 17:23:01 +09:00
syuilo
35e743c955 wip 2023-10-02 15:37:09 +09:00
syuilo
6f17993cba Update user-notes.ts 2023-10-02 13:24:51 +09:00
syuilo
e4de402ca1 wip 2023-10-02 12:59:03 +09:00
syuilo
0db117b0ab Update NoteCreateService.ts 2023-10-02 10:45:48 +09:00
syuilo
cb821d42a6 wip 2023-10-02 10:42:26 +09:00
syuilo
b4c1de11f5 Update NoteCreateService.ts 2023-10-02 10:38:42 +09:00
syuilo
3924a9e494 wip 2023-10-02 08:31:33 +09:00
syuilo
d9aac112d3 wip 2023-10-02 08:21:34 +09:00
syuilo
7f4c00541c wip 2023-10-02 08:15:21 +09:00
syuilo
85430fd889 wip 2023-10-02 08:04:06 +09:00
syuilo
f0a2c3ce76 Update NoteCreateService.ts 2023-10-02 02:34:04 +09:00
syuilo
c019e9cad5 wip 2023-10-02 02:27:28 +09:00
syuilo
167aaabf20 wip 2023-10-02 02:21:18 +09:00
syuilo
72f7413f40 wip 2023-10-01 21:11:12 +09:00
syuilo
783a97fe06 wip 2023-10-01 20:38:18 +09:00
syuilo
3dd3c69303 wip 2023-10-01 20:25:51 +09:00
syuilo
06cfe618bb wip 2023-10-01 20:12:46 +09:00
あすぱる
6840434661 change request.routerPath to requrest.routeOptions.url (#11935) 2023-09-30 14:44:16 +09:00
FineArchs
09dfb9bde3 feat: AiScriptでホストのアドレスを参照できる定数 (#11924)
* add HOST_URL

* Update CHANGELOG.md

* tweak

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-09-30 09:39:21 +09:00
syuilo
b0714cbd7b [ci skip] New Crowdin updates (#11922)
* New translations ja-jp.yml (German)

* New translations ja-jp.yml (Korean)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (German)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (Thai)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Chinese Simplified)
2023-09-30 09:35:36 +09:00
syuilo
d0917aac1a fix test 2023-09-30 08:56:37 +09:00
syuilo
ff6600da2e Update CHANGELOG.md 2023-09-30 08:12:46 +09:00
syuilo
7e74cff126 後方互換性の強化 2023-09-30 08:12:25 +09:00
syuilo
e53749773e 2023.9.3 2023-09-30 08:03:05 +09:00
syuilo
392de4df36 enhance: ノートの翻訳機能の利用可否をロールで設定可能に
Resolve #11923
2023-09-30 07:54:11 +09:00
syuilo
cc6a96e1c9 enhance(front)end: improve moderation log 2023-09-30 07:42:38 +09:00
syuilo
0e681f3cc4 Update CHANGELOG.md 2023-09-30 07:34:52 +09:00
syuilo
a512915a84 fix(backend): Redisに古いMisskeyバージョンのキャッシュが残っている場合の問題を修正 2023-09-30 07:33:58 +09:00
103 changed files with 2059 additions and 973 deletions

View File

@@ -95,6 +95,14 @@ redis:
# #prefix: example-prefix # #prefix: example-prefix
# #db: 1 # #db: 1
#redisForTimelines:
# host: redis
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
# #prefix: example-prefix
# #db: 1
# ┌───────────────────────────┐ # ┌───────────────────────────┐
#───┘ MeiliSearch configuration └───────────────────────────── #───┘ MeiliSearch configuration └─────────────────────────────

View File

@@ -105,6 +105,16 @@ redis:
# # You can specify more ioredis options... # # You can specify more ioredis options...
# #username: example-username # #username: example-username
#redisForTimelines:
# host: localhost
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
# #prefix: example-prefix
# #db: 1
# # You can specify more ioredis options...
# #username: example-username
# ┌───────────────────────────┐ # ┌───────────────────────────┐
#───┘ MeiliSearch configuration └───────────────────────────── #───┘ MeiliSearch configuration └─────────────────────────────

View File

@@ -95,6 +95,14 @@ redis:
# #prefix: example-prefix # #prefix: example-prefix
# #db: 1 # #db: 1
#redisForTimelines:
# host: redis
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
# #prefix: example-prefix
# #db: 1
# ┌───────────────────────────┐ # ┌───────────────────────────┐
#───┘ MeiliSearch configuration └───────────────────────────── #───┘ MeiliSearch configuration └─────────────────────────────

View File

@@ -12,6 +12,38 @@
--> -->
## 2023.10.0
### NOTE
- muted_noteテーブルは使われなくなったため手動で削除を行ってください。
### Changes
- API: users/notes, notes/local-timeline で fileType 指定はできなくなりました
- API: notes/global-timeline は現在常に `[]` を返します
### General
- ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました
- ユーザーリスト内のメンバーごとに他ユーザーへの返信をユーザーリストタイムラインに含めるか設定可能になりました
- ソフトワードミュートとハードワードミュートは統合されました
### Client
- Fix: リアクションしたユーザ一覧のUIが稀に左上に残ってしまう不具合を修正
### Server
- タイムライン取得時のパフォーマンスを改善
## 2023.9.3
### General
- Enhance: ノートの翻訳機能の利用可否をロールで設定可能に
### Client
- Enhance: AiScriptでホストのアドレスを参照する定数`SERVER_URL`を追加
- Enhance: モデレーションログ機能の強化
- Enhance: ローカリゼーションの更新
### Server
- Fix: Redisに古いバージョンのキャッシュが残っている場合、キャッシュが消えるまでの間通知が届かなくなる問題を修正
- Fix: 後方互換性の修正
## 2023.9.2 ## 2023.9.2
### General ### General

View File

@@ -116,6 +116,14 @@ redis:
# #prefix: example-prefix # #prefix: example-prefix
# #db: 1 # #db: 1
#redisForTimelines:
# host: redis
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
# #prefix: example-prefix
# #db: 1
# ┌───────────────────────────┐ # ┌───────────────────────────┐
#───┘ MeiliSearch configuration └───────────────────────────── #───┘ MeiliSearch configuration └─────────────────────────────

View File

@@ -1123,6 +1123,9 @@ authenticationRequiredToContinue: "Bitte authentifiziere dich, um fortzufahren"
dateAndTime: "Zeit" dateAndTime: "Zeit"
showRenotes: "Renotes anzeigen" showRenotes: "Renotes anzeigen"
edited: "Bearbeitet" edited: "Bearbeitet"
notificationRecieveConfig: "Benachrichtigungseinstellungen"
mutualFollow: "Gegenseitig gefolgt"
fileAttachedOnly: "Nur Notizen mit Dateien"
_announcement: _announcement:
forExistingUsers: "Nur für existierende Nutzer" forExistingUsers: "Nur für existierende Nutzer"
forExistingUsersDescription: "Ist diese Option aktiviert, wird diese Ankündigung nur Nutzern angezeigt, die zum Zeitpunkt der Ankündigung bereits registriert sind. Ist sie deaktiviert, wird sie auch Nutzern, die sich nach dessen Veröffentlichung registrieren, angezeigt." forExistingUsersDescription: "Ist diese Option aktiviert, wird diese Ankündigung nur Nutzern angezeigt, die zum Zeitpunkt der Ankündigung bereits registriert sind. Ist sie deaktiviert, wird sie auch Nutzern, die sich nach dessen Veröffentlichung registrieren, angezeigt."
@@ -2132,3 +2135,6 @@ _moderationLogTypes:
unmarkSensitiveDriveFile: "Datei als nicht sensitiv markiert" unmarkSensitiveDriveFile: "Datei als nicht sensitiv markiert"
resolveAbuseReport: "Meldung bearbeitet" resolveAbuseReport: "Meldung bearbeitet"
createInvitation: "Einladung erstellt" createInvitation: "Einladung erstellt"
createAd: "Werbung erstellt"
deleteAd: "Werbung gelöscht"
updateAd: "Werbung aktualisiert"

View File

@@ -1123,6 +1123,9 @@ authenticationRequiredToContinue: "Please authenticate to continue"
dateAndTime: "Timestamp" dateAndTime: "Timestamp"
showRenotes: "Show renotes" showRenotes: "Show renotes"
edited: "Edited" edited: "Edited"
notificationRecieveConfig: "Notification Settings"
mutualFollow: "Mutual follow"
fileAttachedOnly: "Only notes with files"
_announcement: _announcement:
forExistingUsers: "Existing users only" forExistingUsers: "Existing users only"
forExistingUsersDescription: "This announcement will only be shown to users existing at the point of publishment if enabled. If disabled, those newly signing up after it has been posted will also see it." forExistingUsersDescription: "This announcement will only be shown to users existing at the point of publishment if enabled. If disabled, those newly signing up after it has been posted will also see it."
@@ -2132,3 +2135,6 @@ _moderationLogTypes:
unmarkSensitiveDriveFile: "File unmarked as sensitive" unmarkSensitiveDriveFile: "File unmarked as sensitive"
resolveAbuseReport: "Report resolved" resolveAbuseReport: "Report resolved"
createInvitation: "Invite generated" createInvitation: "Invite generated"
createAd: "Ad created"
deleteAd: "Ad deleted"
updateAd: "Ad updated"

8
locales/index.d.ts vendored
View File

@@ -1129,6 +1129,8 @@ export interface Locale {
"notificationRecieveConfig": string; "notificationRecieveConfig": string;
"mutualFollow": string; "mutualFollow": string;
"fileAttachedOnly": string; "fileAttachedOnly": string;
"showRepliesToOthersInTimeline": string;
"hideRepliesToOthersInTimeline": string;
"_announcement": { "_announcement": {
"forExistingUsers": string; "forExistingUsers": string;
"forExistingUsersDescription": string; "forExistingUsersDescription": string;
@@ -1562,6 +1564,7 @@ export interface Locale {
"descriptionOfRateLimitFactor": string; "descriptionOfRateLimitFactor": string;
"canHideAds": string; "canHideAds": string;
"canSearchNotes": string; "canSearchNotes": string;
"canUseTranslator": string;
}; };
"_condition": { "_condition": {
"isLocal": string; "isLocal": string;
@@ -1718,11 +1721,6 @@ export interface Locale {
"muteWords": string; "muteWords": string;
"muteWordsDescription": string; "muteWordsDescription": string;
"muteWordsDescription2": string; "muteWordsDescription2": string;
"softDescription": string;
"hardDescription": string;
"soft": string;
"hard": string;
"mutedNotes": string;
}; };
"_instanceMute": { "_instanceMute": {
"instanceMuteDescription": string; "instanceMuteDescription": string;

View File

@@ -1126,6 +1126,8 @@ edited: "編集済み"
notificationRecieveConfig: "通知の受信設定" notificationRecieveConfig: "通知の受信設定"
mutualFollow: "相互フォロー" mutualFollow: "相互フォロー"
fileAttachedOnly: "ファイル付きのみ" fileAttachedOnly: "ファイル付きのみ"
showRepliesToOthersInTimeline: "TLに他の人への返信を含める"
hideRepliesToOthersInTimeline: "TLに他の人への返信を含めない"
_announcement: _announcement:
forExistingUsers: "既存ユーザーのみ" forExistingUsers: "既存ユーザーのみ"
@@ -1482,7 +1484,8 @@ _role:
rateLimitFactor: "レートリミット" rateLimitFactor: "レートリミット"
descriptionOfRateLimitFactor: "小さいほど制限が緩和され、大きいほど制限が強化されます。" descriptionOfRateLimitFactor: "小さいほど制限が緩和され、大きいほど制限が強化されます。"
canHideAds: "広告の非表示" canHideAds: "広告の非表示"
canSearchNotes: "ノート検索の利用可否" canSearchNotes: "ノート検索の利用"
canUseTranslator: "翻訳機能の利用"
_condition: _condition:
isLocal: "ローカルユーザー" isLocal: "ローカルユーザー"
isRemote: "リモートユーザー" isRemote: "リモートユーザー"
@@ -1635,11 +1638,6 @@ _wordMute:
muteWords: "ミュートするワード" muteWords: "ミュートするワード"
muteWordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。" muteWordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。"
muteWordsDescription2: "キーワードをスラッシュで囲むと正規表現になります。" muteWordsDescription2: "キーワードをスラッシュで囲むと正規表現になります。"
softDescription: "指定した条件のノートをタイムラインから隠します。"
hardDescription: "指定した条件のノートをタイムラインに追加しないようにします。追加されなかったノートは、条件を変更しても除外されたままになります。"
soft: "ソフト"
hard: "ハード"
mutedNotes: "ミュートされたノート"
_instanceMute: _instanceMute:
instanceMuteDescription: "ミュートしたサーバーのユーザーへの返信を含めて、設定したサーバーの全てのートとRenoteをミュートします。" instanceMuteDescription: "ミュートしたサーバーのユーザーへの返信を含めて、設定したサーバーの全てのートとRenoteをミュートします。"

View File

@@ -416,6 +416,9 @@ totp: "인증 앱"
totpDescription: "인증 앱을 사용하여 일회성 비밀번호 입력" totpDescription: "인증 앱을 사용하여 일회성 비밀번호 입력"
moderator: "모더레이터" moderator: "모더레이터"
moderation: "모더레이션" moderation: "모더레이션"
moderationNote: "모더레이션 노트"
addModerationNote: "모더레이션 노트 추가하기"
moderationLogs: "모더레이션 로그"
nUsersMentioned: "{n}명이 언급함" nUsersMentioned: "{n}명이 언급함"
securityKeyAndPasskey: "보안 키 또는 패스 키" securityKeyAndPasskey: "보안 키 또는 패스 키"
securityKey: "보안 키" securityKey: "보안 키"
@@ -1107,6 +1110,18 @@ youHaveUnreadAnnouncements: "읽지 않은 공지사항이 있습니다."
useSecurityKey: "브라우저 또는 기기의 안내에 따라 보안 키 또는 패스키를 사용해 주십시오." useSecurityKey: "브라우저 또는 기기의 안내에 따라 보안 키 또는 패스키를 사용해 주십시오."
replies: "답글" replies: "답글"
renotes: "리노트" renotes: "리노트"
loadReplies: "답글 보기"
loadConversation: "대화 보기"
pinnedList: "고정해놓은 리스트"
keepScreenOn: "기기 화면을 항상 켜기"
verifiedLink: "이 링크의 소유자임이 확인되었습니다."
notifyNotes: "새 노트 알림 켜기"
unnotifyNotes: "새 노트 알림 끄기"
authentication: "인증"
showRenotes: "리노트 표시"
edited: "수정됨"
notificationRecieveConfig: "알림 설정"
mutualFollow: "맞팔로우"
_announcement: _announcement:
forExistingUsers: "기존 유저에게만 알림" forExistingUsers: "기존 유저에게만 알림"
forExistingUsersDescription: "활성화하면 이 공지사항을 게시한 시점에서 이미 가입한 유저에게만 표시합니다. 비활성화하면 게시 후에 가입한 유저에게도 표시합니다." forExistingUsersDescription: "활성화하면 이 공지사항을 게시한 시점에서 이미 가입한 유저에게만 표시합니다. 비활성화하면 게시 후에 가입한 유저에게도 표시합니다."
@@ -1135,6 +1150,12 @@ _serverRules:
description: "회원 가입 이전에 간단하게 표시할 서버 규칙입니다. 이용 약관의 요약으로 구성하는 것을 추천합니다." description: "회원 가입 이전에 간단하게 표시할 서버 규칙입니다. 이용 약관의 요약으로 구성하는 것을 추천합니다."
_serverSettings: _serverSettings:
iconUrl: "아이콘 URL" iconUrl: "아이콘 URL"
appIconUsageExample: "예를 들어, PWA나 스마트폰 홈 화면에 북마크로 추가되었을 때 등"
appIconStyleRecommendation: "아이콘이 원형 또는 둥근 사각형으로 잘리는 경우가 있으므로, 가장자리 여백이 충분한 사진을 사용하는 것을 추천합니다."
appIconResolutionMustBe: "해상도는 반드시 {resolution} 이어야 합니다."
manifestJsonOverride: "manifest.json 오버라이드"
shortName: "약칭"
shortNameDescription: "서버의 정식 명칭이 긴 경우에, 대신에 표시할 수 있는 약칭이나 통칭."
_accountMigration: _accountMigration:
moveFrom: "다른 계정에서 이 계정으로 이사" moveFrom: "다른 계정에서 이 계정으로 이사"
moveFromSub: "다른 계정에 대한 별칭을 생성" moveFromSub: "다른 계정에 대한 별칭을 생성"

View File

@@ -1120,6 +1120,9 @@ authentication: "การตรวจสอบสิทธิ์"
dateAndTime: "เวลาประทับ" dateAndTime: "เวลาประทับ"
showRenotes: "แสดงรีโน้ต" showRenotes: "แสดงรีโน้ต"
edited: "แก้ไขแล้ว" edited: "แก้ไขแล้ว"
notificationRecieveConfig: "การตั้งค่าการแจ้งเตือน"
mutualFollow: "ติดตามซึ่งกันและกัน"
fileAttachedOnly: "เฉพาะโน้ตที่มีไฟล์เท่านั้น"
_announcement: _announcement:
forExistingUsers: "ผู้ใช้งานที่มีอยู่เท่านั้น" forExistingUsers: "ผู้ใช้งานที่มีอยู่เท่านั้น"
forExistingUsersDescription: "การประกาศนี้จะแสดงต่อผู้ใช้ที่มีอยู่ ณ จุดที่เผยแพร่นั้นๆถ้าหากเปิดใช้งาน ถ้าหากปิดใช้งานผู้ที่กำลังสมัครใหม่หลังจากโพสต์แล้วนั้นก็จะเห็นเช่นกัน" forExistingUsersDescription: "การประกาศนี้จะแสดงต่อผู้ใช้ที่มีอยู่ ณ จุดที่เผยแพร่นั้นๆถ้าหากเปิดใช้งาน ถ้าหากปิดใช้งานผู้ที่กำลังสมัครใหม่หลังจากโพสต์แล้วนั้นก็จะเห็นเช่นกัน"
@@ -2104,3 +2107,6 @@ _moderationLogTypes:
resetPassword: "รีเซ็ตรหัสผ่าน" resetPassword: "รีเซ็ตรหัสผ่าน"
resolveAbuseReport: "รายงานได้รับการแก้ไขแล้ว" resolveAbuseReport: "รายงานได้รับการแก้ไขแล้ว"
createInvitation: "สร้างคำเชิญ" createInvitation: "สร้างคำเชิญ"
createAd: "สร้างโฆษณาแล้ว"
deleteAd: "ลบโฆษณาออกแล้ว"
updateAd: "อัปเดตโฆษณาแล้ว"

View File

@@ -1123,6 +1123,9 @@ authenticationRequiredToContinue: "要继续,请先进行验证"
dateAndTime: "日期和时间" dateAndTime: "日期和时间"
showRenotes: "显示转帖" showRenotes: "显示转帖"
edited: "已编辑" edited: "已编辑"
notificationRecieveConfig: "通知接收设置"
mutualFollow: "互相关注"
fileAttachedOnly: "仅限媒体"
_announcement: _announcement:
forExistingUsers: "仅限现有用户" forExistingUsers: "仅限现有用户"
forExistingUsersDescription: "若启用,该公告将仅对创建此公告时存在的用户可见。 如果禁用,则在创建此公告后注册的用户也可以看到该公告。" forExistingUsersDescription: "若启用,该公告将仅对创建此公告时存在的用户可见。 如果禁用,则在创建此公告后注册的用户也可以看到该公告。"
@@ -2130,3 +2133,6 @@ _moderationLogTypes:
unmarkSensitiveDriveFile: "取消标记网盘文件为敏感媒体" unmarkSensitiveDriveFile: "取消标记网盘文件为敏感媒体"
resolveAbuseReport: "处理举报" resolveAbuseReport: "处理举报"
createInvitation: "发行邀请码" createInvitation: "发行邀请码"
createAd: "创建了广告"
deleteAd: "删除了广告"
updateAd: "更新了广告"

View File

@@ -1122,6 +1122,8 @@ authentication: "驗證"
authenticationRequiredToContinue: "請於繼續前完成驗證" authenticationRequiredToContinue: "請於繼續前完成驗證"
dateAndTime: "日期與時間" dateAndTime: "日期與時間"
showRenotes: "顯示轉發貼文" showRenotes: "顯示轉發貼文"
edited: "已編輯"
mutualFollow: "互相追隨"
_announcement: _announcement:
forExistingUsers: "僅限既有的使用者" forExistingUsers: "僅限既有的使用者"
forExistingUsersDescription: "啟用代表僅向現存使用者顯示;停用代表張貼後註冊的新使用者也會看到。" forExistingUsersDescription: "啟用代表僅向現存使用者顯示;停用代表張貼後註冊的新使用者也會看到。"
@@ -2131,3 +2133,6 @@ _moderationLogTypes:
unmarkSensitiveDriveFile: "撤銷標記為敏感檔案" unmarkSensitiveDriveFile: "撤銷標記為敏感檔案"
resolveAbuseReport: "解決檢舉" resolveAbuseReport: "解決檢舉"
createInvitation: "建立邀請碼" createInvitation: "建立邀請碼"
createAd: "建立廣告"
deleteAd: "刪除廣告"
updateAd: "更新廣告"

View File

@@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2023.9.2", "version": "2023.9.3",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@@ -216,4 +216,6 @@ module.exports = {
maxWorkers: 1, // Make it use worker (that can be killed and restarted) maxWorkers: 1, // Make it use worker (that can be killed and restarted)
logHeapUsage: true, // To debug when out-of-memory happens on CI logHeapUsage: true, // To debug when out-of-memory happens on CI
workerIdleMemoryLimit: '1GiB', // Limit the worker to 1GB (GitHub Workflows dies at 2GB) workerIdleMemoryLimit: '1GiB', // Limit the worker to 1GB (GitHub Workflows dies at 2GB)
maxConcurrency: 32,
}; };

View File

@@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class WithReplies1696222183852 {
name = 'WithReplies1696222183852'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "following" ADD "withReplies" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "user_list_joining" ADD "withReplies" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`CREATE INDEX "IDX_d74d8ab5efa7e3bb82825c0fa2" ON "following" ("followeeId", "followerHost") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_d74d8ab5efa7e3bb82825c0fa2"`);
await queryRunner.query(`ALTER TABLE "user_list_joining" DROP COLUMN "withReplies"`);
await queryRunner.query(`ALTER TABLE "following" DROP COLUMN "withReplies"`);
}
}

View File

@@ -0,0 +1,11 @@
export class UserListMembership1696323464251 {
name = 'UserListMembership1696323464251'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list_joining" RENAME TO "user_list_membership"`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list_membership" RENAME TO "user_list_joining"`);
}
}

View File

@@ -0,0 +1,17 @@
export class Hibernation1696331570827 {
name = 'Hibernation1696331570827'
async up(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_d74d8ab5efa7e3bb82825c0fa2"`);
await queryRunner.query(`ALTER TABLE "user" ADD "isHibernated" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "following" ADD "isFollowerHibernated" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`CREATE INDEX "IDX_ce62b50d882d4e9dee10ad0d2f" ON "following" ("followeeId", "followerHost", "isFollowerHibernated") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_ce62b50d882d4e9dee10ad0d2f"`);
await queryRunner.query(`ALTER TABLE "following" DROP COLUMN "isFollowerHibernated"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isHibernated"`);
await queryRunner.query(`CREATE INDEX "IDX_d74d8ab5efa7e3bb82825c0fa2" ON "following" ("followeeId", "followerHost") `);
}
}

View File

@@ -70,11 +70,19 @@ const $redisForSub: Provider = {
inject: [DI.config], inject: [DI.config],
}; };
const $redisForTimelines: Provider = {
provide: DI.redisForTimelines,
useFactory: (config: Config) => {
return new Redis.Redis(config.redisForTimelines);
},
inject: [DI.config],
};
@Global() @Global()
@Module({ @Module({
imports: [RepositoryModule], imports: [RepositoryModule],
providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub], providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines],
exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, RepositoryModule], exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, RepositoryModule],
}) })
export class GlobalModule implements OnApplicationShutdown { export class GlobalModule implements OnApplicationShutdown {
constructor( constructor(
@@ -82,6 +90,7 @@ export class GlobalModule implements OnApplicationShutdown {
@Inject(DI.redis) private redisClient: Redis.Redis, @Inject(DI.redis) private redisClient: Redis.Redis,
@Inject(DI.redisForPub) private redisForPub: Redis.Redis, @Inject(DI.redisForPub) private redisForPub: Redis.Redis,
@Inject(DI.redisForSub) private redisForSub: Redis.Redis, @Inject(DI.redisForSub) private redisForSub: Redis.Redis,
@Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis,
) {} ) {}
public async dispose(): Promise<void> { public async dispose(): Promise<void> {
@@ -98,6 +107,7 @@ export class GlobalModule implements OnApplicationShutdown {
this.redisClient.disconnect(), this.redisClient.disconnect(),
this.redisForPub.disconnect(), this.redisForPub.disconnect(),
this.redisForSub.disconnect(), this.redisForSub.disconnect(),
this.redisForTimelines.disconnect(),
]); ]);
} }

View File

@@ -47,6 +47,7 @@ type Source = {
redis: RedisOptionsSource; redis: RedisOptionsSource;
redisForPubsub?: RedisOptionsSource; redisForPubsub?: RedisOptionsSource;
redisForJobQueue?: RedisOptionsSource; redisForJobQueue?: RedisOptionsSource;
redisForTimelines?: RedisOptionsSource;
meilisearch?: { meilisearch?: {
host: string; host: string;
port: string; port: string;
@@ -161,6 +162,7 @@ export type Config = {
redis: RedisOptions & RedisOptionsSource; redis: RedisOptions & RedisOptionsSource;
redisForPubsub: RedisOptions & RedisOptionsSource; redisForPubsub: RedisOptions & RedisOptionsSource;
redisForJobQueue: RedisOptions & RedisOptionsSource; redisForJobQueue: RedisOptions & RedisOptionsSource;
redisForTimelines: RedisOptions & RedisOptionsSource;
perChannelMaxNoteCacheCount: number; perChannelMaxNoteCacheCount: number;
perUserNotificationsMaxCount: number; perUserNotificationsMaxCount: number;
deactivateAntennaThreshold: number; deactivateAntennaThreshold: number;
@@ -227,6 +229,7 @@ export function loadConfig(): Config {
redis, redis,
redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis, redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis,
redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis, redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis,
redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis,
id: config.id, id: config.id,
proxy: config.proxy, proxy: config.proxy,
proxySmtp: config.proxySmtp, proxySmtp: config.proxySmtp,

View File

@@ -9,7 +9,7 @@ import { IsNull, In, MoreThan, Not } from 'typeorm';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListJoiningsRepository, UsersRepository } from '@/models/_.js'; import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListMembershipsRepository, UsersRepository } from '@/models/_.js';
import type { RelationshipJobData, ThinUser } from '@/queue/types.js'; import type { RelationshipJobData, ThinUser } from '@/queue/types.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
@@ -42,8 +42,8 @@ export class AccountMoveService {
@Inject(DI.mutingsRepository) @Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository, private mutingsRepository: MutingsRepository,
@Inject(DI.userListJoiningsRepository) @Inject(DI.userListMembershipsRepository)
private userListJoiningsRepository: UserListJoiningsRepository, private userListMembershipsRepository: UserListMembershipsRepository,
@Inject(DI.instancesRepository) @Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository, private instancesRepository: InstancesRepository,
@@ -215,40 +215,40 @@ export class AccountMoveService {
@bindThis @bindThis
public async updateLists(src: ThinUser, dst: MiUser): Promise<void> { public async updateLists(src: ThinUser, dst: MiUser): Promise<void> {
// Return if there is no list to be updated. // Return if there is no list to be updated.
const oldJoinings = await this.userListJoiningsRepository.find({ const oldMemberships = await this.userListMembershipsRepository.find({
where: { where: {
userId: src.id, userId: src.id,
}, },
}); });
if (oldJoinings.length === 0) return; if (oldMemberships.length === 0) return;
const existingUserListIds = await this.userListJoiningsRepository.find({ const existingUserListIds = await this.userListMembershipsRepository.find({
where: { where: {
userId: dst.id, userId: dst.id,
}, },
}).then(joinings => joinings.map(joining => joining.userListId)); }).then(memberships => memberships.map(membership => membership.userListId));
const newJoinings: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map(); const newMemberships: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map();
// 重複しないようにIDを生成 // 重複しないようにIDを生成
const genId = (): string => { const genId = (): string => {
let id: string; let id: string;
do { do {
id = this.idService.genId(); id = this.idService.genId();
} while (newJoinings.has(id)); } while (newMemberships.has(id));
return id; return id;
}; };
for (const joining of oldJoinings) { for (const membership of oldMemberships) {
if (existingUserListIds.includes(joining.userListId)) continue; // skip if dst exists in this user's list if (existingUserListIds.includes(membership.userListId)) continue; // skip if dst exists in this user's list
newJoinings.set(genId(), { newMemberships.set(genId(), {
createdAt: new Date(), createdAt: new Date(),
userId: dst.id, userId: dst.id,
userListId: joining.userListId, userListId: membership.userListId,
}); });
} }
const arrayToInsert = Array.from(newJoinings.entries()).map(entry => ({ ...entry[1], id: entry[0] })); const arrayToInsert = Array.from(newMemberships.entries()).map(entry => ({ ...entry[1], id: entry[0] }));
await this.userListJoiningsRepository.insert(arrayToInsert); await this.userListMembershipsRepository.insert(arrayToInsert);
// Have the proxy account follow the new account in the same way as UserListService.push // Have the proxy account follow the new account in the same way as UserListService.push
if (this.userEntityService.isRemoteUser(dst)) { if (this.userEntityService.isRemoteUser(dst)) {

View File

@@ -12,7 +12,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
import * as Acct from '@/misc/acct.js'; import * as Acct from '@/misc/acct.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { AntennasRepository, UserListJoiningsRepository } from '@/models/_.js'; import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js';
@@ -24,8 +24,8 @@ export class AntennaService implements OnApplicationShutdown {
private antennas: MiAntenna[]; private antennas: MiAntenna[];
constructor( constructor(
@Inject(DI.redis) @Inject(DI.redisForTimelines)
private redisClient: Redis.Redis, private redisForTimelines: Redis.Redis,
@Inject(DI.redisForSub) @Inject(DI.redisForSub)
private redisForSub: Redis.Redis, private redisForSub: Redis.Redis,
@@ -33,8 +33,8 @@ export class AntennaService implements OnApplicationShutdown {
@Inject(DI.antennasRepository) @Inject(DI.antennasRepository)
private antennasRepository: AntennasRepository, private antennasRepository: AntennasRepository,
@Inject(DI.userListJoiningsRepository) @Inject(DI.userListMembershipsRepository)
private userListJoiningsRepository: UserListJoiningsRepository, private userListMembershipsRepository: UserListMembershipsRepository,
private utilityService: UtilityService, private utilityService: UtilityService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
@@ -81,7 +81,7 @@ export class AntennaService implements OnApplicationShutdown {
const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const))); const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const)));
const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna); const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna);
const redisPipeline = this.redisClient.pipeline(); const redisPipeline = this.redisForTimelines.pipeline();
for (const antenna of matchedAntennas) { for (const antenna of matchedAntennas) {
redisPipeline.xadd( redisPipeline.xadd(
@@ -108,7 +108,7 @@ export class AntennaService implements OnApplicationShutdown {
if (antenna.src === 'home') { if (antenna.src === 'home') {
// TODO // TODO
} else if (antenna.src === 'list') { } else if (antenna.src === 'list') {
const listUsers = (await this.userListJoiningsRepository.findBy({ const listUsers = (await this.userListMembershipsRepository.findBy({
userListId: antenna.userListId!, userListId: antenna.userListId!,
})).map(x => x.userId); })).map(x => x.userId);

View File

@@ -5,7 +5,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js';
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
import type { MiLocalUser, MiUser } from '@/models/User.js'; import type { MiLocalUser, MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@@ -25,7 +25,7 @@ export class CacheService implements OnApplicationShutdown {
public userBlockingCache: RedisKVCache<Set<string>>; public userBlockingCache: RedisKVCache<Set<string>>;
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
public renoteMutingsCache: RedisKVCache<Set<string>>; public renoteMutingsCache: RedisKVCache<Set<string>>;
public userFollowingsCache: RedisKVCache<Set<string>>; public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>;
public userFollowingChannelsCache: RedisKVCache<Set<string>>; public userFollowingChannelsCache: RedisKVCache<Set<string>>;
constructor( constructor(
@@ -136,12 +136,18 @@ export class CacheService implements OnApplicationShutdown {
fromRedisConverter: (value) => new Set(JSON.parse(value)), fromRedisConverter: (value) => new Set(JSON.parse(value)),
}); });
this.userFollowingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowings', { this.userFollowingsCache = new RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>(this.redisClient, 'userFollowings', {
lifetime: 1000 * 60 * 30, // 30m lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60, // 1m memoryCacheLifetime: 1000 * 60, // 1m
fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))), fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId', 'withReplies'] }).then(xs => {
toRedisConverter: (value) => JSON.stringify(Array.from(value)), const obj: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
fromRedisConverter: (value) => new Set(JSON.parse(value)), for (const x of xs) {
obj[x.followeeId] = { withReplies: x.withReplies };
}
return obj;
}),
toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => JSON.parse(value),
}); });
this.userFollowingChannelsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowingChannels', { this.userFollowingChannelsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowingChannels', {
@@ -188,6 +194,7 @@ export class CacheService implements OnApplicationShutdown {
if (follower) follower.followingCount++; if (follower) follower.followingCount++;
const followee = this.userByIdCache.get(body.followeeId); const followee = this.userByIdCache.get(body.followeeId);
if (followee) followee.followersCount++; if (followee) followee.followersCount++;
this.userFollowingsCache.delete(body.followerId);
break; break;
} }
default: default:

View File

@@ -46,6 +46,7 @@ import { SignupService } from './SignupService.js';
import { WebAuthnService } from './WebAuthnService.js'; import { WebAuthnService } from './WebAuthnService.js';
import { UserBlockingService } from './UserBlockingService.js'; import { UserBlockingService } from './UserBlockingService.js';
import { CacheService } from './CacheService.js'; import { CacheService } from './CacheService.js';
import { UserService } from './UserService.js';
import { UserFollowingService } from './UserFollowingService.js'; import { UserFollowingService } from './UserFollowingService.js';
import { UserKeypairService } from './UserKeypairService.js'; import { UserKeypairService } from './UserKeypairService.js';
import { UserListService } from './UserListService.js'; import { UserListService } from './UserListService.js';
@@ -173,6 +174,7 @@ const $SignupService: Provider = { provide: 'SignupService', useExisting: Signup
const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService }; const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService };
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService }; const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService }; const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService };
const $UserService: Provider = { provide: 'UserService', useExisting: UserService };
const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService }; const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService };
const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService }; const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService };
const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService }; const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService };
@@ -303,6 +305,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebAuthnService, WebAuthnService,
UserBlockingService, UserBlockingService,
CacheService, CacheService,
UserService,
UserFollowingService, UserFollowingService,
UserKeypairService, UserKeypairService,
UserListService, UserListService,
@@ -426,6 +429,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$WebAuthnService, $WebAuthnService,
$UserBlockingService, $UserBlockingService,
$CacheService, $CacheService,
$UserService,
$UserFollowingService, $UserFollowingService,
$UserKeypairService, $UserKeypairService,
$UserListService, $UserListService,
@@ -550,6 +554,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebAuthnService, WebAuthnService,
UserBlockingService, UserBlockingService,
CacheService, CacheService,
UserService,
UserFollowingService, UserFollowingService,
UserKeypairService, UserKeypairService,
UserListService, UserListService,
@@ -672,6 +677,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$WebAuthnService, $WebAuthnService,
$UserBlockingService, $UserBlockingService,
$CacheService, $CacheService,
$UserService,
$UserFollowingService, $UserFollowingService,
$UserKeypairService, $UserKeypairService,
$UserListService, $UserListService,

View File

@@ -5,7 +5,7 @@
import { setImmediate } from 'node:timers/promises'; import { setImmediate } from 'node:timers/promises';
import * as mfm from 'mfm-js'; import * as mfm from 'mfm-js';
import { In, DataSource } from 'typeorm'; import { In, DataSource, IsNull, LessThan } from 'typeorm';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import RE2 from 're2'; import RE2 from 're2';
@@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
import { extractHashtags } from '@/misc/extract-hashtags.js'; import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js';
import type { ChannelsRepository, FollowingsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiApp } from '@/models/App.js'; import type { MiApp } from '@/models/App.js';
import { concat } from '@/misc/prelude/array.js'; import { concat } from '@/misc/prelude/array.js';
@@ -54,8 +54,6 @@ import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js'; import { SearchService } from '@/core/SearchService.js';
const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5);
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
class NotificationManager { class NotificationManager {
@@ -157,8 +155,8 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
@Inject(DI.redis) @Inject(DI.redisForTimelines)
private redisClient: Redis.Redis, private redisForTimelines: Redis.Redis,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@@ -175,8 +173,8 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.userProfilesRepository) @Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository, private userProfilesRepository: UserProfilesRepository,
@Inject(DI.mutedNotesRepository) @Inject(DI.userListMembershipsRepository)
private mutedNotesRepository: MutedNotesRepository, private userListMembershipsRepository: UserListMembershipsRepository,
@Inject(DI.channelsRepository) @Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository, private channelsRepository: ChannelsRepository,
@@ -187,6 +185,9 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.followingsRepository) @Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private idService: IdService, private idService: IdService,
@@ -334,7 +335,7 @@ export class NoteCreateService implements OnApplicationShutdown {
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
if (data.channel) { if (data.channel) {
this.redisClient.xadd( this.redisForTimelines.xadd(
`channelTimeline:${data.channel.id}`, `channelTimeline:${data.channel.id}`,
'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(), 'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(),
'*', '*',
@@ -480,26 +481,13 @@ export class NoteCreateService implements OnApplicationShutdown {
// Increment notes count (user) // Increment notes count (user)
this.incNotesCountOfUser(user); this.incNotesCountOfUser(user);
// Word mute if (data.visibility === 'public' || data.visibility === 'home') {
mutedWordsCache.fetch(() => this.userProfilesRepository.find({ this.pushToTl(note, user);
where: { } else if (data.visibility === 'followers') {
enableWordMute: true, this.pushToTl(note, user);
}, } else if (data.visibility === 'specified') {
select: ['userId', 'mutedWords'], // TODO
})).then(us => { }
for (const u of us) {
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
if (shouldMute) {
this.mutedNotesRepository.insert({
id: this.idService.genId(),
userId: u.userId,
noteId: note.id,
reason: 'word',
});
}
});
}
});
this.antennaService.addNoteToAntennas(note, user); this.antennaService.addNoteToAntennas(note, user);
@@ -508,11 +496,13 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
if (data.reply == null) { if (data.reply == null) {
// TODO: キャッシュ
this.followingsRepository.findBy({ this.followingsRepository.findBy({
followeeId: user.id, followeeId: user.id,
notify: 'normal', notify: 'normal',
}).then(followings => { }).then(followings => {
for (const following of followings) { for (const following of followings) {
// TODO: ワードミュート考慮
this.notificationService.createNotification(following.followerId, 'note', { this.notificationService.createNotification(following.followerId, 'note', {
noteId: note.id, noteId: note.id,
}, user.id); }, user.id);
@@ -811,6 +801,205 @@ export class NoteCreateService implements OnApplicationShutdown {
return mentionedUsers; return mentionedUsers;
} }
@bindThis
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
const redisPipeline = this.redisForTimelines.pipeline();
if (note.channelId) {
const channelFollowings = await this.channelFollowingsRepository.find({
where: {
followeeId: note.channelId,
},
select: ['followerId'],
});
for (const channelFollowing of channelFollowings) {
redisPipeline.xadd(
`homeTimeline:${channelFollowing.followerId}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`homeTimelineWithFiles:${channelFollowing.followerId}`,
'MAXLEN', '~', '100',
'*',
'note', note.id);
}
}
} else {
// TODO: キャッシュ?
const followings = await this.followingsRepository.find({
where: {
followeeId: user.id,
followerHost: IsNull(),
isFollowerHibernated: false,
},
select: ['followerId', 'withReplies'],
});
const userListMemberships = await this.userListMembershipsRepository.find({
where: {
userId: user.id,
},
select: ['userListId', 'withReplies'],
});
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
for (const following of followings) {
// 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) {
if (!following.withReplies) continue;
}
redisPipeline.xadd(
`homeTimeline:${following.followerId}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`homeTimelineWithFiles:${following.followerId}`,
'MAXLEN', '~', '100',
'*',
'note', note.id);
}
}
// TODO
//if (note.visibility === 'followers') {
// // TODO: 重そうだから何とかしたい Set 使う?
// userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListUserId));
//}
for (const userListMembership of userListMemberships) {
// 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) {
if (!userListMembership.withReplies) continue;
}
redisPipeline.xadd(
`userListTimeline:${userListMembership.userListId}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`userListTimelineWithFiles:${userListMembership.userListId}`,
'MAXLEN', '~', '100',
'*',
'note', note.id);
}
}
{ // 自分自身のHTL
redisPipeline.xadd(
`homeTimeline:${user.id}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`homeTimelineWithFiles:${user.id}`,
'MAXLEN', '~', '100',
'*',
'note', note.id);
}
}
if (note.visibility === 'public' || note.visibility === 'home') {
// 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) {
redisPipeline.xadd(
`userTimelineWithReplies:${user.id}`,
'MAXLEN', '~', '1000',
'*',
'note', note.id);
} else {
redisPipeline.xadd(
`userTimeline:${user.id}`,
'MAXLEN', '~', '1000',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`userTimelineWithFiles:${user.id}`,
'MAXLEN', '~', '500',
'*',
'note', note.id);
}
if (note.visibility === 'public' && note.userHost == null) {
redisPipeline.xadd(
'localTimeline',
'MAXLEN', '~', '1000',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
'localTimelineWithFiles',
'MAXLEN', '~', '500',
'*',
'note', note.id);
}
}
}
}
if (Math.random() < 0.1) {
process.nextTick(() => {
this.checkHibernation(followings);
});
}
}
redisPipeline.exec();
}
@bindThis
public async checkHibernation(followings: MiFollowing[]) {
if (followings.length === 0) return;
const shuffle = (array: MiFollowing[]) => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
};
// ランダムに最大1000件サンプリング
const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000));
const hibernatedUsers = await this.usersRepository.find({
where: {
id: In(samples.map(x => x.followerId)),
lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))),
},
select: ['id'],
});
if (hibernatedUsers.length > 0) {
this.usersRepository.update({
id: In(hibernatedUsers.map(x => x.id)),
}, {
isHibernated: true,
});
this.followingsRepository.update({
followerId: In(hibernatedUsers.map(x => x.id)),
}, {
isFollowerHibernated: true,
});
}
}
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.#shutdownController.abort(); this.#shutdownController.abort();

View File

@@ -80,7 +80,10 @@ export class NotificationService implements OnApplicationShutdown {
notifierId?: MiUser['id'] | null, notifierId?: MiUser['id'] | null,
): Promise<MiNotification | null> { ): Promise<MiNotification | null> {
const profile = await this.cacheService.userProfileCache.fetch(notifieeId); const profile = await this.cacheService.userProfileCache.fetch(notifieeId);
const recieveConfig = profile.notificationRecieveConfig[type];
// 古いMisskeyバージョンのキャッシュが残っている可能性がある
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const recieveConfig = (profile.notificationRecieveConfig ?? {})[type];
if (recieveConfig?.type === 'never') { if (recieveConfig?.type === 'never') {
return null; return null;
} }
@@ -96,19 +99,19 @@ export class NotificationService implements OnApplicationShutdown {
} }
if (recieveConfig?.type === 'following') { if (recieveConfig?.type === 'following') {
const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)); const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId));
if (!isFollowing) { if (!isFollowing) {
return null; return null;
} }
} else if (recieveConfig?.type === 'follower') { } else if (recieveConfig?.type === 'follower') {
const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)); const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId));
if (!isFollower) { if (!isFollower) {
return null; return null;
} }
} else if (recieveConfig?.type === 'mutualFollow') { } else if (recieveConfig?.type === 'mutualFollow') {
const [isFollowing, isFollower] = await Promise.all([ const [isFollowing, isFollower] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)), this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)), this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
]); ]);
if (!isFollowing && !isFollower) { if (!isFollowing && !isFollower) {
return null; return null;

View File

@@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Brackets, ObjectLiteral } from 'typeorm'; import { Brackets, ObjectLiteral } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js'; import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { SelectQueryBuilder } from 'typeorm'; import type { SelectQueryBuilder } from 'typeorm';
@@ -23,9 +23,6 @@ export class QueryService {
@Inject(DI.channelFollowingsRepository) @Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository, private channelFollowingsRepository: ChannelFollowingsRepository,
@Inject(DI.mutedNotesRepository)
private mutedNotesRepository: MutedNotesRepository,
@Inject(DI.blockingsRepository) @Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository, private blockingsRepository: BlockingsRepository,
@@ -108,39 +105,6 @@ export class QueryService {
q.setParameters(blockedQuery.getParameters()); q.setParameters(blockedQuery.getParameters());
} }
@bindThis
public generateChannelQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void {
if (me == null) {
q.andWhere('note.channelId IS NULL');
} else {
q.leftJoinAndSelect('note.channel', 'channel');
const channelFollowingQuery = this.channelFollowingsRepository.createQueryBuilder('channelFollowing')
.select('channelFollowing.followeeId')
.where('channelFollowing.followerId = :followerId', { followerId: me.id });
q.andWhere(new Brackets(qb => { qb
// チャンネルのノートではない
.where('note.channelId IS NULL')
// または自分がフォローしているチャンネルのノート
.orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`);
}));
q.setParameters(channelFollowingQuery.getParameters());
}
}
@bindThis
public generateMutedNoteQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
const mutedQuery = this.mutedNotesRepository.createQueryBuilder('muted')
.select('muted.noteId')
.where('muted.userId = :userId', { userId: me.id });
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
q.setParameters(mutedQuery.getParameters());
}
@bindThis @bindThis
public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
@@ -212,32 +176,6 @@ export class QueryService {
q.setParameters(mutingQuery.getParameters()); q.setParameters(mutingQuery.getParameters());
} }
@bindThis
public generateRepliesQuery(q: SelectQueryBuilder<any>, withReplies: boolean, me?: Pick<MiUser, 'id'> | null): void {
if (me == null) {
q.andWhere(new Brackets(qb => { qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}));
}));
} else if (!withReplies) {
q.andWhere(new Brackets(qb => { qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信
.orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信
.where('note.replyId IS NOT NULL')
.andWhere('note.userId = :meId', { meId: me.id });
}))
.orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}));
}));
}
}
@bindThis @bindThis
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void { public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void {
// This code must always be synchronized with the checks in Notes.isVisibleForMe. // This code must always be synchronized with the checks in Notes.isVisibleForMe.

View File

@@ -33,6 +33,7 @@ export type RolePolicies = {
inviteExpirationTime: number; inviteExpirationTime: number;
canManageCustomEmojis: boolean; canManageCustomEmojis: boolean;
canSearchNotes: boolean; canSearchNotes: boolean;
canUseTranslator: boolean;
canHideAds: boolean; canHideAds: boolean;
driveCapacityMb: number; driveCapacityMb: number;
alwaysMarkNsfw: boolean; alwaysMarkNsfw: boolean;
@@ -58,6 +59,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
inviteExpirationTime: 0, inviteExpirationTime: 0,
canManageCustomEmojis: false, canManageCustomEmojis: false,
canSearchNotes: false, canSearchNotes: false,
canUseTranslator: true,
canHideAds: false, canHideAds: false,
driveCapacityMb: 100, driveCapacityMb: 100,
alwaysMarkNsfw: false, alwaysMarkNsfw: false,
@@ -303,6 +305,7 @@ export class RoleService implements OnApplicationShutdown {
inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)), inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)),
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)), canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)),
canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)),
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),
alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)),

View File

@@ -11,7 +11,7 @@ import type { MiBlocking } from '@/models/Blocking.js';
import { QueueService } from '@/core/QueueService.js'; import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/_.js'; import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListMembershipsRepository } from '@/models/_.js';
import Logger from '@/logger.js'; import Logger from '@/logger.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
@@ -38,8 +38,8 @@ export class UserBlockingService implements OnModuleInit {
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository) @Inject(DI.userListMembershipsRepository)
private userListJoiningsRepository: UserListJoiningsRepository, private userListMembershipsRepository: UserListMembershipsRepository,
private cacheService: CacheService, private cacheService: CacheService,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
@@ -149,7 +149,7 @@ export class UserBlockingService implements OnModuleInit {
}); });
for (const userList of userLists) { for (const userList of userLists) {
await this.userListJoiningsRepository.delete({ await this.userListMembershipsRepository.delete({
userListId: userList.id, userListId: userList.id,
userId: user.id, userId: user.id,
}); });

View File

@@ -123,7 +123,11 @@ export class UserFollowingService implements OnModuleInit {
// フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or // フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or
// フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである // フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである
// 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく // 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく
if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee))) { if (
followee.isLocked ||
(followeeProfile.carefulBot && follower.isBot) ||
(this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee) && process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING !== 'true')
) {
let autoAccept = false; let autoAccept = false;
// 鍵アカウントであっても、既にフォローされていた場合はスルー // 鍵アカウントであっても、既にフォローされていた場合はスルー

View File

@@ -5,10 +5,10 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import type { UserListJoiningsRepository } from '@/models/_.js'; import type { UserListMembershipsRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { MiUserList } from '@/models/UserList.js'; import type { MiUserList } from '@/models/UserList.js';
import type { MiUserListJoining } from '@/models/UserListJoining.js'; import type { MiUserListMembership } from '@/models/UserListMembership.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@@ -33,8 +33,8 @@ export class UserListService implements OnApplicationShutdown {
@Inject(DI.redisForSub) @Inject(DI.redisForSub)
private redisForSub: Redis.Redis, private redisForSub: Redis.Redis,
@Inject(DI.userListJoiningsRepository) @Inject(DI.userListMembershipsRepository)
private userListJoiningsRepository: UserListJoiningsRepository, private userListMembershipsRepository: UserListMembershipsRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private idService: IdService, private idService: IdService,
@@ -46,7 +46,7 @@ export class UserListService implements OnApplicationShutdown {
this.membersCache = new RedisKVCache<Set<string>>(this.redisClient, 'userListMembers', { this.membersCache = new RedisKVCache<Set<string>>(this.redisClient, 'userListMembers', {
lifetime: 1000 * 60 * 30, // 30m lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60, // 1m memoryCacheLifetime: 1000 * 60, // 1m
fetcher: (key) => this.userListJoiningsRepository.find({ where: { userListId: key }, select: ['userId'] }).then(xs => new Set(xs.map(x => x.userId))), fetcher: (key) => this.userListMembershipsRepository.find({ where: { userListId: key }, select: ['userId'] }).then(xs => new Set(xs.map(x => x.userId))),
toRedisConverter: (value) => JSON.stringify(Array.from(value)), toRedisConverter: (value) => JSON.stringify(Array.from(value)),
fromRedisConverter: (value) => new Set(JSON.parse(value)), fromRedisConverter: (value) => new Set(JSON.parse(value)),
}); });
@@ -85,19 +85,19 @@ export class UserListService implements OnApplicationShutdown {
@bindThis @bindThis
public async addMember(target: MiUser, list: MiUserList, me: MiUser) { public async addMember(target: MiUser, list: MiUserList, me: MiUser) {
const currentCount = await this.userListJoiningsRepository.countBy({ const currentCount = await this.userListMembershipsRepository.countBy({
userListId: list.id, userListId: list.id,
}); });
if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) { if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) {
throw new UserListService.TooManyUsersError(); throw new UserListService.TooManyUsersError();
} }
await this.userListJoiningsRepository.insert({ await this.userListMembershipsRepository.insert({
id: this.idService.genId(), id: this.idService.genId(),
createdAt: new Date(), createdAt: new Date(),
userId: target.id, userId: target.id,
userListId: list.id, userListId: list.id,
} as MiUserListJoining); } as MiUserListMembership);
this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id }); this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id });
this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target)); this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target));
@@ -113,7 +113,7 @@ export class UserListService implements OnApplicationShutdown {
@bindThis @bindThis
public async removeMember(target: MiUser, list: MiUserList) { public async removeMember(target: MiUser, list: MiUserList) {
await this.userListJoiningsRepository.delete({ await this.userListMembershipsRepository.delete({
userId: target.id, userId: target.id,
userListId: list.id, userListId: list.id,
}); });
@@ -122,6 +122,24 @@ export class UserListService implements OnApplicationShutdown {
this.globalEventService.publishUserListStream(list.id, 'userRemoved', await this.userEntityService.pack(target)); this.globalEventService.publishUserListStream(list.id, 'userRemoved', await this.userEntityService.pack(target));
} }
@bindThis
public async updateMembership(target: MiUser, list: MiUserList, options: { withReplies?: boolean }) {
const membership = await this.userListMembershipsRepository.findOneBy({
userId: target.id,
userListId: list.id,
});
if (membership == null) {
throw new Error('User is not a member of the list');
}
await this.userListMembershipsRepository.update({
id: membership.id,
}, {
withReplies: options.withReplies,
});
}
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.redisForSub.off('message', this.onMessage); this.redisForSub.off('message', this.onMessage);

View File

@@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { FollowingsRepository, UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
@Injectable()
export class UserService {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
) {
}
@bindThis
public async updateLastActiveDate(user: MiUser): Promise<void> {
if (user.isHibernated) {
const result = await this.usersRepository.createQueryBuilder().update()
.set({
lastActiveDate: new Date(),
})
.where('id = :id', { id: user.id })
.returning('*')
.execute()
.then((response) => {
return response.raw[0];
});
const wokeUp = result.isHibernated;
if (wokeUp) {
this.usersRepository.update(user.id, {
isHibernated: false,
});
this.followingsRepository.update({
followerId: user.id,
}, {
isFollowerHibernated: false,
});
}
} else {
this.usersRepository.update(user.id, {
lastActiveDate: new Date(),
});
}
}
}

View File

@@ -98,13 +98,13 @@ export class NoteEntityService implements OnModuleInit {
} else if (meId === packedNote.userId) { } else if (meId === packedNote.userId) {
hide = false; hide = false;
} else if (packedNote.reply && (meId === packedNote.reply.userId)) { } else if (packedNote.reply && (meId === packedNote.reply.userId)) {
// 自分の投稿に対するリプライ // 自分の投稿に対するリプライ
hide = false; hide = false;
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) { } else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
// 自分へのメンション // 自分へのメンション
hide = false; hide = false;
} else { } else {
// フォロワーかどうか // フォロワーかどうか
const isFollowing = await this.followingsRepository.exist({ const isFollowing = await this.followingsRepository.exist({
where: { where: {
followeeId: packedNote.userId, followeeId: packedNote.userId,

View File

@@ -452,6 +452,7 @@ export class UserEntityService implements OnModuleInit {
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
mutedWords: profile!.mutedWords, mutedWords: profile!.mutedWords,
mutedInstances: profile!.mutedInstances, mutedInstances: profile!.mutedInstances,
mutingNotificationTypes: [], // 後方互換性のため
notificationRecieveConfig: profile!.notificationRecieveConfig, notificationRecieveConfig: profile!.notificationRecieveConfig,
emailNotificationTypes: profile!.emailNotificationTypes, emailNotificationTypes: profile!.emailNotificationTypes,
achievements: profile!.achievements, achievements: profile!.achievements,
@@ -486,6 +487,7 @@ export class UserEntityService implements OnModuleInit {
isMuted: relation.isMuted, isMuted: relation.isMuted,
isRenoteMuted: relation.isRenoteMuted, isRenoteMuted: relation.isRenoteMuted,
notify: relation.following?.notify ?? 'none', notify: relation.following?.notify ?? 'none',
withReplies: relation.following?.withReplies ?? false,
} : {}), } : {}),
} as Promiseable<Packed<'User'>> as Promiseable<IsMeAndIsUserDetailed<ExpectsMe, D>>; } as Promiseable<Packed<'User'>> as Promiseable<IsMeAndIsUserDetailed<ExpectsMe, D>>;

View File

@@ -5,11 +5,12 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { UserListJoiningsRepository, UserListsRepository } from '@/models/_.js'; import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js'; import type { } from '@/models/Blocking.js';
import type { MiUserList } from '@/models/UserList.js'; import type { MiUserList } from '@/models/UserList.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { UserEntityService } from './UserEntityService.js';
@Injectable() @Injectable()
export class UserListEntityService { export class UserListEntityService {
@@ -17,8 +18,10 @@ export class UserListEntityService {
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository) @Inject(DI.userListMembershipsRepository)
private userListJoiningsRepository: UserListJoiningsRepository, private userListMembershipsRepository: UserListMembershipsRepository,
private userEntityService: UserEntityService,
) { ) {
} }
@@ -28,7 +31,7 @@ export class UserListEntityService {
): Promise<Packed<'UserList'>> { ): Promise<Packed<'UserList'>> {
const userList = typeof src === 'object' ? src : await this.userListsRepository.findOneByOrFail({ id: src }); const userList = typeof src === 'object' ? src : await this.userListsRepository.findOneByOrFail({ id: src });
const users = await this.userListJoiningsRepository.findBy({ const users = await this.userListMembershipsRepository.findBy({
userListId: userList.id, userListId: userList.id,
}); });
@@ -40,5 +43,18 @@ export class UserListEntityService {
isPublic: userList.isPublic, isPublic: userList.isPublic,
}; };
} }
@bindThis
public async packMembershipsMany(
memberships: MiUserListMembership[],
) {
return Promise.all(memberships.map(async x => ({
id: x.id,
createdAt: x.createdAt.toISOString(),
userId: x.userId,
user: await this.userEntityService.pack(x.userId),
withReplies: x.withReplies,
})));
}
} }

View File

@@ -10,6 +10,7 @@ export const DI = {
redis: Symbol('redis'), redis: Symbol('redis'),
redisForPub: Symbol('redisForPub'), redisForPub: Symbol('redisForPub'),
redisForSub: Symbol('redisForSub'), redisForSub: Symbol('redisForSub'),
redisForTimelines: Symbol('redisForTimelines'),
//#region Repositories //#region Repositories
usersRepository: Symbol('usersRepository'), usersRepository: Symbol('usersRepository'),
@@ -30,7 +31,7 @@ export const DI = {
userPublickeysRepository: Symbol('userPublickeysRepository'), userPublickeysRepository: Symbol('userPublickeysRepository'),
userListsRepository: Symbol('userListsRepository'), userListsRepository: Symbol('userListsRepository'),
userListFavoritesRepository: Symbol('userListFavoritesRepository'), userListFavoritesRepository: Symbol('userListFavoritesRepository'),
userListJoiningsRepository: Symbol('userListJoiningsRepository'), userListMembershipsRepository: Symbol('userListMembershipsRepository'),
userNotePiningsRepository: Symbol('userNotePiningsRepository'), userNotePiningsRepository: Symbol('userNotePiningsRepository'),
userIpsRepository: Symbol('userIpsRepository'), userIpsRepository: Symbol('userIpsRepository'),
usedUsernamesRepository: Symbol('usedUsernamesRepository'), usedUsernamesRepository: Symbol('usedUsernamesRepository'),
@@ -63,7 +64,6 @@ export const DI = {
promoNotesRepository: Symbol('promoNotesRepository'), promoNotesRepository: Symbol('promoNotesRepository'),
promoReadsRepository: Symbol('promoReadsRepository'), promoReadsRepository: Symbol('promoReadsRepository'),
relaysRepository: Symbol('relaysRepository'), relaysRepository: Symbol('relaysRepository'),
mutedNotesRepository: Symbol('mutedNotesRepository'),
channelsRepository: Symbol('channelsRepository'), channelsRepository: Symbol('channelsRepository'),
channelFollowingsRepository: Symbol('channelFollowingsRepository'), channelFollowingsRepository: Symbol('channelFollowingsRepository'),
channelFavoritesRepository: Symbol('channelFavoritesRepository'), channelFavoritesRepository: Symbol('channelFavoritesRepository'),

View File

@@ -9,6 +9,7 @@ import { MiUser } from './User.js';
@Entity('following') @Entity('following')
@Index(['followerId', 'followeeId'], { unique: true }) @Index(['followerId', 'followeeId'], { unique: true })
@Index(['followeeId', 'followerHost', 'isFollowerHibernated'])
export class MiFollowing { export class MiFollowing {
@PrimaryColumn(id()) @PrimaryColumn(id())
public id: string; public id: string;
@@ -45,6 +46,17 @@ export class MiFollowing {
@JoinColumn() @JoinColumn()
public follower: MiUser | null; public follower: MiUser | null;
@Column('boolean', {
default: false,
})
public isFollowerHibernated: boolean;
// タイムラインにその人のリプライまで含めるかどうか
@Column('boolean', {
default: false,
})
public withReplies: boolean;
@Index() @Index()
@Column('varchar', { @Column('varchar', {
length: 32, length: 32,

View File

@@ -1,53 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm';
import { mutedNoteReasons } from '@/types.js';
import { id } from './util/id.js';
import { MiNote } from './Note.js';
import { MiUser } from './User.js';
@Entity('muted_note')
@Index(['noteId', 'userId'], { unique: true })
export class MiMutedNote {
@PrimaryColumn(id())
public id: string;
@Index()
@Column({
...id(),
comment: 'The note ID.',
})
public noteId: MiNote['id'];
@ManyToOne(type => MiNote, {
onDelete: 'CASCADE',
})
@JoinColumn()
public note: MiNote | null;
@Index()
@Column({
...id(),
comment: 'The user ID.',
})
public userId: MiUser['id'];
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: MiUser | null;
/**
* ミュートされた理由。
*/
@Index()
@Column('enum', {
enum: mutedNoteReasons,
comment: 'The reason of the MutedNote.',
})
public reason: typeof mutedNoteReasons[number];
}

View File

@@ -5,7 +5,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMutedNote, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListJoining, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './_.js'; import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './_.js';
import type { DataSource } from 'typeorm'; import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common'; import type { Provider } from '@nestjs/common';
@@ -117,9 +117,9 @@ const $userListFavoritesRepository: Provider = {
inject: [DI.db], inject: [DI.db],
}; };
const $userListJoiningsRepository: Provider = { const $userListMembershipsRepository: Provider = {
provide: DI.userListJoiningsRepository, provide: DI.userListMembershipsRepository,
useFactory: (db: DataSource) => db.getRepository(MiUserListJoining), useFactory: (db: DataSource) => db.getRepository(MiUserListMembership),
inject: [DI.db], inject: [DI.db],
}; };
@@ -315,12 +315,6 @@ const $relaysRepository: Provider = {
inject: [DI.db], inject: [DI.db],
}; };
const $mutedNotesRepository: Provider = {
provide: DI.mutedNotesRepository,
useFactory: (db: DataSource) => db.getRepository(MiMutedNote),
inject: [DI.db],
};
const $channelsRepository: Provider = { const $channelsRepository: Provider = {
provide: DI.channelsRepository, provide: DI.channelsRepository,
useFactory: (db: DataSource) => db.getRepository(MiChannel), useFactory: (db: DataSource) => db.getRepository(MiChannel),
@@ -421,7 +415,7 @@ const $userMemosRepository: Provider = {
$userPublickeysRepository, $userPublickeysRepository,
$userListsRepository, $userListsRepository,
$userListFavoritesRepository, $userListFavoritesRepository,
$userListJoiningsRepository, $userListMembershipsRepository,
$userNotePiningsRepository, $userNotePiningsRepository,
$userIpsRepository, $userIpsRepository,
$usedUsernamesRepository, $usedUsernamesRepository,
@@ -454,7 +448,6 @@ const $userMemosRepository: Provider = {
$promoNotesRepository, $promoNotesRepository,
$promoReadsRepository, $promoReadsRepository,
$relaysRepository, $relaysRepository,
$mutedNotesRepository,
$channelsRepository, $channelsRepository,
$channelFollowingsRepository, $channelFollowingsRepository,
$channelFavoritesRepository, $channelFavoritesRepository,
@@ -488,7 +481,7 @@ const $userMemosRepository: Provider = {
$userPublickeysRepository, $userPublickeysRepository,
$userListsRepository, $userListsRepository,
$userListFavoritesRepository, $userListFavoritesRepository,
$userListJoiningsRepository, $userListMembershipsRepository,
$userNotePiningsRepository, $userNotePiningsRepository,
$userIpsRepository, $userIpsRepository,
$usedUsernamesRepository, $usedUsernamesRepository,
@@ -521,7 +514,6 @@ const $userMemosRepository: Provider = {
$promoNotesRepository, $promoNotesRepository,
$promoReadsRepository, $promoReadsRepository,
$relaysRepository, $relaysRepository,
$mutedNotesRepository,
$channelsRepository, $channelsRepository,
$channelFollowingsRepository, $channelFollowingsRepository,
$channelFavoritesRepository, $channelFavoritesRepository,

View File

@@ -187,6 +187,11 @@ export class MiUser {
}) })
public isExplorable: boolean; public isExplorable: boolean;
@Column('boolean', {
default: false,
})
public isHibernated: boolean;
// アカウントが削除されたかどうかのフラグだが、完全に削除される際は物理削除なので実質削除されるまでの「削除が進行しているかどうか」のフラグ // アカウントが削除されたかどうかのフラグだが、完全に削除される際は物理削除なので実質削除されるまでの「削除が進行しているかどうか」のフラグ
@Column('boolean', { @Column('boolean', {
default: false, default: false,

View File

@@ -8,14 +8,14 @@ import { id } from './util/id.js';
import { MiUser } from './User.js'; import { MiUser } from './User.js';
import { MiUserList } from './UserList.js'; import { MiUserList } from './UserList.js';
@Entity('user_list_joining') @Entity('user_list_membership')
@Index(['userId', 'userListId'], { unique: true }) @Index(['userId', 'userListId'], { unique: true })
export class MiUserListJoining { export class MiUserListMembership {
@PrimaryColumn(id()) @PrimaryColumn(id())
public id: string; public id: string;
@Column('timestamp with time zone', { @Column('timestamp with time zone', {
comment: 'The created date of the UserListJoining.', comment: 'The created date of the UserListMembership.',
}) })
public createdAt: Date; public createdAt: Date;
@@ -44,4 +44,10 @@ export class MiUserListJoining {
}) })
@JoinColumn() @JoinColumn()
public userList: MiUserList | null; public userList: MiUserList | null;
// タイムラインにその人のリプライまで含めるかどうか
@Column('boolean', {
default: false,
})
public withReplies: boolean;
} }

View File

@@ -28,7 +28,6 @@ import { MiHashtag } from '@/models/Hashtag.js';
import { MiInstance } from '@/models/Instance.js'; import { MiInstance } from '@/models/Instance.js';
import { MiMeta } from '@/models/Meta.js'; import { MiMeta } from '@/models/Meta.js';
import { MiModerationLog } from '@/models/ModerationLog.js'; import { MiModerationLog } from '@/models/ModerationLog.js';
import { MiMutedNote } from '@/models/MutedNote.js';
import { MiMuting } from '@/models/Muting.js'; import { MiMuting } from '@/models/Muting.js';
import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiRenoteMuting } from '@/models/RenoteMuting.js';
import { MiNote } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js';
@@ -53,7 +52,7 @@ import { MiUser } from '@/models/User.js';
import { MiUserIp } from '@/models/UserIp.js'; import { MiUserIp } from '@/models/UserIp.js';
import { MiUserKeypair } from '@/models/UserKeypair.js'; import { MiUserKeypair } from '@/models/UserKeypair.js';
import { MiUserList } from '@/models/UserList.js'; import { MiUserList } from '@/models/UserList.js';
import { MiUserListJoining } from '@/models/UserListJoining.js'; import { MiUserListMembership } from '@/models/UserListMembership.js';
import { MiUserNotePining } from '@/models/UserNotePining.js'; import { MiUserNotePining } from '@/models/UserNotePining.js';
import { MiUserPending } from '@/models/UserPending.js'; import { MiUserPending } from '@/models/UserPending.js';
import { MiUserProfile } from '@/models/UserProfile.js'; import { MiUserProfile } from '@/models/UserProfile.js';
@@ -96,7 +95,6 @@ export {
MiInstance, MiInstance,
MiMeta, MiMeta,
MiModerationLog, MiModerationLog,
MiMutedNote,
MiMuting, MiMuting,
MiRenoteMuting, MiRenoteMuting,
MiNote, MiNote,
@@ -122,7 +120,7 @@ export {
MiUserKeypair, MiUserKeypair,
MiUserList, MiUserList,
MiUserListFavorite, MiUserListFavorite,
MiUserListJoining, MiUserListMembership,
MiUserNotePining, MiUserNotePining,
MiUserPending, MiUserPending,
MiUserProfile, MiUserProfile,
@@ -163,7 +161,6 @@ export type HashtagsRepository = Repository<MiHashtag>;
export type InstancesRepository = Repository<MiInstance>; export type InstancesRepository = Repository<MiInstance>;
export type MetasRepository = Repository<MiMeta>; export type MetasRepository = Repository<MiMeta>;
export type ModerationLogsRepository = Repository<MiModerationLog>; export type ModerationLogsRepository = Repository<MiModerationLog>;
export type MutedNotesRepository = Repository<MiMutedNote>;
export type MutingsRepository = Repository<MiMuting>; export type MutingsRepository = Repository<MiMuting>;
export type RenoteMutingsRepository = Repository<MiRenoteMuting>; export type RenoteMutingsRepository = Repository<MiRenoteMuting>;
export type NotesRepository = Repository<MiNote>; export type NotesRepository = Repository<MiNote>;
@@ -189,7 +186,7 @@ export type UserIpsRepository = Repository<MiUserIp>;
export type UserKeypairsRepository = Repository<MiUserKeypair>; export type UserKeypairsRepository = Repository<MiUserKeypair>;
export type UserListsRepository = Repository<MiUserList>; export type UserListsRepository = Repository<MiUserList>;
export type UserListFavoritesRepository = Repository<MiUserListFavorite>; export type UserListFavoritesRepository = Repository<MiUserListFavorite>;
export type UserListJoiningsRepository = Repository<MiUserListJoining>; export type UserListMembershipsRepository = Repository<MiUserListMembership>;
export type UserNotePiningsRepository = Repository<MiUserNotePining>; export type UserNotePiningsRepository = Repository<MiUserNotePining>;
export type UserPendingsRepository = Repository<MiUserPending>; export type UserPendingsRepository = Repository<MiUserPending>;
export type UserProfilesRepository = Repository<MiUserProfile>; export type UserProfilesRepository = Repository<MiUserProfile>;

View File

@@ -277,6 +277,10 @@ export const packedUserDetailedNotMeOnlySchema = {
type: 'string', type: 'string',
nullable: false, optional: true, nullable: false, optional: true,
}, },
withReplies: {
type: 'boolean',
nullable: false, optional: true,
},
//#endregion //#endregion
}, },
} as const; } as const;

View File

@@ -36,7 +36,6 @@ import { MiHashtag } from '@/models/Hashtag.js';
import { MiInstance } from '@/models/Instance.js'; import { MiInstance } from '@/models/Instance.js';
import { MiMeta } from '@/models/Meta.js'; import { MiMeta } from '@/models/Meta.js';
import { MiModerationLog } from '@/models/ModerationLog.js'; import { MiModerationLog } from '@/models/ModerationLog.js';
import { MiMutedNote } from '@/models/MutedNote.js';
import { MiMuting } from '@/models/Muting.js'; import { MiMuting } from '@/models/Muting.js';
import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiRenoteMuting } from '@/models/RenoteMuting.js';
import { MiNote } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js';
@@ -62,7 +61,7 @@ import { MiUserIp } from '@/models/UserIp.js';
import { MiUserKeypair } from '@/models/UserKeypair.js'; import { MiUserKeypair } from '@/models/UserKeypair.js';
import { MiUserList } from '@/models/UserList.js'; import { MiUserList } from '@/models/UserList.js';
import { MiUserListFavorite } from '@/models/UserListFavorite.js'; import { MiUserListFavorite } from '@/models/UserListFavorite.js';
import { MiUserListJoining } from '@/models/UserListJoining.js'; import { MiUserListMembership } from '@/models/UserListMembership.js';
import { MiUserNotePining } from '@/models/UserNotePining.js'; import { MiUserNotePining } from '@/models/UserNotePining.js';
import { MiUserPending } from '@/models/UserPending.js'; import { MiUserPending } from '@/models/UserPending.js';
import { MiUserProfile } from '@/models/UserProfile.js'; import { MiUserProfile } from '@/models/UserProfile.js';
@@ -138,7 +137,7 @@ export const entities = [
MiUserPublickey, MiUserPublickey,
MiUserList, MiUserList,
MiUserListFavorite, MiUserListFavorite,
MiUserListJoining, MiUserListMembership,
MiUserNotePining, MiUserNotePining,
MiUserSecurityKey, MiUserSecurityKey,
MiUsedUsername, MiUsedUsername,
@@ -174,7 +173,6 @@ export const entities = [
MiPromoNote, MiPromoNote,
MiPromoRead, MiPromoRead,
MiRelay, MiRelay,
MiMutedNote,
MiChannel, MiChannel,
MiChannelFollowing, MiChannelFollowing,
MiChannelFavorite, MiChannelFavorite,

View File

@@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { In, LessThan } from 'typeorm'; import { In, LessThan } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { AntennasRepository, MutedNotesRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/_.js'; import type { AntennasRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/_.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
@@ -25,9 +25,6 @@ export class CleanProcessorService {
@Inject(DI.userIpsRepository) @Inject(DI.userIpsRepository)
private userIpsRepository: UserIpsRepository, private userIpsRepository: UserIpsRepository,
@Inject(DI.mutedNotesRepository)
private mutedNotesRepository: MutedNotesRepository,
@Inject(DI.antennasRepository) @Inject(DI.antennasRepository)
private antennasRepository: AntennasRepository, private antennasRepository: AntennasRepository,
@@ -48,16 +45,6 @@ export class CleanProcessorService {
createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))), createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))),
}); });
this.mutedNotesRepository.delete({
id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))),
reason: 'word',
});
this.mutedNotesRepository.delete({
id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))),
reason: 'word',
});
// 使われてないアンテナを停止 // 使われてないアンテナを停止
if (this.config.deactivateAntennaThreshold > 0) { if (this.config.deactivateAntennaThreshold > 0) {
this.antennasRepository.update({ this.antennasRepository.update({

View File

@@ -8,7 +8,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { format as DateFormat } from 'date-fns'; import { format as DateFormat } from 'date-fns';
import { In } from 'typeorm'; import { In } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { AntennasRepository, UsersRepository, UserListJoiningsRepository, MiUser } from '@/models/_.js'; import type { AntennasRepository, UsersRepository, UserListMembershipsRepository, MiUser } from '@/models/_.js';
import Logger from '@/logger.js'; import Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js'; import { DriveService } from '@/core/DriveService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@@ -29,8 +29,8 @@ export class ExportAntennasProcessorService {
@Inject(DI.antennasRepository) @Inject(DI.antennasRepository)
private antennsRepository: AntennasRepository, private antennsRepository: AntennasRepository,
@Inject(DI.userListJoiningsRepository) @Inject(DI.userListMembershipsRepository)
private userListJoiningsRepository: UserListJoiningsRepository, private userListMembershipsRepository: UserListMembershipsRepository,
private driveService: DriveService, private driveService: DriveService,
private utilityService: UtilityService, private utilityService: UtilityService,
@@ -65,9 +65,9 @@ export class ExportAntennasProcessorService {
for (const [index, antenna] of antennas.entries()) { for (const [index, antenna] of antennas.entries()) {
let users: MiUser[] | undefined; let users: MiUser[] | undefined;
if (antenna.userListId !== null) { if (antenna.userListId !== null) {
const joinings = await this.userListJoiningsRepository.findBy({ userListId: antenna.userListId }); const memberships = await this.userListMembershipsRepository.findBy({ userListId: antenna.userListId });
users = await this.usersRepository.findBy({ users = await this.usersRepository.findBy({
id: In(joinings.map(j => j.userId)), id: In(memberships.map(j => j.userId)),
}); });
} }
write(JSON.stringify({ write(JSON.stringify({

View File

@@ -8,7 +8,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm'; import { In } from 'typeorm';
import { format as dateFormat } from 'date-fns'; import { format as dateFormat } from 'date-fns';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { UserListJoiningsRepository, UserListsRepository, UsersRepository } from '@/models/_.js'; import type { UserListMembershipsRepository, UserListsRepository, UsersRepository } from '@/models/_.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js'; import { DriveService } from '@/core/DriveService.js';
import { createTemp } from '@/misc/create-temp.js'; import { createTemp } from '@/misc/create-temp.js';
@@ -29,8 +29,8 @@ export class ExportUserListsProcessorService {
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository) @Inject(DI.userListMembershipsRepository)
private userListJoiningsRepository: UserListJoiningsRepository, private userListMembershipsRepository: UserListMembershipsRepository,
private utilityService: UtilityService, private utilityService: UtilityService,
private driveService: DriveService, private driveService: DriveService,
@@ -61,9 +61,9 @@ export class ExportUserListsProcessorService {
const stream = fs.createWriteStream(path, { flags: 'a' }); const stream = fs.createWriteStream(path, { flags: 'a' });
for (const list of lists) { for (const list of lists) {
const joinings = await this.userListJoiningsRepository.findBy({ userListId: list.id }); const memberships = await this.userListMembershipsRepository.findBy({ userListId: list.id });
const users = await this.usersRepository.findBy({ const users = await this.usersRepository.findBy({
id: In(joinings.map(j => j.userId)), id: In(memberships.map(j => j.userId)),
}); });
for (const u of users) { for (const u of users) {

View File

@@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { UsersRepository, DriveFilesRepository, UserListJoiningsRepository, UserListsRepository } from '@/models/_.js'; import type { UsersRepository, DriveFilesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import * as Acct from '@/misc/acct.js'; import * as Acct from '@/misc/acct.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
@@ -33,8 +33,8 @@ export class ImportUserListsProcessorService {
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository) @Inject(DI.userListMembershipsRepository)
private userListJoiningsRepository: UserListJoiningsRepository, private userListMembershipsRepository: UserListMembershipsRepository,
private utilityService: UtilityService, private utilityService: UtilityService,
private idService: IdService, private idService: IdService,
@@ -99,7 +99,7 @@ export class ImportUserListsProcessorService {
target = await this.remoteUserResolveService.resolveUser(username, host); target = await this.remoteUserResolveService.resolveUser(username, host);
} }
if (await this.userListJoiningsRepository.findOneBy({ userListId: list!.id, userId: target.id }) != null) continue; if (await this.userListMembershipsRepository.findOneBy({ userListId: list!.id, userId: target.id }) != null) continue;
this.userListService.addMember(target, list!, user); this.userListService.addMember(target, list!, user);
} catch (e) { } catch (e) {

View File

@@ -205,7 +205,6 @@ import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js';
import * as ep___i_favorites from './endpoints/i/favorites.js'; import * as ep___i_favorites from './endpoints/i/favorites.js';
import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js'; import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js';
import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js'; import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js';
import * as ep___i_getWordMutedNotesCount from './endpoints/i/get-word-muted-notes-count.js';
import * as ep___i_importBlocking from './endpoints/i/import-blocking.js'; import * as ep___i_importBlocking from './endpoints/i/import-blocking.js';
import * as ep___i_importFollowing from './endpoints/i/import-following.js'; import * as ep___i_importFollowing from './endpoints/i/import-following.js';
import * as ep___i_importMuting from './endpoints/i/import-muting.js'; import * as ep___i_importMuting from './endpoints/i/import-muting.js';
@@ -336,7 +335,9 @@ import * as ep___users_lists_show from './endpoints/users/lists/show.js';
import * as ep___users_lists_update from './endpoints/users/lists/update.js'; import * as ep___users_lists_update from './endpoints/users/lists/update.js';
import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js'; import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js';
import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js'; import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js';
import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js'; import * as ep___users_lists_createFromPublic from './endpoints/users/lists/create-from-public.js';
import * as ep___users_lists_updateMembership from './endpoints/users/lists/update-membership.js';
import * as ep___users_lists_getMemberships from './endpoints/users/lists/get-memberships.js';
import * as ep___users_notes from './endpoints/users/notes.js'; import * as ep___users_notes from './endpoints/users/notes.js';
import * as ep___users_pages from './endpoints/users/pages.js'; import * as ep___users_pages from './endpoints/users/pages.js';
import * as ep___users_flashs from './endpoints/users/flashs.js'; import * as ep___users_flashs from './endpoints/users/flashs.js';
@@ -554,7 +555,6 @@ const $i_exportAntennas: Provider = { provide: 'ep:i/export-antennas', useClass:
const $i_favorites: Provider = { provide: 'ep:i/favorites', useClass: ep___i_favorites.default }; const $i_favorites: Provider = { provide: 'ep:i/favorites', useClass: ep___i_favorites.default };
const $i_gallery_likes: Provider = { provide: 'ep:i/gallery/likes', useClass: ep___i_gallery_likes.default }; const $i_gallery_likes: Provider = { provide: 'ep:i/gallery/likes', useClass: ep___i_gallery_likes.default };
const $i_gallery_posts: Provider = { provide: 'ep:i/gallery/posts', useClass: ep___i_gallery_posts.default }; const $i_gallery_posts: Provider = { provide: 'ep:i/gallery/posts', useClass: ep___i_gallery_posts.default };
const $i_getWordMutedNotesCount: Provider = { provide: 'ep:i/get-word-muted-notes-count', useClass: ep___i_getWordMutedNotesCount.default };
const $i_importBlocking: Provider = { provide: 'ep:i/import-blocking', useClass: ep___i_importBlocking.default }; const $i_importBlocking: Provider = { provide: 'ep:i/import-blocking', useClass: ep___i_importBlocking.default };
const $i_importFollowing: Provider = { provide: 'ep:i/import-following', useClass: ep___i_importFollowing.default }; const $i_importFollowing: Provider = { provide: 'ep:i/import-following', useClass: ep___i_importFollowing.default };
const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep___i_importMuting.default }; const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep___i_importMuting.default };
@@ -685,7 +685,9 @@ const $users_lists_show: Provider = { provide: 'ep:users/lists/show', useClass:
const $users_lists_update: Provider = { provide: 'ep:users/lists/update', useClass: ep___users_lists_update.default }; const $users_lists_update: Provider = { provide: 'ep:users/lists/update', useClass: ep___users_lists_update.default };
const $users_lists_favorite: Provider = { provide: 'ep:users/lists/favorite', useClass: ep___users_lists_favorite.default }; const $users_lists_favorite: Provider = { provide: 'ep:users/lists/favorite', useClass: ep___users_lists_favorite.default };
const $users_lists_unfavorite: Provider = { provide: 'ep:users/lists/unfavorite', useClass: ep___users_lists_unfavorite.default }; const $users_lists_unfavorite: Provider = { provide: 'ep:users/lists/unfavorite', useClass: ep___users_lists_unfavorite.default };
const $users_lists_create_from_public: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_create_from_public.default }; const $users_lists_createFromPublic: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_createFromPublic.default };
const $users_lists_updateMembership: Provider = { provide: 'ep:users/lists/update-membership', useClass: ep___users_lists_updateMembership.default };
const $users_lists_getMemberships: Provider = { provide: 'ep:users/lists/get-memberships', useClass: ep___users_lists_getMemberships.default };
const $users_notes: Provider = { provide: 'ep:users/notes', useClass: ep___users_notes.default }; const $users_notes: Provider = { provide: 'ep:users/notes', useClass: ep___users_notes.default };
const $users_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default }; const $users_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default };
const $users_flashs: Provider = { provide: 'ep:users/flashs', useClass: ep___users_flashs.default }; const $users_flashs: Provider = { provide: 'ep:users/flashs', useClass: ep___users_flashs.default };
@@ -907,7 +909,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_favorites, $i_favorites,
$i_gallery_likes, $i_gallery_likes,
$i_gallery_posts, $i_gallery_posts,
$i_getWordMutedNotesCount,
$i_importBlocking, $i_importBlocking,
$i_importFollowing, $i_importFollowing,
$i_importMuting, $i_importMuting,
@@ -1038,7 +1039,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_lists_update, $users_lists_update,
$users_lists_favorite, $users_lists_favorite,
$users_lists_unfavorite, $users_lists_unfavorite,
$users_lists_create_from_public, $users_lists_createFromPublic,
$users_lists_updateMembership,
$users_lists_getMemberships,
$users_notes, $users_notes,
$users_pages, $users_pages,
$users_flashs, $users_flashs,
@@ -1254,7 +1257,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_favorites, $i_favorites,
$i_gallery_likes, $i_gallery_likes,
$i_gallery_posts, $i_gallery_posts,
$i_getWordMutedNotesCount,
$i_importBlocking, $i_importBlocking,
$i_importFollowing, $i_importFollowing,
$i_importMuting, $i_importMuting,
@@ -1382,7 +1384,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_lists_update, $users_lists_update,
$users_lists_favorite, $users_lists_favorite,
$users_lists_unfavorite, $users_lists_unfavorite,
$users_lists_create_from_public, $users_lists_createFromPublic,
$users_lists_updateMembership,
$users_lists_getMemberships,
$users_notes, $users_notes,
$users_pages, $users_pages,
$users_flashs, $users_flashs,

View File

@@ -14,6 +14,7 @@ import { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { MiLocalUser } from '@/models/User.js'; import { MiLocalUser } from '@/models/User.js';
import { UserService } from '@/core/UserService.js';
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
import MainStreamConnection from './stream/Connection.js'; import MainStreamConnection from './stream/Connection.js';
import { ChannelsService } from './stream/ChannelsService.js'; import { ChannelsService } from './stream/ChannelsService.js';
@@ -37,6 +38,7 @@ export class StreamingApiServerService {
private authenticateService: AuthenticateService, private authenticateService: AuthenticateService,
private channelsService: ChannelsService, private channelsService: ChannelsService,
private notificationService: NotificationService, private notificationService: NotificationService,
private usersService: UserService,
) { ) {
} }
@@ -130,14 +132,10 @@ export class StreamingApiServerService {
this.#connections.set(connection, Date.now()); this.#connections.set(connection, Date.now());
const userUpdateIntervalId = user ? setInterval(() => { const userUpdateIntervalId = user ? setInterval(() => {
this.usersRepository.update(user.id, { this.usersService.updateLastActiveDate(user);
lastActiveDate: new Date(),
});
}, 1000 * 60 * 5) : null; }, 1000 * 60 * 5) : null;
if (user) { if (user) {
this.usersRepository.update(user.id, { this.usersService.updateLastActiveDate(user);
lastActiveDate: new Date(),
});
} }
connection.once('close', () => { connection.once('close', () => {

View File

@@ -205,7 +205,6 @@ import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js';
import * as ep___i_favorites from './endpoints/i/favorites.js'; import * as ep___i_favorites from './endpoints/i/favorites.js';
import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js'; import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js';
import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js'; import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js';
import * as ep___i_getWordMutedNotesCount from './endpoints/i/get-word-muted-notes-count.js';
import * as ep___i_importBlocking from './endpoints/i/import-blocking.js'; import * as ep___i_importBlocking from './endpoints/i/import-blocking.js';
import * as ep___i_importFollowing from './endpoints/i/import-following.js'; import * as ep___i_importFollowing from './endpoints/i/import-following.js';
import * as ep___i_importMuting from './endpoints/i/import-muting.js'; import * as ep___i_importMuting from './endpoints/i/import-muting.js';
@@ -335,8 +334,10 @@ import * as ep___users_lists_push from './endpoints/users/lists/push.js';
import * as ep___users_lists_show from './endpoints/users/lists/show.js'; import * as ep___users_lists_show from './endpoints/users/lists/show.js';
import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js'; import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js';
import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js'; import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js';
import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js'; import * as ep___users_lists_createFromPublic from './endpoints/users/lists/create-from-public.js';
import * as ep___users_lists_update from './endpoints/users/lists/update.js'; import * as ep___users_lists_update from './endpoints/users/lists/update.js';
import * as ep___users_lists_updateMembership from './endpoints/users/lists/update-membership.js';
import * as ep___users_lists_getMemberships from './endpoints/users/lists/get-memberships.js';
import * as ep___users_notes from './endpoints/users/notes.js'; import * as ep___users_notes from './endpoints/users/notes.js';
import * as ep___users_pages from './endpoints/users/pages.js'; import * as ep___users_pages from './endpoints/users/pages.js';
import * as ep___users_flashs from './endpoints/users/flashs.js'; import * as ep___users_flashs from './endpoints/users/flashs.js';
@@ -552,7 +553,6 @@ const eps = [
['i/favorites', ep___i_favorites], ['i/favorites', ep___i_favorites],
['i/gallery/likes', ep___i_gallery_likes], ['i/gallery/likes', ep___i_gallery_likes],
['i/gallery/posts', ep___i_gallery_posts], ['i/gallery/posts', ep___i_gallery_posts],
['i/get-word-muted-notes-count', ep___i_getWordMutedNotesCount],
['i/import-blocking', ep___i_importBlocking], ['i/import-blocking', ep___i_importBlocking],
['i/import-following', ep___i_importFollowing], ['i/import-following', ep___i_importFollowing],
['i/import-muting', ep___i_importMuting], ['i/import-muting', ep___i_importMuting],
@@ -683,7 +683,9 @@ const eps = [
['users/lists/favorite', ep___users_lists_favorite], ['users/lists/favorite', ep___users_lists_favorite],
['users/lists/unfavorite', ep___users_lists_unfavorite], ['users/lists/unfavorite', ep___users_lists_unfavorite],
['users/lists/update', ep___users_lists_update], ['users/lists/update', ep___users_lists_update],
['users/lists/create-from-public', ep___users_lists_create_from_public], ['users/lists/create-from-public', ep___users_lists_createFromPublic],
['users/lists/update-membership', ep___users_lists_updateMembership],
['users/lists/get-memberships', ep___users_lists_getMemberships],
['users/notes', ep___users_notes], ['users/notes', ep___users_notes],
['users/pages', ep___users_pages], ['users/pages', ep___users_pages],
['users/flashs', ep___users_flashs], ['users/flashs', ep___users_flashs],

View File

@@ -56,8 +56,8 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.redis) @Inject(DI.redisForTimelines)
private redisClient: Redis.Redis, private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@@ -86,7 +86,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}); });
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const noteIdsRes = await this.redisClient.xrevrange( const noteIdsRes = await this.redisForTimelines.xrevrange(
`antennaTimeline:${antenna.id}`, `antennaTimeline:${antenna.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-', ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',

View File

@@ -54,8 +54,8 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.redis) @Inject(DI.redisForTimelines)
private redisClient: Redis.Redis, private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@@ -83,7 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
let noteIdsRes: [string, string[]][] = []; let noteIdsRes: [string, string[]][] = [];
if (!ps.sinceId && !ps.sinceDate) { if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisClient.xrevrange( noteIdsRes = await this.redisForTimelines.xrevrange(
`channelTimeline:${channel.id}`, `channelTimeline:${channel.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-', '-',
@@ -104,7 +104,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me) { if (me) {
this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me); this.queryService.generateBlockedUserQuery(query, me);
} }
//#endregion //#endregion
@@ -129,7 +128,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me) { if (me) {
this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me); this.queryService.generateBlockedUserQuery(query, me);
} }
//#endregion //#endregion

View File

@@ -57,8 +57,9 @@ export const paramDef = {
properties: { properties: {
userId: { type: 'string', format: 'misskey:id' }, userId: { type: 'string', format: 'misskey:id' },
notify: { type: 'string', enum: ['normal', 'none'] }, notify: { type: 'string', enum: ['normal', 'none'] },
withReplies: { type: 'boolean' },
}, },
required: ['userId', 'notify'], required: ['userId'],
} as const; } as const;
@Injectable() @Injectable()
@@ -98,7 +99,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
await this.followingsRepository.update({ await this.followingsRepository.update({
id: exist.id, id: exist.id,
}, { }, {
notify: ps.notify === 'none' ? null : ps.notify, notify: ps.notify != null ? (ps.notify === 'none' ? null : ps.notify) : undefined,
withReplies: ps.withReplies != null ? ps.withReplies : undefined,
}); });
return await this.userEntityService.pack(follower.id, me); return await this.userEntityService.pack(follower.id, me);

View File

@@ -1,51 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { MutedNotesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
export const meta = {
tags: ['account'],
requireCredential: true,
kind: 'read:account',
res: {
type: 'object',
optional: false, nullable: false,
properties: {
count: {
type: 'number',
optional: false, nullable: false,
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.mutedNotesRepository)
private mutedNotesRepository: MutedNotesRepository,
) {
super(meta, paramDef, async (ps, me) => {
return {
count: await this.mutedNotesRepository.countBy({
userId: me.id,
reason: 'word',
}),
};
});
}
}

View File

@@ -40,7 +40,6 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
withFiles: { type: 'boolean', default: false }, withFiles: { type: 'boolean', default: false },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' }, sinceId: { type: 'string', format: 'misskey:id' },
@@ -68,49 +67,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.gtlDisabled); throw new ApiError(meta.errors.gtlDisabled);
} }
//#region Construct query // TODO?
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), return [];
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.visibility = \'public\'')
.andWhere('note.channelId IS NULL')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateRepliesQuery(query, ps.withReplies, me);
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
process.nextTick(() => {
if (me) {
this.activeUsersChart.read(me);
}
});
return await this.noteEntityService.packMany(timeline, me);
}); });
} }
} }

View File

@@ -5,14 +5,16 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository, FollowingsRepository } from '@/models/_.js'; import * as Redis from 'ioredis';
import type { NotesRepository, FollowingsRepository, MiNote } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { CacheService } from '@/core/CacheService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@@ -51,7 +53,6 @@ export const paramDef = {
includeRenotedMyNotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false }, withFiles: { type: 'boolean', default: false },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true },
}, },
required: [], required: [],
@@ -60,17 +61,17 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService,
private roleService: RoleService, private roleService: RoleService,
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private idService: IdService, private idService: IdService,
private cacheService: CacheService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me.id); const policies = await this.roleService.getUserPolicies(me.id);
@@ -78,79 +79,75 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.stlDisabled); throw new ApiError(meta.errors.stlDisabled);
} }
//#region Construct query const [
const followingQuery = this.followingsRepository.createQueryBuilder('following') userIdsWhoMeMuting,
.select('following.followeeId') userIdsWhoMeMutingRenotes,
.where('following.followerId = :followerId', { followerId: me.id }); userIdsWhoBlockingMe,
] = await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]);
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), let timeline: MiNote[] = [];
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
.andWhere(new Brackets(qb => { let htlNoteIdsRes: [string, string[]][] = [];
qb.where(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id }) let ltlNoteIdsRes: [string, string[]][] = [];
.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
})) if (!ps.sinceId && !ps.sinceDate) {
htlNoteIdsRes = await this.redisForTimelines.xrevrange(
ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-',
'COUNT', limit);
ltlNoteIdsRes = await this.redisForTimelines.xrevrange(
ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline',
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-',
'COUNT', limit);
}
const htlNoteIds = htlNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
const ltlNoteIds = ltlNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
let noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
noteIds.sort((a, b) => a > b ? -1 : 1);
noteIds = noteIds.slice(0, ps.limit);
if (noteIds.length === 0) {
return [];
}
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser')
.setParameters(followingQuery.getParameters()); .leftJoinAndSelect('note.channel', 'channel');
this.queryService.generateChannelQuery(query, me); timeline = await query.getMany();
this.queryService.generateRepliesQuery(query, ps.withReplies, me);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) { timeline = timeline.filter(note => {
query.andWhere(new Brackets(qb => { if (note.userId === me.id) {
qb.orWhere('note.userId != :meId', { meId: me.id }); return true;
qb.orWhere('note.renoteId IS NULL'); }
qb.orWhere('note.text IS NOT NULL'); if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
qb.orWhere('note.fileIds != \'{}\''); if (isUserRelated(note, userIdsWhoMeMuting)) return false;
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); if (note.renoteId) {
})); if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
} if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
if (ps.includeRenotedMyNotes === false) { return true;
query.andWhere(new Brackets(qb => { });
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeLocalRenotes === false) { // TODO: フィルタした結果件数が足りなかった場合の対応
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.withFiles) { timeline.sort((a, b) => a.id > b.id ? -1 : 1);
query.andWhere('note.fileIds != \'{}\'');
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
process.nextTick(() => { process.nextTick(() => {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);

View File

@@ -5,14 +5,16 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/_.js'; import * as Redis from 'ioredis';
import type { MiNote, NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@@ -41,11 +43,7 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
withFiles: { type: 'boolean', default: false }, withFiles: { type: 'boolean', default: false },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true },
fileType: { type: 'array', items: {
type: 'string',
} },
excludeNsfw: { type: 'boolean', default: false }, excludeNsfw: { type: 'boolean', default: false },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' }, sinceId: { type: 'string', format: 'misskey:id' },
@@ -59,14 +57,17 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService,
private roleService: RoleService, private roleService: RoleService,
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private idService: IdService, private idService: IdService,
private cacheService: CacheService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me ? me.id : null); const policies = await this.roleService.getUserPolicies(me ? me.id : null);
@@ -74,56 +75,63 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.ltlDisabled); throw new ApiError(meta.errors.ltlDisabled);
} }
//#region Construct query const [
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), userIdsWhoMeMuting,
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) userIdsWhoMeMutingRenotes,
.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで userIdsWhoBlockingMe,
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') ] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>(), new Set<string>()];
let timeline: MiNote[] = [];
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
let noteIdsRes: [string, string[]][] = [];
if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisForTimelines.xrevrange(
ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline',
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-',
'COUNT', limit);
}
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
if (noteIds.length === 0) {
return [];
}
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
this.queryService.generateChannelQuery(query, me); timeline = await query.getMany();
this.queryService.generateRepliesQuery(query, ps.withReplies, me);
this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me);
if (me) this.queryService.generateMutedNoteQuery(query, me);
if (me) this.queryService.generateBlockedUserQuery(query, me);
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.withFiles) { timeline = timeline.filter(note => {
query.andWhere('note.fileIds != \'{}\''); if (me && (note.userId === me.id)) {
} return true;
}
if (ps.fileType != null) { if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
query.andWhere('note.fileIds != \'{}\''); if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
query.andWhere(new Brackets(qb => { if (note.renoteId) {
for (const type of ps.fileType!) { if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
const i = ps.fileType!.indexOf(type); if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type }); if (ps.withRenotes === false) return false;
} }
}));
if (ps.excludeNsfw) {
query.andWhere('note.cw IS NULL');
query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)');
} }
}
if (ps.withRenotes === false) { return true;
query.andWhere(new Brackets(qb => { });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
//#endregion
const timeline = await query.limit(ps.limit).getMany(); timeline.sort((a, b) => a.id > b.id ? -1 : 1);
process.nextTick(() => { process.nextTick(() => {
if (me) { if (me) {

View File

@@ -5,13 +5,16 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository, FollowingsRepository } from '@/models/_.js'; import * as Redis from 'ioredis';
import type { NotesRepository, FollowingsRepository, MiNote } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@@ -41,7 +44,6 @@ export const paramDef = {
includeRenotedMyNotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false }, withFiles: { type: 'boolean', default: false },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true },
}, },
required: [], required: [],
@@ -50,96 +52,82 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService,
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private idService: IdService, private idService: IdService,
private cacheService: CacheService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const followees = await this.followingsRepository.createQueryBuilder('following') const [
.select('following.followeeId') followings,
.where('following.followerId = :followerId', { followerId: me.id }) userIdsWhoMeMuting,
.getMany(); userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(me.id),
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]);
//#region Construct query let timeline: MiNote[] = [];
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
// パフォーマンス上の利点が無さそう? let noteIdsRes: [string, string[]][] = [];
//.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisForTimelines.xrevrange(
ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-',
'COUNT', limit);
}
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
if (noteIds.length === 0) {
return [];
}
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
if (followees.length > 0) { timeline = await query.getMany();
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); timeline = timeline.filter(note => {
} else { if (note.userId === me.id) {
query.andWhere('note.userId = :meId', { meId: me.id }); return true;
} }
if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
if (note.reply && note.reply.visibility === 'followers') {
if (!Object.hasOwn(followings, note.reply.userId)) return false;
}
this.queryService.generateChannelQuery(query, me); return true;
this.queryService.generateRepliesQuery(query, ps.withReplies, me); });
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) { // TODO: フィルタした結果件数が足りなかった場合の対応
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeRenotedMyNotes === false) { timeline.sort((a, b) => a.id > b.id ? -1 : 1);
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeLocalRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
process.nextTick(() => { process.nextTick(() => {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);

View File

@@ -10,12 +10,13 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
import { GetterService } from '@/server/api/GetterService.js'; import { GetterService } from '@/server/api/GetterService.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
requireCredential: false, requireCredential: true,
res: { res: {
type: 'object', type: 'object',
@@ -23,6 +24,11 @@ export const meta = {
}, },
errors: { errors: {
unavailable: {
message: 'Translate of notes unavailable.',
code: 'UNAVAILABLE',
id: '50a70314-2d8a-431b-b433-efa5cc56444c',
},
noSuchNote: { noSuchNote: {
message: 'No such note.', message: 'No such note.',
code: 'NO_SUCH_NOTE', code: 'NO_SUCH_NOTE',
@@ -47,14 +53,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private getterService: GetterService, private getterService: GetterService,
private metaService: MetaService, private metaService: MetaService,
private httpRequestService: HttpRequestService, private httpRequestService: HttpRequestService,
private roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me.id);
if (!policies.canUseTranslator) {
throw new ApiError(meta.errors.unavailable);
}
const note = await this.getterService.getNote(ps.noteId).catch(err => { const note = await this.getterService.getNote(ps.noteId).catch(err => {
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
throw err; throw err;
}); });
if (!(await this.noteEntityService.isVisibleForMe(note, me ? me.id : null))) { if (!(await this.noteEntityService.isVisibleForMe(note, me.id))) {
return 204; // TODO: 良い感じのエラー返す return 204; // TODO: 良い感じのエラー返す
} }

View File

@@ -5,12 +5,16 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/_.js'; import * as Redis from 'ioredis';
import type { NotesRepository, UserListsRepository, UserListMembershipsRepository, MiNote } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@@ -49,7 +53,6 @@ export const paramDef = {
includeMyRenotes: { type: 'boolean', default: true }, includeMyRenotes: { type: 'boolean', default: true },
includeRenotedMyNotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true },
withFiles: { withFiles: {
type: 'boolean', type: 'boolean',
@@ -63,18 +66,19 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService,
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private cacheService: CacheService,
private idService: IdService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const list = await this.userListsRepository.findOneBy({ const list = await this.userListsRepository.findOneBy({
@@ -86,72 +90,65 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchList); throw new ApiError(meta.errors.noSuchList);
} }
//#region Construct query const [
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) userIdsWhoMeMuting,
.innerJoin(this.userListJoiningsRepository.metadata.targetName, 'userListJoining', 'userListJoining.userId = note.userId') userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]);
let timeline: MiNote[] = [];
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
let noteIdsRes: [string, string[]][] = [];
if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisForTimelines.xrevrange(
ps.withFiles ? `userListTimelineWithFiles:${list.id}` : `userListTimeline:${list.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-',
'COUNT', limit);
}
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
if (noteIds.length === 0) {
return [];
}
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser')
.andWhere('userListJoining.userListId = :userListId', { userListId: list.id }); .leftJoinAndSelect('note.channel', 'channel');
this.queryService.generateVisibilityQuery(query, me); timeline = await query.getMany();
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) { timeline = timeline.filter(note => {
query.andWhere(new Brackets(qb => { if (note.userId === me.id) {
qb.orWhere('note.userId != :meId', { meId: me.id }); return true;
qb.orWhere('note.renoteId IS NULL'); }
qb.orWhere('note.text IS NOT NULL'); if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
qb.orWhere('note.fileIds != \'{}\''); if (isUserRelated(note, userIdsWhoMeMuting)) return false;
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); if (note.renoteId) {
})); if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
} if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
if (ps.includeRenotedMyNotes === false) { return true;
query.andWhere(new Brackets(qb => { });
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeLocalRenotes === false) { // TODO: フィルタした結果件数が足りなかった場合の対応
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (!ps.withReplies) { timeline.sort((a, b) => a.id > b.id ? -1 : 1);
query.andWhere('note.replyId IS NULL');
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
this.activeUsersChart.read(me); this.activeUsersChart.read(me);

View File

@@ -53,8 +53,8 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.redis) @Inject(DI.redisForTimelines)
private redisClient: Redis.Redis, private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@@ -79,7 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return []; return [];
} }
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const noteIdsRes = await this.redisClient.xrevrange( const noteIdsRes = await this.redisForTimelines.xrevrange(
`roleTimeline:${role.id}`, `roleTimeline:${role.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-', ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',

View File

@@ -4,7 +4,7 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/_.js'; import type { UserListsRepository, UserListMembershipsRepository, BlockingsRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import type { MiUserList } from '@/models/UserList.js'; import type { MiUserList } from '@/models/UserList.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
@@ -76,8 +76,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository) @Inject(DI.userListMembershipsRepository)
private userListJoiningsRepository: UserListJoiningsRepository, private userListMembershipsRepository: UserListMembershipsRepository,
@Inject(DI.blockingsRepository) @Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository, private blockingsRepository: BlockingsRepository,
@@ -110,7 +110,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
name: ps.name, name: ps.name,
} as MiUserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0])); } as MiUserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0]));
const users = (await this.userListJoiningsRepository.findBy({ const users = (await this.userListMembershipsRepository.findBy({
userListId: ps.listId, userListId: ps.listId,
})).map(x => x.userId); })).map(x => x.userId);
@@ -132,7 +132,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
} }
const exist = await this.userListJoiningsRepository.exist({ const exist = await this.userListMembershipsRepository.exist({
where: { where: {
userListId: userList.id, userListId: userList.id,
userId: currentUser.id, userId: currentUser.id,

View File

@@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { UserListsRepository, UserListFavoritesRepository, UserListMembershipsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
import { DI } from '@/di-symbols.js';
import { QueryService } from '@/core/QueryService.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['lists', 'account'],
requireCredential: false,
kind: 'read:account',
errors: {
noSuchList: {
message: 'No such list.',
code: 'NO_SUCH_LIST',
id: '7bc05c21-1d7a-41ae-88f1-66820f4dc686',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
listId: { type: 'string', format: 'misskey:id' },
forPublic: { type: 'boolean', default: false },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
},
required: ['listId'],
} as const;
@Injectable() // eslint-disable-next-line import/no-default-export
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
private userListEntityService: UserListEntityService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
// Fetch the list
const userList = await this.userListsRepository.findOneBy(!ps.forPublic && me !== null ? {
id: ps.listId,
userId: me.id,
} : {
id: ps.listId,
isPublic: true,
});
if (userList == null) {
throw new ApiError(meta.errors.noSuchList);
}
const query = this.queryService.makePaginationQuery(this.userListMembershipsRepository.createQueryBuilder('membership'), ps.sinceId, ps.untilId)
.andWhere('membership.userListId = :userListId', { userListId: userList.id })
.innerJoinAndSelect('membership.user', 'user');
const memberships = await query
.limit(ps.limit)
.getMany();
return this.userListEntityService.packMembershipsMany(memberships);
});
}
}

View File

@@ -5,7 +5,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms'; import ms from 'ms';
import type { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/_.js'; import type { UserListsRepository, UserListMembershipsRepository, BlockingsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { GetterService } from '@/server/api/GetterService.js'; import { GetterService } from '@/server/api/GetterService.js';
import { UserListService } from '@/core/UserListService.js'; import { UserListService } from '@/core/UserListService.js';
@@ -76,8 +76,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository) @Inject(DI.userListMembershipsRepository)
private userListJoiningsRepository: UserListJoiningsRepository, private userListMembershipsRepository: UserListMembershipsRepository,
@Inject(DI.blockingsRepository) @Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository, private blockingsRepository: BlockingsRepository,
@@ -115,7 +115,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
} }
const exist = await this.userListJoiningsRepository.exist({ const exist = await this.userListMembershipsRepository.exist({
where: { where: {
userListId: userList.id, userListId: userList.id,
userId: user.id, userId: user.id,

View File

@@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { UserListsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { GetterService } from '@/server/api/GetterService.js';
import { DI } from '@/di-symbols.js';
import { UserListService } from '@/core/UserListService.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['lists', 'users'],
requireCredential: true,
prohibitMoved: true,
kind: 'write:account',
errors: {
noSuchList: {
message: 'No such list.',
code: 'NO_SUCH_LIST',
id: '7f44670e-ab16-43b8-b4c1-ccd2ee89cc02',
},
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '588e7f72-c744-4a61-b180-d354e912bda2',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
listId: { type: 'string', format: 'misskey:id' },
userId: { type: 'string', format: 'misskey:id' },
withReplies: { type: 'boolean' },
},
required: ['listId', 'userId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
private userListService: UserListService,
private getterService: GetterService,
) {
super(meta, paramDef, async (ps, me) => {
// Fetch the list
const userList = await this.userListsRepository.findOneBy({
id: ps.listId,
userId: me.id,
});
if (userList == null) {
throw new ApiError(meta.errors.noSuchList);
}
// Fetch the user
const user = await this.getterService.getUser(ps.userId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
await this.userListService.updateMembership(user, userList, {
withReplies: ps.withReplies,
});
});
}
}

View File

@@ -5,12 +5,14 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/_.js'; import * as Redis from 'ioredis';
import type { MiNote, NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js'; import { GetterService } from '@/server/api/GetterService.js';
import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@@ -50,9 +52,6 @@ export const paramDef = {
untilDate: { type: 'integer' }, untilDate: { type: 'integer' },
includeMyRenotes: { type: 'boolean', default: true }, includeMyRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false }, withFiles: { type: 'boolean', default: false },
fileType: { type: 'array', items: {
type: 'string',
} },
excludeNsfw: { type: 'boolean', default: false }, excludeNsfw: { type: 'boolean', default: false },
}, },
required: ['userId'], required: ['userId'],
@@ -61,87 +60,63 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService,
private getterService: GetterService, private getterService: GetterService,
private cacheService: CacheService,
private idService: IdService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
// Lookup user let timeline: MiNote[] = [];
const user = await this.getterService.getUser(ps.userId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
//#region Construct query const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) let noteIdsRes: [string, string[]][] = [];
.andWhere('note.userId = :userId', { userId: user.id })
if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisForTimelines.xrevrange(
ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : ps.withReplies ? `userTimelineWithReplies:${ps.userId}` : `userTimeline:${ps.userId}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-',
'COUNT', limit);
}
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
if (noteIds.length === 0) {
return [];
}
const isFollowing = me ? Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId) : false;
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('note.channel', 'channel')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
query.andWhere(new Brackets(qb => { timeline = await query.getMany();
qb.orWhere('note.channelId IS NULL');
qb.orWhere('channel.isSensitive = false');
}));
this.queryService.generateVisibilityQuery(query, me); timeline = timeline.filter(note => {
if (me) { if (note.renoteId) {
this.queryService.generateMutedUserQuery(query, me, user); if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
this.queryService.generateBlockedUserQuery(query, me); if (ps.withRenotes === false) return false;
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (ps.fileType != null) {
query.andWhere('note.fileIds != \'{}\'');
query.andWhere(new Brackets(qb => {
for (const type of ps.fileType!) {
const i = ps.fileType!.indexOf(type);
qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type });
} }
}));
if (ps.excludeNsfw) {
query.andWhere('note.cw IS NULL');
query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)');
} }
}
if (!ps.withReplies) { if (note.visibility === 'followers' && !isFollowing) return false;
query.andWhere('note.replyId IS NULL');
}
if (ps.withRenotes === false) { return true;
query.andWhere(new Brackets(qb => { });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
if (ps.includeMyRenotes === false) { timeline.sort((a, b) => a.id > b.id ? -1 : 1);
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :userId', { userId: user.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
return await this.noteEntityService.packMany(timeline, me); return await this.noteEntityService.packMany(timeline, me);
}); });

View File

@@ -11,7 +11,7 @@ import type { NoteReadService } from '@/core/NoteReadService.js';
import type { NotificationService } from '@/core/NotificationService.js'; import type { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { MiUserProfile } from '@/models/_.js'; import { MiFollowing, MiUserProfile } from '@/models/_.js';
import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js'; import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js';
import type { ChannelsService } from './ChannelsService.js'; import type { ChannelsService } from './ChannelsService.js';
import type { EventEmitter } from 'events'; import type { EventEmitter } from 'events';
@@ -30,7 +30,7 @@ export default class Connection {
private subscribingNotes: any = {}; private subscribingNotes: any = {};
private cachedNotes: Packed<'Note'>[] = []; private cachedNotes: Packed<'Note'>[] = [];
public userProfile: MiUserProfile | null = null; public userProfile: MiUserProfile | null = null;
public following: Set<string> = new Set(); public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
public followingChannels: Set<string> = new Set(); public followingChannels: Set<string> = new Set();
public userIdsWhoMeMuting: Set<string> = new Set(); public userIdsWhoMeMuting: Set<string> = new Set();
public userIdsWhoBlockingMe: Set<string> = new Set(); public userIdsWhoBlockingMe: Set<string> = new Set();

View File

@@ -18,7 +18,6 @@ class GlobalTimelineChannel extends Channel {
public readonly chName = 'globalTimeline'; public readonly chName = 'globalTimeline';
public static shouldShare = true; public static shouldShare = true;
public static requireCredential = false; public static requireCredential = false;
private withReplies: boolean;
private withRenotes: boolean; private withRenotes: boolean;
constructor( constructor(
@@ -38,7 +37,6 @@ class GlobalTimelineChannel extends Channel {
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
if (!policies.gtlAvailable) return; if (!policies.gtlAvailable) return;
this.withReplies = params.withReplies ?? false;
this.withRenotes = params.withRenotes ?? true; this.withRenotes = params.withRenotes ?? true;
// Subscribe events // Subscribe events
@@ -64,7 +62,7 @@ class GlobalTimelineChannel extends Channel {
} }
// 関係ない返信は除外 // 関係ない返信は除外
if (note.reply && !this.withReplies) { if (note.reply && !this.following[note.userId]?.withReplies) {
const reply = note.reply; const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
@@ -82,13 +80,6 @@ class GlobalTimelineChannel extends Channel {
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる
if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return;
this.connection.cacheNote(note); this.connection.cacheNote(note);
this.send('note', note); this.send('note', note);

View File

@@ -16,7 +16,6 @@ class HomeTimelineChannel extends Channel {
public readonly chName = 'homeTimeline'; public readonly chName = 'homeTimeline';
public static shouldShare = true; public static shouldShare = true;
public static requireCredential = true; public static requireCredential = true;
private withReplies: boolean;
private withRenotes: boolean; private withRenotes: boolean;
constructor( constructor(
@@ -31,7 +30,6 @@ class HomeTimelineChannel extends Channel {
@bindThis @bindThis
public async init(params: any) { public async init(params: any) {
this.withReplies = params.withReplies ?? false;
this.withRenotes = params.withRenotes ?? true; this.withRenotes = params.withRenotes ?? true;
this.subscriber.on('notesStream', this.onNote); this.subscriber.on('notesStream', this.onNote);
@@ -43,7 +41,7 @@ class HomeTimelineChannel extends Channel {
if (!this.followingChannels.has(note.channelId)) return; if (!this.followingChannels.has(note.channelId)) return;
} else { } else {
// その投稿のユーザーをフォローしていなかったら弾く // その投稿のユーザーをフォローしていなかったら弾く
if ((this.user!.id !== note.userId) && !this.following.has(note.userId)) return; if ((this.user!.id !== note.userId) && !Object.hasOwn(this.following, note.userId)) return;
} }
// Ignore notes from instances the user has muted // Ignore notes from instances the user has muted
@@ -73,7 +71,7 @@ class HomeTimelineChannel extends Channel {
} }
// 関係ない返信は除外 // 関係ない返信は除外
if (note.reply && !this.withReplies) { if (note.reply && !this.following[note.userId]?.withReplies) {
const reply = note.reply; const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
@@ -88,13 +86,6 @@ class HomeTimelineChannel extends Channel {
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる
if (await checkWordMute(note, this.user, this.userProfile!.mutedWords)) return;
this.connection.cacheNote(note); this.connection.cacheNote(note);
this.send('note', note); this.send('note', note);

View File

@@ -18,7 +18,6 @@ class HybridTimelineChannel extends Channel {
public readonly chName = 'hybridTimeline'; public readonly chName = 'hybridTimeline';
public static shouldShare = true; public static shouldShare = true;
public static requireCredential = true; public static requireCredential = true;
private withReplies: boolean;
private withRenotes: boolean; private withRenotes: boolean;
constructor( constructor(
@@ -38,7 +37,6 @@ class HybridTimelineChannel extends Channel {
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
if (!policies.ltlAvailable) return; if (!policies.ltlAvailable) return;
this.withReplies = params.withReplies ?? false;
this.withRenotes = params.withRenotes ?? true; this.withRenotes = params.withRenotes ?? true;
// Subscribe events // Subscribe events
@@ -53,7 +51,7 @@ class HybridTimelineChannel extends Channel {
// フォローしているチャンネルの投稿 の場合だけ // フォローしているチャンネルの投稿 の場合だけ
if (!( if (!(
(note.channelId == null && this.user!.id === note.userId) || (note.channelId == null && this.user!.id === note.userId) ||
(note.channelId == null && this.following.has(note.userId)) || (note.channelId == null && Object.hasOwn(this.following, note.userId)) ||
(note.channelId == null && (note.user.host == null && note.visibility === 'public')) || (note.channelId == null && (note.user.host == null && note.visibility === 'public')) ||
(note.channelId != null && this.followingChannels.has(note.channelId)) (note.channelId != null && this.followingChannels.has(note.channelId))
)) return; )) return;
@@ -85,7 +83,7 @@ class HybridTimelineChannel extends Channel {
if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances ?? []))) return; if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances ?? []))) return;
// 関係ない返信は除外 // 関係ない返信は除外
if (note.reply && !this.withReplies) { if (note.reply && !this.following[note.userId]?.withReplies) {
const reply = note.reply; const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
@@ -100,13 +98,6 @@ class HybridTimelineChannel extends Channel {
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる
if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return;
this.connection.cacheNote(note); this.connection.cacheNote(note);
this.send('note', note); this.send('note', note);

View File

@@ -17,7 +17,6 @@ class LocalTimelineChannel extends Channel {
public readonly chName = 'localTimeline'; public readonly chName = 'localTimeline';
public static shouldShare = true; public static shouldShare = true;
public static requireCredential = false; public static requireCredential = false;
private withReplies: boolean;
private withRenotes: boolean; private withRenotes: boolean;
constructor( constructor(
@@ -37,7 +36,6 @@ class LocalTimelineChannel extends Channel {
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
if (!policies.ltlAvailable) return; if (!policies.ltlAvailable) return;
this.withReplies = params.withReplies ?? false;
this.withRenotes = params.withRenotes ?? true; this.withRenotes = params.withRenotes ?? true;
// Subscribe events // Subscribe events
@@ -64,7 +62,7 @@ class LocalTimelineChannel extends Channel {
} }
// 関係ない返信は除外 // 関係ない返信は除外
if (note.reply && this.user && !this.withReplies) { if (note.reply && this.user && !this.following[note.userId]?.withReplies) {
const reply = note.reply; const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;
@@ -79,13 +77,6 @@ class LocalTimelineChannel extends Channel {
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる
if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return;
this.connection.cacheNote(note); this.connection.cacheNote(note);
this.send('note', note); this.send('note', note);

View File

@@ -4,7 +4,7 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UserListJoiningsRepository, UserListsRepository } from '@/models/_.js'; import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import { isUserRelated } from '@/misc/is-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
@@ -18,12 +18,12 @@ class UserListChannel extends Channel {
public static shouldShare = false; public static shouldShare = false;
public static requireCredential = false; public static requireCredential = false;
private listId: string; private listId: string;
public listUsers: MiUser['id'][] = []; public membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {};
private listUsersClock: NodeJS.Timeout; private listUsersClock: NodeJS.Timeout;
constructor( constructor(
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
private userListJoiningsRepository: UserListJoiningsRepository, private userListMembershipsRepository: UserListMembershipsRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
id: string, id: string,
@@ -58,19 +58,25 @@ class UserListChannel extends Channel {
@bindThis @bindThis
private async updateListUsers() { private async updateListUsers() {
const users = await this.userListJoiningsRepository.find({ const memberships = await this.userListMembershipsRepository.find({
where: { where: {
userListId: this.listId, userListId: this.listId,
}, },
select: ['userId'], select: ['userId'],
}); });
this.listUsers = users.map(x => x.userId); const membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {};
for (const membership of memberships) {
membershipsMap[membership.userId] = {
withReplies: membership.withReplies,
};
}
this.membershipsMap = membershipsMap;
} }
@bindThis @bindThis
private async onNote(note: Packed<'Note'>) { private async onNote(note: Packed<'Note'>) {
if (!this.listUsers.includes(note.userId)) return; if (!Object.hasOwn(this.membershipsMap, note.userId)) return;
if (['followers', 'specified'].includes(note.visibility)) { if (['followers', 'specified'].includes(note.visibility)) {
note = await this.noteEntityService.pack(note.id, this.user, { note = await this.noteEntityService.pack(note.id, this.user, {
@@ -95,6 +101,13 @@ class UserListChannel extends Channel {
} }
} }
// 関係ない返信は除外
if (note.reply && !this.membershipsMap[note.userId]?.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
}
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return; if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
@@ -124,8 +137,8 @@ export class UserListChannelService {
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository) @Inject(DI.userListMembershipsRepository)
private userListJoiningsRepository: UserListJoiningsRepository, private userListMembershipsRepository: UserListMembershipsRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
) { ) {
@@ -135,7 +148,7 @@ export class UserListChannelService {
public create(id: string, connection: Channel['connection']): UserListChannel { public create(id: string, connection: Channel['connection']): UserListChannel {
return new UserListChannel( return new UserListChannel(
this.userListsRepository, this.userListsRepository,
this.userListJoiningsRepository, this.userListMembershipsRepository,
this.noteEntityService, this.noteEntityService,
id, id,
connection, connection,

View File

@@ -188,7 +188,7 @@ export class ClientServerService {
// Authenticate // Authenticate
fastify.addHook('onRequest', async (request, reply) => { fastify.addHook('onRequest', async (request, reply) => {
// %71ueueとかでリクエストされたら困るため // %71ueueとかでリクエストされたら困るため
const url = decodeURI(request.routerPath); const url = decodeURI(request.routeOptions.url);
if (url === bullBoardPath || url.startsWith(bullBoardPath + '/')) { if (url === bullBoardPath || url.startsWith(bullBoardPath + '/')) {
const token = request.cookies.token; const token = request.cookies.token;
if (token == null) { if (token == null) {

View File

@@ -6,7 +6,7 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import { signup, api, post, react, startServer, waitFire } from '../utils.js'; import { signup, api, post, react, startServer, waitFire, sleep } from '../utils.js';
import type { INestApplicationContext } from '@nestjs/common'; import type { INestApplicationContext } from '@nestjs/common';
import type * as misskey from 'misskey-js'; import type * as misskey from 'misskey-js';
@@ -42,6 +42,9 @@ describe('Renote Mute', () => {
const carolRenote = await post(carol, { renoteId: bobNote.id }); const carolRenote = await post(carol, { renoteId: bobNote.id });
const carolNote = await post(carol, { text: 'hi' }); const carolNote = await post(carol, { text: 'hi' });
// redisに追加されるのを待つ
await sleep(100);
const res = await api('/notes/local-timeline', {}, alice); const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
@@ -56,6 +59,9 @@ describe('Renote Mute', () => {
const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' }); const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' });
const carolNote = await post(carol, { text: 'hi' }); const carolNote = await post(carol, { text: 'hi' });
// redisに追加されるのを待つ
await sleep(100);
const res = await api('/notes/local-timeline', {}, alice); const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);

View File

@@ -0,0 +1,701 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING = 'true';
import * as assert from 'assert';
import { signup, api, post, react, startServer, waitFire, sleep, uploadUrl } from '../utils.js';
import type { INestApplicationContext } from '@nestjs/common';
import type * as misskey from 'misskey-js';
let app: INestApplicationContext;
beforeAll(async () => {
app = await startServer();
}, 1000 * 60 * 2);
afterAll(async () => {
await app.close();
});
describe('Timelines', () => {
describe('Home TL', () => {
test.concurrent('自分の visibility: followers なノートが含まれる', async () => {
const [alice] = await Promise.all([signup()]);
const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi');
});
test.concurrent('フォローしているユーザーのノートが含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi' });
const carolNote = await post(carol, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'followers' });
const carolNote = await post(carol, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi');
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: false でフォローしているユーザーの他人への返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: true でフォローしているユーザーの他人への返信が含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: true でフォローしているユーザーの他人へのDM返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
const carolNote = await post(carol, { text: 'hi', visibility: 'followers' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/create', { userId: carol.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
const carolNote = await post(carol, { text: 'hi', visibility: 'followers' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === carolNote.id).text, 'hi');
});
test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの投稿への visibility: specified な返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/create', { userId: carol.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true);
});
test.concurrent('withReplies: false でフォローしているユーザーのそのユーザー自身への返信が含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
});
test.concurrent('自分の他人への返信が含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const bobNote = await post(bob, { text: 'hi' });
const aliceNote = await post(alice, { text: 'hi', replyId: bobNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true);
});
test.concurrent('フォローしているユーザーの他人の投稿のリノートが含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { renoteId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿のリノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { renoteId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {
withRenotes: false,
}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿の引用が含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {
withRenotes: false,
}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('フォローしているユーザーの他人への visibility: specified なノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/mute/create', { userId: carol.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
await api('/mute/create', { userId: carol.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'home' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('[withFiles: true] フォローしているユーザーのファイル付きノートのみ含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const [bobFile, carolFile] = await Promise.all([
uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png'),
uploadUrl(carol, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png'),
]);
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { fileIds: [bobFile.id] });
const carolNote1 = await post(carol, { text: 'hi' });
const carolNote2 = await post(carol, { fileIds: [carolFile.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', { withFiles: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote2.id), false);
}, 1000 * 10);
});
describe('Local TL', () => {
test.concurrent('visibility: home なノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
const carolNote = await post(carol, { text: 'hi', visibility: 'home' });
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('リモートユーザーのノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]);
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
// 含まれても良いと思うけど実装が面倒なので含まれない
test.concurrent('フォローしているユーザーの visibility: home なノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', {
userId: carol.id,
}, alice);
const carolNote = await post(carol, { text: 'hi', visibility: 'home' });
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('ミュートしているユーザーのノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/mute/create', { userId: carol.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/mute/create', { userId: carol.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
await api('/mute/create', { userId: carol.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png');
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { fileIds: [file.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', { withFiles: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
}, 1000 * 10);
});
describe('Social TL', () => {
test.concurrent('ローカルユーザーのノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/hybrid-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('ローカルユーザーの visibility: home なノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const bobNote = await post(bob, { text: 'hi', visibility: 'home' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/hybrid-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
test.concurrent('フォローしているローカルユーザーの visibility: home なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'home' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/hybrid-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('リモートユーザーのノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]);
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/hybrid-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'home' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/hybrid-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png');
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { fileIds: [file.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/hybrid-timeline', { withFiles: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
}, 1000 * 10);
});
describe('User List TL', () => {
test.concurrent('リスインしているフォローしていないユーザーのノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('リスインしているフォローしていないユーザーの visibility: home なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'home' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
/* 未実装
test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'followers' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
*/
test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれるが隠される', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'followers' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, null);
});
test.concurrent('リスインしているフォローしていないユーザーの他人への返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
test.concurrent('リスインしているフォローしていないユーザーのユーザー自身への返信が含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
});
test.concurrent('withReplies: true でリスインしているフォローしていないユーザーの他人への返信が含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
await api('/users/lists/update-membership', { listId: list.id, userId: bob.id, withReplies: true }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('リスインしているフォローしているユーザーの visibility: home なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'home' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('リスインしているフォローしているユーザーの visibility: followers なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'followers' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi');
});
test.concurrent('[withFiles: true] リスインしているユーザーのファイル付きノートのみ含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png');
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { fileIds: [file.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id, withFiles: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
}, 1000 * 10);
});
// TODO: リノートミュート済みユーザーのテスト
// TODO: ページネーションのテスト
});

View File

@@ -38,23 +38,10 @@ describe('users/notes', () => {
await app.close(); await app.close();
}); });
test('ファイルタイプ指定 (jpg)', async () => { test('withFiles', async () => {
const res = await api('/users/notes', { const res = await api('/users/notes', {
userId: alice.id, userId: alice.id,
fileType: ['image/jpeg'], withFiles: true,
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 2);
assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true);
});
test('ファイルタイプ指定 (jpg or png)', async () => {
const res = await api('/users/notes', {
userId: alice.id,
fileType: ['image/jpeg', 'image/png'],
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);

View File

@@ -133,6 +133,7 @@ describe('ユーザー', () => {
isMuted: user.isMuted ?? false, isMuted: user.isMuted ?? false,
isRenoteMuted: user.isRenoteMuted ?? false, isRenoteMuted: user.isRenoteMuted ?? false,
notify: user.notify ?? 'none', notify: user.notify ?? 'none',
withReplies: user.withReplies ?? false,
}); });
}; };
@@ -166,6 +167,7 @@ describe('ユーザー', () => {
unreadAnnouncements: user.unreadAnnouncements, unreadAnnouncements: user.unreadAnnouncements,
mutedWords: user.mutedWords, mutedWords: user.mutedWords,
mutedInstances: user.mutedInstances, mutedInstances: user.mutedInstances,
mutingNotificationTypes: user.mutingNotificationTypes,
notificationRecieveConfig: user.notificationRecieveConfig, notificationRecieveConfig: user.notificationRecieveConfig,
emailNotificationTypes: user.emailNotificationTypes, emailNotificationTypes: user.emailNotificationTypes,
achievements: user.achievements, achievements: user.achievements,
@@ -414,6 +416,7 @@ describe('ユーザー', () => {
assert.deepStrictEqual(response.unreadAnnouncements, []); assert.deepStrictEqual(response.unreadAnnouncements, []);
assert.deepStrictEqual(response.mutedWords, []); assert.deepStrictEqual(response.mutedWords, []);
assert.deepStrictEqual(response.mutedInstances, []); assert.deepStrictEqual(response.mutedInstances, []);
assert.deepStrictEqual(response.mutingNotificationTypes, []);
assert.deepStrictEqual(response.notificationRecieveConfig, {}); assert.deepStrictEqual(response.notificationRecieveConfig, {});
assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']); assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']);
assert.deepStrictEqual(response.achievements, []); assert.deepStrictEqual(response.achievements, []);

View File

@@ -99,9 +99,17 @@ export const relativeFetch = async (path: string, init?: RequestInit | undefined
return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init); return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init);
}; };
function randomString(chars = 'abcdefghijklmnopqrstuvwxyz0123456789', length = 16) {
let randomString = '';
for (let i = 0; i < length; i++) {
randomString += chars[Math.floor(Math.random() * chars.length)];
}
return randomString;
}
export const signup = async (params?: Partial<misskey.Endpoints['signup']['req']>): Promise<NonNullable<misskey.Endpoints['signup']['res']>> => { export const signup = async (params?: Partial<misskey.Endpoints['signup']['req']>): Promise<NonNullable<misskey.Endpoints['signup']['res']>> => {
const q = Object.assign({ const q = Object.assign({
username: 'test', username: randomString(),
password: 'test', password: 'test',
}, params); }, params);

View File

@@ -165,7 +165,7 @@ import { deepClone } from '@/scripts/clone.js';
import { useTooltip } from '@/scripts/use-tooltip.js'; import { useTooltip } from '@/scripts/use-tooltip.js';
import { claimAchievement } from '@/scripts/achievements.js'; import { claimAchievement } from '@/scripts/achievements.js';
import { getNoteSummary } from '@/scripts/get-note-summary.js'; import { getNoteSummary } from '@/scripts/get-note-summary.js';
import { MenuItem } from '@/types/menu'; import { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { shouldCollapsed } from '@/scripts/collapsed.js'; import { shouldCollapsed } from '@/scripts/collapsed.js';
@@ -211,7 +211,7 @@ const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : n
const isLong = shouldCollapsed(appearNote); const isLong = shouldCollapsed(appearNote);
const collapsed = ref(appearNote.cw == null && isLong); const collapsed = ref(appearNote.cw == null && isLong);
const isDeleted = ref(false); const isDeleted = ref(false);
const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords)); const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
const translation = ref<any>(null); const translation = ref<any>(null);
const translating = ref(false); const translating = ref(false);
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);

View File

@@ -214,7 +214,7 @@ import { useNoteCapture } from '@/scripts/use-note-capture.js';
import { deepClone } from '@/scripts/clone.js'; import { deepClone } from '@/scripts/clone.js';
import { useTooltip } from '@/scripts/use-tooltip.js'; import { useTooltip } from '@/scripts/use-tooltip.js';
import { claimAchievement } from '@/scripts/achievements.js'; import { claimAchievement } from '@/scripts/achievements.js';
import { MenuItem } from '@/types/menu'; import { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkUserCardMini from '@/components/MkUserCardMini.vue';
@@ -258,7 +258,7 @@ let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note
const isMyRenote = $i && ($i.id === note.userId); const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false); const showContent = ref(false);
const isDeleted = ref(false); const isDeleted = ref(false);
const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords)); const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
const translation = ref(null); const translation = ref(null);
const translating = ref(false); const translating = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null; const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;

View File

@@ -49,9 +49,9 @@ import { notePage } from '@/filters/note.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { userPage } from "@/filters/user"; import { userPage } from '@/filters/user.js';
import { checkWordMute } from "@/scripts/check-word-mute"; import { checkWordMute } from '@/scripts/check-word-mute.js';
import { defaultStore } from "@/store"; import { defaultStore } from '@/store.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
note: Misskey.entities.Note; note: Misskey.entities.Note;
@@ -63,7 +63,7 @@ const props = withDefaults(defineProps<{
depth: 1, depth: 1,
}); });
const muted = ref(checkWordMute(props.note, $i, defaultStore.state.mutedWords)); const muted = ref($i ? checkWordMute(props.note, $i, $i.mutedWords) : false);
let showContent = $ref(false); let showContent = $ref(false);
let replies: Misskey.entities.Note[] = $ref([]); let replies: Misskey.entities.Note[] = $ref([]);

View File

@@ -23,11 +23,9 @@ const props = withDefaults(defineProps<{
role?: string; role?: string;
sound?: boolean; sound?: boolean;
withRenotes?: boolean; withRenotes?: boolean;
withReplies?: boolean;
onlyFiles?: boolean; onlyFiles?: boolean;
}>(), { }>(), {
withRenotes: true, withRenotes: true,
withReplies: false,
onlyFiles: false, onlyFiles: false,
}); });
@@ -70,12 +68,10 @@ if (props.src === 'antenna') {
endpoint = 'notes/timeline'; endpoint = 'notes/timeline';
query = { query = {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}; };
connection = stream.useChannel('homeTimeline', { connection = stream.useChannel('homeTimeline', {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}); });
connection.on('note', prepend); connection.on('note', prepend);
@@ -85,12 +81,10 @@ if (props.src === 'antenna') {
endpoint = 'notes/local-timeline'; endpoint = 'notes/local-timeline';
query = { query = {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}; };
connection = stream.useChannel('localTimeline', { connection = stream.useChannel('localTimeline', {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}); });
connection.on('note', prepend); connection.on('note', prepend);
@@ -98,12 +92,10 @@ if (props.src === 'antenna') {
endpoint = 'notes/hybrid-timeline'; endpoint = 'notes/hybrid-timeline';
query = { query = {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}; };
connection = stream.useChannel('hybridTimeline', { connection = stream.useChannel('hybridTimeline', {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}); });
connection.on('note', prepend); connection.on('note', prepend);
@@ -111,12 +103,10 @@ if (props.src === 'antenna') {
endpoint = 'notes/global-timeline'; endpoint = 'notes/global-timeline';
query = { query = {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}; };
connection = stream.useChannel('globalTimeline', { connection = stream.useChannel('globalTimeline', {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}); });
connection.on('note', prepend); connection.on('note', prepend);
@@ -140,13 +130,11 @@ if (props.src === 'antenna') {
endpoint = 'notes/user-list-timeline'; endpoint = 'notes/user-list-timeline';
query = { query = {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
listId: props.list, listId: props.list,
}; };
connection = stream.useChannel('userList', { connection = stream.useChannel('userList', {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
listId: props.list, listId: props.list,
}); });

View File

@@ -68,6 +68,7 @@ export const ROLE_POLICIES = [
'inviteExpirationTime', 'inviteExpirationTime',
'canManageCustomEmojis', 'canManageCustomEmojis',
'canSearchNotes', 'canSearchNotes',
'canUseTranslator',
'canHideAds', 'canHideAds',
'driveCapacityMb', 'driveCapacityMb',
'alwaysMarkNsfw', 'alwaysMarkNsfw',

View File

@@ -287,6 +287,7 @@ const patrons = [
'kino3277', 'kino3277',
'美少女JKぐーちゃん', '美少女JKぐーちゃん',
'てば', 'てば',
'たっくん',
]; ];
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure')); let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));

View File

@@ -17,8 +17,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'suspend'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span> <span v-else-if="log.type === 'suspend'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'unsuspend'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span> <span v-else-if="log.type === 'unsuspend'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'resetPassword'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span> <span v-else-if="log.type === 'resetPassword'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'assignRole'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span> <span v-else-if="log.type === 'assignRole'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} <i class="ti ti-arrow-right"></i> {{ log.info.roleName }}</span>
<span v-else-if="log.type === 'unassignRole'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span> <span v-else-if="log.type === 'unassignRole'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} <i class="ti ti-equal-not"></i> {{ log.info.roleName }}</span>
<span v-else-if="log.type === 'createRole'">: {{ log.info.role.name }}</span> <span v-else-if="log.type === 'createRole'">: {{ log.info.role.name }}</span>
<span v-else-if="log.type === 'updateRole'">: {{ log.info.before.name }}</span> <span v-else-if="log.type === 'updateRole'">: {{ log.info.before.name }}</span>
<span v-else-if="log.type === 'deleteRole'">: {{ log.info.role.name }}</span> <span v-else-if="log.type === 'deleteRole'">: {{ log.info.role.name }}</span>

View File

@@ -299,6 +299,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canUseTranslator, 'canUseTranslator'])">
<template #label>{{ i18n.ts._role._options.canUseTranslator }}</template>
<template #suffix>
<span v-if="role.policies.canUseTranslator.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canUseTranslator.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canUseTranslator)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canUseTranslator.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canUseTranslator.value" :disabled="role.policies.canUseTranslator.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canUseTranslator.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.driveCapacity, 'driveCapacityMb'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.driveCapacity, 'driveCapacityMb'])">
<template #label>{{ i18n.ts._role._options.driveCapacity }}</template> <template #label>{{ i18n.ts._role._options.driveCapacity }}</template>
<template #suffix> <template #suffix>

View File

@@ -103,6 +103,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch> </MkSwitch>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canUseTranslator, 'canSearchNotes'])">
<template #label>{{ i18n.ts._role._options.canUseTranslator }}</template>
<template #suffix>{{ policies.canUseTranslator ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canUseTranslator">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.driveCapacity, 'driveCapacityMb'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.driveCapacity, 'driveCapacityMb'])">
<template #label>{{ i18n.ts._role._options.driveCapacity }}</template> <template #label>{{ i18n.ts._role._options.driveCapacity }}</template>
<template #suffix>{{ policies.driveCapacityMb }}MB</template> <template #suffix>{{ policies.driveCapacityMb }}MB</template>

View File

@@ -29,16 +29,22 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_s"> <div class="_gaps_s">
<MkButton rounded primary style="margin: 0 auto;" @click="addUser()">{{ i18n.ts.addUser }}</MkButton> <MkButton rounded primary style="margin: 0 auto;" @click="addUser()">{{ i18n.ts.addUser }}</MkButton>
<div v-for="user in users" :key="user.id" :class="$style.userItem">
<MkA :class="$style.userItemBody" :to="`${userPage(user)}`"> <MkPagination ref="paginationEl" :pagination="membershipsPagination">
<MkUserCardMini :user="user"/> <template #default="{ items }">
</MkA> <div class="_gaps_s">
<button class="_button" :class="$style.remove" @click="removeUser(user, $event)"><i class="ti ti-x"></i></button> <div v-for="item in items" :key="item.id">
</div> <div :class="$style.userItem">
<MkButton v-if="!fetching && queueUserIds.length !== 0" v-appear="enableInfiniteScroll ? fetchMoreUsers : null" :class="$style.more" :style="{ cursor: 'pointer' }" primary rounded @click="fetchMoreUsers"> <MkA :class="$style.userItemBody" :to="`${userPage(item.user)}`">
{{ i18n.ts.loadMore }} <MkUserCardMini :user="item.user"/>
</MkButton> </MkA>
<MkLoading v-if="fetching" class="loading"/> <button class="_button" :class="$style.menu" @click="showMembershipMenu(item, $event)"><i class="ti ti-dots"></i></button>
<button class="_button" :class="$style.remove" @click="removeUser(item, $event)"><i class="ti ti-x"></i></button>
</div>
</div>
</div>
</template>
</MkPagination>
</div> </div>
</MkFolder> </MkFolder>
</div> </div>
@@ -59,9 +65,11 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import { userListsCache } from '@/cache'; import { userListsCache } from '@/cache.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import MkPagination, { Paging } from '@/components/MkPagination.vue';
const { const {
enableInfiniteScroll, enableInfiniteScroll,
} = defaultStore.reactiveState; } = defaultStore.reactiveState;
@@ -70,40 +78,25 @@ const props = defineProps<{
listId: string; listId: string;
}>(); }>();
const FETCH_USERS_LIMIT = 20; const paginationEl = ref<InstanceType<typeof MkPagination>>();
let list = $ref<Misskey.entities.UserList | null>(null); let list = $ref<Misskey.entities.UserList | null>(null);
let users = $ref<Misskey.entities.UserLite[]>([]);
let queueUserIds = $ref<string[]>([]);
let fetching = $ref(true);
const isPublic = ref(false); const isPublic = ref(false);
const name = ref(''); const name = ref('');
const membershipsPagination = {
endpoint: 'users/lists/get-memberships' as const,
limit: 30,
params: computed(() => ({
listId: props.listId,
})),
};
function fetchList() { function fetchList() {
fetching = true;
os.api('users/lists/show', { os.api('users/lists/show', {
listId: props.listId, listId: props.listId,
}).then(_list => { }).then(_list => {
list = _list; list = _list;
name.value = list.name; name.value = list.name;
isPublic.value = list.isPublic; isPublic.value = list.isPublic;
queueUserIds = list.userIds;
return fetchMoreUsers();
});
}
function fetchMoreUsers() {
if (!list) return;
if (fetching && users.length !== 0) return; // fetchingがtrueならやめるが、usersが空なら続行
fetching = true;
os.api('users/show', {
userIds: queueUserIds.slice(0, FETCH_USERS_LIMIT),
}).then(_users => {
users = users.concat(_users);
queueUserIds = queueUserIds.slice(FETCH_USERS_LIMIT);
}).finally(() => {
fetching = false;
}); });
} }
@@ -114,12 +107,12 @@ function addUser() {
listId: list.id, listId: list.id,
userId: user.id, userId: user.id,
}).then(() => { }).then(() => {
users.push(user); paginationEl.value.reload();
}); });
}); });
} }
async function removeUser(user, ev) { async function removeUser(item, ev) {
os.popupMenu([{ os.popupMenu([{
text: i18n.ts.remove, text: i18n.ts.remove,
icon: 'ti ti-x', icon: 'ti ti-x',
@@ -128,9 +121,28 @@ async function removeUser(user, ev) {
if (!list) return; if (!list) return;
os.api('users/lists/pull', { os.api('users/lists/pull', {
listId: list.id, listId: list.id,
userId: user.id, userId: item.userId,
}).then(() => { }).then(() => {
users = users.filter(x => x.id !== user.id); paginationEl.value.removeItem(item.id);
});
},
}], ev.currentTarget ?? ev.target);
}
async function showMembershipMenu(item, ev) {
os.popupMenu([{
text: item.withReplies ? i18n.ts.hideRepliesToOthersInTimeline : i18n.ts.showRepliesToOthersInTimeline,
icon: item.withReplies ? 'ti ti-messages-off' : 'ti ti-messages',
action: async () => {
os.api('users/lists/update-membership', {
listId: list.id,
userId: item.userId,
withReplies: !item.withReplies,
}).then(() => {
paginationEl.value.updateItem(item.id, (old) => ({
...old,
withReplies: !item.withReplies,
}));
}); });
}, },
}], ev.currentTarget ?? ev.target); }], ev.currentTarget ?? ev.target);
@@ -202,6 +214,12 @@ definePageMetadata(computed(() => list ? {
align-self: center; align-self: center;
} }
.menu {
width: 32px;
height: 32px;
align-self: center;
}
.more { .more {
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;

View File

@@ -5,13 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div class="_gaps_m"> <div class="_gaps_m">
<MkTab v-model="tab"> <MkFolder>
<option value="renoteMute">{{ i18n.ts.mutedUsers }} ({{ i18n.ts.renote }})</option> <template #icon><i class="ti ti-repeat-off"></i></template>
<option value="mute">{{ i18n.ts.mutedUsers }}</option> <template #label>{{ i18n.ts.mutedUsers }} ({{ i18n.ts.renote }})</template>
<option value="block">{{ i18n.ts.blockedUsers }}</option>
</MkTab>
<div v-if="tab === 'renoteMute'">
<MkPagination :pagination="renoteMutingPagination"> <MkPagination :pagination="renoteMutingPagination">
<template #empty> <template #empty>
<div class="_fullinfo"> <div class="_fullinfo">
@@ -37,9 +34,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</template> </template>
</MkPagination> </MkPagination>
</div> </MkFolder>
<MkFolder>
<template #icon><i class="ti ti-eye-off"></i></template>
<template #label>{{ i18n.ts.mutedUsers }}</template>
<div v-else-if="tab === 'mute'">
<MkPagination :pagination="mutingPagination"> <MkPagination :pagination="mutingPagination">
<template #empty> <template #empty>
<div class="_fullinfo"> <div class="_fullinfo">
@@ -67,9 +67,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</template> </template>
</MkPagination> </MkPagination>
</div> </MkFolder>
<MkFolder>
<template #icon><i class="ti ti-ban"></i></template>
<template #label>{{ i18n.ts.blockedUsers }}</template>
<div v-else-if="tab === 'block'">
<MkPagination :pagination="blockingPagination"> <MkPagination :pagination="blockingPagination">
<template #empty> <template #empty>
<div class="_fullinfo"> <div class="_fullinfo">
@@ -97,24 +100,20 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</template> </template>
</MkPagination> </MkPagination>
</div> </MkFolder>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { } from 'vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import MkTab from '@/components/MkTab.vue';
import FormInfo from '@/components/MkInfo.vue';
import FormLink from '@/components/form/link.vue';
import { userPage } from '@/filters/user.js'; import { userPage } from '@/filters/user.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkUserCardMini from '@/components/MkUserCardMini.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { infoImageUrl } from '@/instance.js'; import { infoImageUrl } from '@/instance.js';
import MkFolder from '@/components/MkFolder.vue';
let tab = $ref('renoteMute');
const renoteMutingPagination = { const renoteMutingPagination = {
endpoint: 'renote-mute/list' as const, endpoint: 'renote-mute/list' as const,

View File

@@ -5,29 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div class="_gaps_m"> <div class="_gaps_m">
<MkTab v-model="tab">
<option value="soft">{{ i18n.ts._wordMute.soft }}</option>
<option value="hard">{{ i18n.ts._wordMute.hard }}</option>
</MkTab>
<div> <div>
<div v-show="tab === 'soft'" class="_gaps_m"> <MkTextarea v-model="mutedWords">
<MkInfo>{{ i18n.ts._wordMute.softDescription }}</MkInfo> <span>{{ i18n.ts._wordMute.muteWords }}</span>
<MkTextarea v-model="softMutedWords"> <template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template>
<span>{{ i18n.ts._wordMute.muteWords }}</span> </MkTextarea>
<template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template>
</MkTextarea>
</div>
<div v-show="tab === 'hard'" class="_gaps_m">
<MkInfo>{{ i18n.ts._wordMute.hardDescription }} {{ i18n.ts.reflectMayTakeTime }}</MkInfo>
<MkTextarea v-model="hardMutedWords">
<span>{{ i18n.ts._wordMute.muteWords }}</span>
<template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template>
</MkTextarea>
<MkKeyValue v-if="hardWordMutedNotesCount != null">
<template #key>{{ i18n.ts._wordMute.mutedNotes }}</template>
<template #value>{{ number(hardWordMutedNotesCount) }}</template>
</MkKeyValue>
</div>
</div> </div>
<MkButton primary inline :disabled="!changed" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> <MkButton primary inline :disabled="!changed" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div> </div>
@@ -56,25 +38,15 @@ const render = (mutedWords) => mutedWords.map(x => {
}).join('\n'); }).join('\n');
const tab = ref('soft'); const tab = ref('soft');
const softMutedWords = ref(render(defaultStore.state.mutedWords)); const mutedWords = ref(render($i!.mutedWords));
const hardMutedWords = ref(render($i!.mutedWords));
const hardWordMutedNotesCount = ref(null);
const changed = ref(false); const changed = ref(false);
os.api('i/get-word-muted-notes-count', {}).then(response => { watch(mutedWords, () => {
hardWordMutedNotesCount.value = response?.count;
});
watch(softMutedWords, () => {
changed.value = true;
});
watch(hardMutedWords, () => {
changed.value = true; changed.value = true;
}); });
async function save() { async function save() {
const parseMutes = (mutes, tab) => { const parseMutes = (mutes) => {
// split into lines, remove empty lines and unnecessary whitespace // split into lines, remove empty lines and unnecessary whitespace
let lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line !== ''); let lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line !== '');
@@ -92,7 +64,7 @@ async function save() {
os.alert({ os.alert({
type: 'error', type: 'error',
title: i18n.ts.regexpError, title: i18n.ts.regexpError,
text: i18n.t('regexpErrorDescription', { tab, line: i + 1 }) + '\n' + err.toString(), text: i18n.t('regexpErrorDescription', { tab: 'word mute', line: i + 1 }) + '\n' + err.toString(),
}); });
// re-throw error so these invalid settings are not saved // re-throw error so these invalid settings are not saved
throw err; throw err;
@@ -105,18 +77,16 @@ async function save() {
return lines; return lines;
}; };
let softMutes, hardMutes; let parsed;
try { try {
softMutes = parseMutes(softMutedWords.value, i18n.ts._wordMute.soft); parsed = parseMutes(mutedWords.value);
hardMutes = parseMutes(hardMutedWords.value, i18n.ts._wordMute.hard);
} catch (err) { } catch (err) {
// already displayed error message in parseMutes // already displayed error message in parseMutes
return; return;
} }
defaultStore.set('mutedWords', softMutes);
await os.api('i/update', { await os.api('i/update', {
mutedWords: hardMutes, mutedWords: parsed,
}); });
changed.value = false; changed.value = false;

View File

@@ -15,11 +15,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.tl"> <div :class="$style.tl">
<MkTimeline <MkTimeline
ref="tlComponent" ref="tlComponent"
:key="src + withRenotes + withReplies + onlyFiles" :key="src + withRenotes + onlyFiles"
:src="src.split(':')[0]" :src="src.split(':')[0]"
:list="src.split(':')[1]" :list="src.split(':')[1]"
:withRenotes="withRenotes" :withRenotes="withRenotes"
:withReplies="withReplies"
:onlyFiles="onlyFiles" :onlyFiles="onlyFiles"
:sound="true" :sound="true"
@queue="queueUpdated" @queue="queueUpdated"
@@ -62,7 +61,6 @@ let queue = $ref(0);
let srcWhenNotSignin = $ref(isLocalTimelineAvailable ? 'local' : 'global'); let srcWhenNotSignin = $ref(isLocalTimelineAvailable ? 'local' : 'global');
const src = $computed({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin), set: (x) => saveSrc(x) }); const src = $computed({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin), set: (x) => saveSrc(x) });
const withRenotes = $ref(true); const withRenotes = $ref(true);
const withReplies = $ref(false);
const onlyFiles = $ref(false); const onlyFiles = $ref(false);
watch($$(src), () => queue = 0); watch($$(src), () => queue = 0);
@@ -144,11 +142,6 @@ const headerActions = $computed(() => [{
text: i18n.ts.showRenotes, text: i18n.ts.showRenotes,
icon: 'ti ti-repeat', icon: 'ti ti-repeat',
ref: $$(withRenotes), ref: $$(withRenotes),
}, {
type: 'switch',
text: i18n.ts.withReplies,
icon: 'ti ti-arrow-back-up',
ref: $$(withReplies),
}, { }, {
type: 'switch', type: 'switch',
text: i18n.ts.fileAttachedOnly, text: i18n.ts.fileAttachedOnly,

View File

@@ -128,14 +128,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo> <MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo>
<template v-if="narrow"> <template v-if="narrow">
<XPhotos :key="user.id" :user="user"/> <XFiles :key="user.id" :user="user"/>
<XActivity :key="user.id" :user="user"/> <XActivity :key="user.id" :user="user"/>
</template> </template>
<MkNotes v-if="!disableNotes" :class="$style.tl" :noGap="true" :pagination="pagination"/> <MkNotes v-if="!disableNotes" :class="$style.tl" :noGap="true" :pagination="pagination"/>
</div> </div>
</div> </div>
<div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;"> <div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;">
<XPhotos :key="user.id" :user="user"/> <XFiles :key="user.id" :user="user"/>
<XActivity :key="user.id" :user="user"/> <XActivity :key="user.id" :user="user"/>
</div> </div>
</div> </div>
@@ -182,7 +182,7 @@ function calcAge(birthdate: string): number {
return yearDiff; return yearDiff;
} }
const XPhotos = defineAsyncComponent(() => import('./index.photos.vue')); const XFiles = defineAsyncComponent(() => import('./index.files.vue'));
const XActivity = defineAsyncComponent(() => import('./index.activity.vue')); const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{

View File

@@ -6,20 +6,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<MkContainer :max-height="300" :foldable="true"> <MkContainer :max-height="300" :foldable="true">
<template #icon><i class="ti ti-photo"></i></template> <template #icon><i class="ti ti-photo"></i></template>
<template #header>{{ i18n.ts.images }}</template> <template #header>{{ i18n.ts.files }}</template>
<div :class="$style.root"> <div :class="$style.root">
<MkLoading v-if="fetching"/> <MkLoading v-if="fetching"/>
<div v-if="!fetching && images.length > 0" :class="$style.stream"> <div v-if="!fetching && files.length > 0" :class="$style.stream">
<MkA <MkA
v-for="image in images" v-for="file in files"
:key="image.note.id + image.file.id" :key="file.note.id + file.file.id"
:class="$style.img" :class="$style.img"
:to="notePage(image.note)" :to="notePage(file.note)"
> >
<ImgWithBlurhash :hash="image.file.blurhash" :src="thumbnail(image.file)" :title="image.file.name"/> <!-- TODO: 画像以外のファイルに対応 -->
<ImgWithBlurhash :hash="file.file.blurhash" :src="thumbnail(file.file)" :title="file.file.name"/>
</MkA> </MkA>
</div> </div>
<p v-if="!fetching && images.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p> <p v-if="!fetching && files.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p>
</div> </div>
</MkContainer> </MkContainer>
</template> </template>
@@ -40,7 +41,7 @@ const props = defineProps<{
}>(); }>();
let fetching = $ref(true); let fetching = $ref(true);
let images = $ref<{ let files = $ref<{
note: Misskey.entities.Note; note: Misskey.entities.Note;
file: Misskey.entities.DriveFile; file: Misskey.entities.DriveFile;
}[]>([]); }[]>([]);
@@ -52,24 +53,15 @@ function thumbnail(image: Misskey.entities.DriveFile): string {
} }
onMounted(() => { onMounted(() => {
const image = [
'image/jpeg',
'image/webp',
'image/avif',
'image/png',
'image/gif',
'image/apng',
'image/vnd.mozilla.apng',
];
os.api('users/notes', { os.api('users/notes', {
userId: props.user.id, userId: props.user.id,
fileType: image, withFiles: true,
excludeNsfw: defaultStore.state.nsfw !== 'ignore', excludeNsfw: defaultStore.state.nsfw !== 'ignore',
limit: 10, limit: 15,
}).then(notes => { }).then(notes => {
for (const note of notes) { for (const note of notes) {
for (const file of note.files) { for (const file of note.files) {
images.push({ files.push({
note, note,
file, file,
}); });

View File

@@ -8,7 +8,7 @@ import * as os from '@/os.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js'; import { customEmojis } from '@/custom-emojis.js';
import { lang } from '@/config.js'; import { url, lang } from '@/config.js';
export function createAiScriptEnv(opts) { export function createAiScriptEnv(opts) {
return { return {
@@ -17,6 +17,7 @@ export function createAiScriptEnv(opts) {
USER_USERNAME: $i ? values.STR($i.username) : values.NULL, USER_USERNAME: $i ? values.STR($i.username) : values.NULL,
CUSTOM_EMOJIS: utils.jsToVal(customEmojis.value), CUSTOM_EMOJIS: utils.jsToVal(customEmojis.value),
LOCALE: values.STR(lang), LOCALE: values.STR(lang),
SERVER_URL: values.STR(url),
'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => { 'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => {
await os.alert({ await os.alert({
type: type ? type.value : 'info', type: type ? type.value : 'info',

View File

@@ -288,7 +288,7 @@ export function getNoteMenu(props: {
text: i18n.ts.share, text: i18n.ts.share,
action: share, action: share,
}, },
instance.translatorAvailable ? { $i && $i.policies.canUseTranslator && instance.translatorAvailable ? {
icon: 'ti ti-language-hiragana', icon: 'ti ti-language-hiragana',
text: i18n.ts.translate, text: i18n.ts.translate,
action: translate, action: translate,

View File

@@ -80,6 +80,15 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
}); });
} }
async function toggleWithReplies() {
os.apiWithDialog('following/update', {
userId: user.id,
withReplies: !user.withReplies,
}).then(() => {
user.withReplies = !user.withReplies;
});
}
async function toggleNotify() { async function toggleNotify() {
os.apiWithDialog('following/update', { os.apiWithDialog('following/update', {
userId: user.id, userId: user.id,
@@ -282,6 +291,10 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
// フォローしたとしても user.isFollowing はリアルタイム更新されないので不便なため // フォローしたとしても user.isFollowing はリアルタイム更新されないので不便なため
//if (user.isFollowing) { //if (user.isFollowing) {
menu = menu.concat([{ menu = menu.concat([{
icon: user.withReplies ? 'ti ti-messages-off' : 'ti ti-messages',
text: user.withReplies ? i18n.ts.hideRepliesToOthersInTimeline : i18n.ts.showRepliesToOthersInTimeline,
action: toggleWithReplies,
}, {
icon: user.notify === 'none' ? 'ti ti-bell' : 'ti ti-bell-off', icon: user.notify === 'none' ? 'ti ti-bell' : 'ti ti-bell-off',
text: user.notify === 'none' ? i18n.ts.notifyNotes : i18n.ts.unnotifyNotes, text: user.notify === 'none' ? i18n.ts.notifyNotes : i18n.ts.unnotifyNotes,
action: toggleNotify, action: toggleNotify,

View File

@@ -21,6 +21,8 @@ export function useTooltip(
let changeShowingState: (() => void) | null; let changeShowingState: (() => void) | null;
let autoHidingTimer;
const open = () => { const open = () => {
close(); close();
if (!isHovering) return; if (!isHovering) return;
@@ -33,6 +35,16 @@ export function useTooltip(
changeShowingState = () => { changeShowingState = () => {
showing.value = false; showing.value = false;
}; };
autoHidingTimer = window.setInterval(() => {
if (!document.body.contains(elRef.value)) {
if (!isHovering) return;
isHovering = false;
window.clearTimeout(timeoutId);
close();
window.clearInterval(autoHidingTimer);
}
}, 1000);
}; };
const close = () => { const close = () => {
@@ -53,6 +65,7 @@ export function useTooltip(
if (!isHovering) return; if (!isHovering) return;
isHovering = false; isHovering = false;
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId);
window.clearInterval(autoHidingTimer);
close(); close();
}; };
@@ -67,6 +80,7 @@ export function useTooltip(
if (!isHovering) return; if (!isHovering) return;
isHovering = false; isHovering = false;
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId);
window.clearInterval(autoHidingTimer);
close(); close();
}; };

View File

@@ -5,7 +5,7 @@
import { markRaw, ref } from 'vue'; import { markRaw, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { miLocalStorage } from './local-storage'; import { miLocalStorage } from './local-storage.js';
import { Storage } from '@/pizzax.js'; import { Storage } from '@/pizzax.js';
interface PostFormAction { interface PostFormAction {
@@ -101,10 +101,6 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account', where: 'account',
default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null, default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null,
}, },
mutedWords: {
where: 'account',
default: [],
},
mutedAds: { mutedAds: {
where: 'account', where: 'account',
default: [] as string[], default: [] as string[],

View File

@@ -31,7 +31,6 @@ export type Column = {
excludeTypes?: typeof notificationTypes[number][]; excludeTypes?: typeof notificationTypes[number][];
tl?: 'home' | 'local' | 'social' | 'global'; tl?: 'home' | 'local' | 'social' | 'global';
withRenotes?: boolean; withRenotes?: boolean;
withReplies?: boolean;
onlyFiles?: boolean; onlyFiles?: boolean;
}; };

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