mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
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
This commit is contained in:
@@ -6,7 +6,6 @@ import eventStore from "@/services/event-store";
|
||||
import pool from "@/services/relay-pool";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { ChatViewer } from "./ChatViewer";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import type { ProtocolIdentifier, GroupListIdentifier } from "@/types/chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -16,6 +15,10 @@ 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;
|
||||
|
||||
@@ -40,6 +43,7 @@ interface GroupInfo {
|
||||
relayUrl: string;
|
||||
metadata?: NostrEvent;
|
||||
lastMessage?: NostrEvent;
|
||||
resolvedMetadata?: ResolvedGroupMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,16 +65,11 @@ const GroupListItem = memo(function GroupListItem({
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
// Extract group name from metadata
|
||||
// Extract group name from resolved metadata (includes profile fallback)
|
||||
const isUnmanagedGroup = group.groupId === "_";
|
||||
let groupName: string;
|
||||
if (isUnmanagedGroup) {
|
||||
groupName = formatRelayForDisplay(group.relayUrl);
|
||||
} else if (group.metadata && group.metadata.kind === 39000) {
|
||||
groupName = getTagValue(group.metadata, "name") || group.groupId;
|
||||
} else {
|
||||
groupName = group.groupId;
|
||||
}
|
||||
const groupName = isUnmanagedGroup
|
||||
? formatRelayForDisplay(group.relayUrl)
|
||||
: group.resolvedMetadata?.name || group.groupId;
|
||||
|
||||
// Get last message author and content
|
||||
const lastMessageAuthor = group.lastMessage?.pubkey;
|
||||
@@ -342,6 +341,40 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) {
|
||||
);
|
||||
}, [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(() => {
|
||||
@@ -397,12 +430,16 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) {
|
||||
}
|
||||
|
||||
// Merge with groups
|
||||
const groupsWithInfo: GroupInfo[] = groups.map((g) => ({
|
||||
groupId: g.groupId,
|
||||
relayUrl: g.relayUrl,
|
||||
metadata: groupMetadataMap?.get(g.groupId),
|
||||
lastMessage: messageMap.get(g.groupId),
|
||||
}));
|
||||
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) => {
|
||||
@@ -414,7 +451,7 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) {
|
||||
return groupsWithInfo;
|
||||
}),
|
||||
);
|
||||
}, [groups, groupMetadataMap]);
|
||||
}, [groups, groupMetadataMap, resolvedMetadataMap]);
|
||||
|
||||
// Only require sign-in if no identifier is provided (viewing own groups)
|
||||
if (!targetPubkey) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Observable, firstValueFrom } from "rxjs";
|
||||
import { map, first, toArray } from "rxjs/operators";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import { nip19, kinds } from "nostr-tools";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
|
||||
import type {
|
||||
Conversation,
|
||||
@@ -25,15 +25,7 @@ import {
|
||||
GroupMessageBlueprint,
|
||||
ReactionBlueprint,
|
||||
} from "applesauce-common/blueprints";
|
||||
import { profileLoader } from "@/services/loaders";
|
||||
import { getProfileContent } from "applesauce-core/helpers";
|
||||
|
||||
/**
|
||||
* Check if a string is a valid nostr pubkey (64 character hex string)
|
||||
*/
|
||||
function isValidPubkey(str: string): boolean {
|
||||
return /^[0-9a-f]{64}$/i.test(str);
|
||||
}
|
||||
import { resolveGroupMetadata } from "@/lib/chat/group-metadata-helpers";
|
||||
|
||||
/**
|
||||
* NIP-29 Adapter - Relay-Based Groups
|
||||
@@ -189,55 +181,18 @@ export class Nip29Adapter extends ChatProtocolAdapter {
|
||||
console.log(`[NIP-29] Metadata event tags:`, metadataEvent.tags);
|
||||
}
|
||||
|
||||
// Extract group info from metadata event
|
||||
let title = metadataEvent
|
||||
? getTagValues(metadataEvent, "name")[0] || groupId
|
||||
: groupId;
|
||||
let description = metadataEvent
|
||||
? getTagValues(metadataEvent, "about")[0]
|
||||
: undefined;
|
||||
let icon = metadataEvent
|
||||
? getTagValues(metadataEvent, "picture")[0]
|
||||
: undefined;
|
||||
// Resolve group metadata with profile fallback
|
||||
const resolved = await resolveGroupMetadata(
|
||||
groupId,
|
||||
relayUrl,
|
||||
metadataEvent,
|
||||
);
|
||||
|
||||
// Fallback: If no metadata found and groupId is a valid pubkey, use profile metadata
|
||||
if (!metadataEvent && isValidPubkey(groupId)) {
|
||||
console.log(
|
||||
`[NIP-29] No group metadata found, groupId is valid pubkey. Fetching profile for ${groupId.slice(0, 8)}...`,
|
||||
);
|
||||
const title = resolved.name || groupId;
|
||||
const description = resolved.description;
|
||||
const icon = resolved.icon;
|
||||
|
||||
try {
|
||||
// Fetch profile metadata (kind 0) for the pubkey
|
||||
const profileEvent = await firstValueFrom(
|
||||
profileLoader({
|
||||
kind: kinds.Metadata,
|
||||
pubkey: groupId,
|
||||
relays: [relayUrl], // Try the group relay first
|
||||
}),
|
||||
{ defaultValue: undefined },
|
||||
);
|
||||
|
||||
if (profileEvent) {
|
||||
const profileContent = getProfileContent(profileEvent);
|
||||
if (profileContent) {
|
||||
console.log(
|
||||
`[NIP-29] Using profile metadata as fallback for group ${groupId.slice(0, 8)}...`,
|
||||
);
|
||||
title = profileContent.display_name || profileContent.name || title;
|
||||
description = profileContent.about || description;
|
||||
icon = profileContent.picture || icon;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[NIP-29] Failed to fetch profile fallback for ${groupId.slice(0, 8)}:`,
|
||||
error,
|
||||
);
|
||||
// Continue with default title (groupId) if profile fetch fails
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[NIP-29] Group title: ${title}`);
|
||||
console.log(`[NIP-29] Group title: ${title} (source: ${resolved.source})`);
|
||||
|
||||
// Fetch admins (kind 39001) and members (kind 39002) in parallel
|
||||
// Both use d tag (addressable events signed by relay)
|
||||
|
||||
97
src/lib/chat/group-metadata-helpers.ts
Normal file
97
src/lib/chat/group-metadata-helpers.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { kinds } from "nostr-tools";
|
||||
import { profileLoader } from "@/services/loaders";
|
||||
import { getProfileContent } from "applesauce-core/helpers";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
|
||||
/**
|
||||
* Check if a string is a valid nostr pubkey (64 character hex string)
|
||||
*/
|
||||
export function isValidPubkey(str: string): boolean {
|
||||
return /^[0-9a-f]{64}$/i.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved group metadata
|
||||
*/
|
||||
export interface ResolvedGroupMetadata {
|
||||
name?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
source: "nip29" | "profile" | "fallback";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve group metadata with profile fallback
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* @param groupId - The group identifier (may be a pubkey)
|
||||
* @param relayUrl - The relay URL to fetch profile from (if needed)
|
||||
* @param metadataEvent - Optional NIP-29 metadata event (kind 39000)
|
||||
* @returns Resolved metadata
|
||||
*/
|
||||
export async function resolveGroupMetadata(
|
||||
groupId: string,
|
||||
relayUrl: string,
|
||||
metadataEvent?: NostrEvent,
|
||||
): Promise<ResolvedGroupMetadata> {
|
||||
// If NIP-29 metadata exists, use it (priority 1)
|
||||
if (metadataEvent && metadataEvent.kind === 39000) {
|
||||
const name =
|
||||
metadataEvent.tags.find((t) => t[0] === "name")?.[1] || groupId;
|
||||
const description = metadataEvent.tags.find((t) => t[0] === "about")?.[1];
|
||||
const icon = metadataEvent.tags.find((t) => t[0] === "picture")?.[1];
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
source: "nip29",
|
||||
};
|
||||
}
|
||||
|
||||
// If no NIP-29 metadata and groupId is a valid pubkey, try profile fallback (priority 2)
|
||||
if (isValidPubkey(groupId)) {
|
||||
try {
|
||||
const profileEvent = await firstValueFrom(
|
||||
profileLoader({
|
||||
kind: kinds.Metadata,
|
||||
pubkey: groupId,
|
||||
relays: [relayUrl],
|
||||
}),
|
||||
{ defaultValue: undefined },
|
||||
);
|
||||
|
||||
if (profileEvent) {
|
||||
const profileContent = getProfileContent(profileEvent);
|
||||
if (profileContent) {
|
||||
return {
|
||||
name:
|
||||
profileContent.display_name ||
|
||||
profileContent.name ||
|
||||
groupId.slice(0, 8) + ":" + groupId.slice(-8),
|
||||
description: profileContent.about,
|
||||
icon: profileContent.picture,
|
||||
source: "profile",
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[GroupMetadata] Failed to fetch profile fallback for ${groupId.slice(0, 8)}:`,
|
||||
error,
|
||||
);
|
||||
// Fall through to fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use groupId as name (priority 3)
|
||||
return {
|
||||
name: groupId,
|
||||
source: "fallback",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user