mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-16 17:48:34 +02:00
feat: improve NIP-17 chat UX and inbox functionality
- Remove "load older messages" button from NIP-17 chats (messages stored locally) - Show "Saved Messages" for self-conversations, UserName for others - Hide member list dropdown for self-conversations - Fix inbox item click to properly open NIP-17 chat using addWindow() - Add lock icon to NIP-17 heading indicating end-to-end encryption - Add InboxRelaysDropdown showing participants' NIP-10050 relays - Replace RelaysDropdown with InboxRelaysDropdown for NIP-17 conversations
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
Copy,
|
||||
CopyCheck,
|
||||
FileText,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { getZapRequest } from "applesauce-common/helpers/zap";
|
||||
@@ -39,6 +40,7 @@ import Timestamp from "./Timestamp";
|
||||
import { ReplyPreview } from "./chat/ReplyPreview";
|
||||
import { MembersDropdown } from "./chat/MembersDropdown";
|
||||
import { RelaysDropdown } from "./chat/RelaysDropdown";
|
||||
import { InboxRelaysDropdown } from "./chat/InboxRelaysDropdown";
|
||||
import { MessageReactions } from "./chat/MessageReactions";
|
||||
import { StatusBadge } from "./live/StatusBadge";
|
||||
import { ChatMessageContextMenu } from "./chat/ChatMessageContextMenu";
|
||||
@@ -871,7 +873,33 @@ export function ChatViewer({
|
||||
className="text-sm font-semibold truncate cursor-help text-left"
|
||||
onClick={() => setTooltipOpen(!tooltipOpen)}
|
||||
>
|
||||
{customTitle || conversation.title}
|
||||
{customTitle ||
|
||||
(() => {
|
||||
// For NIP-17, show "Saved Messages" or participant name
|
||||
if (
|
||||
protocol === "nip-17" &&
|
||||
conversation.participants.length === 2
|
||||
) {
|
||||
const [p1, p2] = conversation.participants;
|
||||
const isSelfConversation = p1.pubkey === p2.pubkey;
|
||||
|
||||
if (isSelfConversation) {
|
||||
return "Saved Messages";
|
||||
}
|
||||
|
||||
// Show the other participant's name
|
||||
const otherPubkey =
|
||||
p1.pubkey === pubkey ? p2.pubkey : p1.pubkey;
|
||||
return (
|
||||
<UserName
|
||||
pubkey={otherPubkey}
|
||||
className="text-sm font-semibold"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return conversation.title;
|
||||
})()}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
@@ -974,8 +1002,28 @@ export function ChatViewer({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground p-1">
|
||||
<MembersDropdown participants={derivedParticipants} />
|
||||
<RelaysDropdown conversation={conversation} />
|
||||
{/* Hide member list for self-conversations (Saved Messages) */}
|
||||
{!(
|
||||
protocol === "nip-17" &&
|
||||
conversation.participants.length === 2 &&
|
||||
conversation.participants[0].pubkey ===
|
||||
conversation.participants[1].pubkey
|
||||
) && <MembersDropdown participants={derivedParticipants} />}
|
||||
{/* Show inbox relays for NIP-17, regular relays for other protocols */}
|
||||
{protocol === "nip-17" ? (
|
||||
<InboxRelaysDropdown conversation={conversation} />
|
||||
) : (
|
||||
<RelaysDropdown conversation={conversation} />
|
||||
)}
|
||||
{/* Show lock icon for encrypted conversations */}
|
||||
{protocol === "nip-17" && (
|
||||
<div
|
||||
className="text-muted-foreground"
|
||||
title="End-to-end encrypted"
|
||||
>
|
||||
<Lock className="size-3" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleNipClick}
|
||||
className="rounded bg-muted px-1.5 py-0.5 font-mono hover:bg-muted/80 transition-colors cursor-pointer"
|
||||
@@ -999,7 +1047,8 @@ export function ChatViewer({
|
||||
Header: () =>
|
||||
hasMore &&
|
||||
conversationResult.status === "success" &&
|
||||
protocol !== "nip-10" ? (
|
||||
protocol !== "nip-10" &&
|
||||
protocol !== "nip-17" ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Button
|
||||
onClick={handleLoadOlder}
|
||||
|
||||
@@ -54,7 +54,7 @@ type InboxViewerProps = Record<string, never>;
|
||||
const CONVERSATIONS_PAGE_SIZE = 50;
|
||||
|
||||
export function InboxViewer(_props: InboxViewerProps) {
|
||||
const { state, updateGiftWrapSettings } = useGrimoire();
|
||||
const { state, updateGiftWrapSettings, addWindow } = useGrimoire();
|
||||
const { pubkey } = useAccount();
|
||||
const stats = useGiftWrapStats();
|
||||
const conversations = useGiftWrapConversations();
|
||||
@@ -149,13 +149,14 @@ export function InboxViewer(_props: InboxViewerProps) {
|
||||
_conversationKey: string,
|
||||
otherPubkey: string,
|
||||
) => {
|
||||
// Open chat window with the other participant
|
||||
const npub = nip19.npubEncode(otherPubkey);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("grimoire:execute-command", {
|
||||
detail: `chat ${npub}`,
|
||||
}),
|
||||
);
|
||||
// Open chat window with the other participant using NIP-17
|
||||
addWindow("chat", {
|
||||
protocol: "nip-17",
|
||||
identifier: {
|
||||
type: "dm-recipient",
|
||||
value: otherPubkey,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
||||
165
src/components/chat/InboxRelaysDropdown.tsx
Normal file
165
src/components/chat/InboxRelaysDropdown.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* InboxRelaysDropdown - Shows DM inbox relays (kind 10050) for NIP-17 participants
|
||||
* Displays each participant's private inbox relays with connection status
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import { Inbox } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { RelayLink } from "@/components/nostr/RelayLink";
|
||||
import { UserName } from "@/components/nostr/UserName";
|
||||
import { useRelayState } from "@/hooks/useRelayState";
|
||||
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
|
||||
import eventStore from "@/services/event-store";
|
||||
import type { Conversation } from "@/types/chat";
|
||||
|
||||
interface InboxRelaysDropdownProps {
|
||||
conversation: Conversation;
|
||||
}
|
||||
|
||||
/**
|
||||
* InboxRelaysDropdown - Shows inbox relays for NIP-17 conversation participants
|
||||
*/
|
||||
export function InboxRelaysDropdown({
|
||||
conversation,
|
||||
}: InboxRelaysDropdownProps) {
|
||||
const { relays: relayStates } = useRelayState();
|
||||
|
||||
// Get all participant pubkeys
|
||||
const participantPubkeys = useMemo(() => {
|
||||
return conversation.participants.map((p) => p.pubkey);
|
||||
}, [conversation.participants]);
|
||||
|
||||
// Fetch kind 10050 events for all participants
|
||||
const inboxRelayEvents = use$(() => {
|
||||
const observables = participantPubkeys.map((pubkey) =>
|
||||
eventStore.replaceable(10050, pubkey, ""),
|
||||
);
|
||||
return observables;
|
||||
}, [participantPubkeys]);
|
||||
|
||||
// Extract relays from events
|
||||
const participantRelays = useMemo(() => {
|
||||
if (!inboxRelayEvents) return [];
|
||||
|
||||
return participantPubkeys
|
||||
.map((pubkey, index) => {
|
||||
const event = inboxRelayEvents[index];
|
||||
if (!event) return null;
|
||||
|
||||
const relays = event.tags
|
||||
.filter((t: string[]) => t[0] === "relay" && t[1])
|
||||
.map((t: string[]) => t[1]);
|
||||
|
||||
return {
|
||||
pubkey,
|
||||
relays,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(p): p is { pubkey: string; relays: string[] } =>
|
||||
p !== null && p.relays.length > 0,
|
||||
);
|
||||
}, [inboxRelayEvents, participantPubkeys]);
|
||||
|
||||
// Count total relays and connected relays
|
||||
const { totalRelays, connectedCount } = useMemo(() => {
|
||||
let total = 0;
|
||||
let connected = 0;
|
||||
|
||||
for (const participant of participantRelays) {
|
||||
for (const relay of participant.relays) {
|
||||
total++;
|
||||
const state = relayStates[relay];
|
||||
if (state?.connectionState === "connected") {
|
||||
connected++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { totalRelays: total, connectedCount: connected };
|
||||
}, [participantRelays, relayStates]);
|
||||
|
||||
// Don't show if no inbox relays found
|
||||
if (participantRelays.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Inbox className="size-3" />
|
||||
<span>
|
||||
{connectedCount}/{totalRelays}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-96 max-h-96 overflow-y-auto"
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-border">
|
||||
<div className="text-xs font-semibold text-muted-foreground">
|
||||
Inbox Relays (NIP-10050)
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 space-y-3">
|
||||
{participantRelays.map(({ pubkey, relays }) => (
|
||||
<div key={pubkey}>
|
||||
<div className="px-2 py-1 text-xs font-medium">
|
||||
<UserName pubkey={pubkey} className="text-xs" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{relays.map((relay) => {
|
||||
const state = relayStates[relay];
|
||||
const connIcon = getConnectionIcon(state);
|
||||
const authIcon = getAuthIcon(state);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={relay}
|
||||
className="flex items-center justify-between gap-2 p-1.5 rounded hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<RelayLink url={relay} showInboxOutbox={false} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="cursor-help">{connIcon.icon}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{connIcon.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="cursor-help">{authIcon.icon}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{authIcon.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user