From a7c70fa0a625931828ce910d26b914b8597e7821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Fri, 6 Mar 2026 13:25:44 +0100 Subject: [PATCH] fix: simplify nip-29 group message and metadata fetching and avoid inconsistencies --- src/components/GroupListViewer.tsx | 303 ++--------------------------- src/hooks/useGroupMetadata.ts | 80 ++++++++ src/hooks/useNip29GroupList.ts | 164 ++++++++++++++++ 3 files changed, 264 insertions(+), 283 deletions(-) create mode 100644 src/hooks/useGroupMetadata.ts create mode 100644 src/hooks/useNip29GroupList.ts diff --git a/src/components/GroupListViewer.tsx b/src/components/GroupListViewer.tsx index a45ca0b..c7427a0 100644 --- a/src/components/GroupListViewer.tsx +++ b/src/components/GroupListViewer.tsx @@ -1,12 +1,8 @@ -import { useState, useMemo, memo, useCallback, useEffect } from "react"; +import { useState, memo, useCallback } from "react"; import { use$ } from "applesauce-react/hooks"; -import { map } from "rxjs/operators"; import { Loader2, PanelLeft } from "lucide-react"; -import eventStore from "@/services/event-store"; -import pool from "@/services/relay-pool"; import accountManager from "@/services/accounts"; import { ChatViewer } from "./ChatViewer"; -import type { NostrEvent } from "@/types/nostr"; import type { ProtocolIdentifier, GroupListIdentifier } from "@/types/chat"; import { cn } from "@/lib/utils"; import Timestamp from "./Timestamp"; @@ -15,36 +11,9 @@ import { RichText } from "./nostr/RichText"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; -import { - resolveGroupMetadata, - type ResolvedGroupMetadata, -} from "@/lib/chat/group-metadata-helpers"; - -const MOBILE_BREAKPOINT = 768; - -function useIsMobile() { - const [isMobile, setIsMobile] = useState(undefined); - - useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); - }; - mql.addEventListener("change", onChange); - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); - return () => mql.removeEventListener("change", onChange); - }, []); - - return isMobile; -} - -interface GroupInfo { - groupId: string; - relayUrl: string; - metadata?: NostrEvent; - lastMessage?: NostrEvent; - resolvedMetadata?: ResolvedGroupMetadata; -} +import { useIsMobile } from "@/hooks/useIsMobile"; +import { useNip29GroupList, type GroupEntry } from "@/hooks/useNip29GroupList"; +import { useGroupMetadata } from "@/hooks/useGroupMetadata"; /** * Format relay URL for display @@ -61,17 +30,17 @@ const GroupListItem = memo(function GroupListItem({ isSelected, onClick, }: { - group: GroupInfo; + group: GroupEntry; isSelected: boolean; onClick: () => void; }) { - // Extract group name from resolved metadata (includes profile fallback) const isUnmanagedGroup = group.groupId === "_"; + const resolvedMetadata = useGroupMetadata(group.groupId, group.relayUrl); + const groupName = isUnmanagedGroup ? formatRelayForDisplay(group.relayUrl) - : group.resolvedMetadata?.name || group.groupId; + : resolvedMetadata?.name || group.groupId; - // Get last message author and content const lastMessageAuthor = group.lastMessage?.pubkey; const lastMessageContent = group.lastMessage?.content; @@ -165,16 +134,20 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) { const activeAccount = use$(accountManager.active$); const activePubkey = activeAccount?.pubkey; - // Determine which pubkey/identifier to load: - // - If identifier prop is provided, use that (allows viewing other users' lists) - // - Otherwise, use active user's pubkey (default behavior) + // Determine which pubkey/identifier to load const targetPubkey = identifier?.value.pubkey || activePubkey; - const targetIdentifier = identifier?.value.identifier || ""; // Empty string is default d-tag for kind 10009 + const targetIdentifier = identifier?.value.identifier || ""; const targetRelays = identifier?.relays; - // Mobile detection const isMobile = useIsMobile(); + // Load groups and last messages (per-relay, composite-keyed) + const { groupListEvent, groups } = useNip29GroupList( + targetPubkey, + targetIdentifier, + targetRelays, + ); + // State for selected group const [selectedGroup, setSelectedGroup] = useState<{ groupId: string; @@ -211,7 +184,6 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) { const handleMouseMove = (moveEvent: MouseEvent) => { const deltaX = moveEvent.clientX - startX; const newWidth = startWidth + deltaX; - // Clamp between 200px and 500px setSidebarWidth(Math.max(200, Math.min(500, newWidth))); }; @@ -223,236 +195,10 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) { document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); - - // Cleanup listeners on component unmount (stored in ref) - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; }, [sidebarWidth], ); - // Cleanup resize event listeners on unmount - useEffect(() => { - return () => { - setIsResizing(false); - }; - }, []); - - // Load kind 10009 (group list) event - // If identifier is provided with relays, subscribe to those relays first - useEffect(() => { - if (!targetPubkey || !targetRelays || targetRelays.length === 0) return; - - const subscription = pool - .subscription( - targetRelays, - [{ kinds: [10009], authors: [targetPubkey], "#d": [targetIdentifier] }], - { eventStore }, - ) - .subscribe(); - - return () => { - subscription.unsubscribe(); - }; - }, [targetPubkey, targetIdentifier, targetRelays]); - - const groupListEvent = use$( - () => - targetPubkey - ? eventStore.replaceable(10009, targetPubkey, targetIdentifier) - : undefined, - [targetPubkey, targetIdentifier], - ); - - // Extract groups from the event with relay URL validation - const groups = useMemo(() => { - if (!groupListEvent) return []; - - const extractedGroups: Array<{ - groupId: string; - relayUrl: string; - }> = []; - - for (const tag of groupListEvent.tags) { - if (tag[0] === "group" && tag[1] && tag[2]) { - // Validate relay URL before adding - const relayUrl = tag[2]; - try { - const url = new URL( - relayUrl.startsWith("ws://") || relayUrl.startsWith("wss://") - ? relayUrl - : `wss://${relayUrl}`, - ); - // Only accept ws:// or wss:// protocols - if (url.protocol === "ws:" || url.protocol === "wss:") { - extractedGroups.push({ - groupId: tag[1], - relayUrl: url.toString(), - }); - } - } catch { - // Invalid URL, skip this group - continue; - } - } - } - - return extractedGroups; - }, [groupListEvent]); - - // Subscribe to group metadata (kind 39000) for all groups - useEffect(() => { - if (groups.length === 0) return; - - const groupIds = groups.map((g) => g.groupId).filter((id) => id !== "_"); - const relayUrls = Array.from(new Set(groups.map((g) => g.relayUrl))); - - if (groupIds.length === 0) return; - - const subscription = pool - .subscription(relayUrls, [{ kinds: [39000], "#d": groupIds }], { - eventStore, - }) - .subscribe(); - - return () => { - subscription.unsubscribe(); - }; - }, [groups]); - - // Load metadata for all groups - const groupMetadataMap = use$(() => { - const groupIds = groups.map((g) => g.groupId).filter((id) => id !== "_"); - if (groupIds.length === 0) return undefined; - - return eventStore.timeline([{ kinds: [39000], "#d": groupIds }]).pipe( - map((events) => { - const metadataMap = new Map(); - for (const evt of events) { - const dTag = evt.tags.find((t) => t[0] === "d"); - if (dTag && dTag[1]) { - metadataMap.set(dTag[1], evt); - } - } - return metadataMap; - }), - ); - }, [groups]); - - // Resolve metadata with profile fallback for groups without NIP-29 metadata - const [resolvedMetadataMap, setResolvedMetadataMap] = useState< - Map - >(new Map()); - - useEffect(() => { - if (groups.length === 0) return; - - const resolveAllMetadata = async () => { - const newResolvedMap = new Map(); - - // Resolve metadata for each group - await Promise.all( - groups.map(async (group) => { - // Skip unmanaged groups - if (group.groupId === "_") return; - - const existingMetadata = groupMetadataMap?.get(group.groupId); - const resolved = await resolveGroupMetadata( - group.groupId, - group.relayUrl, - existingMetadata, - ); - - newResolvedMap.set(`${group.relayUrl}'${group.groupId}`, resolved); - }), - ); - - setResolvedMetadataMap(newResolvedMap); - }; - - resolveAllMetadata(); - }, [groups, groupMetadataMap]); - - // Subscribe to latest messages (kind 9) for all groups to get recency - // NOTE: Separate filters needed to ensure we get 1 message per group (not N total across all groups) - useEffect(() => { - if (groups.length === 0) return; - - const relayUrls = Array.from(new Set(groups.map((g) => g.relayUrl))); - const groupIds = groups.map((g) => g.groupId); - - // One filter per group to ensure limit:1 applies per group, not globally - const subscription = pool - .subscription( - relayUrls, - groupIds.map((groupId) => ({ - kinds: [9], - "#h": [groupId], - limit: 1, - })), - { eventStore }, - ) - .subscribe(); - - return () => { - subscription.unsubscribe(); - }; - }, [groups]); - - // Load latest messages and merge with group data - const groupsWithRecency = use$(() => { - if (groups.length === 0) return undefined; - - const groupIds = groups.map((g) => g.groupId); - - return eventStore - .timeline( - groupIds.map((groupId) => ({ - kinds: [9], - "#h": [groupId], - limit: 1, - })), - ) - .pipe( - map((events) => { - // Create a map of groupId -> latest message - const messageMap = new Map(); - for (const evt of events) { - const hTag = evt.tags.find((t) => t[0] === "h"); - if (hTag && hTag[1]) { - const existing = messageMap.get(hTag[1]); - if (!existing || evt.created_at > existing.created_at) { - messageMap.set(hTag[1], evt); - } - } - } - - // Merge with groups - const groupsWithInfo: GroupInfo[] = groups.map((g) => { - const groupKey = `${g.relayUrl}'${g.groupId}`; - return { - groupId: g.groupId, - relayUrl: g.relayUrl, - metadata: groupMetadataMap?.get(g.groupId), - lastMessage: messageMap.get(g.groupId), - resolvedMetadata: resolvedMetadataMap.get(groupKey), - }; - }); - - // Sort by recency (most recent first) - groupsWithInfo.sort((a, b) => { - const aTime = a.lastMessage?.created_at || 0; - const bTime = b.lastMessage?.created_at || 0; - return bTime - aTime; - }); - - return groupsWithInfo; - }), - ); - }, [groups, groupMetadataMap, resolvedMetadataMap]); - // Only require sign-in if no identifier is provided (viewing own groups) if (!targetPubkey) { return ( @@ -471,7 +217,7 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) { ); } - if (!groups || groups.length === 0) { + if (groups.length === 0) { return (
No groups configured. Add groups to your kind 10009 list. @@ -479,19 +225,10 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) { ); } - if (!groupsWithRecency) { - return ( -
- - Loading group details... -
- ); - } - // Group list content - reused in both mobile sheet and desktop sidebar const groupListContent = (
- {groupsWithRecency.map((group) => ( + {groups.map((group) => ( setSidebarOpen(true)} > diff --git a/src/hooks/useGroupMetadata.ts b/src/hooks/useGroupMetadata.ts new file mode 100644 index 0000000..38344d2 --- /dev/null +++ b/src/hooks/useGroupMetadata.ts @@ -0,0 +1,80 @@ +import { useState, useEffect } from "react"; +import { use$ } from "applesauce-react/hooks"; +import { map } from "rxjs/operators"; +import { getSeenRelays } from "applesauce-core/helpers/relays"; +import eventStore from "@/services/event-store"; +import pool from "@/services/relay-pool"; +import { + resolveGroupMetadata, + type ResolvedGroupMetadata, +} from "@/lib/chat/group-metadata-helpers"; + +/** + * Hook that fetches and resolves NIP-29 group metadata for a single group. + * + * Subscribes to kind 39000 on the group's relay, then resolves metadata + * with profile fallback. Filters by seenRelays to avoid cross-relay contamination + * when multiple relays host groups with the same ID. + */ +export function useGroupMetadata( + groupId: string, + relayUrl: string, +): ResolvedGroupMetadata | undefined { + const isUnmanaged = groupId === "_"; + + // Subscribe to kind 39000 metadata on the group's relay + useEffect(() => { + if (isUnmanaged) return; + + const sub = pool + .subscription([relayUrl], [{ kinds: [39000], "#d": [groupId] }], { + eventStore, + }) + .subscribe(); + + return () => sub.unsubscribe(); + }, [groupId, relayUrl, isUnmanaged]); + + // Observe the metadata event from the store via timeline query. + // kind 39000 author is the relay's pubkey (unknown in advance), so we + // query by d-tag and filter by seenRelays to get the correct relay's metadata. + const normalizedRelay = relayUrl.replace(/\/$/, ""); + const metadataEvent = use$( + () => + !isUnmanaged + ? eventStore.timeline([{ kinds: [39000], "#d": [groupId] }]).pipe( + map((events) => { + // Prefer the event actually seen on this relay + const fromRelay = events.find((evt) => { + const seen = getSeenRelays(evt); + if (!seen || seen.size === 0) return false; + return Array.from(seen).some( + (r) => r.replace(/\/$/, "") === normalizedRelay, + ); + }); + return fromRelay ?? events[0]; + }), + ) + : undefined, + [groupId, isUnmanaged, normalizedRelay], + ); + + // Resolve metadata with profile fallback + const [resolved, setResolved] = useState(); + + useEffect(() => { + if (isUnmanaged) return; + + let cancelled = false; + + resolveGroupMetadata(groupId, relayUrl, metadataEvent).then((result) => { + if (!cancelled) setResolved(result); + }); + + return () => { + cancelled = true; + }; + }, [groupId, relayUrl, metadataEvent, isUnmanaged]); + + return resolved; +} diff --git a/src/hooks/useNip29GroupList.ts b/src/hooks/useNip29GroupList.ts new file mode 100644 index 0000000..04dffcf --- /dev/null +++ b/src/hooks/useNip29GroupList.ts @@ -0,0 +1,164 @@ +import { useState, useMemo, useEffect } from "react"; +import { use$ } from "applesauce-react/hooks"; +import { isNostrEvent } from "@/lib/type-guards"; +import eventStore from "@/services/event-store"; +import pool from "@/services/relay-pool"; +import { useStableArray } from "@/hooks/useStable"; +import type { NostrEvent } from "@/types/nostr"; + +export interface GroupEntry { + groupId: string; + relayUrl: string; + lastMessage?: NostrEvent; +} + +/** + * Hook that loads a kind 10009 group list, extracts groups, + * and subscribes per-relay for last messages (kind 9). + * + * Keys last-message map by `relayUrl'groupId` to prevent + * cross-relay contamination when groups share the same ID. + */ +export function useNip29GroupList( + pubkey: string | undefined, + identifier: string, + relays?: string[], +): { + groupListEvent: NostrEvent | undefined; + groups: GroupEntry[]; + loading: boolean; +} { + const stableRelays = useStableArray(relays || []); + + // Subscribe to kind 10009 from hint relays if provided + useEffect(() => { + if (!pubkey || stableRelays.length === 0) return; + + const sub = pool + .subscription( + stableRelays, + [{ kinds: [10009], authors: [pubkey], "#d": [identifier] }], + { eventStore }, + ) + .subscribe(); + + return () => sub.unsubscribe(); + }, [pubkey, identifier, stableRelays]); + + // Observe the replaceable event from the store + const groupListEvent = use$( + () => + pubkey ? eventStore.replaceable(10009, pubkey, identifier) : undefined, + [pubkey, identifier], + ); + + // Extract group entries from tags + const extractedGroups = useMemo(() => { + if (!groupListEvent) return []; + + const result: Array<{ groupId: string; relayUrl: string }> = []; + + for (const tag of groupListEvent.tags) { + if (tag[0] === "group" && tag[1] && tag[2]) { + const raw = tag[2]; + try { + const url = new URL( + raw.startsWith("ws://") || raw.startsWith("wss://") + ? raw + : `wss://${raw}`, + ); + if (url.protocol === "ws:" || url.protocol === "wss:") { + result.push({ groupId: tag[1], relayUrl: url.toString() }); + } + } catch { + continue; + } + } + } + + return result; + }, [groupListEvent]); + + // Track last message per relay'groupId + const [lastMessageMap, setLastMessageMap] = useState>( + new Map(), + ); + + // Per-relay subscriptions for last messages (kind 9) + // Each relay only gets filters for its own groups, and we attribute + // incoming events to the relay we received them from. + useEffect(() => { + if (extractedGroups.length === 0) return; + + // Group by relay URL + const byRelay = new Map(); + for (const g of extractedGroups) { + const list = byRelay.get(g.relayUrl) || []; + list.push(g.groupId); + byRelay.set(g.relayUrl, list); + } + + const subs: Array<{ unsubscribe: () => void }> = []; + + for (const [relayUrl, groupIds] of byRelay) { + // One filter per group so limit:1 applies per group, not globally + const filters = groupIds.map((gid) => ({ + kinds: [9], + "#h": [gid], + limit: 1, + })); + + const sub = pool + .subscription([relayUrl], filters, { eventStore }) + .subscribe((response) => { + if (!isNostrEvent(response)) return; + + const hTag = response.tags.find((t) => t[0] === "h"); + if (!hTag?.[1]) return; + const groupId = hTag[1]; + + // Only track if this groupId belongs to this relay + if (!groupIds.includes(groupId)) return; + + const key = `${relayUrl}'${groupId}`; + + setLastMessageMap((prev) => { + const existing = prev.get(key); + if (existing && existing.created_at >= response.created_at) { + return prev; + } + const next = new Map(prev); + next.set(key, response); + return next; + }); + }); + + subs.push(sub); + } + + return () => subs.forEach((s) => s.unsubscribe()); + }, [extractedGroups]); + + // Merge groups with last messages and sort by recency + const groups: GroupEntry[] = useMemo(() => { + const result = extractedGroups.map((g) => ({ + groupId: g.groupId, + relayUrl: g.relayUrl, + lastMessage: lastMessageMap.get(`${g.relayUrl}'${g.groupId}`), + })); + + result.sort((a, b) => { + const aTime = a.lastMessage?.created_at || 0; + const bTime = b.lastMessage?.created_at || 0; + return bTime - aTime; + }); + + return result; + }, [extractedGroups, lastMessageMap]); + + return { + groupListEvent, + groups, + loading: !groupListEvent && !!pubkey, + }; +}