feat: improve NIP-17 inbox with relay fallback and better UX

Fixed Gift Wrap Sync Issues:
- Add relay fallback: kind 10050 → kind 10002 → kind 3
- Fix loadOlderGiftWraps to work on first use (no existing gift wraps)
- Add comprehensive logging for debugging sync issues

Improved UI/UX:
- Use RelayLink component for relay display (with icons, hover cards)
- Show relays in collapsible dropdown with icon + count
- Add contextual error messages:
  - No account active
  - Sync disabled
  - No relays configured
  - No gift wraps received
  - Waiting for messages
- Better visual hierarchy with Radio icon

Technical Details:
- getDMRelays now tries kind 10050, then 10002, then 3
- loadOlderGiftWraps uses lastSyncTimestamp or 90 days ago when empty
- Collapsible relay panel with ChevronDown animation
- RelayLink integration with showInboxOutbox={false}

Before: Silent failures when no DM relays configured
After: Clear messaging and automatic fallback to general relays
This commit is contained in:
Claude
2026-01-20 09:48:30 +00:00
parent 9dfc7c3dcd
commit a4f302f0d6
2 changed files with 147 additions and 31 deletions

View File

@@ -20,10 +20,22 @@ import {
import { useProfile } from "@/hooks/useProfile";
import eventStore from "@/services/event-store";
import { getDisplayName } from "@/lib/nostr-utils";
import { Copy, Settings, MessageSquare, ChevronDown } from "lucide-react";
import {
Copy,
Settings,
MessageSquare,
ChevronDown,
Radio,
} from "lucide-react";
import { useCopy } from "@/hooks/useCopy";
import { toast } from "sonner";
import giftWrapManager from "@/services/gift-wrap";
import { RelayLink } from "@/components/nostr/RelayLink";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
type InboxViewerProps = Record<string, never>;
@@ -37,6 +49,7 @@ export function InboxViewer(_props: InboxViewerProps) {
const [showSettings, setShowSettings] = useState(false);
const [conversationsPage, setConversationsPage] = useState(1);
const [isLoadingOlder, setIsLoadingOlder] = useState(false);
const [relaysOpen, setRelaysOpen] = useState(false);
const syncEnabled = state.giftWrapSettings?.syncEnabled ?? true;
const autoDecrypt = state.giftWrapSettings?.autoDecrypt ?? true;
@@ -249,25 +262,37 @@ export function InboxViewer(_props: InboxViewerProps) {
</div>
{/* DM Relays Panel */}
<div className="border-b bg-muted/20 px-4 py-3">
<h3 className="mb-2 text-sm font-semibold">DM Relays (Kind 10050)</h3>
{dmRelays.length > 0 ? (
<div className="flex flex-wrap gap-2">
{dmRelays.map((relay) => (
<span
key={relay}
className="rounded bg-muted px-2 py-1 text-xs font-mono"
>
{relay}
</span>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
No DM relays configured (using general relays)
</p>
)}
</div>
<Collapsible
open={relaysOpen}
onOpenChange={setRelaysOpen}
className="border-b bg-muted/20"
>
<div className="px-4 py-3">
<CollapsibleTrigger className="flex w-full items-center justify-between hover:opacity-70">
<div className="flex items-center gap-2">
<Radio className="h-4 w-4" />
<h3 className="text-sm font-semibold">
DM Relays ({dmRelays.length})
</h3>
</div>
<ChevronDown
className={`h-4 w-4 transition-transform ${relaysOpen ? "rotate-180" : ""}`}
/>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-1.5">
{dmRelays.length > 0 ? (
dmRelays.map((relay) => (
<RelayLink key={relay} url={relay} showInboxOutbox={false} />
))
) : (
<p className="text-sm text-muted-foreground">
No DM relays configured. Using general relays from kind 10002 or
kind 3.
</p>
)}
</CollapsibleContent>
</div>
</Collapsible>
{/* Conversations List */}
<div className="flex-1 overflow-auto">
@@ -281,10 +306,51 @@ export function InboxViewer(_props: InboxViewerProps) {
{conversationsList.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
<MessageSquare className="mx-auto mb-2 h-12 w-12 opacity-50" />
<p>No conversations yet</p>
<p className="text-xs">
Start a chat using: <code>chat npub...</code>
</p>
{!pubkey ? (
<>
<p>No account active</p>
<p className="text-xs mt-1">
Login to view your encrypted messages
</p>
</>
) : !syncEnabled ? (
<>
<p>Gift wrap sync is disabled</p>
<p className="text-xs mt-1">
Enable sync in settings to receive NIP-17 messages
</p>
</>
) : dmRelays.length === 0 && stats.totalGiftWraps === 0 ? (
<>
<p>No relays configured</p>
<p className="text-xs mt-1">
Configure kind 10050 (DM relays) or kind 10002 (general
relays)
</p>
<p className="text-xs mt-1">
Try clicking "Load Older" to fetch messages from available
relays
</p>
</>
) : stats.totalGiftWraps === 0 ? (
<>
<p>No gift wraps received yet</p>
<p className="text-xs mt-1">
Waiting for encrypted messages on {dmRelays.length} relay
{dmRelays.length !== 1 ? "s" : ""}
</p>
<p className="text-xs mt-1">
Try "Load Older" to fetch older messages
</p>
</>
) : (
<>
<p>No conversations yet</p>
<p className="text-xs mt-1">
Start a chat using: <code>chat npub...</code>
</p>
</>
)}
</div>
) : (
<>

View File

@@ -270,18 +270,29 @@ class GiftWrapManager {
const { pubkey } = account;
// Get oldest gift wrap timestamp
// Get oldest gift wrap timestamp, or use lastSyncTimestamp if no gift wraps yet
const decryptions = await db.giftWrapDecryptions
.where("recipientPubkey")
.equals(pubkey)
.sortBy("lastAttempt");
let oldestTimestamp: number;
if (decryptions.length === 0) {
console.log("[GiftWrap] No gift wraps to paginate from");
return 0;
// No gift wraps yet - use lastSyncTimestamp or current time
if (this.lastSyncTimestamp > 0) {
oldestTimestamp = this.lastSyncTimestamp;
console.log("[GiftWrap] No gift wraps yet, using last sync timestamp");
} else {
// First time - start from 90 days ago
const now = Math.floor(Date.now() / 1000);
oldestTimestamp =
now - GIFT_WRAP_CONFIG.MAX_STORAGE_DAYS * 24 * 60 * 60;
console.log("[GiftWrap] No gift wraps yet, starting from 90 days ago");
}
} else {
oldestTimestamp = decryptions[0].lastAttempt;
}
const oldestTimestamp = decryptions[0].lastAttempt;
const cutoff = oldestTimestamp - 30 * 24 * 60 * 60; // 30 days before oldest
console.log(
@@ -381,10 +392,10 @@ class GiftWrapManager {
}
/**
* Get DM relays from user's kind 10050 event
* Get DM relays from user's kind 10050 event, with fallback to general relays
*/
private async getDMRelays(pubkey: string): Promise<string[]> {
// Try to get kind 10050 from event store
// Try to get kind 10050 (DM relay list) from event store
const dmRelayEvent = eventStore.getReplaceable(10050, pubkey, "");
if (dmRelayEvent) {
@@ -394,11 +405,50 @@ class GiftWrapManager {
.map((t: string[]) => t[1]);
if (relays.length > 0) {
console.log(
`[GiftWrap] Using ${relays.length} DM relays from kind 10050`,
);
return relays;
}
}
// TODO: Fall back to general relay list (kind 10002 or kind 3)
console.log(
"[GiftWrap] No kind 10050 found, falling back to general relays",
);
// Fall back to general relay list (kind 10002)
const relayListEvent = eventStore.getReplaceable(10002, pubkey, "");
if (relayListEvent) {
const relays = relayListEvent.tags
.filter((t: string[]) => t[0] === "r" && t[1])
.map((t: string[]) => t[1]);
if (relays.length > 0) {
console.log(`[GiftWrap] Using ${relays.length} relays from kind 10002`);
return relays;
}
}
console.log("[GiftWrap] No kind 10002 found, falling back to contact list");
// Final fallback to contact list relays (kind 3)
const contactsEvent = eventStore.getReplaceable(3, pubkey, "");
if (contactsEvent) {
try {
const content = JSON.parse(contactsEvent.content);
if (typeof content === "object" && content !== null) {
const relays = Object.keys(content);
if (relays.length > 0) {
console.log(`[GiftWrap] Using ${relays.length} relays from kind 3`);
return relays;
}
}
} catch (error) {
console.warn("[GiftWrap] Failed to parse kind 3 content:", error);
}
}
console.warn("[GiftWrap] No relays found for user");
return [];
}