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:
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',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user