Compare commits
40 Commits
2023.10.0-
...
2023.10.0-
Author | SHA1 | Date | |
---|---|---|---|
![]() |
457b880eba | ||
![]() |
13dbfef9f8 | ||
![]() |
11c9e193a4 | ||
![]() |
0bb0c32908 | ||
![]() |
aafe80c121 | ||
![]() |
7473b2854f | ||
![]() |
04971ca565 | ||
![]() |
6ff98846e6 | ||
![]() |
7066d61730 | ||
![]() |
b3d6334b5c | ||
![]() |
0680ea3a78 | ||
![]() |
0f367da84b | ||
![]() |
19a507633e | ||
![]() |
3a4039e2e1 | ||
![]() |
4f20c87186 | ||
![]() |
a2d3544a08 | ||
![]() |
1564651bf6 | ||
![]() |
fce557715b | ||
![]() |
ca07459f5e | ||
![]() |
457b4cf608 | ||
![]() |
5601ed0914 | ||
![]() |
ca022cbbdf | ||
![]() |
c78b4a7597 | ||
![]() |
274c21e2cc | ||
![]() |
4bbfc98883 | ||
![]() |
9240db35f3 | ||
![]() |
774bf6a55e | ||
![]() |
f37a3eff79 | ||
![]() |
bb9f04d586 | ||
![]() |
8e0fb23068 | ||
![]() |
308745f6de | ||
![]() |
cd8fda50c8 | ||
![]() |
2859cbab91 | ||
![]() |
7cd9a90f26 | ||
![]() |
04c8a7077f | ||
![]() |
6d5e18aa8d | ||
![]() |
986623dbdc | ||
![]() |
8c684d5391 | ||
![]() |
69de8cad7c | ||
![]() |
fb3338029b |
10
CHANGELOG.md
10
CHANGELOG.md
@@ -28,6 +28,9 @@
|
||||
- Feat: ユーザーごとのハイライト
|
||||
- Feat: プライバシーポリシー・運営者情報(Impressum)の指定が可能になりました
|
||||
- プライバシーポリシーはサーバー登録時に同意確認が入ります
|
||||
- Feat: タイムラインがリアルタイム更新中に広告を挿入できるようになりました
|
||||
- デフォルトは無効
|
||||
- 頻度はコントロールパネルから設定できます。運営中のサーバーのTLの流速を見て、最適な値を指定してください。
|
||||
- Enhance: ソフトワードミュートとハードワードミュートは統合されました
|
||||
- Enhance: モデレーションログ機能の強化
|
||||
- Enhance: ローカリゼーションの更新
|
||||
@@ -37,14 +40,21 @@
|
||||
|
||||
### Client
|
||||
- Enhance: 二要素認証のバックアップコード一覧をテキストファイルでダウンロード可能に
|
||||
- Enhance: 動画再生時のデフォルトボリュームを30%に
|
||||
- Fix: リアクションしたユーザ一覧のUIが稀に左上に残ってしまう不具合を修正
|
||||
|
||||
### Server
|
||||
- Enhance: タイムライン取得時のパフォーマンスを大幅に向上
|
||||
- Enhance: ハイライト取得時のパフォーマンスを大幅に向上
|
||||
- Enhance: トレンドハッシュタグ取得時のパフォーマンスを大幅に向上
|
||||
- Enhance: WebSocket接続が多い場合のパフォーマンスを向上
|
||||
- Enhance: 不要なPostgreSQLのインデックスを削除しパフォーマンスを向上
|
||||
- Fix: 連合なしアンケートに投票をするとUpdateがリモートに配信されてしまうのを修正
|
||||
- Fix: nodeinfoにおいてCORS用のヘッダーが設定されていないのを修正
|
||||
- Fix: 同じ種類のTLのストリーミングを複数接続できない問題を修正
|
||||
- Fix: アンテナTLを途中までしかページネーションできなくなることがある問題を修正
|
||||
- Fix: 「ファイル付きのみ」のTLでファイル無しの新着ノートが流れる問題を修正
|
||||
- Fix: プロセスが終了しない、あるいは非常に時間がかかる問題を修正
|
||||
|
||||
## 2023.9.3
|
||||
### General
|
||||
|
@@ -1129,6 +1129,12 @@ fileAttachedOnly: "Nur Notizen mit Dateien"
|
||||
showRepliesToOthersInTimeline: "Antworten in Chronik anzeigen"
|
||||
hideRepliesToOthersInTimeline: "Antworten nicht in Chronik anzeigen"
|
||||
externalServices: "Externe Dienste"
|
||||
impressum: "Impressum"
|
||||
impressumUrl: "Impressums-URL"
|
||||
impressumDescription: "In manchen Ländern, wie Deutschland und dessen Umgebung, ist die Angabe von Betreiberinformationen (ein Impressum) bei kommerziellem Betrieb zwingend."
|
||||
privacyPolicy: "Datenschutzerklärung"
|
||||
privacyPolicyUrl: "Datenschutzerklärungs-URL"
|
||||
tosAndPrivacyPolicy: "Nutzungsbedingungen und Datenschutzerklärung"
|
||||
_announcement:
|
||||
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."
|
||||
@@ -1527,6 +1533,10 @@ _ad:
|
||||
reduceFrequencyOfThisAd: "Diese Werbung weniger anzeigen"
|
||||
hide: "Ausblenden"
|
||||
timezoneinfo: "Der Wochentag wird durch die Serverzeitzone bestimmt."
|
||||
adsSettings: "Werbeeinstellungen"
|
||||
notesPerOneAd: "Werbeintervall während Echtzeitaktualisierung (Notizen pro Werbung)"
|
||||
setZeroToDisable: "Setze dies auf 0, um Werbung während Echtzeitaktualisierung zu deaktivieren"
|
||||
adsTooClose: "Durch den momentan sehr niedrigen Werbeintervall kann es zu einer starken Verschlechterung der Benutzererfahrung kommen."
|
||||
_forgotPassword:
|
||||
enterEmail: "Gib die Email-Adresse ein, mit der du dich registriert hast. An diese wird ein Link gesendet, mit dem du dein Passwort zurücksetzen kannst."
|
||||
ifNoEmail: "Solltest du bei der Registrierung keine Email-Adresse angegeben haben, wende dich bitte an den Administrator."
|
||||
|
@@ -1129,6 +1129,12 @@ fileAttachedOnly: "Only notes with files"
|
||||
showRepliesToOthersInTimeline: "Show replies to others in TL"
|
||||
hideRepliesToOthersInTimeline: "Hide replies to others from TL"
|
||||
externalServices: "External Services"
|
||||
impressum: "Impressum"
|
||||
impressumUrl: "Impressum URL"
|
||||
impressumDescription: "In some countries, like germany, the inclusion of operator contact information (an Impressum) is legally required for commercial websites."
|
||||
privacyPolicy: "Privacy Policy"
|
||||
privacyPolicyUrl: "Privacy Policy URL"
|
||||
tosAndPrivacyPolicy: "Terms of Service and Privacy Policy"
|
||||
_announcement:
|
||||
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."
|
||||
@@ -1527,6 +1533,10 @@ _ad:
|
||||
reduceFrequencyOfThisAd: "Show this ad less"
|
||||
hide: "Hide"
|
||||
timezoneinfo: "The day of the week is determined from the server's timezone."
|
||||
adsSettings: "Ad settings"
|
||||
notesPerOneAd: "Real-time update ad placement interval (Notes per ad)"
|
||||
setZeroToDisable: "Set this value to 0 to disable real-time update ads"
|
||||
adsTooClose: "The current ad interval may significantly worsen the user experience due to being too low."
|
||||
_forgotPassword:
|
||||
enterEmail: "Enter the email address you used to register. A link with which you can reset your password will then be sent to it."
|
||||
ifNoEmail: "If you did not use an email during registration, please contact the instance administrator instead."
|
||||
|
4
locales/index.d.ts
vendored
4
locales/index.d.ts
vendored
@@ -1627,6 +1627,10 @@ export interface Locale {
|
||||
"reduceFrequencyOfThisAd": string;
|
||||
"hide": string;
|
||||
"timezoneinfo": string;
|
||||
"adsSettings": string;
|
||||
"notesPerOneAd": string;
|
||||
"setZeroToDisable": string;
|
||||
"adsTooClose": string;
|
||||
};
|
||||
"_forgotPassword": {
|
||||
"enterEmail": string;
|
||||
|
@@ -64,7 +64,7 @@ reply: "Rispondi"
|
||||
loadMore: "Mostra di più"
|
||||
showMore: "Espandi"
|
||||
showLess: "Comprimi"
|
||||
youGotNewFollower: "Ti sta seguendo"
|
||||
youGotNewFollower: "Adesso ti segue"
|
||||
receiveFollowRequest: "Hai ricevuto una richiesta di follow"
|
||||
followRequestAccepted: "Ha accettato la tua richiesta di follow"
|
||||
mention: "Menzioni"
|
||||
@@ -336,7 +336,7 @@ instanceName: "Nome dell'istanza"
|
||||
instanceDescription: "Descrizione dell'istanza"
|
||||
maintainerName: "Nome dell'amministratore"
|
||||
maintainerEmail: "Indirizzo e-mail dell'amministratore"
|
||||
tosUrl: "URL dei termini del servizio e della privacy"
|
||||
tosUrl: "URL delle condizioni d'uso"
|
||||
thisYear: "Anno"
|
||||
thisMonth: "Mese"
|
||||
today: "Oggi"
|
||||
@@ -1129,6 +1129,12 @@ fileAttachedOnly: "Con file in allegato"
|
||||
showRepliesToOthersInTimeline: "Risposte altrui nella TL"
|
||||
hideRepliesToOthersInTimeline: "Nascondi Riposte altrui nella TL"
|
||||
externalServices: "Servizi esterni"
|
||||
impressum: "Dichiarazione di proprietà"
|
||||
impressumUrl: "URL della dichiarazione di proprietà"
|
||||
impressumDescription: "La dichiarazione di proprietà, è obbligatoria in alcuni paesi come la Germania (Impressum)."
|
||||
privacyPolicy: "Informativa sulla privacy"
|
||||
privacyPolicyUrl: "URL della informativa privacy"
|
||||
tosAndPrivacyPolicy: "Condizioni d'uso e informativa sulla privacy"
|
||||
_announcement:
|
||||
forExistingUsers: "Solo ai profili attuali"
|
||||
forExistingUsersDescription: "L'annuncio sarà visibile solo ai profili esistenti in questo momento. Se disabilitato, sarà visibile anche ai profili che verranno creati dopo la pubblicazione di questo annuncio."
|
||||
@@ -1538,7 +1544,7 @@ _gallery:
|
||||
unlike: "Non mi piace più"
|
||||
_email:
|
||||
_follow:
|
||||
title: "Ha iniziato a seguirti"
|
||||
title: "Adesso ti segue"
|
||||
_receiveFollowRequest:
|
||||
title: "Hai ricevuto una richiesta di follow"
|
||||
_plugin:
|
||||
@@ -2019,7 +2025,7 @@ _notification:
|
||||
youGotReply: "{name} ti ha risposto"
|
||||
youGotQuote: "{name} ha citato la tua Nota e ha detto"
|
||||
youRenoted: "{name} ha rinotato"
|
||||
youWereFollowed: "Ha iniziato a seguirti"
|
||||
youWereFollowed: "Adesso ti segue"
|
||||
youReceivedFollowRequest: "Hai ricevuto una richiesta di follow"
|
||||
yourFollowRequestAccepted: "La tua richiesta di follow è stata accettata"
|
||||
pollEnded: "Risultati del sondaggio."
|
||||
|
@@ -1546,6 +1546,10 @@ _ad:
|
||||
reduceFrequencyOfThisAd: "この広告の表示頻度を下げる"
|
||||
hide: "表示しない"
|
||||
timezoneinfo: "曜日はサーバーのタイムゾーンを元に指定されます。"
|
||||
adsSettings: "広告配信設定"
|
||||
notesPerOneAd: "リアルタイム更新中に広告を配信する間隔(ノートの個数)"
|
||||
setZeroToDisable: "0でリアルタイム更新時の広告配信を無効"
|
||||
adsTooClose: "広告の配信間隔が極めて短いため、ユーザー体験が著しく損われる可能性があります。"
|
||||
|
||||
_forgotPassword:
|
||||
enterEmail: "アカウントに登録したメールアドレスを入力してください。そのアドレス宛てに、パスワードリセット用のリンクが送信されます。"
|
||||
|
@@ -1129,6 +1129,12 @@ fileAttachedOnly: "包含附件"
|
||||
showRepliesToOthersInTimeline: "在時間軸上顯示給其他人的回覆"
|
||||
hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆"
|
||||
externalServices: "外部服務"
|
||||
impressum: "營運者資訊"
|
||||
impressumUrl: "營運者資訊網址"
|
||||
impressumDescription: "在德國與部份地區必須要明確顯示營運者資訊。"
|
||||
privacyPolicy: "隱私政策"
|
||||
privacyPolicyUrl: "隱私政策網址"
|
||||
tosAndPrivacyPolicy: "服務條款和隱私政策"
|
||||
_announcement:
|
||||
forExistingUsers: "僅限既有的使用者"
|
||||
forExistingUsersDescription: "啟用代表僅向現存使用者顯示;停用代表張貼後註冊的新使用者也會看到。"
|
||||
@@ -1527,6 +1533,10 @@ _ad:
|
||||
reduceFrequencyOfThisAd: "降低此廣告的頻率 "
|
||||
hide: "隱藏"
|
||||
timezoneinfo: "星期幾是由伺服器的時區指定的。"
|
||||
adsSettings: "廣告投放設定"
|
||||
notesPerOneAd: "即時更新中投放廣告的間隔(貼文數)"
|
||||
setZeroToDisable: "設為 0 則在即時更新時不投放廣告"
|
||||
adsTooClose: "由於廣告投放的間隔極短,可能會嚴重影響使用者體驗。"
|
||||
_forgotPassword:
|
||||
enterEmail: "請輸入您的帳戶註冊的電子郵件地址。 密碼重置連結將被發送到該電子郵件地址。"
|
||||
ifNoEmail: "如果您還沒有註冊您的電子郵件地址,請聯繫管理員。 "
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "2023.10.0-beta.6",
|
||||
"version": "2023.10.0-beta.13",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -55,7 +55,7 @@
|
||||
"@typescript-eslint/parser": "6.7.4",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "13.3.0",
|
||||
"eslint": "8.50.0",
|
||||
"eslint": "8.51.0",
|
||||
"start-server-and-test": "2.0.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
16
packages/backend/migration/1696743032098-AdsOnStream.js
Normal file
16
packages/backend/migration/1696743032098-AdsOnStream.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class AdsOnStream1696743032098 {
|
||||
name = 'AdsOnStream1696743032098'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "notesPerOneAd" integer NOT NULL DEFAULT '0'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "notesPerOneAd"`);
|
||||
}
|
||||
}
|
21
packages/backend/migration/1696807733453-userListUserId.js
Normal file
21
packages/backend/migration/1696807733453-userListUserId.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class UserListUserId1696807733453 {
|
||||
name = 'UserListUserId1696807733453'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_list_membership" ADD "userListUserId" character varying(32) NOT NULL DEFAULT ''`);
|
||||
const memberships = await queryRunner.query(`SELECT "id", "userListId" FROM "user_list_membership"`);
|
||||
for(let i = 0; i < memberships.length; i++) {
|
||||
const userList = await queryRunner.query(`SELECT "userId" FROM "user_list" WHERE "id" = $1`, [memberships[i].userListId]);
|
||||
await queryRunner.query(`UPDATE "user_list_membership" SET "userListUserId" = $1 WHERE "id" = $2`, [userList[0].userId, memberships[i].id]);
|
||||
}
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_list_membership" DROP COLUMN "userListUserId"`);
|
||||
}
|
||||
}
|
16
packages/backend/migration/1696808725134-userListUserId-2.js
Normal file
16
packages/backend/migration/1696808725134-userListUserId-2.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class UserListUserId21696808725134 {
|
||||
name = 'UserListUserId21696808725134'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_list_membership" ALTER COLUMN "userListUserId" DROP DEFAULT`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_list_membership" ALTER COLUMN "userListUserId" SET DEFAULT ''`);
|
||||
}
|
||||
}
|
@@ -124,13 +124,13 @@
|
||||
"nanoid": "5.0.1",
|
||||
"nested-property": "4.0.0",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "6.9.5",
|
||||
"nodemailer": "6.9.6",
|
||||
"nsfwjs": "2.4.2",
|
||||
"oauth": "0.10.0",
|
||||
"oauth2orize": "1.11.1",
|
||||
"oauth2orize-pkce": "0.1.2",
|
||||
"os-utils": "0.0.14",
|
||||
"otpauth": "9.1.4",
|
||||
"otpauth": "9.1.5",
|
||||
"parse5": "7.1.2",
|
||||
"pg": "8.11.3",
|
||||
"pkce-challenge": "4.0.1",
|
||||
@@ -189,7 +189,7 @@
|
||||
"@types/jsrsasign": "10.5.9",
|
||||
"@types/mime-types": "2.1.2",
|
||||
"@types/ms": "0.7.32",
|
||||
"@types/node": "20.8.2",
|
||||
"@types/node": "20.8.3",
|
||||
"@types/node-fetch": "3.0.3",
|
||||
"@types/nodemailer": "6.4.11",
|
||||
"@types/oauth": "0.9.2",
|
||||
@@ -216,7 +216,7 @@
|
||||
"@typescript-eslint/parser": "6.7.4",
|
||||
"aws-sdk-client-mock": "3.0.0",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.50.0",
|
||||
"eslint": "8.51.0",
|
||||
"eslint-plugin-import": "2.28.1",
|
||||
"execa": "8.0.1",
|
||||
"jest": "29.7.0",
|
||||
|
@@ -17,7 +17,6 @@ export async function server() {
|
||||
const app = await NestFactory.createApplicationContext(MainModule, {
|
||||
logger: new NestLogger(),
|
||||
});
|
||||
app.enableShutdownHooks();
|
||||
|
||||
const serverService = app.get(ServerService);
|
||||
await serverService.launch();
|
||||
@@ -35,7 +34,6 @@ export async function jobQueue() {
|
||||
const jobQueue = await NestFactory.createApplicationContext(QueueProcessorModule, {
|
||||
logger: new NestLogger(),
|
||||
});
|
||||
jobQueue.enableShutdownHooks();
|
||||
|
||||
jobQueue.get(QueueProcessorService).start();
|
||||
jobQueue.get(ChartManagementService).start();
|
||||
|
@@ -228,7 +228,7 @@ export class AccountMoveService {
|
||||
},
|
||||
}).then(memberships => memberships.map(membership => membership.userListId));
|
||||
|
||||
const newMemberships: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map();
|
||||
const newMemberships: Map<string, { createdAt: Date; userId: string; userListId: string; userListUserId: string; }> = new Map();
|
||||
|
||||
// 重複しないようにIDを生成
|
||||
const genId = (): string => {
|
||||
@@ -244,6 +244,7 @@ export class AccountMoveService {
|
||||
createdAt: new Date(),
|
||||
userId: dst.id,
|
||||
userListId: membership.userListId,
|
||||
userListUserId: membership.userListUserId,
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -16,6 +16,7 @@ import type { AntennasRepository, UserListMembershipsRepository } from '@/models
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
@@ -38,6 +39,7 @@ export class AntennaService implements OnApplicationShutdown {
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private redisTimelineService: RedisTimelineService,
|
||||
) {
|
||||
this.antennasFetched = false;
|
||||
this.antennas = [];
|
||||
@@ -84,12 +86,7 @@ export class AntennaService implements OnApplicationShutdown {
|
||||
const redisPipeline = this.redisForTimelines.pipeline();
|
||||
|
||||
for (const antenna of matchedAntennas) {
|
||||
redisPipeline.xadd(
|
||||
`antennaTimeline:${antenna.id}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
this.redisTimelineService.push(`antennaTimeline:${antenna.id}`, note.id, 200, redisPipeline);
|
||||
this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
|
||||
}
|
||||
|
||||
|
@@ -61,6 +61,7 @@ import { FileInfoService } from './FileInfoService.js';
|
||||
import { SearchService } from './SearchService.js';
|
||||
import { ClipService } from './ClipService.js';
|
||||
import { FeaturedService } from './FeaturedService.js';
|
||||
import { RedisTimelineService } from './RedisTimelineService.js';
|
||||
import { ChartLoggerService } from './chart/ChartLoggerService.js';
|
||||
import FederationChart from './chart/charts/federation.js';
|
||||
import NotesChart from './chart/charts/notes.js';
|
||||
@@ -189,6 +190,7 @@ const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: Fi
|
||||
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
|
||||
const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService };
|
||||
const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService };
|
||||
const $RedisTimelineService: Provider = { provide: 'RedisTimelineService', useExisting: RedisTimelineService };
|
||||
|
||||
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
|
||||
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
|
||||
@@ -321,6 +323,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
SearchService,
|
||||
ClipService,
|
||||
FeaturedService,
|
||||
RedisTimelineService,
|
||||
ChartLoggerService,
|
||||
FederationChart,
|
||||
NotesChart,
|
||||
@@ -446,6 +449,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$SearchService,
|
||||
$ClipService,
|
||||
$FeaturedService,
|
||||
$RedisTimelineService,
|
||||
$ChartLoggerService,
|
||||
$FederationChart,
|
||||
$NotesChart,
|
||||
@@ -572,6 +576,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
SearchService,
|
||||
ClipService,
|
||||
FeaturedService,
|
||||
RedisTimelineService,
|
||||
FederationChart,
|
||||
NotesChart,
|
||||
UsersChart,
|
||||
@@ -696,6 +701,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$SearchService,
|
||||
$ClipService,
|
||||
$FeaturedService,
|
||||
$RedisTimelineService,
|
||||
$FederationChart,
|
||||
$NotesChart,
|
||||
$UsersChart,
|
||||
|
@@ -43,16 +43,16 @@ export class FeaturedService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getRankingOf(name: string, windowRange: number, limit: number): Promise<string[]> {
|
||||
private async getRankingOf(name: string, windowRange: number, threshold: number): Promise<string[]> {
|
||||
const currentWindow = this.getCurrentWindow(windowRange);
|
||||
const previousWindow = currentWindow - 1;
|
||||
|
||||
const [currentRankingResult, previousRankingResult] = await Promise.all([
|
||||
this.redisClient.zrange(
|
||||
`${name}:${currentWindow}`, 0, limit, 'REV', 'WITHSCORES'),
|
||||
this.redisClient.zrange(
|
||||
`${name}:${previousWindow}`, 0, limit, 'REV', 'WITHSCORES'),
|
||||
]);
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
redisPipeline.zrange(
|
||||
`${name}:${currentWindow}`, 0, threshold, 'REV', 'WITHSCORES');
|
||||
redisPipeline.zrange(
|
||||
`${name}:${previousWindow}`, 0, threshold, 'REV', 'WITHSCORES');
|
||||
const [currentRankingResult, previousRankingResult] = await redisPipeline.exec().then(result => result ? result.map(r => r[1] as string[]) : [[], []]);
|
||||
|
||||
const ranking = new Map<string, number>();
|
||||
for (let i = 0; i < currentRankingResult.length; i += 2) {
|
||||
@@ -95,22 +95,22 @@ export class FeaturedService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getGlobalNotesRanking(limit: number): Promise<MiNote['id'][]> {
|
||||
return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, limit);
|
||||
public getGlobalNotesRanking(threshold: number): Promise<MiNote['id'][]> {
|
||||
return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, threshold);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getInChannelNotesRanking(channelId: MiNote['channelId'], limit: number): Promise<MiNote['id'][]> {
|
||||
return this.getRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, limit);
|
||||
public getInChannelNotesRanking(channelId: MiNote['channelId'], threshold: number): Promise<MiNote['id'][]> {
|
||||
return this.getRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, threshold);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getPerUserNotesRanking(userId: MiUser['id'], limit: number): Promise<MiNote['id'][]> {
|
||||
return this.getRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, limit);
|
||||
public getPerUserNotesRanking(userId: MiUser['id'], threshold: number): Promise<MiNote['id'][]> {
|
||||
return this.getRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, threshold);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getHashtagsRanking(limit: number): Promise<string[]> {
|
||||
return this.getRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, limit);
|
||||
public getHashtagsRanking(threshold: number): Promise<string[]> {
|
||||
return this.getRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, threshold);
|
||||
}
|
||||
}
|
||||
|
@@ -174,16 +174,15 @@ export class HashtagService {
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
|
||||
// TODO: これらの Set は Bloom Filter を使うようにしても良さそう
|
||||
|
||||
// チャート用
|
||||
redisPipeline.sadd(`hashtagUsers:${hashtag}:${window}`, userId);
|
||||
redisPipeline.pfadd(`hashtagUsers:${hashtag}:${window}`, userId);
|
||||
redisPipeline.expire(`hashtagUsers:${hashtag}:${window}`,
|
||||
60 * 60 * 24 * 3, // 3日間
|
||||
'NX', // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
|
||||
);
|
||||
|
||||
// ユニークカウント用
|
||||
// TODO: Bloom Filter を使うようにしても良さそう
|
||||
redisPipeline.sadd(`hashtagUsers:${hashtag}`, userId);
|
||||
redisPipeline.expire(`hashtagUsers:${hashtag}`,
|
||||
60 * 60, // 1時間
|
||||
@@ -202,7 +201,7 @@ export class HashtagService {
|
||||
|
||||
for (let i = 0; i < range; i++) {
|
||||
const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`;
|
||||
redisPipeline.scard(`hashtagUsers:${hashtag}:${window}`);
|
||||
redisPipeline.pfcount(`hashtagUsers:${hashtag}:${window}`);
|
||||
now.setMinutes(now.getMinutes() - (i * 10), 0, 0);
|
||||
}
|
||||
|
||||
@@ -223,7 +222,7 @@ export class HashtagService {
|
||||
for (let i = 0; i < range; i++) {
|
||||
const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`;
|
||||
for (const hashtag of hashtags) {
|
||||
redisPipeline.scard(`hashtagUsers:${hashtag}:${window}`);
|
||||
redisPipeline.pfcount(`hashtagUsers:${hashtag}:${window}`);
|
||||
}
|
||||
now.setMinutes(now.getMinutes() - (i * 10), 0, 0);
|
||||
}
|
||||
|
@@ -54,6 +54,7 @@ import { RoleService } from '@/core/RoleService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { SearchService } from '@/core/SearchService.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
|
||||
|
||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||
|
||||
@@ -194,6 +195,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private queueService: QueueService,
|
||||
private redisTimelineService: RedisTimelineService,
|
||||
private noteReadService: NoteReadService,
|
||||
private notificationService: NotificationService,
|
||||
private relayService: RelayService,
|
||||
@@ -347,14 +349,6 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
|
||||
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
|
||||
|
||||
if (data.channel) {
|
||||
this.redisForTimelines.xadd(
|
||||
`channelTimeline:${data.channel.id}`,
|
||||
'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(),
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
|
||||
setImmediate('post created', { signal: this.#shutdownController.signal }).then(
|
||||
() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!),
|
||||
() => { /* aborted, ignore this */ },
|
||||
@@ -494,11 +488,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
// Increment notes count (user)
|
||||
this.incNotesCountOfUser(user);
|
||||
|
||||
if (data.visibility === 'specified') {
|
||||
// TODO?
|
||||
} else {
|
||||
this.pushToTl(note, user);
|
||||
}
|
||||
this.pushToTl(note, user);
|
||||
|
||||
this.antennaService.addNoteToAntennas(note, user);
|
||||
|
||||
@@ -828,14 +818,12 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
const redisPipeline = this.redisForTimelines.pipeline();
|
||||
const r = this.redisForTimelines.pipeline();
|
||||
|
||||
if (note.channelId) {
|
||||
redisPipeline.xadd(
|
||||
`userTimelineWithChannel:${user.id}`,
|
||||
'MAXLEN', '~', note.userHost == null ? meta.perLocalUserUserTimelineCacheMax.toString() : meta.perRemoteUserUserTimelineCacheMax.toString(),
|
||||
'*',
|
||||
'note', note.id);
|
||||
this.redisTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r);
|
||||
|
||||
this.redisTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
|
||||
|
||||
const channelFollowings = await this.channelFollowingsRepository.find({
|
||||
where: {
|
||||
@@ -845,138 +833,90 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
});
|
||||
|
||||
for (const channelFollowing of channelFollowings) {
|
||||
redisPipeline.xadd(
|
||||
`homeTimeline:${channelFollowing.followerId}`,
|
||||
'MAXLEN', '~', meta.perUserHomeTimelineCacheMax.toString(),
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
this.redisTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`homeTimelineWithFiles:${channelFollowing.followerId}`,
|
||||
'MAXLEN', '~', (meta.perUserHomeTimelineCacheMax / 2).toString(),
|
||||
'*',
|
||||
'note', note.id);
|
||||
this.redisTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO: キャッシュ?
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: {
|
||||
followeeId: user.id,
|
||||
followerHost: IsNull(),
|
||||
isFollowerHibernated: false,
|
||||
},
|
||||
select: ['followerId', 'withReplies'],
|
||||
});
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [followings, userListMemberships] = await Promise.all([
|
||||
this.followingsRepository.find({
|
||||
where: {
|
||||
followeeId: user.id,
|
||||
followerHost: IsNull(),
|
||||
isFollowerHibernated: false,
|
||||
},
|
||||
select: ['followerId', 'withReplies'],
|
||||
}),
|
||||
this.userListMembershipsRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: ['userListId', 'userListUserId', 'withReplies'],
|
||||
}),
|
||||
]);
|
||||
|
||||
const userListMemberships = await this.userListMembershipsRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: ['userListId', 'withReplies'],
|
||||
});
|
||||
if (note.visibility === 'followers') {
|
||||
// TODO: 重そうだから何とかしたい Set 使う?
|
||||
userListMemberships = userListMemberships.filter(x => followings.some(f => f.followerId === x.userListUserId));
|
||||
}
|
||||
|
||||
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
|
||||
for (const following of followings) {
|
||||
// 基本的にvisibleUserIdsには自身のidが含まれている前提であること
|
||||
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
|
||||
|
||||
// 自分自身以外への返信
|
||||
if (note.replyId && note.replyUserId !== note.userId) {
|
||||
if (!following.withReplies) continue;
|
||||
}
|
||||
|
||||
redisPipeline.xadd(
|
||||
`homeTimeline:${following.followerId}`,
|
||||
'MAXLEN', '~', meta.perUserHomeTimelineCacheMax.toString(),
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
this.redisTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`homeTimelineWithFiles:${following.followerId}`,
|
||||
'MAXLEN', '~', (meta.perUserHomeTimelineCacheMax / 2).toString(),
|
||||
'*',
|
||||
'note', note.id);
|
||||
this.redisTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.visibility === 'specified' &&
|
||||
!note.visibleUserIds.some(v => v === userListMembership.userListUserId)
|
||||
) continue;
|
||||
|
||||
// 自分自身以外への返信
|
||||
if (note.replyId && note.replyUserId !== note.userId) {
|
||||
if (!userListMembership.withReplies) continue;
|
||||
}
|
||||
|
||||
redisPipeline.xadd(
|
||||
`userListTimeline:${userListMembership.userListId}`,
|
||||
'MAXLEN', '~', meta.perUserListTimelineCacheMax.toString(),
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
this.redisTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`userListTimelineWithFiles:${userListMembership.userListId}`,
|
||||
'MAXLEN', '~', (meta.perUserListTimelineCacheMax / 2).toString(),
|
||||
'*',
|
||||
'note', note.id);
|
||||
this.redisTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r);
|
||||
}
|
||||
}
|
||||
|
||||
{ // 自分自身のHTL
|
||||
redisPipeline.xadd(
|
||||
`homeTimeline:${user.id}`,
|
||||
'MAXLEN', '~', meta.perUserHomeTimelineCacheMax.toString(),
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { // 自分自身のHTL
|
||||
this.redisTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`homeTimelineWithFiles:${user.id}`,
|
||||
'MAXLEN', '~', (meta.perUserHomeTimelineCacheMax / 2).toString(),
|
||||
'*',
|
||||
'note', note.id);
|
||||
this.redisTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
|
||||
}
|
||||
}
|
||||
|
||||
// 自分自身以外への返信
|
||||
if (note.replyId && note.replyUserId !== note.userId) {
|
||||
redisPipeline.xadd(
|
||||
`userTimelineWithReplies:${user.id}`,
|
||||
'MAXLEN', '~', note.userHost == null ? meta.perLocalUserUserTimelineCacheMax.toString() : meta.perRemoteUserUserTimelineCacheMax.toString(),
|
||||
'*',
|
||||
'note', note.id);
|
||||
this.redisTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
|
||||
} else {
|
||||
redisPipeline.xadd(
|
||||
`userTimeline:${user.id}`,
|
||||
'MAXLEN', '~', note.userHost == null ? meta.perLocalUserUserTimelineCacheMax.toString() : meta.perRemoteUserUserTimelineCacheMax.toString(),
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
this.redisTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`userTimelineWithFiles:${user.id}`,
|
||||
'MAXLEN', '~', note.userHost == null ? (meta.perLocalUserUserTimelineCacheMax / 2).toString() : (meta.perRemoteUserUserTimelineCacheMax / 2).toString(),
|
||||
'*',
|
||||
'note', note.id);
|
||||
this.redisTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r);
|
||||
}
|
||||
|
||||
if (note.visibility === 'public' && note.userHost == null) {
|
||||
redisPipeline.xadd(
|
||||
'localTimeline',
|
||||
'MAXLEN', '~', '1000',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
this.redisTimelineService.push('localTimeline', note.id, 1000, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
'localTimelineWithFiles',
|
||||
'MAXLEN', '~', '500',
|
||||
'*',
|
||||
'note', note.id);
|
||||
this.redisTimelineService.push('localTimelineWithFiles', note.id, 500, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -988,7 +928,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
}
|
||||
}
|
||||
|
||||
redisPipeline.exec();
|
||||
r.exec();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@@ -9,6 +9,7 @@ import { DI } from '@/di-symbols.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
@@ -34,6 +35,8 @@ export class QueryService {
|
||||
|
||||
@Inject(DI.renoteMutingsRepository)
|
||||
private renoteMutingsRepository: RenoteMutingsRepository,
|
||||
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -49,15 +52,15 @@ export class QueryService {
|
||||
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
} else if (sinceDate && untilDate) {
|
||||
q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) });
|
||||
q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) });
|
||||
q.orderBy(`${q.alias}.createdAt`, 'DESC');
|
||||
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.genId(new Date(sinceDate)) });
|
||||
q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.genId(new Date(untilDate)) });
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
} else if (sinceDate) {
|
||||
q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) });
|
||||
q.orderBy(`${q.alias}.createdAt`, 'ASC');
|
||||
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.genId(new Date(sinceDate)) });
|
||||
q.orderBy(`${q.alias}.id`, 'ASC');
|
||||
} else if (untilDate) {
|
||||
q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) });
|
||||
q.orderBy(`${q.alias}.createdAt`, 'DESC');
|
||||
q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.genId(new Date(untilDate)) });
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
} else {
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
}
|
||||
@@ -76,13 +79,15 @@ export class QueryService {
|
||||
// 投稿の引用元の作者にブロックされていない
|
||||
q
|
||||
.andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`)
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyUserId IS NULL')
|
||||
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyUserId IS NULL')
|
||||
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.renoteUserId IS NULL')
|
||||
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.renoteUserId IS NULL')
|
||||
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
}));
|
||||
|
||||
q.setParameters(blockingQuery.getParameters());
|
||||
@@ -112,16 +117,17 @@ export class QueryService {
|
||||
.where('threadMuted.userId = :userId', { userId: me.id });
|
||||
|
||||
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
.where('note.threadId IS NULL')
|
||||
.orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
|
||||
q.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.threadId IS NULL')
|
||||
.orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
|
||||
}));
|
||||
|
||||
q.setParameters(mutedQuery.getParameters());
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedUserQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }, exclude?: MiUser): void {
|
||||
public generateMutedUserQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void {
|
||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('muting.muteeId')
|
||||
.where('muting.muterId = :muterId', { muterId: me.id });
|
||||
@@ -139,26 +145,31 @@ export class QueryService {
|
||||
// 投稿の引用元の作者をミュートしていない
|
||||
q
|
||||
.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`)
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyUserId IS NULL')
|
||||
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyUserId IS NULL')
|
||||
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.renoteUserId IS NULL')
|
||||
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.renoteUserId IS NULL')
|
||||
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
}))
|
||||
// mute instances
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.andWhere('note.userHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.andWhere('note.userHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.renoteUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.renoteUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
|
||||
}));
|
||||
|
||||
q.setParameters(mutingQuery.getParameters());
|
||||
@@ -180,36 +191,41 @@ export class QueryService {
|
||||
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void {
|
||||
// This code must always be synchronized with the checks in Notes.isVisibleForMe.
|
||||
if (me == null) {
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
q.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}));
|
||||
} else {
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :meId');
|
||||
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
q.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
// 公開投稿である
|
||||
.where(new Brackets(qb => { qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}))
|
||||
.where(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}))
|
||||
// または 自分自身
|
||||
.orWhere('note.userId = :meId')
|
||||
.orWhere('note.userId = :meId')
|
||||
// または 自分宛て
|
||||
.orWhere(':meId = ANY(note.visibleUserIds)')
|
||||
.orWhere(':meId = ANY(note.mentions)')
|
||||
.orWhere(new Brackets(qb => { qb
|
||||
// または フォロワー宛ての投稿であり、
|
||||
.where('note.visibility = \'followers\'')
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
// 自分がフォロワーである
|
||||
.where(`note.userId IN (${ followingQuery.getQuery() })`)
|
||||
// または 自分の投稿へのリプライ
|
||||
.orWhere('note.replyUserId = :meId');
|
||||
.orWhere(':meId = ANY(note.visibleUserIds)')
|
||||
.orWhere(':meId = ANY(note.mentions)')
|
||||
.orWhere(new Brackets(qb => {
|
||||
qb
|
||||
// または フォロワー宛ての投稿であり、
|
||||
.where('note.visibility = \'followers\'')
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
// 自分がフォロワーである
|
||||
.where(`note.userId IN (${ followingQuery.getQuery() })`)
|
||||
// または 自分の投稿へのリプライ
|
||||
.orWhere('note.replyUserId = :meId');
|
||||
}));
|
||||
}));
|
||||
}));
|
||||
}));
|
||||
|
||||
q.setParameters({ meId: me.id });
|
||||
|
80
packages/backend/src/core/RedisTimelineService.ts
Normal file
80
packages/backend/src/core/RedisTimelineService.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
|
||||
@Injectable()
|
||||
export class RedisTimelineService {
|
||||
constructor(
|
||||
@Inject(DI.redisForTimelines)
|
||||
private redisForTimelines: Redis.Redis,
|
||||
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public push(tl: string, id: string, maxlen: number, pipeline: Redis.ChainableCommander) {
|
||||
// リモートから遅れて届いた(もしくは後から追加された)投稿日時が古い投稿が追加されるとページネーション時に問題を引き起こすため、
|
||||
// 3分以内に投稿されたものでない場合、Redisにある最古のIDより新しい場合のみ追加する
|
||||
if (this.idService.parse(id).date.getTime() > Date.now() - 1000 * 60 * 3) {
|
||||
pipeline.lpush('list:' + tl, id);
|
||||
if (Math.random() < 0.1) { // 10%の確率でトリム
|
||||
pipeline.ltrim('list:' + tl, 0, maxlen - 1);
|
||||
}
|
||||
} else {
|
||||
// 末尾のIDを取得
|
||||
this.redisForTimelines.lindex('list:' + tl, -1).then(lastId => {
|
||||
if (lastId == null || (this.idService.parse(id).date.getTime() > this.idService.parse(lastId).date.getTime())) {
|
||||
this.redisForTimelines.lpush('list:' + tl, id);
|
||||
} else {
|
||||
Promise.resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public get(name: string, untilId?: string | null, sinceId?: string | null) {
|
||||
if (untilId && sinceId) {
|
||||
return this.redisForTimelines.lrange('list:' + name, 0, -1)
|
||||
.then(ids => ids.filter(id => id > untilId && id < sinceId).sort((a, b) => a > b ? -1 : 1));
|
||||
} else if (untilId) {
|
||||
return this.redisForTimelines.lrange('list:' + name, 0, -1)
|
||||
.then(ids => ids.filter(id => id > untilId).sort((a, b) => a > b ? -1 : 1));
|
||||
} else if (sinceId) {
|
||||
return this.redisForTimelines.lrange('list:' + name, 0, -1)
|
||||
.then(ids => ids.filter(id => id < sinceId).sort((a, b) => a < b ? -1 : 1));
|
||||
} else {
|
||||
return this.redisForTimelines.lrange('list:' + name, 0, -1)
|
||||
.then(ids => ids.sort((a, b) => a > b ? -1 : 1));
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getMulti(name: string[], untilId?: string | null, sinceId?: string | null): Promise<string[][]> {
|
||||
const pipeline = this.redisForTimelines.pipeline();
|
||||
for (const n of name) {
|
||||
pipeline.lrange('list:' + n, 0, -1);
|
||||
}
|
||||
return pipeline.exec().then(res => {
|
||||
if (res == null) return [];
|
||||
const tls = res.map(r => r[1] as string[]);
|
||||
return tls.map(ids =>
|
||||
(untilId && sinceId)
|
||||
? ids.filter(id => id > untilId && id < sinceId).sort((a, b) => a > b ? -1 : 1)
|
||||
: untilId
|
||||
? ids.filter(id => id > untilId).sort((a, b) => a > b ? -1 : 1)
|
||||
: sinceId
|
||||
? ids.filter(id => id < sinceId).sort((a, b) => a < b ? -1 : 1)
|
||||
: ids.sort((a, b) => a > b ? -1 : 1),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
@@ -20,6 +20,7 @@ import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
export type RolePolicies = {
|
||||
@@ -102,6 +103,7 @@ export class RoleService implements OnApplicationShutdown {
|
||||
private globalEventService: GlobalEventService,
|
||||
private idService: IdService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private redisTimelineService: RedisTimelineService,
|
||||
) {
|
||||
//this.onMessage = this.onMessage.bind(this);
|
||||
|
||||
@@ -472,12 +474,7 @@ export class RoleService implements OnApplicationShutdown {
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
|
||||
for (const role of roles) {
|
||||
redisPipeline.xadd(
|
||||
`roleTimeline:${role.id}`,
|
||||
'MAXLEN', '~', '1000',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
this.redisTimelineService.push(`roleTimeline:${role.id}`, note.id, 1000, redisPipeline);
|
||||
this.globalEventService.publishRoleTimelineStream(role.id, 'note', note);
|
||||
}
|
||||
|
||||
|
@@ -97,6 +97,7 @@ export class UserListService implements OnApplicationShutdown {
|
||||
createdAt: new Date(),
|
||||
userId: target.id,
|
||||
userListId: list.id,
|
||||
userListUserId: list.userId,
|
||||
} as MiUserListMembership);
|
||||
|
||||
this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id });
|
||||
|
@@ -17,6 +17,7 @@ import type { MiNoteReaction } from '@/models/NoteReaction.js';
|
||||
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { DebounceLoader } from '@/misc/loader.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { CustomEmojiService } from '../CustomEmojiService.js';
|
||||
import type { ReactionService } from '../ReactionService.js';
|
||||
@@ -29,6 +30,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||
private driveFileEntityService: DriveFileEntityService;
|
||||
private customEmojiService: CustomEmojiService;
|
||||
private reactionService: ReactionService;
|
||||
private noteLoader = new DebounceLoader(this.findNoteOrFail);
|
||||
|
||||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
@@ -285,7 +287,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||
}, options);
|
||||
|
||||
const meId = me ? me.id : null;
|
||||
const note = typeof src === 'object' ? src : await this.notesRepository.findOneOrFail({ where: { id: src }, relations: ['user'] });
|
||||
const note = typeof src === 'object' ? src : await this.noteLoader.load(src);
|
||||
const host = note.userHost;
|
||||
|
||||
let text = note.text;
|
||||
@@ -450,4 +452,12 @@ export class NoteEntityService implements OnModuleInit {
|
||||
}
|
||||
return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[];
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private findNoteOrFail(id: string): Promise<MiNote> {
|
||||
return this.notesRepository.findOneOrFail({
|
||||
where: { id },
|
||||
relations: ['user'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -33,9 +33,10 @@ export class RoleEntityService {
|
||||
|
||||
const assignedCount = await this.roleAssignmentsRepository.createQueryBuilder('assign')
|
||||
.where('assign.roleId = :roleId', { roleId: role.id })
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('assign.expiresAt IS NULL')
|
||||
.orWhere('assign.expiresAt > :now', { now: new Date() });
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('assign.expiresAt IS NULL')
|
||||
.orWhere('assign.expiresAt > :now', { now: new Date() });
|
||||
}))
|
||||
.getCount();
|
||||
|
||||
|
52
packages/backend/src/misc/loader.ts
Normal file
52
packages/backend/src/misc/loader.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export type FetchFunction<K, V> = (key: K) => Promise<V>;
|
||||
|
||||
type ResolveReject<V> = Parameters<ConstructorParameters<typeof Promise<V>>[0]>;
|
||||
|
||||
type ResolverPair<V> = {
|
||||
resolve: ResolveReject<V>[0];
|
||||
reject: ResolveReject<V>[1];
|
||||
};
|
||||
|
||||
export class DebounceLoader<K, V> {
|
||||
private resolverMap = new Map<K, ResolverPair<V>>();
|
||||
private promiseMap = new Map<K, Promise<V>>();
|
||||
private resolvedPromise = Promise.resolve();
|
||||
constructor(private loadFn: FetchFunction<K, V>) {}
|
||||
|
||||
public load(key: K): Promise<V> {
|
||||
const promise = this.promiseMap.get(key);
|
||||
if (typeof promise !== 'undefined') {
|
||||
return promise;
|
||||
}
|
||||
|
||||
const isFirst = this.promiseMap.size === 0;
|
||||
const newPromise = new Promise<V>((resolve, reject) => {
|
||||
this.resolverMap.set(key, { resolve, reject });
|
||||
});
|
||||
this.promiseMap.set(key, newPromise);
|
||||
|
||||
if (isFirst) {
|
||||
this.enqueueDebouncedLoadJob();
|
||||
}
|
||||
|
||||
return newPromise;
|
||||
}
|
||||
|
||||
private runDebouncedLoad(): void {
|
||||
const resolvers = [...this.resolverMap];
|
||||
this.resolverMap.clear();
|
||||
this.promiseMap.clear();
|
||||
|
||||
for (const [key, { resolve, reject }] of resolvers) {
|
||||
this.loadFn(key).then(resolve, reject);
|
||||
}
|
||||
}
|
||||
|
||||
private enqueueDebouncedLoadJob(): void {
|
||||
this.resolvedPromise.then(() => {
|
||||
process.nextTick(() => {
|
||||
this.runDebouncedLoad();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
@@ -503,4 +503,9 @@ export class MiMeta {
|
||||
default: 300,
|
||||
})
|
||||
public perUserListTimelineCacheMax: number;
|
||||
|
||||
@Column('integer', {
|
||||
default: 0,
|
||||
})
|
||||
public notesPerOneAd: number;
|
||||
}
|
||||
|
@@ -50,4 +50,11 @@ export class MiUserListMembership {
|
||||
default: false,
|
||||
})
|
||||
public withReplies: boolean;
|
||||
|
||||
//#region Denormalized fields
|
||||
@Column({
|
||||
...id(),
|
||||
})
|
||||
public userListUserId: MiUser['id'];
|
||||
//#endregion
|
||||
}
|
||||
|
@@ -379,9 +379,10 @@ export class ActivityPubServerService {
|
||||
if (page) {
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId)
|
||||
.andWhere('note.userId = :userId', { userId: user.id })
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}))
|
||||
.andWhere('note.localOnly = FALSE');
|
||||
|
||||
|
@@ -135,7 +135,11 @@ export class NodeinfoServerService {
|
||||
.type(
|
||||
'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"',
|
||||
)
|
||||
.header('Cache-Control', 'public, max-age=600');
|
||||
.header('Cache-Control', 'public, max-age=600')
|
||||
.header('Access-Control-Allow-Headers', 'Accept')
|
||||
.header('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
||||
.header('Access-Control-Allow-Origin', '*')
|
||||
.header('Access-Control-Expose-Headers', 'Vary');
|
||||
return { version: '2.1', ...base };
|
||||
});
|
||||
|
||||
@@ -148,7 +152,11 @@ export class NodeinfoServerService {
|
||||
.type(
|
||||
'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"',
|
||||
)
|
||||
.header('Cache-Control', 'public, max-age=600');
|
||||
.header('Cache-Control', 'public, max-age=600')
|
||||
.header('Access-Control-Allow-Headers', 'Accept')
|
||||
.header('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
||||
.header('Access-Control-Allow-Origin', '*')
|
||||
.header('Access-Control-Expose-Headers', 'Vary');
|
||||
return { version: '2.0', ...base };
|
||||
});
|
||||
|
||||
|
@@ -199,10 +199,10 @@ export class ServerService implements OnApplicationShutdown {
|
||||
includeSecrets: true,
|
||||
}));
|
||||
|
||||
reply.code(200);
|
||||
return 'Verify succeeded!';
|
||||
reply.code(200).send('Verification succeeded! メールアドレスの認証に成功しました。');
|
||||
return;
|
||||
} else {
|
||||
reply.code(404);
|
||||
reply.code(404).send('Verification failed. Please try again. メールアドレスの認証に失敗しました。もう一度お試しください');
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
@@ -297,6 +297,10 @@ export const meta = {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
notesPerOneAd: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -408,6 +412,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax,
|
||||
perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax,
|
||||
perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax,
|
||||
notesPerOneAd: instance.notesPerOneAd,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@@ -61,9 +61,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
|
||||
.andWhere('assign.roleId = :roleId', { roleId: role.id })
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('assign.expiresAt IS NULL')
|
||||
.orWhere('assign.expiresAt > :now', { now: new Date() });
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('assign.expiresAt IS NULL')
|
||||
.orWhere('assign.expiresAt > :now', { now: new Date() });
|
||||
}))
|
||||
.innerJoinAndSelect('assign.user', 'user');
|
||||
|
||||
|
@@ -114,6 +114,7 @@ export const paramDef = {
|
||||
perRemoteUserUserTimelineCacheMax: { type: 'integer' },
|
||||
perUserHomeTimelineCacheMax: { type: 'integer' },
|
||||
perUserListTimelineCacheMax: { type: 'integer' },
|
||||
notesPerOneAd: { type: 'integer' },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
@@ -471,6 +472,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
set.perUserListTimelineCacheMax = ps.perUserListTimelineCacheMax;
|
||||
}
|
||||
|
||||
if (ps.notesPerOneAd !== undefined) {
|
||||
set.notesPerOneAd = ps.notesPerOneAd;
|
||||
}
|
||||
|
||||
const before = await this.metaService.fetch(true);
|
||||
|
||||
await this.metaService.update(set);
|
||||
|
@@ -12,6 +12,7 @@ import { NoteReadService } from '@/core/NoteReadService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -69,8 +70,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private noteReadService: NoteReadService,
|
||||
private redisTimelineService: RedisTimelineService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null;
|
||||
const sinceId = ps.sinceId ?? ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null;
|
||||
|
||||
const antenna = await this.antennasRepository.findOneBy({
|
||||
id: ps.antennaId,
|
||||
userId: me.id,
|
||||
@@ -85,19 +90,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
lastUsedAt: new Date(),
|
||||
});
|
||||
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
const noteIdsRes = await this.redisForTimelines.xrevrange(
|
||||
`antennaTimeline:${antenna.id}`,
|
||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||
'COUNT', limit);
|
||||
|
||||
if (noteIdsRes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
||||
|
||||
let noteIds = await this.redisTimelineService.get(`antennaTimeline:${antenna.id}`, untilId, sinceId);
|
||||
noteIds = noteIds.slice(0, ps.limit);
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
@@ -55,9 +55,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
if (ps.query !== '') {
|
||||
if (ps.type === 'nameAndDescription') {
|
||||
query.andWhere(new Brackets(qb => { qb
|
||||
.where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
|
||||
.orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
|
||||
.orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
|
||||
}));
|
||||
} else {
|
||||
query.andWhere('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
|
||||
|
@@ -12,6 +12,9 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -66,9 +69,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private idService: IdService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private redisTimelineService: RedisTimelineService,
|
||||
private cacheService: CacheService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null;
|
||||
const sinceId = ps.sinceId ?? ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null;
|
||||
const isRangeSpecified = untilId != null && sinceId != null;
|
||||
|
||||
const channel = await this.channelsRepository.findOneBy({
|
||||
id: ps.channelId,
|
||||
});
|
||||
@@ -77,68 +86,66 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
|
||||
let timeline: MiNote[] = [];
|
||||
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
let noteIdsRes: [string, string[]][] = [];
|
||||
|
||||
if (!ps.sinceId && !ps.sinceDate) {
|
||||
noteIdsRes = await this.redisForTimelines.xrevrange(
|
||||
`channelTimeline:${channel.id}`,
|
||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||
'COUNT', limit);
|
||||
}
|
||||
|
||||
// redis から取得していないとき・取得数が足りないとき
|
||||
if (noteIdsRes.length < limit) {
|
||||
//#region Construct query
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('note.channelId = :channelId', { channelId: channel.id })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQuery(query, me);
|
||||
this.queryService.generateBlockedUserQuery(query, me);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
timeline = await query.limit(ps.limit).getMany();
|
||||
} else {
|
||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
//#region Construct query
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQuery(query, me);
|
||||
this.queryService.generateBlockedUserQuery(query, me);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
timeline = await query.getMany();
|
||||
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
}
|
||||
|
||||
if (me) this.activeUsersChart.read(me);
|
||||
|
||||
if (isRangeSpecified || sinceId == null) {
|
||||
const [
|
||||
userIdsWhoMeMuting,
|
||||
] = me ? await Promise.all([
|
||||
this.cacheService.userMutingsCache.fetch(me.id),
|
||||
]) : [new Set<string>()];
|
||||
|
||||
let noteIds = await this.redisTimelineService.get(`channelTimeline:${channel.id}`, untilId, sinceId);
|
||||
noteIds = noteIds.slice(0, ps.limit);
|
||||
|
||||
if (noteIds.length > 0) {
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
let timeline = await query.getMany();
|
||||
|
||||
timeline = timeline.filter(note => {
|
||||
if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// TODO: フィルタで件数が減った場合の埋め合わせ処理
|
||||
|
||||
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
|
||||
if (timeline.length > 0) {
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//#region fallback to database
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('note.channelId = :channelId', { channelId: channel.id })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQuery(query, me);
|
||||
this.queryService.generateBlockedUserQuery(query, me);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const timeline = await query.limit(ps.limit).getMany();
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
//#endregion
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -181,6 +181,11 @@ export const meta = {
|
||||
},
|
||||
},
|
||||
},
|
||||
notesPerOneAd: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
default: 0,
|
||||
},
|
||||
requireSetup: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
@@ -331,6 +336,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
imageUrl: ad.imageUrl,
|
||||
dayOfWeek: ad.dayOfWeek,
|
||||
})),
|
||||
notesPerOneAd: instance.notesPerOneAd,
|
||||
enableEmail: instance.enableEmail,
|
||||
enableServiceWorker: instance.enableServiceWorker,
|
||||
|
||||
|
@@ -49,16 +49,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyId = :noteId', { noteId: ps.noteId })
|
||||
.orWhere(new Brackets(qb => { qb
|
||||
.where('note.renoteId = :noteId', { noteId: ps.noteId })
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.text IS NOT NULL')
|
||||
.orWhere('note.fileIds != \'{}\'')
|
||||
.orWhere('note.hasPoll = TRUE');
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyId = :noteId', { noteId: ps.noteId })
|
||||
.orWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.renoteId = :noteId', { noteId: ps.noteId })
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.text IS NOT NULL')
|
||||
.orWhere('note.fileIds != \'{}\'')
|
||||
.orWhere('note.hasPoll = TRUE');
|
||||
}));
|
||||
}));
|
||||
}));
|
||||
}))
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
|
@@ -15,6 +15,7 @@ import { RoleService } from '@/core/RoleService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -72,8 +73,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private idService: IdService,
|
||||
private cacheService: CacheService,
|
||||
private redisTimelineService: RedisTimelineService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null;
|
||||
const sinceId = ps.sinceId ?? ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null;
|
||||
|
||||
const policies = await this.roleService.getUserPolicies(me.id);
|
||||
if (!policies.ltlAvailable) {
|
||||
throw new ApiError(meta.errors.stlDisabled);
|
||||
@@ -89,29 +94,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
this.cacheService.userBlockedCache.fetch(me.id),
|
||||
]);
|
||||
|
||||
let timeline: MiNote[] = [];
|
||||
const [htlNoteIds, ltlNoteIds] = await this.redisTimelineService.getMulti([
|
||||
ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`,
|
||||
ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline',
|
||||
], untilId, sinceId);
|
||||
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
let htlNoteIdsRes: [string, string[]][] = [];
|
||||
let ltlNoteIdsRes: [string, string[]][] = [];
|
||||
|
||||
if (!ps.sinceId && !ps.sinceDate) {
|
||||
[htlNoteIdsRes, ltlNoteIdsRes] = await Promise.all([
|
||||
this.redisForTimelines.xrevrange(
|
||||
ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`,
|
||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||
'COUNT', limit),
|
||||
this.redisForTimelines.xrevrange(
|
||||
ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline',
|
||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||
'COUNT', limit),
|
||||
]);
|
||||
}
|
||||
|
||||
const htlNoteIds = htlNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
||||
const ltlNoteIds = ltlNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
||||
let noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
|
||||
noteIds.sort((a, b) => a > b ? -1 : 1);
|
||||
noteIds = noteIds.slice(0, ps.limit);
|
||||
@@ -129,7 +116,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
timeline = await query.getMany();
|
||||
let timeline = await query.getMany();
|
||||
|
||||
timeline = timeline.filter(note => {
|
||||
if (note.userId === me.id) {
|
||||
|
@@ -15,6 +15,7 @@ import { RoleService } from '@/core/RoleService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -68,8 +69,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private idService: IdService,
|
||||
private cacheService: CacheService,
|
||||
private redisTimelineService: RedisTimelineService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null;
|
||||
const sinceId = ps.sinceId ?? ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null;
|
||||
|
||||
const policies = await this.roleService.getUserPolicies(me ? me.id : null);
|
||||
if (!policies.ltlAvailable) {
|
||||
throw new ApiError(meta.errors.ltlDisabled);
|
||||
@@ -85,20 +90,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
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) + (ps.sinceId ? 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 ?? '+',
|
||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||
'COUNT', limit);
|
||||
}
|
||||
|
||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
||||
let noteIds = await this.redisTimelineService.get(ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline', untilId, sinceId);
|
||||
noteIds = noteIds.slice(0, ps.limit);
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
@@ -113,7 +106,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
timeline = await query.getMany();
|
||||
let timeline = await query.getMany();
|
||||
|
||||
timeline = timeline.filter(note => {
|
||||
if (me && (note.userId === me.id)) {
|
||||
@@ -131,6 +124,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
return true;
|
||||
});
|
||||
|
||||
// TODO: フィルタした結果件数が足りなかった場合の対応
|
||||
|
||||
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
|
||||
process.nextTick(() => {
|
||||
|
@@ -59,9 +59,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.where('following.followerId = :followerId', { followerId: me.id });
|
||||
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where(`'{"${me.id}"}' <@ note.mentions`)
|
||||
.orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`);
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where(`'{"${me.id}"}' <@ note.mentions`)
|
||||
.orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`);
|
||||
}))
|
||||
// Avoid scanning primary key index
|
||||
.orderBy('CONCAT(note.id)', 'DESC')
|
||||
|
@@ -57,9 +57,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.where('poll.userHost IS NULL')
|
||||
.andWhere('poll.userId != :meId', { meId: me.id })
|
||||
.andWhere('poll.noteVisibility = \'public\'')
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('poll.expiresAt IS NULL')
|
||||
.orWhere('poll.expiresAt > :now', { now: new Date() });
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('poll.expiresAt IS NULL')
|
||||
.orWhere('poll.expiresAt > :now', { now: new Date() });
|
||||
}));
|
||||
|
||||
//#region exclude arleady voted polls
|
||||
|
@@ -15,6 +15,7 @@ import { DI } from '@/di-symbols.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
@@ -62,8 +63,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private idService: IdService,
|
||||
private cacheService: CacheService,
|
||||
private redisTimelineService: RedisTimelineService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null;
|
||||
const sinceId = ps.sinceId ?? ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null;
|
||||
|
||||
const [
|
||||
followings,
|
||||
userIdsWhoMeMuting,
|
||||
@@ -76,20 +81,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
this.cacheService.userBlockedCache.fetch(me.id),
|
||||
]);
|
||||
|
||||
let timeline: MiNote[] = [];
|
||||
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
let noteIdsRes: [string, string[]][] = [];
|
||||
|
||||
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 ?? '+',
|
||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||
'COUNT', limit);
|
||||
}
|
||||
|
||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
||||
let noteIds = await this.redisTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId);
|
||||
noteIds = noteIds.slice(0, ps.limit);
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
@@ -104,7 +97,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
timeline = await query.getMany();
|
||||
let timeline = await query.getMany();
|
||||
|
||||
timeline = timeline.filter(note => {
|
||||
if (note.userId === me.id) {
|
||||
|
@@ -15,6 +15,7 @@ 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 { RedisTimelineService } from '@/core/RedisTimelineService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -79,8 +80,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private cacheService: CacheService,
|
||||
private idService: IdService,
|
||||
private redisTimelineService: RedisTimelineService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null;
|
||||
const sinceId = ps.sinceId ?? ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null;
|
||||
|
||||
const list = await this.userListsRepository.findOneBy({
|
||||
id: ps.listId,
|
||||
userId: me.id,
|
||||
@@ -100,20 +105,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
this.cacheService.userBlockedCache.fetch(me.id),
|
||||
]);
|
||||
|
||||
let timeline: MiNote[] = [];
|
||||
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 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 ?? '+',
|
||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||
'COUNT', limit);
|
||||
}
|
||||
|
||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
||||
let noteIds = await this.redisTimelineService.get(ps.withFiles ? `userListTimelineWithFiles:${list.id}` : `userListTimeline:${list.id}`, untilId, sinceId);
|
||||
noteIds = noteIds.slice(0, ps.limit);
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
@@ -128,7 +121,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
timeline = await query.getMany();
|
||||
let timeline = await query.getMany();
|
||||
|
||||
timeline = timeline.filter(note => {
|
||||
if (note.userId === me.id) {
|
||||
|
@@ -11,6 +11,7 @@ import { QueryService } from '@/core/QueryService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -65,8 +66,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private idService: IdService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private redisTimelineService: RedisTimelineService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null;
|
||||
const sinceId = ps.sinceId ?? ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null;
|
||||
|
||||
const role = await this.rolesRepository.findOneBy({
|
||||
id: ps.roleId,
|
||||
isPublic: true,
|
||||
@@ -78,18 +83,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
if (!role.isExplorable) {
|
||||
return [];
|
||||
}
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
const noteIdsRes = await this.redisForTimelines.xrevrange(
|
||||
`roleTimeline:${role.id}`,
|
||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||
'COUNT', limit);
|
||||
|
||||
if (noteIdsRes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
||||
let noteIds = await this.redisTimelineService.get(`roleTimeline:${role.id}`, untilId, sinceId);
|
||||
noteIds = noteIds.slice(0, ps.limit);
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
|
@@ -62,9 +62,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
|
||||
.andWhere('assign.roleId = :roleId', { roleId: role.id })
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('assign.expiresAt IS NULL')
|
||||
.orWhere('assign.expiresAt > :now', { now: new Date() });
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('assign.expiresAt IS NULL')
|
||||
.orWhere('assign.expiresAt > :now', { now: new Date() });
|
||||
}))
|
||||
.innerJoinAndSelect('assign.user', 'user');
|
||||
|
||||
|
@@ -50,16 +50,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
let noteIds = await this.featuredService.getPerUserNotesRanking(ps.userId, 50);
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
noteIds.sort((a, b) => a > b ? -1 : 1);
|
||||
if (ps.untilId) {
|
||||
noteIds = noteIds.filter(id => id < ps.untilId!);
|
||||
}
|
||||
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')
|
||||
|
@@ -10,10 +10,11 @@ import type { MiNote, NotesRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -67,90 +68,114 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private getterService: GetterService,
|
||||
private queryService: QueryService,
|
||||
private cacheService: CacheService,
|
||||
private idService: IdService,
|
||||
private redisTimelineService: RedisTimelineService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const [
|
||||
userIdsWhoMeMuting,
|
||||
] = me ? await Promise.all([
|
||||
this.cacheService.userMutingsCache.fetch(me.id),
|
||||
]) : [new Set<string>()];
|
||||
const untilId = ps.untilId ?? ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null;
|
||||
const sinceId = ps.sinceId ?? ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null;
|
||||
const isRangeSpecified = untilId != null && sinceId != null;
|
||||
|
||||
let timeline: MiNote[] = [];
|
||||
if (isRangeSpecified || sinceId == null) {
|
||||
const [
|
||||
userIdsWhoMeMuting,
|
||||
] = me ? await Promise.all([
|
||||
this.cacheService.userMutingsCache.fetch(me.id),
|
||||
]) : [new Set<string>()];
|
||||
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
let noteIdsRes: [string, string[]][] = [];
|
||||
let repliesNoteIdsRes: [string, string[]][] = [];
|
||||
let channelNoteIdsRes: [string, string[]][] = [];
|
||||
|
||||
if (!ps.sinceId && !ps.sinceDate) {
|
||||
[noteIdsRes, repliesNoteIdsRes, channelNoteIdsRes] = await Promise.all([
|
||||
this.redisForTimelines.xrevrange(
|
||||
ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`,
|
||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||
'COUNT', limit),
|
||||
ps.withReplies
|
||||
? this.redisForTimelines.xrevrange(
|
||||
`userTimelineWithReplies:${ps.userId}`,
|
||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||
'COUNT', limit)
|
||||
: Promise.resolve([]),
|
||||
ps.withChannelNotes
|
||||
? this.redisForTimelines.xrevrange(
|
||||
`userTimelineWithChannel:${ps.userId}`,
|
||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||
'COUNT', limit)
|
||||
: Promise.resolve([]),
|
||||
const [noteIdsRes, repliesNoteIdsRes, channelNoteIdsRes] = await Promise.all([
|
||||
this.redisTimelineService.get(ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`, untilId, sinceId),
|
||||
ps.withReplies ? this.redisTimelineService.get(`userTimelineWithReplies:${ps.userId}`, untilId, sinceId) : Promise.resolve([]),
|
||||
ps.withChannelNotes ? this.redisTimelineService.get(`userTimelineWithChannel:${ps.userId}`, untilId, sinceId) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
let noteIds = Array.from(new Set([
|
||||
...noteIdsRes,
|
||||
...repliesNoteIdsRes,
|
||||
...channelNoteIdsRes,
|
||||
]));
|
||||
noteIds.sort((a, b) => a > b ? -1 : 1);
|
||||
noteIds = noteIds.slice(0, ps.limit);
|
||||
|
||||
if (noteIds.length > 0) {
|
||||
const isFollowing = me ? me.id === ps.userId || 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')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
let timeline = await query.getMany();
|
||||
|
||||
timeline = timeline.filter(note => {
|
||||
if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false;
|
||||
|
||||
if (note.renoteId) {
|
||||
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
|
||||
if (ps.withRenotes === false) return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false;
|
||||
if (note.visibility === 'followers' && !isFollowing) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// TODO: フィルタで件数が減った場合の埋め合わせ処理
|
||||
|
||||
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
|
||||
if (timeline.length > 0) {
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let noteIds = Array.from(new Set([
|
||||
...noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId),
|
||||
...repliesNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId),
|
||||
...channelNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId),
|
||||
]));
|
||||
noteIds.sort((a, b) => a > b ? -1 : 1);
|
||||
noteIds = noteIds.slice(0, ps.limit);
|
||||
|
||||
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 })
|
||||
//#region fallback to database
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('note.userId = :userId', { userId: ps.userId })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('note.channel', 'channel')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
timeline = await query.getMany();
|
||||
if (!ps.withChannelNotes) {
|
||||
query.andWhere('note.channelId IS NULL');
|
||||
}
|
||||
|
||||
timeline = timeline.filter(note => {
|
||||
if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false;
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQuery(query, me, { id: ps.userId });
|
||||
this.queryService.generateBlockedUserQuery(query, me);
|
||||
}
|
||||
|
||||
if (note.renoteId) {
|
||||
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
|
||||
if (ps.withRenotes === false) return false;
|
||||
}
|
||||
}
|
||||
if (ps.withFiles) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
}
|
||||
|
||||
if (note.visibility === 'followers' && !isFollowing) return false;
|
||||
if (ps.includeMyRenotes === false) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.userId != :userId', { userId: ps.userId });
|
||||
qb.orWhere('note.renoteId IS NULL');
|
||||
qb.orWhere('note.text IS NOT NULL');
|
||||
qb.orWhere('note.fileIds != \'{}\'');
|
||||
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
||||
}));
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
const timeline = await query.limit(ps.limit).getMany();
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
//#endregion
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -92,9 +92,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.andWhere(`user.id IN (${ followingQuery.getQuery() })`)
|
||||
.andWhere('user.id != :meId', { meId: me.id })
|
||||
.andWhere('user.isSuspended = FALSE')
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('user.updatedAt IS NULL')
|
||||
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('user.updatedAt IS NULL')
|
||||
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
|
||||
}));
|
||||
|
||||
query.setParameters(followingQuery.getParameters());
|
||||
|
@@ -64,9 +64,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
if (isUsername) {
|
||||
const usernameQuery = this.usersRepository.createQueryBuilder('user')
|
||||
.where('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.query.replace('@', '').toLowerCase()) + '%' })
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('user.updatedAt IS NULL')
|
||||
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('user.updatedAt IS NULL')
|
||||
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
|
||||
}))
|
||||
.andWhere('user.isSuspended = FALSE');
|
||||
|
||||
@@ -91,9 +92,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(ps.query.toLowerCase()) + '%' });
|
||||
}
|
||||
}))
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('user.updatedAt IS NULL')
|
||||
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('user.updatedAt IS NULL')
|
||||
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
|
||||
}))
|
||||
.andWhere('user.isSuspended = FALSE');
|
||||
|
||||
@@ -122,9 +124,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
const query = this.usersRepository.createQueryBuilder('user')
|
||||
.where(`user.id IN (${ profQuery.getQuery() })`)
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('user.updatedAt IS NULL')
|
||||
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('user.updatedAt IS NULL')
|
||||
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
|
||||
}))
|
||||
.andWhere('user.isSuspended = FALSE')
|
||||
.setParameters(profQuery.getParameters());
|
||||
|
@@ -16,9 +16,10 @@ import Channel from '../channel.js';
|
||||
|
||||
class GlobalTimelineChannel extends Channel {
|
||||
public readonly chName = 'globalTimeline';
|
||||
public static shouldShare = true;
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = false;
|
||||
private withRenotes: boolean;
|
||||
private withFiles: boolean;
|
||||
|
||||
constructor(
|
||||
private metaService: MetaService,
|
||||
@@ -38,6 +39,7 @@ class GlobalTimelineChannel extends Channel {
|
||||
if (!policies.gtlAvailable) return;
|
||||
|
||||
this.withRenotes = params.withRenotes ?? true;
|
||||
this.withFiles = params.withFiles ?? false;
|
||||
|
||||
// Subscribe events
|
||||
this.subscriber.on('notesStream', this.onNote);
|
||||
@@ -45,6 +47,8 @@ class GlobalTimelineChannel extends Channel {
|
||||
|
||||
@bindThis
|
||||
private async onNote(note: Packed<'Note'>) {
|
||||
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
|
||||
|
||||
if (note.visibility !== 'public') return;
|
||||
if (note.channelId != null) return;
|
||||
|
||||
|
@@ -14,9 +14,10 @@ import Channel from '../channel.js';
|
||||
|
||||
class HomeTimelineChannel extends Channel {
|
||||
public readonly chName = 'homeTimeline';
|
||||
public static shouldShare = true;
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = true;
|
||||
private withRenotes: boolean;
|
||||
private withFiles: boolean;
|
||||
|
||||
constructor(
|
||||
private noteEntityService: NoteEntityService,
|
||||
@@ -31,12 +32,15 @@ class HomeTimelineChannel extends Channel {
|
||||
@bindThis
|
||||
public async init(params: any) {
|
||||
this.withRenotes = params.withRenotes ?? true;
|
||||
this.withFiles = params.withFiles ?? false;
|
||||
|
||||
this.subscriber.on('notesStream', this.onNote);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onNote(note: Packed<'Note'>) {
|
||||
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
|
||||
|
||||
if (note.channelId) {
|
||||
if (!this.followingChannels.has(note.channelId)) return;
|
||||
} else {
|
||||
|
@@ -16,9 +16,10 @@ import Channel from '../channel.js';
|
||||
|
||||
class HybridTimelineChannel extends Channel {
|
||||
public readonly chName = 'hybridTimeline';
|
||||
public static shouldShare = true;
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = true;
|
||||
private withRenotes: boolean;
|
||||
private withFiles: boolean;
|
||||
|
||||
constructor(
|
||||
private metaService: MetaService,
|
||||
@@ -38,6 +39,7 @@ class HybridTimelineChannel extends Channel {
|
||||
if (!policies.ltlAvailable) return;
|
||||
|
||||
this.withRenotes = params.withRenotes ?? true;
|
||||
this.withFiles = params.withFiles ?? false;
|
||||
|
||||
// Subscribe events
|
||||
this.subscriber.on('notesStream', this.onNote);
|
||||
@@ -45,6 +47,8 @@ class HybridTimelineChannel extends Channel {
|
||||
|
||||
@bindThis
|
||||
private async onNote(note: Packed<'Note'>) {
|
||||
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
|
||||
|
||||
// チャンネルの投稿ではなく、自分自身の投稿 または
|
||||
// チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または
|
||||
// チャンネルの投稿ではなく、全体公開のローカルの投稿 または
|
||||
|
@@ -15,9 +15,10 @@ import Channel from '../channel.js';
|
||||
|
||||
class LocalTimelineChannel extends Channel {
|
||||
public readonly chName = 'localTimeline';
|
||||
public static shouldShare = true;
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = false;
|
||||
private withRenotes: boolean;
|
||||
private withFiles: boolean;
|
||||
|
||||
constructor(
|
||||
private metaService: MetaService,
|
||||
@@ -37,6 +38,7 @@ class LocalTimelineChannel extends Channel {
|
||||
if (!policies.ltlAvailable) return;
|
||||
|
||||
this.withRenotes = params.withRenotes ?? true;
|
||||
this.withFiles = params.withFiles ?? false;
|
||||
|
||||
// Subscribe events
|
||||
this.subscriber.on('notesStream', this.onNote);
|
||||
@@ -44,6 +46,8 @@ class LocalTimelineChannel extends Channel {
|
||||
|
||||
@bindThis
|
||||
private async onNote(note: Packed<'Note'>) {
|
||||
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
|
||||
|
||||
if (note.user.host !== null) return;
|
||||
if (note.visibility !== 'public') return;
|
||||
if (note.channelId != null && !this.followingChannels.has(note.channelId)) return;
|
||||
|
@@ -18,8 +18,9 @@ class UserListChannel extends Channel {
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = false;
|
||||
private listId: string;
|
||||
public membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {};
|
||||
private membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {};
|
||||
private listUsersClock: NodeJS.Timeout;
|
||||
private withFiles: boolean;
|
||||
|
||||
constructor(
|
||||
private userListsRepository: UserListsRepository,
|
||||
@@ -37,6 +38,7 @@ class UserListChannel extends Channel {
|
||||
@bindThis
|
||||
public async init(params: any) {
|
||||
this.listId = params.listId as string;
|
||||
this.withFiles = params.withFiles ?? false;
|
||||
|
||||
// Check existence and owner
|
||||
const listExist = await this.userListsRepository.exist({
|
||||
@@ -76,6 +78,8 @@ class UserListChannel extends Channel {
|
||||
|
||||
@bindThis
|
||||
private async onNote(note: Packed<'Note'>) {
|
||||
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
|
||||
|
||||
if (!Object.hasOwn(this.membershipsMap, note.userId)) return;
|
||||
|
||||
if (['followers', 'specified'].includes(note.visibility)) {
|
||||
|
@@ -3,6 +3,9 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
// How to run:
|
||||
// pnpm jest -- e2e/timelines.ts
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING = 'true';
|
||||
|
||||
@@ -378,6 +381,104 @@ describe('Timelines', () => {
|
||||
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
|
||||
});
|
||||
|
||||
test.concurrent('自分の visibility: specified なノートが含まれる', async () => {
|
||||
const [alice] = await Promise.all([signup()]);
|
||||
|
||||
const aliceNote = await post(alice, { text: 'hi', visibility: 'specified' });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
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('フォローしているユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれる', async () => {
|
||||
const [alice, bob] = await Promise.all([signup(), signup()]);
|
||||
|
||||
await api('/following/create', { userId: bob.id }, alice);
|
||||
await sleep(1000);
|
||||
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
test.concurrent('フォローしていないユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれない', async () => {
|
||||
const [alice, bob] = await Promise.all([signup(), signup()]);
|
||||
|
||||
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
const res = await api('/notes/timeline', {}, alice);
|
||||
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
|
||||
});
|
||||
|
||||
test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定していない visibility: specified なノートが含まれない', async () => {
|
||||
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
|
||||
|
||||
await api('/following/create', { userId: bob.id }, alice);
|
||||
await sleep(1000);
|
||||
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
const res = await api('/notes/timeline', {}, alice);
|
||||
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
|
||||
});
|
||||
|
||||
test.concurrent('フォローしていないユーザーからの visibility: specified なノートに返信したときの自身のノートが含まれる', async () => {
|
||||
const [alice, bob] = await Promise.all([signup(), signup()]);
|
||||
|
||||
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] });
|
||||
const aliceNote = await post(alice, { text: 'ok', visibility: 'specified', visibleUserIds: [bob.id], replyId: bobNote.id });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
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, 'ok');
|
||||
});
|
||||
|
||||
/* TODO
|
||||
test.concurrent('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれる', async () => {
|
||||
const [alice, bob] = await Promise.all([signup(), signup()]);
|
||||
|
||||
const aliceNote = await post(alice, { text: 'hi', visibility: 'specified', visibleUserIds: [bob.id] });
|
||||
const bobNote = await post(bob, { text: 'ok', visibility: 'specified', visibleUserIds: [alice.id], replyId: aliceNote.id });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
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, 'ok');
|
||||
});
|
||||
*/
|
||||
|
||||
// ↑の挙動が理想だけど実装が面倒かも
|
||||
test.concurrent('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれない', async () => {
|
||||
const [alice, bob] = await Promise.all([signup(), signup()]);
|
||||
|
||||
const aliceNote = await post(alice, { text: 'hi', visibility: 'specified', visibleUserIds: [bob.id] });
|
||||
const bobNote = await post(bob, { text: 'ok', visibility: 'specified', visibleUserIds: [alice.id], replyId: aliceNote.id });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
const res = await api('/notes/timeline', {}, alice);
|
||||
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Local TL', () => {
|
||||
@@ -630,7 +731,6 @@ describe('Timelines', () => {
|
||||
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()]);
|
||||
|
||||
@@ -645,23 +745,6 @@ describe('Timelines', () => {
|
||||
|
||||
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);
|
||||
await sleep(1000);
|
||||
const bobNote = await post(bob, { text: 'hi', visibility: 'followers' });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
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()]);
|
||||
@@ -778,6 +861,38 @@ describe('Timelines', () => {
|
||||
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);
|
||||
|
||||
test.concurrent('リスインしているユーザーの自身宛ての visibility: specified なノートが含まれる', 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);
|
||||
await sleep(1000);
|
||||
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
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('リスインしているユーザーの自身宛てではない visibility: specified なノートが含まれない', 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/push', { listId: list.id, userId: carol.id }, alice);
|
||||
await sleep(1000);
|
||||
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
|
||||
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User TL', () => {
|
||||
@@ -820,6 +935,19 @@ describe('Timelines', () => {
|
||||
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi');
|
||||
});
|
||||
|
||||
test.concurrent('自身の visibility: followers なノートが含まれる', async () => {
|
||||
const [alice] = await Promise.all([signup()]);
|
||||
|
||||
const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
const res = await api('/users/notes', { userId: alice.id }, 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] = await Promise.all([signup(), signup()]);
|
||||
|
||||
@@ -938,6 +1066,30 @@ describe('Timelines', () => {
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === bobNote3.id), true);
|
||||
});
|
||||
|
||||
test.concurrent('自身の visibility: specified なノートが含まれる', async () => {
|
||||
const [alice] = await Promise.all([signup()]);
|
||||
|
||||
const aliceNote = await post(alice, { text: 'hi', visibility: 'specified' });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
const res = await api('/users/notes', { userId: alice.id, withReplies: true }, alice);
|
||||
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true);
|
||||
});
|
||||
|
||||
test.concurrent('visibleUserIds に指定されてない visibility: specified なノートが含まれない', async () => {
|
||||
const [alice, bob] = await Promise.all([signup(), signup()]);
|
||||
|
||||
const bobNote = await post(bob, { text: 'hi', visibility: 'specified' });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
const res = await api('/users/notes', { userId: bob.id, withReplies: true }, alice);
|
||||
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: リノートミュート済みユーザーのテスト
|
||||
|
88
packages/backend/test/unit/misc/loader.ts
Normal file
88
packages/backend/test/unit/misc/loader.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { DebounceLoader } from '@/misc/loader.js';
|
||||
|
||||
class Mock {
|
||||
loadCountByKey = new Map<number, number>();
|
||||
load = async (key: number): Promise<number> => {
|
||||
const count = this.loadCountByKey.get(key);
|
||||
if (typeof count === 'undefined') {
|
||||
this.loadCountByKey.set(key, 1);
|
||||
} else {
|
||||
this.loadCountByKey.set(key, count + 1);
|
||||
}
|
||||
return key * 2;
|
||||
};
|
||||
reset() {
|
||||
this.loadCountByKey.clear();
|
||||
}
|
||||
}
|
||||
|
||||
describe(DebounceLoader, () => {
|
||||
describe('single request', () => {
|
||||
it('loads once', async () => {
|
||||
const mock = new Mock();
|
||||
const loader = new DebounceLoader(mock.load);
|
||||
expect(await loader.load(7)).toBe(14);
|
||||
expect(mock.loadCountByKey.size).toBe(1);
|
||||
expect(mock.loadCountByKey.get(7)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('two duplicated requests at same time', () => {
|
||||
it('loads once', async () => {
|
||||
const mock = new Mock();
|
||||
const loader = new DebounceLoader(mock.load);
|
||||
const [v1, v2] = await Promise.all([
|
||||
loader.load(7),
|
||||
loader.load(7),
|
||||
]);
|
||||
expect(v1).toBe(14);
|
||||
expect(v2).toBe(14);
|
||||
expect(mock.loadCountByKey.size).toBe(1);
|
||||
expect(mock.loadCountByKey.get(7)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('two different requests at same time', () => {
|
||||
it('loads twice', async () => {
|
||||
const mock = new Mock();
|
||||
const loader = new DebounceLoader(mock.load);
|
||||
const [v1, v2] = await Promise.all([
|
||||
loader.load(7),
|
||||
loader.load(13),
|
||||
]);
|
||||
expect(v1).toBe(14);
|
||||
expect(v2).toBe(26);
|
||||
expect(mock.loadCountByKey.size).toBe(2);
|
||||
expect(mock.loadCountByKey.get(7)).toBe(1);
|
||||
expect(mock.loadCountByKey.get(13)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-continuous same two requests', () => {
|
||||
it('loads twice', async () => {
|
||||
const mock = new Mock();
|
||||
const loader = new DebounceLoader(mock.load);
|
||||
expect(await loader.load(7)).toBe(14);
|
||||
expect(mock.loadCountByKey.size).toBe(1);
|
||||
expect(mock.loadCountByKey.get(7)).toBe(1);
|
||||
mock.reset();
|
||||
expect(await loader.load(7)).toBe(14);
|
||||
expect(mock.loadCountByKey.size).toBe(1);
|
||||
expect(mock.loadCountByKey.get(7)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-continuous different two requests', () => {
|
||||
it('loads twice', async () => {
|
||||
const mock = new Mock();
|
||||
const loader = new DebounceLoader(mock.load);
|
||||
expect(await loader.load(7)).toBe(14);
|
||||
expect(mock.loadCountByKey.size).toBe(1);
|
||||
expect(mock.loadCountByKey.get(7)).toBe(1);
|
||||
mock.reset();
|
||||
expect(await loader.load(13)).toBe(26);
|
||||
expect(mock.loadCountByKey.size).toBe(1);
|
||||
expect(mock.loadCountByKey.get(13)).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
@@ -38,7 +38,7 @@
|
||||
"chartjs-chart-matrix": "2.0.1",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
"chartjs-plugin-zoom": "2.0.1",
|
||||
"chromatic": "7.2.2",
|
||||
"chromatic": "7.2.3",
|
||||
"compare-versions": "6.1.0",
|
||||
"cropperjs": "2.0.0-beta.4",
|
||||
"date-fns": "2.30.0",
|
||||
@@ -57,7 +57,7 @@
|
||||
"prismjs": "1.29.0",
|
||||
"punycode": "2.3.0",
|
||||
"querystring": "0.2.1",
|
||||
"rollup": "4.0.0",
|
||||
"rollup": "4.0.2",
|
||||
"sanitize-html": "2.11.0",
|
||||
"sass": "1.69.0",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
@@ -101,12 +101,12 @@
|
||||
"@types/estree": "1.0.2",
|
||||
"@types/matter-js": "0.19.1",
|
||||
"@types/micromatch": "4.0.3",
|
||||
"@types/node": "20.8.2",
|
||||
"@types/node": "20.8.3",
|
||||
"@types/punycode": "2.1.0",
|
||||
"@types/sanitize-html": "2.9.1",
|
||||
"@types/throttle-debounce": "5.0.0",
|
||||
"@types/tinycolor2": "1.4.4",
|
||||
"@types/uuid": "9.0.4",
|
||||
"@types/uuid": "9.0.5",
|
||||
"@types/websocket": "1.0.7",
|
||||
"@types/ws": "8.5.6",
|
||||
"@typescript-eslint/eslint-plugin": "6.7.4",
|
||||
@@ -116,7 +116,7 @@
|
||||
"acorn": "8.10.0",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "13.3.0",
|
||||
"eslint": "8.50.0",
|
||||
"eslint": "8.51.0",
|
||||
"eslint-plugin-import": "2.28.1",
|
||||
"eslint-plugin-vue": "9.17.0",
|
||||
"fast-glob": "3.3.1",
|
||||
@@ -135,7 +135,7 @@
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "0.34.6",
|
||||
"vitest-fetch-mock": "0.2.2",
|
||||
"vue-eslint-parser": "9.3.1",
|
||||
"vue-tsc": "1.8.15"
|
||||
"vue-eslint-parser": "9.3.2",
|
||||
"vue-tsc": "1.8.18"
|
||||
}
|
||||
}
|
||||
|
@@ -23,6 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:spellcheck="spellcheck"
|
||||
:step="step"
|
||||
:list="id"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
@keydown="onKeydown($event)"
|
||||
@@ -59,6 +61,8 @@ const props = defineProps<{
|
||||
spellcheck?: boolean;
|
||||
step?: any;
|
||||
datalist?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
inline?: boolean;
|
||||
debounce?: boolean;
|
||||
manualSave?: boolean;
|
||||
|
@@ -17,7 +17,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:title="media.name"
|
||||
controls
|
||||
preload="metadata"
|
||||
@volumechange="volumechange"
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
@@ -33,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { onMounted, shallowRef, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { soundConfigStore } from '@/scripts/sound.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
@@ -43,15 +42,13 @@ const props = withDefaults(defineProps<{
|
||||
}>(), {
|
||||
});
|
||||
|
||||
const audioEl = $shallowRef<HTMLAudioElement | null>();
|
||||
const audioEl = shallowRef<HTMLAudioElement>();
|
||||
let hide = $ref(true);
|
||||
|
||||
function volumechange() {
|
||||
if (audioEl) soundConfigStore.set('mediaVolume', audioEl.volume);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (audioEl) audioEl.volume = soundConfigStore.state.mediaVolume;
|
||||
watch(audioEl, () => {
|
||||
if (audioEl.value) {
|
||||
audioEl.value.volume = 0.3;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
<div v-else :class="[$style.visible, (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitiveContainer]">
|
||||
<video
|
||||
ref="videoEl"
|
||||
:class="$style.video"
|
||||
:poster="video.thumbnailUrl"
|
||||
:title="video.comment"
|
||||
@@ -31,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, shallowRef, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
@@ -42,6 +43,14 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'));
|
||||
|
||||
const videoEl = shallowRef<HTMLVideoElement>();
|
||||
|
||||
watch(videoEl, () => {
|
||||
if (videoEl.value) {
|
||||
videoEl.value.volume = 0.3;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
@@ -13,6 +13,7 @@ import MkNotes from '@/components/MkNotes.vue';
|
||||
import { useStream } from '@/stream.js';
|
||||
import * as sound from '@/scripts/sound.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -38,7 +39,15 @@ provide('inChannel', computed(() => props.src === 'channel'));
|
||||
|
||||
const tlComponent: InstanceType<typeof MkNotes> = $ref();
|
||||
|
||||
let tlNotesCount = 0;
|
||||
|
||||
const prepend = note => {
|
||||
tlNotesCount++;
|
||||
|
||||
if (instance.notesPerOneAd > 0 && tlNotesCount % instance.notesPerOneAd === 0) {
|
||||
note._shouldInsertAd_ = true;
|
||||
}
|
||||
|
||||
tlComponent.pagingComponent?.prepend(note);
|
||||
|
||||
emit('note');
|
||||
@@ -129,12 +138,10 @@ if (props.src === 'antenna') {
|
||||
} else if (props.src === 'list') {
|
||||
endpoint = 'notes/user-list-timeline';
|
||||
query = {
|
||||
withRenotes: props.withRenotes,
|
||||
withFiles: props.onlyFiles ? true : undefined,
|
||||
listId: props.list,
|
||||
};
|
||||
connection = stream.useChannel('userList', {
|
||||
withRenotes: props.withRenotes,
|
||||
withFiles: props.onlyFiles ? true : undefined,
|
||||
listId: props.list,
|
||||
});
|
||||
|
@@ -187,6 +187,9 @@ const patronsWithIcon = [{
|
||||
}, {
|
||||
name: 'フランギ・シュウ',
|
||||
icon: 'https://misskey-hub.net/patrons/3016d37e35f3430b90420176c912d304.jpg',
|
||||
}, {
|
||||
name: '百日紅',
|
||||
icon: 'https://misskey-hub.net/patrons/302dce2898dd457ba03c3f7dc037900b.jpg',
|
||||
}];
|
||||
|
||||
const patrons = [
|
||||
|
@@ -107,6 +107,22 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkInput>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection>
|
||||
<template #label>{{ i18n.ts._ad.adsSettings }}</template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<div class="_gaps_s">
|
||||
<MkInput v-model="notesPerOneAd" :min="0" type="number">
|
||||
<template #label>{{ i18n.ts._ad.notesPerOneAd }}</template>
|
||||
<template #caption>{{ i18n.ts._ad.setZeroToDisable }}</template>
|
||||
</MkInput>
|
||||
<MkInfo v-if="notesPerOneAd > 0 && notesPerOneAd < 20" :warn="true">
|
||||
{{ i18n.ts._ad.adsTooClose }}
|
||||
</MkInfo>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
</div>
|
||||
</FormSuspense>
|
||||
</MkSpacer>
|
||||
@@ -127,6 +143,7 @@ import XHeader from './_header_.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import FormSection from '@/components/form/section.vue';
|
||||
import FormSplit from '@/components/form/split.vue';
|
||||
import FormSuspense from '@/components/form/suspense.vue';
|
||||
@@ -152,6 +169,7 @@ let perLocalUserUserTimelineCacheMax: number = $ref(0);
|
||||
let perRemoteUserUserTimelineCacheMax: number = $ref(0);
|
||||
let perUserHomeTimelineCacheMax: number = $ref(0);
|
||||
let perUserListTimelineCacheMax: number = $ref(0);
|
||||
let notesPerOneAd: number = $ref(0);
|
||||
|
||||
async function init(): Promise<void> {
|
||||
const meta = await os.api('admin/meta');
|
||||
@@ -171,10 +189,11 @@ async function init(): Promise<void> {
|
||||
perRemoteUserUserTimelineCacheMax = meta.perRemoteUserUserTimelineCacheMax;
|
||||
perUserHomeTimelineCacheMax = meta.perUserHomeTimelineCacheMax;
|
||||
perUserListTimelineCacheMax = meta.perUserListTimelineCacheMax;
|
||||
notesPerOneAd = meta.notesPerOneAd;
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
os.apiWithDialog('admin/update-meta', {
|
||||
async function save(): void {
|
||||
await os.apiWithDialog('admin/update-meta', {
|
||||
name,
|
||||
shortName: shortName === '' ? null : shortName,
|
||||
description,
|
||||
@@ -191,9 +210,10 @@ function save(): void {
|
||||
perRemoteUserUserTimelineCacheMax,
|
||||
perUserHomeTimelineCacheMax,
|
||||
perUserListTimelineCacheMax,
|
||||
}).then(() => {
|
||||
fetchInstance();
|
||||
notesPerOneAd,
|
||||
});
|
||||
|
||||
fetchInstance();
|
||||
}
|
||||
|
||||
const headerTabs = $computed(() => []);
|
||||
|
@@ -286,8 +286,7 @@ definePageMetadata(computed(() => {
|
||||
let title = i18n.ts._pages.newPage;
|
||||
if (props.initPageId) {
|
||||
title = i18n.ts._pages.editPage;
|
||||
}
|
||||
else if (props.initPageName && props.initUser) {
|
||||
} else if (props.initPageName && props.initUser) {
|
||||
title = i18n.ts._pages.readPage;
|
||||
}
|
||||
return {
|
||||
|
@@ -7,10 +7,6 @@ import { markRaw } from 'vue';
|
||||
import { Storage } from '@/pizzax.js';
|
||||
|
||||
export const soundConfigStore = markRaw(new Storage('sound', {
|
||||
mediaVolume: {
|
||||
where: 'device',
|
||||
default: 0.5,
|
||||
},
|
||||
sound_masterVolume: {
|
||||
where: 'device',
|
||||
default: 0.3,
|
||||
|
@@ -5,7 +5,11 @@
|
||||
|
||||
import { ref } from 'vue';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { globalEvents } from '@/events';
|
||||
import { deepClone } from './clone.js';
|
||||
import { globalEvents } from '@/events.js';
|
||||
import lightTheme from '@/themes/_light.json5';
|
||||
import darkTheme from '@/themes/_dark.json5';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
|
||||
export type Theme = {
|
||||
id: string;
|
||||
@@ -16,11 +20,6 @@ export type Theme = {
|
||||
props: Record<string, string>;
|
||||
};
|
||||
|
||||
import lightTheme from '@/themes/_light.json5';
|
||||
import darkTheme from '@/themes/_dark.json5';
|
||||
import { deepClone } from './clone';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
|
||||
export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
|
||||
|
||||
export const getBuiltinThemes = () => Promise.all(
|
||||
@@ -101,18 +100,11 @@ export function applyTheme(theme: Theme, persist = true) {
|
||||
|
||||
function compile(theme: Theme): Record<string, string> {
|
||||
function getColor(val: string): tinycolor.Instance {
|
||||
// ref (prop)
|
||||
if (val[0] === '@') {
|
||||
if (val[0] === '@') { // ref (prop)
|
||||
return getColor(theme.props[val.substring(1)]);
|
||||
}
|
||||
|
||||
// ref (const)
|
||||
else if (val[0] === '$') {
|
||||
} else if (val[0] === '$') { // ref (const)
|
||||
return getColor(theme.props[val]);
|
||||
}
|
||||
|
||||
// func
|
||||
else if (val[0] === ':') {
|
||||
} else if (val[0] === ':') { // func
|
||||
const parts = val.split('<');
|
||||
const func = parts.shift().substring(1);
|
||||
const arg = parseFloat(parts.shift());
|
||||
|
@@ -2448,6 +2448,7 @@ type LiteInstanceMetadata = {
|
||||
url: string;
|
||||
imageUrl: string;
|
||||
}[];
|
||||
notesPerOneAd: number;
|
||||
translatorAvailable: boolean;
|
||||
serverRules: string[];
|
||||
};
|
||||
@@ -2754,6 +2755,9 @@ type Notification_2 = {
|
||||
invitation: UserGroup;
|
||||
user: User;
|
||||
userId: User['id'];
|
||||
} | {
|
||||
type: 'achievementEarned';
|
||||
achievement: string;
|
||||
} | {
|
||||
type: 'app';
|
||||
header?: string | null;
|
||||
@@ -2764,7 +2768,7 @@ type Notification_2 = {
|
||||
});
|
||||
|
||||
// @public (undocumented)
|
||||
export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app"];
|
||||
export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "achievementEarned"];
|
||||
|
||||
// @public (undocumented)
|
||||
type OriginType = 'combined' | 'local' | 'remote';
|
||||
@@ -2980,7 +2984,7 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
|
||||
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
|
||||
// src/api.types.ts:630:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
|
||||
// src/entities.ts:107:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts
|
||||
// src/entities.ts:596:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
|
||||
// src/entities.ts:600:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
|
||||
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
@@ -23,10 +23,10 @@
|
||||
"@microsoft/api-extractor": "7.38.0",
|
||||
"@swc/jest": "0.2.29",
|
||||
"@types/jest": "29.5.5",
|
||||
"@types/node": "20.8.2",
|
||||
"@types/node": "20.8.3",
|
||||
"@typescript-eslint/eslint-plugin": "6.7.4",
|
||||
"@typescript-eslint/parser": "6.7.4",
|
||||
"eslint": "8.50.0",
|
||||
"eslint": "8.51.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-fetch-mock": "3.0.3",
|
||||
"jest-websocket-mock": "2.5.0",
|
||||
|
@@ -67,8 +67,7 @@ export class APIClient {
|
||||
IsCaseMatched<E, P, 8> extends true ? GetCaseResult<E, P, 8> :
|
||||
IsCaseMatched<E, P, 9> extends true ? GetCaseResult<E, P, 9> :
|
||||
Endpoints[E]['res']['$switch']['$default']
|
||||
: Endpoints[E]['res']>
|
||||
{
|
||||
: Endpoints[E]['res']> {
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
this.fetch(`${this.origin}/api/${endpoint}`, {
|
||||
method: 'POST',
|
||||
|
@@ -1,4 +1,4 @@
|
||||
export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'] as const;
|
||||
export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'achievementEarned'] as const;
|
||||
|
||||
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;
|
||||
|
||||
|
@@ -277,6 +277,9 @@ export type Notification = {
|
||||
invitation: UserGroup;
|
||||
user: User;
|
||||
userId: User['id'];
|
||||
} | {
|
||||
type: 'achievementEarned';
|
||||
achievement: string;
|
||||
} | {
|
||||
type: 'app';
|
||||
header?: string | null;
|
||||
@@ -362,6 +365,7 @@ export type LiteInstanceMetadata = {
|
||||
url: string;
|
||||
imageUrl: string;
|
||||
}[];
|
||||
notesPerOneAd: number;
|
||||
translatorAvailable: boolean;
|
||||
serverRules: string[];
|
||||
};
|
||||
|
@@ -38,6 +38,9 @@ module.exports = {
|
||||
'before': true,
|
||||
'after': true,
|
||||
}],
|
||||
'brace-style': ['error', '1tbs', {
|
||||
'allowSingleLine': true,
|
||||
}],
|
||||
'padded-blocks': ['error', 'never'],
|
||||
/* TODO: path aliasを使わないとwarnする
|
||||
'no-restricted-imports': ['warn', {
|
||||
|
@@ -16,7 +16,7 @@
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/parser": "6.7.4",
|
||||
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67",
|
||||
"eslint": "8.50.0",
|
||||
"eslint": "8.51.0",
|
||||
"eslint-plugin-import": "2.28.1",
|
||||
"typescript": "5.2.2"
|
||||
},
|
||||
|
@@ -15,7 +15,7 @@ import { getUrlWithLoginId } from '@/scripts/login-id.js';
|
||||
export const cli = new Misskey.api.APIClient({ origin, fetch: (...args): Promise<Response> => fetch(...args) });
|
||||
|
||||
export async function api<E extends keyof Misskey.Endpoints, O extends Misskey.Endpoints[E]['req']>(endpoint: E, userId?: string, options?: O): Promise<void | ReturnType<typeof cli.request<E, O>>> {
|
||||
let account: { token: string; id: string } | void;
|
||||
let account: { token: string; id: string } | void = undefined;
|
||||
|
||||
if (userId) {
|
||||
account = await getAccountFromId(userId);
|
||||
|
549
pnpm-lock.yaml
generated
549
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user