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:
Claude
2026-01-22 17:24:20 +00:00
parent 3d907020bc
commit 0aa590bbfc
3 changed files with 163 additions and 74 deletions

View File

@@ -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) {

View File

@@ -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)

View 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",
};
}