diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx
index 3bdc0cf..25e12ed 100644
--- a/src/components/InboxViewer.tsx
+++ b/src/components/InboxViewer.tsx
@@ -21,18 +21,29 @@ import { useProfile } from "@/hooks/useProfile";
import eventStore from "@/services/event-store";
import accountManager from "@/services/accounts";
import { getDisplayName } from "@/lib/nostr-utils";
-import { Copy, Settings, RefreshCw, MessageSquare } from "lucide-react";
+import {
+ Copy,
+ Settings,
+ RefreshCw,
+ MessageSquare,
+ ChevronDown,
+} from "lucide-react";
import { useCopy } from "@/hooks/useCopy";
import { toast } from "sonner";
+import giftWrapManager from "@/services/gift-wrap";
interface InboxViewerProps {}
+const CONVERSATIONS_PAGE_SIZE = 50;
+
export function InboxViewer(_props: InboxViewerProps) {
const { state, updateGiftWrapSettings } = useGrimoire();
const { pubkey } = useAccount();
const stats = useGiftWrapStats();
const conversations = useGiftWrapConversations();
const [showSettings, setShowSettings] = useState(false);
+ const [conversationsPage, setConversationsPage] = useState(1);
+ const [isLoadingOlder, setIsLoadingOlder] = useState(false);
const syncEnabled = state.giftWrapSettings?.syncEnabled ?? true;
const autoDecrypt = state.giftWrapSettings?.autoDecrypt ?? true;
@@ -53,20 +64,36 @@ export function InboxViewer(_props: InboxViewerProps) {
.map((t) => t[1]);
}, [dmRelayEvent]);
- // Convert conversations map to sorted array
- const conversationsList = useMemo(() => {
- if (!conversations) return [];
- return Array.from(conversations.entries())
- .map(([key, latestMessage]) => ({
- key,
- latestMessage,
- otherPubkey:
- latestMessage.senderPubkey === pubkey
- ? latestMessage.recipientPubkey
- : latestMessage.senderPubkey,
- }))
- .sort((a, b) => b.latestMessage.createdAt - a.latestMessage.createdAt);
- }, [conversations, pubkey]);
+ // Convert conversations map to sorted array with pagination
+ const { conversationsList, totalConversations, hasMoreConversations } =
+ useMemo(() => {
+ if (!conversations)
+ return {
+ conversationsList: [],
+ totalConversations: 0,
+ hasMoreConversations: false,
+ };
+
+ const allConversations = Array.from(conversations.entries())
+ .map(([key, latestMessage]) => ({
+ key,
+ latestMessage,
+ otherPubkey:
+ latestMessage.senderPubkey === pubkey
+ ? latestMessage.recipientPubkey
+ : latestMessage.senderPubkey,
+ }))
+ .sort((a, b) => b.latestMessage.createdAt - a.latestMessage.createdAt);
+
+ const pageSize = CONVERSATIONS_PAGE_SIZE * conversationsPage;
+ const pagedConversations = allConversations.slice(0, pageSize);
+
+ return {
+ conversationsList: pagedConversations,
+ totalConversations: allConversations.length,
+ hasMoreConversations: allConversations.length > pageSize,
+ };
+ }, [conversations, pubkey, conversationsPage]);
const handleToggleSync = () => {
updateGiftWrapSettings({ syncEnabled: !syncEnabled });
@@ -95,6 +122,27 @@ export function InboxViewer(_props: InboxViewerProps) {
);
};
+ const handleLoadMoreConversations = () => {
+ setConversationsPage((prev) => prev + 1);
+ };
+
+ const handleLoadOlderGiftWraps = async () => {
+ setIsLoadingOlder(true);
+ try {
+ const count = await giftWrapManager.loadOlderGiftWraps();
+ if (count > 0) {
+ toast.success(`Loaded ${count} older gift wraps`);
+ } else {
+ toast.info("No older gift wraps found");
+ }
+ } catch (error) {
+ console.error("[Inbox] Error loading older gift wraps:", error);
+ toast.error("Failed to load older gift wraps");
+ } finally {
+ setIsLoadingOlder(false);
+ }
+ };
+
if (!pubkey) {
return (
@@ -158,7 +206,17 @@ export function InboxViewer(_props: InboxViewerProps) {
{/* Stats Panel */}
-
Gift Wrap Statistics
+
+
Gift Wrap Statistics
+
+
{stats.totalGiftWraps}
@@ -183,6 +241,13 @@ export function InboxViewer(_props: InboxViewerProps) {
Pending
+ {stats.oldestGiftWrap && stats.newestGiftWrap && (
+
+ Storage:{" "}
+ {new Date(stats.oldestGiftWrap * 1000).toLocaleDateString()} -{" "}
+ {new Date(stats.newestGiftWrap * 1000).toLocaleDateString()}
+
+ )}
{/* DM Relays Panel */}
@@ -210,7 +275,10 @@ export function InboxViewer(_props: InboxViewerProps) {
- Conversations ({conversationsList.length})
+ Conversations ({conversationsList.length}
+ {totalConversations > conversationsList.length &&
+ ` of ${totalConversations}`}
+ )
{conversationsList.length === 0 ? (
@@ -221,17 +289,33 @@ export function InboxViewer(_props: InboxViewerProps) {
) : (
-
- {conversationsList.map(({ key, latestMessage, otherPubkey }) => (
- handleOpenConversation(key, otherPubkey)}
- />
- ))}
-
+ <>
+
+ {conversationsList.map(
+ ({ key, latestMessage, otherPubkey }) => (
+ handleOpenConversation(key, otherPubkey)}
+ />
+ ),
+ )}
+
+ {hasMoreConversations && (
+
+
+
+ )}
+ >
)}
diff --git a/src/services/gift-wrap.ts b/src/services/gift-wrap.ts
index 9a8572a..fb994c6 100644
--- a/src/services/gift-wrap.ts
+++ b/src/services/gift-wrap.ts
@@ -20,8 +20,20 @@ export interface GiftWrapStats {
successfulDecryptions: number;
failedDecryptions: number;
pendingDecryptions: number;
+ oldestGiftWrap?: number; // Unix timestamp of oldest gift wrap
+ newestGiftWrap?: number; // Unix timestamp of newest gift wrap
}
+/**
+ * Gift wrap sync configuration
+ */
+const GIFT_WRAP_CONFIG = {
+ INITIAL_LIMIT: 500, // Max gift wraps to fetch on initial sync
+ PAGINATION_SIZE: 100, // Batch size for loading older gift wraps
+ MAX_STORAGE_DAYS: 90, // Keep gift wraps for 90 days
+ AUTH_TIMEOUT_MS: 10000, // Wait 10s for auth before proceeding
+};
+
/**
* Rumor structure (unsigned event from NIP-59)
*/
@@ -45,10 +57,15 @@ class GiftWrapManager {
failedDecryptions: 0,
pendingDecryptions: 0,
});
+ private lastSyncTimestamp: number = 0; // Last sync time (for incremental updates)
+ private isAuthenticating = false;
+ private authenticated = new Set
(); // Track which relays are authenticated
/**
* Start syncing gift wraps for the active account
- * Subscribes to kind 1059 events from user's DM relays
+ * 1. Gets DM relays
+ * 2. Authenticates with dummy REQ (triggers NIP-42 AUTH)
+ * 3. Subscribes to gift wraps with pagination
*/
async startSync(): Promise {
const account = accountManager.active$.value;
@@ -66,18 +83,42 @@ class GiftWrapManager {
// Get user's DM relays (kind 10050) or fall back to general relays
const dmRelays = await this.getDMRelays(pubkey);
if (dmRelays.length === 0) {
- console.warn("[GiftWrap] No DM relays found, using general relays");
+ console.warn("[GiftWrap] No DM relays found, cannot sync gift wraps");
// TODO: Get general relays from user's relay list
return;
}
console.log(`[GiftWrap] Syncing from ${dmRelays.length} relays:`, dmRelays);
- // Subscribe to gift wraps (kind 1059) addressed to us
+ // Step 1: Authenticate with relays using dummy REQ
+ // This triggers NIP-42 AUTH which is required for relays to serve kind 1059
+ await this.authenticateWithRelays(dmRelays, pubkey);
+
+ // Step 2: Determine sync window (initial vs incremental)
+ const now = Math.floor(Date.now() / 1000);
+ const isInitialSync = this.lastSyncTimestamp === 0;
+
+ let since: number | undefined;
+ if (isInitialSync) {
+ // Initial sync: Fetch last N days of gift wraps
+ since = now - GIFT_WRAP_CONFIG.MAX_STORAGE_DAYS * 24 * 60 * 60;
+ console.log(
+ `[GiftWrap] Initial sync from ${new Date(since * 1000).toISOString()}`,
+ );
+ } else {
+ // Incremental sync: Fetch only new gift wraps since last sync
+ since = this.lastSyncTimestamp;
+ console.log(
+ `[GiftWrap] Incremental sync from ${new Date(since * 1000).toISOString()}`,
+ );
+ }
+
+ // Step 3: Subscribe to gift wraps with pagination
const filter: Filter = {
kinds: [1059],
"#p": [pubkey],
- limit: 100,
+ since,
+ limit: isInitialSync ? GIFT_WRAP_CONFIG.INITIAL_LIMIT : undefined,
};
const subscription = pool
@@ -88,6 +129,8 @@ class GiftWrapManager {
next: (response) => {
if (typeof response === "string") {
console.log("[GiftWrap] EOSE received");
+ // Update last sync timestamp after EOSE
+ this.lastSyncTimestamp = now;
} else {
console.log(
`[GiftWrap] Received gift wrap: ${response.id.slice(0, 8)}...`,
@@ -108,11 +151,14 @@ class GiftWrapManager {
this.subscriptions.set(pubkey, subscription);
- // Process any existing gift wraps in the event store
+ // Process any existing gift wraps in the event store (from previous sessions)
await this.processExistingGiftWraps(pubkey);
// Update stats
await this.updateStats();
+
+ // Clean up old gift wraps
+ await this.cleanupOldGiftWraps();
}
/**
@@ -124,6 +170,189 @@ class GiftWrapManager {
this.subscriptions.clear();
}
+ /**
+ * Authenticate with relays using dummy REQ
+ * This triggers NIP-42 AUTH which is required for relays to serve kind 1059
+ */
+ private async authenticateWithRelays(
+ relays: string[],
+ pubkey: string,
+ ): Promise {
+ if (this.isAuthenticating) {
+ console.log("[GiftWrap] Already authenticating, skipping");
+ return;
+ }
+
+ this.isAuthenticating = true;
+ console.log("[GiftWrap] Authenticating with relays...");
+
+ try {
+ // Send a dummy REQ to trigger AUTH
+ // We'll request kind 1059 with a very restrictive filter (no results expected)
+ const dummyFilter: Filter = {
+ kinds: [1059],
+ "#p": [pubkey],
+ limit: 1,
+ since: Math.floor(Date.now() / 1000), // Only future events (none exist)
+ };
+
+ // Create a promise that resolves after timeout or first event
+ await new Promise((resolve) => {
+ const timeout = setTimeout(() => {
+ subscription.unsubscribe();
+ resolve();
+ }, GIFT_WRAP_CONFIG.AUTH_TIMEOUT_MS);
+
+ const subscription = pool
+ .subscription(relays, [dummyFilter], {})
+ .subscribe({
+ next: (response) => {
+ // Got EOSE or event, auth likely completed
+ if (typeof response === "string") {
+ clearTimeout(timeout);
+ subscription.unsubscribe();
+ resolve();
+ }
+ },
+ error: () => {
+ clearTimeout(timeout);
+ resolve();
+ },
+ });
+ });
+
+ console.log("[GiftWrap] Authentication complete");
+ relays.forEach((relay) => this.authenticated.add(relay));
+ } catch (error) {
+ console.error("[GiftWrap] Authentication error:", error);
+ } finally {
+ this.isAuthenticating = false;
+ }
+ }
+
+ /**
+ * Load older gift wraps for pagination
+ * Fetches gift wraps before the oldest currently loaded
+ */
+ async loadOlderGiftWraps(): Promise {
+ const account = accountManager.active$.value;
+ if (!account) {
+ console.log("[GiftWrap] No active account");
+ return 0;
+ }
+
+ const { pubkey } = account;
+
+ // Get oldest gift wrap timestamp
+ const decryptions = await db.giftWrapDecryptions
+ .where("recipientPubkey")
+ .equals(pubkey)
+ .sortBy("lastAttempt");
+
+ if (decryptions.length === 0) {
+ console.log("[GiftWrap] No gift wraps to paginate from");
+ return 0;
+ }
+
+ const oldestTimestamp = decryptions[0].lastAttempt;
+ const cutoff = oldestTimestamp - 30 * 24 * 60 * 60; // 30 days before oldest
+
+ console.log(
+ `[GiftWrap] Loading older gift wraps before ${new Date(oldestTimestamp * 1000).toISOString()}`,
+ );
+
+ // Get DM relays
+ const dmRelays = await this.getDMRelays(pubkey);
+ if (dmRelays.length === 0) {
+ console.warn("[GiftWrap] No DM relays found");
+ return 0;
+ }
+
+ // Fetch older gift wraps
+ const filter: Filter = {
+ kinds: [1059],
+ "#p": [pubkey],
+ until: oldestTimestamp,
+ since: cutoff,
+ limit: GIFT_WRAP_CONFIG.PAGINATION_SIZE,
+ };
+
+ let count = 0;
+
+ await new Promise((resolve) => {
+ const subscription = pool
+ .subscription(dmRelays, [filter], {
+ eventStore,
+ })
+ .subscribe({
+ next: (response) => {
+ if (typeof response === "string") {
+ console.log(`[GiftWrap] Loaded ${count} older gift wraps`);
+ subscription.unsubscribe();
+ resolve();
+ } else {
+ count++;
+ this.processGiftWrap(response, pubkey).catch((error) => {
+ console.error(
+ `[GiftWrap] Error processing ${response.id.slice(0, 8)}:`,
+ error,
+ );
+ });
+ }
+ },
+ error: () => {
+ subscription.unsubscribe();
+ resolve();
+ },
+ });
+ });
+
+ // Update stats
+ await this.updateStats();
+
+ return count;
+ }
+
+ /**
+ * Clean up old gift wraps to prevent storage bloat
+ * Removes decryption records older than MAX_STORAGE_DAYS
+ */
+ private async cleanupOldGiftWraps(): Promise {
+ const cutoff =
+ Math.floor(Date.now() / 1000) -
+ GIFT_WRAP_CONFIG.MAX_STORAGE_DAYS * 24 * 60 * 60;
+
+ console.log(
+ `[GiftWrap] Cleaning up gift wraps older than ${new Date(cutoff * 1000).toISOString()}`,
+ );
+
+ // Delete old decryption records
+ const oldDecryptions = await db.giftWrapDecryptions
+ .where("lastAttempt")
+ .below(cutoff)
+ .toArray();
+
+ if (oldDecryptions.length > 0) {
+ console.log(
+ `[GiftWrap] Removing ${oldDecryptions.length} old decryption records`,
+ );
+ await db.giftWrapDecryptions.bulkDelete(
+ oldDecryptions.map((d) => d.giftWrapId),
+ );
+ }
+
+ // Delete old unsealed DMs
+ const oldDMs = await db.unsealedDMs
+ .where("receivedAt")
+ .below(cutoff)
+ .toArray();
+
+ if (oldDMs.length > 0) {
+ console.log(`[GiftWrap] Removing ${oldDMs.length} old unsealed DMs`);
+ await db.unsealedDMs.bulkDelete(oldDMs.map((d) => d.id));
+ }
+ }
+
/**
* Get DM relays from user's kind 10050 event
*/
@@ -363,6 +592,18 @@ class GiftWrapManager {
private async updateStats(): Promise {
const decryptions = await db.giftWrapDecryptions.toArray();
+ // Find oldest and newest gift wrap timestamps
+ let oldestTimestamp: number | undefined;
+ let newestTimestamp: number | undefined;
+
+ if (decryptions.length > 0) {
+ const timestamps = decryptions
+ .map((d) => d.lastAttempt)
+ .sort((a, b) => a - b);
+ oldestTimestamp = timestamps[0];
+ newestTimestamp = timestamps[timestamps.length - 1];
+ }
+
const stats: GiftWrapStats = {
totalGiftWraps: decryptions.length,
successfulDecryptions: decryptions.filter(
@@ -374,6 +615,8 @@ class GiftWrapManager {
pendingDecryptions: decryptions.filter(
(d) => d.decryptionState === "pending",
).length,
+ oldestGiftWrap: oldestTimestamp,
+ newestGiftWrap: newestTimestamp,
};
this.stats$.next(stats);