Let paginationMiddleware be configurable, add pagination to reactions handler

This commit is contained in:
Alex Gleason 2025-03-15 17:15:37 -05:00
parent 36ffd4283a
commit 88ef8087a5
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
8 changed files with 122 additions and 97 deletions

View file

@ -633,7 +633,7 @@ const zappedByController: AppController = async (c) => {
const { db, relay } = c.var;
const id = c.req.param('id');
const { offset, limit } = paginationSchema.parse(c.req.query());
const { offset, limit } = paginationSchema().parse(c.req.query());
const zaps = await db.kysely.selectFrom('event_zaps')
.selectAll()

View file

@ -9,7 +9,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
export const suggestionsV1Controller: AppController = async (c) => {
const { signal } = c.var;
const { offset, limit } = paginationSchema.parse(c.req.query());
const { offset, limit } = paginationSchema().parse(c.req.query());
const suggestions = await renderV2Suggestions(c, { offset, limit }, signal);
const accounts = suggestions.map(({ account }) => account);
return paginatedList(c, { offset, limit }, accounts);
@ -17,7 +17,7 @@ export const suggestionsV1Controller: AppController = async (c) => {
export const suggestionsV2Controller: AppController = async (c) => {
const { signal } = c.var;
const { offset, limit } = paginationSchema.parse(c.req.query());
const { offset, limit } = paginationSchema().parse(c.req.query());
const suggestions = await renderV2Suggestions(c, { offset, limit }, signal);
return paginatedList(c, { offset, limit }, suggestions);
};

View file

@ -124,7 +124,7 @@ async function getTrendingLinks(conf: DittoConf, relay: NStore): Promise<Trendin
const trendingStatusesController: AppController = async (c) => {
const { conf, relay } = c.var;
const { limit, offset, until } = paginationSchema.parse(c.req.query());
const { limit, offset, until } = paginationSchema().parse(c.req.query());
const [label] = await relay.query([{
kinds: [1985],

View file

@ -1,4 +1,4 @@
import { userMiddleware } from '@ditto/mastoapi/middleware';
import { paginationMiddleware, userMiddleware } from '@ditto/mastoapi/middleware';
import { DittoRoute } from '@ditto/mastoapi/router';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
@ -109,92 +109,97 @@ route.delete('/:id{[0-9a-f]{64}}/reactions/:emoji', userMiddleware(), async (c)
* Get an object of emoji to account mappings with accounts that reacted to the post.
* https://docs.pleroma.social/backend/development/API/pleroma_api/#get-apiv1pleromastatusesidreactions
*/
route.get('/:id{[0-9a-f]{64}}/reactions/:emoji?', userMiddleware({ required: false }), async (c) => {
const { relay, user } = c.var;
route.get(
'/:id{[0-9a-f]{64}}/reactions/:emoji?',
paginationMiddleware({ limit: 100 }),
userMiddleware({ required: false }),
async (c) => {
const { relay, user, pagination, paginate } = c.var;
const params = c.req.param();
const result = params.emoji ? parseEmojiParam(params.emoji) : undefined;
const pubkey = await user?.signer.getPublicKey();
const params = c.req.param();
const result = params.emoji ? parseEmojiParam(params.emoji) : undefined;
const pubkey = await user?.signer.getPublicKey();
const events = await relay.query([{ kinds: [7], '#e': [params.id], limit: 100 }])
.then((events) =>
events.filter((event) => {
if (!result) return true;
const events = await relay.query([{ kinds: [7], '#e': [params.id], ...pagination }])
.then((events) =>
events.filter((event) => {
if (!result) return true;
switch (result.type) {
case 'native':
return event.content === result.native;
case 'custom':
return event.content === `:${result.shortcode}:`;
}
})
)
.then((events) => hydrateEvents({ ...c.var, events }));
switch (result.type) {
case 'native':
return event.content === result.native;
case 'custom':
return event.content === `:${result.shortcode}:`;
}
})
)
.then((events) => hydrateEvents({ ...c.var, events }));
/** Events grouped by emoji key. */
const byEmojiKey = events.reduce((acc, event) => {
const result = parseEmojiInput(event.content);
/** Events grouped by emoji key. */
const byEmojiKey = events.reduce((acc, event) => {
const result = parseEmojiInput(event.content);
if (!result || result.type === 'basic') {
return acc;
}
let url: URL | undefined;
if (result.type === 'custom') {
const tag = event.tags.find(([name, value]) => name === 'emoji' && value === result.shortcode);
try {
url = new URL(tag![2]);
} catch {
if (!result || result.type === 'basic') {
return acc;
}
}
let key: string;
switch (result.type) {
case 'native':
key = result.native;
break;
case 'custom':
key = `${result.shortcode}:${url}`;
break;
}
let url: URL | undefined;
acc[key] = acc[key] || [];
acc[key].push(event);
return acc;
}, {} as Record<string, DittoEvent[]>);
const results = await Promise.all(
Object.entries(byEmojiKey).map(async ([key, events]) => {
let name: string = key;
let url: string | undefined;
// Custom emojis: `<shortcode>:<url>`
try {
const [shortcode, ...rest] = key.split(':');
url = new URL(rest.join(':')).toString();
name = shortcode;
} catch {
// fallthrough
if (result.type === 'custom') {
const tag = event.tags.find(([name, value]) => name === 'emoji' && value === result.shortcode);
try {
url = new URL(tag![2]);
} catch {
return acc;
}
}
return {
name,
count: events.length,
me: pubkey && events.some((event) => event.pubkey === pubkey),
accounts: await Promise.all(
events.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)),
),
url,
};
}),
);
let key: string;
switch (result.type) {
case 'native':
key = result.native;
break;
case 'custom':
key = `${result.shortcode}:${url}`;
break;
}
return c.json(results);
});
acc[key] = acc[key] || [];
acc[key].push(event);
return acc;
}, {} as Record<string, DittoEvent[]>);
const results = await Promise.all(
Object.entries(byEmojiKey).map(async ([key, events]) => {
let name: string = key;
let url: string | undefined;
// Custom emojis: `<shortcode>:<url>`
try {
const [shortcode, ...rest] = key.split(':');
url = new URL(rest.join(':')).toString();
name = shortcode;
} catch {
// fallthrough
}
return {
name,
count: events.length,
me: pubkey && events.some((event) => event.pubkey === pubkey),
accounts: await Promise.all(
events.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)),
),
url,
};
}),
);
return paginate(events, results);
},
);
/** Determine if the input is a native or custom emoji, returning a structured object or throwing an error. */
function parseEmojiParam(input: string):

View file

@ -41,7 +41,7 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], opts?:
}
async function renderAccounts(c: AppContext, pubkeys: string[]) {
const { offset, limit } = paginationSchema.parse(c.req.query());
const { offset, limit } = paginationSchema().parse(c.req.query());
const authors = pubkeys.reverse().slice(offset, offset + limit);
const { relay, signal } = c.var;

View file

@ -1,5 +1,5 @@
import { paginated, paginatedList } from '../pagination/paginate.ts';
import { paginationSchema } from '../pagination/schema.ts';
import { paginationSchema, type PaginationSchemaOpts } from '../pagination/schema.ts';
import type { DittoMiddleware } from '@ditto/mastoapi/router';
import type { NostrEvent } from '@nostrify/nostrify';
@ -19,19 +19,26 @@ type HeaderRecord = Record<string, string | string[]>;
type PaginateFn = (events: NostrEvent[], body: object | unknown[], headers?: HeaderRecord) => Response;
type ListPaginateFn = (params: ListPagination, body: object | unknown[], headers?: HeaderRecord) => Response;
interface PaginationMiddlewareOpts extends PaginationSchemaOpts {
type?: string;
}
/** Fixes compatibility with Mastodon apps by that don't use `Link` headers. */
// @ts-ignore Types are right.
export function paginationMiddleware(): DittoMiddleware<{ pagination: Pagination; paginate: PaginateFn }>;
export function paginationMiddleware(
type: 'list',
opts: PaginationMiddlewareOpts & { type: 'list' },
): DittoMiddleware<{ pagination: ListPagination; paginate: ListPaginateFn }>;
export function paginationMiddleware(
type?: string,
opts?: PaginationMiddlewareOpts,
): DittoMiddleware<{ pagination: Pagination; paginate: PaginateFn }>;
export function paginationMiddleware(
opts: PaginationMiddlewareOpts = {},
): DittoMiddleware<{ pagination?: Pagination | ListPagination; paginate: PaginateFn | ListPaginateFn }> {
return async (c, next) => {
const { relay } = c.var;
const pagination = paginationSchema.parse(c.req.query());
const pagination = paginationSchema(opts).parse(c.req.query());
const {
max_id: maxId,
@ -59,7 +66,7 @@ export function paginationMiddleware(
}
}
if (type === 'list') {
if (opts.type === 'list') {
c.set('pagination', {
limit: pagination.limit,
offset: pagination.offset,

View file

@ -3,7 +3,7 @@ import { assertEquals } from '@std/assert';
import { paginationSchema } from './schema.ts';
Deno.test('paginationSchema', () => {
const pagination = paginationSchema.parse({
const pagination = paginationSchema().parse({
limit: '10',
offset: '20',
max_id: '1',

View file

@ -9,15 +9,28 @@ export interface Pagination {
offset: number;
}
export interface PaginationSchemaOpts {
limit?: number;
max?: number;
}
/** Schema to parse pagination query params. */
export const paginationSchema: z.ZodType<Pagination> = z.object({
max_id: z.string().transform((val) => {
if (!val.includes('-')) return val;
return val.split('-')[1];
}).optional().catch(undefined),
min_id: z.string().optional().catch(undefined),
since: z.coerce.number().nonnegative().optional().catch(undefined),
until: z.coerce.number().nonnegative().optional().catch(undefined),
limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)),
offset: z.coerce.number().nonnegative().catch(0),
}) as z.ZodType<Pagination>;
export function paginationSchema(opts: PaginationSchemaOpts = {}): z.ZodType<Pagination> {
let { limit = 20, max = 40 } = opts;
if (limit > max) {
max = limit;
}
return z.object({
max_id: z.string().transform((val) => {
if (!val.includes('-')) return val;
return val.split('-')[1];
}).optional().catch(undefined),
min_id: z.string().optional().catch(undefined),
since: z.coerce.number().nonnegative().optional().catch(undefined),
until: z.coerce.number().nonnegative().optional().catch(undefined),
limit: z.coerce.number().catch(limit).transform((value) => Math.min(Math.max(value, 0), max)),
offset: z.coerce.number().nonnegative().catch(0),
}) as z.ZodType<Pagination>;
}