diff --git a/packages/ditto/interfaces/DittoEvent.ts b/packages/ditto/interfaces/DittoEvent.ts index cdd4343d..62f6c626 100644 --- a/packages/ditto/interfaces/DittoEvent.ts +++ b/packages/ditto/interfaces/DittoEvent.ts @@ -56,4 +56,5 @@ export interface DittoEvent extends NostrEvent { zap_message?: string; /** Language of the event (kind 1s are more accurate). */ language?: LanguageCode; + client?: DittoEvent; } diff --git a/packages/ditto/storages/hydrate.ts b/packages/ditto/storages/hydrate.ts index d2c64e90..7568a2b6 100644 --- a/packages/ditto/storages/hydrate.ts +++ b/packages/ditto/storages/hydrate.ts @@ -1,6 +1,6 @@ import { DittoDB, DittoTables } from '@ditto/db'; import { DittoConf } from '@ditto/conf'; -import { NStore } from '@nostrify/nostrify'; +import { type NostrFilter, NStore } from '@nostrify/nostrify'; import { Kysely } from 'kysely'; import { matchFilter } from 'nostr-tools'; import { NSchema as n } from '@nostrify/nostrify'; @@ -50,6 +50,10 @@ async function hydrateEvents(opts: HydrateOpts): Promise { cache.push(event); } + for (const event of await gatherClients({ ...opts, events: cache })) { + cache.push(event); + } + const authorStats = await gatherAuthorStats(cache, db.kysely); const eventStats = await gatherEventStats(cache, db.kysely); @@ -128,6 +132,16 @@ export function assembleEvents( event.user = b.find((e) => matchFilter({ kinds: [30382], authors: [admin], '#d': [event.pubkey] }, e)); event.info = b.find((e) => matchFilter({ kinds: [30383], authors: [admin], '#d': [event.id] }, e)); + for (const [name, _value, addr] of event.tags) { + if (name === 'client' && addr) { + const match = addr.match(/^31990:([0-9a-f]{64}):(.+)$/); + if (match) { + const [, pubkey, d] = match; + event.client = b.find((e) => matchFilter({ kinds: [31990], authors: [pubkey], '#d': [d] }, e)); + } + } + } + if (event.kind === 1) { const id = findQuoteTag(event.tags)?.[1] || findQuoteInContent(event.content); if (id) { @@ -353,6 +367,28 @@ async function gatherInfo({ conf, events, relay, signal }: HydrateOpts): Promise ); } +function gatherClients({ events, relay, signal }: HydrateOpts): Promise { + const filters: NostrFilter[] = []; + + for (const event of events) { + for (const [name, _value, addr] of event.tags) { + if (name === 'client' && addr) { + const match = addr.match(/^31990:([0-9a-f]{64}):(.+)$/); + if (match) { + const [, pubkey, d] = match; + filters.push({ kinds: [31990], authors: [pubkey], '#d': [d], limit: 1 }); + } + } + } + } + + if (!filters.length) { + return Promise.resolve([]); + } + + return relay.query(filters, { signal }); +} + /** Collect author stats from the events. */ async function gatherAuthorStats( events: DittoEvent[], diff --git a/packages/ditto/views/mastodon/statuses.ts b/packages/ditto/views/mastodon/statuses.ts index ba2e8d86..579aea2b 100644 --- a/packages/ditto/views/mastodon/statuses.ts +++ b/packages/ditto/views/mastodon/statuses.ts @@ -1,4 +1,4 @@ -import { NostrEvent, NStore } from '@nostrify/nostrify'; +import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; import { Conf } from '@/config.ts'; @@ -118,11 +118,27 @@ async function renderStatus( return acc; }, [] as { name: string; count: number; me: boolean; url?: string }[]); + let application: MastodonStatus['application'] = undefined; + + if (event.client) { + const result = n.json().pipe(n.metadata()).safeParse(event.client.content); + if (result.success) { + const name = result.data.name ?? result.data.display_name ?? event.tags.find(([name]) => name === 'client')?.[1]; + if (name) { + application = { + name, + website: result.data.website ?? null, + }; + } + } + } + const expiresAt = new Date(Number(event.tags.find(([name]) => name === 'expiration')?.[1]) * 1000); return { id: event.id, account, + application, card: event.event_stats?.link_preview ?? null, content: compatMentions + html, created_at: nostrDate(event.created_at).toISOString(), @@ -142,7 +158,6 @@ async function renderStatus( bookmarked: Boolean(bookmarkEvent), pinned: Boolean(pinEvent), reblog: null, - application: null, media_attachments: media .map((m) => renderAttachment({ tags: m })) .filter((m): m is MastodonAttachment => Boolean(m)), diff --git a/packages/mastoapi/types/MastodonStatus.ts b/packages/mastoapi/types/MastodonStatus.ts index 019e5a7b..db99d847 100644 --- a/packages/mastoapi/types/MastodonStatus.ts +++ b/packages/mastoapi/types/MastodonStatus.ts @@ -5,6 +5,10 @@ import type { MastodonPreviewCard } from './MastodonPreviewCard.ts'; export interface MastodonStatus { id: string; account: MastodonAccount; + application?: { + name: string; + website: string | null; + }; card: MastodonPreviewCard | null; content: string; created_at: string; @@ -24,7 +28,6 @@ export interface MastodonStatus { bookmarked: boolean; pinned: boolean; reblog: MastodonStatus | null; - application: unknown; media_attachments: MastodonAttachment[]; mentions: unknown[]; tags: unknown[];