Merge remote-tracking branch 'misskey-original/develop' into develop

# Conflicts:
#	package.json
#	packages/backend/src/server/api/stream/channels/global-timeline.ts
#	packages/backend/src/server/api/stream/channels/home-timeline.ts
#	packages/backend/src/server/api/stream/channels/hybrid-timeline.ts
#	packages/backend/src/server/api/stream/channels/local-timeline.ts
This commit is contained in:
mattyatea 2023-10-09 19:46:43 +09:00
commit dd4ecf845c
39 changed files with 576 additions and 283 deletions

View File

@ -51,6 +51,8 @@
- Fix: 連合なしアンケートに投票をするとUpdateがリモートに配信されてしまうのを修正 - Fix: 連合なしアンケートに投票をするとUpdateがリモートに配信されてしまうのを修正
- Fix: nodeinfoにおいてCORS用のヘッダーが設定されていないのを修正 - Fix: nodeinfoにおいてCORS用のヘッダーが設定されていないのを修正
- Fix: 同じ種類のTLのストリーミングを複数接続できない問題を修正 - Fix: 同じ種類のTLのストリーミングを複数接続できない問題を修正
- Fix: アンテナTLを途中までしかページネーションできなくなることがある問題を修正
- Fix: 「ファイル付きのみ」のTLでファイル無しの新着ートが流れる問題を修正
## 2023.9.3 ## 2023.9.3
### General ### General

View File

@ -1129,6 +1129,12 @@ fileAttachedOnly: "Nur Notizen mit Dateien"
showRepliesToOthersInTimeline: "Antworten in Chronik anzeigen" showRepliesToOthersInTimeline: "Antworten in Chronik anzeigen"
hideRepliesToOthersInTimeline: "Antworten nicht in Chronik anzeigen" hideRepliesToOthersInTimeline: "Antworten nicht in Chronik anzeigen"
externalServices: "Externe Dienste" 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: _announcement:
forExistingUsers: "Nur für existierende Nutzer" forExistingUsers: "Nur für existierende Nutzer"
forExistingUsersDescription: "Ist diese Option aktiviert, wird diese Ankündigung nur Nutzern angezeigt, die zum Zeitpunkt der Ankündigung bereits registriert sind. Ist sie deaktiviert, wird sie auch Nutzern, die sich nach dessen Veröffentlichung registrieren, angezeigt." forExistingUsersDescription: "Ist diese Option aktiviert, wird diese Ankündigung nur Nutzern angezeigt, die zum Zeitpunkt der Ankündigung bereits registriert sind. Ist sie deaktiviert, wird sie auch Nutzern, die sich nach dessen Veröffentlichung registrieren, angezeigt."
@ -1527,6 +1533,10 @@ _ad:
reduceFrequencyOfThisAd: "Diese Werbung weniger anzeigen" reduceFrequencyOfThisAd: "Diese Werbung weniger anzeigen"
hide: "Ausblenden" hide: "Ausblenden"
timezoneinfo: "Der Wochentag wird durch die Serverzeitzone bestimmt." 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: _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." 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." ifNoEmail: "Solltest du bei der Registrierung keine Email-Adresse angegeben haben, wende dich bitte an den Administrator."

View File

@ -1136,6 +1136,12 @@ fileAttachedOnly: "Only notes with files"
showRepliesToOthersInTimeline: "Show replies to others in TL" showRepliesToOthersInTimeline: "Show replies to others in TL"
hideRepliesToOthersInTimeline: "Hide replies to others from TL" hideRepliesToOthersInTimeline: "Hide replies to others from TL"
externalServices: "External Services" 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: _announcement:
forExistingUsers: "Existing users only" forExistingUsers: "Existing users only"
forExistingUsersDescription: "This announcement will only be shown to users existing at the point of publishment if enabled. If disabled, those newly signing up after it has been posted will also see it." forExistingUsersDescription: "This announcement will only be shown to users existing at the point of publishment if enabled. If disabled, those newly signing up after it has been posted will also see it."
@ -1534,6 +1540,10 @@ _ad:
reduceFrequencyOfThisAd: "Show this ad less" reduceFrequencyOfThisAd: "Show this ad less"
hide: "Hide" hide: "Hide"
timezoneinfo: "The day of the week is determined from the server's timezone." 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: _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." 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." ifNoEmail: "If you did not use an email during registration, please contact the instance administrator instead."

View File

