wip
This commit is contained in:
20
packages/backend/migration/1696222183852-withReplies.js
Normal file
20
packages/backend/migration/1696222183852-withReplies.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class WithReplies1696222183852 {
|
||||
name = 'WithReplies1696222183852'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "following" ADD "withReplies" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`ALTER TABLE "user_list_joining" ADD "withReplies" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_d74d8ab5efa7e3bb82825c0fa2" ON "following" ("followeeId", "followerHost") `);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_d74d8ab5efa7e3bb82825c0fa2"`);
|
||||
await queryRunner.query(`ALTER TABLE "user_list_joining" DROP COLUMN "withReplies"`);
|
||||
await queryRunner.query(`ALTER TABLE "following" DROP COLUMN "withReplies"`);
|
||||
}
|
||||
}
|
@@ -5,7 +5,7 @@
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js';
|
||||
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
||||
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
@@ -25,7 +25,7 @@ export class CacheService implements OnApplicationShutdown {
|
||||
public userBlockingCache: RedisKVCache<Set<string>>;
|
||||
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
|
||||
public renoteMutingsCache: RedisKVCache<Set<string>>;
|
||||
public userFollowingsCache: RedisKVCache<Set<string>>;
|
||||
public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>;
|
||||
public userFollowingChannelsCache: RedisKVCache<Set<string>>;
|
||||
|
||||
constructor(
|
||||
@@ -136,12 +136,18 @@ export class CacheService implements OnApplicationShutdown {
|
||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
||||
});
|
||||
|
||||
this.userFollowingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowings', {
|
||||
this.userFollowingsCache = new RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>(this.redisClient, 'userFollowings', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
memoryCacheLifetime: 1000 * 60, // 1m
|
||||
fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))),
|
||||
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
|
||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
||||
fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId', 'withReplies'] }).then(xs => {
|
||||
const obj: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
|
||||
for (const x of xs) {
|
||||
obj[x.followeeId] = { withReplies: x.withReplies };
|
||||
}
|
||||
return obj;
|
||||
}),
|
||||
toRedisConverter: (value) => JSON.stringify(value),
|
||||
fromRedisConverter: (value) => JSON.parse(value),
|
||||
});
|
||||
|
||||
this.userFollowingChannelsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowingChannels', {
|
||||
|
@@ -805,15 +805,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
|
||||
if (note.replyId && note.replyUserId !== note.userId) {
|
||||
if (note.visibility === 'public' || note.visibility === 'home') {
|
||||
redisPipeline.xadd(
|
||||
`userTimelineWithReplies:${user.id}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
} else if (note.channelId) {
|
||||
if (note.channelId) {
|
||||
const channelFollowings = await this.channelFollowingsRepository.find({
|
||||
where: {
|
||||
followeeId: note.channelId,
|
||||
@@ -845,7 +837,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
followeeId: user.id,
|
||||
followerHost: IsNull(),
|
||||
},
|
||||
select: ['followerId'],
|
||||
select: ['followerId', 'withReplies'],
|
||||
});
|
||||
|
||||
let userLists = await this.userListJoiningsRepository.find({
|
||||
@@ -857,6 +849,11 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
|
||||
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
|
||||
for (const following of followings) {
|
||||
// 自分自身以外への返信
|
||||
if (note.replyId && note.replyUserId !== note.userId) {
|
||||
if (!following.withReplies) continue;
|
||||
}
|
||||
|
||||
redisPipeline.xadd(
|
||||
`homeTimeline:${following.followerId}`,
|
||||
'MAXLEN', '~', '200',
|
||||
@@ -878,6 +875,11 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
}
|
||||
|
||||
for (const userList of userLists) {
|
||||
// 自分自身以外への返信
|
||||
if (note.replyId && note.replyUserId !== note.userId) {
|
||||
if (!userList.withReplies) continue;
|
||||
}
|
||||
|
||||
redisPipeline.xadd(
|
||||
`userListTimeline:${userList.userListId}`,
|
||||
'MAXLEN', '~', '200',
|
||||
@@ -893,49 +895,60 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
}
|
||||
}
|
||||
|
||||
redisPipeline.xadd(
|
||||
`homeTimeline:${user.id}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
if (note.fileIds.length > 0) {
|
||||
{ // 自分自身のHTL
|
||||
redisPipeline.xadd(
|
||||
`homeTimelineWithFiles:${user.id}`,
|
||||
'MAXLEN', '~', '100',
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
|
||||
if (note.visibility === 'public' || note.visibility === 'home') {
|
||||
redisPipeline.xadd(
|
||||
`userTimeline:${user.id}`,
|
||||
`homeTimeline:${user.id}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`userTimelineWithFiles:${user.id}`,
|
||||
`homeTimelineWithFiles:${user.id}`,
|
||||
'MAXLEN', '~', '100',
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (note.userHost == null) {
|
||||
if (note.visibility === 'public' || note.visibility === 'home') {
|
||||
// 自分自身以外への返信
|
||||
if (note.replyId && note.replyUserId !== note.userId) {
|
||||
redisPipeline.xadd(
|
||||
'localTimeline',
|
||||
'MAXLEN', '~', '1000',
|
||||
`userTimelineWithReplies:${user.id}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
} else {
|
||||
redisPipeline.xadd(
|
||||
`userTimeline:${user.id}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
'localTimelineWithFiles',
|
||||
'MAXLEN', '~', '500',
|
||||
`userTimelineWithFiles:${user.id}`,
|
||||
'MAXLEN', '~', '100',
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
|
||||
if (note.userHost == null) {
|
||||
redisPipeline.xadd(
|
||||
'localTimeline',
|
||||
'MAXLEN', '~', '1000',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
'localTimelineWithFiles',
|
||||
'MAXLEN', '~', '500',
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -99,19 +99,19 @@ export class NotificationService implements OnApplicationShutdown {
|
||||
}
|
||||
|
||||
if (recieveConfig?.type === 'following') {
|
||||
const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId));
|
||||
const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId));
|
||||
if (!isFollowing) {
|
||||
return null;
|
||||
}
|
||||
} else if (recieveConfig?.type === 'follower') {
|
||||
const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId));
|
||||
const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId));
|
||||
if (!isFollower) {
|
||||
return null;
|
||||
}
|
||||
} else if (recieveConfig?.type === 'mutualFollow') {
|
||||
const [isFollowing, isFollower] = await Promise.all([
|
||||
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)),
|
||||
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)),
|
||||
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
|
||||
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
|
||||
]);
|
||||
if (!isFollowing && !isFollower) {
|
||||
return null;
|
||||
|
@@ -487,6 +487,7 @@ export class UserEntityService implements OnModuleInit {
|
||||
isMuted: relation.isMuted,
|
||||
isRenoteMuted: relation.isRenoteMuted,
|
||||
notify: relation.following?.notify ?? 'none',
|
||||
withReplies: relation.following?.withReplies ?? false,
|
||||
} : {}),
|
||||
} as Promiseable<Packed<'User'>> as Promiseable<IsMeAndIsUserDetailed<ExpectsMe, D>>;
|
||||
|
||||
|
@@ -9,6 +9,7 @@ import { MiUser } from './User.js';
|
||||
|
||||
@Entity('following')
|
||||
@Index(['followerId', 'followeeId'], { unique: true })
|
||||
@Index(['followeeId', 'followerHost'])
|
||||
export class MiFollowing {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
@@ -45,6 +46,12 @@ export class MiFollowing {
|
||||
@JoinColumn()
|
||||
public follower: MiUser | null;
|
||||
|
||||
// タイムラインにその人のリプライまで含めるかどうか
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public withReplies: boolean;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 32,
|
||||
|
@@ -44,4 +44,10 @@ export class MiUserListJoining {
|
||||
})
|
||||
@JoinColumn()
|
||||
public userList: MiUserList | null;
|
||||
|
||||
// タイムラインにその人のリプライまで含めるかどうか
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public withReplies: boolean;
|
||||
}
|
||||
|
@@ -277,6 +277,10 @@ export const packedUserDetailedNotMeOnlySchema = {
|
||||
type: 'string',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
withReplies: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
//#endregion
|
||||
},
|
||||
} as const;
|
||||
|
@@ -57,8 +57,9 @@ export const paramDef = {
|
||||
properties: {
|
||||
userId: { type: 'string', format: 'misskey:id' },
|
||||
notify: { type: 'string', enum: ['normal', 'none'] },
|
||||
withReplies: { type: 'boolean' },
|
||||
},
|
||||
required: ['userId', 'notify'],
|
||||
required: ['userId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
@@ -98,7 +99,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
await this.followingsRepository.update({
|
||||
id: exist.id,
|
||||
}, {
|
||||
notify: ps.notify === 'none' ? null : ps.notify,
|
||||
notify: ps.notify != null ? (ps.notify === 'none' ? null : ps.notify) : undefined,
|
||||
withReplies: ps.withReplies != null ? ps.withReplies : undefined,
|
||||
});
|
||||
|
||||
return await this.userEntityService.pack(follower.id, me);
|
||||
|
@@ -91,7 +91,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
return [];
|
||||
}
|
||||
|
||||
const isFollowing = me ? (await this.cacheService.userFollowingsCache.fetch(me.id)).has(ps.userId) : false;
|
||||
const isFollowing = me ? Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId) : false;
|
||||
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||
|
@@ -11,7 +11,7 @@ import type { NoteReadService } from '@/core/NoteReadService.js';
|
||||
import type { NotificationService } from '@/core/NotificationService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { MiUserProfile } from '@/models/_.js';
|
||||
import { MiFollowing, MiUserProfile } from '@/models/_.js';
|
||||
import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import type { ChannelsService } from './ChannelsService.js';
|
||||
import type { EventEmitter } from 'events';
|
||||
@@ -30,7 +30,7 @@ export default class Connection {
|
||||
private subscribingNotes: any = {};
|
||||
private cachedNotes: Packed<'Note'>[] = [];
|
||||
public userProfile: MiUserProfile | null = null;
|
||||
public following: Set<string> = new Set();
|
||||
public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
|
||||
public followingChannels: Set<string> = new Set();
|
||||
public userIdsWhoMeMuting: Set<string> = new Set();
|
||||
public userIdsWhoBlockingMe: Set<string> = new Set();
|
||||
|
@@ -18,7 +18,6 @@ class GlobalTimelineChannel extends Channel {
|
||||
public readonly chName = 'globalTimeline';
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = false;
|
||||
private withReplies: boolean;
|
||||
private withRenotes: boolean;
|
||||
|
||||
constructor(
|
||||
@@ -38,7 +37,6 @@ class GlobalTimelineChannel extends Channel {
|
||||
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
|
||||
if (!policies.gtlAvailable) return;
|
||||
|
||||
this.withReplies = params.withReplies ?? false;
|
||||
this.withRenotes = params.withRenotes ?? true;
|
||||
|
||||
// Subscribe events
|
||||
@@ -64,7 +62,7 @@ class GlobalTimelineChannel extends Channel {
|
||||
}
|
||||
|
||||
// 関係ない返信は除外
|
||||
if (note.reply && !this.withReplies) {
|
||||
if (note.reply && !this.following[note.userId]?.withReplies) {
|
||||
const reply = note.reply;
|
||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
|
||||
|
@@ -16,7 +16,6 @@ class HomeTimelineChannel extends Channel {
|
||||
public readonly chName = 'homeTimeline';
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = true;
|
||||
private withReplies: boolean;
|
||||
private withRenotes: boolean;
|
||||
|
||||
constructor(
|
||||
@@ -31,7 +30,6 @@ class HomeTimelineChannel extends Channel {
|
||||
|
||||
@bindThis
|
||||
public async init(params: any) {
|
||||
this.withReplies = params.withReplies ?? false;
|
||||
this.withRenotes = params.withRenotes ?? true;
|
||||
|
||||
this.subscriber.on('notesStream', this.onNote);
|
||||
@@ -43,7 +41,7 @@ class HomeTimelineChannel extends Channel {
|
||||
if (!this.followingChannels.has(note.channelId)) return;
|
||||
} else {
|
||||
// その投稿のユーザーをフォローしていなかったら弾く
|
||||
if ((this.user!.id !== note.userId) && !this.following.has(note.userId)) return;
|
||||
if ((this.user!.id !== note.userId) && !Object.hasOwn(this.following, note.userId)) return;
|
||||
}
|
||||
|
||||
// Ignore notes from instances the user has muted
|
||||
@@ -73,7 +71,7 @@ class HomeTimelineChannel extends Channel {
|
||||
}
|
||||
|
||||
// 関係ない返信は除外
|
||||
if (note.reply && !this.withReplies) {
|
||||
if (note.reply && !this.following[note.userId]?.withReplies) {
|
||||
const reply = note.reply;
|
||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
|
||||
|
@@ -18,7 +18,6 @@ class HybridTimelineChannel extends Channel {
|
||||
public readonly chName = 'hybridTimeline';
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = true;
|
||||
private withReplies: boolean;
|
||||
private withRenotes: boolean;
|
||||
|
||||
constructor(
|
||||
@@ -38,7 +37,6 @@ class HybridTimelineChannel extends Channel {
|
||||
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
|
||||
if (!policies.ltlAvailable) return;
|
||||
|
||||
this.withReplies = params.withReplies ?? false;
|
||||
this.withRenotes = params.withRenotes ?? true;
|
||||
|
||||
// Subscribe events
|
||||
@@ -53,7 +51,7 @@ class HybridTimelineChannel extends Channel {
|
||||
// フォローしているチャンネルの投稿 の場合だけ
|
||||
if (!(
|
||||
(note.channelId == null && this.user!.id === note.userId) ||
|
||||
(note.channelId == null && this.following.has(note.userId)) ||
|
||||
(note.channelId == null && Object.hasOwn(this.following, note.userId)) ||
|
||||
(note.channelId == null && (note.user.host == null && note.visibility === 'public')) ||
|
||||
(note.channelId != null && this.followingChannels.has(note.channelId))
|
||||
)) return;
|
||||
@@ -85,7 +83,7 @@ class HybridTimelineChannel extends Channel {
|
||||
if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances ?? []))) return;
|
||||
|
||||
// 関係ない返信は除外
|
||||
if (note.reply && !this.withReplies) {
|
||||
if (note.reply && !this.following[note.userId]?.withReplies) {
|
||||
const reply = note.reply;
|
||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
|
||||
|
@@ -17,7 +17,6 @@ class LocalTimelineChannel extends Channel {
|
||||
public readonly chName = 'localTimeline';
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = false;
|
||||
private withReplies: boolean;
|
||||
private withRenotes: boolean;
|
||||
|
||||
constructor(
|
||||
@@ -37,7 +36,6 @@ class LocalTimelineChannel extends Channel {
|
||||
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
|
||||
if (!policies.ltlAvailable) return;
|
||||
|
||||
this.withReplies = params.withReplies ?? false;
|
||||
this.withRenotes = params.withRenotes ?? true;
|
||||
|
||||
// Subscribe events
|
||||
@@ -64,7 +62,7 @@ class LocalTimelineChannel extends Channel {
|
||||
}
|
||||
|
||||
// 関係ない返信は除外
|
||||
if (note.reply && this.user && !this.withReplies) {
|
||||
if (note.reply && this.user && !this.following[note.userId]?.withReplies) {
|
||||
const reply = note.reply;
|
||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;
|
||||
|
@@ -133,6 +133,7 @@ describe('ユーザー', () => {
|
||||
isMuted: user.isMuted ?? false,
|
||||
isRenoteMuted: user.isRenoteMuted ?? false,
|
||||
notify: user.notify ?? 'none',
|
||||
withReplies: user.withReplies ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
|
Reference in New Issue
Block a user