fix: simplify nip-29 group message and metadata fetching and avoid inconsistencies

This commit is contained in:
Alejandro Gómez
2026-03-06 13:25:44 +01:00
parent 80a421b9fe
commit a7c70fa0a6
3 changed files with 264 additions and 283 deletions

View File

@@ -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<boolean | undefined>(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<string, NostrEvent>();
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<string, ResolvedGroupMetadata>
>(new Map());
useEffect(() => {
if (groups.length === 0) return;
const resolveAllMetadata = async () => {
const newResolvedMap = new Map<string, ResolvedGroupMetadata>();
// 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<string, NostrEvent>();
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 (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No groups configured. Add groups to your kind 10009 list.
@@ -479,19 +225,10 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) {
);
}
if (!groupsWithRecency) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
<span>Loading group details...</span>
</div>
);
}
// Group list content - reused in both mobile sheet and desktop sidebar
const groupListContent = (
<div className="flex-1 overflow-y-auto">
{groupsWithRecency.map((group) => (
{groups.map((group) => (
<GroupListItem
key={`${group.relayUrl}'${group.groupId}`}
group={group}
@@ -515,7 +252,7 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) {
<Button
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0"
className="h-7 w-7 shrink-0"
onClick={() => setSidebarOpen(true)}
>
<PanelLeft className="size-4" />

View File

@@ -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<ResolvedGroupMetadata | undefined>();
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;
}

View File

@@ -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<Map<string, NostrEvent>>(
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<string, string[]>();
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,
};
}