@ -64,7 +64,7 @@ reply: "Rispondi"
loadMore: "Mostra di più" loadMore: "Mostra di più"
showMore: "Espandi" showMore: "Espandi"
showLess: "Comprimi" showLess: "Comprimi"
youGotNewFollower: "Ti sta seguendo" youGotNewFollower: "Adesso ti segue"
receiveFollowRequest: "Hai ricevuto una richiesta di follow" receiveFollowRequest: "Hai ricevuto una richiesta di follow"
followRequestAccepted: "Ha accettato la tua richiesta di follow" followRequestAccepted: "Ha accettato la tua richiesta di follow"
mention: "Menzioni" mention: "Menzioni"
@ -336,7 +336,7 @@ instanceName: "Nome dell'istanza"
instanceDescription: "Descrizione dell'istanza" instanceDescription: "Descrizione dell'istanza"
maintainerName: "Nome dell'amministratore" maintainerName: "Nome dell'amministratore"
maintainerEmail: "Indirizzo e-mail 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" thisYear: "Anno"
thisMonth: "Mese" thisMonth: "Mese"
today: "Oggi" today: "Oggi"
@ -1129,6 +1129,12 @@ fileAttachedOnly: "Con file in allegato"
showRepliesToOthersInTimeline: "Risposte altrui nella TL" showRepliesToOthersInTimeline: "Risposte altrui nella TL"
hideRepliesToOthersInTimeline: "Nascondi Riposte altrui nella TL" hideRepliesToOthersInTimeline: "Nascondi Riposte altrui nella TL"
externalServices: "Servizi esterni" 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: _announcement:
forExistingUsers: "Solo ai profili attuali" 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." 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ù" unlike: "Non mi piace più"
_email: _email:
_follow: _follow:
title: "Ha iniziato a seguirti" title: "Adesso ti segue"
_receiveFollowRequest: _receiveFollowRequest:
title: "Hai ricevuto una richiesta di follow" title: "Hai ricevuto una richiesta di follow"
_plugin: _plugin:
@ -2019,7 +2025,7 @@ _notification:
youGotReply: "{name} ti ha risposto" youGotReply: "{name} ti ha risposto"
youGotQuote: "{name} ha citato la tua Nota e ha detto" youGotQuote: "{name} ha citato la tua Nota e ha detto"
youRenoted: "{name} ha rinotato" youRenoted: "{name} ha rinotato"
youWereFollowed: "Ha iniziato a seguirti" youWereFollowed: "Adesso ti segue"
youReceivedFollowRequest: "Hai ricevuto una richiesta di follow" youReceivedFollowRequest: "Hai ricevuto una richiesta di follow"
yourFollowRequestAccepted: "La tua richiesta di follow è stata accettata" yourFollowRequestAccepted: "La tua richiesta di follow è stata accettata"
pollEnded: "Risultati del sondaggio." pollEnded: "Risultati del sondaggio."

View File

@ -1129,6 +1129,12 @@ fileAttachedOnly: "包含附件"
showRepliesToOthersInTimeline: "在時間軸上顯示給其他人的回覆" showRepliesToOthersInTimeline: "在時間軸上顯示給其他人的回覆"
hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆" hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆"
externalServices: "外部服務" externalServices: "外部服務"
impressum: "營運者資訊"
impressumUrl: "營運者資訊網址"
impressumDescription: "在德國與部份地區必須要明確顯示營運者資訊。"
privacyPolicy: "隱私政策"
privacyPolicyUrl: "隱私政策網址"
tosAndPrivacyPolicy: "服務條款和隱私政策"
_announcement: _announcement:
forExistingUsers: "僅限既有的使用者" forExistingUsers: "僅限既有的使用者"
forExistingUsersDescription: "啟用代表僅向現存使用者顯示;停用代表張貼後註冊的新使用者也會看到。" forExistingUsersDescription: "啟用代表僅向現存使用者顯示;停用代表張貼後註冊的新使用者也會看到。"
@ -1527,6 +1533,10 @@ _ad:
reduceFrequencyOfThisAd: "降低此廣告的頻率 " reduceFrequencyOfThisAd: "降低此廣告的頻率 "
hide: "隱藏" hide: "隱藏"
timezoneinfo: "星期幾是由伺服器的時區指定的。" timezoneinfo: "星期幾是由伺服器的時區指定的。"
adsSettings: "廣告投放設定"
notesPerOneAd: "即時更新中投放廣告的間隔(貼文數)"
setZeroToDisable: "設為 0 則在即時更新時不投放廣告"
adsTooClose: "由於廣告投放的間隔極短,可能會嚴重影響使用者體驗。"
_forgotPassword: _forgotPassword:
enterEmail: "請輸入您的帳戶註冊的電子郵件地址。 密碼重置連結將被發送到該電子郵件地址。" enterEmail: "請輸入您的帳戶註冊的電子郵件地址。 密碼重置連結將被發送到該電子郵件地址。"
ifNoEmail: "如果您還沒有註冊您的電子郵件地址,請聯繫管理員。 " ifNoEmail: "如果您還沒有註冊您的電子郵件地址,請聯繫管理員。 "

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2023.10.0-beta.9-prismisskey.1", "version": "2023.10.0-beta.12",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
@ -18,7 +18,6 @@
"build-assets": "node ./scripts/build-assets.mjs", "build-assets": "node ./scripts/build-assets.mjs",
"build": "pnpm build-pre && pnpm -r build && pnpm build-assets", "build": "pnpm build-pre && pnpm -r build && pnpm build-assets",
"build-storybook": "pnpm --filter frontend build-storybook", "build-storybook": "pnpm --filter frontend build-storybook",
"build-and-start": "pnpm run init && pnpm build && pnpm start",
"start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js", "start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js",
"start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js", "start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js",
"init": "pnpm migrate", "init": "pnpm migrate",

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

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

View File

@ -228,7 +228,7 @@ export class AccountMoveService {
}, },
}).then(memberships => memberships.map(membership => membership.userListId)); }).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を生成 // 重複しないようにIDを生成
const genId = (): string => { const genId = (): string => {
@ -244,6 +244,7 @@ export class AccountMoveService {
createdAt: new Date(), createdAt: new Date(),
userId: dst.id, userId: dst.id,
userListId: membership.userListId, userListId: membership.userListId,
userListUserId: membership.userListUserId,
}); });
} }

View File

@ -77,6 +77,9 @@ export class AntennaService implements OnApplicationShutdown {
@bindThis @bindThis
public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; }): Promise<void> { public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; }): Promise<void> {
// リモートから遅れて届いた(もしくは後から追加された)投稿日時が古い投稿が追加されるとページネーション時に問題を引き起こすため、3分以内に投稿されたもののみを追加する
if (Date.now() - note.createdAt.getTime() > 1000 * 60 * 3) return;
const antennas = await this.getAntennas(); const antennas = await this.getAntennas();
const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const))); const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const)));
const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna); const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna);

View File

@ -43,16 +43,16 @@ export class FeaturedService {
} }
@bindThis @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 currentWindow = this.getCurrentWindow(windowRange);
const previousWindow = currentWindow - 1; const previousWindow = currentWindow - 1;
const [currentRankingResult, previousRankingResult] = await Promise.all([ const redisPipeline = this.redisClient.pipeline();
this.redisClient.zrange( redisPipeline.zrange(
`${name}:${currentWindow}`, 0, limit, 'REV', 'WITHSCORES'), `${name}:${currentWindow}`, 0, threshold, 'REV', 'WITHSCORES');
this.redisClient.zrange( redisPipeline.zrange(
`${name}:${previousWindow}`, 0, limit, 'REV', 'WITHSCORES'), `${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>(); const ranking = new Map<string, number>();
for (let i = 0; i < currentRankingResult.length; i += 2) { for (let i = 0; i < currentRankingResult.length; i += 2) {
@ -95,22 +95,22 @@ export class FeaturedService {
} }
@bindThis @bindThis
public getGlobalNotesRanking(limit: number): Promise<MiNote['id'][]> { public getGlobalNotesRanking(threshold: number): Promise<MiNote['id'][]> {
return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, limit); return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, threshold);
} }
@bindThis @bindThis
public getInChannelNotesRanking(channelId: MiNote['channelId'], limit: number): Promise<MiNote['id'][]> { public getInChannelNotesRanking(channelId: MiNote['channelId'], threshold: number): Promise<MiNote['id'][]> {
return this.getRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, limit); return this.getRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, threshold);
} }
@bindThis @bindThis
public getPerUserNotesRanking(userId: MiUser['id'], limit: number): Promise<MiNote['id'][]> { public getPerUserNotesRanking(userId: MiUser['id'], threshold: number): Promise<MiNote['id'][]> {
return this.getRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, limit); return this.getRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, threshold);
} }
@bindThis @bindThis
public getHashtagsRanking(limit: number): Promise<string[]> { public getHashtagsRanking(threshold: number): Promise<string[]> {
return this.getRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, limit); return this.getRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, threshold);
} }
} }

View File

@ -494,11 +494,7 @@ export class NoteCreateService implements OnApplicationShutdown {
// Increment notes count (user) // Increment notes count (user)
this.incNotesCountOfUser(user); this.incNotesCountOfUser(user);
if (data.visibility === 'specified') {
// TODO?
} else {
this.pushToTl(note, user); this.pushToTl(note, user);
}
this.antennaService.addNoteToAntennas(note, user); this.antennaService.addNoteToAntennas(note, user);
@ -826,6 +822,10 @@ export class NoteCreateService implements OnApplicationShutdown {
@bindThis @bindThis
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
// リモートから遅れて届いた(もしくは後から追加された)投稿日時が古い投稿が追加されるとページネーション時に問題を引き起こすため、3分以内に投稿されたもののみを追加する
// TODO: https://github.com/misskey-dev/misskey/issues/11404#issuecomment-1752480890 をやる
if (note.userHost != null && (Date.now() - note.createdAt.getTime()) > 1000 * 60 * 3) return;
const meta = await this.metaService.fetch(); const meta = await this.metaService.fetch();
const redisPipeline = this.redisForTimelines.pipeline(); const redisPipeline = this.redisForTimelines.pipeline();
@ -861,24 +861,34 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
} else { } else {
// TODO: キャッシュ? // TODO: キャッシュ?
const followings = await this.followingsRepository.find({ // eslint-disable-next-line prefer-const
let [followings, userListMemberships] = await Promise.all([
this.followingsRepository.find({
where: { where: {
followeeId: user.id, followeeId: user.id,
followerHost: IsNull(), followerHost: IsNull(),
isFollowerHibernated: false, isFollowerHibernated: false,
}, },
select: ['followerId', 'withReplies'], select: ['followerId', 'withReplies'],
}); }),
this.userListMembershipsRepository.find({
const userListMemberships = await this.userListMembershipsRepository.find({
where: { where: {
userId: user.id, userId: user.id,
}, },
select: ['userListId', 'withReplies'], select: ['userListId', 'userListUserId', 'withReplies'],
}); }),
]);
if (note.visibility === 'followers') {
// TODO: 重そうだから何とかしたい Set 使う?
userListMemberships = userListMemberships.filter(x => followings.some(f => f.followerId === x.userListUserId));
}
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする // TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
for (const following of followings) { 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 (note.replyId && note.replyUserId !== note.userId) {
if (!following.withReplies) continue; if (!following.withReplies) continue;
@ -899,13 +909,13 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
} }
// TODO
//if (note.visibility === 'followers') {
// // TODO: 重そうだから何とかしたい Set 使う?
// userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListUserId));
//}
for (const userListMembership of userListMemberships) { 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 (note.replyId && note.replyUserId !== note.userId) {
if (!userListMembership.withReplies) continue; if (!userListMembership.withReplies) continue;
@ -926,7 +936,7 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
} }
{ // 自分自身のHTL if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { // 自分自身のHTL
redisPipeline.xadd( redisPipeline.xadd(
`homeTimeline:${user.id}`, `homeTimeline:${user.id}`,
'MAXLEN', '~', meta.perUserHomeTimelineCacheMax.toString(), 'MAXLEN', '~', meta.perUserHomeTimelineCacheMax.toString(),

View File

@ -9,6 +9,7 @@ import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js'; import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import type { SelectQueryBuilder } from 'typeorm'; import type { SelectQueryBuilder } from 'typeorm';
@Injectable() @Injectable()
@ -34,6 +35,8 @@ export class QueryService {
@Inject(DI.renoteMutingsRepository) @Inject(DI.renoteMutingsRepository)
private renoteMutingsRepository: RenoteMutingsRepository, private renoteMutingsRepository: RenoteMutingsRepository,
private idService: IdService,
) { ) {
} }
@ -49,15 +52,15 @@ export class QueryService {
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
q.orderBy(`${q.alias}.id`, 'DESC'); q.orderBy(`${q.alias}.id`, 'DESC');
} else if (sinceDate && untilDate) { } else if (sinceDate && untilDate) {
q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) }); q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.genId(new Date(sinceDate)) });
q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) }); q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.genId(new Date(untilDate)) });
q.orderBy(`${q.alias}.createdAt`, 'DESC'); q.orderBy(`${q.alias}.id`, 'DESC');
} else if (sinceDate) { } else if (sinceDate) {
q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) }); q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.genId(new Date(sinceDate)) });
q.orderBy(`${q.alias}.createdAt`, 'ASC'); q.orderBy(`${q.alias}.id`, 'ASC');
} else if (untilDate) { } else if (untilDate) {
q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) }); q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.genId(new Date(untilDate)) });
q.orderBy(`${q.alias}.createdAt`, 'DESC'); q.orderBy(`${q.alias}.id`, 'DESC');
} else { } else {
q.orderBy(`${q.alias}.id`, 'DESC'); q.orderBy(`${q.alias}.id`, 'DESC');
} }
@ -76,11 +79,13 @@ export class QueryService {
// 投稿の引用元の作者にブロックされていない // 投稿の引用元の作者にブロックされていない
q q
.andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`) .andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`)
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.where('note.replyUserId IS NULL') .where('note.replyUserId IS NULL')
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`); .orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
})) }))
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.where('note.renoteUserId IS NULL') .where('note.renoteUserId IS NULL')
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`); .orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
})); }));
@ -112,7 +117,8 @@ export class QueryService {
.where('threadMuted.userId = :userId', { userId: me.id }); .where('threadMuted.userId = :userId', { userId: me.id });
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
q.andWhere(new Brackets(qb => { qb q.andWhere(new Brackets(qb => {
qb
.where('note.threadId IS NULL') .where('note.threadId IS NULL')
.orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`); .orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
})); }));
@ -121,7 +127,7 @@ export class QueryService {
} }
@bindThis @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') const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
.select('muting.muteeId') .select('muting.muteeId')
.where('muting.muterId = :muterId', { muterId: me.id }); .where('muting.muterId = :muterId', { muterId: me.id });
@ -139,24 +145,29 @@ export class QueryService {
// 投稿の引用元の作者をミュートしていない // 投稿の引用元の作者をミュートしていない
q q
.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`) .andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`)
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.where('note.replyUserId IS NULL') .where('note.replyUserId IS NULL')
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`); .orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
})) }))
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.where('note.renoteUserId IS NULL') .where('note.renoteUserId IS NULL')
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`); .orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
})) }))
// mute instances // mute instances
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.andWhere('note.userHost IS NULL') .andWhere('note.userHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`); .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
})) }))
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.where('note.replyUserHost IS NULL') .where('note.replyUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`); .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
})) }))
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.where('note.renoteUserHost IS NULL') .where('note.renoteUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`); .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
})); }));
@ -180,7 +191,8 @@ export class QueryService {
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void { public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void {
// This code must always be synchronized with the checks in Notes.isVisibleForMe. // This code must always be synchronized with the checks in Notes.isVisibleForMe.
if (me == null) { if (me == null) {
q.andWhere(new Brackets(qb => { qb q.andWhere(new Brackets(qb => {
qb
.where('note.visibility = \'public\'') .where('note.visibility = \'public\'')
.orWhere('note.visibility = \'home\''); .orWhere('note.visibility = \'home\'');
})); }));
@ -189,9 +201,11 @@ export class QueryService {
.select('following.followeeId') .select('following.followeeId')
.where('following.followerId = :meId'); .where('following.followerId = :meId');
q.andWhere(new Brackets(qb => { qb q.andWhere(new Brackets(qb => {
qb
// 公開投稿である // 公開投稿である
.where(new Brackets(qb => { qb .where(new Brackets(qb => {
qb
.where('note.visibility = \'public\'') .where('note.visibility = \'public\'')
.orWhere('note.visibility = \'home\''); .orWhere('note.visibility = \'home\'');
})) }))
@ -200,10 +214,12 @@ export class QueryService {
// または 自分宛て // または 自分宛て
.orWhere(':meId = ANY(note.visibleUserIds)') .orWhere(':meId = ANY(note.visibleUserIds)')
.orWhere(':meId = ANY(note.mentions)') .orWhere(':meId = ANY(note.mentions)')
.orWhere(new Brackets(qb => { qb .orWhere(new Brackets(qb => {
qb
// または フォロワー宛ての投稿であり、 // または フォロワー宛ての投稿であり、
.where('note.visibility = \'followers\'') .where('note.visibility = \'followers\'')
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
// 自分がフォロワーである // 自分がフォロワーである
.where(`note.userId IN (${ followingQuery.getQuery() })`) .where(`note.userId IN (${ followingQuery.getQuery() })`)
// または 自分の投稿へのリプライ // または 自分の投稿へのリプライ

View File

@ -97,6 +97,7 @@ export class UserListService implements OnApplicationShutdown {
createdAt: new Date(), createdAt: new Date(),
userId: target.id, userId: target.id,
userListId: list.id, userListId: list.id,
userListUserId: list.userId,
} as MiUserListMembership); } as MiUserListMembership);
this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id }); this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id });

View File

@ -33,7 +33,8 @@ export class RoleEntityService {
const assignedCount = await this.roleAssignmentsRepository.createQueryBuilder('assign') const assignedCount = await this.roleAssignmentsRepository.createQueryBuilder('assign')
.where('assign.roleId = :roleId', { roleId: role.id }) .where('assign.roleId = :roleId', { roleId: role.id })
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.where('assign.expiresAt IS NULL') .where('assign.expiresAt IS NULL')
.orWhere('assign.expiresAt > :now', { now: new Date() }); .orWhere('assign.expiresAt > :now', { now: new Date() });
})) }))

View File

@ -50,4 +50,11 @@ export class MiUserListMembership {
default: false, default: false,
}) })
public withReplies: boolean; public withReplies: boolean;
//#region Denormalized fields
@Column({
...id(),
})
public userListUserId: MiUser['id'];
//#endregion
} }

View File

@ -379,7 +379,8 @@ export class ActivityPubServerService {
if (page) { if (page) {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId) const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId)
.andWhere('note.userId = :userId', { userId: user.id }) .andWhere('note.userId = :userId', { userId: user.id })
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.where('note.visibility = \'public\'') .where('note.visibility = \'public\'')
.orWhere('note.visibility = \'home\''); .orWhere('note.visibility = \'home\'');
})) }))

