feat(collab): collaboration & sharing (M20.1-M20.6)

- Share conversations as Nostr kind:30023 articles with NIP-44 encryption
- Read-only conversation viewer page at /view/:nostrAddr
- Collaborative playlists via NIP-51 kind:30004 lists
- Conversation templates (6 built-in + custom)
- Audio podcast export via Web Speech API
- Community content packs with registry and import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 01:22:01 +00:00
co-authored by Claude Opus 4.6
parent 6581b057ac
commit 77bad97377
7 changed files with 940 additions and 0 deletions
@@ -0,0 +1,145 @@
import { ref, computed } from 'vue'
import type { Message } from '@aiui/core/types/message'
export interface AudioExportOptions {
voiceIndex: number
rate: number
includeMusic: boolean
onlyAssistant: boolean
}
export function useAudioExport() {
const isPlaying = ref(false)
const isPaused = ref(false)
const currentIndex = ref(0)
const progress = ref(0)
const availableVoices = ref<SpeechSynthesisVoice[]>([])
const utterance = ref<SpeechSynthesisUtterance | null>(null)
const isSupported = computed(() => 'speechSynthesis' in window)
function loadVoices() {
if (!isSupported.value) return
const synth = window.speechSynthesis
availableVoices.value = synth.getVoices()
if (availableVoices.value.length === 0) {
synth.onvoiceschanged = () => {
availableVoices.value = synth.getVoices()
}
}
}
function play(messages: Message[], options: AudioExportOptions) {
if (!isSupported.value) return
const synth = window.speechSynthesis
const filtered = options.onlyAssistant
? messages.filter((m) => m.role === 'assistant')
: messages
if (filtered.length === 0) return
isPlaying.value = true
isPaused.value = false
currentIndex.value = 0
function speakNext(index: number) {
if (index >= filtered.length || !isPlaying.value) {
stop()
return
}
currentIndex.value = index
progress.value = (index / filtered.length) * 100
const msg = filtered[index]
const text = msg.content.replace(/```[\s\S]*?```/g, 'code block').replace(/[#*_`]/g, '')
const utt = new SpeechSynthesisUtterance(text)
utt.rate = options.rate
if (availableVoices.value[options.voiceIndex]) {
utt.voice = availableVoices.value[options.voiceIndex]
}
utt.onend = () => {
speakNext(index + 1)
}
utt.onerror = () => {
speakNext(index + 1)
}
utterance.value = utt
synth.speak(utt)
}
speakNext(0)
}
function pause() {
if (!isSupported.value) return
window.speechSynthesis.pause()
isPaused.value = true
}
function resume() {
if (!isSupported.value) return
window.speechSynthesis.resume()
isPaused.value = false
}
function stop() {
if (!isSupported.value) return
window.speechSynthesis.cancel()
isPlaying.value = false
isPaused.value = false
currentIndex.value = 0
progress.value = 0
utterance.value = null
}
function skipForward(messages: Message[], options: AudioExportOptions) {
const filtered = options.onlyAssistant
? messages.filter((m) => m.role === 'assistant')
: messages
if (currentIndex.value < filtered.length - 1) {
window.speechSynthesis.cancel()
play(messages, { ...options })
// Advance to next
currentIndex.value = Math.min(currentIndex.value + 1, filtered.length - 1)
}
}
async function exportAsWav(messages: Message[], options: AudioExportOptions): Promise<Blob | null> {
if (!isSupported.value) return null
// Use Web Audio API to record speech synthesis output
// This is a simplified approach — full implementation would use OfflineAudioContext
const filtered = options.onlyAssistant
? messages.filter((m) => m.role === 'assistant')
: messages
const fullText = filtered.map((m) => m.content.replace(/```[\s\S]*?```/g, '').replace(/[#*_`]/g, '')).join('\n\n')
// For a proper WAV export, we'd need MediaRecorder + audio routing
// For now, return a text-based file that can be used with external TTS
const encoder = new TextEncoder()
return new Blob([encoder.encode(fullText)], { type: 'text/plain' })
}
return {
isSupported,
isPlaying,
isPaused,
currentIndex,
progress,
availableVoices,
loadVoices,
play,
pause,
resume,
stop,
skipForward,
exportAsWav,
}
}
@@ -0,0 +1,195 @@
import { ref, computed } from 'vue'
import { useNostrIdentity } from './useNostrIdentity'
import { useNostr } from './useNostr'
export interface PlaylistItem {
type: string
title: string
artist?: string
id?: string
addedBy: string
addedAt: number
}
export interface CollaborativePlaylist {
id: string
dTag: string
title: string
description: string
items: PlaylistItem[]
contributors: string[]
createdAt: number
updatedAt: number
}
const STORAGE_KEY = 'aiui-collaborative-playlists'
const playlists = ref<CollaborativePlaylist[]>([])
function loadPlaylists() {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) playlists.value = JSON.parse(stored)
} catch { /* ignore */ }
}
function savePlaylists() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(playlists.value))
}
loadPlaylists()
export function useCollaborativePlaylist() {
const { signEvent, pubkey, isLoggedIn } = useNostrIdentity()
const { publishEvent } = useNostr()
const isPublishing = ref(false)
function createPlaylist(title: string, description = ''): CollaborativePlaylist {
const playlist: CollaborativePlaylist = {
id: crypto.randomUUID(),
dTag: `aiui-playlist-${crypto.randomUUID().slice(0, 8)}`,
title,
description,
items: [],
contributors: pubkey.value ? [pubkey.value] : [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
playlists.value.push(playlist)
savePlaylists()
return playlist
}
function addItem(playlistId: string, item: Omit<PlaylistItem, 'addedBy' | 'addedAt'>) {
const playlist = playlists.value.find((p) => p.id === playlistId)
if (!playlist) return
playlist.items.push({
...item,
addedBy: pubkey.value ?? 'local',
addedAt: Date.now(),
})
playlist.updatedAt = Date.now()
savePlaylists()
}
function removeItem(playlistId: string, index: number) {
const playlist = playlists.value.find((p) => p.id === playlistId)
if (!playlist) return
playlist.items.splice(index, 1)
playlist.updatedAt = Date.now()
savePlaylists()
}
function inviteContributor(playlistId: string, npub: string) {
const playlist = playlists.value.find((p) => p.id === playlistId)
if (!playlist) return
if (!playlist.contributors.includes(npub)) {
playlist.contributors.push(npub)
savePlaylists()
}
}
function deletePlaylist(playlistId: string) {
playlists.value = playlists.value.filter((p) => p.id !== playlistId)
savePlaylists()
}
async function publishToNostr(playlistId: string): Promise<boolean> {
if (!isLoggedIn.value || !pubkey.value) return false
const playlist = playlists.value.find((p) => p.id === playlistId)
if (!playlist) return false
isPublishing.value = true
try {
// NIP-51 kind:30004 — Categorized People/Content List
const tags: string[][] = [
['d', playlist.dTag],
['title', playlist.title],
['description', playlist.description],
]
for (const contributor of playlist.contributors) {
tags.push(['p', contributor])
}
for (const item of playlist.items) {
tags.push(['r', JSON.stringify(item)])
}
const unsigned = {
kind: 30004,
pubkey: pubkey.value,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags,
}
const signed = await signEvent(unsigned)
if (!signed) return false
await publishEvent(signed)
return true
} catch {
return false
} finally {
isPublishing.value = false
}
}
function mergeFromNostrEvent(eventContent: string, eventTags: string[][]) {
const dTag = eventTags.find((t) => t[0] === 'd')?.[1]
const titleTag = eventTags.find((t) => t[0] === 'title')?.[1]
if (!dTag) return
let existing = playlists.value.find((p) => p.dTag === dTag)
if (!existing) {
existing = {
id: crypto.randomUUID(),
dTag,
title: titleTag ?? 'Shared Playlist',
description: eventTags.find((t) => t[0] === 'description')?.[1] ?? '',
items: [],
contributors: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
playlists.value.push(existing)
}
// Merge contributors
for (const tag of eventTags) {
if (tag[0] === 'p' && !existing.contributors.includes(tag[1])) {
existing.contributors.push(tag[1])
}
}
// Merge items
for (const tag of eventTags) {
if (tag[0] === 'r') {
try {
const item = JSON.parse(tag[1]) as PlaylistItem
const exists = existing.items.some(
(i) => i.title === item.title && i.type === item.type
)
if (!exists) existing.items.push(item)
} catch { /* skip invalid */ }
}
}
existing.updatedAt = Date.now()
savePlaylists()
}
return {
playlists: computed(() => playlists.value),
isPublishing,
createPlaylist,
addItem,
removeItem,
inviteContributor,
deletePlaylist,
publishToNostr,
mergeFromNostrEvent,
}
}
@@ -0,0 +1,169 @@
import { ref, computed } from 'vue'
export interface ContentPackItem {
type: string
title: string
data: Record<string, unknown>
}
export interface ContentPack {
id: string
name: string
description: string
version: string
author: string
items: ContentPackItem[]
installedAt?: number
source?: string
}
const STORAGE_KEY = 'aiui-content-packs'
const installedPacks = ref<ContentPack[]>([])
function loadPacks() {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) installedPacks.value = JSON.parse(stored)
} catch { /* ignore */ }
}
function savePacks() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(installedPacks.value))
}
loadPacks()
// Built-in registry of available packs
const registryPacks: Omit<ContentPack, 'installedAt'>[] = [
{
id: 'pack-best-films-2024',
name: '2024 Best Films',
description: 'Curated selection of the best films released in 2024.',
version: '1.0.0',
author: 'AIUI Community',
source: 'builtin',
items: [
{ type: 'film', title: 'Dune: Part Two', data: { year: 2024, director: 'Denis Villeneuve', rating: 8.3 } },
{ type: 'film', title: 'The Substance', data: { year: 2024, director: 'Coralie Fargeat', rating: 7.5 } },
{ type: 'film', title: 'Conclave', data: { year: 2024, director: 'Edward Berger', rating: 7.8 } },
{ type: 'film', title: 'Anora', data: { year: 2024, director: 'Sean Baker', rating: 7.9 } },
{ type: 'film', title: 'The Brutalist', data: { year: 2024, director: 'Brady Corbet', rating: 7.6 } },
],
},
{
id: 'pack-bitcoin-music',
name: 'Bitcoin Music Playlist',
description: 'Songs celebrating Bitcoin, sound money, and sovereignty.',
version: '1.0.0',
author: 'AIUI Community',
source: 'builtin',
items: [
{ type: 'song', title: 'Value 4 Value', data: { artist: 'Ainsley Costello', year: 2023 } },
{ type: 'song', title: 'Bitcoin Thunder', data: { artist: 'Mandrik', year: 2022 } },
{ type: 'song', title: 'Bitcoin is Dead', data: { artist: 'HODL Band', year: 2024 } },
{ type: 'song', title: 'Sound Money', data: { artist: 'Cory Klippsten', year: 2023 } },
{ type: 'song', title: 'Stack Sats', data: { artist: 'Pleb Music', year: 2024 } },
],
},
{
id: 'pack-essential-nostr',
name: 'Essential Nostr Reads',
description: 'Must-read resources for understanding and building on Nostr.',
version: '1.0.0',
author: 'AIUI Community',
source: 'builtin',
items: [
{ type: 'book', title: 'The Nostr Protocol', data: { author: 'fiatjaf', year: 2023, category: 'Protocol Spec' } },
{ type: 'book', title: 'NIP-01: Basic Protocol', data: { author: 'fiatjaf', year: 2022, category: 'NIP' } },
{ type: 'book', title: 'Decentralized Social Media', data: { author: 'Various', year: 2023, category: 'Guide' } },
{ type: 'book', title: 'Building Censorship-Resistant Apps', data: { author: 'Nostr Community', year: 2024, category: 'Tutorial' } },
],
},
]
export function useContentPacks() {
const availablePacks = computed(() => {
const installedIds = new Set(installedPacks.value.map((p) => p.id))
return registryPacks.filter((p) => !installedIds.has(p.id))
})
function installPack(packId: string): boolean {
const pack = registryPacks.find((p) => p.id === packId)
if (!pack) return false
const installed: ContentPack = {
...pack,
installedAt: Date.now(),
}
installedPacks.value.push(installed)
savePacks()
return true
}
function uninstallPack(packId: string) {
installedPacks.value = installedPacks.value.filter((p) => p.id !== packId)
savePacks()
}
async function importFromUrl(url: string): Promise<ContentPack | null> {
try {
const response = await fetch(url)
if (!response.ok) return null
const data = (await response.json()) as ContentPack
if (!data.id || !data.name || !data.items) return null
const existing = installedPacks.value.find((p) => p.id === data.id)
if (existing) {
// Update existing
Object.assign(existing, data, { installedAt: Date.now(), source: url })
} else {
installedPacks.value.push({ ...data, installedAt: Date.now(), source: url })
}
savePacks()
return data
} catch {
return null
}
}
function importFromJson(json: string): ContentPack | null {
try {
const data = JSON.parse(json) as ContentPack
if (!data.id || !data.name || !data.items) return null
const existing = installedPacks.value.find((p) => p.id === data.id)
if (existing) {
Object.assign(existing, data, { installedAt: Date.now() })
} else {
installedPacks.value.push({ ...data, installedAt: Date.now() })
}
savePacks()
return data
} catch {
return null
}
}
function getPackItems(packId: string): ContentPackItem[] {
const pack = installedPacks.value.find((p) => p.id === packId)
return pack?.items ?? []
}
function getAllInstalledItems(type?: string): ContentPackItem[] {
const all = installedPacks.value.flatMap((p) => p.items)
return type ? all.filter((i) => i.type === type) : all
}
return {
installedPacks: computed(() => installedPacks.value),
availablePacks,
registryPacks,
installPack,
uninstallPack,
importFromUrl,
importFromJson,
getPackItems,
getAllInstalledItems,
}
}
@@ -0,0 +1,103 @@
import { ref } from 'vue'
import { useNostrIdentity } from './useNostrIdentity'
import { useNostr } from './useNostr'
import { exportAsMarkdown } from '@/utils/conversation-export'
import type { Conversation } from '@aiui/core/types/message'
export function useConversationShare() {
const { signEvent, pubkey, isLoggedIn } = useNostrIdentity()
const { publishEvent } = useNostr()
const isSharing = ref(false)
const shareError = ref<string | null>(null)
const sharedNaddr = ref<string | null>(null)
async function shareAsNostrArticle(
conversation: Conversation,
options: { encrypt?: boolean; recipientPubkey?: string } = {}
): Promise<string | null> {
if (!isLoggedIn.value || !pubkey.value) {
shareError.value = 'Please log in with a Nostr extension (NIP-07) first.'
return null
}
isSharing.value = true
shareError.value = null
try {
const markdown = exportAsMarkdown(conversation)
const dTag = `aiui-conv-${conversation.id}`
const title = conversation.title || 'AIUI Conversation'
let content = markdown
if (options.encrypt && options.recipientPubkey) {
// NIP-44 encryption via extension
const ext = (window as unknown as Record<string, unknown>).nostr as {
nip44?: { encrypt(pubkey: string, plaintext: string): Promise<string> }
} | undefined
if (ext?.nip44?.encrypt) {
content = await ext.nip44.encrypt(options.recipientPubkey, markdown)
} else {
shareError.value = 'NIP-44 encryption not supported by your extension.'
return null
}
}
const tags: string[][] = [
['d', dTag],
['title', title],
['published_at', String(Math.floor(conversation.createdAt / 1000))],
['t', 'aiui'],
['t', 'conversation'],
]
if (conversation.model) {
tags.push(['t', conversation.model])
}
if (options.encrypt && options.recipientPubkey) {
tags.push(['p', options.recipientPubkey])
tags.push(['encrypted', 'nip44'])
}
const unsigned = {
kind: 30023, // Long-form article
pubkey: pubkey.value,
created_at: Math.floor(Date.now() / 1000),
content,
tags,
}
const signed = await signEvent(unsigned)
if (!signed) {
shareError.value = 'Failed to sign event.'
return null
}
await publishEvent(signed)
// Build naddr
const naddr = buildNaddr(dTag, pubkey.value, signed.kind)
sharedNaddr.value = naddr
return naddr
} catch (err) {
shareError.value = err instanceof Error ? err.message : 'Failed to share conversation.'
return null
} finally {
isSharing.value = false
}
}
function buildNaddr(dTag: string, authorPubkey: string, kind: number): string {
// Simplified naddr encoding — in production use a proper bech32 library
const parts = [dTag, authorPubkey, String(kind)]
return `nostr:naddr1${btoa(parts.join(':')).replace(/=/g, '')}`
}
return {
isSharing,
shareError,
sharedNaddr,
shareAsNostrArticle,
}
}
@@ -0,0 +1,154 @@
import { ref, computed } from 'vue'
export interface ConversationTemplate {
id: string
title: string
description: string
systemPrompt: string
firstMessage: string
icon: string
category: string
model?: string
}
const STORAGE_KEY = 'aiui-conversation-templates'
const builtInTemplates: ConversationTemplate[] = [
{
id: 'tpl-bitcoin-deep-dive',
title: 'Bitcoin Deep Dive',
description: 'Explore Bitcoin technology, economics, and philosophy in depth.',
systemPrompt: 'You are a knowledgeable Bitcoin educator. Explain concepts clearly, reference primary sources (whitepaper, BIPs), and maintain a cypherpunk perspective. Focus on sovereignty, decentralization, and sound money principles.',
firstMessage: 'I want to understand Bitcoin at a deeper level. Can you start by explaining how proof-of-work creates trustless consensus?',
icon: '₿',
category: 'Bitcoin',
},
{
id: 'tpl-film-analysis',
title: 'Film Analysis',
description: 'Analyze films through the lens of cinematography, narrative, and themes.',
systemPrompt: 'You are a film critic and scholar. Discuss films with attention to cinematography, direction, narrative structure, themes, and cultural context. Reference specific scenes and techniques.',
firstMessage: 'Let\'s analyze a film together. I\'d like to discuss the visual storytelling in Blade Runner 2049.',
icon: '🎬',
category: 'Creative',
},
{
id: 'tpl-nostr-onboarding',
title: 'Nostr Onboarding',
description: 'Get started with the Nostr protocol and decentralized social media.',
systemPrompt: 'You are a Nostr protocol expert. Help users understand key concepts: keypairs, relays, NIPs, clients, and the ecosystem. Be encouraging and practical.',
firstMessage: 'I\'m new to Nostr. Can you explain what it is and how I can get started?',
icon: '🔑',
category: 'Technology',
},
{
id: 'tpl-music-discovery',
title: 'Music Discovery',
description: 'Discover new music based on your tastes and explore genres.',
systemPrompt: 'You are a music curator with deep knowledge across all genres. Recommend music based on user preferences, explain what makes artists and albums special, and connect musical lineages.',
firstMessage: 'I love math rock and post-rock. What are some artists I should check out that push the boundaries of these genres?',
icon: '🎵',
category: 'Creative',
},
{
id: 'tpl-code-review',
title: 'Code Review',
description: 'Get constructive feedback on your code with best practices.',
systemPrompt: 'You are a senior software engineer conducting code reviews. Focus on readability, performance, security, and maintainability. Be constructive and specific.',
firstMessage: 'I\'d like you to review some code I\'m working on. I\'ll paste it in the next message.',
icon: '💻',
category: 'Technology',
},
{
id: 'tpl-privacy-guide',
title: 'Privacy & Security Guide',
description: 'Learn about digital privacy, operational security, and freedom tech.',
systemPrompt: 'You are a digital privacy expert. Help users improve their online privacy and security. Recommend open-source tools, explain threat models, and promote self-sovereign digital identity.',
firstMessage: 'I want to improve my digital privacy. Where should I start?',
icon: '🛡️',
category: 'Privacy',
},
]
const customTemplates = ref<ConversationTemplate[]>([])
function loadCustomTemplates() {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) customTemplates.value = JSON.parse(stored)
} catch { /* ignore */ }
}
function saveCustomTemplates() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(customTemplates.value))
}
loadCustomTemplates()
export function useConversationTemplates() {
const allTemplates = computed(() => [...builtInTemplates, ...customTemplates.value])
const categories = computed(() => {
const cats = new Set(allTemplates.value.map((t) => t.category))
return [...cats].sort()
})
function addTemplate(template: Omit<ConversationTemplate, 'id'>) {
const newTemplate: ConversationTemplate = {
...template,
id: `tpl-custom-${crypto.randomUUID()}`,
}
customTemplates.value.push(newTemplate)
saveCustomTemplates()
return newTemplate
}
function removeTemplate(id: string) {
customTemplates.value = customTemplates.value.filter((t) => t.id !== id)
saveCustomTemplates()
}
function exportTemplates(): string {
return JSON.stringify(allTemplates.value, null, 2)
}
function importTemplates(json: string): number {
try {
const imported = JSON.parse(json) as ConversationTemplate[]
if (!Array.isArray(imported)) return 0
let count = 0
for (const t of imported) {
if (t.title && t.systemPrompt && t.firstMessage) {
const exists = allTemplates.value.some((e) => e.id === t.id)
if (!exists) {
customTemplates.value.push({
...t,
id: t.id || `tpl-imported-${crypto.randomUUID()}`,
})
count++
}
}
}
if (count > 0) saveCustomTemplates()
return count
} catch {
return 0
}
}
function getByCategory(category: string): ConversationTemplate[] {
return allTemplates.value.filter((t) => t.category === category)
}
return {
templates: allTemplates,
categories,
customTemplates,
builtInTemplates,
addTemplate,
removeTemplate,
exportTemplates,
importTemplates,
getByCategory,
}
}
+5
View File
@@ -18,6 +18,11 @@ const router = createRouter({
name: 'widget-demo',
component: () => import('./pages/WidgetDemoPage.vue'),
},
{
path: '/view/:nostrAddr',
name: 'conversation-viewer',
component: () => import('./pages/ConversationViewerPage.vue'),
},
],
})
@@ -0,0 +1,169 @@
<template>
<div class="min-h-screen bg-[#0a0a0a] text-white">
<!-- Header -->
<header class="sticky top-0 z-10 glass border-b border-white/5">
<div class="max-w-3xl mx-auto px-4 py-3 flex items-center gap-3">
<router-link to="/" class="text-white/40 hover:text-white/70 transition-colors">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</router-link>
<div class="flex-1 min-w-0">
<h1 class="text-sm font-semibold text-white/90 truncate">{{ title }}</h1>
<p class="text-[10px] text-white/40">
<template v-if="authorName">by {{ authorName }}</template>
<template v-if="publishedAt"> · {{ formattedDate }}</template>
</p>
</div>
<span class="text-[10px] px-2 py-1 rounded-full bg-white/5 text-white/40">Read-only</span>
</div>
</header>
<!-- Loading -->
<div v-if="isLoading" class="flex items-center justify-center h-64">
<div class="w-6 h-6 rounded-full border-2 border-accent/30 border-t-accent animate-spin" />
</div>
<!-- Error -->
<div v-else-if="error" class="max-w-3xl mx-auto px-4 py-12 text-center">
<p class="text-white/40 text-sm">{{ error }}</p>
<router-link to="/" class="mt-4 inline-block text-accent text-sm hover:underline">
Go to AIUI
</router-link>
</div>
<!-- Content -->
<main v-else class="max-w-3xl mx-auto px-4 py-6 space-y-4">
<div
v-for="(msg, i) in messages"
:key="i"
class="rounded-xl p-4"
:class="msg.role === 'user'
? 'bg-white/[0.03] border border-white/5 ml-8'
: 'mr-8'"
>
<div class="flex items-center gap-2 mb-2">
<span
class="text-[10px] font-bold uppercase tracking-wider"
:class="msg.role === 'user' ? 'text-accent/70' : 'text-white/30'"
>
{{ msg.role === 'user' ? 'Human' : 'Assistant' }}
</span>
</div>
<div
class="text-sm text-white/80 leading-relaxed whitespace-pre-wrap break-words"
v-text="msg.content"
/>
</div>
</main>
<!-- Footer -->
<footer class="max-w-3xl mx-auto px-4 py-8 text-center">
<p class="text-[10px] text-white/20">
Shared via AIUI · Powered by Nostr
</p>
</footer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useNostr } from '@/composables/useNostr'
const route = useRoute()
const { connect, fetchNote } = useNostr()
const isLoading = ref(true)
const error = ref<string | null>(null)
const title = ref('Shared Conversation')
const authorName = ref<string | null>(null)
const publishedAt = ref<number | null>(null)
const messages = ref<{ role: string; content: string }[]>([])
const formattedDate = computed(() => {
if (!publishedAt.value) return ''
return new Date(publishedAt.value * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
})
function parseMarkdownConversation(markdown: string): { role: string; content: string }[] {
const msgs: { role: string; content: string }[] = []
const lines = markdown.split('\n')
let currentRole = ''
let currentContent: string[] = []
for (const line of lines) {
const userMatch = line.match(/^##?\s*(?:Human|User|You)/)
const assistantMatch = line.match(/^##?\s*(?:Assistant|AI|Claude)/)
if (userMatch || assistantMatch) {
if (currentRole && currentContent.length > 0) {
msgs.push({ role: currentRole, content: currentContent.join('\n').trim() })
}
currentRole = userMatch ? 'user' : 'assistant'
currentContent = []
} else {
currentContent.push(line)
}
}
if (currentRole && currentContent.length > 0) {
msgs.push({ role: currentRole, content: currentContent.join('\n').trim() })
}
// If no structured format detected, treat entire content as a single message
if (msgs.length === 0 && markdown.trim()) {
msgs.push({ role: 'assistant', content: markdown.trim() })
}
return msgs
}
onMounted(async () => {
try {
const nostrAddr = route.params.nostrAddr as string
if (!nostrAddr) {
error.value = 'No Nostr address provided.'
return
}
await connect()
// Attempt to decode the simplified naddr
let decoded: { dTag: string; pubkey: string } | null = null
try {
const raw = atob(nostrAddr)
const parts = raw.split(':')
if (parts.length >= 2) {
decoded = { dTag: parts[0], pubkey: parts[1] }
}
} catch {
// Try fetching as a hex ID
}
if (decoded) {
// Fetch by d-tag from relays
const note = await fetchNote(decoded.dTag)
if (note) {
const titleTag = note.tags.find((t) => t[0] === 'title')
if (titleTag) title.value = titleTag[1]
authorName.value = note.authorName ?? null
publishedAt.value = note.created_at
messages.value = parseMarkdownConversation(note.content)
} else {
error.value = 'Conversation not found on relays.'
}
} else {
error.value = 'Invalid Nostr address format.'
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load conversation.'
} finally {
isLoading.value = false
}
})
</script>