mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-04 09:41:13 +02:00
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:
@@ -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) {
|
||||
|
||||
@@ -180,4 +180,77 @@ describe("Nip29Adapter", () => {
|
||||
expect(capabilities.requiresRelay).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("profile fallback for pubkey group IDs", () => {
|
||||
it("should parse valid pubkey as group ID", () => {
|
||||
const validPubkey =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const result = adapter.parseIdentifier(
|
||||
`wss://relay.example.com'${validPubkey}`,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "group",
|
||||
value: validPubkey,
|
||||
relays: ["wss://relay.example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse uppercase pubkey as group ID", () => {
|
||||
const validPubkey =
|
||||
"3BF0C63FCB93463407AF97A5E5EE64FA883D107EF9E558472C4EB9AAAEFA459D";
|
||||
const result = adapter.parseIdentifier(
|
||||
`wss://relay.example.com'${validPubkey}`,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "group",
|
||||
value: validPubkey,
|
||||
relays: ["wss://relay.example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse mixed case pubkey as group ID", () => {
|
||||
const validPubkey =
|
||||
"3bF0c63Fcb93463407aF97a5e5Ee64fA883d107eF9e558472c4eB9aaaEfa459D";
|
||||
const result = adapter.parseIdentifier(
|
||||
`wss://relay.example.com'${validPubkey}`,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "group",
|
||||
value: validPubkey,
|
||||
relays: ["wss://relay.example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should not treat short hex strings as valid pubkeys", () => {
|
||||
// Less than 64 characters should be treated as normal group IDs
|
||||
const shortHex = "3bf0c63f";
|
||||
const result = adapter.parseIdentifier(
|
||||
`wss://relay.example.com'${shortHex}`,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "group",
|
||||
value: shortHex,
|
||||
relays: ["wss://relay.example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should not treat non-hex strings as valid pubkeys", () => {
|
||||
// 64 characters but contains non-hex characters
|
||||
const nonHex =
|
||||
"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
|
||||
const result = adapter.parseIdentifier(
|
||||
`wss://relay.example.com'${nonHex}`,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "group",
|
||||
value: nonHex,
|
||||
relays: ["wss://relay.example.com"],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
GroupMessageBlueprint,
|
||||
ReactionBlueprint,
|
||||
} from "applesauce-common/blueprints";
|
||||
import { resolveGroupMetadata } from "@/lib/chat/group-metadata-helpers";
|
||||
|
||||
/**
|
||||
* NIP-29 Adapter - Relay-Based Groups
|
||||
@@ -183,18 +184,18 @@ export class Nip29Adapter extends ChatProtocolAdapter {
|
||||
console.log(`[NIP-29] Metadata event tags:`, metadataEvent.tags);
|
||||
}
|
||||
|
||||
// Extract group info from metadata event
|
||||
const title = metadataEvent
|
||||
? getTagValues(metadataEvent, "name")[0] || groupId
|
||||
: groupId;
|
||||
const description = metadataEvent
|
||||
? getTagValues(metadataEvent, "about")[0]
|
||||
: undefined;
|
||||
const icon = metadataEvent
|
||||
? getTagValues(metadataEvent, "picture")[0]
|
||||
: undefined;
|
||||
// Resolve group metadata with profile fallback
|
||||
const resolved = await resolveGroupMetadata(
|
||||
groupId,
|
||||
relayUrl,
|
||||
metadataEvent,
|
||||
);
|
||||
|
||||
console.log(`[NIP-29] Group title: ${title}`);
|
||||
const title = resolved.name || groupId;
|
||||
const description = resolved.description;
|
||||
const icon = resolved.icon;
|
||||
|
||||
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