mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 08:27:27 +02:00
feat: Add read-only NIP-17 chat adapter for private DMs
- Create NIP-17 chat adapter supporting npub, nprofile, hex pubkey, NIP-05
- Support self-messages ("Saved Messages"), groups, and 1-on-1 conversations
- Add gift wrap event persistence to Dexie (version 17)
- Load stored gift wraps into EventStore on startup
- Export Rumor type from gift-wrap service for adapter use
- Wire up NIP-17 adapter to chat parser and ChatViewer
- Add kind 14 to CHAT_KINDS for consistent message filtering
- Update tests to reflect NIP-17 support for npub identifiers
Chat command formats now supported:
- chat npub1.../nprofile1.../hex pubkey (single recipient)
- chat user@example.com (NIP-05 resolution)
- chat npub1...,npub2... (group conversation)
This commit is contained in:
@@ -25,6 +25,7 @@ import type {
|
||||
} from "@/types/chat";
|
||||
import { CHAT_KINDS } from "@/types/chat";
|
||||
// import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon
|
||||
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";
|
||||
@@ -1046,10 +1047,10 @@ function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
|
||||
switch (protocol) {
|
||||
// case "nip-c7": // Phase 1 - Simple chat (coming soon)
|
||||
// return new NipC7Adapter();
|
||||
case "nip-17":
|
||||
return new Nip17Adapter();
|
||||
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-53":
|
||||
|
||||
@@ -89,10 +89,15 @@ describe("parseChatCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for npub (NIP-C7 disabled)", () => {
|
||||
expect(() => parseChatCommand(["npub1xyz"])).toThrow(
|
||||
/Unable to determine chat protocol/,
|
||||
);
|
||||
it("should parse npub as NIP-17 private DM", () => {
|
||||
// Valid npub format (64 hex chars encoded)
|
||||
const result = parseChatCommand([
|
||||
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6",
|
||||
]);
|
||||
|
||||
expect(result.protocol).toBe("nip-17");
|
||||
expect(result.identifier.type).toBe("dm-recipient");
|
||||
expect(result.adapter.protocol).toBe("nip-17");
|
||||
});
|
||||
|
||||
it("should throw error for note/nevent (NIP-28 not implemented)", () => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ChatCommandResult, GroupListIdentifier } from "@/types/chat";
|
||||
// import { NipC7Adapter } from "./chat/adapters/nip-c7-adapter";
|
||||
import { Nip17Adapter } from "./chat/adapters/nip-17-adapter";
|
||||
import { Nip29Adapter } from "./chat/adapters/nip-29-adapter";
|
||||
import { Nip53Adapter } from "./chat/adapters/nip-53-adapter";
|
||||
import { nip19 } from "nostr-tools";
|
||||
// Import other adapters as they're implemented
|
||||
// import { Nip17Adapter } from "./chat/adapters/nip-17-adapter";
|
||||
// import { Nip28Adapter } from "./chat/adapters/nip-28-adapter";
|
||||
|
||||
/**
|
||||
@@ -62,10 +62,10 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
|
||||
|
||||
// Try each adapter in priority order
|
||||
const adapters = [
|
||||
// new Nip17Adapter(), // Phase 2
|
||||
// new Nip28Adapter(), // Phase 3
|
||||
new Nip29Adapter(), // Phase 4 - Relay groups
|
||||
new Nip53Adapter(), // Phase 5 - Live activity chat
|
||||
new Nip17Adapter(), // Private DMs (gift-wrapped)
|
||||
// new Nip28Adapter(), // Phase 3 - Public channels
|
||||
new Nip29Adapter(), // Relay groups
|
||||
new Nip53Adapter(), // Live activity chat
|
||||
// new NipC7Adapter(), // Phase 1 - Simple chat (disabled for now)
|
||||
];
|
||||
|
||||
@@ -84,6 +84,12 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
|
||||
`Unable to determine chat protocol from identifier: ${identifier}
|
||||
|
||||
Currently supported formats:
|
||||
- npub1.../nprofile1.../hex pubkey (NIP-17 private DMs)
|
||||
Examples:
|
||||
chat npub1abc...
|
||||
chat nprofile1...
|
||||
chat user@example.com (NIP-05)
|
||||
chat npub1...,npub2...,npub3... (group chat)
|
||||
- relay.com'group-id (NIP-29 relay group, wss:// prefix optional)
|
||||
Examples:
|
||||
chat relay.example.com'bitcoin-dev
|
||||
@@ -99,7 +105,6 @@ Currently supported formats:
|
||||
chat naddr1... (group list address)
|
||||
|
||||
More formats coming soon:
|
||||
- npub/nprofile/hex pubkey (NIP-C7/NIP-17 direct messages)
|
||||
- note/nevent (NIP-28 public channels)`,
|
||||
);
|
||||
}
|
||||
|
||||
436
src/lib/chat/adapters/nip-17-adapter.ts
Normal file
436
src/lib/chat/adapters/nip-17-adapter.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import { Observable, of } from "rxjs";
|
||||
import { map } from "rxjs/operators";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
|
||||
import type {
|
||||
Conversation,
|
||||
Message,
|
||||
ProtocolIdentifier,
|
||||
ChatCapabilities,
|
||||
LoadMessagesOptions,
|
||||
Participant,
|
||||
} from "@/types/chat";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import giftWrapService, { type Rumor } from "@/services/gift-wrap";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { resolveNip05 } from "@/lib/nip05";
|
||||
|
||||
/** Kind 14: Private direct message (NIP-17) */
|
||||
const PRIVATE_DM_KIND = 14;
|
||||
|
||||
/**
|
||||
* Compute a stable conversation ID from sorted participant pubkeys
|
||||
*/
|
||||
function computeConversationId(participants: string[]): string {
|
||||
const sorted = [...participants].sort();
|
||||
return `nip17:${sorted.join(",")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse participants from a comma-separated list or single identifier
|
||||
* Supports: npub, nprofile, hex pubkey (32 bytes), NIP-05
|
||||
*/
|
||||
async function parseParticipants(input: string): Promise<string[]> {
|
||||
const parts = input
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
const pubkeys: string[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
const pubkey = await resolveToPubkey(part);
|
||||
if (pubkey && !pubkeys.includes(pubkey)) {
|
||||
pubkeys.push(pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
return pubkeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an identifier to a hex pubkey
|
||||
*/
|
||||
async function resolveToPubkey(input: string): Promise<string | null> {
|
||||
// Try npub
|
||||
if (input.startsWith("npub1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(input);
|
||||
if (decoded.type === "npub") {
|
||||
return decoded.data;
|
||||
}
|
||||
} catch {
|
||||
// Not a valid npub
|
||||
}
|
||||
}
|
||||
|
||||
// Try nprofile
|
||||
if (input.startsWith("nprofile1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(input);
|
||||
if (decoded.type === "nprofile") {
|
||||
return decoded.data.pubkey;
|
||||
}
|
||||
} catch {
|
||||
// Not a valid nprofile
|
||||
}
|
||||
}
|
||||
|
||||
// Try hex pubkey (64 chars)
|
||||
if (/^[0-9a-fA-F]{64}$/.test(input)) {
|
||||
return input.toLowerCase();
|
||||
}
|
||||
|
||||
// Try NIP-05 (contains @ or is bare domain)
|
||||
if (input.includes("@") || input.includes(".")) {
|
||||
try {
|
||||
const pubkey = await resolveNip05(input);
|
||||
if (pubkey) {
|
||||
return pubkey;
|
||||
}
|
||||
} catch {
|
||||
// NIP-05 resolution failed
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-17 Adapter - Private Direct Messages (Gift Wrapped)
|
||||
*
|
||||
* Features:
|
||||
* - End-to-end encrypted messages via NIP-59 gift wraps
|
||||
* - 1-on-1 conversations
|
||||
* - Group conversations (multiple recipients)
|
||||
* - Self-messages ("saved messages")
|
||||
* - Read-only for now (sending messages coming later)
|
||||
*
|
||||
* Identifier formats:
|
||||
* - npub1... (single recipient)
|
||||
* - nprofile1... (single recipient with relay hints)
|
||||
* - hex pubkey (64 chars)
|
||||
* - NIP-05 address (user@domain.com or _@domain.com)
|
||||
* - Comma-separated list of any of the above for groups
|
||||
*/
|
||||
export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
readonly protocol = "nip-17" as const;
|
||||
readonly type = "dm" as const;
|
||||
|
||||
/**
|
||||
* Parse identifier - accepts pubkeys, npubs, nprofiles, NIP-05, or comma-separated list
|
||||
*/
|
||||
parseIdentifier(input: string): ProtocolIdentifier | null {
|
||||
// Quick check: must look like a pubkey identifier or NIP-05
|
||||
const trimmed = input.trim();
|
||||
|
||||
// Check for npub, nprofile, hex, or NIP-05 patterns
|
||||
const looksLikePubkey =
|
||||
trimmed.startsWith("npub1") ||
|
||||
trimmed.startsWith("nprofile1") ||
|
||||
/^[0-9a-fA-F]{64}$/.test(trimmed) ||
|
||||
trimmed.includes("@") ||
|
||||
(trimmed.includes(".") &&
|
||||
!trimmed.includes("'") &&
|
||||
!trimmed.includes("/"));
|
||||
|
||||
// Also check for comma-separated list
|
||||
const looksLikeList =
|
||||
trimmed.includes(",") &&
|
||||
trimmed
|
||||
.split(",")
|
||||
.some(
|
||||
(p) =>
|
||||
p.trim().startsWith("npub1") ||
|
||||
p.trim().startsWith("nprofile1") ||
|
||||
/^[0-9a-fA-F]{64}$/.test(p.trim()) ||
|
||||
p.trim().includes("@"),
|
||||
);
|
||||
|
||||
if (!looksLikePubkey && !looksLikeList) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return a placeholder identifier - actual resolution happens in resolveConversation
|
||||
return {
|
||||
type: "dm-recipient",
|
||||
value: trimmed, // Will be resolved later
|
||||
relays: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve conversation from DM identifier
|
||||
*/
|
||||
async resolveConversation(
|
||||
identifier: ProtocolIdentifier,
|
||||
): Promise<Conversation> {
|
||||
if (
|
||||
identifier.type !== "dm-recipient" &&
|
||||
identifier.type !== "chat-partner"
|
||||
) {
|
||||
throw new Error(
|
||||
`NIP-17 adapter cannot handle identifier type: ${identifier.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (!activePubkey) {
|
||||
throw new Error("No active account");
|
||||
}
|
||||
|
||||
// Check if private messages are enabled
|
||||
const settings = giftWrapService.settings$.value;
|
||||
if (!settings.enabled) {
|
||||
throw new Error(
|
||||
"Private messages are not enabled. Enable them in the inbox settings.",
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the identifier to get participant pubkeys
|
||||
const inputPubkeys = await parseParticipants(identifier.value);
|
||||
if (inputPubkeys.length === 0) {
|
||||
throw new Error(
|
||||
`Could not resolve any pubkeys from: ${identifier.value}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Build full participant list (always include self)
|
||||
const allParticipants = [
|
||||
activePubkey,
|
||||
...inputPubkeys.filter((p) => p !== activePubkey),
|
||||
];
|
||||
const uniqueParticipants = [...new Set(allParticipants)];
|
||||
|
||||
// Determine conversation type
|
||||
const isSelfChat = uniqueParticipants.length === 1; // Only self
|
||||
const isGroup = uniqueParticipants.length > 2; // More than 2 people
|
||||
|
||||
// Create conversation ID from participants
|
||||
const conversationId = computeConversationId(uniqueParticipants);
|
||||
|
||||
// Build title
|
||||
let title: string;
|
||||
if (isSelfChat) {
|
||||
title = "Saved Messages";
|
||||
} else if (isGroup) {
|
||||
title = `Group (${uniqueParticipants.length})`;
|
||||
} else {
|
||||
// 1-on-1: use the other person's pubkey for title
|
||||
const otherPubkey = uniqueParticipants.find((p) => p !== activePubkey);
|
||||
title = otherPubkey ? `${otherPubkey.slice(0, 8)}...` : "Private Chat";
|
||||
}
|
||||
|
||||
// Build participants array
|
||||
const participants: Participant[] = uniqueParticipants.map((pubkey) => ({
|
||||
pubkey,
|
||||
role: pubkey === activePubkey ? "member" : undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: conversationId,
|
||||
type: "dm",
|
||||
protocol: "nip-17",
|
||||
title,
|
||||
participants,
|
||||
metadata: {
|
||||
encrypted: true,
|
||||
giftWrapped: true,
|
||||
},
|
||||
unreadCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages for a conversation
|
||||
* Filters decrypted rumors to match conversation participants
|
||||
*/
|
||||
loadMessages(
|
||||
conversation: Conversation,
|
||||
_options?: LoadMessagesOptions,
|
||||
): Observable<Message[]> {
|
||||
const participantSet = new Set(
|
||||
conversation.participants.map((p) => p.pubkey),
|
||||
);
|
||||
|
||||
return giftWrapService.decryptedRumors$.pipe(
|
||||
map((rumors) => {
|
||||
// Filter rumors that belong to this conversation
|
||||
const conversationRumors = rumors.filter(({ rumor }) => {
|
||||
// Only include kind 14 (private DMs)
|
||||
if (rumor.kind !== PRIVATE_DM_KIND) return false;
|
||||
|
||||
// Get all participants from the rumor
|
||||
const rumorParticipants = this.getRumorParticipants(rumor);
|
||||
|
||||
// Check if participants match (same set of pubkeys)
|
||||
if (rumorParticipants.size !== participantSet.size) return false;
|
||||
for (const p of rumorParticipants) {
|
||||
if (!participantSet.has(p)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Convert to Message format
|
||||
return conversationRumors.map(({ giftWrap, rumor }) =>
|
||||
this.rumorToMessage(conversation.id, giftWrap, rumor),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all participants from a rumor (author + all p-tag recipients)
|
||||
*/
|
||||
private getRumorParticipants(rumor: Rumor): Set<string> {
|
||||
const participants = new Set<string>();
|
||||
participants.add(rumor.pubkey); // Author
|
||||
|
||||
// Add all p-tag recipients
|
||||
for (const tag of rumor.tags) {
|
||||
if (tag[0] === "p" && tag[1]) {
|
||||
participants.add(tag[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return participants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a rumor to a Message
|
||||
*/
|
||||
private rumorToMessage(
|
||||
conversationId: string,
|
||||
giftWrap: NostrEvent,
|
||||
rumor: Rumor,
|
||||
): Message {
|
||||
// Find reply-to from e tags
|
||||
let replyTo: string | undefined;
|
||||
for (const tag of rumor.tags) {
|
||||
if (tag[0] === "e" && tag[1]) {
|
||||
// NIP-10: last e tag is usually the reply target
|
||||
replyTo = tag[1];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: rumor.id,
|
||||
conversationId,
|
||||
author: rumor.pubkey,
|
||||
content: rumor.content,
|
||||
timestamp: rumor.created_at,
|
||||
type: "user",
|
||||
replyTo,
|
||||
metadata: {
|
||||
encrypted: true,
|
||||
},
|
||||
protocol: "nip-17",
|
||||
// Use gift wrap as the event since rumor is unsigned
|
||||
event: giftWrap,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more historical messages (pagination)
|
||||
*/
|
||||
async loadMoreMessages(
|
||||
_conversation: Conversation,
|
||||
_before: number,
|
||||
): Promise<Message[]> {
|
||||
// For now, all messages are loaded at once from the gift wrap service
|
||||
// Pagination would require fetching more gift wraps from relays
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message (not implemented yet - read-only for now)
|
||||
*/
|
||||
async sendMessage(
|
||||
_conversation: Conversation,
|
||||
_content: string,
|
||||
_options?: SendMessageOptions,
|
||||
): Promise<void> {
|
||||
throw new Error(
|
||||
"Sending messages is not yet implemented for NIP-17. Coming soon!",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get capabilities
|
||||
*/
|
||||
getCapabilities(): ChatCapabilities {
|
||||
return {
|
||||
supportsEncryption: true,
|
||||
supportsThreading: true, // via e tags
|
||||
supportsModeration: false,
|
||||
supportsRoles: false,
|
||||
supportsGroupManagement: false,
|
||||
canCreateConversations: false, // read-only for now
|
||||
requiresRelay: false, // uses inbox relays from profile
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a replied-to message by ID
|
||||
*/
|
||||
async loadReplyMessage(
|
||||
_conversation: Conversation,
|
||||
eventId: string,
|
||||
): Promise<NostrEvent | null> {
|
||||
// Check decrypted rumors for the message
|
||||
const rumors = giftWrapService.decryptedRumors$.value;
|
||||
const found = rumors.find(({ rumor }) => rumor.id === eventId);
|
||||
if (found) {
|
||||
// Return the gift wrap event
|
||||
return found.giftWrap;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load conversation list from gift wrap service
|
||||
*/
|
||||
loadConversationList(): Observable<Conversation[]> {
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (!activePubkey) {
|
||||
return of([]);
|
||||
}
|
||||
|
||||
return giftWrapService.conversations$.pipe(
|
||||
map((conversations) =>
|
||||
conversations.map((conv) => ({
|
||||
id: conv.id,
|
||||
type: "dm" as const,
|
||||
protocol: "nip-17" as const,
|
||||
title: this.getConversationTitle(conv.participants, activePubkey),
|
||||
participants: conv.participants.map((pubkey) => ({ pubkey })),
|
||||
metadata: {
|
||||
encrypted: true,
|
||||
giftWrapped: true,
|
||||
},
|
||||
lastMessage: conv.lastMessage
|
||||
? this.rumorToMessage(conv.id, conv.lastGiftWrap!, conv.lastMessage)
|
||||
: undefined,
|
||||
unreadCount: 0,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation title from participants
|
||||
*/
|
||||
private getConversationTitle(
|
||||
participants: string[],
|
||||
activePubkey: string,
|
||||
): string {
|
||||
const others = participants.filter((p) => p !== activePubkey);
|
||||
|
||||
if (others.length === 0) {
|
||||
return "Saved Messages";
|
||||
} else if (others.length === 1) {
|
||||
return `${others[0].slice(0, 8)}...`;
|
||||
} else {
|
||||
return `Group (${participants.length})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,13 @@ export interface EncryptedContentEntry {
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
export interface StoredGiftWrap {
|
||||
id: string; // Event ID
|
||||
event: NostrEvent; // Full gift wrap event
|
||||
userPubkey: string; // Recipient pubkey (from #p tag)
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stored encrypted content IDs
|
||||
* Used to check which gift wraps have already been decrypted
|
||||
@@ -76,6 +83,36 @@ export async function getStoredEncryptedContentIds(): Promise<Set<string>> {
|
||||
return new Set(entries.map((e) => e.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save gift wrap events to Dexie for persistence across sessions
|
||||
*/
|
||||
export async function saveGiftWraps(
|
||||
events: NostrEvent[],
|
||||
userPubkey: string,
|
||||
): Promise<void> {
|
||||
const entries: StoredGiftWrap[] = events.map((event) => ({
|
||||
id: event.id,
|
||||
event,
|
||||
userPubkey,
|
||||
savedAt: Date.now(),
|
||||
}));
|
||||
|
||||
await db.giftWraps.bulkPut(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load stored gift wrap events for a user
|
||||
*/
|
||||
export async function loadStoredGiftWraps(
|
||||
userPubkey: string,
|
||||
): Promise<NostrEvent[]> {
|
||||
const entries = await db.giftWraps
|
||||
.where("userPubkey")
|
||||
.equals(userPubkey)
|
||||
.toArray();
|
||||
return entries.map((e) => e.event);
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -114,6 +151,7 @@ class GrimoireDb extends Dexie {
|
||||
spells!: Table<LocalSpell>;
|
||||
spellbooks!: Table<LocalSpellbook>;
|
||||
encryptedContent!: Table<EncryptedContentEntry>;
|
||||
giftWraps!: Table<StoredGiftWrap>;
|
||||
|
||||
constructor(name: string) {
|
||||
super(name);
|
||||
@@ -364,6 +402,22 @@ class GrimoireDb extends Dexie {
|
||||
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
|
||||
encryptedContent: "&id, savedAt",
|
||||
});
|
||||
|
||||
// Version 17: Add gift wrap event storage for NIP-17 chat
|
||||
this.version(17).stores({
|
||||
profiles: "&pubkey",
|
||||
nip05: "&nip05",
|
||||
nips: "&id",
|
||||
relayInfo: "&url",
|
||||
relayAuthPreferences: "&url",
|
||||
relayLists: "&pubkey, updatedAt",
|
||||
relayLiveness: "&url",
|
||||
blossomServers: "&pubkey, updatedAt",
|
||||
spells: "&id, alias, createdAt, isPublished, deletedAt",
|
||||
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
|
||||
encryptedContent: "&id, savedAt",
|
||||
giftWraps: "&id, userPubkey, savedAt",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,12 @@ import type { NostrEvent } from "@/types/nostr";
|
||||
import type { ISigner } from "applesauce-signers";
|
||||
import eventStore from "./event-store";
|
||||
import pool from "./relay-pool";
|
||||
import { encryptedContentStorage, getStoredEncryptedContentIds } from "./db";
|
||||
import {
|
||||
encryptedContentStorage,
|
||||
getStoredEncryptedContentIds,
|
||||
saveGiftWraps,
|
||||
loadStoredGiftWraps,
|
||||
} from "./db";
|
||||
import { AGGREGATOR_RELAYS } from "./loaders";
|
||||
import relayListCache from "./relay-list-cache";
|
||||
|
||||
@@ -25,7 +30,7 @@ const DM_RELAY_LIST_KIND = 10050;
|
||||
const PRIVATE_DM_KIND = 14;
|
||||
|
||||
/** Rumor is an unsigned event - used for gift wrap contents */
|
||||
interface Rumor {
|
||||
export interface Rumor {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
created_at: number;
|
||||
@@ -177,12 +182,33 @@ class GiftWrapService {
|
||||
});
|
||||
this.subscriptions.push(updateSub);
|
||||
|
||||
// If enabled, start syncing
|
||||
// If enabled, load stored gift wraps and start syncing
|
||||
if (this.settings$.value.enabled) {
|
||||
await this.loadStoredGiftWraps();
|
||||
this.startSync();
|
||||
}
|
||||
}
|
||||
|
||||
/** Load stored gift wraps from Dexie into EventStore */
|
||||
private async loadStoredGiftWraps() {
|
||||
if (!this.userPubkey) return;
|
||||
|
||||
try {
|
||||
const storedEvents = await loadStoredGiftWraps(this.userPubkey);
|
||||
if (storedEvents.length > 0) {
|
||||
console.log(
|
||||
`[GiftWrap] Loading ${storedEvents.length} stored gift wraps into EventStore`,
|
||||
);
|
||||
// Add stored events to EventStore - this triggers the timeline subscription
|
||||
for (const event of storedEvents) {
|
||||
eventStore.add(event);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[GiftWrap] Error loading stored gift wraps:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Update settings */
|
||||
updateSettings(settings: Partial<InboxSettings>) {
|
||||
const newSettings = { ...this.settings$.value, ...settings };
|
||||
@@ -321,6 +347,11 @@ class GiftWrapService {
|
||||
.timeline(reqFilter)
|
||||
.pipe(map((events) => events.sort((a, b) => b.created_at - a.created_at)))
|
||||
.subscribe((giftWraps) => {
|
||||
// Find new gift wraps that we haven't seen before
|
||||
const newGiftWraps = giftWraps.filter(
|
||||
(gw) => !this.giftWraps.some((existing) => existing.id === gw.id),
|
||||
);
|
||||
|
||||
this.giftWraps = giftWraps;
|
||||
this.giftWraps$.next(giftWraps);
|
||||
|
||||
@@ -340,6 +371,13 @@ class GiftWrapService {
|
||||
this.decryptStates$.next(new Map(this.decryptStates));
|
||||
this.updatePendingCount();
|
||||
|
||||
// Persist new gift wraps to Dexie for fast loading on next startup
|
||||
if (newGiftWraps.length > 0 && this.userPubkey) {
|
||||
saveGiftWraps(newGiftWraps, this.userPubkey).catch((err) => {
|
||||
console.warn(`[GiftWrap] Error saving gift wraps:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
// Update conversations
|
||||
this.updateConversations();
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NostrEvent } from "./nostr";
|
||||
*/
|
||||
export const CHAT_KINDS = [
|
||||
9, // NIP-29: Group chat messages
|
||||
14, // NIP-17: Private direct messages (inside gift wrap)
|
||||
9321, // NIP-61: Nutzaps (ecash zaps in groups/live chats)
|
||||
1311, // NIP-53: Live chat messages
|
||||
9735, // NIP-57: Zap receipts (part of chat context)
|
||||
|
||||
Reference in New Issue
Block a user