Browse Source

update client tags

imwald
Silberengel 5 months ago
parent
commit
3fb100a421
  1. 137
      src/components/ApplicationHandlerInfo/index.tsx
  2. 78
      src/components/ApplicationHandlerRecommendation/index.tsx
  3. 9
      src/components/ClientTag/index.tsx
  4. 10
      src/components/ContentPreview/index.tsx
  5. 10
      src/constants.ts
  6. 13
      src/lib/draft-event.ts
  7. 8
      src/lib/event.ts
  8. 24
      src/lib/nip89-utils.ts
  9. 5
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx
  10. 250
      src/services/nip89.service.ts

137
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 (
<Button
key={platform}
variant="outline"
size="sm"
onClick={() => handlePlatformClick(url)}
className="flex items-center gap-2"
>
<Icon className="w-4 h-4" />
{platformName}
</Button>
)
})
return (
<Card className={className}>
<CardHeader>
<div className="flex items-start gap-4">
{handlerInfo.picture && (
<img
src={handlerInfo.picture}
alt={handlerInfo.name}
className="w-16 h-16 rounded-lg object-cover"
/>
)}
<div className="flex-1 min-w-0">
<CardTitle className="text-xl">{handlerInfo.name}</CardTitle>
{handlerInfo.description && (
<CardDescription className="mt-2">
{handlerInfo.description}
</CardDescription>
)}
{handlerInfo.website && (
<a
href={handlerInfo.website}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 mt-2"
>
<ExternalLink className="w-4 h-4" />
{handlerInfo.website}
</a>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Supported Event Kinds */}
<div>
<h4 className="text-sm font-semibold mb-2">
{t('Supported Event Types')}
</h4>
<div className="flex flex-wrap gap-1">
{handlerInfo.supportedKinds.map(kind => (
<Badge key={kind} variant="secondary">
Kind {kind}
</Badge>
))}
</div>
</div>
{/* Platform Access */}
{platformButtons.length > 0 && (
<div>
<h4 className="text-sm font-semibold mb-2">
{t('Access via')}
</h4>
<div className="flex flex-wrap gap-2">
{platformButtons}
</div>
</div>
)}
{/* Relays */}
{handlerInfo.relays.length > 0 && (
<div>
<h4 className="text-sm font-semibold mb-2">
{t('Recommended Relays')}
</h4>
<div className="space-y-1">
{handlerInfo.relays.map((relay, index) => (
<div key={index} className="text-sm text-muted-foreground font-mono">
{relay}
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
)
}

78
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 (
<Card className={className}>
<CardHeader>
<CardTitle className="text-lg">
{t('Application Recommendations')}
</CardTitle>
<CardDescription>
{t('Recommended applications for handling events of kind {{kind}}', {
kind: recommendation.supportedKind
})}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{recommendation.handlers.map((handler, index) => (
<div key={index} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">
{t('Handler {{index}}', { index: index + 1 })}
</div>
<div className="text-xs text-muted-foreground font-mono">
{handler.pubkey.substring(0, 16)}...{handler.pubkey.substring(-8)}
</div>
{handler.identifier && (
<div className="text-xs text-muted-foreground">
ID: {handler.identifier}
</div>
)}
{handler.relay && (
<div className="text-xs text-muted-foreground font-mono">
{handler.relay}
</div>
)}
</div>
<div className="flex items-center gap-2">
{handler.platform && (
<Badge variant="outline">
{handler.platform}
</Badge>
)}
<Badge variant="secondary">
Kind {recommendation.supportedKind}
</Badge>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)
}

9
src/components/ClientTag/index.tsx

@ -1,17 +1,16 @@
import { getUsingClient } from '@/lib/event' import { getUsingClient } from '@/lib/event'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { Badge } from '@/components/ui/badge'
export default function ClientTag({ event }: { event: NostrEvent }) { export default function ClientTag({ event }: { event: NostrEvent }) {
const { t } = useTranslation()
const usingClient = useMemo(() => getUsingClient(event), [event]) const usingClient = useMemo(() => getUsingClient(event), [event])
if (!usingClient) return null if (!usingClient) return null
return ( return (
<span className="text-sm text-muted-foreground shrink-0"> <Badge variant="outline" className="text-xs px-2 py-1 h-auto">
{t('via {{client}}', { client: usingClient })} {usingClient}
</span> </Badge>
) )
} }

10
src/components/ContentPreview/index.tsx

@ -17,6 +17,8 @@ import PollPreview from './PollPreview'
import VideoNotePreview from './VideoNotePreview' import VideoNotePreview from './VideoNotePreview'
import ZapPreview from './ZapPreview' import ZapPreview from './ZapPreview'
import DiscussionNote from '../DiscussionNote' import DiscussionNote from '../DiscussionNote'
import ApplicationHandlerInfo from '../ApplicationHandlerInfo'
import ApplicationHandlerRecommendation from '../ApplicationHandlerRecommendation'
export default function ContentPreview({ export default function ContentPreview({
event, event,
@ -111,5 +113,13 @@ export default function ContentPreview({
return <ZapPreview event={event} className={className} /> return <ZapPreview event={event} className={className} />
} }
if (event.kind === ExtendedKind.APPLICATION_HANDLER_INFO) {
return <ApplicationHandlerInfo event={event} className={className} />
}
if (event.kind === ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION) {
return <ApplicationHandlerRecommendation event={event} className={className} />
}
return <div className={className}>[{t('Cannot handle event of kind k', { k: event.kind })}]</div> return <div className={className}>[{t('Cannot handle event of kind k', { k: event.kind })}]</div>
} }

10
src/constants.ts

@ -130,7 +130,10 @@ export const ExtendedKind = {
ZAP_RECEIPT: 9735, ZAP_RECEIPT: 9735,
PUBLICATION: 30040, PUBLICATION: 30040,
WIKI_ARTICLE: 30818, 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 = [ export const SUPPORTED_KINDS = [
@ -151,7 +154,10 @@ export const SUPPORTED_KINDS = [
ExtendedKind.ZAP_RECEIPT, ExtendedKind.ZAP_RECEIPT,
ExtendedKind.PUBLICATION, ExtendedKind.PUBLICATION,
ExtendedKind.WIKI_ARTICLE, 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 = export const URL_REGEX =

13
src/lib/draft-event.ts

@ -868,7 +868,18 @@ function buildResponseTag(value: string) {
return ['response', value] 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'] return ['client', 'jumble']
} }

8
src/lib/event.ts

@ -194,7 +194,13 @@ export function getNoteBech32Id(event: Event) {
} }
export function getUsingClient(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) { export function getImetaInfosFromEvent(event: Event) {

24
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<Event, 'id' | 'sig'> {
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)
* }
*/

5
src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx

@ -45,6 +45,10 @@ function buildClientTag(): string[] {
return ['client', 'jumble'] return ['client', 'jumble']
} }
function buildAltTag(): string[] {
return ['alt', 'This event was published by https://jumble.imwald.eu.']
}
interface DynamicTopic { interface DynamicTopic {
id: string id: string
@ -406,6 +410,7 @@ export default function CreateThreadDialog({
// Add client tag if enabled // Add client tag if enabled
if (addClientTag) { if (addClientTag) {
tags.push(buildClientTag()) tags.push(buildClientTag())
tags.push(buildAltTag())
} }
// Create the thread event (kind 11) // Create the thread event (kind 11)

250
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<Event, 'id' | 'sig'> {
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<Event, 'id' | 'sig'> {
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<typeof handler> => handler !== null)
return {
supportedKind,
handlers
}
}
/**
* Create the Jumble ImWald application handler info event
*/
createJumbleImWaldHandlerInfo(pubkey: string): Omit<Event, 'id' | 'sig'> {
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()
Loading…
Cancel
Save