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:
Padreug 2026-04-20 08:31:21 +02:00
commit c62229c795
9 changed files with 683 additions and 22 deletions

View file

@ -5,6 +5,7 @@ import { format } from 'date-fns'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { MapPin, Calendar, Ticket } from 'lucide-vue-next' import { MapPin, Calendar, Ticket } from 'lucide-vue-next'
import BookmarkButton from './BookmarkButton.vue'
import type { Activity } from '../types/activity' import type { Activity } from '../types/activity'
const props = defineProps<{ const props = defineProps<{
@ -90,6 +91,14 @@ const placeholderBg = computed(() => {
> >
{{ priceDisplay }} {{ priceDisplay }}
</Badge> </Badge>
<!-- Bookmark button -->
<div class="absolute bottom-2 right-2">
<BookmarkButton
:pubkey="activity.organizer.pubkey"
:d-tag="activity.id"
/>
</div>
</div> </div>
<CardContent class="p-4 flex-1 flex flex-col gap-2"> <CardContent class="p-4 flex-1 flex flex-col gap-2">

View 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>

View 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>

View 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>

View 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
}

View 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,
}
}

View 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
}

View file

@ -1,14 +1,54 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { Heart } from 'lucide-vue-next' 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> </script>
<template> <template>
<div class="container mx-auto px-4 py-6"> <div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold text-foreground mb-4">Favorites</h1> <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" /> <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-muted-foreground">Log in to save your favorite activities</p>
<p class="text-sm text-muted-foreground/70 mt-1">Coming in Phase 4</p>
</div> </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> </div>
</template> </template>

View file

@ -7,9 +7,12 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { import {
Calendar, MapPin, ArrowLeft, User, Calendar, MapPin, ArrowLeft,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useActivityDetail } from '../composables/useActivityDetail' 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 route = useRoute()
const router = useRouter() const router = useRouter()
@ -47,11 +50,18 @@ function goBack() {
<template> <template>
<div class="container mx-auto py-6 px-4 max-w-3xl"> <div class="container mx-auto py-6 px-4 max-w-3xl">
<!-- Back button --> <!-- Top bar -->
<Button variant="ghost" size="sm" class="mb-4 gap-1.5" @click="goBack"> <div class="flex items-center justify-between mb-4">
<ArrowLeft class="w-4 h-4" /> <Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
Back <ArrowLeft class="w-4 h-4" />
</Button> Back
</Button>
<BookmarkButton
v-if="activity"
:pubkey="activity.organizer.pubkey"
:d-tag="activity.id"
/>
</div>
<!-- Loading --> <!-- Loading -->
<div v-if="isLoading" class="space-y-4"> <div v-if="isLoading" class="space-y-4">
@ -123,21 +133,18 @@ function goBack() {
</div> </div>
</div> </div>
<!-- RSVP -->
<RSVPButton
:pubkey="activity.organizer.pubkey"
:d-tag="activity.id"
/>
<!-- Organizer --> <!-- Organizer -->
<div class="bg-muted/50 rounded-lg p-4"> <div class="bg-muted/50 rounded-lg p-4">
<div class="flex items-center gap-3"> <p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0"> {{ t('activities.detail.organizer') }}
<User class="w-5 h-5 text-primary" /> </p>
</div> <OrganizerCard :pubkey="activity.organizer.pubkey" />
<div class="min-w-0">
<p class="text-sm font-medium text-foreground">
{{ 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>
</div> </div>
<Separator /> <Separator />