mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 00:46:54 +02:00
feat: Improve NIP-17 UI/UX and relay resolution
- Fix reply preview to support NIP-17 synthetic events via adapter.loadReplyMessage() - Update DM title styling to use regular text color with lock icon in header - Add copy button support for NIP-17 chat identifiers (nprofile/npub) - Ensure header structure matches NIP-29 (consistent control placement) - Fix comma spacing in participant names (items-baseline, non-breaking spaces) - Add conditional tooltip with participant details for NIP-17 chats - Improve inbox relay resolution (longer timeouts, more relays, better logging) - Move NIP badge to right-side controls for consistency - Add lock icon before member count for encrypted conversations Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,12 +11,14 @@ import {
|
||||
Paperclip,
|
||||
Copy,
|
||||
CopyCheck,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { getZapRequest } from "applesauce-common/helpers/zap";
|
||||
import { toast } from "sonner";
|
||||
import accountManager from "@/services/accounts";
|
||||
import eventStore from "@/services/event-store";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import type {
|
||||
ChatProtocol,
|
||||
ProtocolIdentifier,
|
||||
@@ -35,6 +37,7 @@ import { parseSlashCommand } from "@/lib/chat/slash-command-parser";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
import { RichText } from "./nostr/RichText";
|
||||
import Timestamp from "./Timestamp";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "./ui/avatar";
|
||||
import { ReplyPreview } from "./chat/ReplyPreview";
|
||||
import { MembersDropdown } from "./chat/MembersDropdown";
|
||||
import { RelaysDropdown } from "./chat/RelaysDropdown";
|
||||
@@ -50,7 +53,9 @@ import {
|
||||
} from "./editor/MentionEditor";
|
||||
import { useProfileSearch } from "@/hooks/useProfileSearch";
|
||||
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
import { Label } from "./ui/label";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -121,8 +126,59 @@ function isDifferentDay(timestamp1: number, timestamp2: number): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ParticipantAvatar - Renders a single participant avatar
|
||||
*/
|
||||
const ParticipantAvatar = memo(function ParticipantAvatar({
|
||||
pubkey,
|
||||
className,
|
||||
}: {
|
||||
pubkey: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const profile = useProfile(pubkey);
|
||||
const displayName = getDisplayName(pubkey, profile);
|
||||
|
||||
return (
|
||||
<Avatar className={className}>
|
||||
<AvatarImage src={profile?.picture} alt={displayName} />
|
||||
<AvatarFallback>
|
||||
<span className="text-[10px]">{displayName.slice(0, 2)}</span>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* ParticipantName - Renders a single participant name without accent color
|
||||
* (for use in DM titles where we want regular text color)
|
||||
*/
|
||||
const ParticipantName = memo(function ParticipantName({
|
||||
pubkey,
|
||||
}: {
|
||||
pubkey: string;
|
||||
}) {
|
||||
const { addWindow } = useGrimoire();
|
||||
const profile = useProfile(pubkey);
|
||||
const displayName = getDisplayName(pubkey, profile);
|
||||
|
||||
return (
|
||||
<span
|
||||
dir="auto"
|
||||
className="font-semibold cursor-crosshair hover:underline hover:decoration-dotted text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
addWindow("profile", { pubkey });
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* DmTitle - Renders profile names for NIP-17 DM conversations
|
||||
* Uses regular text color (not accent) without lock icon (lock shown in header controls)
|
||||
*/
|
||||
const DmTitle = memo(function DmTitle({
|
||||
participants,
|
||||
@@ -141,15 +197,17 @@ const DmTitle = memo(function DmTitle({
|
||||
|
||||
// 1-on-1 or group
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 flex-wrap">
|
||||
<span className="inline-flex items-baseline flex-wrap">
|
||||
{others.slice(0, 3).map((p, i) => (
|
||||
<span key={p.pubkey} className="inline-flex items-center">
|
||||
{i > 0 && <span className="text-muted-foreground">, </span>}
|
||||
<UserName pubkey={p.pubkey} className="font-semibold" />
|
||||
<span key={p.pubkey} className="inline-flex items-baseline">
|
||||
{i > 0 && <span className="text-muted-foreground">, </span>}
|
||||
<ParticipantName pubkey={p.pubkey} />
|
||||
</span>
|
||||
))}
|
||||
{others.length > 3 && (
|
||||
<span className="text-muted-foreground">+{others.length - 3}</span>
|
||||
<span className="text-muted-foreground">
|
||||
+{others.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
@@ -173,10 +231,50 @@ function isLiveActivityMetadata(value: unknown): value is LiveActivityMetadata {
|
||||
* Get the chat command identifier for a conversation
|
||||
* Returns a string that can be passed to the `chat` command to open this conversation
|
||||
*
|
||||
* For NIP-17 DMs: nprofile1.../npub1... (chat partner's identifier)
|
||||
* For NIP-29 groups: relay'group-id (without wss:// prefix)
|
||||
* For NIP-53 live activities: naddr1... encoding
|
||||
*/
|
||||
function getChatIdentifier(conversation: Conversation): string | null {
|
||||
function getChatIdentifier(
|
||||
conversation: Conversation,
|
||||
activePubkey?: string,
|
||||
): string | null {
|
||||
if (conversation.protocol === "nip-17") {
|
||||
// For DMs, get the other participant(s)
|
||||
const others = conversation.participants
|
||||
.map((p) => p.pubkey)
|
||||
.filter((p) => p !== activePubkey);
|
||||
|
||||
// Self-chat or no participants
|
||||
if (others.length === 0) {
|
||||
// Return own npub
|
||||
if (activePubkey) {
|
||||
return nip19.npubEncode(activePubkey);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1-on-1 chat: return nprofile with inbox relays if available
|
||||
if (others.length === 1) {
|
||||
const pubkey = others[0];
|
||||
const participantInboxRelays =
|
||||
conversation.metadata?.participantInboxRelays;
|
||||
const relays = participantInboxRelays?.[pubkey];
|
||||
|
||||
if (relays && relays.length > 0) {
|
||||
return nip19.nprofileEncode({
|
||||
pubkey,
|
||||
relays: relays.slice(0, 3), // Limit to 3 relays
|
||||
});
|
||||
} else {
|
||||
return nip19.npubEncode(pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
// Group chat: return comma-separated npubs
|
||||
return others.map((p) => nip19.npubEncode(p)).join(",");
|
||||
}
|
||||
|
||||
if (conversation.protocol === "nip-29") {
|
||||
const groupId = conversation.metadata?.groupId;
|
||||
const relayUrl = conversation.metadata?.relayUrl;
|
||||
@@ -220,11 +318,41 @@ type ConversationResult =
|
||||
const ComposerReplyPreview = memo(function ComposerReplyPreview({
|
||||
replyToId,
|
||||
onClear,
|
||||
adapter,
|
||||
conversation,
|
||||
}: {
|
||||
replyToId: string;
|
||||
onClear: () => void;
|
||||
adapter: ChatProtocolAdapter;
|
||||
conversation: Conversation;
|
||||
}) {
|
||||
const replyEvent = use$(() => eventStore.event(replyToId), [replyToId]);
|
||||
// State for manually loaded events (NIP-17 synthetic events)
|
||||
const [manualEvent, setManualEvent] = useState<NostrEvent | null>(null);
|
||||
|
||||
// Load the event being replied to (reactive - updates when event arrives)
|
||||
const storeEvent = use$(() => eventStore.event(replyToId), [replyToId]);
|
||||
|
||||
// Use store event if available, otherwise fall back to manually loaded event
|
||||
const replyEvent = storeEvent ?? manualEvent;
|
||||
|
||||
// Fetch event from adapter if not in store (for NIP-17 synthetic events)
|
||||
useEffect(() => {
|
||||
if (!replyEvent) {
|
||||
adapter
|
||||
.loadReplyMessage(conversation, replyToId)
|
||||
.then((event) => {
|
||||
if (event) {
|
||||
setManualEvent(event);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
`[ComposerReplyPreview] Failed to load reply ${replyToId.slice(0, 8)}:`,
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [replyEvent, adapter, conversation, replyToId]);
|
||||
|
||||
if (!replyEvent) {
|
||||
return (
|
||||
@@ -735,7 +863,9 @@ export function ChatViewer({
|
||||
|
||||
// Handle NIP badge click
|
||||
const handleNipClick = useCallback(() => {
|
||||
if (conversation?.protocol === "nip-29") {
|
||||
if (conversation?.protocol === "nip-17") {
|
||||
addWindow("nip", { number: 17 });
|
||||
} else if (conversation?.protocol === "nip-29") {
|
||||
addWindow("nip", { number: 29 });
|
||||
} else if (conversation?.protocol === "nip-53") {
|
||||
addWindow("nip", { number: 53 });
|
||||
@@ -846,29 +976,34 @@ export function ChatViewer({
|
||||
className="max-w-md p-3"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Icon + Name */}
|
||||
{/* Icon/Avatar + Name */}
|
||||
<div className="flex items-center gap-2">
|
||||
{conversation.metadata?.icon && (
|
||||
<img
|
||||
src={conversation.metadata.icon}
|
||||
alt=""
|
||||
className="size-6 rounded object-cover flex-shrink-0"
|
||||
onError={(e) => {
|
||||
// Hide image if it fails to load
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="font-semibold">
|
||||
{conversation.protocol === "nip-17" ? (
|
||||
{/* For NIP-17 DMs, just show title without big avatars */}
|
||||
{conversation.protocol === "nip-17" ? (
|
||||
<span className="font-semibold">
|
||||
<DmTitle
|
||||
participants={conversation.participants}
|
||||
activePubkey={activeAccount?.pubkey}
|
||||
/>
|
||||
) : (
|
||||
conversation.title
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{conversation.metadata?.icon && (
|
||||
<img
|
||||
src={conversation.metadata.icon}
|
||||
alt=""
|
||||
className="size-6 rounded object-cover flex-shrink-0"
|
||||
onError={(e) => {
|
||||
// Hide image if it fails to load
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="font-semibold">
|
||||
{conversation.title}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Description */}
|
||||
{conversation.metadata?.description && (
|
||||
@@ -878,26 +1013,78 @@ export function ChatViewer({
|
||||
)}
|
||||
{/* Protocol Type - Clickable */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{(conversation.type === "group" ||
|
||||
conversation.type === "live-chat") && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleNipClick();
|
||||
}}
|
||||
className="rounded bg-primary-foreground/20 px-1.5 py-0.5 font-mono hover:bg-primary-foreground/30 transition-colors cursor-pointer text-primary-foreground"
|
||||
>
|
||||
{conversation.protocol.toUpperCase()}
|
||||
</button>
|
||||
)}
|
||||
{(conversation.type === "group" ||
|
||||
conversation.type === "live-chat") && (
|
||||
<span className="text-primary-foreground/60">•</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleNipClick();
|
||||
}}
|
||||
className="rounded bg-primary-foreground/20 px-1.5 py-0.5 font-mono hover:bg-primary-foreground/30 transition-colors cursor-pointer text-primary-foreground"
|
||||
>
|
||||
{conversation.protocol.toUpperCase()}
|
||||
</button>
|
||||
<span className="text-primary-foreground/60">•</span>
|
||||
<span className="capitalize text-primary-foreground/80">
|
||||
{conversation.type}
|
||||
</span>
|
||||
</div>
|
||||
{/* Participants (NIP-17) */}
|
||||
{conversation.protocol === "nip-17" &&
|
||||
(() => {
|
||||
const others = conversation.participants.filter(
|
||||
(p) => p.pubkey !== activeAccount?.pubkey,
|
||||
);
|
||||
|
||||
// Self-chat
|
||||
if (others.length === 0) {
|
||||
return (
|
||||
<div className="text-xs text-primary-foreground/80">
|
||||
Saved Messages
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 1-on-1 chat
|
||||
if (others.length === 1) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ParticipantAvatar
|
||||
pubkey={others[0].pubkey}
|
||||
className="size-4 flex-shrink-0"
|
||||
/>
|
||||
<UserName
|
||||
pubkey={others[0].pubkey}
|
||||
className="text-xs text-primary-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group chat (2+ others)
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-primary-foreground/60">
|
||||
Participants:
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{others.map((p) => (
|
||||
<div
|
||||
key={p.pubkey}
|
||||
className="text-xs text-primary-foreground/80 flex items-center gap-2"
|
||||
>
|
||||
<ParticipantAvatar
|
||||
pubkey={p.pubkey}
|
||||
className="size-4 flex-shrink-0"
|
||||
/>
|
||||
<UserName
|
||||
pubkey={p.pubkey}
|
||||
className="text-xs text-primary-foreground"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Live Activity Status */}
|
||||
{liveActivity?.status && (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
@@ -922,10 +1109,13 @@ export function ChatViewer({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{/* Copy Chat ID button */}
|
||||
{getChatIdentifier(conversation) && (
|
||||
{getChatIdentifier(conversation, activeAccount?.pubkey) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const chatId = getChatIdentifier(conversation);
|
||||
const chatId = getChatIdentifier(
|
||||
conversation,
|
||||
activeAccount?.pubkey,
|
||||
);
|
||||
if (chatId) copyChatId(chatId);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
||||
@@ -940,17 +1130,19 @@ export function ChatViewer({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground p-1">
|
||||
{/* Lock icon for encrypted conversations */}
|
||||
{conversation.protocol === "nip-17" && (
|
||||
<Lock className="size-3.5" />
|
||||
)}
|
||||
<MembersDropdown participants={derivedParticipants} />
|
||||
<RelaysDropdown conversation={conversation} />
|
||||
{(conversation.type === "group" ||
|
||||
conversation.type === "live-chat") && (
|
||||
<button
|
||||
onClick={handleNipClick}
|
||||
className="rounded bg-muted px-1.5 py-0.5 font-mono hover:bg-muted/80 transition-colors cursor-pointer"
|
||||
>
|
||||
{conversation.protocol.toUpperCase()}
|
||||
</button>
|
||||
)}
|
||||
{/* NIP badge - clickable for all protocols */}
|
||||
<button
|
||||
onClick={handleNipClick}
|
||||
className="rounded bg-muted px-1.5 py-0.5 font-mono hover:bg-muted/80 transition-colors cursor-pointer"
|
||||
>
|
||||
{conversation.protocol.toUpperCase()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1037,6 +1229,8 @@ export function ChatViewer({
|
||||
<ComposerReplyPreview
|
||||
replyToId={replyTo}
|
||||
onClear={() => setReplyTo(undefined)}
|
||||
adapter={adapter}
|
||||
conversation={conversation}
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-1.5 items-center">
|
||||
|
||||
@@ -428,22 +428,22 @@ function ConversationRow({
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<div className="flex items-baseline flex-wrap">
|
||||
{isSelfConversation ? (
|
||||
<span className="text-sm font-medium">Saved Messages</span>
|
||||
) : (
|
||||
<>
|
||||
{otherParticipants.slice(0, 3).map((pubkey, i) => (
|
||||
<span key={pubkey} className="inline-flex items-center">
|
||||
<span key={pubkey} className="inline-flex items-baseline">
|
||||
{i > 0 && (
|
||||
<span className="text-muted-foreground mr-1">,</span>
|
||||
<span className="text-muted-foreground">, </span>
|
||||
)}
|
||||
<UserName pubkey={pubkey} className="text-sm font-medium" />
|
||||
</span>
|
||||
))}
|
||||
{otherParticipants.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
+{otherParticipants.length - 3}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{otherParticipants.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -58,14 +58,19 @@ async function fetchInboxRelays(pubkey: string): Promise<string[]> {
|
||||
eventStore.replaceable(DM_RELAY_LIST_KIND, pubkey).pipe(
|
||||
filter((e): e is NostrEvent => e !== undefined),
|
||||
take(1),
|
||||
timeout(50),
|
||||
timeout(100),
|
||||
),
|
||||
);
|
||||
if (existing) {
|
||||
return parseRelayTags(existing);
|
||||
const relays = parseRelayTags(existing);
|
||||
console.log(
|
||||
`[NIP-17] Found cached inbox relays for ${pubkey.slice(0, 8)}:`,
|
||||
relays.length,
|
||||
);
|
||||
return relays;
|
||||
}
|
||||
} catch {
|
||||
// Not in store
|
||||
// Not in store, continue to network fetch
|
||||
}
|
||||
|
||||
// 2. Build relay list to query: participant's outbox + aggregators
|
||||
@@ -74,20 +79,29 @@ async function fetchInboxRelays(pubkey: string): Promise<string[]> {
|
||||
try {
|
||||
const cached = await relayListCache.get(pubkey);
|
||||
if (cached?.write) {
|
||||
relaysToQuery.push(...cached.write.slice(0, 2));
|
||||
relaysToQuery.push(...cached.write.slice(0, 3));
|
||||
}
|
||||
} catch {
|
||||
// Cache miss
|
||||
}
|
||||
|
||||
// Add aggregator relays
|
||||
relaysToQuery.push(...AGGREGATOR_RELAYS.slice(0, 2));
|
||||
relaysToQuery.push(...AGGREGATOR_RELAYS.slice(0, 3));
|
||||
|
||||
// Dedupe
|
||||
const uniqueRelays = [...new Set(relaysToQuery)];
|
||||
if (uniqueRelays.length === 0) return [];
|
||||
if (uniqueRelays.length === 0) {
|
||||
console.warn(
|
||||
`[NIP-17] No relays to query for inbox relays of ${pubkey.slice(0, 8)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
// 3. Fetch from relays
|
||||
console.log(
|
||||
`[NIP-17] Fetching inbox relays for ${pubkey.slice(0, 8)} from ${uniqueRelays.length} relays`,
|
||||
);
|
||||
|
||||
// 3. Fetch from relays with longer timeout
|
||||
try {
|
||||
const events = await firstValueFrom(
|
||||
pool
|
||||
@@ -98,7 +112,7 @@ async function fetchInboxRelays(pubkey: string): Promise<string[]> {
|
||||
)
|
||||
.pipe(
|
||||
toArray(),
|
||||
timeout(3000),
|
||||
timeout(5000), // Increased from 3s to 5s
|
||||
catchError(() => of([] as NostrEvent[])),
|
||||
),
|
||||
);
|
||||
@@ -107,7 +121,16 @@ async function fetchInboxRelays(pubkey: string): Promise<string[]> {
|
||||
const latest = events.reduce((a, b) =>
|
||||
a.created_at > b.created_at ? a : b,
|
||||
);
|
||||
return parseRelayTags(latest);
|
||||
const relays = parseRelayTags(latest);
|
||||
console.log(
|
||||
`[NIP-17] Fetched inbox relays for ${pubkey.slice(0, 8)}:`,
|
||||
relays.length,
|
||||
);
|
||||
return relays;
|
||||
} else {
|
||||
console.log(
|
||||
`[NIP-17] No inbox relay list found for ${pubkey.slice(0, 8)}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
|
||||
Reference in New Issue
Block a user