From 3fb100a421aaeafc5261993cc512dffac7a320c7 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 29 Oct 2025 13:02:41 +0100 Subject: [PATCH] update client tags --- .../ApplicationHandlerInfo/index.tsx | 137 ++++++++++ .../index.tsx | 78 ++++++ src/components/ClientTag/index.tsx | 9 +- src/components/ContentPreview/index.tsx | 10 + src/constants.ts | 10 +- src/lib/draft-event.ts | 13 +- src/lib/event.ts | 8 +- src/lib/nip89-utils.ts | 24 ++ .../DiscussionsPage/CreateThreadDialog.tsx | 5 + src/services/nip89.service.ts | 250 ++++++++++++++++++ 10 files changed, 535 insertions(+), 9 deletions(-) create mode 100644 src/components/ApplicationHandlerInfo/index.tsx create mode 100644 src/components/ApplicationHandlerRecommendation/index.tsx create mode 100644 src/lib/nip89-utils.ts create mode 100644 src/services/nip89.service.ts diff --git a/src/components/ApplicationHandlerInfo/index.tsx b/src/components/ApplicationHandlerInfo/index.tsx new file mode 100644 index 0000000..7adcd8a --- /dev/null +++ b/src/components/ApplicationHandlerInfo/index.tsx @@ -0,0 +1,137 @@ +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { ExternalLink, Globe, Smartphone, Monitor } from 'lucide-react' +import { Event } from 'nostr-tools' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import nip89Service from '@/services/nip89.service' + +interface ApplicationHandlerInfoProps { + event: Event + className?: string +} + +export default function ApplicationHandlerInfo({ event, className }: ApplicationHandlerInfoProps) { + const { t } = useTranslation() + + const handlerInfo = useMemo(() => { + return nip89Service.parseApplicationHandlerInfo(event) + }, [event]) + + if (!handlerInfo) { + return null + } + + const handlePlatformClick = (url: string) => { + // Replace bech32 placeholder with actual event ID + const actualUrl = url.replace('bech32', event.id) + window.open(actualUrl, '_blank', 'noopener,noreferrer') + } + + const platformButtons = Object.entries(handlerInfo.platforms) + .filter(([_, url]) => url) + .map(([platform, url]) => { + const icons = { + web: Globe, + ios: Smartphone, + android: Smartphone, + desktop: Monitor + } + + const Icon = icons[platform as keyof typeof icons] + const platformName = platform.charAt(0).toUpperCase() + platform.slice(1) + + return ( + + ) + }) + + return ( + + +
+ {handlerInfo.picture && ( + {handlerInfo.name} + )} +
+ {handlerInfo.name} + {handlerInfo.description && ( + + {handlerInfo.description} + + )} + {handlerInfo.website && ( + + + {handlerInfo.website} + + )} +
+
+
+ + + {/* Supported Event Kinds */} +
+

+ {t('Supported Event Types')} +

+
+ {handlerInfo.supportedKinds.map(kind => ( + + Kind {kind} + + ))} +
+
+ + {/* Platform Access */} + {platformButtons.length > 0 && ( +
+

+ {t('Access via')} +

+
+ {platformButtons} +
+
+ )} + + {/* Relays */} + {handlerInfo.relays.length > 0 && ( +
+

+ {t('Recommended Relays')} +

+
+ {handlerInfo.relays.map((relay, index) => ( +
+ {relay} +
+ ))} +
+
+ )} +
+
+ ) +} diff --git a/src/components/ApplicationHandlerRecommendation/index.tsx b/src/components/ApplicationHandlerRecommendation/index.tsx new file mode 100644 index 0000000..f67730a --- /dev/null +++ b/src/components/ApplicationHandlerRecommendation/index.tsx @@ -0,0 +1,78 @@ +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Event } from 'nostr-tools' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import nip89Service from '@/services/nip89.service' + +interface ApplicationHandlerRecommendationProps { + event: Event + className?: string +} + +export default function ApplicationHandlerRecommendation({ + event, + className +}: ApplicationHandlerRecommendationProps) { + const { t } = useTranslation() + + const recommendation = useMemo(() => { + return nip89Service.parseApplicationHandlerRecommendation(event) + }, [event]) + + if (!recommendation) { + return null + } + + return ( + + + + {t('Application Recommendations')} + + + {t('Recommended applications for handling events of kind {{kind}}', { + kind: recommendation.supportedKind + })} + + + + +
+ {recommendation.handlers.map((handler, index) => ( +
+
+
+ {t('Handler {{index}}', { index: index + 1 })} +
+
+ {handler.pubkey.substring(0, 16)}...{handler.pubkey.substring(-8)} +
+ {handler.identifier && ( +
+ ID: {handler.identifier} +
+ )} + {handler.relay && ( +
+ {handler.relay} +
+ )} +
+
+ {handler.platform && ( + + {handler.platform} + + )} + + Kind {recommendation.supportedKind} + +
+
+ ))} +
+
+
+ ) +} diff --git a/src/components/ClientTag/index.tsx b/src/components/ClientTag/index.tsx index b441cc0..f7b1cea 100644 --- a/src/components/ClientTag/index.tsx +++ b/src/components/ClientTag/index.tsx @@ -1,17 +1,16 @@ import { getUsingClient } from '@/lib/event' import { NostrEvent } from 'nostr-tools' import { useMemo } from 'react' -import { useTranslation } from 'react-i18next' +import { Badge } from '@/components/ui/badge' export default function ClientTag({ event }: { event: NostrEvent }) { - const { t } = useTranslation() const usingClient = useMemo(() => getUsingClient(event), [event]) if (!usingClient) return null return ( - - {t('via {{client}}', { client: usingClient })} - + + {usingClient} + ) } diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index 8b4b8bd..8772acc 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -17,6 +17,8 @@ import PollPreview from './PollPreview' import VideoNotePreview from './VideoNotePreview' import ZapPreview from './ZapPreview' import DiscussionNote from '../DiscussionNote' +import ApplicationHandlerInfo from '../ApplicationHandlerInfo' +import ApplicationHandlerRecommendation from '../ApplicationHandlerRecommendation' export default function ContentPreview({ event, @@ -111,5 +113,13 @@ export default function ContentPreview({ return } + if (event.kind === ExtendedKind.APPLICATION_HANDLER_INFO) { + return + } + + if (event.kind === ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION) { + return + } + return
[{t('Cannot handle event of kind k', { k: event.kind })}]
} diff --git a/src/constants.ts b/src/constants.ts index d069f16..3148a37 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -130,7 +130,10 @@ export const ExtendedKind = { ZAP_RECEIPT: 9735, PUBLICATION: 30040, WIKI_ARTICLE: 30818, - WIKI_CHAPTER: 30041 + PUBLICATION_CONTENT: 30041, + // NIP-89 Application Handlers + APPLICATION_HANDLER_RECOMMENDATION: 31989, + APPLICATION_HANDLER_INFO: 31990 } export const SUPPORTED_KINDS = [ @@ -151,7 +154,10 @@ export const SUPPORTED_KINDS = [ ExtendedKind.ZAP_RECEIPT, ExtendedKind.PUBLICATION, ExtendedKind.WIKI_ARTICLE, - ExtendedKind.WIKI_CHAPTER + ExtendedKind.PUBLICATION_CONTENT, + // NIP-89 Application Handlers + ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION, + ExtendedKind.APPLICATION_HANDLER_INFO ] export const URL_REGEX = diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 1cc3631..5881d44 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -868,7 +868,18 @@ function buildResponseTag(value: string) { return ['response', value] } -function buildClientTag() { +function buildClientTag(handlerPubkey?: string, handlerIdentifier?: string, relay?: string) { + // Use NIP-89 format if handler information is provided + if (handlerPubkey && handlerIdentifier) { + const aTag = `31990:${handlerPubkey}:${handlerIdentifier}` + const tag = ['client', 'Jumble ImWald', aTag] + if (relay) { + tag.push(relay) + } + return tag + } + + // Fallback to simple format for backward compatibility return ['client', 'jumble'] } diff --git a/src/lib/event.ts b/src/lib/event.ts index 5f034eb..829a715 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -194,7 +194,13 @@ export function getNoteBech32Id(event: Event) { } export function getUsingClient(event: Event) { - return event.tags.find(tagNameEquals('client'))?.[1] + const clientTag = event.tags.find(tagNameEquals('client')) + if (!clientTag) return undefined + + // NIP-89 client tag format: ["client", "Client Name", "31990:pubkey:identifier", "relay"] + // Simple format: ["client", "client_name"] + // For display purposes, we use the client name (second element) + return clientTag[1] } export function getImetaInfosFromEvent(event: Event) { diff --git a/src/lib/nip89-utils.ts b/src/lib/nip89-utils.ts new file mode 100644 index 0000000..261cbf2 --- /dev/null +++ b/src/lib/nip89-utils.ts @@ -0,0 +1,24 @@ +import { Event } from 'nostr-tools' +import nip89Service from '@/services/nip89.service' + +/** + * Create the Jumble ImWald application handler info event (kind 31990) + * This can be published using the existing publish function from NostrProvider + */ +export function createJumbleImWaldHandlerInfoEvent(pubkey: string): Omit { + return nip89Service.createJumbleImWaldHandlerInfo(pubkey) +} + +/** + * Example usage in a component: + * + * const { pubkey, signEvent, publish } = useNostr() + * + * const handlePublishHandlerInfo = async () => { + * if (!pubkey) return + * + * const handlerInfoEvent = createJumbleImWaldHandlerInfoEvent(pubkey) + * const signedEvent = await signEvent(handlerInfoEvent) + * await publish(signedEvent) + * } + */ diff --git a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx index 7d22d08..776ab03 100644 --- a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx +++ b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx @@ -45,6 +45,10 @@ function buildClientTag(): string[] { return ['client', 'jumble'] } +function buildAltTag(): string[] { + return ['alt', 'This event was published by https://jumble.imwald.eu.'] +} + interface DynamicTopic { id: string @@ -406,6 +410,7 @@ export default function CreateThreadDialog({ // Add client tag if enabled if (addClientTag) { tags.push(buildClientTag()) + tags.push(buildAltTag()) } // Create the thread event (kind 11) diff --git a/src/services/nip89.service.ts b/src/services/nip89.service.ts new file mode 100644 index 0000000..0434e34 --- /dev/null +++ b/src/services/nip89.service.ts @@ -0,0 +1,250 @@ +import { ExtendedKind } from '@/constants' +import { Event, kinds } from 'nostr-tools' +import * as nip19 from 'nostr-tools/nip19' + +export interface ApplicationHandlerInfo { + name: string + description?: string + website?: string + picture?: string + supportedKinds: number[] + platforms: { + web?: string + ios?: string + android?: string + desktop?: string + } + relays: string[] +} + +export interface ApplicationHandlerRecommendation { + supportedKind: number + handlers: Array<{ + pubkey: string + identifier: string + relay: string + platform?: string + }> +} + +class Nip89Service { + static instance: Nip89Service + + constructor() { + if (Nip89Service.instance) { + return Nip89Service.instance + } + Nip89Service.instance = this + } + + /** + * Create a NIP-89 application handler info event (kind 31990) + */ + createApplicationHandlerInfoEvent( + pubkey: string, + handlerInfo: ApplicationHandlerInfo, + identifier: string = 'main' + ): Omit { + const content = JSON.stringify({ + name: handlerInfo.name, + description: handlerInfo.description, + website: handlerInfo.website, + picture: handlerInfo.picture + }) + + const tags: string[][] = [ + ['d', identifier], + ...handlerInfo.supportedKinds.map(kind => ['k', kind.toString()]), + ...handlerInfo.relays.map(relay => ['relay', relay]) + ] + + // Add platform-specific handlers + if (handlerInfo.platforms.web) { + tags.push(['web', handlerInfo.platforms.web, 'nevent']) + } + if (handlerInfo.platforms.ios) { + tags.push(['ios', handlerInfo.platforms.ios, 'nevent']) + } + if (handlerInfo.platforms.android) { + tags.push(['android', handlerInfo.platforms.android, 'nevent']) + } + if (handlerInfo.platforms.desktop) { + tags.push(['desktop', handlerInfo.platforms.desktop, 'nevent']) + } + + return { + kind: ExtendedKind.APPLICATION_HANDLER_INFO, + pubkey, + content, + created_at: Math.floor(Date.now() / 1000), + tags + } + } + + /** + * Create a NIP-89 application handler recommendation event (kind 31989) + */ + createApplicationHandlerRecommendationEvent( + pubkey: string, + recommendation: ApplicationHandlerRecommendation + ): Omit { + const tags: string[][] = [ + ['d', recommendation.supportedKind.toString()], + ...recommendation.handlers.map(handler => { + const aTag = `31990:${handler.pubkey}:${handler.identifier}` + const tag = ['a', aTag, handler.relay] + if (handler.platform) { + tag.push(handler.platform) + } + return tag + }) + ] + + return { + kind: ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION, + pubkey, + content: '', + created_at: Math.floor(Date.now() / 1000), + tags + } + } + + /** + * Parse application handler info from a kind 31990 event + */ + parseApplicationHandlerInfo(event: Event): ApplicationHandlerInfo | null { + if (event.kind !== ExtendedKind.APPLICATION_HANDLER_INFO) { + return null + } + + let metadata: any = {} + try { + metadata = JSON.parse(event.content || '{}') + } catch { + // If parsing fails, use empty object + } + + const supportedKinds: number[] = [] + const platforms: ApplicationHandlerInfo['platforms'] = {} + const relays: string[] = [] + + for (const tag of event.tags) { + if (tag[0] === 'k' && tag[1]) { + const kind = parseInt(tag[1]) + if (!isNaN(kind)) { + supportedKinds.push(kind) + } + } else if (tag[0] === 'relay' && tag[1]) { + relays.push(tag[1]) + } else if (tag[0] === 'web' && tag[1]) { + platforms.web = tag[1] + } else if (tag[0] === 'ios' && tag[1]) { + platforms.ios = tag[1] + } else if (tag[0] === 'android' && tag[1]) { + platforms.android = tag[1] + } else if (tag[0] === 'desktop' && tag[1]) { + platforms.desktop = tag[1] + } + } + + return { + name: metadata.name || 'Unknown Application', + description: metadata.description, + website: metadata.website, + picture: metadata.picture, + supportedKinds, + platforms, + relays + } + } + + /** + * Parse application handler recommendation from a kind 31989 event + */ + parseApplicationHandlerRecommendation(event: Event): ApplicationHandlerRecommendation | null { + if (event.kind !== ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION) { + return null + } + + const dTag = event.tags.find(tag => tag[0] === 'd') + if (!dTag || !dTag[1]) { + return null + } + + const supportedKind = parseInt(dTag[1]) + if (isNaN(supportedKind)) { + return null + } + + const handlers = event.tags + .filter(tag => tag[0] === 'a' && tag[1]) + .map(tag => { + const aTag = tag[1] + const parts = aTag.split(':') + if (parts.length !== 3 || parts[0] !== '31990') { + return null + } + + return { + pubkey: parts[1], + identifier: parts[2], + relay: tag[2] || '', + platform: tag[3] + } + }) + .filter((handler): handler is NonNullable => handler !== null) + + return { + supportedKind, + handlers + } + } + + /** + * Create the Jumble ImWald application handler info event + */ + createJumbleImWaldHandlerInfo(pubkey: string): Omit { + const handlerInfo: ApplicationHandlerInfo = { + name: 'Jumble ImWald', + description: 'A modern Nostr client with advanced features for content discovery, discussions, and community building.', + website: 'https://jumble.gitcitadel.eu', + picture: 'https://jumble.gitcitadel.eu/logo.png', + supportedKinds: [ + kinds.ShortTextNote, + kinds.Repost, + kinds.Reaction, + kinds.Zap, + kinds.LongFormArticle, + kinds.Highlights, + ExtendedKind.PICTURE, + ExtendedKind.VIDEO, + ExtendedKind.SHORT_VIDEO, + ExtendedKind.POLL, + ExtendedKind.COMMENT, + ExtendedKind.VOICE, + ExtendedKind.VOICE_COMMENT, + ExtendedKind.DISCUSSION, + ExtendedKind.RELAY_REVIEW, + ExtendedKind.PUBLICATION, + ExtendedKind.WIKI_ARTICLE, + ExtendedKind.WIKI_CHAPTER + ], + platforms: { + web: 'https://jumble.gitcitadel.eu/note/bech32', + ios: 'jumble://note/bech32', + android: 'jumble://note/bech32', + desktop: 'jumble://note/bech32' + }, + relays: [ + 'wss://relay.damus.io', + 'wss://relay.snort.social', + 'wss://nos.lol', + 'wss://relay.nostr.band' + ] + } + + return this.createApplicationHandlerInfoEvent(pubkey, handlerInfo, 'jumble-imwald') + } +} + +export default new Nip89Service()