View File

@ -61,7 +61,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId) const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
.andWhere('assign.roleId = :roleId', { roleId: role.id }) .andWhere('assign.roleId = :roleId', { roleId: role.id })
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.where('assign.expiresAt IS NULL') .where('assign.expiresAt IS NULL')
.orWhere('assign.expiresAt > :now', { now: new Date() }); .orWhere('assign.expiresAt > :now', { now: new Date() });
})) }))

View File

@ -55,7 +55,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.query !== '') { if (ps.query !== '') {
if (ps.type === 'nameAndDescription') { if (ps.type === 'nameAndDescription') {
query.andWhere(new Brackets(qb => { qb query.andWhere(new Brackets(qb => {
qb
.where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }) .where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
.orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); .orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
})); }));

View File

@ -49,11 +49,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.where('note.replyId = :noteId', { noteId: ps.noteId }) .where('note.replyId = :noteId', { noteId: ps.noteId })
.orWhere(new Brackets(qb => { qb .orWhere(new Brackets(qb => {
qb
.where('note.renoteId = :noteId', { noteId: ps.noteId }) .where('note.renoteId = :noteId', { noteId: ps.noteId })
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.where('note.text IS NOT NULL') .where('note.text IS NOT NULL')
.orWhere('note.fileIds != \'{}\'') .orWhere('note.fileIds != \'{}\'')
.orWhere('note.hasPoll = TRUE'); .orWhere('note.hasPoll = TRUE');

View File

@ -59,7 +59,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.where('following.followerId = :followerId', { followerId: me.id }); .where('following.followerId = :followerId', { followerId: me.id });
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.where(`'{"${me.id}"}' <@ note.mentions`) .where(`'{"${me.id}"}' <@ note.mentions`)
.orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`); .orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`);
})) }))

