mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 03:19:46 +00:00
Merge branch 'client-tag' into 'main'
Support NIP-89 "client" tag See merge request soapbox-pub/ditto!732
This commit is contained in:
commit
0b2d72d5f1
9 changed files with 105 additions and 4 deletions
|
|
@ -24,6 +24,8 @@ To learn about Nostr, use the fetch tool to read [NIP-01](https://raw.githubuser
|
||||||
|
|
||||||
To read a specific NIP, construct the NIP URL following this template: `https://raw.githubusercontent.com/nostr-protocol/nips/refs/heads/master/{nip}.md` (replace `{nip}` in the URL template with the relevant NIP name, eg `07` for NIP-07, or `C7` for NIP-C7). Then use the fetch tool to read the URL.
|
To read a specific NIP, construct the NIP URL following this template: `https://raw.githubusercontent.com/nostr-protocol/nips/refs/heads/master/{nip}.md` (replace `{nip}` in the URL template with the relevant NIP name, eg `07` for NIP-07, or `C7` for NIP-C7). Then use the fetch tool to read the URL.
|
||||||
|
|
||||||
|
To read the definition of a specific kind, construct a URL following this template: `https://nostrbook.dev/kinds/{kind}.md` (replace `{kind}` in the template with the kind number, eg `https://nostrbook.dev/kinds/0.md` for kind 0).
|
||||||
|
|
||||||
To discover the full list of NIPs, use the fetch tool to read the [NIPs README](https://raw.githubusercontent.com/nostr-protocol/nips/refs/heads/master/README.md).
|
To discover the full list of NIPs, use the fetch tool to read the [NIPs README](https://raw.githubusercontent.com/nostr-protocol/nips/refs/heads/master/README.md).
|
||||||
|
|
||||||
It's important that Ditto conforms to Nostr standards. Please read as much of the NIPs as you need to have a full understanding before adding or modifying Nostr events and filters. It is possible to add new ideas to Nostr that don't exist yet in the NIPs, but only after other options have been explored. Care must be taken when adding new Nostr ideas, to ensure they fit seamlessly within the existing Nostr ecosystem.
|
It's important that Ditto conforms to Nostr standards. Please read as much of the NIPs as you need to have a full understanding before adding or modifying Nostr events and filters. It is possible to add new ideas to Nostr that don't exist yet in the NIPs, but only after other options have been explored. Care must be taken when adding new Nostr ideas, to ensure they fit seamlessly within the existing Nostr ecosystem.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { startSentry } from '@/sentry.ts';
|
||||||
import { DittoAPIStore } from '@/storages/DittoAPIStore.ts';
|
import { DittoAPIStore } from '@/storages/DittoAPIStore.ts';
|
||||||
import { DittoPgStore } from '@/storages/DittoPgStore.ts';
|
import { DittoPgStore } from '@/storages/DittoPgStore.ts';
|
||||||
import { DittoPool } from '@/storages/DittoPool.ts';
|
import { DittoPool } from '@/storages/DittoPool.ts';
|
||||||
|
import { createNip89 } from '@/utils/nip89.ts';
|
||||||
import { Time } from '@/utils/time.ts';
|
import { Time } from '@/utils/time.ts';
|
||||||
import { seedZapSplits } from '@/utils/zap-split.ts';
|
import { seedZapSplits } from '@/utils/zap-split.ts';
|
||||||
|
|
||||||
|
|
@ -198,6 +199,7 @@ const pgstore = new DittoPgStore({
|
||||||
const pool = new DittoPool({ conf, relay: pgstore });
|
const pool = new DittoPool({ conf, relay: pgstore });
|
||||||
const relay = new DittoRelayStore({ db, conf, pool, relay: pgstore });
|
const relay = new DittoRelayStore({ db, conf, pool, relay: pgstore });
|
||||||
|
|
||||||
|
await createNip89({ conf, relay });
|
||||||
await seedZapSplits({ conf, relay });
|
await seedZapSplits({ conf, relay });
|
||||||
|
|
||||||
if (conf.firehoseEnabled) {
|
if (conf.firehoseEnabled) {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { languageSchema } from '@/schema.ts';
|
||||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||||
import { assertAuthenticated, createEvent, parseBody, updateListEvent } from '@/utils/api.ts';
|
import { assertAuthenticated, createEvent, parseBody, updateListEvent } from '@/utils/api.ts';
|
||||||
import { getCustomEmojis } from '@/utils/custom-emoji.ts';
|
import { getCustomEmojis } from '@/utils/custom-emoji.ts';
|
||||||
|
import { getInstanceMetadata } from '@/utils/instance.ts';
|
||||||
import { getInvoice, getLnurl } from '@/utils/lnurl.ts';
|
import { getInvoice, getLnurl } from '@/utils/lnurl.ts';
|
||||||
import { purifyEvent } from '@/utils/purify.ts';
|
import { purifyEvent } from '@/utils/purify.ts';
|
||||||
import { getZapSplits } from '@/utils/zap-split.ts';
|
import { getZapSplits } from '@/utils/zap-split.ts';
|
||||||
|
|
@ -25,6 +26,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||||
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
||||||
|
|
||||||
const createStatusSchema = z.object({
|
const createStatusSchema = z.object({
|
||||||
|
disclose_client: z.boolean().nullish(),
|
||||||
in_reply_to_id: n.id().nullish(),
|
in_reply_to_id: n.id().nullish(),
|
||||||
language: languageSchema.nullish(),
|
language: languageSchema.nullish(),
|
||||||
media_ids: z.string().array().nullish(),
|
media_ids: z.string().array().nullish(),
|
||||||
|
|
@ -265,6 +267,11 @@ const createStatusController: AppController = async (c) => {
|
||||||
content += mediaUrls.join('\n');
|
content += mediaUrls.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.disclose_client) {
|
||||||
|
const { name } = await getInstanceMetadata(c.var);
|
||||||
|
tags.push(['client', name, `31990:${await conf.signer.getPublicKey()}:ditto`, conf.relay]);
|
||||||
|
}
|
||||||
|
|
||||||
const event = await createEvent({
|
const event = await createEvent({
|
||||||
kind: 1,
|
kind: 1,
|
||||||
content,
|
content,
|
||||||
|
|
|
||||||
|
|
@ -56,4 +56,5 @@ export interface DittoEvent extends NostrEvent {
|
||||||
zap_message?: string;
|
zap_message?: string;
|
||||||
/** Language of the event (kind 1s are more accurate). */
|
/** Language of the event (kind 1s are more accurate). */
|
||||||
language?: LanguageCode;
|
language?: LanguageCode;
|
||||||
|
client?: DittoEvent;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { DittoDB, DittoTables } from '@ditto/db';
|
import { DittoDB, DittoTables } from '@ditto/db';
|
||||||
import { DittoConf } from '@ditto/conf';
|
import { DittoConf } from '@ditto/conf';
|
||||||
import { NStore } from '@nostrify/nostrify';
|
import { type NostrFilter, NStore } from '@nostrify/nostrify';
|
||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { matchFilter } from 'nostr-tools';
|
import { matchFilter } from 'nostr-tools';
|
||||||
import { NSchema as n } from '@nostrify/nostrify';
|
import { NSchema as n } from '@nostrify/nostrify';
|
||||||
|
|
@ -50,6 +50,10 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
|
||||||
cache.push(event);
|
cache.push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const event of await gatherClients({ ...opts, events: cache })) {
|
||||||
|
cache.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
const authorStats = await gatherAuthorStats(cache, db.kysely);
|
const authorStats = await gatherAuthorStats(cache, db.kysely);
|
||||||
const eventStats = await gatherEventStats(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.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));
|
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) {
|
if (event.kind === 1) {
|
||||||
const id = findQuoteTag(event.tags)?.[1] || findQuoteInContent(event.content);
|
const id = findQuoteTag(event.tags)?.[1] || findQuoteInContent(event.content);
|
||||||
if (id) {
|
if (id) {
|
||||||
|
|
@ -353,6 +367,28 @@ async function gatherInfo({ conf, events, relay, signal }: HydrateOpts): Promise
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gatherClients({ events, relay, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
||||||
|
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. */
|
/** Collect author stats from the events. */
|
||||||
async function gatherAuthorStats(
|
async function gatherAuthorStats(
|
||||||
events: DittoEvent[],
|
events: DittoEvent[],
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export async function getInstanceMetadata(opts: GetInstanceMetadataOpts): Promis
|
||||||
tagline: meta.tagline ?? meta.about ?? 'Nostr community server',
|
tagline: meta.tagline ?? meta.about ?? 'Nostr community server',
|
||||||
email: meta.email ?? `postmaster@${conf.url.host}`,
|
email: meta.email ?? `postmaster@${conf.url.host}`,
|
||||||
picture: meta.picture ?? conf.local('/images/thumbnail.png'),
|
picture: meta.picture ?? conf.local('/images/thumbnail.png'),
|
||||||
|
website: meta.website ?? conf.localDomain,
|
||||||
event,
|
event,
|
||||||
screenshots: meta.screenshots ?? [],
|
screenshots: meta.screenshots ?? [],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
34
packages/ditto/utils/nip89.ts
Normal file
34
packages/ditto/utils/nip89.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { DittoConf } from '@ditto/conf';
|
||||||
|
|
||||||
|
import { getInstanceMetadata } from '@/utils/instance.ts';
|
||||||
|
|
||||||
|
import type { NStore } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
interface CreateNip89Opts {
|
||||||
|
conf: DittoConf;
|
||||||
|
relay: NStore;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a NIP-89 application handler event (kind 31990)
|
||||||
|
* This identifies Ditto as a client that can handle various kinds of events
|
||||||
|
*/
|
||||||
|
export async function createNip89(opts: CreateNip89Opts): Promise<void> {
|
||||||
|
const { conf, relay, signal } = opts;
|
||||||
|
|
||||||
|
const { event: _, ...metadata } = await getInstanceMetadata(opts);
|
||||||
|
|
||||||
|
const event = await conf.signer.signEvent({
|
||||||
|
kind: 31990,
|
||||||
|
tags: [
|
||||||
|
['d', 'ditto'],
|
||||||
|
['k', '1'],
|
||||||
|
['web', conf.local('/<bech32>'), 'web'],
|
||||||
|
],
|
||||||
|
content: JSON.stringify(metadata),
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
await relay.event(event, { signal });
|
||||||
|
}
|
||||||
|
|
@ -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 { nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
|
|
@ -118,11 +118,27 @@ async function renderStatus(
|
||||||
return acc;
|
return acc;
|
||||||
}, [] as { name: string; count: number; me: boolean; url?: string }[]);
|
}, [] 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);
|
const expiresAt = new Date(Number(event.tags.find(([name]) => name === 'expiration')?.[1]) * 1000);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
account,
|
account,
|
||||||
|
application,
|
||||||
card: event.event_stats?.link_preview ?? null,
|
card: event.event_stats?.link_preview ?? null,
|
||||||
content: compatMentions + html,
|
content: compatMentions + html,
|
||||||
created_at: nostrDate(event.created_at).toISOString(),
|
created_at: nostrDate(event.created_at).toISOString(),
|
||||||
|
|
@ -142,7 +158,6 @@ async function renderStatus(
|
||||||
bookmarked: Boolean(bookmarkEvent),
|
bookmarked: Boolean(bookmarkEvent),
|
||||||
pinned: Boolean(pinEvent),
|
pinned: Boolean(pinEvent),
|
||||||
reblog: null,
|
reblog: null,
|
||||||
application: null,
|
|
||||||
media_attachments: media
|
media_attachments: media
|
||||||
.map((m) => renderAttachment({ tags: m }))
|
.map((m) => renderAttachment({ tags: m }))
|
||||||
.filter((m): m is MastodonAttachment => Boolean(m)),
|
.filter((m): m is MastodonAttachment => Boolean(m)),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ import type { MastodonPreviewCard } from './MastodonPreviewCard.ts';
|
||||||
export interface MastodonStatus {
|
export interface MastodonStatus {
|
||||||
id: string;
|
id: string;
|
||||||
account: MastodonAccount;
|
account: MastodonAccount;
|
||||||
|
application?: {
|
||||||
|
name: string;
|
||||||
|
website: string | null;
|
||||||
|
};
|
||||||
card: MastodonPreviewCard | null;
|
card: MastodonPreviewCard | null;
|
||||||
content: string;
|
content: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|
@ -24,7 +28,6 @@ export interface MastodonStatus {
|
||||||
bookmarked: boolean;
|
bookmarked: boolean;
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
reblog: MastodonStatus | null;
|
reblog: MastodonStatus | null;
|
||||||
application: unknown;
|
|
||||||
media_attachments: MastodonAttachment[];
|
media_attachments: MastodonAttachment[];
|
||||||
mentions: unknown[];
|
mentions: unknown[];
|
||||||
tags: unknown[];
|
tags: unknown[];
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue