import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify'; import { z } from 'zod'; import { AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { Storages } from '@/storages.ts'; import { generateDateRange, Time } from '@/utils/time.ts'; import { unfurlCardCached } from '@/utils/unfurl.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; let trendingHashtagsCache = getTrendingHashtags().catch((e) => { console.error(`Failed to get trending hashtags: ${e}`); return Promise.resolve([]); }); Deno.cron('update trending hashtags cache', '35 * * * *', async () => { try { const trends = await getTrendingHashtags(); trendingHashtagsCache = Promise.resolve(trends); } catch (e) { console.error(e); } }); const trendingTagsQuerySchema = z.object({ limit: z.coerce.number().catch(10).transform((value) => Math.min(Math.max(value, 0), 20)), offset: z.number().nonnegative().catch(0), }); const trendingTagsController: AppController = async (c) => { const { limit, offset } = trendingTagsQuerySchema.parse(c.req.query()); const trends = await trendingHashtagsCache; return c.json(trends.slice(offset, offset + limit)); }; async function getTrendingHashtags() { const store = await Storages.db(); const trends = await getTrendingTags(store, 't'); return trends.map((trend) => { const hashtag = trend.value; const history = trend.history.map(({ day, authors, uses }) => ({ day: String(day), accounts: String(authors), uses: String(uses), })); return { name: hashtag, url: Conf.local(`/tags/${hashtag}`), history, }; }); } let trendingLinksCache = getTrendingLinks().catch((e) => { console.error(`Failed to get trending links: ${e}`); return Promise.resolve([]); }); Deno.cron('update trending links cache', '50 * * * *', async () => { try { const trends = await getTrendingLinks(); trendingLinksCache = Promise.resolve(trends); } catch (e) { console.error(e); } }); const trendingLinksController: AppController = async (c) => { const { limit, offset } = trendingTagsQuerySchema.parse(c.req.query()); const trends = await trendingLinksCache; return c.json(trends.slice(offset, offset + limit)); }; async function getTrendingLinks() { const store = await Storages.db(); const trends = await getTrendingTags(store, 'r'); return Promise.all(trends.map(async (trend) => { const link = trend.value; const card = await unfurlCardCached(link); const history = trend.history.map(({ day, authors, uses }) => ({ day: String(day), accounts: String(authors), uses: String(uses), })); return { url: link, title: '', description: '', type: 'link', author_name: '', author_url: '', provider_name: '', provider_url: '', html: '', width: 0, height: 0, image: null, embed_url: '', blurhash: null, ...card, history, }; })); } const trendingStatusesQuerySchema = z.object({ limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)), offset: z.number().nonnegative().catch(0), }); const trendingStatusesController: AppController = async (c) => { const store = await Storages.db(); const { limit, offset } = trendingStatusesQuerySchema.parse(c.req.query()); const [label] = await store.query([{ kinds: [1985], '#L': ['pub.ditto.trends'], '#l': ['#e'], authors: [Conf.pubkey], limit: 1, }]); const ids = (label?.tags ?? []) .filter(([name]) => name === 'e') .map(([, id]) => id) .slice(offset, offset + limit); if (!ids.length) { return c.json([]); } const results = await store.query([{ kinds: [1], ids }]) .then((events) => hydrateEvents({ events, store })); // Sort events in the order they appear in the label. const events = ids .map((id) => results.find((event) => event.id === id)) .filter((event): event is NostrEvent => !!event); const statuses = await Promise.all( events.map((event) => renderStatus(event, {})), ); return c.json(statuses.filter(Boolean)); }; interface TrendingTag { name: string; value: string; history: { day: number; authors: number; uses: number; }[]; } export async function getTrendingTags(store: NStore, tagName: string): Promise { const [label] = await store.query([{ kinds: [1985], '#L': ['pub.ditto.trends'], '#l': [`#${tagName}`], authors: [Conf.pubkey], limit: 1, }]); if (!label) return []; const date = new Date(label.created_at * 1000); const lastWeek = new Date(date.getTime() - Time.days(7)); const dates = generateDateRange(lastWeek, date).reverse(); const results: TrendingTag[] = []; for (const [name, value] of label.tags) { if (name !== tagName) continue; const history: TrendingTag['history'] = []; for (const date of dates) { const [label] = await store.query([{ kinds: [1985], '#L': ['pub.ditto.trends'], '#l': [`#${tagName}`], [`#${tagName}`]: [value], authors: [Conf.pubkey], since: Math.floor(date.getTime() / 1000), until: Math.floor((date.getTime() + Time.days(1)) / 1000), limit: 1, } as NostrFilter]); const [, , , accounts, uses] = label?.tags.find(([n, v]) => n === tagName && v === value) ?? []; history.push({ day: Math.floor(date.getTime() / 1000), authors: Number(accounts || 0), uses: Number(uses || 0), }); } results.push({ name: tagName, value, history, }); } return results; } export { trendingLinksController, trendingStatusesController, trendingTagsController };