View File

@ -57,7 +57,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.where('poll.userHost IS NULL') .where('poll.userHost IS NULL')
.andWhere('poll.userId != :meId', { meId: me.id }) .andWhere('poll.userId != :meId', { meId: me.id })
.andWhere('poll.noteVisibility = \'public\'') .andWhere('poll.noteVisibility = \'public\'')
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.where('poll.expiresAt IS NULL') .where('poll.expiresAt IS NULL')
.orWhere('poll.expiresAt > :now', { now: new Date() }); .orWhere('poll.expiresAt > :now', { now: new Date() });
})); }));

View File

@ -62,7 +62,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId) const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
.andWhere('assign.roleId = :roleId', { roleId: role.id }) .andWhere('assign.roleId = :roleId', { roleId: role.id })
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.where('assign.expiresAt IS NULL') .where('assign.expiresAt IS NULL')
.orWhere('assign.expiresAt > :now', { now: new Date() }); .orWhere('assign.expiresAt > :now', { now: new Date() });
})) }))

View File

@ -72,6 +72,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService, private idService: IdService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const isRangeSpecified = (ps.sinceId != null || ps.sinceDate != null) && (ps.untilId != null || ps.untilDate != null);
if (isRangeSpecified || !(ps.sinceId != null || ps.sinceDate != null)) {
const [ const [
userIdsWhoMeMuting, userIdsWhoMeMuting,
] = me ? await Promise.all([ ] = me ? await Promise.all([
@ -114,7 +117,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
noteIds = noteIds.slice(0, ps.limit); noteIds = noteIds.slice(0, ps.limit);
if (noteIds.length > 0) { if (noteIds.length > 0) {
const isFollowing = me ? Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId) : false; 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') const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds }) .where('note.id IN (:...noteIds)', { noteIds: noteIds })
@ -136,6 +139,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
} }
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; if (note.visibility === 'followers' && !isFollowing) return false;
return true; return true;
@ -143,8 +147,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
timeline.sort((a, b) => a.id > b.id ? -1 : 1); timeline.sort((a, b) => a.id > b.id ? -1 : 1);
if (timeline.length > 0) {
return await this.noteEntityService.packMany(timeline, me); return await this.noteEntityService.packMany(timeline, me);
} else { }
}
}
// fallback to database // fallback to database
//#region Construct query //#region Construct query
@ -162,6 +170,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
this.queryService.generateVisibilityQuery(query, me); this.queryService.generateVisibilityQuery(query, me);
if (me) {
this.queryService.generateMutedUserQuery(query, me, { id: ps.userId });
this.queryService.generateBlockedUserQuery(query, me);
}
if (ps.withFiles) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); query.andWhere('note.fileIds != \'{}\'');
@ -181,7 +193,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const timeline = await query.limit(ps.limit).getMany(); const timeline = await query.limit(ps.limit).getMany();
return await this.noteEntityService.packMany(timeline, me); return await this.noteEntityService.packMany(timeline, me);
}
}); });
} }
} }

