fix: NIP-17 chat fixes and inbox UX improvements

Enable NIP-17 Support:
- Uncomment Nip17Adapter in ChatViewer getAdapter function
- Import Nip17Adapter and register in protocol switch
- Update adapter documentation to list NIP-17 as supported

Fix Auto-Decrypt Behavior:
- Add autoDecrypt parameter to processGiftWrap (default true)
- Only decrypt gift wraps when autoDecrypt=true, otherwise store as pending
- Pass autoDecrypt from state to startSync and loadOlderGiftWraps
- Update useAccountSync to pass autoDecrypt setting
- Update InboxViewer to pass autoDecrypt to loadOlderGiftWraps
- Prevents unwanted decryption attempts when setting is off

Fix Stats Persistence:
- Add initializeStats() method to load stats from DB on first access
- Add statsInitialized flag to prevent duplicate initialization
- Call initializeStats() in getStats() before returning observable
- Stats now persist across page reloads even when sync is disabled

Inbox UX Improvements:
- Increase message preview from 50 to 100 characters
- Remove flex-1 from preview span to eliminate large gap before timestamp
- Add ml-auto to timestamp to push it to the right
- Change gap from gap-1 to gap-2 for better spacing
- Messages now show more content with timestamp properly aligned

All tests passing (980/980). No new lint errors.
This commit is contained in:
Claude
2026-01-20 11:19:03 +00:00
parent 7215ac6277
commit c402ac16f8
4 changed files with 88 additions and 34 deletions

View File

@@ -26,6 +26,7 @@ import type {
import { CHAT_KINDS } from "@/types/chat";
// import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon
import { Nip10Adapter } from "@/lib/chat/adapters/nip-10-adapter";
import { Nip17Adapter } from "@/lib/chat/adapters/nip-17-adapter";
import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter";
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
@@ -1138,21 +1139,24 @@ export function ChatViewer({
/**
* Get the appropriate adapter for a protocol
* Currently NIP-10 (thread chat), NIP-29 (relay-based groups) and NIP-53 (live activity chat) are supported
* Other protocols will be enabled in future phases
* Currently supported:
* - NIP-10 (thread chat)
* - NIP-17 (encrypted DMs with gift wrap)
* - NIP-29 (relay-based groups)
* - NIP-53 (live activity chat)
*/
function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
switch (protocol) {
case "nip-10":
return new Nip10Adapter();
case "nip-17":
return new Nip17Adapter();
// case "nip-c7": // Phase 1 - Simple chat (coming soon)
// return new NipC7Adapter();
case "nip-29":
return new Nip29Adapter();
// case "nip-17": // Phase 2 - Encrypted DMs (coming soon)
// return new Nip17Adapter();
// case "nip-28": // Phase 3 - Public channels (coming soon)
// return new Nip28Adapter();
case "nip-29":
return new Nip29Adapter();
case "nip-53":
return new Nip53Adapter();
default:

View File

@@ -173,7 +173,7 @@ export function InboxViewer(_props: InboxViewerProps) {
const handleLoadOlderGiftWraps = async () => {
setIsLoadingOlder(true);
try {
const count = await giftWrapManager.loadOlderGiftWraps();
const count = await giftWrapManager.loadOlderGiftWraps(autoDecrypt);
if (count > 0) {
toast.success(`Loaded ${count} older gift wraps`);
} else {
@@ -542,28 +542,30 @@ function ConversationRow({
day: "numeric",
});
// Truncate content preview
const preview = latestMessage.content.slice(0, 50);
const truncated = latestMessage.content.length > 50;
// Truncate content preview - show more text
const preview = latestMessage.content.slice(0, 100);
const truncated = latestMessage.content.length > 100;
return (
<div
onClick={onClick}
className="flex cursor-pointer items-center gap-1 border-b px-3 py-1.5 hover:bg-muted/30 last:border-b-0 font-mono text-xs"
className="flex cursor-pointer items-center gap-2 border-b px-3 py-1.5 hover:bg-muted/30 last:border-b-0 font-mono text-xs"
>
{/* Name */}
<div className="w-28 shrink-0 truncate">
<UserName pubkey={otherPubkey} className="text-xs font-medium" />
</div>
{/* Message preview */}
<span className="min-w-0 flex-1 truncate text-muted-foreground/70">
{/* Message preview - no flex-1 to avoid big gap */}
<span className="truncate text-muted-foreground/70">
{preview}
{truncated && "..."}
</span>
{/* Timestamp */}
<span className="shrink-0 text-muted-foreground/50">{timeStr}</span>
{/* Timestamp - push to end with ml-auto */}
<span className="shrink-0 ml-auto text-muted-foreground/50">
{timeStr}
</span>
</div>
);
}

View File

@@ -131,6 +131,7 @@ export function useAccountSync() {
// Start gift wrap sync (NIP-17) when account changes
useEffect(() => {
const syncEnabled = grimoire.state.giftWrapSettings?.syncEnabled ?? false;
const autoDecrypt = grimoire.state.giftWrapSettings?.autoDecrypt ?? false;
if (!activeAccount?.pubkey || !syncEnabled) {
// Stop sync when no account is active or sync is disabled
@@ -139,7 +140,7 @@ export function useAccountSync() {
}
// Start syncing gift wraps for this account
giftWrapManager.startSync().catch((error) => {
giftWrapManager.startSync(autoDecrypt).catch((error) => {
console.error("[useAccountSync] Failed to start gift wrap sync:", error);
});
@@ -147,5 +148,9 @@ export function useAccountSync() {
return () => {
giftWrapManager.stopSync();
};
}, [activeAccount?.pubkey, grimoire.state.giftWrapSettings?.syncEnabled]);
}, [
activeAccount?.pubkey,
grimoire.state.giftWrapSettings?.syncEnabled,
grimoire.state.giftWrapSettings?.autoDecrypt,
]);
}

View File

@@ -62,14 +62,26 @@ class GiftWrapManager {
private authenticated = new Set<string>(); // Track which relays are authenticated
private isSyncing = false; // Prevent concurrent sync attempts
private statsUpdateTimer: NodeJS.Timeout | null = null; // Debounce stats updates
private statsInitialized = false; // Track if stats have been loaded from DB
/**
* Initialize stats from database
* Called automatically when stats are first accessed
*/
private async initializeStats(): Promise<void> {
if (this.statsInitialized) return;
this.statsInitialized = true;
await this.updateStats();
}
/**
* Start syncing gift wraps for the active account
* 1. Gets DM relays
* 2. Authenticates with dummy REQ (triggers NIP-42 AUTH)
* 3. Subscribes to gift wraps with pagination
* @param autoDecrypt - If false, stores gift wraps without decrypting
*/
async startSync(): Promise<void> {
async startSync(autoDecrypt = true): Promise<void> {
// Prevent concurrent sync attempts
if (this.isSyncing) {
console.log("[GiftWrap] Sync already in progress, skipping");
@@ -150,12 +162,14 @@ class GiftWrapManager {
`[GiftWrap] Received gift wrap: ${response.id.slice(0, 8)}...`,
);
// Process gift wrap asynchronously
this.processGiftWrap(response, pubkey).catch((error) => {
console.error(
`[GiftWrap] Error processing ${response.id.slice(0, 8)}:`,
error,
);
});
this.processGiftWrap(response, pubkey, autoDecrypt).catch(
(error) => {
console.error(
`[GiftWrap] Error processing ${response.id.slice(0, 8)}:`,
error,
);
},
);
}
},
error: (error) => {
@@ -166,7 +180,7 @@ class GiftWrapManager {
this.subscriptions.set(pubkey, subscription);
// Process any existing gift wraps in the event store (from previous sessions)
await this.processExistingGiftWraps(pubkey);
await this.processExistingGiftWraps(pubkey, autoDecrypt);
// Update stats
await this.updateStats();
@@ -261,8 +275,9 @@ class GiftWrapManager {
/**
* Load older gift wraps for pagination
* Fetches gift wraps before the oldest currently loaded
* @param autoDecrypt - If false, stores gift wraps without decrypting
*/
async loadOlderGiftWraps(): Promise<number> {
async loadOlderGiftWraps(autoDecrypt = true): Promise<number> {
const account = accountManager.active$.value;
if (!account) {
console.log("[GiftWrap] No active account");
@@ -331,12 +346,14 @@ class GiftWrapManager {
resolve();
} else {
count++;
this.processGiftWrap(response, pubkey).catch((error) => {
console.error(
`[GiftWrap] Error processing ${response.id.slice(0, 8)}:`,
error,
);
});
this.processGiftWrap(response, pubkey, autoDecrypt).catch(
(error) => {
console.error(
`[GiftWrap] Error processing ${response.id.slice(0, 8)}:`,
error,
);
},
);
}
},
error: () => {
@@ -525,7 +542,10 @@ class GiftWrapManager {
/**
* Process existing gift wraps from event store
*/
private async processExistingGiftWraps(pubkey: string): Promise<void> {
private async processExistingGiftWraps(
pubkey: string,
autoDecrypt = true,
): Promise<void> {
console.log("[GiftWrap] Processing existing gift wraps...");
// Get all kind 1059 events addressed to us
@@ -539,7 +559,7 @@ class GiftWrapManager {
// Process each gift wrap
for (const giftWrap of giftWraps) {
try {
await this.processGiftWrap(giftWrap, pubkey);
await this.processGiftWrap(giftWrap, pubkey, autoDecrypt);
} catch (error) {
console.error(
`[GiftWrap] Error processing ${giftWrap.id.slice(0, 8)}:`,
@@ -551,10 +571,12 @@ class GiftWrapManager {
/**
* Process a single gift wrap event
* @param autoDecrypt - If false, only stores gift wrap without attempting decryption
*/
private async processGiftWrap(
giftWrap: NostrEvent,
recipientPubkey: string,
autoDecrypt = true,
): Promise<void> {
const giftWrapId = giftWrap.id;
@@ -569,6 +591,24 @@ class GiftWrapManager {
// Failed too many times, don't retry
return;
}
// If autoDecrypt is false and we have a pending record, don't retry
if (!autoDecrypt && existing.decryptionState === "pending") {
return;
}
}
// If autoDecrypt is false, just store as pending without attempting decryption
if (!autoDecrypt) {
const decryption: GiftWrapDecryption = {
giftWrapId,
recipientPubkey,
decryptionState: "pending",
lastAttempt: Math.floor(Date.now() / 1000),
attempts: existing?.attempts || 0,
};
await db.giftWrapDecryptions.put(decryption);
this.debouncedUpdateStats();
return;
}
// Create or update decryption record
@@ -800,8 +840,11 @@ class GiftWrapManager {
/**
* Get statistics observable
* Initializes stats from database on first call
*/
getStats(): Observable<GiftWrapStats> {
// Initialize stats from database on first access
this.initializeStats();
return this.stats$.asObservable();
}