mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 11:22:25 +00:00
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:
3 files changed
+738
-2
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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() : "";
|
||||
|
||||
|
||||
Reference in new issue
Block a user