mirror of
https://activitypub.software/TransFem-org/Sharkey.git
synced 2025-09-18 21:38:07 +00:00
fix streaming API notes missing reactions, not always being hidden, and having incorrect values for the isRenoted, isFavorited, isMutingThread, and isMutingNote properties
This commit is contained in:
parent
8cbe1344f6
commit
4c2a0fed63
12 changed files with 227 additions and 76 deletions
|
@ -137,9 +137,21 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
return packedNote.visibility;
|
return packedNote.visibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async hideNotes(notes: Packed<'Note'>[], meId: string | null): Promise<void> {
|
||||||
|
const myFollowing = meId ? new Map(await this.cacheService.userFollowingsCache.fetch(meId)) : new Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>();
|
||||||
|
const myBlockers = meId ? new Set(await this.cacheService.userBlockedCache.fetch(meId)) : new Set<string>();
|
||||||
|
|
||||||
|
// This shouldn't actually await, but we have to wrap it anyway because hideNote() is async
|
||||||
|
await Promise.all(notes.map(note => this.hideNote(note, meId, {
|
||||||
|
myFollowing,
|
||||||
|
myBlockers,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null, hint?: {
|
public async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null, hint?: {
|
||||||
myFollowing?: ReadonlyMap<string, unknown>,
|
myFollowing?: ReadonlyMap<string, Omit<MiFollowing, 'isFollowerHibernated'>> | ReadonlySet<string>,
|
||||||
myBlockers?: ReadonlySet<string>,
|
myBlockers?: ReadonlySet<string>,
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (meId === packedNote.userId) return;
|
if (meId === packedNote.userId) return;
|
||||||
|
@ -281,6 +293,152 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async populateMyNoteMutings(notes: Packed<'Note'>[], meId: string): Promise<Set<string>> {
|
||||||
|
const mutedNotes = await this.cacheService.noteMutingsCache.fetch(meId);
|
||||||
|
|
||||||
|
const mutedIds = notes
|
||||||
|
.filter(note => mutedNotes.has(note.id))
|
||||||
|
.map(note => note.id);
|
||||||
|
return new Set(mutedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async populateMyTheadMutings(notes: Packed<'Note'>[], meId: string): Promise<Set<string>> {
|
||||||
|
const mutedThreads = await this.cacheService.threadMutingsCache.fetch(meId);
|
||||||
|
|
||||||
|
const mutedIds = notes
|
||||||
|
.filter(note => mutedThreads.has(note.threadId))
|
||||||
|
.map(note => note.id);
|
||||||
|
return new Set(mutedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async populateMyRenotes(notes: Packed<'Note'>[], meId: string, _hint_?: {
|
||||||
|
myRenotes: Map<string, boolean>;
|
||||||
|
}): Promise<Set<string>> {
|
||||||
|
const fetchedRenotes = new Set<string>();
|
||||||
|
const toFetch = new Set<string>();
|
||||||
|
|
||||||
|
if (_hint_) {
|
||||||
|
for (const note of notes) {
|
||||||
|
const fromHint = _hint_.myRenotes.get(note.id);
|
||||||
|
|
||||||
|
// null means we know there's no renote, so just skip it.
|
||||||
|
if (fromHint === false) continue;
|
||||||
|
|
||||||
|
if (fromHint) {
|
||||||
|
fetchedRenotes.add(note.id);
|
||||||
|
} else {
|
||||||
|
toFetch.add(note.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toFetch.size > 0) {
|
||||||
|
const fetched = await this.queryService
|
||||||
|
.andIsRenote(this.notesRepository.createQueryBuilder('note'), 'note')
|
||||||
|
.andWhere({
|
||||||
|
userId: meId,
|
||||||
|
renoteId: In(Array.from(toFetch)),
|
||||||
|
})
|
||||||
|
.select('note.renoteId', 'renoteId')
|
||||||
|
.getRawMany<{ renoteId: string }>();
|
||||||
|
|
||||||
|
for (const { renoteId } of fetched) {
|
||||||
|
fetchedRenotes.add(renoteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchedRenotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async populateMyFavorites(notes: Packed<'Note'>[], meId: string, _hint_?: {
|
||||||
|
myFavorites: Map<string, boolean>;
|
||||||
|
}): Promise<Set<string>> {
|
||||||
|
const fetchedFavorites = new Set<string>();
|
||||||
|
const toFetch = new Set<string>();
|
||||||
|
|
||||||
|
if (_hint_) {
|
||||||
|
for (const note of notes) {
|
||||||
|
const fromHint = _hint_.myFavorites.get(note.id);
|
||||||
|
|
||||||
|
// null means we know there's no favorite, so just skip it.
|
||||||
|
if (fromHint === false) continue;
|
||||||
|
|
||||||
|
if (fromHint) {
|
||||||
|
fetchedFavorites.add(note.id);
|
||||||
|
} else {
|
||||||
|
toFetch.add(note.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toFetch.size > 0) {
|
||||||
|
const fetched = await this.noteFavoritesRepository.find({
|
||||||
|
where: {
|
||||||
|
userId: meId,
|
||||||
|
noteId: In(Array.from(toFetch)),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
noteId: true,
|
||||||
|
},
|
||||||
|
}) as { noteId: string }[];
|
||||||
|
|
||||||
|
for (const { noteId } of fetched) {
|
||||||
|
fetchedFavorites.add(noteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchedFavorites;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async populateMyReactions(notes: Packed<'Note'>[], meId: string, _hint_?: {
|
||||||
|
myReactions: Map<MiNote['id'], string | null>;
|
||||||
|
}): Promise<Map<string, string>> {
|
||||||
|
const fetchedReactions = new Map<string, string>();
|
||||||
|
const toFetch = new Set<string>();
|
||||||
|
|
||||||
|
if (_hint_) {
|
||||||
|
for (const note of notes) {
|
||||||
|
const fromHint = _hint_.myReactions.get(note.id);
|
||||||
|
|
||||||
|
// null means we know there's no reaction, so just skip it.
|
||||||
|
if (fromHint === null) continue;
|
||||||
|
|
||||||
|
if (fromHint) {
|
||||||
|
const converted = this.reactionService.convertLegacyReaction(fromHint);
|
||||||
|
fetchedReactions.set(note.id, converted);
|
||||||
|
} else if (Object.values(note.reactions).some(count => count > 0)) {
|
||||||
|
// Note has at least one reaction, so we need to fetch
|
||||||
|
toFetch.add(note.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toFetch.size > 0) {
|
||||||
|
const fetched = await this.noteReactionsRepository.find({
|
||||||
|
where: {
|
||||||
|
userId: meId,
|
||||||
|
noteId: In(Array.from(toFetch)),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
noteId: true,
|
||||||
|
reaction: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { noteId, reaction } of fetched) {
|
||||||
|
const converted = this.reactionService.convertLegacyReaction(reaction);
|
||||||
|
fetchedReactions.set(noteId, converted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchedReactions;
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async populateMyReaction(note: { id: MiNote['id']; reactions: MiNote['reactions']; reactionAndUserPairCache?: MiNote['reactionAndUserPairCache']; }, meId: MiUser['id'], _hint_?: {
|
public async populateMyReaction(note: { id: MiNote['id']; reactions: MiNote['reactions']; reactionAndUserPairCache?: MiNote['reactionAndUserPairCache']; }, meId: MiUser['id'], _hint_?: {
|
||||||
myReactions: Map<MiNote['id'], string | null>;
|
myReactions: Map<MiNote['id'], string | null>;
|
||||||
|
@ -312,9 +470,14 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reaction = await this.noteReactionsRepository.findOneBy({
|
const reaction = await this.noteReactionsRepository.findOne({
|
||||||
userId: meId,
|
where: {
|
||||||
noteId: note.id,
|
userId: meId,
|
||||||
|
noteId: note.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
reaction: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (reaction) {
|
if (reaction) {
|
||||||
|
|
|
@ -10,7 +10,8 @@ import { isRenotePacked, isQuotePacked, isPackedPureRenote } from '@/misc/is-ren
|
||||||
import type { Packed } from '@/misc/json-schema.js';
|
import type { Packed } from '@/misc/json-schema.js';
|
||||||
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
|
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
|
||||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||||
import type Connection from './Connection.js';
|
import { deepClone } from '@/misc/clone.js';
|
||||||
|
import type Connection from '@/server/api/stream/Connection.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream channel
|
* Stream channel
|
||||||
|
@ -116,24 +117,6 @@ export default abstract class Channel {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* This function modifies {@link note}, please make sure it has been shallow cloned.
|
|
||||||
* See Dakkar's comment of {@link assignMyReaction} for more
|
|
||||||
* @param note The note to change
|
|
||||||
*/
|
|
||||||
protected async hideNote(note: Packed<'Note'>): Promise<void> {
|
|
||||||
if (note.renote) {
|
|
||||||
await this.hideNote(note.renote);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (note.reply) {
|
|
||||||
await this.hideNote(note.reply);
|
|
||||||
}
|
|
||||||
|
|
||||||
const meId = this.user?.id ?? null;
|
|
||||||
await this.noteEntityService.hideNote(note, meId);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(id: string, connection: Connection, noteEntityService: NoteEntityService) {
|
constructor(id: string, connection: Connection, noteEntityService: NoteEntityService) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.connection = connection;
|
this.connection = connection;
|
||||||
|
@ -160,37 +143,41 @@ export default abstract class Channel {
|
||||||
|
|
||||||
public onMessage?(type: string, body: JsonValue): void;
|
public onMessage?(type: string, body: JsonValue): void;
|
||||||
|
|
||||||
public async assignMyReaction(note: Packed<'Note'>): Promise<Packed<'Note'>> {
|
public async rePackNote(note: Packed<'Note'>): Promise<Packed<'Note'>> {
|
||||||
|
// If there's no user, then packing won't change anything.
|
||||||
|
// We can just re-use the original note.
|
||||||
|
if (!this.user) {
|
||||||
|
return note;
|
||||||
|
}
|
||||||
|
|
||||||
// StreamingApiServerService creates a single EventEmitter per server process,
|
// StreamingApiServerService creates a single EventEmitter per server process,
|
||||||
// so a new note arriving from redis gets de-serialised once per server process,
|
// so a new note arriving from redis gets de-serialised once per server process,
|
||||||
// and then that single object is passed to all active channels on each connection.
|
// and then that single object is passed to all active channels on each connection.
|
||||||
// If we didn't clone the notes here, different connections would asynchronously write
|
// If we didn't clone the notes here, different connections would asynchronously write
|
||||||
// different values to the same object, resulting in a random value being sent to each frontend. -- Dakkar
|
// different values to the same object, resulting in a random value being sent to each frontend. -- Dakkar
|
||||||
const clonedNote = { ...note };
|
const clonedNote = deepClone(note);
|
||||||
if (this.user && isRenotePacked(note) && !isQuotePacked(note)) {
|
const notes = crawl(clonedNote);
|
||||||
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
|
|
||||||
const myReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
|
// Hide notes before everything else, since this modifies fields that the other functions will check.
|
||||||
if (myReaction) {
|
await this.noteEntityService.hideNotes(notes, this.user.id);
|
||||||
clonedNote.renote = { ...note.renote };
|
|
||||||
clonedNote.renote.myReaction = myReaction;
|
// TODO cache reaction/renote/favorite hints in the connection.
|
||||||
}
|
// Those functions accept partial hints and will fetch anything else.
|
||||||
}
|
|
||||||
if (note.renote?.reply && Object.keys(note.renote.reply.reactions).length > 0) {
|
const [myReactions, myRenotes, myFavorites, myThreadMutings, myNoteMutings] = await Promise.all([
|
||||||
const myReaction = await this.noteEntityService.populateMyReaction(note.renote.reply, this.user.id);
|
this.noteEntityService.populateMyReactions(notes, this.user.id),
|
||||||
if (myReaction) {
|
this.noteEntityService.populateMyRenotes(notes, this.user.id),
|
||||||
clonedNote.renote = { ...note.renote };
|
this.noteEntityService.populateMyFavorites(notes, this.user.id),
|
||||||
clonedNote.renote.reply = { ...note.renote.reply };
|
this.noteEntityService.populateMyTheadMutings(notes, this.user.id),
|
||||||
clonedNote.renote.reply.myReaction = myReaction;
|
this.noteEntityService.populateMyNoteMutings(notes, this.user.id),
|
||||||
}
|
]);
|
||||||
}
|
|
||||||
}
|
note.myReaction = myReactions.get(note.id) ?? null;
|
||||||
if (this.user && note.reply && Object.keys(note.reply.reactions).length > 0) {
|
note.isRenoted = myRenotes.has(note.id);
|
||||||
const myReaction = await this.noteEntityService.populateMyReaction(note.reply, this.user.id);
|
note.isFavorited = myFavorites.has(note.id);
|
||||||
if (myReaction) {
|
note.isMutingThread = myThreadMutings.has(note.id);
|
||||||
clonedNote.reply = { ...note.reply };
|
note.isMutingNote = myNoteMutings.has(note.id);
|
||||||
clonedNote.reply.myReaction = myReaction;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return clonedNote;
|
return clonedNote;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -201,3 +188,21 @@ export type MiChannelService<T extends boolean> = {
|
||||||
kind: T extends true ? string : string | null | undefined;
|
kind: T extends true ? string : string | null | undefined;
|
||||||
create: (id: string, connection: Connection) => Channel;
|
create: (id: string, connection: Connection) => Channel;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function crawl(note: Packed<'Note'>, into?: Packed<'Note'>[]): Packed<'Note'>[] {
|
||||||
|
into ??= [];
|
||||||
|
|
||||||
|
if (!into.includes(note)) {
|
||||||
|
into.push(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.reply) {
|
||||||
|
crawl(note.reply, into);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.renote) {
|
||||||
|
crawl(note.renote, into);
|
||||||
|
}
|
||||||
|
|
||||||
|
return into;
|
||||||
|
}
|
||||||
|
|
|
@ -78,9 +78,7 @@ class BubbleTimelineChannel extends Channel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clonedNote = await this.assignMyReaction(note);
|
const clonedNote = await this.rePackNote(note);
|
||||||
await this.hideNote(clonedNote);
|
|
||||||
|
|
||||||
this.send('note', clonedNote);
|
this.send('note', clonedNote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -49,9 +49,7 @@ class ChannelChannel extends Channel {
|
||||||
|
|
||||||
if (this.isNoteMutedOrBlocked(note)) return;
|
if (this.isNoteMutedOrBlocked(note)) return;
|
||||||
|
|
||||||
const clonedNote = await this.assignMyReaction(note);
|
const clonedNote = await this.rePackNote(note);
|
||||||
await this.hideNote(clonedNote);
|
|
||||||
|
|
||||||
this.send('note', clonedNote);
|
this.send('note', clonedNote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -79,9 +79,7 @@ class GlobalTimelineChannel extends Channel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clonedNote = await this.assignMyReaction(note);
|
const clonedNote = await this.rePackNote(note);
|
||||||
await this.hideNote(clonedNote);
|
|
||||||
|
|
||||||
this.send('note', clonedNote);
|
this.send('note', clonedNote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -45,9 +45,7 @@ class HashtagChannel extends Channel {
|
||||||
|
|
||||||
if (this.isNoteMutedOrBlocked(note)) return;
|
if (this.isNoteMutedOrBlocked(note)) return;
|
||||||
|
|
||||||
const clonedNote = await this.assignMyReaction(note);
|
const clonedNote = await this.rePackNote(note);
|
||||||
await this.hideNote(clonedNote);
|
|
||||||
|
|
||||||
this.send('note', clonedNote);
|
this.send('note', clonedNote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -73,9 +73,7 @@ class HomeTimelineChannel extends Channel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clonedNote = await this.assignMyReaction(note);
|
const clonedNote = await this.rePackNote(note);
|
||||||
await this.hideNote(clonedNote);
|
|
||||||
|
|
||||||
this.send('note', clonedNote);
|
this.send('note', clonedNote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -90,9 +90,7 @@ class HybridTimelineChannel extends Channel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clonedNote = await this.assignMyReaction(note);
|
const clonedNote = await this.rePackNote(note);
|
||||||
await this.hideNote(clonedNote);
|
|
||||||
|
|
||||||
this.send('note', clonedNote);
|
this.send('note', clonedNote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -82,9 +82,7 @@ class LocalTimelineChannel extends Channel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clonedNote = await this.assignMyReaction(note);
|
const clonedNote = await this.rePackNote(note);
|
||||||
await this.hideNote(clonedNote);
|
|
||||||
|
|
||||||
this.send('note', clonedNote);
|
this.send('note', clonedNote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -70,9 +70,7 @@ class RoleTimelineChannel extends Channel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clonedNote = await this.assignMyReaction(note);
|
const clonedNote = await this.rePackNote(note);
|
||||||
await this.hideNote(clonedNote);
|
|
||||||
|
|
||||||
this.send('note', clonedNote);
|
this.send('note', clonedNote);
|
||||||
} else {
|
} else {
|
||||||
this.send(data.type, data.body);
|
this.send(data.type, data.body);
|
||||||
|
|
|
@ -114,9 +114,7 @@ class UserListChannel extends Channel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clonedNote = await this.assignMyReaction(note);
|
const clonedNote = await this.rePackNote(note);
|
||||||
await this.hideNote(clonedNote);
|
|
||||||
|
|
||||||
this.send('note', clonedNote);
|
this.send('note', clonedNote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -601,6 +601,7 @@ export class ClientServerService {
|
||||||
relations: ['user'],
|
relations: ['user'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO pack with current user, or the frontend can get bad data
|
||||||
if (note && !note.user!.requireSigninToViewContents) {
|
if (note && !note.user!.requireSigninToViewContents) {
|
||||||
const _note = await this.noteEntityService.pack(note);
|
const _note = await this.noteEntityService.pack(note);
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId });
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId });
|
||||||
|
|
Loading…
Add table
Reference in a new issue