This commit is contained in:
thePR0M3TH3AN committed 2025-10-26 21:50:09 -04:00
1 parent 281a6511f1
commit 42edebcf6d
45 files changed
+8446 -1010

No files matched your search

+66
View File
@@ -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
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;
@@ -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
View File
@@ -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, {
+379
View File
@@ -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,
};
}