From c62229c795531b778dcfb79e564e80d0d9fa9d08 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 20 Apr 2026 08:31:21 +0200 Subject: [PATCH] Add social features: bookmarks, RSVP, organizer profiles (Phase 4) Bookmarks (NIP-51 kind 10003): useBookmarks composable publishes/reads bookmark lists with 'a' tag references to calendar events. BookmarkButton (heart icon) on activity cards and detail page. Favorites page shows bookmarked activities. RSVP (NIP-52 kind 31925): useRSVP composable publishes addressable RSVP events with accepted/declined/tentative status. RSVPButton with Going/ Maybe/Not Going toggle and attendee count on detail page. Organizer profiles (NIP-01 kind 0): useOrganizerProfile fetches metadata from relays with global cache. OrganizerCard displays avatar, name, and nip05. useBatchProfiles for bulk fetching. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../activities/components/ActivityCard.vue | 9 + .../activities/components/BookmarkButton.vue | 40 ++++ .../activities/components/OrganizerCard.vue | 34 ++++ .../activities/components/RSVPButton.vue | 54 ++++++ .../activities/composables/useBookmarks.ts | 156 ++++++++++++++++ .../composables/useOrganizerProfile.ts | 149 +++++++++++++++ src/modules/activities/composables/useRSVP.ts | 172 ++++++++++++++++++ .../views/ActivitiesFavoritesPage.vue | 46 ++++- .../activities/views/ActivityDetailPage.vue | 45 +++-- 9 files changed, 683 insertions(+), 22 deletions(-) create mode 100644 src/modules/activities/components/BookmarkButton.vue create mode 100644 src/modules/activities/components/OrganizerCard.vue create mode 100644 src/modules/activities/components/RSVPButton.vue create mode 100644 src/modules/activities/composables/useBookmarks.ts create mode 100644 src/modules/activities/composables/useOrganizerProfile.ts create mode 100644 src/modules/activities/composables/useRSVP.ts diff --git a/src/modules/activities/components/ActivityCard.vue b/src/modules/activities/components/ActivityCard.vue index 5eb1184..4e9cadc 100644 --- a/src/modules/activities/components/ActivityCard.vue +++ b/src/modules/activities/components/ActivityCard.vue @@ -5,6 +5,7 @@ import { format } from 'date-fns' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { MapPin, Calendar, Ticket } from 'lucide-vue-next' +import BookmarkButton from './BookmarkButton.vue' import type { Activity } from '../types/activity' const props = defineProps<{ @@ -90,6 +91,14 @@ const placeholderBg = computed(() => { > {{ priceDisplay }} + + +
+ +
diff --git a/src/modules/activities/components/BookmarkButton.vue b/src/modules/activities/components/BookmarkButton.vue new file mode 100644 index 0000000..cd71320 --- /dev/null +++ b/src/modules/activities/components/BookmarkButton.vue @@ -0,0 +1,40 @@ + + + diff --git a/src/modules/activities/components/OrganizerCard.vue b/src/modules/activities/components/OrganizerCard.vue new file mode 100644 index 0000000..66030ea --- /dev/null +++ b/src/modules/activities/components/OrganizerCard.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/modules/activities/components/RSVPButton.vue b/src/modules/activities/components/RSVPButton.vue new file mode 100644 index 0000000..000fc85 --- /dev/null +++ b/src/modules/activities/components/RSVPButton.vue @@ -0,0 +1,54 @@ + + + diff --git a/src/modules/activities/composables/useBookmarks.ts b/src/modules/activities/composables/useBookmarks.ts new file mode 100644 index 0000000..b427a28 --- /dev/null +++ b/src/modules/activities/composables/useBookmarks.ts @@ -0,0 +1,156 @@ +import { ref, computed, onMounted, onUnmounted } from 'vue' +import { finalizeEvent, type EventTemplate, type Event as NostrEvent } from 'nostr-tools' +import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' +import { useAuth } from '@/composables/useAuthService' + +/** + * NIP-51 Bookmarks (kind 10003) for saving favorite activities. + * + * Stores references to NIP-52 calendar events as 'a' tags: + * ['a', '::'] + * + * The bookmark list is a replaceable event (kind 10003) — publishing + * a new one replaces the previous. + */ + +const BOOKMARK_KIND = 10003 + +interface BookmarkState { + /** Set of bookmarked activity coordinates: "kind:pubkey:d-tag" */ + bookmarkedCoords: Set + /** The latest bookmark event we've seen */ + lastEventId: string | null +} + +// Shared state across all component instances +const state = ref({ + bookmarkedCoords: new Set(), + lastEventId: null, +}) +const isLoaded = ref(false) + +export function useBookmarks() { + const { isAuthenticated, currentUser } = useAuth() + let unsubscribe: (() => void) | null = null + + const bookmarkedIds = computed(() => state.value.bookmarkedCoords) + + function isBookmarked(activityKind: number, pubkey: string, dTag: string): boolean { + return state.value.bookmarkedCoords.has(`${activityKind}:${pubkey}:${dTag}`) + } + + function isBookmarkedByDTag(dTag: string): boolean { + for (const coord of state.value.bookmarkedCoords) { + if (coord.endsWith(`:${dTag}`)) return true + } + return false + } + + /** + * Load the user's bookmark list from relays. + */ + function loadBookmarks() { + if (!isAuthenticated.value || !currentUser.value?.pubkey) return + + const relayHub = tryInjectService(SERVICE_TOKENS.RELAY_HUB) + if (!relayHub) return + + unsubscribe = relayHub.subscribe({ + id: `bookmarks-${Date.now()}`, + filters: [{ + kinds: [BOOKMARK_KIND], + authors: [currentUser.value.pubkey], + limit: 1, + }], + onEvent: (event: NostrEvent) => { + // Only process if newer than what we have + if (state.value.lastEventId && event.created_at <= (state.value as any).lastCreatedAt) return + + const coords = new Set() + for (const tag of event.tags) { + if (tag[0] === 'a' && tag[1]) { + coords.add(tag[1]) + } + } + state.value = { + bookmarkedCoords: coords, + lastEventId: event.id, + } + ;(state.value as any).lastCreatedAt = event.created_at + isLoaded.value = true + }, + onEose: () => { + isLoaded.value = true + }, + }) + } + + /** + * Toggle bookmark for an activity. Publishes updated NIP-51 bookmark list. + */ + async function toggleBookmark(activityKind: number, pubkey: string, dTag: string) { + if (!isAuthenticated.value || !currentUser.value?.prvkey) return + + const coord = `${activityKind}:${pubkey}:${dTag}` + const newCoords = new Set(state.value.bookmarkedCoords) + + if (newCoords.has(coord)) { + newCoords.delete(coord) + } else { + newCoords.add(coord) + } + + // Build and publish updated bookmark list + const tags: string[][] = Array.from(newCoords).map(c => ['a', c]) + + const template: EventTemplate = { + kind: BOOKMARK_KIND, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags, + } + + const signingKey = hexToUint8Array(currentUser.value.prvkey) + const signedEvent = finalizeEvent(template, signingKey) + + const relayHub = tryInjectService(SERVICE_TOKENS.RELAY_HUB) + if (!relayHub) return + + const result = await relayHub.publishEvent(signedEvent) + if (result.success > 0) { + state.value = { + bookmarkedCoords: newCoords, + lastEventId: signedEvent.id, + } + } + } + + onMounted(() => { + if (!isLoaded.value) { + loadBookmarks() + } + }) + + onUnmounted(() => { + if (unsubscribe) { + unsubscribe() + } + }) + + return { + bookmarkedIds, + isBookmarked, + isBookmarkedByDTag, + toggleBookmark, + isLoaded, + loadBookmarks, + } +} + +function hexToUint8Array(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substr(i, 2), 16) + } + return bytes +} diff --git a/src/modules/activities/composables/useOrganizerProfile.ts b/src/modules/activities/composables/useOrganizerProfile.ts new file mode 100644 index 0000000..359faeb --- /dev/null +++ b/src/modules/activities/composables/useOrganizerProfile.ts @@ -0,0 +1,149 @@ +import { ref, onMounted, onUnmounted } from 'vue' +import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' +import type { Event as NostrEvent } from 'nostr-tools' + +export interface OrganizerProfile { + pubkey: string + name?: string + displayName?: string + about?: string + picture?: string + banner?: string + nip05?: string + lud16?: string + website?: string +} + +// Global cache of fetched profiles +const profileCache = ref>(new Map()) + +/** + * Composable for fetching and displaying organizer profiles (NIP-01 kind 0). + * Uses its own relay subscription to avoid depending on the nostr-feed module. + */ +export function useOrganizerProfile(pubkey: string) { + const profile = ref(profileCache.value.get(pubkey) ?? null) + const isLoading = ref(!profile.value) + let unsubscribe: (() => void) | null = null + + function load() { + if (profileCache.value.has(pubkey)) { + profile.value = profileCache.value.get(pubkey)! + isLoading.value = false + return + } + + const relayHub = tryInjectService(SERVICE_TOKENS.RELAY_HUB) + if (!relayHub) { + isLoading.value = false + return + } + + unsubscribe = relayHub.subscribe({ + id: `profile-${pubkey}-${Date.now()}`, + filters: [{ + kinds: [0], + authors: [pubkey], + limit: 1, + }], + onEvent: (event: NostrEvent) => { + try { + const metadata = JSON.parse(event.content) + const p: OrganizerProfile = { + pubkey, + name: metadata.name, + displayName: metadata.display_name, + about: metadata.about, + picture: metadata.picture, + banner: metadata.banner, + nip05: metadata.nip05, + lud16: metadata.lud16, + website: metadata.website, + } + profileCache.value.set(pubkey, p) + profile.value = p + } catch { + // invalid metadata JSON + } + isLoading.value = false + }, + onEose: () => { + isLoading.value = false + }, + }) + } + + onMounted(() => { + load() + }) + + onUnmounted(() => { + if (unsubscribe) { + unsubscribe() + } + }) + + return { + profile, + isLoading, + get displayName() { + const p = profile.value + return p?.displayName ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}` + }, + } +} + +/** + * Batch-fetch profiles for multiple pubkeys (for activity cards). + */ +export function useBatchProfiles() { + function fetchProfiles(pubkeys: string[]) { + const uncached = pubkeys.filter(pk => !profileCache.value.has(pk)) + if (uncached.length === 0) return + + const relayHub = tryInjectService(SERVICE_TOKENS.RELAY_HUB) + if (!relayHub) return + + relayHub.subscribe({ + id: `batch-profiles-${Date.now()}`, + filters: [{ + kinds: [0], + authors: uncached, + }], + onEvent: (event: NostrEvent) => { + try { + const metadata = JSON.parse(event.content) + profileCache.value.set(event.pubkey, { + pubkey: event.pubkey, + name: metadata.name, + displayName: metadata.display_name, + about: metadata.about, + picture: metadata.picture, + banner: metadata.banner, + nip05: metadata.nip05, + lud16: metadata.lud16, + website: metadata.website, + }) + } catch { + // skip invalid + } + }, + }) + } + + function getProfile(pubkey: string): OrganizerProfile | undefined { + return profileCache.value.get(pubkey) + } + + function getDisplayName(pubkey: string): string { + const p = profileCache.value.get(pubkey) + return p?.displayName ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}` + } + + return { + profiles: profileCache, + fetchProfiles, + getProfile, + getDisplayName, + } +} diff --git a/src/modules/activities/composables/useRSVP.ts b/src/modules/activities/composables/useRSVP.ts new file mode 100644 index 0000000..e02cae3 --- /dev/null +++ b/src/modules/activities/composables/useRSVP.ts @@ -0,0 +1,172 @@ +import { ref, onMounted, onUnmounted } from 'vue' +import { finalizeEvent, type EventTemplate, type Event as NostrEvent } from 'nostr-tools' +import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' +import { useAuth } from '@/composables/useAuthService' +import { NIP52_KINDS, type RSVPStatus } from '../types/nip52' + +/** + * NIP-52 RSVP (kind 31925) for responding to calendar events. + * + * Each RSVP is an addressable event with: + * d-tag: unique identifier for this RSVP + * a-tag: reference to the calendar event (kind:pubkey:d-tag) + * status tag: 'accepted' | 'declined' | 'tentative' + */ + +interface RSVPEntry { + status: RSVPStatus + eventId: string + createdAt: number +} + +// Cache: activityCoord -> user's RSVP status +const rsvpCache = ref>(new Map()) +// Cache: activityCoord -> count of RSVPs from all users +const rsvpCounts = ref>(new Map()) +const isLoaded = ref(false) + +export function useRSVP() { + const { isAuthenticated, currentUser } = useAuth() + let unsubscribe: (() => void) | null = null + + /** + * Get the user's RSVP status for an activity. + */ + function getMyRSVP(activityKind: number, pubkey: string, dTag: string): RSVPStatus | null { + const coord = `${activityKind}:${pubkey}:${dTag}` + return rsvpCache.value.get(coord)?.status ?? null + } + + /** + * Get RSVP count for an activity. + */ + function getRSVPCount(activityKind: number, pubkey: string, dTag: string): number { + const coord = `${activityKind}:${pubkey}:${dTag}` + return rsvpCounts.value.get(coord) ?? 0 + } + + /** + * Load the user's RSVPs and counts for visible activities from relays. + */ + function loadRSVPs() { + const relayHub = tryInjectService(SERVICE_TOKENS.RELAY_HUB) + if (!relayHub) return + + // Subscribe to all RSVPs (for counts) and filter user's own + unsubscribe = relayHub.subscribe({ + id: `rsvps-${Date.now()}`, + filters: [{ + kinds: [NIP52_KINDS.RSVP], + limit: 500, + }], + onEvent: (event: NostrEvent) => { + const aTag = event.tags.find(t => t[0] === 'a')?.[1] + if (!aTag) return + + const statusTag = event.tags.find(t => t[0] === 'status')?.[1] as RSVPStatus | undefined + // Also check 'l' tag pattern used by some clients + const lStatus = event.tags.find(t => t[0] === 'l' && t[2] === 'status')?.[1] as RSVPStatus | undefined + const status = statusTag ?? lStatus + if (!status || !['accepted', 'declined', 'tentative'].includes(status)) return + + // Update count + if (status === 'accepted') { + rsvpCounts.value.set(aTag, (rsvpCounts.value.get(aTag) ?? 0) + 1) + } + + // Update user's own RSVP + if (currentUser.value?.pubkey && event.pubkey === currentUser.value.pubkey) { + const existing = rsvpCache.value.get(aTag) + if (!existing || event.created_at > existing.createdAt) { + rsvpCache.value.set(aTag, { + status, + eventId: event.id, + createdAt: event.created_at, + }) + } + } + }, + onEose: () => { + isLoaded.value = true + }, + }) + } + + /** + * Publish an RSVP for an activity. + * Clicking the same status again removes the RSVP (publishes 'declined'). + */ + async function setRSVP( + activityKind: number, + activityPubkey: string, + activityDTag: string, + status: RSVPStatus + ) { + if (!isAuthenticated.value || !currentUser.value?.prvkey) return + + const coord = `${activityKind}:${activityPubkey}:${activityDTag}` + + // Toggle: if already this status, decline instead + const currentStatus = getMyRSVP(activityKind, activityPubkey, activityDTag) + const newStatus = currentStatus === status ? 'declined' : status + + const dTag = `rsvp-${activityDTag}` + + const template: EventTemplate = { + kind: NIP52_KINDS.RSVP, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags: [ + ['d', dTag], + ['a', coord], + ['status', newStatus], + ['L', 'status'], + ['l', newStatus, 'status'], + ['p', activityPubkey], + ], + } + + const signingKey = hexToUint8Array(currentUser.value.prvkey) + const signedEvent = finalizeEvent(template, signingKey) + + const relayHub = tryInjectService(SERVICE_TOKENS.RELAY_HUB) + if (!relayHub) return + + const result = await relayHub.publishEvent(signedEvent) + if (result.success > 0) { + rsvpCache.value.set(coord, { + status: newStatus, + eventId: signedEvent.id, + createdAt: signedEvent.created_at, + }) + } + } + + onMounted(() => { + if (!isLoaded.value) { + loadRSVPs() + } + }) + + onUnmounted(() => { + if (unsubscribe) { + unsubscribe() + } + }) + + return { + getMyRSVP, + getRSVPCount, + setRSVP, + isLoaded, + loadRSVPs, + } +} + +function hexToUint8Array(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substr(i, 2), 16) + } + return bytes +} diff --git a/src/modules/activities/views/ActivitiesFavoritesPage.vue b/src/modules/activities/views/ActivitiesFavoritesPage.vue index 6c5dee5..3d30e15 100644 --- a/src/modules/activities/views/ActivitiesFavoritesPage.vue +++ b/src/modules/activities/views/ActivitiesFavoritesPage.vue @@ -1,14 +1,54 @@ diff --git a/src/modules/activities/views/ActivityDetailPage.vue b/src/modules/activities/views/ActivityDetailPage.vue index ea6fa5a..ef7765a 100644 --- a/src/modules/activities/views/ActivityDetailPage.vue +++ b/src/modules/activities/views/ActivityDetailPage.vue @@ -7,9 +7,12 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Separator } from '@/components/ui/separator' import { - Calendar, MapPin, ArrowLeft, User, + Calendar, MapPin, ArrowLeft, } from 'lucide-vue-next' import { useActivityDetail } from '../composables/useActivityDetail' +import BookmarkButton from '../components/BookmarkButton.vue' +import RSVPButton from '../components/RSVPButton.vue' +import OrganizerCard from '../components/OrganizerCard.vue' const route = useRoute() const router = useRouter() @@ -47,11 +50,18 @@ function goBack() {