10 changed files with 535 additions and 9 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
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> |
||||||
) |
) |
||||||
} |
} |
||||||
|
|||||||
@ -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 @@ |
|||||||
|
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