View File

@ -92,7 +92,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.andWhere(`user.id IN (${ followingQuery.getQuery() })`) .andWhere(`user.id IN (${ followingQuery.getQuery() })`)
.andWhere('user.id != :meId', { meId: me.id }) .andWhere('user.id != :meId', { meId: me.id })
.andWhere('user.isSuspended = FALSE') .andWhere('user.isSuspended = FALSE')
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.where('user.updatedAt IS NULL') .where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
})); }));

View File

@ -64,7 +64,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (isUsername) { if (isUsername) {
const usernameQuery = this.usersRepository.createQueryBuilder('user') const usernameQuery = this.usersRepository.createQueryBuilder('user')
.where('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.query.replace('@', '').toLowerCase()) + '%' }) .where('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.query.replace('@', '').toLowerCase()) + '%' })
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.where('user.updatedAt IS NULL') .where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
})) }))
@ -91,7 +92,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(ps.query.toLowerCase()) + '%' }); qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(ps.query.toLowerCase()) + '%' });
} }
})) }))
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.where('user.updatedAt IS NULL') .where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
})) }))
@ -122,7 +124,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.usersRepository.createQueryBuilder('user') const query = this.usersRepository.createQueryBuilder('user')
.where(`user.id IN (${ profQuery.getQuery() })`) .where(`user.id IN (${ profQuery.getQuery() })`)
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
qb
.where('user.updatedAt IS NULL') .where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
})) }))

