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:
151
src/composables/useAccounts.ts
Normal file
151
src/composables/useAccounts.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
292
src/composables/useContentDiscovery.ts
Normal file
292
src/composables/useContentDiscovery.ts
Normal 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',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
36
src/composables/useObservable.ts
Normal file
36
src/composables/useObservable.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user