mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 08:27:27 +02:00
fix: Improve inbox gift wrap handling and UI
Fixes several issues with the inbox feature:
1. Load persisted decrypted content on init:
- Load stored encrypted content IDs from Dexie on service init
- Subscribe to eventStore.update$ to detect cache restoration
- Automatically update conversations when restored content is available
2. Mark already-decrypted gift wraps correctly:
- Check both in-memory unlock state AND persisted IDs
- Prevents showing decrypted messages as "pending" after reload
3. Hide manual decrypt UI when auto-decrypt is enabled:
- Only show "Decrypt All" button when auto-decrypt is off
- Show "Auto-decrypting..." status when auto-decrypt is on
4. Show pending count in user menu:
- Add pendingCount$ observable to gift wrap service
- Display badge on "Private Messages" menu item when there are
undecrypted messages and auto-decrypt is disabled
5. Expose full rumor for future kind support:
- Add decryptedRumors$ observable with all decrypted rumors
- Full rumor event (id, pubkey, kind, tags, content) is preserved
- Enables future support for any kind sent via gift wrap
This commit is contained in:
@@ -239,32 +239,43 @@ function InboxViewer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(counts.pending > 0 || counts.decrypting > 0) && (
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{counts.pending + counts.decrypting} messages waiting to be
|
||||
decrypted
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleDecryptAll}
|
||||
disabled={isDecryptingAll || !account?.signer}
|
||||
>
|
||||
{isDecryptingAll ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Decrypting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Unlock className="size-4 mr-2" />
|
||||
Decrypt All
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Only show manual decrypt options when auto-decrypt is OFF */}
|
||||
{!settings?.autoDecrypt &&
|
||||
(counts.pending > 0 || counts.decrypting > 0) && (
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{counts.pending + counts.decrypting} messages waiting to be
|
||||
decrypted
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleDecryptAll}
|
||||
disabled={isDecryptingAll || !account?.signer}
|
||||
>
|
||||
{isDecryptingAll ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Decrypting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Unlock className="size-4 mr-2" />
|
||||
Decrypt All
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show auto-decrypt status when enabled and there are pending messages */}
|
||||
{settings?.autoDecrypt &&
|
||||
(counts.pending > 0 || counts.decrypting > 0) && (
|
||||
<div className="px-4 py-3 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Auto-decrypting messages...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { User, HardDrive, Palette, Mail } from "lucide-react";
|
||||
import accounts from "@/services/accounts";
|
||||
import giftWrapService from "@/services/gift-wrap";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -63,6 +65,13 @@ export default function UserMenu() {
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const { themeId, setTheme, availableThemes } = useTheme();
|
||||
|
||||
// Gift wrap service state for pending message count
|
||||
const inboxSettings = use$(giftWrapService.settings$);
|
||||
const pendingCount = use$(giftWrapService.pendingCount$);
|
||||
// Show badge when enabled, not auto-decrypt, and has pending messages
|
||||
const showPendingBadge =
|
||||
inboxSettings?.enabled && !inboxSettings?.autoDecrypt && pendingCount > 0;
|
||||
|
||||
function openProfile() {
|
||||
if (!account?.pubkey) return;
|
||||
addWindow(
|
||||
@@ -165,7 +174,15 @@ export default function UserMenu() {
|
||||
}}
|
||||
>
|
||||
<Mail className="size-4 mr-2" />
|
||||
Private Messages
|
||||
<span className="flex-1">Private Messages</span>
|
||||
{showPendingBadge && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-2 bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
|
||||
>
|
||||
{pendingCount}
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="cursor-crosshair">
|
||||
|
||||
@@ -63,10 +63,19 @@ export interface CachedBlossomServerList {
|
||||
|
||||
export interface EncryptedContentEntry {
|
||||
id: string; // Event ID (gift wrap or seal)
|
||||
plaintext: string; // Decrypted content
|
||||
plaintext: string; // Decrypted content (JSON string for rumor/seal)
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stored encrypted content IDs
|
||||
* Used to check which gift wraps have already been decrypted
|
||||
*/
|
||||
export async function getStoredEncryptedContentIds(): Promise<Set<string>> {
|
||||
const entries = await db.encryptedContent.toArray();
|
||||
return new Set(entries.map((e) => e.id));
|
||||
}
|
||||
|
||||
export interface LocalSpell {
|
||||
id: string; // UUID for local-only spells, or event ID for published spells
|
||||
alias?: string; // Optional local-only quick name (e.g., "btc")
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { NostrEvent } from "@/types/nostr";
|
||||
import type { ISigner } from "applesauce-signers";
|
||||
import eventStore from "./event-store";
|
||||
import pool from "./relay-pool";
|
||||
import { encryptedContentStorage } from "./db";
|
||||
import { encryptedContentStorage, getStoredEncryptedContentIds } from "./db";
|
||||
|
||||
/** Kind 10050: DM relay list (NIP-17) */
|
||||
const DM_RELAY_LIST_KIND = 10050;
|
||||
@@ -88,9 +88,18 @@ class GiftWrapService {
|
||||
private giftWraps: NostrEvent[] = [];
|
||||
readonly giftWraps$ = new BehaviorSubject<NostrEvent[]>([]);
|
||||
|
||||
/** Conversations grouped by participants */
|
||||
/** Conversations grouped by participants (NIP-17 kind 14 messages only) */
|
||||
readonly conversations$ = new BehaviorSubject<Conversation[]>([]);
|
||||
|
||||
/**
|
||||
* All decrypted rumors (any kind, not just DMs)
|
||||
* The full rumor event is preserved including: id, pubkey, created_at, kind, tags, content
|
||||
* This allows future support for any kind sent via gift wrap (messages, files, etc.)
|
||||
*/
|
||||
readonly decryptedRumors$ = new BehaviorSubject<
|
||||
Array<{ giftWrap: NostrEvent; rumor: Rumor }>
|
||||
>([]);
|
||||
|
||||
/** Inbox relays (kind 10050) */
|
||||
readonly inboxRelays$ = new BehaviorSubject<string[]>([]);
|
||||
|
||||
@@ -110,9 +119,14 @@ class GiftWrapService {
|
||||
error?: string;
|
||||
}>();
|
||||
|
||||
/** Pending count observable for UI display */
|
||||
readonly pendingCount$ = new BehaviorSubject<number>(0);
|
||||
|
||||
private subscriptions: Subscription[] = [];
|
||||
private relaySubscription: Subscription | null = null;
|
||||
private persistenceCleanup: (() => void) | null = null;
|
||||
/** IDs of gift wraps that have persisted decrypted content */
|
||||
private persistedIds = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
// Start encrypted content persistence
|
||||
@@ -123,16 +137,44 @@ class GiftWrapService {
|
||||
}
|
||||
|
||||
/** Initialize the service with user pubkey and signer */
|
||||
init(pubkey: string, signer: ISigner | null) {
|
||||
async init(pubkey: string, signer: ISigner | null) {
|
||||
this.cleanup();
|
||||
this.userPubkey = pubkey;
|
||||
this.signer = signer;
|
||||
this.decryptStates.clear();
|
||||
this.decryptStates$.next(new Map());
|
||||
this.pendingCount$.next(0);
|
||||
|
||||
// Load persisted encrypted content IDs to know which gift wraps are already decrypted
|
||||
this.persistedIds = await getStoredEncryptedContentIds();
|
||||
|
||||
// Load inbox relays (kind 10050)
|
||||
this.loadInboxRelays();
|
||||
|
||||
// Subscribe to event updates to detect when cache restoration completes
|
||||
const updateSub = eventStore.update$.subscribe((event) => {
|
||||
if (
|
||||
event.kind === kinds.GiftWrap &&
|
||||
this.giftWraps.some((g) => g.id === event.id)
|
||||
) {
|
||||
// A gift wrap was updated (possibly restored from cache)
|
||||
// Check if it's now unlocked and update state accordingly
|
||||
if (isGiftWrapUnlocked(event)) {
|
||||
const currentState = this.decryptStates.get(event.id);
|
||||
if (currentState?.status === "pending") {
|
||||
this.decryptStates.set(event.id, {
|
||||
status: "success",
|
||||
decryptedAt: Date.now(),
|
||||
});
|
||||
this.decryptStates$.next(new Map(this.decryptStates));
|
||||
this.updatePendingCount();
|
||||
this.updateConversations();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
this.subscriptions.push(updateSub);
|
||||
|
||||
// If enabled, start syncing
|
||||
if (this.settings$.value.enabled) {
|
||||
this.startSync();
|
||||
@@ -232,7 +274,10 @@ class GiftWrapService {
|
||||
// Update decrypt states for new gift wraps
|
||||
for (const gw of giftWraps) {
|
||||
if (!this.decryptStates.has(gw.id)) {
|
||||
const isUnlocked = isGiftWrapUnlocked(gw);
|
||||
// Check both in-memory unlock state and persisted IDs
|
||||
// Persisted IDs indicate content was decrypted in a previous session
|
||||
const isUnlocked =
|
||||
isGiftWrapUnlocked(gw) || this.persistedIds.has(gw.id);
|
||||
this.decryptStates.set(gw.id, {
|
||||
status: isUnlocked ? "success" : "pending",
|
||||
decryptedAt: isUnlocked ? Date.now() : undefined,
|
||||
@@ -240,6 +285,7 @@ class GiftWrapService {
|
||||
}
|
||||
}
|
||||
this.decryptStates$.next(new Map(this.decryptStates));
|
||||
this.updatePendingCount();
|
||||
|
||||
// Update conversations
|
||||
this.updateConversations();
|
||||
@@ -267,15 +313,37 @@ class GiftWrapService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Update conversations from decrypted gift wraps */
|
||||
/** Update pending count for UI display */
|
||||
private updatePendingCount() {
|
||||
let count = 0;
|
||||
for (const state of this.decryptStates.values()) {
|
||||
if (state.status === "pending" || state.status === "decrypting") {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
this.pendingCount$.next(count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update conversations and decrypted rumors from gift wraps.
|
||||
* Applesauce persistence stores the full JSON representation of rumors,
|
||||
* preserving all fields (id, pubkey, created_at, kind, tags, content).
|
||||
*/
|
||||
private updateConversations() {
|
||||
const conversationMap = new Map<string, Conversation>();
|
||||
const allRumors: Array<{ giftWrap: NostrEvent; rumor: Rumor }> = [];
|
||||
|
||||
for (const gw of this.giftWraps) {
|
||||
if (!isGiftWrapUnlocked(gw)) continue;
|
||||
|
||||
const rumor = getGiftWrapRumor(gw);
|
||||
if (!rumor || rumor.kind !== PRIVATE_DM_KIND) continue;
|
||||
if (!rumor) continue;
|
||||
|
||||
// Collect all decrypted rumors (any kind) for future use
|
||||
allRumors.push({ giftWrap: gw, rumor });
|
||||
|
||||
// Only group NIP-17 DMs (kind 14) into conversations
|
||||
if (rumor.kind !== PRIVATE_DM_KIND) continue;
|
||||
|
||||
const convId = getConversationIdentifierFromMessage(rumor);
|
||||
const existing = conversationMap.get(convId);
|
||||
@@ -293,6 +361,10 @@ class GiftWrapService {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort rumors by created_at descending
|
||||
allRumors.sort((a, b) => b.rumor.created_at - a.rumor.created_at);
|
||||
this.decryptedRumors$.next(allRumors);
|
||||
|
||||
const conversations = Array.from(conversationMap.values()).sort(
|
||||
(a, b) =>
|
||||
(b.lastMessage?.created_at ?? 0) - (a.lastMessage?.created_at ?? 0),
|
||||
@@ -320,16 +392,21 @@ class GiftWrapService {
|
||||
// Update state to decrypting
|
||||
this.decryptStates.set(giftWrapId, { status: "decrypting" });
|
||||
this.decryptStates$.next(new Map(this.decryptStates));
|
||||
this.updatePendingCount();
|
||||
|
||||
try {
|
||||
const rumor = await unlockGiftWrap(gw, this.signer);
|
||||
|
||||
// Add to persisted IDs so it's recognized on next reload
|
||||
this.persistedIds.add(giftWrapId);
|
||||
|
||||
// Update state to success
|
||||
this.decryptStates.set(giftWrapId, {
|
||||
status: "success",
|
||||
decryptedAt: Date.now(),
|
||||
});
|
||||
this.decryptStates$.next(new Map(this.decryptStates));
|
||||
this.updatePendingCount();
|
||||
|
||||
// Emit decrypt event
|
||||
this.decryptEvent$.next({
|
||||
@@ -348,6 +425,7 @@ class GiftWrapService {
|
||||
// Update state to error
|
||||
this.decryptStates.set(giftWrapId, { status: "error", error });
|
||||
this.decryptStates$.next(new Map(this.decryptStates));
|
||||
this.updatePendingCount();
|
||||
|
||||
// Emit decrypt event
|
||||
this.decryptEvent$.next({
|
||||
|
||||
Reference in New Issue
Block a user