merge: Implement replies collection for posts (resolves and ) ()

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/918

Closes  and 

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
Hazelnoot 2025-03-19 17:39:53 +00:00
commit 9fa3be8246
4 changed files with 295 additions and 3 deletions
packages/backend

View file

@ -29,10 +29,11 @@ import { bindThis } from '@/decorators.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { IdService } from '@/core/IdService.js';
import { appendContentWarning } from '@/misc/append-content-warning.js';
import { QueryService } from '@/core/QueryService.js';
import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js';
import { getApId } from './type.js';
import { getApId, IOrderedCollection, IOrderedCollectionPage } from './type.js';
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
@Injectable()
@ -70,6 +71,7 @@ export class ApRendererService {
private apMfmService: ApMfmService,
private mfmService: MfmService,
private idService: IdService,
private readonly queryService: QueryService,
) {
}
@ -388,13 +390,16 @@ export class ApRendererService {
let to: string[] = [];
let cc: string[] = [];
let isPublic = false;
if (note.visibility === 'public') {
to = ['https://www.w3.org/ns/activitystreams#Public'];
cc = [`${attributedTo}/followers`].concat(mentions);
isPublic = true;
} else if (note.visibility === 'home') {
to = [`${attributedTo}/followers`];
cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions);
isPublic = true;
} else if (note.visibility === 'followers') {
to = [`${attributedTo}/followers`];
cc = mentions;
@ -455,6 +460,10 @@ export class ApRendererService {
})),
} as const : {};
// Render the outer replies collection wrapper, which contains the count but not the actual URLs.
// This saves one hop (request) when de-referencing the replies.
const replies = isPublic ? await this.renderRepliesCollection(note.id) : undefined;
return {
id: `${this.config.url}/notes/${note.id}`,
type: 'Note',
@ -473,6 +482,7 @@ export class ApRendererService {
to,
cc,
inReplyTo,
replies,
attachment: files.map(x => this.renderDocument(x)),
sensitive: note.cw != null || files.some(file => file.isSensitive),
tag,
@ -909,6 +919,67 @@ export class ApRendererService {
return page;
}
/**
* Renders the reply collection wrapper object for a note
* @param noteId Note whose reply collection to render.
*/
@bindThis
public async renderRepliesCollection(noteId: string): Promise<IOrderedCollection> {
const replyCount = await this.notesRepository.countBy({
replyId: noteId,
visibility: In(['public', 'home']),
localOnly: false,
});
return {
type: 'OrderedCollection',
id: `${this.config.url}/notes/${noteId}/replies`,
first: `${this.config.url}/notes/${noteId}/replies?page=true`,
totalItems: replyCount,
};
}
/**
* Renders a page of the replies collection for a note
* @param noteId Return notes that are inReplyTo this value.
* @param untilId If set, return only notes that are *older* than this value.
*/
@bindThis
public async renderRepliesCollectionPage(noteId: string, untilId: string | undefined): Promise<IOrderedCollectionPage> {
const replyCount = await this.notesRepository.countBy({
replyId: noteId,
visibility: In(['public', 'home']),
localOnly: false,
});
const limit = 50;
const results = await this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), undefined, untilId)
.andWhere({
replyId: noteId,
visibility: In(['public', 'home']),
localOnly: false,
})
.select(['note.id', 'note.uri'])
.limit(limit)
.getRawMany<{ note_id: string, note_uri: string | null }>();
const hasNextPage = results.length >= limit;
const baseId = `${this.config.url}/notes/${noteId}/replies?page=true`;
return {
type: 'OrderedCollectionPage',
id: untilId == null ? baseId : `${baseId}&until_id=${untilId}`,
partOf: `${this.config.url}/notes/${noteId}/replies`,
first: baseId,
next: hasNextPage ? `${baseId}&until_id=${results.at(-1)?.note_id}` : undefined,
totalItems: replyCount,
orderedItems: results.map(r => {
// Remote notes have a URI, local have just an ID.
return r.note_uri ?? `${this.config.url}/notes/${r.note_id}`;
}),
};
}
@bindThis
private async getEmojis(names: string[]): Promise<MiEmoji[]> {
if (names.length === 0) return [];

View file

@ -26,7 +26,7 @@ export interface IObject {
attributedTo?: ApObject;
attachment?: any[];
inReplyTo?: any;
replies?: ICollection;
replies?: ICollection | IOrderedCollection | string;
content?: string | null;
startTime?: Date;
endTime?: Date;
@ -125,6 +125,8 @@ export interface ICollection extends IObject {
type: 'Collection';
totalItems: number;
first?: IObject | string;
last?: IObject | string;
current?: IObject | string;
items?: ApObject;
}
@ -132,6 +134,32 @@ export interface IOrderedCollection extends IObject {
type: 'OrderedCollection';
totalItems: number;
first?: IObject | string;
last?: IObject | string;
current?: IObject | string;
orderedItems?: ApObject;
}
export interface ICollectionPage extends IObject {
type: 'CollectionPage';
totalItems: number;
first?: IObject | string;
last?: IObject | string;
current?: IObject | string;
partOf?: IObject | string;
next?: IObject | string;
prev?: IObject | string;
items?: ApObject;
}
export interface IOrderedCollectionPage extends IObject {
type: 'OrderedCollectionPage';
totalItems: number;
first?: IObject | string;
last?: IObject | string;
current?: IObject | string;
partOf?: IObject | string;
next?: IObject | string;
prev?: IObject | string;
orderedItems?: ApObject;
}
@ -231,8 +259,14 @@ export const isCollection = (object: IObject): object is ICollection =>
export const isOrderedCollection = (object: IObject): object is IOrderedCollection =>
getApType(object) === 'OrderedCollection';
export const isCollectionPage = (object: IObject): object is ICollectionPage =>
getApType(object) === 'CollectionPage';
export const isOrderedCollectionPage = (object: IObject): object is IOrderedCollectionPage =>
getApType(object) === 'OrderedCollectionPage';
export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection =>
isCollection(object) || isOrderedCollection(object);
isCollection(object) || isOrderedCollection(object) || isCollectionPage(object) || isOrderedCollectionPage(object);
export interface IApPropertyValue extends IObject {
type: 'PropertyValue';

View file

@ -783,6 +783,52 @@ export class ActivityPubServerService {
return (this.apRendererService.addContext(await this.packActivity(note, author)));
});
// replies
fastify.get<{
Params: { note: string; };
Querystring: { page?: unknown; until_id?: unknown; };
}>('/notes/:note/replies', async (request, reply) => {
vary(reply.raw, 'Accept');
this.setResponseType(request, reply);
// Raw query to avoid fetching the while entity just to check access and get the user ID
const note = await this.notesRepository
.createQueryBuilder('note')
.andWhere({
id: request.params.note,
userHost: IsNull(),
visibility: In(['public', 'home']),
localOnly: false,
})
.select(['note.id', 'note.userId'])
.getRawOne<{ note_id: string, note_userId: string }>();
const { reject } = await this.checkAuthorizedFetch(request, reply, note?.note_userId);
if (reject) return;
if (note == null) {
reply.code(404);
return;
}
const untilId = request.query.until_id;
if (untilId != null && typeof(untilId) !== 'string') {
reply.code(400);
return;
}
// If page is unset, then we just provide the outer wrapper.
// This is because the spec doesn't allow the wrapper to contain both elements *and* pages.
// We could technically do it anyway, but that may break other instances.
if (request.query.page !== 'true') {
const collection = await this.apRendererService.renderRepliesCollection(note.note_id);
return this.apRendererService.addContext(collection);
}
const page = await this.apRendererService.renderRepliesCollectionPage(note.note_id, untilId ?? undefined);
return this.apRendererService.addContext(page);
});
// outbox
fastify.get<{
Params: { user: string; };

View file

@ -9,6 +9,7 @@ import { generateKeyPair } from 'crypto';
import { Test } from '@nestjs/testing';
import { jest } from '@jest/globals';
import type { Config } from '@/config.js';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
import { ApImageService } from '@/core/activitypub/models/ApImageService.js';
import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js';
@ -99,6 +100,7 @@ describe('ActivityPub', () => {
let idService: IdService;
let userPublickeysRepository: UserPublickeysRepository;
let userKeypairService: UserKeypairService;
let config: Config;
const metaInitial = {
cacheRemoteFiles: true,
@ -149,6 +151,7 @@ describe('ActivityPub', () => {
idService = app.get<IdService>(IdService);
userPublickeysRepository = app.get<UserPublickeysRepository>(DI.userPublickeysRepository);
userKeypairService = app.get<UserKeypairService>(UserKeypairService);
config = app.get<Config>(DI.config);
// Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error
const federatedInstanceService = app.get<FederatedInstanceService>(FederatedInstanceService);
@ -612,6 +615,40 @@ describe('ActivityPub', () => {
expect(result.summary).toBe('original and mandatory');
});
});
describe('replies', () => {
it('should be included when visibility=public', async () => {
note.visibility = 'public';
const rendered = await rendererService.renderNote(note, author, false);
expect(rendered.replies).toBeDefined();
});
it('should be included when visibility=home', async () => {
note.visibility = 'home';
const rendered = await rendererService.renderNote(note, author, false);
expect(rendered.replies).toBeDefined();
});
it('should be excluded when visibility=followers', async () => {
note.visibility = 'followers';
const rendered = await rendererService.renderNote(note, author, false);
expect(rendered.replies).not.toBeDefined();
});
it('should be excluded when visibility=specified', async () => {
note.visibility = 'specified';
const rendered = await rendererService.renderNote(note, author, false);
expect(rendered.replies).not.toBeDefined();
});
});
});
describe('renderUpnote', () => {
@ -695,6 +732,110 @@ describe('ActivityPub', () => {
expect(result.name).toBeUndefined();
});
});
describe('renderRepliesCollection', () => {
it('should include type', async () => {
const collection = await rendererService.renderRepliesCollection(note.id);
expect(collection.type).toBe('OrderedCollection');
});
it('should include id', async () => {
const collection = await rendererService.renderRepliesCollection(note.id);
expect(collection.id).toBe(`${config.url}/notes/${note.id}/replies`);
});
it('should include first', async () => {
const collection = await rendererService.renderRepliesCollection(note.id);
expect(collection.first).toBe(`${config.url}/notes/${note.id}/replies?page=true`);
});
it('should include totalItems', async () => {
const collection = await rendererService.renderRepliesCollection(note.id);
expect(collection.totalItems).toBe(0);
});
});
describe('renderRepliesCollectionPage', () => {
describe('with untilId', () => {
it('should include type', async () => {
const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123');
expect(collection.type).toBe('OrderedCollectionPage');
});
it('should include id', async () => {
const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123');
expect(collection.id).toBe(`${config.url}/notes/${note.id}/replies?page=true&until_id=abc123`);
});
it('should include partOf', async () => {
const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123');
expect(collection.partOf).toBe(`${config.url}/notes/${note.id}/replies`);
});
it('should include first', async () => {
const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123');
expect(collection.first).toBe(`${config.url}/notes/${note.id}/replies?page=true`);
});
it('should include totalItems', async () => {
const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123');
expect(collection.totalItems).toBe(0);
});
it('should include orderedItems', async () => {
const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123');
expect(collection.orderedItems).toBeDefined();
});
});
describe('without untilId', () => {
it('should include type', async () => {
const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined);
expect(collection.type).toBe('OrderedCollectionPage');
});
it('should include id', async () => {
const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined);
expect(collection.id).toBe(`${config.url}/notes/${note.id}/replies?page=true`);
});
it('should include partOf', async () => {
const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined);
expect(collection.partOf).toBe(`${config.url}/notes/${note.id}/replies`);
});
it('should include first', async () => {
const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined);
expect(collection.first).toBe(`${config.url}/notes/${note.id}/replies?page=true`);
});
it('should include totalItems', async () => {
const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined);
expect(collection.totalItems).toBe(0);
});
it('should include orderedItems', async () => {
const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined);
expect(collection.orderedItems).toBeDefined();
});
});
});
});
describe(ApPersonService, () => {