diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index eb603335..f22d7a24 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -4,6 +4,7 @@ import { nip19 } from 'nostr-tools'; import { ArrowLeft, Bookmark, + CalendarDays, Crown, MessageCircle, Pencil, @@ -33,6 +34,7 @@ import { useAuthor } from '@/hooks/useAuthor'; import { useAuthors } from '@/hooks/useAuthors'; import { useComments } from '@/hooks/useComments'; import { useCommunityBookmarks } from '@/hooks/useCommunityBookmarks'; +import { useCommunityEvents } from '@/hooks/useCommunityEvents'; import { useCommunityMembers } from '@/hooks/useCommunityMembers'; import { useCommunityGoals } from '@/hooks/useCommunityGoals'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -122,6 +124,23 @@ function ReplyCardSkeleton() { ); } +function getTag(tags: string[][], name: string): string | undefined { + return tags.find(([n]) => n === name)?.[1]; +} + +function getCalendarEventStart(event: NostrEvent): number { + const start = getTag(event.tags, 'start'); + if (!start) return 0; + + if (event.kind === 31922) { + const date = new Date(`${start}T00:00:00Z`); + return isNaN(date.getTime()) ? 0 : Math.floor(date.getTime() / 1000); + } + + const timestamp = parseInt(start, 10); + return isNaN(timestamp) ? 0 : timestamp; +} + // ── Main component ──────────────────────────────────────────────────────────── export function CommunityDetailPage({ event }: { event: NostrEvent }) { @@ -178,16 +197,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { toggleCommunityBookmark.mutate({ aTag: communityATag }); }, [user, communityATag, toggleCommunityBookmark]); - const handleAddMembersClick = useCallback(() => { - setAddMemberOpen(true); - }, []); - - useLayoutOptions({ - showFAB: canAddMembers && activeTab === 'members', - onFabClick: handleAddMembersClick, - fabIcon: , - }); - // Batch-fetch profiles for all members const allMemberPubkeys = useMemo( () => membership?.members.map((m) => m.pubkey) ?? [], @@ -237,6 +246,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { // ── Fundraising goals (NIP-75) ────────────────────────────────────────────── const { data: goals, isLoading: goalsLoading } = useCommunityGoals(communityATag || undefined); + const { data: communityEvents, isLoading: eventsLoading } = useCommunityEvents(communityATag || undefined); const now = useNow(60_000); /** Check if a goal event's `closed_at` deadline has passed. */ @@ -267,6 +277,22 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { }); }, [moderatedGoals, membersOnly, rankMap, isExpired]); + const eventItems = useMemo(() => { + const moderated = applyCommunityModerationToEvents(communityEvents ?? [], moderation); + const visible = membersOnly ? moderated.filter((e) => rankMap.has(e.pubkey)) : moderated; + + return [...visible].sort((a, b) => { + const aStart = getCalendarEventStart(a); + const bStart = getCalendarEventStart(b); + const aFuture = aStart >= now; + const bFuture = bStart >= now; + if (aFuture && !bFuture) return -1; + if (!aFuture && bFuture) return 1; + if (aFuture && bFuture) return aStart - bStart; + return bStart - aStart; + }); + }, [communityEvents, moderation, membersOnly, rankMap, now]); + const replyTree = useMemo((): ReplyNode[] => { if (!commentsData) return []; const topLevel = commentsData.topLevelComments ?? []; @@ -340,8 +366,10 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { showFAB: activeTab === 'comments' || activeTab === 'fundraising' + || activeTab === 'events' || (activeTab === 'members' && canAddMembers), - onFabClick: handleFabClick, + fabKind: activeTab === 'events' ? 31923 : 1, + onFabClick: activeTab === 'events' ? undefined : handleFabClick, fabIcon, }); @@ -455,6 +483,13 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { Fundraising + + + Events + {/* ── Members tab ── */} @@ -562,6 +597,29 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { )} + + {/* ── Events tab ── */} + + {eventsLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : eventItems.length === 0 ? ( +
+ {membersOnly && (communityEvents ?? []).length > 0 + ? 'No events from community members yet. Toggle the shield icon to see all events.' + : <>No events yet.{user ? ' Create one to get started!' : ''}} +
+ ) : ( +
+ {eventItems.map((e) => ( + + ))} +
+ )} +
diff --git a/src/hooks/useCommunityEvents.ts b/src/hooks/useCommunityEvents.ts new file mode 100644 index 00000000..2d221fdc --- /dev/null +++ b/src/hooks/useCommunityEvents.ts @@ -0,0 +1,47 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; + +const CALENDAR_EVENT_KINDS = [31922, 31923]; + +function getTag(tags: string[][], name: string): string | undefined { + return tags.find(([n]) => n === name)?.[1]; +} + +function isValidCalendarEvent(event: NostrEvent): boolean { + if (!CALENDAR_EVENT_KINDS.includes(event.kind)) return false; + + const d = getTag(event.tags, 'd'); + const title = getTag(event.tags, 'title'); + const start = getTag(event.tags, 'start'); + if (!d || !title || !start) return false; + + if (event.kind === 31922) { + return /^\d{4}-\d{2}-\d{2}$/.test(start); + } + + const startTs = parseInt(start, 10); + return Number.isFinite(startTs) && startTs > 0; +} + +/** Fetches NIP-52 calendar events scoped to a community via the uppercase `A` tag. */ +export function useCommunityEvents(communityATag: string | undefined) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['community-events', communityATag], + queryFn: async ({ signal }): Promise => { + if (!communityATag) return []; + const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]); + + const events = await nostr.query( + [{ kinds: CALENDAR_EVENT_KINDS, '#A': [communityATag], limit: 50 }], + { signal: combinedSignal }, + ); + + return events.filter(isValidCalendarEvent); + }, + enabled: !!communityATag, + staleTime: 60_000, + }); +}