Add profile fallback for pubkey-based NIP-29 group IDs (#203)

* 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>
This commit is contained in:
Alejandro
2026-01-22 18:29:56 +01:00
committed by GitHub
parent 7cf75c648c
commit f551604866
4 changed files with 236 additions and 28 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) {