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:
commit
dd4ecf845c
39 changed files with 576 additions and 283 deletions
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 ''`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,9 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
|
||||
@bindThis
|
||||
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 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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -494,11 +494,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);
|
||||
|
||||
|
|
@ -826,6 +822,10 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
|
||||
@bindThis
|
||||
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 redisPipeline = this.redisForTimelines.pipeline();
|
||||
|
|
@ -861,24 +861,34 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
} 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;
|
||||
|
|
@ -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) {
|
||||
// ダイレクトのとき、そのリストが対象外のユーザーの場合
|
||||
if (
|
||||
note.visibility === 'specified' &&
|
||||
!note.visibleUserIds.some(v => v === userListMembership.userListUserId)
|
||||
) continue;
|
||||
|
||||
// 自分自身以外への返信
|
||||
if (note.replyId && note.replyUserId !== note.userId) {
|
||||
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(
|
||||
`homeTimeline:${user.id}`,
|
||||
'MAXLEN', '~', meta.perUserHomeTimelineCacheMax.toString(),
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }%` });
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -72,116 +72,127 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const [
|
||||
userIdsWhoMeMuting,
|
||||
] = me ? await Promise.all([
|
||||
this.cacheService.userMutingsCache.fetch(me.id),
|
||||
]) : [new Set<string>()];
|
||||
const isRangeSpecified = (ps.sinceId != null || ps.sinceDate != null) && (ps.untilId != null || ps.untilDate != null);
|
||||
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
if (isRangeSpecified || !(ps.sinceId != null || ps.sinceDate != null)) {
|
||||
const [
|
||||
userIdsWhoMeMuting,
|
||||
] = me ? await Promise.all([
|
||||
this.cacheService.userMutingsCache.fetch(me.id),
|
||||
]) : [new Set<string>()];
|
||||
|
||||
const [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,
|
||||
).then(res => res.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId)),
|
||||
ps.withReplies
|
||||
? this.redisForTimelines.xrevrange(
|
||||
`userTimelineWithReplies:${ps.userId}`,
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
|
||||
const [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,
|
||||
).then(res => res.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId))
|
||||
: 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,
|
||||
).then(res => res.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId))
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
).then(res => res.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId)),
|
||||
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,
|
||||
).then(res => res.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId))
|
||||
: 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,
|
||||
).then(res => res.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.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);
|
||||
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 ? Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId) : false;
|
||||
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');
|
||||
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();
|
||||
let timeline = await query.getMany();
|
||||
|
||||
timeline = timeline.filter(note => {
|
||||
if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false;
|
||||
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.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;
|
||||
});
|
||||
|
||||
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
|
||||
if (timeline.length > 0) {
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
}
|
||||
|
||||
if (note.visibility === 'followers' && !isFollowing) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
} else {
|
||||
// fallback to database
|
||||
|
||||
//#region Construct query
|
||||
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');
|
||||
|
||||
if (!ps.withChannelNotes) {
|
||||
query.andWhere('note.channelId IS NULL');
|
||||
}
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
|
||||
if (ps.withFiles) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
}
|
||||
|
||||
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)');
|
||||
}));
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const timeline = await query.limit(ps.limit).getMany();
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
}
|
||||
|
||||
// fallback to database
|
||||
|
||||
//#region Construct query
|
||||
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');
|
||||
|
||||
if (!ps.withChannelNotes) {
|
||||
query.andWhere('note.channelId IS NULL');
|
||||
}
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQuery(query, me, { id: ps.userId });
|
||||
this.queryService.generateBlockedUserQuery(query, me);
|
||||
}
|
||||
|
||||
if (ps.withFiles) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
}
|
||||
|
||||
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)');
|
||||
}));
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const timeline = await query.limit(ps.limit).getMany();
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class GlobalTimelineChannel extends Channel {
|
|||
|
||||
this.withReplies = params.withReplies ?? false;
|
||||
this.withRenotes = params.withRenotes ?? true;
|
||||
this.withFiles = params.withFiles as boolean;
|
||||
this.withFiles = params.withFiles ?? false;
|
||||
|
||||
// Subscribe events
|
||||
this.subscriber.on('notesStream', this.onNote);
|
||||
|
|
|
|||
|
|
@ -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: リノートミュート済みユーザーのテスト
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue