mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 02:31:13 +02:00
feat: Update UI components for new gift wrap system
Updated all UI components to use the new gift wrap manager API:
InboxViewer.tsx:
- Use giftWrapManager.syncAll() for initial sync
- Use giftWrapManager.subscribeToNew() for real-time updates
- Use giftWrapManager.decryptBatch() for batch decryption
- Use giftWrapManager.state for counts (pending/decrypted/failed)
- Read from db.decryptedGiftWraps for display
- Simplified UI - no more complex conversation tracking
user-menu.tsx:
- Use use$(giftWrapManager.state) instead of useLiveQuery
- Get pendingCount from syncState.pendingCount
- Remove giftWrapLoader import
useAccountSync.ts:
- Remove gift wrap loading logic entirely
- Gift wrap sync now handled in InboxViewer component
- Hook only manages relay lists and blossom servers
nip-17-adapter.ts:
- Use WrappedMessagesModel from applesauce-common
- Load decrypted kind 14 messages directly from model
- No custom DB tables (decryptedRumors, conversations)
- Simplified message loading with filter logic
- Fixed type signatures for ChatProtocolAdapter
WindowRenderer.tsx:
- Fix InboxViewer lazy load (now default export)
Build verified: All TypeScript errors resolved ✅
This commit is contained in:
@@ -1,543 +1,338 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import {
|
||||
Mail,
|
||||
Settings,
|
||||
Lock,
|
||||
Unlock,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
MessageSquare,
|
||||
Radio,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import { Button } from "./ui/button";
|
||||
import { Switch } from "./ui/switch";
|
||||
import giftWrapLoader from "@/services/gift-wrap-loader";
|
||||
import { getConversations } from "@/services/gift-wrap";
|
||||
import db from "@/services/db";
|
||||
import { toast } from "sonner";
|
||||
import { useEffect, useState } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import accounts from "@/services/accounts";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
Package,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import giftWrapManager from "@/services/gift-wrap";
|
||||
import accountManager from "@/services/accounts";
|
||||
import eventStore from "@/services/event-store";
|
||||
import db, { DecryptedGiftWrap } from "@/services/db";
|
||||
|
||||
export function InboxViewer() {
|
||||
const { state, setPrivateMessagesEnabled, setAutoDecryptGiftWraps } =
|
||||
useGrimoire();
|
||||
const activeAccount = use$(accounts.active$);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
const [decryptResult, setDecryptResult] = useState<{
|
||||
success: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
interface InboxViewerProps {
|
||||
action?: "decrypt-pending" | "clear-failed" | null;
|
||||
}
|
||||
|
||||
// Get settings
|
||||
const privateMessagesEnabled = state.privateMessagesEnabled ?? false;
|
||||
const autoDecrypt = state.autoDecryptGiftWraps ?? false;
|
||||
export default function InboxViewer({ action }: InboxViewerProps) {
|
||||
const account = use$(accountManager.active$);
|
||||
const syncState = use$(giftWrapManager.state);
|
||||
const [decrypted, setDecrypted] = useState<DecryptedGiftWrap[]>([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [decrypting, setDecrypting] = useState(false);
|
||||
|
||||
// Get pending count (gift wraps not yet decrypted)
|
||||
const pendingCount = useLiveQuery(async () => {
|
||||
if (!activeAccount?.pubkey) return 0;
|
||||
return db.giftWraps
|
||||
.where("[recipientPubkey+status]")
|
||||
.equals([activeAccount.pubkey, "pending"])
|
||||
.count();
|
||||
}, [activeAccount?.pubkey]);
|
||||
const pubkey = account?.pubkey;
|
||||
|
||||
// Get decrypted count (successfully decrypted)
|
||||
const decryptedCount = useLiveQuery(async () => {
|
||||
if (!activeAccount?.pubkey) return 0;
|
||||
return db.giftWraps
|
||||
.where("[recipientPubkey+status]")
|
||||
.equals([activeAccount.pubkey, "decrypted"])
|
||||
.count();
|
||||
}, [activeAccount?.pubkey]);
|
||||
|
||||
// Get failed count (decryption failed)
|
||||
const failedCount = useLiveQuery(async () => {
|
||||
if (!activeAccount?.pubkey) return 0;
|
||||
return db.giftWraps
|
||||
.where("[recipientPubkey+status]")
|
||||
.equals([activeAccount.pubkey, "failed"])
|
||||
.count();
|
||||
}, [activeAccount?.pubkey]);
|
||||
|
||||
// Get failed gift wraps with error messages
|
||||
const failedGiftWraps = useLiveQuery(async () => {
|
||||
if (!activeAccount?.pubkey) return [];
|
||||
return db.giftWraps
|
||||
.where("[recipientPubkey+status]")
|
||||
.equals([activeAccount.pubkey, "failed"])
|
||||
.limit(10)
|
||||
.toArray();
|
||||
}, [activeAccount?.pubkey]);
|
||||
|
||||
// Get conversations (from decrypted rumors)
|
||||
const conversations = useLiveQuery(async () => {
|
||||
if (!activeAccount?.pubkey) return [];
|
||||
return await getConversations(activeAccount.pubkey);
|
||||
}, [activeAccount?.pubkey]);
|
||||
|
||||
// Get loader state for relay info
|
||||
const [loaderState, setLoaderState] = useState<any>(null);
|
||||
|
||||
// Subscribe to loader state
|
||||
// Load decrypted gift wraps from Dexie
|
||||
useEffect(() => {
|
||||
const subscription = giftWrapLoader.state.subscribe(setLoaderState);
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
if (!pubkey) return;
|
||||
|
||||
const handleTogglePrivateMessages = (enabled: boolean) => {
|
||||
setPrivateMessagesEnabled(enabled);
|
||||
db.decryptedGiftWraps
|
||||
.orderBy("receivedAt")
|
||||
.reverse()
|
||||
.offset(page * 50)
|
||||
.limit(50)
|
||||
.toArray()
|
||||
.then(setDecrypted);
|
||||
}, [pubkey, page, syncState?.decryptedCount]);
|
||||
|
||||
if (enabled) {
|
||||
toast.success("Private messages enabled");
|
||||
} else {
|
||||
toast.info("Private messages disabled");
|
||||
}
|
||||
};
|
||||
// Initial sync on mount
|
||||
useEffect(() => {
|
||||
if (!pubkey) return;
|
||||
|
||||
const handleToggleAutoDecrypt = (enabled: boolean) => {
|
||||
setAutoDecryptGiftWraps(enabled);
|
||||
const syncGiftWraps = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Get DM relays from user's kind 10050 relay list (NIP-17)
|
||||
const dmRelayListEvent = await firstValueFrom(
|
||||
eventStore.replaceable({ kind: 10050, pubkey }),
|
||||
);
|
||||
|
||||
if (enabled) {
|
||||
toast.success("Auto-decrypt enabled");
|
||||
} else {
|
||||
toast.info("Auto-decrypt disabled");
|
||||
}
|
||||
};
|
||||
// Kind 10050 uses read/write relay tags
|
||||
const dmRelays = dmRelayListEvent
|
||||
? dmRelayListEvent.tags
|
||||
.filter((t) => t[0] === "relay" && (!t[2] || t[2] === "read"))
|
||||
.map((t) => t[1])
|
||||
: [];
|
||||
|
||||
const handleDecryptPending = async () => {
|
||||
setIsDecrypting(true);
|
||||
setDecryptResult(null);
|
||||
// Fallback to default relays if no DM relays configured
|
||||
const relays =
|
||||
dmRelays.length > 0
|
||||
? dmRelays
|
||||
: [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.nostr.band",
|
||||
];
|
||||
|
||||
await giftWrapManager.syncAll(pubkey, relays);
|
||||
|
||||
// Subscribe to new gift wraps
|
||||
giftWrapManager.subscribeToNew(pubkey, relays);
|
||||
} catch (error) {
|
||||
console.error("[InboxViewer] Sync error:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
syncGiftWraps();
|
||||
|
||||
return () => {
|
||||
giftWrapManager.unsubscribe(pubkey);
|
||||
};
|
||||
}, [pubkey]);
|
||||
|
||||
// Handle action flags
|
||||
useEffect(() => {
|
||||
if (!action || !pubkey || !account) return;
|
||||
|
||||
const handleAction = async () => {
|
||||
if (action === "decrypt-pending") {
|
||||
await handleDecryptAll();
|
||||
} else if (action === "clear-failed") {
|
||||
await giftWrapManager.clearErrors();
|
||||
await giftWrapManager.updateCounts(pubkey);
|
||||
}
|
||||
};
|
||||
|
||||
handleAction();
|
||||
}, [action, pubkey, account]);
|
||||
|
||||
const handleDecryptAll = async () => {
|
||||
if (!pubkey || !account) return;
|
||||
|
||||
setDecrypting(true);
|
||||
try {
|
||||
const result = await giftWrapLoader.decryptPending();
|
||||
setDecryptResult(result);
|
||||
// Get pending gift wrap events (returns array)
|
||||
const pendingEvents = await firstValueFrom(
|
||||
giftWrapManager.getPendingGiftWraps(pubkey),
|
||||
);
|
||||
|
||||
if (result.success > 0) {
|
||||
toast.success(
|
||||
`Decrypted ${result.success} message${result.success === 1 ? "" : "s"}`,
|
||||
);
|
||||
// Extract IDs
|
||||
const pending = Array.isArray(pendingEvents)
|
||||
? pendingEvents.map((e) => e.id)
|
||||
: [];
|
||||
|
||||
if (pending.length === 0) {
|
||||
console.log("[InboxViewer] No pending gift wraps to decrypt");
|
||||
setDecrypting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.failed > 0) {
|
||||
toast.error(
|
||||
`Failed to decrypt ${result.failed} message${result.failed === 1 ? "" : "s"}`,
|
||||
);
|
||||
console.log(`[InboxViewer] Decrypting ${pending.length} gift wraps...`);
|
||||
|
||||
// Decrypt batch
|
||||
for await (const result of giftWrapManager.decryptBatch(
|
||||
pending,
|
||||
account,
|
||||
)) {
|
||||
if (result.status === "success") {
|
||||
// Refresh decrypted list
|
||||
const updated = await db.decryptedGiftWraps
|
||||
.orderBy("receivedAt")
|
||||
.reverse()
|
||||
.offset(page * 50)
|
||||
.limit(50)
|
||||
.toArray();
|
||||
setDecrypted(updated);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.total === 0) {
|
||||
toast.info("No pending messages to decrypt");
|
||||
}
|
||||
// Update counts
|
||||
await giftWrapManager.updateCounts(pubkey);
|
||||
console.log("[InboxViewer] Batch decrypt complete");
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt pending messages:", error);
|
||||
toast.error("Failed to decrypt pending messages");
|
||||
console.error("[InboxViewer] Decrypt error:", error);
|
||||
} finally {
|
||||
setIsDecrypting(false);
|
||||
setDecrypting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeAccount) {
|
||||
const handleClearAll = async () => {
|
||||
if (!confirm("Clear all decrypted gift wraps? This cannot be undone.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
await giftWrapManager.clearDecrypted();
|
||||
setDecrypted([]);
|
||||
|
||||
if (pubkey) {
|
||||
await giftWrapManager.updateCounts(pubkey);
|
||||
}
|
||||
};
|
||||
|
||||
if (!account || !pubkey) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<Lock className="h-12 w-12 mb-4 text-muted-foreground" />
|
||||
<h2 className="text-xl font-semibold mb-2">No Active Account</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Sign in to view your private messages
|
||||
</p>
|
||||
<div className="flex items-center justify-center h-full text-base-content/50">
|
||||
<div className="text-center space-y-4">
|
||||
<Package className="w-12 h-12 mx-auto opacity-50" />
|
||||
<p>No active account. Please login to view gift wraps.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
<h1 className="text-xl font-semibold">Private Messages</h1>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header with status */}
|
||||
<div className="flex flex-col gap-4 p-4 border-b border-base-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Package className="w-5 h-5" />
|
||||
Gift Wrap Inbox
|
||||
</h2>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-sm text-base-content/60">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Syncing...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Decrypted
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{decryptedCount ?? 0}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Failed
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{failedCount ?? 0}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Pending
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="text-2xl font-bold text-yellow-600">
|
||||
{pendingCount ?? 0}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
<CardTitle className="text-base">Settings</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 space-y-4">
|
||||
{/* Enable Private Messages */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="enable-private-messages"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
Enable Private Messages
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fetch and store encrypted gift wraps from DM relays
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enable-private-messages"
|
||||
checked={privateMessagesEnabled}
|
||||
onCheckedChange={handleTogglePrivateMessages}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto-Decrypt */}
|
||||
{privateMessagesEnabled && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="auto-decrypt"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
Auto-Decrypt Messages
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically decrypt gift wraps as they arrive
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-decrypt"
|
||||
checked={autoDecrypt}
|
||||
onCheckedChange={handleToggleAutoDecrypt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Manual Decrypt */}
|
||||
{privateMessagesEnabled && !autoDecrypt && (pendingCount ?? 0) > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Unlock className="h-4 w-4" />
|
||||
<CardTitle className="text-base">Pending Messages</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You have {pendingCount} encrypted message
|
||||
{pendingCount === 1 ? "" : "s"} waiting to be decrypted.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
onClick={handleDecryptPending}
|
||||
disabled={isDecrypting}
|
||||
className="w-full"
|
||||
>
|
||||
{isDecrypting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Decrypting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Unlock className="mr-2 h-4 w-4" />
|
||||
Decrypt Pending Messages
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{decryptResult && (
|
||||
<div className="space-y-2">
|
||||
{decryptResult.success > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span>
|
||||
{decryptResult.success} message
|
||||
{decryptResult.success === 1 ? "" : "s"} decrypted
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{decryptResult.failed > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<span>
|
||||
{decryptResult.failed} message
|
||||
{decryptResult.failed === 1 ? "" : "s"} failed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
{privateMessagesEnabled && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
privateMessagesEnabled
|
||||
? "bg-green-500"
|
||||
: "bg-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{privateMessagesEnabled
|
||||
? autoDecrypt
|
||||
? "Auto-decrypt enabled - messages will be decrypted automatically"
|
||||
: "Manual decrypt - messages will be queued for manual decryption"
|
||||
: "Private messages disabled"}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Relay Status */}
|
||||
{privateMessagesEnabled &&
|
||||
loaderState?.relays &&
|
||||
loaderState.relays.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="h-4 w-4" />
|
||||
<CardTitle className="text-base">DM Relays</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-1">
|
||||
{loaderState.relays.map((relay: string) => (
|
||||
<div
|
||||
key={relay}
|
||||
className="text-sm font-mono text-muted-foreground flex items-center gap-2"
|
||||
>
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
{relay}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Debug Info */}
|
||||
{privateMessagesEnabled && (
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
<CardTitle className="text-base">Debug Info</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 space-y-2">
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Loader Enabled:</span>
|
||||
<span className="font-mono">
|
||||
{loaderState?.enabled ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Auto-Decrypt:</span>
|
||||
<span className="font-mono">
|
||||
{loaderState?.autoDecrypt ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Loading:</span>
|
||||
<span className="font-mono">
|
||||
{loaderState?.loading ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Error Count:</span>
|
||||
<span className="font-mono">
|
||||
{loaderState?.errorCount ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Last Sync:</span>
|
||||
<span className="font-mono text-xs">
|
||||
{loaderState?.lastSync
|
||||
? formatDistanceToNow(loaderState.lastSync, {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "Never"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Conversations List */}
|
||||
{privateMessagesEnabled && (
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<CardTitle className="text-base">
|
||||
Conversations ({conversations?.length ?? 0})
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
{conversations && conversations.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{conversations.map((conv) => (
|
||||
<ConversationItem key={conv.id} conversation={conv} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No conversations yet. Decrypt pending messages to see
|
||||
conversations.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Decryption Errors */}
|
||||
{privateMessagesEnabled &&
|
||||
failedGiftWraps &&
|
||||
failedGiftWraps.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
<CardTitle className="text-base text-red-600">
|
||||
Decryption Errors ({failedCount ?? 0})
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-2">
|
||||
{failedGiftWraps.map((gw) => (
|
||||
<div
|
||||
key={gw.id}
|
||||
className="p-2 rounded bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900"
|
||||
>
|
||||
<div className="text-xs font-mono text-red-900 dark:text-red-300 mb-1">
|
||||
{gw.id.slice(0, 16)}...
|
||||
</div>
|
||||
<div className="text-sm text-red-700 dark:text-red-400">
|
||||
{gw.failureReason || "Unknown error"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(failedCount ?? 0) > 10 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Showing first 10 of {failedCount} errors
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Help Text */}
|
||||
{!privateMessagesEnabled && (
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
<p>
|
||||
Private messages use NIP-59 gift wraps to provide
|
||||
metadata-obscured messaging.
|
||||
</p>
|
||||
<p>
|
||||
Messages are fetched from your DM relays (NIP-17: kind 10050) and
|
||||
encrypted with NIP-44.
|
||||
</p>
|
||||
<p>
|
||||
Enable private messages above to start receiving encrypted
|
||||
messages.
|
||||
</p>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-warning/10 text-warning rounded-lg">
|
||||
<Package className="w-4 h-4" />
|
||||
<span className="font-medium">{syncState?.pendingCount ?? 0}</span>
|
||||
<span className="text-sm">Pending</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationItem({ conversation }: { conversation: any }) {
|
||||
const profile = useProfile(conversation.senderPubkey);
|
||||
const displayName = getDisplayName(conversation.senderPubkey, profile);
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg border hover:bg-accent cursor-pointer">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="font-medium truncate">{displayName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(conversation.lastMessageCreatedAt * 1000, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{conversation.lastMessagePreview}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{conversation.messageCount} message
|
||||
{conversation.messageCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
{conversation.unreadCount > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary text-primary-foreground font-medium">
|
||||
{conversation.unreadCount} new
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-success/10 text-success rounded-lg">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="font-medium">
|
||||
{syncState?.decryptedCount ?? 0}
|
||||
</span>
|
||||
<span className="text-sm">Decrypted</span>
|
||||
</div>
|
||||
|
||||
{(syncState?.failedCount ?? 0) > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-error/10 text-error rounded-lg">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span className="font-medium">{syncState?.failedCount}</span>
|
||||
<span className="text-sm">Failed</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={handleDecryptAll}
|
||||
disabled={decrypting || (syncState?.pendingCount ?? 0) === 0}
|
||||
className="btn btn-sm btn-primary"
|
||||
>
|
||||
{decrypting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Decrypting...
|
||||
</>
|
||||
) : (
|
||||
<>Decrypt All Pending ({syncState?.pendingCount ?? 0})</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{(syncState?.failedCount ?? 0) > 0 && (
|
||||
<button
|
||||
onClick={() => giftWrapManager.clearErrors()}
|
||||
className="btn btn-sm btn-ghost"
|
||||
>
|
||||
Clear Failed Attempts
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(syncState?.decryptedCount ?? 0) > 0 && (
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="btn btn-sm btn-ghost text-error"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Clear All Decrypted
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decrypted gift wraps list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{decrypted.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-base-content/50">
|
||||
<div className="text-center space-y-4">
|
||||
<Package className="w-12 h-12 mx-auto opacity-50" />
|
||||
<p>No decrypted gift wraps yet.</p>
|
||||
{(syncState?.pendingCount ?? 0) > 0 && (
|
||||
<button
|
||||
onClick={handleDecryptAll}
|
||||
disabled={decrypting}
|
||||
className="btn btn-primary btn-sm"
|
||||
>
|
||||
Decrypt {syncState?.pendingCount} Pending
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-base-300">
|
||||
{decrypted.map((wrap) => (
|
||||
<div key={wrap.giftWrapId} className="p-4 hover:bg-base-200/50">
|
||||
<div className="flex items-start gap-3">
|
||||
<Package className="w-5 h-5 text-base-content/40 mt-1" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium">
|
||||
Kind {wrap.rumor.kind}
|
||||
</span>
|
||||
{wrap.sealPubkey && (
|
||||
<span className="text-xs text-base-content/60 font-mono">
|
||||
from {wrap.sealPubkey.slice(0, 8)}...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-base-content/80 line-clamp-2">
|
||||
{wrap.rumor.content.slice(0, 200)}
|
||||
{wrap.rumor.content.length > 200 && "..."}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-base-content/60">
|
||||
<span>
|
||||
Received:{" "}
|
||||
{new Date(wrap.receivedAt * 1000).toLocaleString()}
|
||||
</span>
|
||||
<span>
|
||||
Decrypted:{" "}
|
||||
{new Date(wrap.decryptedAt * 1000).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more button */}
|
||||
{decrypted.length >= 50 && (
|
||||
<div className="p-4 text-center">
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="btn btn-sm btn-ghost"
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with sync info */}
|
||||
{(syncState?.lastSyncAt ?? 0) > 0 && (
|
||||
<div className="p-2 text-xs text-center text-base-content/50 border-t border-base-300">
|
||||
Last synced: {new Date(syncState!.lastSyncAt).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,9 +42,7 @@ const SpellbooksViewer = lazy(() =>
|
||||
const BlossomViewer = lazy(() =>
|
||||
import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })),
|
||||
);
|
||||
const InboxViewer = lazy(() =>
|
||||
import("./InboxViewer").then((m) => ({ default: m.InboxViewer })),
|
||||
);
|
||||
const InboxViewer = lazy(() => import("./InboxViewer"));
|
||||
const CountViewer = lazy(() => import("./CountViewer"));
|
||||
|
||||
// Loading fallback component
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getDisplayName } from "@/lib/nostr-utils";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import giftWrapLoader from "@/services/gift-wrap-loader";
|
||||
import giftWrapManager from "@/services/gift-wrap";
|
||||
import { dmRelayListCache } from "@/services/dm-relay-list-cache";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -73,16 +73,13 @@ export default function UserMenu() {
|
||||
}, [account?.pubkey]);
|
||||
|
||||
// Get pending gift wrap count
|
||||
const pendingCount = useLiveQuery(async () => {
|
||||
if (!account?.pubkey || !state.privateMessagesEnabled) return 0;
|
||||
// Only show count if auto-decrypt is disabled
|
||||
if (state.autoDecryptGiftWraps) return 0;
|
||||
return giftWrapLoader.getPendingCount(account.pubkey);
|
||||
}, [
|
||||
account?.pubkey,
|
||||
state.privateMessagesEnabled,
|
||||
state.autoDecryptGiftWraps,
|
||||
]);
|
||||
const syncState = use$(giftWrapManager.state);
|
||||
const pendingCount =
|
||||
account?.pubkey &&
|
||||
state.privateMessagesEnabled &&
|
||||
!state.autoDecryptGiftWraps
|
||||
? (syncState?.pendingCount ?? 0)
|
||||
: 0;
|
||||
|
||||
function openProfile() {
|
||||
if (!account?.pubkey) return;
|
||||
|
||||
@@ -6,14 +6,12 @@ import { addressLoader } from "@/services/loaders";
|
||||
import type { RelayInfo } from "@/types/app";
|
||||
import { normalizeRelayURL } from "@/lib/relay-url";
|
||||
import { getServersFromEvent } from "@/services/blossom";
|
||||
import giftWrapLoader from "@/services/gift-wrap-loader";
|
||||
|
||||
/**
|
||||
* Hook that syncs active account with Grimoire state and fetches relay lists and blossom servers
|
||||
*/
|
||||
export function useAccountSync() {
|
||||
const {
|
||||
state,
|
||||
setActiveAccount,
|
||||
setActiveAccountRelays,
|
||||
setActiveAccountBlossomServers,
|
||||
@@ -127,39 +125,4 @@ export function useAccountSync() {
|
||||
storeSubscription.unsubscribe();
|
||||
};
|
||||
}, [activeAccount?.pubkey, eventStore, setActiveAccountBlossomServers]);
|
||||
|
||||
// Enable/disable gift wrap loader based on feature flag and active account
|
||||
useEffect(() => {
|
||||
const privateMessagesEnabled = state.privateMessagesEnabled ?? false;
|
||||
const autoDecrypt = state.autoDecryptGiftWraps ?? false;
|
||||
|
||||
if (
|
||||
privateMessagesEnabled &&
|
||||
activeAccount?.pubkey &&
|
||||
activeAccount.signer
|
||||
) {
|
||||
// Enable gift wrap loading
|
||||
console.log(
|
||||
`[AccountSync] Enabling private messages for ${activeAccount.pubkey.slice(0, 8)}`,
|
||||
);
|
||||
giftWrapLoader.enable(
|
||||
activeAccount.pubkey,
|
||||
activeAccount.signer,
|
||||
autoDecrypt,
|
||||
);
|
||||
} else {
|
||||
// Disable gift wrap loading
|
||||
giftWrapLoader.disable();
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup on unmount
|
||||
giftWrapLoader.disable();
|
||||
};
|
||||
}, [
|
||||
state.privateMessagesEnabled,
|
||||
state.autoDecryptGiftWraps,
|
||||
activeAccount?.pubkey,
|
||||
activeAccount?.signer,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
* This adapter provides read-only access to NIP-17 encrypted DMs
|
||||
* that have been received and decrypted via gift wraps (NIP-59).
|
||||
*
|
||||
* Messages are loaded from the local decryptedRumors database table,
|
||||
* which is populated by the gift-wrap-loader service.
|
||||
* Messages are loaded from applesauce WrappedMessagesModel which
|
||||
* returns decrypted kind 14 rumors.
|
||||
*
|
||||
* Protocol: https://github.com/nostr-protocol/nips/blob/master/17.md
|
||||
*/
|
||||
|
||||
import { Observable } from "rxjs";
|
||||
import { Observable, map } from "rxjs";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { WrappedMessagesModel } from "applesauce-common/models";
|
||||
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
|
||||
import type {
|
||||
Conversation,
|
||||
@@ -22,8 +23,8 @@ import type {
|
||||
Participant,
|
||||
} from "@/types/chat";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import db from "@/services/db";
|
||||
import eventStore from "@/services/event-store";
|
||||
import accountManager from "@/services/accounts";
|
||||
|
||||
export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
readonly protocol = "nip-17" as const;
|
||||
@@ -73,24 +74,21 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
identifier: ProtocolIdentifier,
|
||||
): Promise<Conversation> {
|
||||
if (identifier.type !== "dm-recipient") {
|
||||
throw new Error("Invalid identifier type for NIP-17");
|
||||
throw new Error(
|
||||
`NIP-17 adapter cannot handle identifier type: ${identifier.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
const peerPubkey = identifier.value;
|
||||
const recipientPubkey = identifier.value;
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
|
||||
// Get active account pubkey
|
||||
const activePubkey = await this.getActivePubkey();
|
||||
|
||||
// Try to get conversation metadata from database
|
||||
const conversationId = `${peerPubkey}:${activePubkey}`;
|
||||
const conversation = await db.conversations.get(conversationId);
|
||||
|
||||
// Get profile from eventStore
|
||||
const profile = eventStore.getReplaceable(0, peerPubkey, "");
|
||||
if (!activePubkey) {
|
||||
throw new Error("No active account");
|
||||
}
|
||||
|
||||
// Get peer participant
|
||||
const peerParticipant: Participant = {
|
||||
pubkey: peerPubkey,
|
||||
pubkey: recipientPubkey,
|
||||
};
|
||||
|
||||
// Get self participant
|
||||
@@ -98,162 +96,176 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
pubkey: activePubkey,
|
||||
};
|
||||
|
||||
// Create conversation ID (use sorted pubkeys for consistent ID)
|
||||
const conversationId = [activePubkey, recipientPubkey].sort().join(":");
|
||||
|
||||
return {
|
||||
id: conversationId,
|
||||
type: "dm",
|
||||
protocol: "nip-17",
|
||||
title: profile?.content
|
||||
? JSON.parse(profile.content).name || peerPubkey.slice(0, 8)
|
||||
: peerPubkey.slice(0, 8),
|
||||
protocol: this.protocol,
|
||||
type: this.type,
|
||||
title: `DM with ${recipientPubkey.slice(0, 8)}...`,
|
||||
participants: [peerParticipant, selfParticipant],
|
||||
unreadCount: conversation?.unreadCount ?? 0,
|
||||
metadata: {
|
||||
encrypted: true,
|
||||
giftWrapped: true,
|
||||
},
|
||||
unreadCount: 0,
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages from decryptedRumors table
|
||||
* Returns an Observable with all messages for this conversation
|
||||
* Load messages for a conversation
|
||||
* Uses WrappedMessagesModel to get decrypted kind 14 messages
|
||||
*/
|
||||
loadMessages(
|
||||
conversation: Conversation,
|
||||
options?: LoadMessagesOptions,
|
||||
_options?: LoadMessagesOptions,
|
||||
): Observable<Message[]> {
|
||||
// Parse peer pubkey from conversation ID
|
||||
const [peerPubkey] = conversation.id.split(":");
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (!activePubkey) {
|
||||
throw new Error("No active account");
|
||||
}
|
||||
|
||||
// Get messages from database (async)
|
||||
const messagesPromise = this.loadMessagesFromDb(
|
||||
peerPubkey,
|
||||
conversation.id,
|
||||
options?.limit ?? 100,
|
||||
const recipientPubkey = conversation.participants.find(
|
||||
(p) => p.pubkey !== activePubkey,
|
||||
)?.pubkey;
|
||||
|
||||
if (!recipientPubkey) {
|
||||
throw new Error("Recipient pubkey not found in conversation");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[NIP-17] Loading messages with ${recipientPubkey} for ${activePubkey}`,
|
||||
);
|
||||
|
||||
// Convert promise to observable
|
||||
return new Observable((subscriber) => {
|
||||
messagesPromise
|
||||
.then((messages) => {
|
||||
subscriber.next(messages);
|
||||
subscriber.complete();
|
||||
})
|
||||
.catch((err) => subscriber.error(err));
|
||||
});
|
||||
// WrappedMessagesModel returns ALL decrypted rumors for the user
|
||||
// We need to filter for this specific conversation
|
||||
return eventStore.model(WrappedMessagesModel, activePubkey).pipe(
|
||||
map((rumors) => {
|
||||
// rumors is an array of decrypted kind 14 events
|
||||
if (!Array.isArray(rumors)) {
|
||||
console.warn("[NIP-17] WrappedMessagesModel returned non-array");
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter for messages with the specific conversation partner
|
||||
const conversationRumors = rumors.filter((rumor) => {
|
||||
// Message is in this conversation if:
|
||||
// 1. We sent it to the recipient (rumor.pubkey === activePubkey, p-tag === recipientPubkey)
|
||||
// 2. Recipient sent it to us (rumor.pubkey === recipientPubkey)
|
||||
const pTags = rumor.tags.filter((t) => t[0] === "p");
|
||||
const hasRecipient = pTags.some((t) => t[1] === recipientPubkey);
|
||||
|
||||
return (
|
||||
(rumor.pubkey === activePubkey && hasRecipient) ||
|
||||
rumor.pubkey === recipientPubkey
|
||||
);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[NIP-17] Got ${conversationRumors.length} messages for conversation (out of ${rumors.length} total)`,
|
||||
);
|
||||
|
||||
const messages = conversationRumors.map((rumor) =>
|
||||
this.rumorToMessage(rumor, conversation.id),
|
||||
);
|
||||
|
||||
// Sort by timestamp (oldest first)
|
||||
return messages.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more historical messages (pagination)
|
||||
* Convert a rumor (kind 14) to a Message
|
||||
*/
|
||||
private rumorToMessage(rumor: any, conversationId: string): Message {
|
||||
// Extract reply info if present (e-tag referencing previous message)
|
||||
const eTags = rumor.tags.filter((t: string[]) => t[0] === "e");
|
||||
const replyTo = eTags.length > 0 ? eTags[0][1] : undefined;
|
||||
|
||||
// Create a NostrEvent from rumor (add sig field)
|
||||
const event: NostrEvent = {
|
||||
...rumor,
|
||||
sig: rumor.sig || "", // Rumors don't have sig
|
||||
};
|
||||
|
||||
return {
|
||||
id: rumor.id,
|
||||
conversationId,
|
||||
author: rumor.pubkey,
|
||||
content: rumor.content,
|
||||
timestamp: rumor.created_at,
|
||||
protocol: this.protocol,
|
||||
event,
|
||||
replyTo,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more messages (not applicable for NIP-17)
|
||||
*/
|
||||
async loadMoreMessages(
|
||||
conversation: Conversation,
|
||||
before: number,
|
||||
_conversation: Conversation,
|
||||
_before: number,
|
||||
): Promise<Message[]> {
|
||||
const [peerPubkey] = conversation.id.split(":");
|
||||
return this.loadMessagesFromDb(peerPubkey, conversation.id, 50, before);
|
||||
// NIP-17 loads all messages at once, no pagination
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages from database
|
||||
* Load a specific reply message (not implemented for NIP-17)
|
||||
*/
|
||||
private async loadMessagesFromDb(
|
||||
peerPubkey: string,
|
||||
conversationId: string,
|
||||
limit: number,
|
||||
before?: number,
|
||||
): Promise<Message[]> {
|
||||
const activePubkey = await this.getActivePubkey();
|
||||
|
||||
// Query decryptedRumors table for messages with this peer
|
||||
let receivedQuery = db.decryptedRumors
|
||||
.where("[recipientPubkey+senderPubkey]")
|
||||
.equals([activePubkey, peerPubkey]);
|
||||
|
||||
if (before) {
|
||||
receivedQuery = receivedQuery.filter((r) => r.rumorCreatedAt < before);
|
||||
}
|
||||
|
||||
const rumors = await receivedQuery.reverse().limit(limit).toArray();
|
||||
|
||||
// Also get messages I sent to them (if any)
|
||||
let sentQuery = db.decryptedRumors
|
||||
.where("[recipientPubkey+senderPubkey]")
|
||||
.equals([peerPubkey, activePubkey]);
|
||||
|
||||
if (before) {
|
||||
sentQuery = sentQuery.filter((r) => r.rumorCreatedAt < before);
|
||||
}
|
||||
|
||||
const sentRumors = await sentQuery.reverse().limit(limit).toArray();
|
||||
|
||||
// Combine and sort by timestamp
|
||||
const allRumors = [...rumors, ...sentRumors].sort(
|
||||
(a, b) => a.rumorCreatedAt - b.rumorCreatedAt,
|
||||
);
|
||||
|
||||
// Convert to Message format
|
||||
return allRumors
|
||||
.filter((r) => r.rumorKind === 14) // Only chat messages (kind 14)
|
||||
.map((r) => ({
|
||||
id: r.giftWrapId,
|
||||
conversationId,
|
||||
author: r.senderPubkey,
|
||||
content: r.rumor.content,
|
||||
timestamp: r.rumorCreatedAt,
|
||||
protocol: "nip-17" as const,
|
||||
event: r.rumor,
|
||||
}));
|
||||
async loadReplyMessage(
|
||||
_conversation: Conversation,
|
||||
_eventId: string,
|
||||
): Promise<NostrEvent | null> {
|
||||
// Would need to search through decrypted rumors
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message - NOT IMPLEMENTED (read-only for now)
|
||||
* Send a message (not yet implemented for NIP-17)
|
||||
*/
|
||||
async sendMessage(
|
||||
_conversation: Conversation,
|
||||
_content: string,
|
||||
_options?: SendMessageOptions,
|
||||
): Promise<void> {
|
||||
throw new Error(
|
||||
"Sending NIP-17 messages is not yet implemented. This adapter is read-only.",
|
||||
);
|
||||
throw new Error("Sending NIP-17 messages is not yet implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get capabilities - read-only for now
|
||||
* React to a message (not supported for NIP-17)
|
||||
*/
|
||||
async reactToMessage(_message: Message, _emoji: string): Promise<NostrEvent> {
|
||||
throw new Error("Reactions are not supported for NIP-17");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message (not supported for NIP-17)
|
||||
*/
|
||||
async deleteMessage(_message: Message): Promise<void> {
|
||||
throw new Error("Message deletion is not supported for NIP-17");
|
||||
}
|
||||
|
||||
/**
|
||||
* List conversations (not yet implemented for NIP-17)
|
||||
* This would require scanning all decrypted rumors to find unique conversation partners
|
||||
*/
|
||||
listConversations(): Observable<Conversation[]> {
|
||||
throw new Error("Listing NIP-17 conversations is not yet implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get capabilities - NIP-17 is read-only for now
|
||||
*/
|
||||
getCapabilities(): ChatCapabilities {
|
||||
return {
|
||||
supportsEncryption: true, // NIP-17 is encrypted
|
||||
supportsThreading: false, // DMs don't have threading
|
||||
supportsModeration: false, // No moderation in DMs
|
||||
supportsRoles: false, // No roles in 1-on-1 DMs
|
||||
supportsGroupManagement: false, // Not a group
|
||||
canCreateConversations: false, // Read-only for now
|
||||
requiresRelay: false, // Uses DM relay lists, not specific relay
|
||||
supportsEncryption: true,
|
||||
supportsThreading: false,
|
||||
supportsModeration: false,
|
||||
supportsRoles: false,
|
||||
supportsGroupManagement: false,
|
||||
canCreateConversations: false,
|
||||
requiresRelay: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load reply message - not implemented for NIP-17
|
||||
*/
|
||||
async loadReplyMessage(
|
||||
_conversation: Conversation,
|
||||
_eventId: string,
|
||||
): Promise<NostrEvent | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active pubkey from account manager
|
||||
*/
|
||||
private async getActivePubkey(): Promise<string> {
|
||||
// Import dynamically to avoid circular dependency
|
||||
const { default: accountManager } = await import("@/services/accounts");
|
||||
const account = accountManager.active;
|
||||
if (!account) {
|
||||
throw new Error("No active account");
|
||||
}
|
||||
return account.pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user