mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-10 06:43:00 +00:00
update
This commit is contained in:
1 parent
281a6511f1
commit
42edebcf6d
45 files changed
+8446
-1010
No files matched your search
@@ -567,6 +567,72 @@ export class ModerationService {
|
||||
this.reconcileTrustedMuteSubscriptions(previousContacts, seededNext);
|
||||
}
|
||||
|
||||
getViewerContactsSnapshot({ includeViewer = false } = {}) {
|
||||
const contacts =
|
||||
this.viewerContacts instanceof Set ? this.viewerContacts : new Set();
|
||||
const viewer = normalizeHex(this.viewerPubkey);
|
||||
const list = [];
|
||||
|
||||
for (const value of contacts) {
|
||||
const normalized = normalizeHex(value);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (!includeViewer && viewer && normalized === viewer) {
|
||||
continue;
|
||||
}
|
||||
list.push({
|
||||
pubkey: normalized,
|
||||
npub: encodeToNpub(normalized) || "",
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
async ensureViewerContactsLoaded(pubkey = null) {
|
||||
const target = normalizeHex(pubkey);
|
||||
const viewer = normalizeHex(this.viewerPubkey);
|
||||
|
||||
if (target && target !== viewer) {
|
||||
try {
|
||||
await this.setViewerPubkey(target);
|
||||
} catch (error) {
|
||||
this.log(
|
||||
"[moderationService] failed to set viewer pubkey while ensuring contacts",
|
||||
error,
|
||||
);
|
||||
}
|
||||
return this.getViewerContactsSnapshot();
|
||||
}
|
||||
|
||||
if (this.contactListPromise) {
|
||||
try {
|
||||
await this.contactListPromise;
|
||||
} catch (error) {
|
||||
this.log(
|
||||
"[moderationService] contact hydration failed while ensuring contacts",
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else if (viewer) {
|
||||
const hasContacts =
|
||||
this.viewerContacts instanceof Set && this.viewerContacts.size > 0;
|
||||
if (!hasContacts) {
|
||||
try {
|
||||
await this.fetchTrustedContacts(viewer);
|
||||
} catch (error) {
|
||||
this.log(
|
||||
"[moderationService] failed to fetch viewer contacts while ensuring contacts",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.getViewerContactsSnapshot();
|
||||
}
|
||||
|
||||
setTrustedSeeds(seeds = []) {
|
||||
const sanitizedSeeds = new Set();
|
||||
|
||||
|
||||
+622
-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;
|
||||
@@ -118,6 +335,11 @@ export class NostrService {
|
||||
this.initialLoadPromise = null;
|
||||
this.initialLoadResolved = false;
|
||||
this.initialLoadResolve = null;
|
||||
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);
|
||||
@@ -180,6 +402,400 @@ export class NostrService {
|
||||
return this.ensureInitialLoadDeferred();
|
||||
}
|
||||
|
||||
resolveActiveDmActor() {
|
||||
if (
|
||||
this.nostrClient &&
|
||||
typeof this.nostrClient.pubkey === "string" &&
|
||||
this.nostrClient.pubkey
|
||||
) {
|
||||
return this.nostrClient.pubkey;
|
||||
}
|
||||
|
||||
if (
|
||||
this.nostrClient?.sessionActor &&
|
||||
typeof this.nostrClient.sessionActor.pubkey === "string"
|
||||
) {
|
||||
return this.nostrClient.sessionActor.pubkey;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
getDirectMessages() {
|
||||
return Array.isArray(this.dmMessages) ? [...this.dmMessages] : [];
|
||||
}
|
||||
|
||||
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();
|
||||
} catch (error) {
|
||||
userLogger.warn("[nostrService] Failed to clear DM decrypt cache", error);
|
||||
}
|
||||
}
|
||||
if (emit) {
|
||||
this.emit("directMessages:cleared", {});
|
||||
this.emit("directMessages:updated", { messages: [] });
|
||||
}
|
||||
}
|
||||
|
||||
applyDirectMessage(message, { reason = "update", event = null } = {}) {
|
||||
if (!message || message.ok !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventId = message?.event?.id;
|
||||
if (typeof eventId !== "string" || !eventId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
...message,
|
||||
timestamp:
|
||||
Number.isFinite(message?.timestamp)
|
||||
? message.timestamp
|
||||
: Number.isFinite(message?.message?.created_at)
|
||||
? message.message.created_at
|
||||
: Number.isFinite(message?.event?.created_at)
|
||||
? message.event.created_at
|
||||
: Date.now() / 1000,
|
||||
};
|
||||
|
||||
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,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
this.dmMessages[existingIndex] = normalized;
|
||||
} else {
|
||||
this.dmMessages.push(normalized);
|
||||
}
|
||||
|
||||
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 } = {}) {
|
||||
const activeActor = actorPubkey || this.resolveActiveDmActor();
|
||||
const normalizedActor = normalizeHexPubkey(activeActor);
|
||||
if (!normalizedActor) {
|
||||
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, {
|
||||
relays,
|
||||
...options,
|
||||
});
|
||||
} catch (error) {
|
||||
userLogger.warn("[nostrService] Failed to load direct messages", error);
|
||||
return this.getDirectMessages();
|
||||
}
|
||||
|
||||
const collected = [];
|
||||
const index = new Map();
|
||||
|
||||
for (const message of Array.isArray(messages) ? messages : []) {
|
||||
if (!message || message.ok !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const eventId = message?.event?.id;
|
||||
if (typeof eventId !== "string" || !eventId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
...message,
|
||||
timestamp:
|
||||
Number.isFinite(message?.timestamp)
|
||||
? message.timestamp
|
||||
: Number.isFinite(message?.message?.created_at)
|
||||
? message.message.created_at
|
||||
: Number.isFinite(message?.event?.created_at)
|
||||
? message.event.created_at
|
||||
: Date.now() / 1000,
|
||||
};
|
||||
|
||||
index.set(eventId, normalized);
|
||||
collected.push(normalized);
|
||||
}
|
||||
|
||||
collected.sort((a, b) => (b?.timestamp || 0) - (a?.timestamp || 0));
|
||||
this.dmMessageIndex = index;
|
||||
this.dmMessages = collected;
|
||||
|
||||
const snapshot = this.getDirectMessages();
|
||||
|
||||
this.dmHydratedFromSnapshot = false;
|
||||
|
||||
this.emit("directMessages:loaded", {
|
||||
messages: snapshot,
|
||||
actorPubkey: normalizedActor,
|
||||
});
|
||||
this.emit("directMessages:updated", {
|
||||
messages: snapshot,
|
||||
reason: "load",
|
||||
});
|
||||
|
||||
try {
|
||||
await this.persistDirectMessageSnapshot(normalizedActor, snapshot);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostrService] Failed to persist DM snapshot after load",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
ensureDirectMessageSubscription({ actorPubkey, relays, ...handlers } = {}) {
|
||||
if (this.dmSubscription) {
|
||||
return this.dmSubscription;
|
||||
}
|
||||
|
||||
const activeActor = actorPubkey || this.resolveActiveDmActor();
|
||||
const normalizedActor = normalizeHexPubkey(activeActor);
|
||||
if (!normalizedActor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = this.nostrClient.subscribeDirectMessages(
|
||||
normalizedActor,
|
||||
{
|
||||
relays,
|
||||
onEvent: handlers.onEvent,
|
||||
onMessage: (message, context = {}) => {
|
||||
this.applyDirectMessage(message, {
|
||||
reason: "subscription",
|
||||
event: context.event || null,
|
||||
});
|
||||
if (typeof handlers.onMessage === "function") {
|
||||
try {
|
||||
handlers.onMessage(message, context);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostrService] DM onMessage handler threw",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onFailure: (payload, context) => {
|
||||
this.emit("directMessages:failure", { failure: payload, context });
|
||||
if (typeof handlers.onFailure === "function") {
|
||||
try {
|
||||
handlers.onFailure(payload, context);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostrService] DM onFailure handler threw",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error, context) => {
|
||||
this.emit("directMessages:error", { error, context });
|
||||
if (typeof handlers.onError === "function") {
|
||||
try {
|
||||
handlers.onError(error, context);
|
||||
} catch (handlerError) {
|
||||
userLogger.warn(
|
||||
"[nostrService] DM onError handler threw",
|
||||
handlerError,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
userLogger.warn(
|
||||
"[nostrService] Direct message subscription error",
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
onEose: () => {
|
||||
this.emit("directMessages:eose", { actorPubkey: normalizedActor });
|
||||
if (typeof handlers.onEose === "function") {
|
||||
try {
|
||||
handlers.onEose();
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostrService] DM onEose handler threw",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.dmSubscription = subscription;
|
||||
this.dmActorPubkey = normalizedActor;
|
||||
this.emit("directMessages:subscribed", { subscription });
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostrService] Failed to subscribe to direct messages",
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
stopDirectMessageSubscription() {
|
||||
if (this.dmSubscription && typeof this.dmSubscription.unsub === "function") {
|
||||
try {
|
||||
this.dmSubscription.unsub();
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostrService] Failed to unsubscribe from direct messages",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -328,7 +944,11 @@ export class NostrService {
|
||||
|
||||
const viewerIsAuthor = this.isViewerVideoAuthor(video);
|
||||
|
||||
if (ALLOW_NSFW_CONTENT !== true && video.isNsfw === true && !viewerIsAuthor) {
|
||||
if (viewerIsAuthor) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ALLOW_NSFW_CONTENT !== true && video.isNsfw === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -346,7 +966,7 @@ export class NostrService {
|
||||
}
|
||||
}
|
||||
|
||||
if (video.isPrivate === true && !viewerIsAuthor) {
|
||||
if (video.isPrivate === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+33
-24
@@ -22,6 +22,11 @@ import {
|
||||
} from "../storage/r2-s3.js";
|
||||
import { truncateMiddle } from "../utils/formatters.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
import {
|
||||
getVideoNoteErrorMessage,
|
||||
normalizeVideoNotePayload,
|
||||
VIDEO_NOTE_ERROR_CODES,
|
||||
} from "./videoNotePayload.js";
|
||||
|
||||
const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]);
|
||||
|
||||
@@ -654,9 +659,17 @@ class R2Service {
|
||||
return false;
|
||||
}
|
||||
|
||||
const title = String(metadata.title || "").trim();
|
||||
const rawTitleCandidate =
|
||||
metadata && typeof metadata === "object" ? metadata.title : metadata;
|
||||
const title = typeof rawTitleCandidate === "string"
|
||||
? rawTitleCandidate.trim()
|
||||
: String(rawTitleCandidate ?? "").trim();
|
||||
|
||||
if (!title) {
|
||||
this.setCloudflareUploadStatus("Title is required.", "error");
|
||||
this.setCloudflareUploadStatus(
|
||||
getVideoNoteErrorMessage(VIDEO_NOTE_ERROR_CODES.MISSING_TITLE),
|
||||
"error",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -668,18 +681,6 @@ class R2Service {
|
||||
return false;
|
||||
}
|
||||
|
||||
const description = String(metadata.description || "").trim();
|
||||
const thumbnail = String(metadata.thumbnail || "").trim();
|
||||
const magnet = String(metadata.magnet || "").trim();
|
||||
const ws = String(metadata.ws || "").trim();
|
||||
const xs = String(metadata.xs || "").trim();
|
||||
const enableComments = metadata.enableComments !== false;
|
||||
const isNsfw = metadata.isNsfw === true;
|
||||
const isForKids = metadata.isForKids === true && !isNsfw;
|
||||
|
||||
metadata.isNsfw = isNsfw;
|
||||
metadata.isForKids = isForKids;
|
||||
|
||||
const accountId = (this.cloudflareSettings?.accountId || "").trim();
|
||||
const accessKeyId = (this.cloudflareSettings?.accessKeyId || "").trim();
|
||||
const secretAccessKey = (this.cloudflareSettings?.secretAccessKey || "").trim();
|
||||
@@ -766,17 +767,17 @@ class R2Service {
|
||||
);
|
||||
publishOutcome = false;
|
||||
} else {
|
||||
const payload = {
|
||||
const rawVideoPayload = {
|
||||
title,
|
||||
url: publicUrl,
|
||||
magnet,
|
||||
thumbnail,
|
||||
description,
|
||||
ws,
|
||||
xs,
|
||||
enableComments,
|
||||
isNsfw,
|
||||
isForKids,
|
||||
magnet: metadata?.magnet ?? "",
|
||||
thumbnail: metadata?.thumbnail ?? "",
|
||||
description: metadata?.description ?? "",
|
||||
ws: metadata?.ws ?? "",
|
||||
xs: metadata?.xs ?? "",
|
||||
enableComments: metadata?.enableComments,
|
||||
isNsfw: metadata?.isNsfw,
|
||||
isForKids: metadata?.isForKids,
|
||||
};
|
||||
|
||||
const mergedNip71 = this.buildNip71MetadataForUpload(metadata?.nip71, {
|
||||
@@ -784,7 +785,15 @@ class R2Service {
|
||||
file,
|
||||
});
|
||||
if (mergedNip71 && Object.keys(mergedNip71).length) {
|
||||
payload.nip71 = mergedNip71;
|
||||
rawVideoPayload.nip71 = mergedNip71;
|
||||
}
|
||||
|
||||
const { payload, errors } = normalizeVideoNotePayload(rawVideoPayload);
|
||||
|
||||
if (errors.length) {
|
||||
const message = getVideoNoteErrorMessage(errors[0]);
|
||||
this.setCloudflareUploadStatus(message, "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
const published = await publishVideoNote(payload, {
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
import { extractMagnetHints, normalizeAndAugmentMagnet } from "../magnet.js";
|
||||
|
||||
export const VIDEO_NOTE_ERROR_CODES = {
|
||||
MISSING_TITLE: "missing_title",
|
||||
MISSING_SOURCE: "missing_source",
|
||||
INVALID_URL_PROTOCOL: "invalid_url_protocol",
|
||||
};
|
||||
|
||||
export const VIDEO_NOTE_ERROR_MESSAGES = {
|
||||
[VIDEO_NOTE_ERROR_CODES.MISSING_TITLE]: "Title is required.",
|
||||
[VIDEO_NOTE_ERROR_CODES.MISSING_SOURCE]:
|
||||
"Provide a hosted URL, magnet link, or an imeta variant before publishing.",
|
||||
[VIDEO_NOTE_ERROR_CODES.INVALID_URL_PROTOCOL]:
|
||||
"Hosted video URLs must use HTTPS.",
|
||||
};
|
||||
|
||||
export function getVideoNoteErrorMessage(code) {
|
||||
return VIDEO_NOTE_ERROR_MESSAGES[code] || "Unable to submit video. Please try again.";
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? String(value).trim() : "";
|
||||
}
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function parseNumberOrNull(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseUnixSeconds(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
const normalized = value > 1e12 ? value / 1000 : value;
|
||||
return Math.floor(normalized);
|
||||
}
|
||||
const trimmed = normalizeString(value);
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const numeric = Number(trimmed);
|
||||
if (Number.isFinite(numeric)) {
|
||||
const normalized = numeric > 1e12 ? numeric / 1000 : numeric;
|
||||
return Math.floor(normalized);
|
||||
}
|
||||
const timestamp = Date.parse(trimmed);
|
||||
if (!Number.isNaN(timestamp)) {
|
||||
return Math.floor(timestamp / 1000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeStringArray(input) {
|
||||
if (!Array.isArray(input)) {
|
||||
return [];
|
||||
}
|
||||
return input
|
||||
.map((entry) => normalizeString(entry))
|
||||
.filter((entry) => Boolean(entry));
|
||||
}
|
||||
|
||||
function normalizeBooleanFlag(value, defaultValue = false) {
|
||||
if (value === true) {
|
||||
return true;
|
||||
}
|
||||
if (value === false) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(defaultValue);
|
||||
}
|
||||
|
||||
function normalizeImetaVariant(variant) {
|
||||
if (!variant || typeof variant !== "object") {
|
||||
return null;
|
||||
}
|
||||
const normalized = {};
|
||||
const m = normalizeString(variant.m);
|
||||
if (m) normalized.m = m;
|
||||
const dim = normalizeString(variant.dim);
|
||||
if (dim) normalized.dim = dim;
|
||||
const url = normalizeString(variant.url);
|
||||
if (url) normalized.url = url;
|
||||
const x = normalizeString(variant.x);
|
||||
if (x) normalized.x = x;
|
||||
const image = normalizeStringArray(variant.image);
|
||||
if (image.length) normalized.image = image;
|
||||
const fallback = normalizeStringArray(variant.fallback);
|
||||
if (fallback.length) normalized.fallback = fallback;
|
||||
const service = normalizeStringArray(variant.service);
|
||||
if (service.length) normalized.service = service;
|
||||
if (variant.autoGenerated === true) {
|
||||
normalized.autoGenerated = true;
|
||||
}
|
||||
|
||||
return Object.keys(normalized).length ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeSegments(segments) {
|
||||
if (!Array.isArray(segments)) {
|
||||
return [];
|
||||
}
|
||||
return segments
|
||||
.map((segment) => {
|
||||
if (!segment || typeof segment !== "object") {
|
||||
return null;
|
||||
}
|
||||
const startRaw = segment.start;
|
||||
const endRaw = segment.end;
|
||||
const start = parseNumberOrNull(startRaw);
|
||||
const end = parseNumberOrNull(endRaw);
|
||||
const normalizedSegment = {};
|
||||
if (start !== null) {
|
||||
normalizedSegment.start = start;
|
||||
} else {
|
||||
const startString = normalizeString(startRaw);
|
||||
if (startString) {
|
||||
normalizedSegment.start = startString;
|
||||
}
|
||||
}
|
||||
if (end !== null) {
|
||||
normalizedSegment.end = end;
|
||||
} else {
|
||||
const endString = normalizeString(endRaw);
|
||||
if (endString) {
|
||||
normalizedSegment.end = endString;
|
||||
}
|
||||
}
|
||||
const title = normalizeString(segment.title);
|
||||
if (title) {
|
||||
normalizedSegment.title = title;
|
||||
}
|
||||
const thumbnail = normalizeString(segment.thumbnail);
|
||||
if (thumbnail) {
|
||||
normalizedSegment.thumbnail = thumbnail;
|
||||
}
|
||||
return Object.keys(normalizedSegment).length ? normalizedSegment : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeTextTracks(tracks) {
|
||||
if (!Array.isArray(tracks)) {
|
||||
return [];
|
||||
}
|
||||
return tracks
|
||||
.map((track) => {
|
||||
if (!track || typeof track !== "object") {
|
||||
return null;
|
||||
}
|
||||
const normalizedTrack = {};
|
||||
const url = normalizeString(track.url);
|
||||
if (url) normalizedTrack.url = url;
|
||||
const type = normalizeString(track.type);
|
||||
if (type) normalizedTrack.type = type;
|
||||
const language = normalizeString(track.language);
|
||||
if (language) normalizedTrack.language = language;
|
||||
return Object.keys(normalizedTrack).length ? normalizedTrack : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeParticipants(participants) {
|
||||
if (!Array.isArray(participants)) {
|
||||
return [];
|
||||
}
|
||||
return participants
|
||||
.map((participant) => {
|
||||
if (!participant || typeof participant !== "object") {
|
||||
return null;
|
||||
}
|
||||
const normalizedParticipant = {};
|
||||
const pubkey = normalizeString(participant.pubkey);
|
||||
if (pubkey) normalizedParticipant.pubkey = pubkey;
|
||||
const relay = normalizeString(participant.relay);
|
||||
if (relay) normalizedParticipant.relay = relay;
|
||||
return Object.keys(normalizedParticipant).length
|
||||
? normalizedParticipant
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeNip71Metadata(rawMetadata) {
|
||||
if (!rawMetadata || typeof rawMetadata !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = {};
|
||||
|
||||
if (rawMetadata.kind !== undefined) {
|
||||
const kindNumber = parseNumberOrNull(rawMetadata.kind);
|
||||
if (kindNumber !== null) {
|
||||
normalized.kind = kindNumber;
|
||||
} else {
|
||||
const kindString = normalizeString(rawMetadata.kind);
|
||||
if (kindString) {
|
||||
normalized.kind = kindString;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summary = normalizeString(rawMetadata.summary);
|
||||
if (summary) {
|
||||
normalized.summary = summary;
|
||||
}
|
||||
|
||||
const publishedAt = parseUnixSeconds(rawMetadata.publishedAt);
|
||||
if (publishedAt !== null) {
|
||||
normalized.publishedAt = publishedAt;
|
||||
}
|
||||
|
||||
const alt = normalizeString(rawMetadata.alt);
|
||||
if (alt) {
|
||||
normalized.alt = alt;
|
||||
}
|
||||
|
||||
const duration = parseNumberOrNull(rawMetadata.duration);
|
||||
if (duration !== null) {
|
||||
normalized.duration = duration;
|
||||
}
|
||||
|
||||
const contentWarning = normalizeString(rawMetadata.contentWarning);
|
||||
if (contentWarning) {
|
||||
normalized.contentWarning = contentWarning;
|
||||
}
|
||||
|
||||
const imeta = Array.isArray(rawMetadata.imeta)
|
||||
? rawMetadata.imeta
|
||||
.map((variant) => normalizeImetaVariant(variant))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
if (imeta.length) {
|
||||
normalized.imeta = imeta;
|
||||
}
|
||||
|
||||
const textTracks = normalizeTextTracks(rawMetadata.textTracks);
|
||||
if (textTracks.length) {
|
||||
normalized.textTracks = textTracks;
|
||||
}
|
||||
|
||||
const segments = normalizeSegments(rawMetadata.segments);
|
||||
if (segments.length) {
|
||||
normalized.segments = segments;
|
||||
}
|
||||
|
||||
const hashtags = normalizeStringArray(rawMetadata.hashtags);
|
||||
if (hashtags.length) {
|
||||
normalized.hashtags = hashtags;
|
||||
}
|
||||
|
||||
const participants = normalizeParticipants(rawMetadata.participants);
|
||||
if (participants.length) {
|
||||
normalized.participants = participants;
|
||||
}
|
||||
|
||||
const references = normalizeStringArray(rawMetadata.references);
|
||||
if (references.length) {
|
||||
normalized.references = references;
|
||||
}
|
||||
|
||||
return Object.keys(normalized).length ? normalized : null;
|
||||
}
|
||||
|
||||
export function normalizeVideoNotePayload(input) {
|
||||
const rawPayload = input && typeof input === "object" ? input : {};
|
||||
const legacyPayload =
|
||||
rawPayload.legacyFormData && typeof rawPayload.legacyFormData === "object"
|
||||
? rawPayload.legacyFormData
|
||||
: rawPayload;
|
||||
|
||||
const rawNip71 =
|
||||
(rawPayload.nip71 && typeof rawPayload.nip71 === "object"
|
||||
? rawPayload.nip71
|
||||
: null) ||
|
||||
(legacyPayload.nip71 && typeof legacyPayload.nip71 === "object"
|
||||
? legacyPayload.nip71
|
||||
: null);
|
||||
|
||||
const title = normalizeString(legacyPayload?.title || "");
|
||||
const url = normalizeString(legacyPayload?.url || "");
|
||||
const magnet = normalizeString(legacyPayload?.magnet || "");
|
||||
const thumbnail = normalizeString(legacyPayload?.thumbnail || "");
|
||||
const description = normalizeString(legacyPayload?.description || "");
|
||||
const ws = normalizeString(legacyPayload?.ws || "");
|
||||
const xs = normalizeString(legacyPayload?.xs || "");
|
||||
const rawMode = normalizeString(legacyPayload?.mode || "");
|
||||
const normalizedMode = rawMode && rawMode.toLowerCase() === "dev" ? "dev" : "live";
|
||||
const enableComments = normalizeBooleanFlag(legacyPayload?.enableComments, true);
|
||||
const rawIsNsfw = normalizeBooleanFlag(legacyPayload?.isNsfw, false);
|
||||
const rawIsForKids = normalizeBooleanFlag(legacyPayload?.isForKids, false);
|
||||
const isNsfw = rawIsNsfw;
|
||||
const isForKids = rawIsNsfw ? false : rawIsForKids;
|
||||
const isPrivate = normalizeBooleanFlag(legacyPayload?.isPrivate, false);
|
||||
|
||||
const legacyFormData = {
|
||||
version: 3,
|
||||
title,
|
||||
url,
|
||||
magnet,
|
||||
thumbnail,
|
||||
description,
|
||||
mode: normalizedMode,
|
||||
enableComments,
|
||||
isNsfw,
|
||||
isForKids,
|
||||
};
|
||||
|
||||
if (isPrivate) {
|
||||
legacyFormData.isPrivate = true;
|
||||
}
|
||||
|
||||
const normalizedNip71 = normalizeNip71Metadata(rawNip71);
|
||||
|
||||
const hasLegacySource = Boolean(legacyFormData.url || legacyFormData.magnet);
|
||||
const hasImetaVariant = Boolean(
|
||||
normalizedNip71?.imeta?.some((variant) =>
|
||||
Boolean(
|
||||
(variant.url && variant.url.length) ||
|
||||
(variant.m && variant.m.length) ||
|
||||
(variant.x && variant.x.length) ||
|
||||
(variant.dim && variant.dim.length) ||
|
||||
(Array.isArray(variant.image) && variant.image.length > 0) ||
|
||||
(Array.isArray(variant.fallback) && variant.fallback.length > 0) ||
|
||||
(Array.isArray(variant.service) && variant.service.length > 0),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const errors = [];
|
||||
|
||||
if (!legacyFormData.title) {
|
||||
errors.push(VIDEO_NOTE_ERROR_CODES.MISSING_TITLE);
|
||||
}
|
||||
|
||||
if (!hasLegacySource && !hasImetaVariant) {
|
||||
errors.push(VIDEO_NOTE_ERROR_CODES.MISSING_SOURCE);
|
||||
}
|
||||
|
||||
if (legacyFormData.url && !/^https:\/\//i.test(legacyFormData.url)) {
|
||||
errors.push(VIDEO_NOTE_ERROR_CODES.INVALID_URL_PROTOCOL);
|
||||
}
|
||||
|
||||
if (legacyFormData.magnet) {
|
||||
const normalizedMagnet = normalizeAndAugmentMagnet(legacyFormData.magnet, {
|
||||
ws,
|
||||
xs,
|
||||
});
|
||||
legacyFormData.magnet = normalizedMagnet;
|
||||
const hints = extractMagnetHints(normalizedMagnet);
|
||||
legacyFormData.ws = hints.ws || "";
|
||||
legacyFormData.xs = hints.xs || "";
|
||||
} else {
|
||||
legacyFormData.ws = "";
|
||||
legacyFormData.xs = "";
|
||||
}
|
||||
|
||||
const publishPayload = {
|
||||
legacyFormData,
|
||||
};
|
||||
|
||||
if (normalizedNip71) {
|
||||
publishPayload.nip71 = normalizedNip71;
|
||||
}
|
||||
|
||||
return {
|
||||
payload: publishPayload,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user