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) <noreply@anthropic.com>
This commit is contained in:
parent
703e093488
commit
c62229c795
9 changed files with 683 additions and 22 deletions
|
|
@ -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 }}
|
||||
</Badge>
|
||||
|
||||
<!-- Bookmark button -->
|
||||
<div class="absolute bottom-2 right-2">
|
||||
<BookmarkButton
|
||||
:pubkey="activity.organizer.pubkey"
|
||||
:d-tag="activity.id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent class="p-4 flex-1 flex flex-col gap-2">
|
||||
|
|
|
|||
40
src/modules/activities/components/BookmarkButton.vue
Normal file
40
src/modules/activities/components/BookmarkButton.vue
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Heart } from 'lucide-vue-next'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useBookmarks } from '../composables/useBookmarks'
|
||||
import { NIP52_KINDS } from '../types/nip52'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Activity organizer pubkey */
|
||||
pubkey: string
|
||||
/** Activity d-tag */
|
||||
dTag: string
|
||||
/** Activity kind (default: 31923) */
|
||||
kind?: number
|
||||
}>()
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { isBookmarked, toggleBookmark } = useBookmarks()
|
||||
|
||||
const activityKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
||||
const bookmarked = computed(() => isBookmarked(activityKind.value, props.pubkey, props.dTag))
|
||||
|
||||
function handleToggle() {
|
||||
toggleBookmark(activityKind.value, props.pubkey, props.dTag)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
v-if="isAuthenticated"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:class="bookmarked ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-foreground'"
|
||||
@click.stop="handleToggle"
|
||||
>
|
||||
<Heart class="w-4 h-4" :class="{ 'fill-current': bookmarked }" />
|
||||
</Button>
|
||||
</template>
|
||||
34
src/modules/activities/components/OrganizerCard.vue
Normal file
34
src/modules/activities/components/OrganizerCard.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<script setup lang="ts">
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { User } from 'lucide-vue-next'
|
||||
import { useOrganizerProfile } from '../composables/useOrganizerProfile'
|
||||
|
||||
const props = defineProps<{
|
||||
pubkey: string
|
||||
}>()
|
||||
|
||||
const { profile, displayName, isLoading } = useOrganizerProfile(props.pubkey)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar class="h-10 w-10">
|
||||
<AvatarImage v-if="profile?.picture" :src="profile.picture" :alt="displayName" />
|
||||
<AvatarFallback class="bg-primary/10">
|
||||
<User class="w-5 h-5 text-primary" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-foreground truncate">
|
||||
<template v-if="isLoading">Loading...</template>
|
||||
<template v-else>{{ displayName }}</template>
|
||||
</p>
|
||||
<p v-if="profile?.nip05" class="text-xs text-muted-foreground truncate">
|
||||
{{ profile.nip05 }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted-foreground font-mono truncate">
|
||||
{{ pubkey.slice(0, 16) }}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
54
src/modules/activities/components/RSVPButton.vue
Normal file
54
src/modules/activities/components/RSVPButton.vue
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Check, HelpCircle, X } from 'lucide-vue-next'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useRSVP } from '../composables/useRSVP'
|
||||
import { NIP52_KINDS, type RSVPStatus } from '../types/nip52'
|
||||
|
||||
const props = defineProps<{
|
||||
pubkey: string
|
||||
dTag: string
|
||||
kind?: number
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { getMyRSVP, getRSVPCount, setRSVP } = useRSVP()
|
||||
|
||||
const activityKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
||||
const myStatus = computed(() => getMyRSVP(activityKind.value, props.pubkey, props.dTag))
|
||||
const goingCount = computed(() => getRSVPCount(activityKind.value, props.pubkey, props.dTag))
|
||||
|
||||
const buttons: { status: RSVPStatus; labelKey: string; icon: any }[] = [
|
||||
{ status: 'accepted', labelKey: 'activities.detail.going', icon: Check },
|
||||
{ status: 'tentative', labelKey: 'activities.detail.maybe', icon: HelpCircle },
|
||||
{ status: 'declined', labelKey: 'activities.detail.notGoing', icon: X },
|
||||
]
|
||||
|
||||
function handleClick(status: RSVPStatus) {
|
||||
setRSVP(activityKind.value, props.pubkey, props.dTag, status)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isAuthenticated" class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-for="btn in buttons"
|
||||
:key="btn.status"
|
||||
:variant="myStatus === btn.status ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="flex-1 gap-1.5"
|
||||
@click="handleClick(btn.status)"
|
||||
>
|
||||
<component :is="btn.icon" class="w-3.5 h-3.5" />
|
||||
{{ t(btn.labelKey) }}
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="goingCount > 0" class="text-xs text-muted-foreground">
|
||||
{{ goingCount }} {{ goingCount === 1 ? 'person' : 'people' }} going
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
156
src/modules/activities/composables/useBookmarks.ts
Normal file
156
src/modules/activities/composables/useBookmarks.ts
Normal file
|
|
@ -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', '<kind>:<pubkey>:<d-tag>']
|
||||
*
|
||||
* 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<string>
|
||||
/** The latest bookmark event we've seen */
|
||||
lastEventId: string | null
|
||||
}
|
||||
|
||||
// Shared state across all component instances
|
||||
const state = ref<BookmarkState>({
|
||||
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<any>(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<string>()
|
||||
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<any>(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
|
||||
}
|
||||
149
src/modules/activities/composables/useOrganizerProfile.ts
Normal file
149
src/modules/activities/composables/useOrganizerProfile.ts
Normal file
|
|
@ -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<Map<string, OrganizerProfile>>(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<OrganizerProfile | null>(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<any>(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<any>(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,
|
||||
}
|
||||
}
|
||||
172
src/modules/activities/composables/useRSVP.ts
Normal file
172
src/modules/activities/composables/useRSVP.ts
Normal file
|
|
@ -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<Map<string, RSVPEntry>>(new Map())
|
||||
// Cache: activityCoord -> count of RSVPs from all users
|
||||
const rsvpCounts = ref<Map<string, number>>(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<any>(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<any>(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
|
||||
}
|
||||
|
|
@ -1,14 +1,54 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Heart } from 'lucide-vue-next'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useBookmarks } from '../composables/useBookmarks'
|
||||
import { useActivitiesStore } from '../stores/activities'
|
||||
import ActivityList from '../components/ActivityList.vue'
|
||||
import type { Activity } from '../types/activity'
|
||||
|
||||
const router = useRouter()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { isBookmarkedByDTag, isLoaded } = useBookmarks()
|
||||
const store = useActivitiesStore()
|
||||
|
||||
const favoriteActivities = computed(() => {
|
||||
return store.activities.filter(a => isBookmarkedByDTag(a.id))
|
||||
})
|
||||
|
||||
function handleSelect(activity: Activity) {
|
||||
router.push({ name: 'activity-detail', params: { id: activity.id } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<h1 class="text-2xl font-bold text-foreground mb-4">Favorites</h1>
|
||||
<div class="flex flex-col items-center justify-center py-16 text-center">
|
||||
|
||||
<!-- Not authenticated -->
|
||||
<div v-if="!isAuthenticated" class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Heart class="w-16 h-16 text-muted-foreground/30 mb-4" />
|
||||
<p class="text-muted-foreground">Bookmark your favorite activities</p>
|
||||
<p class="text-sm text-muted-foreground/70 mt-1">Coming in Phase 4</p>
|
||||
<p class="text-muted-foreground">Log in to save your favorite activities</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="!isLoaded" class="flex flex-col items-center justify-center py-16">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="favoriteActivities.length === 0" class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Heart class="w-16 h-16 text-muted-foreground/30 mb-4" />
|
||||
<p class="text-muted-foreground">No favorites yet</p>
|
||||
<p class="text-sm text-muted-foreground/70 mt-1">Tap the heart icon on any activity to save it here</p>
|
||||
</div>
|
||||
|
||||
<!-- Favorites list -->
|
||||
<ActivityList
|
||||
v-else
|
||||
:activities="favoriteActivities"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
|
||||
<template>
|
||||
<div class="container mx-auto py-6 px-4 max-w-3xl">
|
||||
<!-- Back button -->
|
||||
<Button variant="ghost" size="sm" class="mb-4 gap-1.5" @click="goBack">
|
||||
<!-- Top bar -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
|
||||
<ArrowLeft class="w-4 h-4" />
|
||||
Back
|
||||
</Button>
|
||||
<BookmarkButton
|
||||
v-if="activity"
|
||||
:pubkey="activity.organizer.pubkey"
|
||||
:d-tag="activity.id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="isLoading" class="space-y-4">
|
||||
|
|
@ -123,21 +133,18 @@ function goBack() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSVP -->
|
||||
<RSVPButton
|
||||
:pubkey="activity.organizer.pubkey"
|
||||
:d-tag="activity.id"
|
||||
/>
|
||||
|
||||
<!-- Organizer -->
|
||||
<div class="bg-muted/50 rounded-lg p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<User class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||
{{ t('activities.detail.organizer') }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground font-mono truncate">
|
||||
{{ activity.organizer.name ?? activity.organizer.pubkey.slice(0, 16) + '...' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<OrganizerCard :pubkey="activity.organizer.pubkey" />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue