implement note mutings and move favorited/renoted status into note entity directly
This commit is contained in:
parent
9bebf7718f
commit
7200c3d6c8
24 changed files with 342 additions and 181 deletions
|
|
@ -47,6 +47,7 @@ export class CacheService implements OnApplicationShutdown {
|
|||
public userBlockedCache: QuantumKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
|
||||
public renoteMutingsCache: QuantumKVCache<Set<string>>;
|
||||
public threadMutingsCache: QuantumKVCache<Set<string>>;
|
||||
public noteMutingsCache: QuantumKVCache<Set<string>>;
|
||||
public userFollowingsCache: QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>;
|
||||
public userFollowersCache: QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>;
|
||||
public hibernatedUserCache: QuantumKVCache<boolean>;
|
||||
|
|
@ -152,13 +153,27 @@ export class CacheService implements OnApplicationShutdown {
|
|||
this.threadMutingsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'threadMutings', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: muterId => this.noteThreadMutingsRepository
|
||||
.find({ where: { userId: muterId }, select: { threadId: true } })
|
||||
.find({ where: { userId: muterId, isPostMute: false }, select: { threadId: true } })
|
||||
.then(ms => new Set(ms.map(m => m.threadId))),
|
||||
bulkFetcher: muterIds => this.noteThreadMutingsRepository
|
||||
.createQueryBuilder('muting')
|
||||
.select('"muting"."userId"', 'userId')
|
||||
.addSelect('array_agg("muting"."threadId")', 'threadIds')
|
||||
.where({ userId: In(muterIds) })
|
||||
.where({ userId: In(muterIds), isPostMute: false })
|
||||
.getRawMany<{ userId: string, threadIds: string[] }>()
|
||||
.then(ms => ms.map(m => [m.userId, new Set(m.threadIds)])),
|
||||
});
|
||||
|
||||
this.noteMutingsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'noteMutings', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: muterId => this.noteThreadMutingsRepository
|
||||
.find({ where: { userId: muterId, isPostMute: true }, select: { threadId: true } })
|
||||
.then(ms => new Set(ms.map(m => m.threadId))),
|
||||
bulkFetcher: muterIds => this.noteThreadMutingsRepository
|
||||
.createQueryBuilder('muting')
|
||||
.select('"muting"."userId"', 'userId')
|
||||
.addSelect('array_agg("muting"."threadId")', 'threadIds')
|
||||
.where({ userId: In(muterIds), isPostMute: true })
|
||||
.getRawMany<{ userId: string, threadIds: string[] }>()
|
||||
.then(ms => ms.map(m => [m.userId, new Set(m.threadIds)])),
|
||||
});
|
||||
|
|
@ -290,6 +305,8 @@ export class CacheService implements OnApplicationShutdown {
|
|||
this.userFollowingsCache.delete(body.id),
|
||||
this.userFollowersCache.delete(body.id),
|
||||
this.hibernatedUserCache.delete(body.id),
|
||||
this.threadMutingsCache.delete(body.id),
|
||||
this.noteMutingsCache.delete(body.id),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -560,7 +577,11 @@ export class CacheService implements OnApplicationShutdown {
|
|||
this.userBlockingCache.dispose();
|
||||
this.userBlockedCache.dispose();
|
||||
this.renoteMutingsCache.dispose();
|
||||
this.threadMutingsCache.dispose();
|
||||
this.noteMutingsCache.dispose();
|
||||
this.userFollowingsCache.dispose();
|
||||
this.userFollowersCache.dispose();
|
||||
this.hibernatedUserCache.dispose();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -394,6 +394,7 @@ export class WebhookTestService {
|
|||
private async toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Promise<Packed<'Note'>> {
|
||||
return {
|
||||
id: note.id,
|
||||
threadId: note.threadId ?? note.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
deletedAt: null,
|
||||
text: note.text,
|
||||
|
|
@ -403,6 +404,10 @@ export class WebhookTestService {
|
|||
replyId: note.replyId,
|
||||
renoteId: note.renoteId,
|
||||
isHidden: false,
|
||||
isMutingThread: false,
|
||||
isMutingNote: false,
|
||||
isFavorited: false,
|
||||
isRenoted: false,
|
||||
visibility: note.visibility,
|
||||
mentions: note.mentions,
|
||||
visibleUserIds: note.visibleUserIds,
|
||||
|
|
|
|||
|
|
@ -11,11 +11,12 @@ import type { Packed } from '@/misc/json-schema.js';
|
|||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, MiPollVote, MiPoll, MiChannel, MiFollowing } from '@/models/_.js';
|
||||
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, MiPollVote, MiPoll, MiChannel, MiFollowing, NoteFavoritesRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { DebounceLoader } from '@/misc/loader.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { isPackedPureRenote } from '@/misc/is-renote.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
|
|
@ -55,6 +56,7 @@ function getAppearNoteIds(notes: MiNote[]): Set<string> {
|
|||
return appearNoteIds;
|
||||
}
|
||||
|
||||
// noinspection ES6MissingAwait
|
||||
@Injectable()
|
||||
export class NoteEntityService implements OnModuleInit {
|
||||
private userEntityService: UserEntityService;
|
||||
|
|
@ -96,6 +98,10 @@ export class NoteEntityService implements OnModuleInit {
|
|||
@Inject(DI.config)
|
||||
private readonly config: Config,
|
||||
|
||||
@Inject(DI.noteFavoritesRepository)
|
||||
private noteFavoritesRepository: NoteFavoritesRepository,
|
||||
|
||||
private readonly queryService: QueryService,
|
||||
//private userEntityService: UserEntityService,
|
||||
//private driveFileEntityService: DriveFileEntityService,
|
||||
//private customEmojiService: CustomEmojiService,
|
||||
|
|
@ -423,6 +429,9 @@ export class NoteEntityService implements OnModuleInit {
|
|||
channels: Map<string, MiChannel>;
|
||||
notes: Map<string, MiNote>;
|
||||
mutedThreads: Set<string>;
|
||||
mutedNotes: Set<string>;
|
||||
favoriteNotes: Set<string>;
|
||||
renotedNotes: Set<string>;
|
||||
};
|
||||
},
|
||||
): Promise<Packed<'Note'>> {
|
||||
|
|
@ -462,6 +471,23 @@ export class NoteEntityService implements OnModuleInit {
|
|||
const packedUsers = options?._hint_?.packedUsers;
|
||||
|
||||
const threadId = note.threadId ?? note.id;
|
||||
const [mutedThreads, mutedNotes, isFavorited, isRenoted] = await Promise.all([
|
||||
// mutedThreads
|
||||
opts._hint_?.mutedThreads
|
||||
?? (meId ? this.cacheService.threadMutingsCache.fetch(meId) : new Set<string>()),
|
||||
// mutedNotes
|
||||
opts._hint_?.mutedNotes
|
||||
?? (meId ? this.cacheService.noteMutingsCache.fetch(meId) : new Set<string>),
|
||||
// isFavorited
|
||||
opts._hint_?.favoriteNotes.has(note.id)
|
||||
?? (meId ? this.noteFavoritesRepository.existsBy({ userId: meId, noteId: note.id }) : false),
|
||||
// isRenoted
|
||||
opts._hint_?.renotedNotes.has(note.id)
|
||||
?? (meId ? this.queryService
|
||||
.andIsRenote(this.notesRepository.createQueryBuilder('note'), 'note')
|
||||
.andWhere({ renoteId: note.id, userId: meId })
|
||||
.getExists() : false),
|
||||
]);
|
||||
|
||||
const packed: Packed<'Note'> = await awaitAll({
|
||||
id: note.id,
|
||||
|
|
@ -505,8 +531,10 @@ export class NoteEntityService implements OnModuleInit {
|
|||
poll: opts._hint_?.polls.get(note.id),
|
||||
myVotes: opts._hint_?.pollVotes.get(note.id)?.get(note.userId),
|
||||
}) : undefined,
|
||||
isMuting: opts._hint_?.mutedThreads.has(threadId)
|
||||
?? (meId != null && this.cacheService.threadMutingsCache.fetch(meId).then(ms => ms.has(threadId))),
|
||||
isMutingThread: mutedThreads.has(threadId),
|
||||
isMutingNote: mutedNotes.has(note.id),
|
||||
isFavorited,
|
||||
isRenoted,
|
||||
|
||||
...(meId && Object.keys(reactions).length > 0 ? {
|
||||
myReaction: this.populateMyReaction({
|
||||
|
|
@ -654,7 +682,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
const fileIds = new Set(targetNotes.flatMap(n => n.fileIds));
|
||||
const mentionedUsers = new Set(targetNotes.flatMap(note => note.mentions));
|
||||
|
||||
const [{ bufferedReactions, myReactionsMap }, packedFiles, packedUsers, mentionHandles, userFollowings, userBlockers, polls, pollVotes, channels, mutedThreads] = await Promise.all([
|
||||
const [{ bufferedReactions, myReactionsMap }, packedFiles, packedUsers, mentionHandles, userFollowings, userBlockers, polls, pollVotes, channels, mutedThreads, mutedNotes, favoriteNotes, renotedNotes] = await Promise.all([
|
||||
// bufferedReactions & myReactionsMap
|
||||
this.getReactions(targetNotes, me),
|
||||
// packedFiles
|
||||
|
|
@ -692,6 +720,22 @@ export class NoteEntityService implements OnModuleInit {
|
|||
this.getChannels(targetNotes),
|
||||
// mutedThreads
|
||||
me ? this.cacheService.threadMutingsCache.fetch(me.id) : new Set<string>(),
|
||||
// mutedNotes
|
||||
me ? this.cacheService.noteMutingsCache.fetch(me.id) : new Set<string>(),
|
||||
// favoriteNotes
|
||||
me ? this.noteFavoritesRepository
|
||||
.createQueryBuilder('favorite')
|
||||
.select('favorite.noteId', 'noteId')
|
||||
.where({ userId: me.id, noteId: In(noteIds) })
|
||||
.getRawMany<{ noteId: string }>()
|
||||
.then(fs => new Set(fs.map(f => f.noteId))) : new Set<string>(),
|
||||
// renotedNotes
|
||||
me ? this.queryService
|
||||
.andIsRenote(this.notesRepository.createQueryBuilder('note'), 'note')
|
||||
.andWhere({ renoteId: In(noteIds), userId: me.id })
|
||||
.select('note.id', 'id')
|
||||
.getRawMany<{ id: string }>()
|
||||
.then(ns => new Set(ns.map(n => n.id))) : new Set<string>(),
|
||||
// (not returned)
|
||||
this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes)),
|
||||
]);
|
||||
|
|
@ -711,6 +755,9 @@ export class NoteEntityService implements OnModuleInit {
|
|||
channels,
|
||||
notes: new Map(targetNotes.map(n => [n.id, n])),
|
||||
mutedThreads,
|
||||
mutedNotes,
|
||||
favoriteNotes,
|
||||
renotedNotes,
|
||||
},
|
||||
})));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { id } from './util/id.js';
|
|||
import { MiUser } from './User.js';
|
||||
|
||||
@Entity('note_thread_muting')
|
||||
@Index(['userId', 'threadId'], { unique: true })
|
||||
@Index(['userId', 'threadId', 'isPostMute'], { unique: true })
|
||||
export class MiNoteThreadMuting {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
|
@ -30,4 +30,10 @@ export class MiNoteThreadMuting {
|
|||
length: 256,
|
||||
})
|
||||
public threadId: string;
|
||||
|
||||
@Column('boolean', {
|
||||
comment: 'If true, then this mute applies only to the referenced note. If false (default), then it applies to all replies as well.',
|
||||
default: false,
|
||||
})
|
||||
public isPostMute: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,7 +173,19 @@ export const packedNoteSchema = {
|
|||
},
|
||||
},
|
||||
},
|
||||
isMuting: {
|
||||
isMutingThread: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isMutingNote: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isFavorited: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isRenoted: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { NotesRepository, NoteThreadMutingsRepository, NoteFavoritesReposit
|
|||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
|
@ -27,6 +28,14 @@ export const meta = {
|
|||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isMutedNote: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isRenoted: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -58,23 +67,37 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private noteFavoritesRepository: NoteFavoritesRepository,
|
||||
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly queryService: QueryService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const note = await this.notesRepository.findOneByOrFail({ id: ps.noteId });
|
||||
|
||||
const [favorite, threadMuting] = await Promise.all([
|
||||
const [favorite, threadMuting, noteMuting, renoted] = await Promise.all([
|
||||
// favorite
|
||||
this.noteFavoritesRepository.exists({
|
||||
where: {
|
||||
userId: me.id,
|
||||
noteId: note.id,
|
||||
},
|
||||
}),
|
||||
// treadMuting
|
||||
this.cacheService.threadMutingsCache.fetch(me.id).then(ms => ms.has(note.threadId ?? note.id)),
|
||||
// noteMuting
|
||||
this.cacheService.noteMutingsCache.fetch(me.id).then(ms => ms.has(note.id)),
|
||||
// renoted
|
||||
this.notesRepository
|
||||
.createQueryBuilder('note')
|
||||
.andWhere({ renoteId: note.id, userId: me.id })
|
||||
.andWhere(qb => this.queryService
|
||||
.andIsRenote(qb, 'note'))
|
||||
.getExists(),
|
||||
]);
|
||||
|
||||
return {
|
||||
isFavorited: favorite,
|
||||
isMutedThread: threadMuting,
|
||||
isMutedNote: noteMuting,
|
||||
isRenoted: renoted,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ export const meta = {
|
|||
|
||||
kind: 'write:account',
|
||||
|
||||
// Up to 10 calls, then 1/second
|
||||
limit: {
|
||||
duration: ms('1hour'),
|
||||
max: 10,
|
||||
type: 'bucket',
|
||||
size: 10,
|
||||
dripRate: 1000,
|
||||
},
|
||||
|
||||
errors: {
|
||||
|
|
@ -38,6 +40,7 @@ export const paramDef = {
|
|||
type: 'object',
|
||||
properties: {
|
||||
noteId: { type: 'string', format: 'misskey:id' },
|
||||
noteOnly: { type: 'boolean', default: false },
|
||||
},
|
||||
required: ['noteId'],
|
||||
} as const;
|
||||
|
|
@ -71,13 +74,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
});
|
||||
*/
|
||||
|
||||
const threadId = note.threadId ?? note.id;
|
||||
await this.noteThreadMutingsRepository.insert({
|
||||
id: this.idService.gen(),
|
||||
threadId: note.threadId ?? note.id,
|
||||
threadId: ps.noteOnly ? note.id : threadId,
|
||||
userId: me.id,
|
||||
isPostMute: ps.noteOnly,
|
||||
});
|
||||
|
||||
await this.cacheService.threadMutingsCache.refresh(me.id);
|
||||
await Promise.all([
|
||||
this.cacheService.threadMutingsCache.refresh(me.id),
|
||||
this.cacheService.noteMutingsCache.refresh(me.id),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,10 +26,11 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
|
||||
// 10 calls per hour (match create)
|
||||
// Up to 20 calls, then 2/second
|
||||
limit: {
|
||||
duration: 1000 * 60 * 60,
|
||||
max: 10,
|
||||
type: 'bucket',
|
||||
size: 20,
|
||||
dripRate: 2000,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
@ -37,6 +38,7 @@ export const paramDef = {
|
|||
type: 'object',
|
||||
properties: {
|
||||
noteId: { type: 'string', format: 'misskey:id' },
|
||||
noteOnly: { type: 'boolean', default: false },
|
||||
},
|
||||
required: ['noteId'],
|
||||
} as const;
|
||||
|
|
@ -56,12 +58,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw err;
|
||||
});
|
||||
|
||||
const threadId = note.threadId ?? note.id;
|
||||
await this.noteThreadMutingsRepository.delete({
|
||||
threadId: note.threadId ?? note.id,
|
||||
threadId: ps.noteOnly ? note.id : threadId,
|
||||
userId: me.id,
|
||||
isPostMute: ps.noteOnly,
|
||||
});
|
||||
|
||||
await this.cacheService.threadMutingsCache.refresh(me.id);
|
||||
await Promise.all([
|
||||
this.cacheService.threadMutingsCache.refresh(me.id),
|
||||
this.cacheService.noteMutingsCache.refresh(me.id),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue