Update package dependencies and enhance application structure

- Added several new dependencies related to the Applesauce library, including 'applesauce-accounts', 'applesauce-common', 'applesauce-core', 'applesauce-loaders', 'applesauce-relay', and 'applesauce-signers', all at version 5.1.0.
- Updated the development script in package.json to specify a port for Vite and added new seed scripts for profiles and activity.
- Removed outdated image files from the public directory to clean up unused assets.
- Enhanced the App.vue structure by integrating shared components like AppHeader and AuthModal for improved user experience.
- Refactored ContentDetailModal and MobileNav components to support new features and improve usability.

These changes improve the overall functionality and maintainability of the application while ensuring it utilizes the latest libraries for better performance.
This commit is contained in:
Dorian
2026-02-12 12:24:58 +00:00
parent c970f5b29f
commit 725896673c
42 changed files with 3767 additions and 1329 deletions

View File

@@ -0,0 +1,151 @@
import { ref, computed, onUnmounted } from 'vue'
import { Accounts } from 'applesauce-accounts'
import { accountManager } from '../lib/accounts'
import { TEST_PERSONAS, TASTEMAKER_PERSONAS } from '../data/testPersonas'
import type { Subscription } from 'rxjs'
type Persona = { name: string; nsec: string; pubkey: string }
/**
* Vue composable for Nostr account management.
* Wraps applesauce AccountManager observables as Vue reactive refs.
* Provides login methods for Extension, test personas, and private keys.
*/
export function useAccounts() {
const activeAccount = ref<any>(null)
const allAccounts = ref<any[]>([])
const isLoggingIn = ref(false)
const loginError = ref<string | null>(null)
const subscriptions: Subscription[] = []
// Subscribe to active account changes
const activeSub = accountManager.active$.subscribe((account) => {
activeAccount.value = account ?? null
})
subscriptions.push(activeSub)
// Subscribe to all accounts changes
const accountsSub = accountManager.accounts$.subscribe((accounts) => {
allAccounts.value = [...accounts]
})
subscriptions.push(accountsSub)
const isLoggedIn = computed(() => activeAccount.value !== null)
const activePubkey = computed(() => activeAccount.value?.pubkey ?? null)
const activeName = computed(() => {
if (!activeAccount.value) return null
// Check test personas for name
const allPersonas: Persona[] = [
...(TEST_PERSONAS as unknown as Persona[]),
...(TASTEMAKER_PERSONAS as unknown as Persona[]),
]
const match = allPersonas.find(
(p) => p.pubkey === activeAccount.value?.pubkey,
)
return match?.name ?? activeAccount.value?.pubkey?.slice(0, 8) ?? 'Unknown'
})
/**
* Login with a browser extension (NIP-07)
*/
async function loginWithExtension() {
isLoggingIn.value = true
loginError.value = null
try {
const account = await Accounts.ExtensionAccount.fromExtension()
accountManager.addAccount(account)
accountManager.setActive(account)
} catch (err: any) {
loginError.value = err.message || 'Extension login failed'
console.error('Extension login error:', err)
} finally {
isLoggingIn.value = false
}
}
/**
* Login with a test persona (for development)
*/
async function loginWithPersona(persona: Persona) {
isLoggingIn.value = true
loginError.value = null
try {
const account = Accounts.PrivateKeyAccount.fromKey(persona.nsec)
accountManager.addAccount(account)
accountManager.setActive(account)
} catch (err: any) {
loginError.value = err.message || 'Persona login failed'
console.error('Persona login error:', err)
} finally {
isLoggingIn.value = false
}
}
/**
* Login with a private key (nsec)
*/
async function loginWithPrivateKey(nsec: string) {
isLoggingIn.value = true
loginError.value = null
try {
const account = Accounts.PrivateKeyAccount.fromKey(nsec)
accountManager.addAccount(account)
accountManager.setActive(account)
} catch (err: any) {
loginError.value = err.message || 'Private key login failed'
console.error('Private key login error:', err)
} finally {
isLoggingIn.value = false
}
}
/**
* Logout current account
*/
function logout() {
const current = accountManager.active
if (current) {
accountManager.removeAccount(current)
}
accountManager.setActive(null as any)
}
/**
* Get all available test personas
*/
const testPersonas = computed(() => [
...(TEST_PERSONAS as unknown as Persona[]),
])
const tastemakerPersonas = computed(() => [
...(TASTEMAKER_PERSONAS as unknown as Persona[]),
])
// Cleanup on unmount
onUnmounted(() => {
subscriptions.forEach((sub) => sub.unsubscribe())
})
return {
// State
activeAccount,
allAccounts,
isLoggedIn,
isLoggingIn,
loginError,
activePubkey,
activeName,
// Login methods
loginWithExtension,
loginWithPersona,
loginWithPrivateKey,
logout,
// Personas for dev UI
testPersonas,
tastemakerPersonas,
}
}

