mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 00:17:02 +02:00
* feat: add profile metadata fallback for NIP-29 groups When a NIP-29 group ID is a valid pubkey and the relay doesn't support NIP-29 (no kind 39000 metadata), fall back to using the pubkey's profile metadata (kind 0) for group name, description, and icon. This allows users to create simple group chats using their pubkey as the group identifier on relays that don't have full NIP-29 support. Changes: - Add isValidPubkey() helper to validate 64-char hex strings - Modify resolveConversation() to fetch profile when metadata is missing - Add comprehensive tests for pubkey validation and parsing - Prefer NIP-29 metadata over profile fallback when available Tests: 20 NIP-29 adapter tests passing, 1037 total tests passing Build: Successful * refactor: extract group metadata resolution into shared helper Refactored profile fallback logic into a reusable helper that both NIP-29 adapter and GroupListViewer can use. This fixes the issue where GroupListViewer wasn't benefiting from the profile metadata fallback. Changes: - Created shared `group-metadata-helpers.ts` with: - `isValidPubkey()` - validates 64-character hex strings - `resolveGroupMetadata()` - unified metadata resolution with fallback - `ResolvedGroupMetadata` type with source tracking - Updated NIP-29 adapter to use shared helper (DRY) - Updated GroupListViewer to resolve metadata with profile fallback - Added resolvedMetadata to GroupInfo interface - Added useEffect to resolve metadata for all groups - Updated GroupListItem to use resolved metadata Priority: 1. NIP-29 metadata (kind 39000) if available 2. Profile metadata (kind 0) if groupId is a valid pubkey 3. Fallback to groupId as name Tests: All 1037 tests passing Build: Successful --------- Co-authored-by: Claude <noreply@anthropic.com>
595 lines
17 KiB
TypeScript
595 lines
17 KiB
TypeScript
import { useState, useMemo, memo, useCallback, useEffect } 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";
|
|
import { UserName } from "./nostr/UserName";
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Format relay URL for display
|
|
*/
|
|
function formatRelayForDisplay(url: string): string {
|
|
return url.replace(/^wss?:\/\//, "").replace(/\/$/, "");
|
|
}
|
|
|
|
/**
|
|
* GroupListItem - Single group in the list
|
|
*/
|
|
const GroupListItem = memo(function GroupListItem({
|
|
group,
|
|
isSelected,
|
|
onClick,
|
|
}: {
|
|
group: GroupInfo;
|
|
isSelected: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
// Extract group name from resolved metadata (includes profile fallback)
|
|
const isUnmanagedGroup = group.groupId === "_";
|
|
const groupName = isUnmanagedGroup
|
|
? formatRelayForDisplay(group.relayUrl)
|
|
: group.resolvedMetadata?.name || group.groupId;
|
|
|
|
// Get last message author and content
|
|
const lastMessageAuthor = group.lastMessage?.pubkey;
|
|
const lastMessageContent = group.lastMessage?.content;
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex flex-col gap-0 px-2 py-0.5 cursor-crosshair hover:bg-muted/50 transition-colors border-b",
|
|
isSelected && "bg-muted/70",
|
|
)}
|
|
onClick={onClick}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-sm font-medium truncate">{groupName}</span>
|
|
{group.lastMessage && (
|
|
<span className="text-xs text-muted-foreground flex-shrink-0">
|
|
<Timestamp timestamp={group.lastMessage.created_at} />
|
|
</span>
|
|
)}
|
|
</div>
|
|
{/* Last message preview - hide images and event embeds */}
|
|
{lastMessageAuthor && lastMessageContent && (
|
|
<div className="text-xs text-muted-foreground truncate line-clamp-1 pointer-events-none">
|
|
<UserName
|
|
pubkey={lastMessageAuthor}
|
|
className="text-xs font-medium"
|
|
/>
|
|
:{" "}
|
|
<span className="inline truncate">
|
|
<RichText
|
|
event={group.lastMessage}
|
|
className="inline"
|
|
options={{
|
|
showImages: false,
|
|
showEventEmbeds: false,
|
|
}}
|
|
/>
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
/**
|
|
* MemoizedChatViewer - Memoized chat viewer to prevent unnecessary re-renders
|
|
*/
|
|
const MemoizedChatViewer = memo(
|
|
function MemoizedChatViewer({
|
|
groupId,
|
|
relayUrl,
|
|
headerPrefix,
|
|
}: {
|
|
groupId: string;
|
|
relayUrl: string;
|
|
headerPrefix?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<ChatViewer
|
|
protocol="nip-29"
|
|
identifier={
|
|
{
|
|
type: "group",
|
|
value: groupId,
|
|
relays: [relayUrl],
|
|
} as ProtocolIdentifier
|
|
}
|
|
headerPrefix={headerPrefix}
|
|
/>
|
|
);
|
|
},
|
|
// Custom comparison: only re-render if group actually changed
|
|
// Note: headerPrefix is intentionally excluded - it's expected to be stable or change with isMobile
|
|
(prev, next) =>
|
|
prev.groupId === next.groupId && prev.relayUrl === next.relayUrl,
|
|
);
|
|
|
|
interface GroupListViewerProps {
|
|
identifier?: GroupListIdentifier;
|
|
}
|
|
|
|
/**
|
|
* GroupListViewer - Multi-room chat interface
|
|
*
|
|
* Left panel: List of groups from kind 10009, sorted by recency
|
|
* Right panel: Chat view for selected group
|
|
*
|
|
* @param identifier - Optional group list identifier. If provided, loads that specific
|
|
* kind 10009 event. If not provided, loads active user's list.
|
|
*/
|
|
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)
|
|
const targetPubkey = identifier?.value.pubkey || activePubkey;
|
|
const targetIdentifier = identifier?.value.identifier || ""; // Empty string is default d-tag for kind 10009
|
|
const targetRelays = identifier?.relays;
|
|
|
|
// Mobile detection
|
|
const isMobile = useIsMobile();
|
|
|
|
// State for selected group
|
|
const [selectedGroup, setSelectedGroup] = useState<{
|
|
groupId: string;
|
|
relayUrl: string;
|
|
} | null>(null);
|
|
|
|
// State for mobile sidebar sheet
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
|
|
// State for sidebar width (desktop only)
|
|
const [sidebarWidth, setSidebarWidth] = useState(280);
|
|
const [isResizing, setIsResizing] = useState(false);
|
|
|
|
// Handle group selection - close sidebar on mobile
|
|
const handleGroupSelect = useCallback(
|
|
(group: { groupId: string; relayUrl: string }) => {
|
|
setSelectedGroup(group);
|
|
if (isMobile) {
|
|
setSidebarOpen(false);
|
|
}
|
|
},
|
|
[isMobile],
|
|
);
|
|
|
|
// Handle resize with proper cleanup
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setIsResizing(true);
|
|
|
|
const startX = e.clientX;
|
|
const startWidth = sidebarWidth;
|
|
|
|
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)));
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsResizing(false);
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
};
|
|
|
|
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 (
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
Sign in to view your groups
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!groupListEvent) {
|
|
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 groups...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!groups || 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.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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) => (
|
|
<GroupListItem
|
|
key={`${group.relayUrl}'${group.groupId}`}
|
|
group={group}
|
|
isSelected={
|
|
selectedGroup?.groupId === group.groupId &&
|
|
selectedGroup?.relayUrl === group.relayUrl
|
|
}
|
|
onClick={() =>
|
|
handleGroupSelect({
|
|
groupId: group.groupId,
|
|
relayUrl: group.relayUrl,
|
|
})
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// Sidebar toggle button for mobile - passed to ChatViewer's headerPrefix
|
|
const sidebarToggle = isMobile ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 flex-shrink-0"
|
|
onClick={() => setSidebarOpen(true)}
|
|
>
|
|
<PanelLeft className="size-4" />
|
|
<span className="sr-only">Toggle sidebar</span>
|
|
</Button>
|
|
) : null;
|
|
|
|
// Chat view content
|
|
const chatContent = selectedGroup ? (
|
|
<MemoizedChatViewer
|
|
groupId={selectedGroup.groupId}
|
|
relayUrl={selectedGroup.relayUrl}
|
|
headerPrefix={sidebarToggle}
|
|
/>
|
|
) : (
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
{isMobile ? (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setSidebarOpen(true)}
|
|
className="gap-2"
|
|
>
|
|
<PanelLeft className="size-4" />
|
|
Select a group
|
|
</Button>
|
|
) : (
|
|
"Select a group to view chat"
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// Mobile layout: Sheet-based sidebar
|
|
if (isMobile) {
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
{/* Mobile sheet sidebar */}
|
|
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
|
<SheetContent side="left" className="w-[280px] p-0">
|
|
<VisuallyHidden.Root>
|
|
<SheetTitle>Groups</SheetTitle>
|
|
</VisuallyHidden.Root>
|
|
<div className="flex h-full flex-col pt-10">{groupListContent}</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
{/* Chat content - takes full height, sidebar toggle is in ChatViewer header */}
|
|
<div className="flex-1 min-h-0">{chatContent}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Desktop layout: Resizable sidebar
|
|
return (
|
|
<div className="flex h-full">
|
|
{/* Left sidebar: Group list */}
|
|
<aside
|
|
className="flex flex-col border-r bg-background"
|
|
style={{ width: sidebarWidth }}
|
|
>
|
|
{groupListContent}
|
|
</aside>
|
|
|
|
{/* Resize handle */}
|
|
<div
|
|
className={cn(
|
|
"w-1 bg-border hover:bg-primary/50 cursor-col-resize transition-colors",
|
|
isResizing && "bg-primary",
|
|
)}
|
|
onMouseDown={handleMouseDown}
|
|
/>
|
|
|
|
{/* Right panel: Chat view */}
|
|
<div className="flex-1 min-w-0">{chatContent}</div>
|
|
</div>
|
|
);
|
|
}
|