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.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()