View File

@@ -0,0 +1,292 @@
import { ref, computed } from 'vue'
import { COMMENT_KIND } from 'applesauce-common/helpers'
import { mapEventsToStore } from 'applesauce-core'
import { onlyEvents } from 'applesauce-relay'
import type { NostrEvent } from 'applesauce-core/helpers/event'
import type { Subscription } from 'rxjs'
import { eventStore, pool, APP_RELAYS } from '../lib/nostr'
import { TASTEMAKER_PUBKEYS } from '../data/testPersonas'
import { getExternalContentId } from './useNostr'
import type { Content } from '../types/content'
// --- Algorithm Definitions ---
export const ALGORITHMS = [
{ id: 'popularity', label: 'Popularity' },
{ id: 'trending', label: 'Trending' },
{ id: 'most-zapped', label: 'Most Zapped' },
{ id: 'most-reviews', label: 'Most Reviews' },
{ id: 'tastemaker', label: 'Tastemaker' },
] as const
export type AlgorithmId = (typeof ALGORITHMS)[number]['id'] | null
export interface ContentStats {
plusCount: number
minusCount: number
commentCount: number
reviewCount: number
recentEvents: NostrEvent[]
}
const SEVEN_DAYS = 7 * 24 * 60 * 60
// --- Shared Module State (singleton) ---
const activeAlgorithm = ref<AlgorithmId>(null)
const contentStatsMap = ref<Map<string, ContentStats>>(new Map())
const isDiscoveryLoading = ref(false)
let subscriptionRefs: Subscription[] = []
let initialized = false
function getTagValue(event: NostrEvent, tagName: string): string | undefined {
return event.tags.find((t) => t[0] === tagName)?.[1]
}
/**
* Rebuild the stats map from current EventStore data
*/
function rebuildStats() {
const map = new Map<string, ContentStats>()
const getOrCreate = (id: string): ContentStats => {
let stats = map.get(id)
if (!stats) {
stats = {
plusCount: 0,
minusCount: 0,
commentCount: 0,
reviewCount: 0,
recentEvents: [],
}
map.set(id, stats)
}
return stats
}
// Process reactions from the EventStore
const reactions = eventStore.getByFilters([{ kinds: [17], '#k': ['web'] }])
if (reactions) {
for (const event of reactions) {
const externalId = getTagValue(event, 'i')
if (!externalId) continue
const stats = getOrCreate(externalId)
if (event.content === '+') stats.plusCount++
else if (event.content === '-') stats.minusCount++
stats.recentEvents.push(event)
}
}
// Process comments from the EventStore
const comments = eventStore.getByFilters([
{ kinds: [COMMENT_KIND], '#K': ['web'] },
])
if (comments) {
for (const event of comments) {
const externalId = getTagValue(event, 'I')
if (!externalId) continue
const stats = getOrCreate(externalId)
stats.commentCount++
// Top-level comments also have #i (direct ref to external content)
if (getTagValue(event, 'i')) stats.reviewCount++
stats.recentEvents.push(event)
}
}
contentStatsMap.value = map
}
/**
* Start relay subscriptions for global discovery data.
* Only runs once; subsequent calls are no-ops.
*/
function initSubscriptions() {
if (initialized) return
initialized = true
isDiscoveryLoading.value = true
const currentRelays = APP_RELAYS
// Subscribe to all web reactions (kind 17)
const reactionSub = pool
.subscription(currentRelays, [{ kinds: [17], '#k': ['web'] }])
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
next: () => rebuildStats(),
error: (err) => console.error('Discovery reaction subscription error:', err),
})
subscriptionRefs.push(reactionSub)
// Subscribe to all web comments (kind 1111)
const commentSub = pool
.subscription(currentRelays, [
{ kinds: [COMMENT_KIND], '#K': ['web'] },
])
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
next: () => rebuildStats(),
error: (err) => console.error('Discovery comment subscription error:', err),
})
subscriptionRefs.push(commentSub)
// Initial build from any already-cached events
rebuildStats()
// Mark loading done after initial data window
setTimeout(() => {
isDiscoveryLoading.value = false
}, 2000)
}
// --- Public Composable ---
const EMPTY_STATS: ContentStats = {
plusCount: 0,
minusCount: 0,
commentCount: 0,
reviewCount: 0,
recentEvents: [],
}
/**
* Composable providing discovery algorithm state and content sorting.
*
* Shared state: `activeAlgorithm` and `contentStatsMap` are singletons
* so that the header, mobile nav, and browse view all see the same state.
*/
export function useContentDiscovery() {
// Ensure relay subscriptions are running
initSubscriptions()
/**
* Sort an array of Content items by the active algorithm.
* Returns content as-is when no filter is active (null).
*/
const sortContent = computed(() => {
return (contents: Content[], overrideAlgo?: ActiveAlgorithmId): Content[] => {
if (!contents || contents.length === 0) return contents
const effectiveAlgo: ActiveAlgorithmId | null = overrideAlgo ?? activeAlgorithm.value
if (!effectiveAlgo) return contents
const statsMap = contentStatsMap.value
const origin = window.location.origin
// Build entries array: [Content, stats] for each content item
const withStats: [Content, ContentStats][] = contents.map((c) => {
const externalId = `${origin}/content/${c.id}`
return [c, statsMap.get(externalId) || EMPTY_STATS]
})
// Sort entries
const sortedEntries = sortContentEntries(withStats, effectiveAlgo)
return sortedEntries.map(([content]) => content)
}
})
/**
* The label for the currently active algorithm, or null if none.
*/
const activeAlgorithmLabel = computed(() => {
if (!activeAlgorithm.value) return null
return ALGORITHMS.find((a) => a.id === activeAlgorithm.value)?.label ?? null
})
/**
* Whether a filter is currently active
*/
const isFilterActive = computed(() => activeAlgorithm.value !== null)
/**
* Set algorithm. Clicking the same algorithm again toggles it off.
*/
function setAlgorithm(algo: AlgorithmId) {
if (activeAlgorithm.value === algo) {
activeAlgorithm.value = null
} else {
activeAlgorithm.value = algo
}
}
/**
* Get stats for a specific content item
*/
function getStats(contentId: string): ContentStats {
const externalId = getExternalContentId(contentId)
return contentStatsMap.value.get(externalId) || EMPTY_STATS
}
return {
// State
activeAlgorithm,
activeAlgorithmLabel,
isFilterActive,
algorithms: ALGORITHMS,
contentStatsMap,
isDiscoveryLoading,
// Methods
sortContent,
setAlgorithm,
getStats,
}
}
// --- Internal sorting helper for Content tuples ---
type ActiveAlgorithmId = Exclude<AlgorithmId, null>
function sortContentEntries(
entries: [Content, ContentStats][],
algo: ActiveAlgorithmId,
): [Content, ContentStats][] {
const now = Math.floor(Date.now() / 1000)
switch (algo) {
case 'popularity':
return [...entries].sort(
(a: [Content, ContentStats], b: [Content, ContentStats]) =>
(b[1].plusCount - b[1].minusCount + b[1].commentCount) -
(a[1].plusCount - a[1].minusCount + a[1].commentCount),
)
case 'trending': {
const trendScore = (stats: ContentStats) => {
let score = 0
for (const e of stats.recentEvents) {
score += e.created_at > now - SEVEN_DAYS ? 2 : 1
}
return score
}
return [...entries].sort(
(a: [Content, ContentStats], b: [Content, ContentStats]) =>
trendScore(b[1]) - trendScore(a[1]),
)
}
case 'most-zapped':
return [...entries].sort(
(a: [Content, ContentStats], b: [Content, ContentStats]) =>
b[1].plusCount - a[1].plusCount,
)
case 'most-reviews':
return [...entries].sort(
(a: [Content, ContentStats], b: [Content, ContentStats]) =>
b[1].reviewCount - a[1].reviewCount,
)
case 'tastemaker': {
if (TASTEMAKER_PUBKEYS.length === 0) {
return sortContentEntries(entries, 'popularity')
}
const filtered = entries.filter(([, stats]) =>
stats.recentEvents.some((e) => TASTEMAKER_PUBKEYS.includes(e.pubkey)),
)
return sortContentEntries(
filtered.length > 0 ? filtered : entries,
'popularity',
)
}
}
}

