From e777b4006577da534a4ed71b7e7fc9a0fafaaae4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 26 Oct 2025 20:23:28 -0400 Subject: [PATCH] Add cached DM snapshot storage and hydration --- js/directMessagesStore.js | 327 ++++++++++++++++++++++++++++ js/services/nostrService.js | 366 +++++++++++++++++++++++++++++++- js/ui/profileModalController.js | 47 ++++ 3 files changed, 738 insertions(+), 2 deletions(-) create mode 100644 js/directMessagesStore.js diff --git a/js/directMessagesStore.js b/js/directMessagesStore.js new file mode 100644 index 00000000..4e9c4811 --- /dev/null +++ b/js/directMessagesStore.js @@ -0,0 +1,327 @@ +import { userLogger } from "./utils/logger.js"; + +const DB_NAME = "bitvidSettings"; +const DB_VERSION = 1; +const STORE_NAME = "kv"; +const KEY_PREFIX = "dmSnapshot:"; +const LOCALSTORAGE_PREFIX = "bitvid:dmSnapshot:"; +const PREVIEW_MAX_LENGTH = 160; +const HEX64_REGEX = /^[0-9a-f]{64}$/i; + +function isIndexedDbAvailable() { + try { + return typeof indexedDB !== "undefined"; + } catch (error) { + return false; + } +} + +function openSettingsDb() { + return new Promise((resolve, reject) => { + if (!isIndexedDbAvailable()) { + resolve(null); + return; + } + + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error || new Error("Failed to open settings DB")); + }; + }); +} + +function normalizePubkey(pubkey) { + if (typeof pubkey !== "string") { + return ""; + } + + const trimmed = pubkey.trim(); + if (!trimmed) { + return ""; + } + + if (HEX64_REGEX.test(trimmed)) { + return trimmed.toLowerCase(); + } + + if (trimmed.startsWith("npub1") && typeof window !== "undefined") { + try { + const decoded = window?.NostrTools?.nip19?.decode?.(trimmed); + if (decoded?.type === "npub" && typeof decoded.data === "string") { + const hex = decoded.data.trim(); + if (HEX64_REGEX.test(hex)) { + return hex.toLowerCase(); + } + } + } catch (error) { + // Ignore decode errors and fall through to return empty string. + } + } + + return ""; +} + +function resolveStorageKeys(pubkey) { + const normalized = normalizePubkey(pubkey); + if (!normalized) { + throw new Error("Invalid pubkey for DM snapshot storage."); + } + + return { + normalized, + dbKey: `${KEY_PREFIX}${normalized}`, + fallbackKey: `${LOCALSTORAGE_PREFIX}${normalized}`, + }; +} + +function sanitizeTimestamp(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return 0; + } + + const rounded = Math.floor(numeric); + return Number.isFinite(rounded) && rounded > 0 ? rounded : 0; +} + +function sanitizePreview(value) { + if (typeof value !== "string") { + return ""; + } + + let preview = value.replace(/\s+/g, " ").trim(); + if (!preview) { + return ""; + } + + if (preview.length > PREVIEW_MAX_LENGTH) { + preview = `${preview.slice(0, PREVIEW_MAX_LENGTH).trimEnd()}\u2026`; + } + + return preview; +} + +function normalizeSnapshotEntry(entry) { + if (!entry || typeof entry !== "object") { + return null; + } + + const remotePubkey = normalizePubkey( + entry.remotePubkey || entry.remote || entry.pubkey, + ); + if (!remotePubkey) { + return null; + } + + const latestTimestamp = sanitizeTimestamp( + entry.latestTimestamp ?? entry.timestamp ?? entry.lastTimestamp, + ); + + const preview = sanitizePreview( + entry.preview ?? entry.plaintext ?? entry.text ?? "", + ); + + return { + remotePubkey, + latestTimestamp, + preview, + }; +} + +function normalizeSnapshotList(raw) { + if (!Array.isArray(raw)) { + return []; + } + + const seen = new Set(); + const results = []; + + for (const entry of raw) { + const normalized = normalizeSnapshotEntry(entry); + if (!normalized) { + continue; + } + + if (seen.has(normalized.remotePubkey)) { + const existingIndex = results.findIndex( + (candidate) => candidate.remotePubkey === normalized.remotePubkey, + ); + if (existingIndex >= 0) { + const existing = results[existingIndex]; + if (normalized.latestTimestamp > existing.latestTimestamp) { + results[existingIndex] = normalized; + } + } + continue; + } + + seen.add(normalized.remotePubkey); + results.push(normalized); + } + + results.sort( + (a, b) => (b.latestTimestamp || 0) - (a.latestTimestamp || 0), + ); + + return results; +} + +export async function loadDirectMessageSnapshot(pubkey) { + let keys; + try { + keys = resolveStorageKeys(pubkey); + } catch (error) { + userLogger.warn("[directMessagesStore] Invalid pubkey for load", error); + return []; + } + + const { normalized, dbKey, fallbackKey } = keys; + + try { + const db = await openSettingsDb(); + if (db) { + const stored = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + const request = store.get(dbKey); + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => + reject(request.error || new Error("Failed to load snapshot")); + }); + + return normalizeSnapshotList(stored); + } + } catch (error) { + userLogger.warn( + "[directMessagesStore] Failed to read IndexedDB snapshot", + error, + ); + } + + try { + if (typeof localStorage !== "undefined") { + const raw = localStorage.getItem(fallbackKey); + if (raw) { + return normalizeSnapshotList(JSON.parse(raw)); + } + } + } catch (error) { + userLogger.warn( + "[directMessagesStore] Failed to read fallback snapshot", + error, + ); + } + + // If we reached here, ensure we still return a normalized empty list for + // callers that rely on consistent return values. + return normalizeSnapshotList([]); +} + +export async function saveDirectMessageSnapshot(pubkey, snapshot) { + let keys; + try { + keys = resolveStorageKeys(pubkey); + } catch (error) { + userLogger.warn("[directMessagesStore] Invalid pubkey for save", error); + return []; + } + + const normalizedSnapshot = normalizeSnapshotList(snapshot); + const { dbKey, fallbackKey } = keys; + + try { + const db = await openSettingsDb(); + if (db) { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const request = store.put(normalizedSnapshot, dbKey); + request.onsuccess = () => resolve(); + request.onerror = () => + reject(request.error || new Error("Failed to write snapshot")); + }); + return normalizedSnapshot; + } + } catch (error) { + userLogger.warn( + "[directMessagesStore] Failed to write IndexedDB snapshot", + error, + ); + } + + try { + if (typeof localStorage !== "undefined") { + localStorage.setItem(fallbackKey, JSON.stringify(normalizedSnapshot)); + } + } catch (error) { + userLogger.warn( + "[directMessagesStore] Failed to write fallback snapshot", + error, + ); + } + + return normalizedSnapshot; +} + +export async function clearDirectMessageSnapshot(pubkey) { + let keys; + try { + keys = resolveStorageKeys(pubkey); + } catch (error) { + userLogger.warn("[directMessagesStore] Invalid pubkey for clear", error); + return false; + } + + const { dbKey, fallbackKey } = keys; + let cleared = false; + + try { + const db = await openSettingsDb(); + if (db) { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const request = store.delete(dbKey); + request.onsuccess = () => resolve(); + request.onerror = () => + reject(request.error || new Error("Failed to clear snapshot")); + }); + cleared = true; + } + } catch (error) { + userLogger.warn( + "[directMessagesStore] Failed to clear IndexedDB snapshot", + error, + ); + } + + try { + if (typeof localStorage !== "undefined") { + localStorage.removeItem(fallbackKey); + cleared = true; + } + } catch (error) { + userLogger.warn( + "[directMessagesStore] Failed to clear fallback snapshot", + error, + ); + } + + return cleared; +} + +export function buildDirectMessageSnapshotPayload(entries) { + return normalizeSnapshotList(entries); +} diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 20cbe5e0..341f7251 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -4,6 +4,11 @@ import { accessControl } from "../accessControl.js"; import { ALLOW_NSFW_CONTENT } from "../config.js"; import { userLogger } from "../utils/logger.js"; import moderationService from "./moderationService.js"; +import { + loadDirectMessageSnapshot, + saveDirectMessageSnapshot, + clearDirectMessageSnapshot, +} from "../directMessagesStore.js"; import { getVideosMap as getStoredVideosMap, setVideosMap as setStoredVideosMap, @@ -99,6 +104,218 @@ function normalizeUntil(timestamp) { return seconds > 0 ? seconds : 0; } +function sanitizeSnapshotTimestamp(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return 0; + } + + const floored = Math.floor(numeric); + if (!Number.isFinite(floored) || floored <= 0) { + return 0; + } + + return floored; +} + +const SNAPSHOT_PREVIEW_LIMIT = 160; + +function sanitizeSnapshotPreview(value) { + if (typeof value !== "string") { + return ""; + } + + let preview = value.replace(/\s+/g, " ").trim(); + if (!preview) { + return ""; + } + + if (preview.length > SNAPSHOT_PREVIEW_LIMIT) { + preview = `${preview.slice(0, SNAPSHOT_PREVIEW_LIMIT).trimEnd()}\u2026`; + } + + return preview; +} + +function extractSnapshotPreview(message) { + if (!message || typeof message !== "object") { + return ""; + } + + const candidates = []; + if (typeof message.plaintext === "string") { + candidates.push(message.plaintext); + } + if (typeof message.preview === "string") { + candidates.push(message.preview); + } + if (message.message && typeof message.message.content === "string") { + candidates.push(message.message.content); + } + + const selected = candidates.find((candidate) => candidate && candidate.trim()); + return sanitizeSnapshotPreview(selected || ""); +} + +function resolveDirectMessageRemotePubkey(message, actorPubkey = "") { + const normalizedActor = normalizeHexPubkey(actorPubkey); + + const directRemote = + typeof message?.remotePubkey === "string" + ? normalizeHexPubkey(message.remotePubkey) + : ""; + if (directRemote && directRemote !== normalizedActor) { + return directRemote; + } + + const snapshotRemote = + typeof message?.snapshot?.remotePubkey === "string" + ? normalizeHexPubkey(message.snapshot.remotePubkey) + : ""; + if (snapshotRemote && snapshotRemote !== normalizedActor) { + return snapshotRemote; + } + + const direction = + typeof message?.direction === "string" + ? message.direction.toLowerCase() + : ""; + + const senderHex = + typeof message?.sender?.pubkey === "string" + ? normalizeHexPubkey(message.sender.pubkey) + : ""; + + if (direction === "incoming" && senderHex && senderHex !== normalizedActor) { + return senderHex; + } + + if (Array.isArray(message?.recipients)) { + for (const recipient of message.recipients) { + const candidate = + recipient && typeof recipient.pubkey === "string" + ? normalizeHexPubkey(recipient.pubkey) + : ""; + if (candidate && candidate !== normalizedActor) { + return candidate; + } + } + } + + if (direction === "outgoing" && senderHex && senderHex !== normalizedActor) { + return senderHex; + } + + const messagePubkey = + typeof message?.message?.pubkey === "string" + ? normalizeHexPubkey(message.message.pubkey) + : ""; + if (messagePubkey && messagePubkey !== normalizedActor) { + return messagePubkey; + } + + const eventPubkey = + typeof message?.event?.pubkey === "string" + ? normalizeHexPubkey(message.event.pubkey) + : ""; + if (eventPubkey && eventPubkey !== normalizedActor) { + return eventPubkey; + } + + if (senderHex && senderHex !== normalizedActor) { + return senderHex; + } + + return ""; +} + +function buildSnapshotFromMessages(messages, actorPubkey = "") { + const normalizedActor = normalizeHexPubkey(actorPubkey); + const threadMap = new Map(); + + for (const message of Array.isArray(messages) ? messages : []) { + if (!message || message.ok !== true) { + continue; + } + + const remote = resolveDirectMessageRemotePubkey(message, normalizedActor); + if (!remote) { + continue; + } + + const timestamp = sanitizeSnapshotTimestamp(message.timestamp); + const preview = extractSnapshotPreview(message); + + const existing = threadMap.get(remote); + if (!existing || timestamp > existing.latestTimestamp) { + threadMap.set(remote, { + remotePubkey: remote, + latestTimestamp: timestamp, + preview, + }); + } + } + + return Array.from(threadMap.values()).sort( + (a, b) => (b.latestTimestamp || 0) - (a.latestTimestamp || 0), + ); +} + +function hydrateMessagesFromSnapshot(snapshot, actorPubkey = "") { + const normalizedActor = normalizeHexPubkey(actorPubkey); + const hydrated = []; + + const entries = Array.isArray(snapshot) ? snapshot : []; + entries.sort( + (a, b) => (b?.latestTimestamp || 0) - (a?.latestTimestamp || 0), + ); + + entries.forEach((entry, index) => { + const remote = normalizeHexPubkey(entry?.remotePubkey || entry?.remote); + if (!remote) { + return; + } + + const timestamp = sanitizeSnapshotTimestamp(entry?.latestTimestamp); + const preview = sanitizeSnapshotPreview(entry?.preview || ""); + + hydrated.push({ + ok: true, + timestamp, + plaintext: preview || null, + preview: preview || "", + actorPubkey: normalizedActor, + remotePubkey: remote, + sender: remote + ? { + pubkey: remote, + relayHints: [], + role: "snapshot", + } + : null, + recipients: [], + direction: "unknown", + scheme: "", + decryptor: { scheme: "", source: "snapshot" }, + event: { + id: `dm-snapshot:${normalizedActor || "actor"}:${remote}:${timestamp}:${index}`, + }, + message: preview + ? { + content: preview, + } + : null, + snapshot: { + remotePubkey: remote, + timestamp, + preview, + }, + }); + }); + + return hydrated; +} + export class NostrService { constructor({ logger } = {}) { this.nostrClient = nostrClient; @@ -121,6 +338,8 @@ export class NostrService { this.dmMessages = []; this.dmMessageIndex = new Map(); this.dmSubscription = null; + this.dmActorPubkey = null; + this.dmHydratedFromSnapshot = false; try { if (this.moderationService && typeof this.moderationService.setNostrClient === "function") { this.moderationService.setNostrClient(this.nostrClient); @@ -206,9 +425,21 @@ export class NostrService { return Array.isArray(this.dmMessages) ? [...this.dmMessages] : []; } - clearDirectMessages({ emit = true } = {}) { + clearDirectMessages({ actorPubkey = null, emit = true } = {}) { this.dmMessages = []; this.dmMessageIndex = new Map(); + this.dmHydratedFromSnapshot = false; + const activeActor = normalizeHexPubkey( + typeof actorPubkey === "string" && actorPubkey + ? actorPubkey + : this.dmActorPubkey || this.resolveActiveDmActor(), + ); + if (activeActor) { + clearDirectMessageSnapshot(activeActor).catch((error) => { + userLogger.warn("[nostrService] Failed to clear DM snapshot", error); + }); + } + this.dmActorPubkey = null; if (typeof this.nostrClient?.clearDmDecryptCache === "function") { try { this.nostrClient.clearDmDecryptCache(); @@ -245,6 +476,19 @@ export class NostrService { }; this.dmMessageIndex.set(eventId, normalized); + + const actor = normalizeHexPubkey( + normalized?.actorPubkey || this.dmActorPubkey || this.resolveActiveDmActor(), + ); + const remote = resolveDirectMessageRemotePubkey(normalized, actor); + + if (remote) { + this.dmMessages = this.dmMessages.filter( + (entry) => + !entry?.snapshot || entry.snapshot.remotePubkey !== remote, + ); + } + const existingIndex = this.dmMessages.findIndex( (entry) => entry?.event?.id === eventId, ); @@ -255,12 +499,82 @@ export class NostrService { } this.dmMessages.sort((a, b) => (b?.timestamp || 0) - (a?.timestamp || 0)); + if (actor) { + this.dmActorPubkey = actor; + } + this.dmHydratedFromSnapshot = false; this.emit("directMessages:message", { message: normalized, reason, event }); this.emit("directMessages:updated", { messages: this.getDirectMessages(), reason, }); + + if (actor) { + this.persistDirectMessageSnapshot(actor).catch((error) => { + userLogger.warn( + "[nostrService] Failed to persist DM snapshot after update", + error, + ); + }); + } + } + + async hydrateDirectMessagesFromStore({ + actorPubkey = null, + emit = false, + force = false, + } = {}) { + const activeActor = normalizeHexPubkey( + typeof actorPubkey === "string" && actorPubkey + ? actorPubkey + : this.resolveActiveDmActor(), + ); + + if (!activeActor) { + return []; + } + + let stored = []; + try { + stored = await loadDirectMessageSnapshot(activeActor); + } catch (error) { + userLogger.warn("[nostrService] Failed to load DM snapshot", error); + return []; + } + + const hydrated = hydrateMessagesFromSnapshot(stored, activeActor); + + const shouldReplace = + force || + !Array.isArray(this.dmMessages) || + !this.dmMessages.length || + this.dmHydratedFromSnapshot; + + this.dmActorPubkey = activeActor; + + if (!shouldReplace) { + return this.getDirectMessages(); + } + + this.dmMessages = hydrated; + this.dmMessageIndex = new Map(); + this.dmHydratedFromSnapshot = hydrated.length > 0; + + const snapshot = this.getDirectMessages(); + + if (emit) { + this.emit("directMessages:hydrated", { + messages: snapshot, + actorPubkey: activeActor, + }); + this.emit("directMessages:updated", { + messages: snapshot, + reason: "snapshot", + }); + } + + return snapshot; } async loadDirectMessages({ actorPubkey, relays, ...options } = {}) { @@ -270,6 +584,17 @@ export class NostrService { return []; } + const forceHydration = + this.dmActorPubkey && this.dmActorPubkey !== normalizedActor; + + await this.hydrateDirectMessagesFromStore({ + actorPubkey: normalizedActor, + emit: true, + force: forceHydration, + }); + + this.dmActorPubkey = normalizedActor; + let messages = []; try { messages = await this.nostrClient.listDirectMessages(normalizedActor, { @@ -278,7 +603,7 @@ export class NostrService { }); } catch (error) { userLogger.warn("[nostrService] Failed to load direct messages", error); - return []; + return this.getDirectMessages(); } const collected = []; @@ -316,6 +641,8 @@ export class NostrService { const snapshot = this.getDirectMessages(); + this.dmHydratedFromSnapshot = false; + this.emit("directMessages:loaded", { messages: snapshot, actorPubkey: normalizedActor, @@ -325,6 +652,15 @@ export class NostrService { reason: "load", }); + try { + await this.persistDirectMessageSnapshot(normalizedActor, snapshot); + } catch (error) { + userLogger.warn( + "[nostrService] Failed to persist DM snapshot after load", + error, + ); + } + return snapshot; } @@ -409,6 +745,7 @@ export class NostrService { ); this.dmSubscription = subscription; + this.dmActorPubkey = normalizedActor; this.emit("directMessages:subscribed", { subscription }); return subscription; } catch (error) { @@ -434,6 +771,31 @@ export class NostrService { this.dmSubscription = null; } + async persistDirectMessageSnapshot(actorPubkey = null, messages = null) { + const normalizedActor = normalizeHexPubkey( + typeof actorPubkey === "string" && actorPubkey + ? actorPubkey + : this.dmActorPubkey || this.resolveActiveDmActor(), + ); + + if (!normalizedActor) { + return []; + } + + const sourceMessages = Array.isArray(messages) ? messages : this.dmMessages; + const snapshotPayload = buildSnapshotFromMessages( + sourceMessages, + normalizedActor, + ); + + if (!snapshotPayload.length) { + await clearDirectMessageSnapshot(normalizedActor); + return snapshotPayload; + } + + return saveDirectMessageSnapshot(normalizedActor, snapshotPayload); + } + getModerationService() { if (!this.moderationService) { return null; diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index fb34a9e7..c1fc1bac 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -1543,6 +1543,29 @@ export class ProfileModalController { if (actor) { this.directMessagesLastActor = actor; } + + if ( + this.nostrService && + typeof this.nostrService.hydrateDirectMessagesFromStore === "function" + ) { + void this.nostrService + .hydrateDirectMessagesFromStore({ emit: true }) + .then((messages) => { + if (Array.isArray(messages)) { + this.directMessagesCache = messages; + const active = this.resolveActiveDmActor(); + if (active) { + this.directMessagesLastActor = active; + } + } + }) + .catch((error) => { + devLogger.warn( + "[profileModal] Failed to hydrate cached direct messages:", + error, + ); + }); + } } teardownDirectMessagesService() { @@ -1875,6 +1898,16 @@ export class ProfileModalController { if (typeof entry.plaintext === "string") { candidates.push(entry.plaintext); } + if (typeof entry.preview === "string") { + candidates.push(entry.preview); + } + if ( + entry.snapshot && + typeof entry.snapshot.preview === "string" && + entry.snapshot.preview.trim() + ) { + candidates.push(entry.snapshot.preview); + } if (entry.message && typeof entry.message.content === "string") { candidates.push(entry.message.content); } @@ -1991,6 +2024,20 @@ export class ProfileModalController { ? this.normalizeHexPubkey(actorPubkey) : this.resolveActiveDmActor(); + if (typeof entry.remotePubkey === "string") { + const directRemote = this.normalizeHexPubkey(entry.remotePubkey); + if (directRemote && directRemote !== normalizedActor) { + return directRemote; + } + } + + if (entry.snapshot && typeof entry.snapshot.remotePubkey === "string") { + const snapshotRemote = this.normalizeHexPubkey(entry.snapshot.remotePubkey); + if (snapshotRemote && snapshotRemote !== normalizedActor) { + return snapshotRemote; + } + } + const direction = typeof entry.direction === "string" ? entry.direction.toLowerCase() : "";