View File

@ -41,7 +41,7 @@ class GlobalTimelineChannel extends Channel {
this.withReplies = params.withReplies ?? false; this.withReplies = params.withReplies ?? false;
this.withRenotes = params.withRenotes ?? true; this.withRenotes = params.withRenotes ?? true;
this.withFiles = params.withFiles as boolean; this.withFiles = params.withFiles ?? false;
// Subscribe events // Subscribe events
this.subscriber.on('notesStream', this.onNote); this.subscriber.on('notesStream', this.onNote);

View File

@ -18,8 +18,9 @@ class UserListChannel extends Channel {
public static shouldShare = false; public static shouldShare = false;
public static requireCredential = false; public static requireCredential = false;
private listId: string; private listId: string;
public membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {}; private membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {};
private listUsersClock: NodeJS.Timeout; private listUsersClock: NodeJS.Timeout;
private withFiles: boolean;
constructor( constructor(
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@ -37,6 +38,7 @@ class UserListChannel extends Channel {
@bindThis @bindThis
public async init(params: any) { public async init(params: any) {
this.listId = params.listId as string; this.listId = params.listId as string;
this.withFiles = params.withFiles ?? false;
// Check existence and owner // Check existence and owner
const listExist = await this.userListsRepository.exist({ const listExist = await this.userListsRepository.exist({
@ -76,6 +78,8 @@ class UserListChannel extends Channel {
@bindThis @bindThis
private async onNote(note: Packed<'Note'>) { 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 (!Object.hasOwn(this.membershipsMap, note.userId)) return;
if (['followers', 'specified'].includes(note.visibility)) { if (['followers', 'specified'].includes(note.visibility)) {

View File

@ -3,6 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
// How to run:
// pnpm jest -- e2e/timelines.ts
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING = 'true'; 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); 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', () => { describe('Local TL', () => {
@ -630,7 +731,6 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
}); });
/*
test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => { test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]); 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); 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 () => { test.concurrent('リスインしているフォローしていないユーザーの他人への返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); 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 === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
}, 1000 * 10); }, 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', () => { describe('User TL', () => {
@ -820,6 +935,19 @@ describe('Timelines', () => {
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); 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 () => { test.concurrent('チャンネル投稿が含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]); 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 === bobNote2.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote3.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: リノートミュート済みユーザーのテスト // TODO: リノートミュート済みユーザーのテスト

View File

@ -162,12 +162,10 @@ if (props.src === 'antenna') {
} else if (props.src === 'list') { } else if (props.src === 'list') {
endpoint = 'notes/user-list-timeline'; endpoint = 'notes/user-list-timeline';
query = { query = {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
listId: props.list, listId: props.list,
}; };
connection = stream.useChannel('userList', { connection = stream.useChannel('userList', {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
listId: props.list, listId: props.list,
}); });

View File

@ -187,6 +187,9 @@ const patronsWithIcon = [{
}, { }, {
name: 'フランギ・シュウ', name: 'フランギ・シュウ',
icon: 'https://misskey-hub.net/patrons/3016d37e35f3430b90420176c912d304.jpg', icon: 'https://misskey-hub.net/patrons/3016d37e35f3430b90420176c912d304.jpg',
}, {
name: '百日紅',
icon: 'https://misskey-hub.net/patrons/302dce2898dd457ba03c3f7dc037900b.jpg',
}]; }];
const patrons = [ const patrons = [

View File

@ -286,8 +286,7 @@ definePageMetadata(computed(() => {
let title = i18n.ts._pages.newPage; let title = i18n.ts._pages.newPage;
if (props.initPageId) { if (props.initPageId) {
title = i18n.ts._pages.editPage; title = i18n.ts._pages.editPage;
} } else if (props.initPageName && props.initUser) {
else if (props.initPageName && props.initUser) {
title = i18n.ts._pages.readPage; title = i18n.ts._pages.readPage;
} }
return { return {

View File

@ -5,7 +5,11 @@
import { ref } from 'vue'; import { ref } from 'vue';
import tinycolor from 'tinycolor2'; 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 = { export type Theme = {
id: string; id: string;
@ -16,11 +20,6 @@ export type Theme = {
props: Record<string, string>; 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 themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
export const getBuiltinThemes = () => Promise.all( export const getBuiltinThemes = () => Promise.all(
@ -101,18 +100,11 @@ export function applyTheme(theme: Theme, persist = true) {
function compile(theme: Theme): Record<string, string> { function compile(theme: Theme): Record<string, string> {
function getColor(val: string): tinycolor.Instance { function getColor(val: string): tinycolor.Instance {
// ref (prop) if (val[0] === '@') { // ref (prop)
if (val[0] === '@') {
return getColor(theme.props[val.substring(1)]); return getColor(theme.props[val.substring(1)]);
} } else if (val[0] === '$') { // ref (const)
// ref (const)
else if (val[0] === '$') {
return getColor(theme.props[val]); return getColor(theme.props[val]);
} } else if (val[0] === ':') { // func
// func
else if (val[0] === ':') {
const parts = val.split('<'); const parts = val.split('<');
const func = parts.shift().substring(1); const func = parts.shift().substring(1);
const arg = parseFloat(parts.shift()); const arg = parseFloat(parts.shift());

View File

@ -2755,6 +2755,9 @@ type Notification_2 = {
invitation: UserGroup; invitation: UserGroup;
user: User; user: User;
userId: User['id']; userId: User['id'];
} | {
type: 'achievementEarned';
achievement: string;
} | { } | {
type: 'app'; type: 'app';
header?: string | null; header?: string | null;
@ -2765,7 +2768,7 @@ type Notification_2 = {
}); });
// @public (undocumented) // @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) // @public (undocumented)
type OriginType = 'combined' | 'local' | 'remote'; type OriginType = 'combined' | 'local' | 'remote';
@ -2981,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: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/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:107:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts
// src/entities.ts:597: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 // 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) // (No @packageDocumentation comment for this package)

View File

@ -67,8 +67,7 @@ export class APIClient {
IsCaseMatched<E, P, 8> extends true ? GetCaseResult<E, P, 8> : IsCaseMatched<E, P, 8> extends true ? GetCaseResult<E, P, 8> :
IsCaseMatched<E, P, 9> extends true ? GetCaseResult<E, P, 9> : IsCaseMatched<E, P, 9> extends true ? GetCaseResult<E, P, 9> :
Endpoints[E]['res']['$switch']['$default'] Endpoints[E]['res']['$switch']['$default']
: Endpoints[E]['res']> : Endpoints[E]['res']> {
{
const promise = new Promise((resolve, reject) => { const promise = new Promise((resolve, reject) => {
this.fetch(`${this.origin}/api/${endpoint}`, { this.fetch(`${this.origin}/api/${endpoint}`, {
method: 'POST', method: 'POST',

View File

@ -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; export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;

View File

@ -278,6 +278,9 @@ export type Notification = {
invitation: UserGroup; invitation: UserGroup;
user: User; user: User;
userId: User['id']; userId: User['id'];
} | {
type: 'achievementEarned';
achievement: string;
} | { } | {
type: 'app'; type: 'app';
header?: string | null; header?: string | null;

View File

@ -38,6 +38,9 @@ module.exports = {
'before': true, 'before': true,
'after': true, 'after': true,
}], }],
'brace-style': ['error', '1tbs', {
'allowSingleLine': true,
}],
'padded-blocks': ['error', 'never'], 'padded-blocks': ['error', 'never'],
/* TODO: path alias使warn /* TODO: path alias使warn
'no-restricted-imports': ['warn', { 'no-restricted-imports': ['warn', {

View File

@ -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 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>>> { 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) { if (userId) {
account = await getAccountFromId(userId); account = await getAccountFromId(userId);