Merge pull request #953 from PR0M3TH3AN/codex/add-dm-snapshot-storage-helper

Cache DM thread snapshots for faster profile modal loads
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-26 20:32:10 -04:00
commit 419088d523
3 files changed
+738 -2

No files matched your search

+327
View File
@@ -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);
}
+364 -2
View File
@@ -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;
+47
View File
@@ -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() : "";