View File

@@ -1,316 +1,440 @@
import { ref, computed, onUnmounted } from 'vue'
import { nostrClient } from '../lib/nostr'
import { getNostrContentIdentifier } from '../utils/mappers'
import { getMockComments, getMockReactions, getMockProfile, mockProfiles } from '../data/mockSocialData'
import type { Event as NostrEvent } from 'nostr-tools'
import { COMMENT_KIND } from 'applesauce-common/helpers'
import { mapEventsToStore } from 'applesauce-core'
import { onlyEvents } from 'applesauce-relay'
import type { NostrEvent } from 'applesauce-core/helpers/event'
import type { Subscription } from 'rxjs'
import { eventStore, pool, appRelays, factory, APP_RELAYS } from '../lib/nostr'
import { accountManager } from '../lib/accounts'
import { useObservable } from './useObservable'
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
/**
* Build the external content identifier used in Nostr tags.
* Matches indeehub convention: {origin}/content/{contentId}
*/
export function getExternalContentId(contentId: string): string {
return `${window.location.origin}/content/${contentId}`
}
// --- Comment Tree Types ---
export interface CommentNode {
event: NostrEvent
replies: CommentNode[]
}
/**
* Build a threaded comment tree from flat event arrays.
* Top-level comments have #i = externalId.
* Replies reference parent via #e tag.
*/
function buildCommentTree(
topLevel: NostrEvent[],
allInThread: NostrEvent[],
): CommentNode[] {
// Group replies by parent event ID
const childrenMap = new Map<string, NostrEvent[]>()
const topLevelIds = new Set(topLevel.map((e) => e.id))
for (const event of allInThread) {
// Skip if it's a top-level comment
if (topLevelIds.has(event.id)) continue
const parentTag = event.tags.find((t) => t[0] === 'e')
if (parentTag) {
const parentId = parentTag[1]
if (!childrenMap.has(parentId)) childrenMap.set(parentId, [])
childrenMap.get(parentId)!.push(event)
}
}
function buildNode(event: NostrEvent): CommentNode {
const children = childrenMap.get(event.id) || []
return {
event,
replies: [...children]
.sort((a, b) => a.created_at - b.created_at)
.map(buildNode),
}
}
return [...topLevel]
.sort((a, b) => b.created_at - a.created_at)
.map(buildNode)
}
/**
* Nostr Composable
* Reactive interface for Nostr features
* Uses mock data in development mode
* Real Nostr integration using applesauce stack.
* Subscribes to relays for comments (kind 1111) and reactions (kind 17).
* Supports threaded replies, per-comment reactions, and user vote state.
*/
export function useNostr(contentId?: string) {
const comments = ref<NostrEvent[]>([])
const relays = useObservable(appRelays, [...APP_RELAYS])
// Comment tree (threaded)
const commentTree = ref<CommentNode[]>([])
// Flat list of all comment events in thread
const allComments = ref<NostrEvent[]>([])
// Movie-level reactions
const reactions = ref<NostrEvent[]>([])
// Per-comment reactions: eventId -> NostrEvent[]
const commentReactions = ref<Map<string, NostrEvent[]>>(new Map())
// User profiles
const profiles = ref<Map<string, any>>(new Map())
// Loading state
const isLoading = ref(false)
const error = ref<string | null>(null)
// Current external ID being viewed
let currentExternalId = ''
let commentSub: any = null
let reactionSub: any = null
const subscriptions: Subscription[] = []
/**
* Fetch comments for content
* Subscribe to comments and reactions for a given content ID.
*/
async function fetchComments(id: string = contentId!) {
if (!id) return
function subscribeToContent(id: string) {
const externalId = getExternalContentId(id)
currentExternalId = externalId
const currentRelays = relays.value ?? APP_RELAYS
isLoading.value = true
error.value = null
try {
if (useMockData) {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 200))
const mockComments = getMockComments(id)
comments.value = mockComments as unknown as NostrEvent[]
// Subscribe to top-level comments (#i) and all thread comments (#I)
const commentSub = pool
.subscription(currentRelays, [
{ kinds: [COMMENT_KIND], '#i': [externalId] },
{ kinds: [COMMENT_KIND], '#I': [externalId] },
])
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
next: () => refreshComments(externalId),
error: (err) => {
console.error('Comment subscription error:', err)
error.value = 'Failed to load comments'
isLoading.value = false
},
})
subscriptions.push(commentSub)
// Populate profiles from mock data
mockComments.forEach((comment) => {
const profile = getMockProfile(comment.pubkey)
if (profile) {
profiles.value.set(comment.pubkey, {
name: profile.name,
picture: profile.picture,
about: profile.about,
})
}
})
return
}
// Subscribe to movie-level reactions (kind 17)
const reactionSub = pool
.subscription(currentRelays, [
{ kinds: [17], '#i': [externalId] },
])
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
next: () => refreshReactions(externalId),
error: (err) => console.error('Reaction subscription error:', err),
})
subscriptions.push(reactionSub)
const identifier = getNostrContentIdentifier(id)
const events = await nostrClient.getComments(identifier)
// Sort by timestamp (newest first)
comments.value = events.sort((a, b) => b.created_at - a.created_at)
// Read existing data from store immediately
refreshComments(externalId)
refreshReactions(externalId)
// Fetch profiles for comment authors
await fetchProfiles(events.map((e) => e.pubkey))
} catch (err: any) {
error.value = err.message || 'Failed to fetch comments'
console.error('Nostr comments error:', err)
} finally {
// Mark loading done after initial data window
setTimeout(() => {
isLoading.value = false
}
}, 1500)
}
/**
* Fetch reactions for content
* Read all comments from the EventStore and build the threaded tree.
*/
async function fetchReactions(id: string = contentId!) {
if (!id) return
function refreshComments(externalId: string) {
// Top-level comments: have #i = externalId
const topLevel = eventStore.getByFilters([
{ kinds: [COMMENT_KIND], '#i': [externalId] },
])
// All comments in thread: have #I = externalId (includes replies)
const allInThread = eventStore.getByFilters([
{ kinds: [COMMENT_KIND], '#I': [externalId] },
])
try {
if (useMockData) {
await new Promise((resolve) => setTimeout(resolve, 100))
reactions.value = getMockReactions(id) as unknown as NostrEvent[]
return
}
const topLevelEvents = topLevel ? [...topLevel] : []
const allEvents = allInThread ? [...allInThread] : []
const identifier = getNostrContentIdentifier(id)
const events = await nostrClient.getReactions(identifier)
reactions.value = events
} catch (err: any) {
console.error('Nostr reactions error:', err)
// Merge: allInThread should include top-level too, but add any missed
const allIds = new Set(allEvents.map((e) => e.id))
for (const e of topLevelEvents) {
if (!allIds.has(e.id)) allEvents.push(e)
}
allComments.value = allEvents
commentTree.value = buildCommentTree(topLevelEvents, allEvents)
// Fetch profiles for all comment authors
const pubkeys = [...new Set(allEvents.map((e) => e.pubkey))]
fetchProfiles(pubkeys)
// Load reactions for all comments
loadCommentReactions(allEvents)
}
/**
* Read movie-level reactions from the EventStore.
*/
function refreshReactions(externalId: string) {
const events = eventStore.getByFilters([
{ kinds: [17], '#i': [externalId] },
])
if (events) {
reactions.value = [...events]
}
}
/**
* Fetch user profiles
* Load reactions for comment events using reactionsLoader.
*/
function loadCommentReactions(commentEvents: NostrEvent[]) {
const currentRelays = relays.value ?? APP_RELAYS
for (const event of commentEvents) {
// Subscribe to reactions for this comment
const sub = pool
.subscription(currentRelays, [
{ kinds: [7], '#e': [event.id] },
])
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
next: () => refreshSingleCommentReactions(event.id),
})
subscriptions.push(sub)
// Also read existing reactions from store
refreshSingleCommentReactions(event.id)
}
}
/**
* Refresh reactions for a single comment event from the store.
*/
function refreshSingleCommentReactions(eventId: string) {
const events = eventStore.getByFilters([
{ kinds: [7], '#e': [eventId] },
])
if (events) {
const newMap = new Map(commentReactions.value)
newMap.set(eventId, [...events])
commentReactions.value = newMap
}
}
/**
* Fetch user profiles for comment authors.
*/
async function fetchProfiles(pubkeys: string[]) {
const uniquePubkeys = [...new Set(pubkeys)]
const currentRelays = relays.value ?? APP_RELAYS
await Promise.all(
uniquePubkeys.map(async (pubkey) => {
if (profiles.value.has(pubkey)) return
for (const pubkey of pubkeys) {
if (profiles.value.has(pubkey)) continue
if (useMockData) {
const profile = getMockProfile(pubkey)
if (profile) {
profiles.value.set(pubkey, {
name: profile.name,
picture: profile.picture,
about: profile.about,
})
}
return
}
try {
const profileSub = pool
.subscription([...currentRelays, 'wss://purplepag.es'], [
{ kinds: [0], authors: [pubkey], limit: 1 },
])
.pipe(onlyEvents())
.subscribe({
next: (event) => {
try {
const metadata = JSON.parse(event.content)
profiles.value = new Map(profiles.value).set(pubkey, metadata)
} catch {
// Invalid profile JSON
}
},
})
try {
const profileEvent = await nostrClient.getProfile(pubkey)
if (profileEvent) {
const metadata = JSON.parse(profileEvent.content)
profiles.value.set(pubkey, metadata)
}
} catch (err) {
console.error(`Failed to fetch profile for ${pubkey}:`, err)
}
})
)
}
/**
* Subscribe to real-time comments
*/
function subscribeToComments(id: string = contentId!) {
if (!id || commentSub) return
if (useMockData) {
// In mock mode, no real-time subscription needed
return
}
const identifier = getNostrContentIdentifier(id)
commentSub = nostrClient.subscribeToComments(
identifier,
(event) => {
comments.value = [event, ...comments.value]
fetchProfiles([event.pubkey])
setTimeout(() => profileSub.unsubscribe(), 5000)
subscriptions.push(profileSub)
} catch (err) {
console.error(`Failed to fetch profile for ${pubkey}:`, err)
}
)
}
/**
* Subscribe to real-time reactions
*/
function subscribeToReactions(id: string = contentId!) {
if (!id || reactionSub) return
if (useMockData) {
return
}
const identifier = getNostrContentIdentifier(id)
reactionSub = nostrClient.subscribeToReactions(
identifier,
(event) => {
reactions.value = [...reactions.value, event]
}
)
}
// --- Posting Methods ---
/**
* Post a comment
* Post a top-level comment on content.
* Uses kind 1111 with proper #i, #I, #k, #K tags.
*/
async function postComment(content: string, id: string = contentId!) {
if (!id) {
throw new Error('Content ID required')
}
if (!id) throw new Error('Content ID required')
if (useMockData) {
// In mock mode, add the comment locally
const mockProfile = mockProfiles[0]
const newComment = {
id: Math.random().toString(36).slice(2).padEnd(64, '0'),
pubkey: mockProfile.pubkey,
content,
created_at: Math.floor(Date.now() / 1000),
kind: 1 as const,
tags: [['i', `https://indeedhub.com/content/${id}`, 'text']],
sig: '0'.repeat(128),
}
comments.value = [newComment as unknown as NostrEvent, ...comments.value]
const account = accountManager.active
if (!account) throw new Error('Not logged in')
if (!profiles.value.has(mockProfile.pubkey)) {
profiles.value.set(mockProfile.pubkey, {
name: mockProfile.name,
picture: mockProfile.picture,
about: mockProfile.about,
})
}
return newComment
}
if (!window.nostr) {
throw new Error('Nostr extension not available')
}
const externalId = getExternalContentId(id)
try {
const pubkey = await window.nostr.getPublicKey()
const identifier = getNostrContentIdentifier(id)
const event = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [
['i', identifier, 'text'],
],
const template = await factory.comment(
{ type: 'external', kind: 'web', identifier: externalId },
content,
pubkey,
}
const signedEvent = await window.nostr.signEvent(event)
await nostrClient.publishEvent(signedEvent)
return signedEvent
)
const signed = await factory.sign(template)
await pool.publish(APP_RELAYS, signed)
eventStore.add(signed)
refreshComments(externalId)
return signed
} catch (err: any) {
throw new Error(err.message || 'Failed to post comment')
}
}
/**
* Post a reaction (+1 or -1)
* Post a reply to an existing comment event.
* Uses factory.comment(parentEvent, content) which handles all tagging.
*/
async function postReaction(positive: boolean, id: string = contentId!) {
if (!id) {
throw new Error('Content ID required')
}
if (useMockData) {
const mockProfile = mockProfiles[0]
const newReaction = {
id: Math.random().toString(36).slice(2).padEnd(64, '0'),
pubkey: mockProfile.pubkey,
content: positive ? '+' : '-',
created_at: Math.floor(Date.now() / 1000),
kind: 17 as const,
tags: [['i', `https://indeedhub.com/content/${id}`, 'text']],
sig: '0'.repeat(128),
}
reactions.value = [...reactions.value, newReaction as unknown as NostrEvent]
return newReaction
}
if (!window.nostr) {
throw new Error('Nostr extension not available')
}
async function postReply(parentEvent: NostrEvent, content: string) {
const account = accountManager.active
if (!account) throw new Error('Not logged in')
try {
const pubkey = await window.nostr.getPublicKey()
const identifier = getNostrContentIdentifier(id)
const template = await factory.comment(parentEvent, content)
const signed = await factory.sign(template)
await pool.publish(APP_RELAYS, signed)
eventStore.add(signed)
const event = {
// Refresh thread
if (currentExternalId) {
refreshComments(currentExternalId)
}
return signed
} catch (err: any) {
throw new Error(err.message || 'Failed to post reply')
}
}
/**
* Post a reaction (+/-) on the movie/content itself.
* Uses kind 17 with #i and #k tags.
*/
async function postReaction(positive: boolean, id: string = contentId!) {
if (!id) throw new Error('Content ID required')
const account = accountManager.active
if (!account) throw new Error('Not logged in')
const externalId = getExternalContentId(id)
try {
const template = {
kind: 17,
created_at: Math.floor(Date.now() / 1000),
tags: [
['i', identifier, 'text'],
],
content: positive ? '+' : '-',
pubkey,
tags: [
['i', externalId],
['k', 'web'],
],
created_at: Math.floor(Date.now() / 1000),
}
const signedEvent = await window.nostr.signEvent(event)
await nostrClient.publishEvent(signedEvent)
return signedEvent
const signed = await factory.sign(template)
await pool.publish(APP_RELAYS, signed)
eventStore.add(signed)
refreshReactions(externalId)
return signed
} catch (err: any) {
throw new Error(err.message || 'Failed to post reaction')
}
}
/**
* Get reaction counts
* React to a comment event (+/-).
* Uses factory.reaction(event, emoji) which creates kind 7 events.
*/
async function reactToComment(event: NostrEvent, positive: boolean) {
const account = accountManager.active
if (!account) throw new Error('Not logged in')
const currentRelays = relays.value ?? APP_RELAYS
try {
const template = await factory.reaction(event, positive ? '+' : '-')
const signed = await factory.sign(template)
await pool.publish(currentRelays, signed)
eventStore.add(signed)
refreshSingleCommentReactions(event.id)
return signed
} catch (err: any) {
throw new Error(err.message || 'Failed to react to comment')
}
}
// --- Computed State ---
/**
* Movie-level reaction counts.
*/
const reactionCounts = computed(() => {
const positive = reactions.value.filter((r) => r.content === '+').length
const negative = reactions.value.filter((r) => r.content === '-').length
return { positive, negative, total: positive - negative }
})
/**
* Get user's reaction
* Current user's movie-level reaction, read from relay data.
*/
async function getUserReaction(id: string = contentId!) {
if (!id) return null
const userContentReaction = computed((): string | null => {
const account = accountManager.active
if (!account) return null
const userReaction = reactions.value.find((r) => r.pubkey === account.pubkey)
return userReaction?.content || null
})
if (useMockData) {
return null // Mock user has no existing reaction
}
/**
* Whether the current user has already voted on the movie.
*/
const hasVotedOnContent = computed(() => userContentReaction.value !== null)
if (!window.nostr) return null
try {
const pubkey = await window.nostr.getPublicKey()
const userReaction = reactions.value.find((r) => r.pubkey === pubkey)
return userReaction?.content || null
} catch {
return null
}
/**
* Get reaction counts for a specific comment event.
*/
function getCommentReactionCounts(eventId: string) {
const events = commentReactions.value.get(eventId) || []
const positive = events.filter((r) => r.content === '+').length
const negative = events.filter((r) => r.content === '-').length
return { positive, negative }
}
/**
* Cleanup subscriptions
* Get the current user's reaction on a specific comment.
*/
function getUserCommentReaction(eventId: string): string | null {
const account = accountManager.active
if (!account) return null
const events = commentReactions.value.get(eventId) || []
const userReaction = events.find((r) => r.pubkey === account.pubkey)
return userReaction?.content || null
}
/**
* Whether the user has already reacted to a specific comment.
*/
function hasVotedOnComment(eventId: string): boolean {
return getUserCommentReaction(eventId) !== null
}
/**
* Total comment count (all in thread including replies).
*/
const commentCount = computed(() => allComments.value.length)
/**
* Cleanup all subscriptions.
*/
function cleanup() {
if (commentSub) commentSub.close()
if (reactionSub) reactionSub.close()
subscriptions.forEach((sub) => sub.unsubscribe())
subscriptions.length = 0
currentExternalId = ''
}
// Auto-subscribe when contentId is provided
if (contentId) {
subscribeToContent(contentId)
}
// Auto-cleanup on unmount
@@ -320,31 +444,27 @@ export function useNostr(contentId?: string) {
return {
// State
comments,
commentTree,
allComments,
reactions,
commentReactions,
profiles,
isLoading,
error,
reactionCounts,
userContentReaction,
hasVotedOnContent,
commentCount,
// Methods
fetchComments,
fetchReactions,
subscribeToComments,
subscribeToReactions,
subscribeToContent,
postComment,
postReply,
postReaction,
getUserReaction,
reactToComment,
getCommentReactionCounts,
getUserCommentReaction,
hasVotedOnComment,
cleanup,
}
}
// Declare window.nostr for TypeScript
declare global {
interface Window {
nostr?: {
getPublicKey: () => Promise<string>
signEvent: (event: any) => Promise<any>
}
}
}

View File

@@ -0,0 +1,36 @@
import { ref, onUnmounted, type Ref } from 'vue'
import type { Observable, Subscription } from 'rxjs'
/**
* Vue composable that subscribes to an RxJS Observable and returns a reactive ref.
* This is the Vue equivalent of applesauce-react's `use$()` hook.
*
* @param observable$ - The RxJS Observable or BehaviorSubject to subscribe to
* @param initialValue - Optional initial value before the first emission
* @returns A Vue ref that updates whenever the observable emits
*/
export function useObservable<T>(
observable$: Observable<T>,
initialValue?: T,
): Ref<T | undefined> {
const value = ref<T | undefined>(initialValue) as Ref<T | undefined>
let subscription: Subscription | null = null
subscription = observable$.subscribe({
next: (v) => {
value.value = v
},
error: (err) => {
console.error('useObservable error:', err)
},
})
onUnmounted(() => {
if (subscription) {
subscription.unsubscribe()
subscription = null
}
})
return value
}