10 changed files with 535 additions and 9 deletions
@ -0,0 +1,137 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,78 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -1,17 +1,16 @@
@@ -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 ( |
||||
<span className="text-sm text-muted-foreground shrink-0"> |
||||
{t('via {{client}}', { client: usingClient })} |
||||
</span> |
||||
<Badge variant="outline" className="text-xs px-2 py-1 h-auto"> |
||||
{usingClient} |
||||
</Badge> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,24 @@
@@ -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) |
||||
* } |
||||
*/ |
||||
@ -0,0 +1,250 @@
@@ -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…
Reference in new issue