Files
bitvid/js/reactionCounter.js
T

854 lines
21 KiB
JavaScript

import { nostrClient } from "./nostrClientFacade.js";
import { normalizePointerInput, pointerKey } from "./nostr/watchHistory.js";
import { devLogger, userLogger } from "./utils/logger.js";
const pointerStates = new Map();
const pointerHandlers = new Map();
const pointerHydrationPromises = new Map();
const pointerSubscriptions = new Map();
const REACTION_EVENT_KIND = 7;
const REACTION_HYDRATION_LIMIT = 400;
function clonePointerDescriptor(pointer) {
if (!pointer || typeof pointer !== "object") {
return null;
}
const descriptor = {
type: pointer.type === "a" ? "a" : "e",
value:
typeof pointer.value === "string" && pointer.value.trim()
? pointer.value.trim()
: ""
};
if (typeof pointer.relay === "string" && pointer.relay.trim()) {
descriptor.relay = pointer.relay.trim();
}
const eventId =
typeof pointer.eventId === "string" && pointer.eventId.trim()
? pointer.eventId.trim()
: typeof pointer.pointerEventId === "string" &&
pointer.pointerEventId.trim()
? pointer.pointerEventId.trim()
: "";
if (eventId) {
descriptor.eventId = eventId;
}
return descriptor;
}
function canonicalizePointerForState(pointerInput) {
const normalized = normalizePointerInput(pointerInput);
if (!normalized) {
return null;
}
const key = pointerKey(normalized);
if (!key) {
return null;
}
const descriptor = clonePointerDescriptor({
...normalized,
eventId:
typeof pointerInput?.eventId === "string" && pointerInput.eventId.trim()
? pointerInput.eventId
: typeof pointerInput?.pointerEventId === "string" &&
pointerInput.pointerEventId.trim()
? pointerInput.pointerEventId
: ""
});
return descriptor ? { key, pointer: descriptor } : null;
}
function ensurePointerState(key, pointer) {
let state = pointerStates.get(key);
if (!state) {
state = {
pointer: pointer ? clonePointerDescriptor(pointer) : null,
totals: new Map(),
reactionsByPubkey: new Map(),
lastUpdatedAt: 0,
latestCreatedAt: 0,
lastSyncedAt: 0,
hydrated: false
};
pointerStates.set(key, state);
return state;
}
if (pointer) {
const descriptor = clonePointerDescriptor(pointer);
if (!state.pointer) {
state.pointer = descriptor;
} else {
state.pointer = {
...state.pointer,
...(descriptor?.relay && !state.pointer.relay
? { relay: descriptor.relay }
: {}),
...(descriptor?.eventId && !state.pointer.eventId
? { eventId: descriptor.eventId }
: {})
};
}
}
return state;
}
function normalizeReactionEvent(event) {
if (!event || typeof event !== "object") {
return null;
}
const pubkey =
typeof event.pubkey === "string" && event.pubkey.trim()
? event.pubkey.trim().toLowerCase()
: "";
if (!pubkey) {
return null;
}
let content = "+";
if (typeof event.content === "string") {
content = event.content;
} else if (event.content !== undefined && event.content !== null) {
try {
content = String(event.content);
} catch (error) {
content = "+";
}
}
if (!content) {
content = "+";
}
const createdAt = Number.isFinite(event.created_at)
? Math.max(0, Math.floor(event.created_at))
: 0;
const eventId =
typeof event.id === "string" && event.id.trim() ? event.id.trim() : "";
return { pubkey, content, created_at: createdAt, eventId };
}
function incrementContentCount(state, content) {
if (typeof content !== "string") {
return;
}
const existing = state.totals.get(content) || 0;
state.totals.set(content, existing + 1);
}
function decrementContentCount(state, content) {
if (typeof content !== "string") {
return;
}
const existing = state.totals.get(content);
if (!existing) {
return;
}
if (existing <= 1) {
state.totals.delete(content);
} else {
state.totals.set(content, existing - 1);
}
}
function applyReactionToState(key, pointer, event) {
const normalized = normalizeReactionEvent(event);
if (!normalized) {
return false;
}
const state = ensurePointerState(key, pointer);
const existing = state.reactionsByPubkey.get(normalized.pubkey);
if (existing) {
if (existing.created_at > normalized.created_at) {
return false;
}
if (
existing.created_at === normalized.created_at &&
existing.eventId &&
normalized.eventId &&
existing.eventId === normalized.eventId &&
existing.content === normalized.content
) {
return false;
}
if (
existing.created_at === normalized.created_at &&
existing.eventId &&
!normalized.eventId &&
existing.content === normalized.content
) {
return false;
}
}
let changed = false;
if (!existing) {
incrementContentCount(state, normalized.content);
changed = true;
} else {
if (existing.content !== normalized.content) {
decrementContentCount(state, existing.content);
incrementContentCount(state, normalized.content);
changed = true;
}
}
if (
!existing ||
existing.created_at !== normalized.created_at ||
existing.eventId !== normalized.eventId ||
existing.content !== normalized.content
) {
state.reactionsByPubkey.set(normalized.pubkey, normalized);
state.lastUpdatedAt = Date.now();
if (Number.isFinite(normalized.created_at)) {
const created = Math.max(0, Math.floor(normalized.created_at));
if (
!Number.isFinite(state.latestCreatedAt) ||
created > state.latestCreatedAt
) {
state.latestCreatedAt = created;
}
}
if (!changed) {
changed = true;
}
}
return changed;
}
function snapshotState(state) {
const counts = {};
let total = 0;
for (const [content, count] of state.totals.entries()) {
counts[content] = count;
total += count;
}
const reactions = {};
for (const [pubkey, record] of state.reactionsByPubkey.entries()) {
reactions[pubkey] = {
content: record.content,
created_at: record.created_at,
eventId: record.eventId
};
}
const latestCreatedAt = Number.isFinite(state.latestCreatedAt)
? Math.max(0, Math.floor(state.latestCreatedAt))
: 0;
return {
pointer: state.pointer ? { ...state.pointer } : null,
total,
counts,
reactions,
lastUpdatedAt: state.lastUpdatedAt,
latestCreatedAt,
lastSyncedAt: state.lastSyncedAt,
hydrated: Boolean(state.hydrated)
};
}
function notifyHandlers(key) {
const handlers = pointerHandlers.get(key);
if (!handlers || !handlers.size) {
return;
}
const state = pointerStates.get(key);
if (!state) {
return;
}
const snapshot = snapshotState(state);
for (const handler of handlers) {
if (typeof handler !== "function") {
continue;
}
try {
handler(snapshot);
} catch (error) {
userLogger.warn("[reactionCounter] Reaction handler threw:", error);
}
}
}
function subscribeToPointer(pointerInput, handler) {
if (typeof handler !== "function") {
return () => {};
}
const canonical = canonicalizePointerForState(pointerInput);
if (!canonical) {
return () => {};
}
const { key, pointer } = canonical;
const state = ensurePointerState(key, pointer);
let handlers = pointerHandlers.get(key);
if (!handlers) {
handlers = new Set();
pointerHandlers.set(key, handlers);
}
handlers.add(handler);
if (state) {
try {
handler(snapshotState(state));
} catch (error) {
userLogger.warn("[reactionCounter] Reaction handler threw:", error);
}
}
const hydrationPromise = ensureReactionHydration(key, pointer, state);
if (hydrationPromise && typeof hydrationPromise.then === "function") {
hydrationPromise
.catch((error) => {
devLogger.warn(
"[reactionCounter] Failed to hydrate reaction history:",
error
);
})
.finally(() => {
if (hasHandlers(key)) {
ensureReactionLiveSubscription(key, pointer);
}
});
} else {
const liveToken = ensureReactionLiveSubscription(key, pointer);
if (
!liveToken &&
!hydrationPromise &&
!nostrClient?.pool &&
typeof nostrClient?.ensurePool === "function"
) {
Promise.resolve()
.then(() => nostrClient.ensurePool())
.then(() => {
if (!hasHandlers(key)) {
return;
}
const followUp = ensureReactionHydration(key, pointer, state);
if (followUp && typeof followUp.then === "function") {
followUp
.catch((error) => {
devLogger.warn(
"[reactionCounter] Failed to hydrate reaction history after pool init:",
error
);
})
.finally(() => {
if (hasHandlers(key)) {
ensureReactionLiveSubscription(key, pointer);
}
});
} else if (hasHandlers(key)) {
ensureReactionLiveSubscription(key, pointer);
}
})
.catch((error) => {
devLogger.warn(
"[reactionCounter] Failed to initialize nostr pool for reactions:",
error
);
});
}
}
return () => {
unsubscribeFromPointer(pointer, handler);
};
}
function unsubscribeFromPointer(pointerInput, handler) {
const canonical = canonicalizePointerForState(pointerInput);
if (!canonical) {
return;
}
const { key } = canonical;
const handlers = pointerHandlers.get(key);
if (!handlers) {
return;
}
if (handler && typeof handler === "function") {
handlers.delete(handler);
} else {
handlers.clear();
}
if (handlers.size === 0) {
pointerHandlers.delete(key);
teardownLiveSubscription(key);
}
}
function getPointerSnapshot(pointerInput) {
const canonical = canonicalizePointerForState(pointerInput);
if (!canonical) {
return null;
}
const state = pointerStates.get(canonical.key);
if (!state) {
return null;
}
return snapshotState(state);
}
function ingestLocalReaction({ event, pointer }) {
if (!event || !pointer) {
return;
}
try {
const canonical = canonicalizePointerForState(pointer);
if (!canonical) {
return;
}
const changed = applyReactionToState(
canonical.key,
canonical.pointer,
event
);
if (changed) {
notifyHandlers(canonical.key);
}
} catch (error) {
userLogger.warn(
"[reactionCounter] Failed to ingest local reaction:",
error
);
}
}
function hasHandlers(key) {
const handlers = pointerHandlers.get(key);
return Boolean(handlers && handlers.size);
}
function getPointerSignature(pointer) {
if (!pointer || typeof pointer !== "object") {
return "";
}
const type = pointer.type === "a" ? "a" : pointer.type === "e" ? "e" : "";
const value =
typeof pointer.value === "string" && pointer.value.trim()
? pointer.value.trim()
: "";
if (!type || !value) {
return "";
}
const relay =
typeof pointer.relay === "string" && pointer.relay.trim()
? pointer.relay.trim()
: "";
return `${type}:${value}:${relay}`;
}
function gatherRelayCandidates(pointer) {
const relays = [];
const seen = new Set();
const addRelay = (candidate) => {
if (typeof candidate !== "string") {
return;
}
const trimmed = candidate.trim();
if (!trimmed || seen.has(trimmed)) {
return;
}
seen.add(trimmed);
relays.push(trimmed);
};
if (pointer && typeof pointer.relay === "string") {
addRelay(pointer.relay);
}
const client = nostrClient || null;
const candidateLists = [client?.readRelays, client?.relays];
for (const list of candidateLists) {
if (!list) {
continue;
}
if (Array.isArray(list)) {
for (const relay of list) {
addRelay(relay);
}
} else if (list instanceof Set) {
for (const relay of list) {
addRelay(relay);
}
}
}
return relays;
}
export function buildReactionFilters(pointer, { limit, since } = {}) {
if (!pointer || typeof pointer !== "object") {
return [];
}
const type = pointer.type === "a" ? "a" : pointer.type === "e" ? "e" : "";
const value =
typeof pointer.value === "string" && pointer.value.trim()
? pointer.value.trim()
: "";
if (!type || !value) {
return [];
}
const withBounds = (filter) => {
if (Number.isFinite(limit) && limit > 0) {
filter.limit = Math.max(1, Math.floor(limit));
}
if (Number.isFinite(since) && since >= 0) {
filter.since = Math.max(0, Math.floor(since));
}
return filter;
};
if (type === "e") {
return [withBounds({ kinds: [REACTION_EVENT_KIND], "#e": [value] })];
}
const filters = [withBounds({ kinds: [REACTION_EVENT_KIND], "#a": [value] })];
const eventId =
typeof pointer.eventId === "string" && pointer.eventId.trim()
? pointer.eventId.trim()
: "";
if (eventId) {
filters.push(withBounds({ kinds: [REACTION_EVENT_KIND], "#e": [eventId] }));
}
return filters;
}
function buildPointerQuery(pointer, options = {}) {
const client = nostrClient || null;
const pool = client?.pool || null;
if (!pool || typeof pool.list !== "function") {
return null;
}
const relays = gatherRelayCandidates(pointer);
if (!relays.length) {
return null;
}
const filters = buildReactionFilters(pointer, options);
if (!filters.length) {
return null;
}
return { pool, relays, filters };
}
async function hydratePointerState(key, pointer, state) {
const query = buildPointerQuery(pointer, { limit: REACTION_HYDRATION_LIMIT });
if (!query) {
return null;
}
let events = [];
try {
events = await query.pool.list(query.relays, query.filters);
} catch (error) {
throw error;
}
const eventList = Array.isArray(events) ? events : [];
const ordered = eventList
.filter((event) => event && typeof event === "object")
.sort((a, b) => {
const aTime = Number.isFinite(a?.created_at) ? Number(a.created_at) : 0;
const bTime = Number.isFinite(b?.created_at) ? Number(b.created_at) : 0;
return aTime - bTime;
});
let changed = false;
for (const event of ordered) {
if (applyReactionToState(key, pointer, event)) {
changed = true;
}
}
state.lastSyncedAt = Date.now();
state.hydrated = true;
if (changed && hasHandlers(key)) {
notifyHandlers(key);
} else if (!ordered.length && hasHandlers(key)) {
notifyHandlers(key);
}
return null;
}
function ensureReactionHydration(key, pointer, state) {
if (!state || state.hydrated) {
return null;
}
if (pointerHydrationPromises.has(key)) {
return pointerHydrationPromises.get(key);
}
const query = buildPointerQuery(pointer, { limit: REACTION_HYDRATION_LIMIT });
if (!query) {
return null;
}
const promise = hydratePointerState(key, pointer, state)
.catch((error) => {
devLogger.warn(
"[reactionCounter] Failed to hydrate reaction history:",
error
);
})
.finally(() => {
pointerHydrationPromises.delete(key);
});
pointerHydrationPromises.set(key, promise);
return promise;
}
function teardownLiveSubscription(key) {
const existing = pointerSubscriptions.get(key);
if (!existing) {
return;
}
pointerSubscriptions.delete(key);
const unsub = existing.unsub;
if (typeof unsub === "function") {
try {
unsub();
} catch (error) {
devLogger.warn(
"[reactionCounter] Failed to tear down reaction stream:",
error
);
}
}
}
function ensureReactionLiveSubscription(key, pointer) {
const signature = getPointerSignature(pointer);
if (!signature) {
teardownLiveSubscription(key);
return null;
}
const existing = pointerSubscriptions.get(key);
if (existing && existing.signature === signature) {
return existing.unsub;
}
teardownLiveSubscription(key);
const query = buildPointerQuery(pointer, {});
if (!query || typeof query.pool.sub !== "function") {
return null;
}
const state = pointerStates.get(key);
const sinceSeconds =
Number.isFinite(state?.latestCreatedAt) && state.latestCreatedAt > 0
? Math.max(0, Math.floor(state.latestCreatedAt))
: null;
if (Number.isFinite(sinceSeconds)) {
for (const filter of query.filters) {
if (filter && typeof filter === "object") {
filter.since = sinceSeconds;
}
}
}
let subscription;
try {
subscription = query.pool.sub(query.relays, query.filters);
} catch (error) {
devLogger.warn(
"[reactionCounter] Failed to subscribe to reaction events:",
error
);
return null;
}
const handleEvent = (event) => {
if (!event) {
return;
}
try {
const changed = applyReactionToState(key, pointer, event);
if (changed && hasHandlers(key)) {
notifyHandlers(key);
}
} catch (error) {
devLogger.warn("[reactionCounter] Reaction event handler failed:", error);
}
};
if (subscription && typeof subscription.on === "function") {
subscription.on("event", handleEvent);
}
const originalUnsub =
subscription && typeof subscription.unsub === "function"
? subscription.unsub.bind(subscription)
: null;
const unsubscribe = () => {
if (originalUnsub) {
try {
originalUnsub();
} catch (error) {
devLogger.warn(
"[reactionCounter] Failed to unsubscribe from reaction events:",
error
);
}
}
};
pointerSubscriptions.set(key, {
signature,
unsub: unsubscribe
});
return unsubscribe;
}
export const reactionCounter = {
async publish(pointer, options = {}) {
try {
const publishOptions = { ...options };
const pointerRelayFromPointer = (() => {
if (Array.isArray(pointer) && pointer.length >= 3) {
const relay = pointer[2];
if (typeof relay === "string" && relay.trim()) {
return relay.trim();
}
}
if (pointer && typeof pointer === "object") {
const relay = pointer.relay;
if (typeof relay === "string" && relay.trim()) {
return relay.trim();
}
}
return "";
})();
const pointerRelay =
typeof publishOptions.pointerRelay === "string" &&
publishOptions.pointerRelay.trim()
? publishOptions.pointerRelay.trim()
: pointerRelayFromPointer;
if (pointerRelay && !publishOptions.pointerRelay) {
publishOptions.pointerRelay = pointerRelay;
}
const enrichedPointer = (() => {
if (!pointerRelay) {
return pointer;
}
if (Array.isArray(pointer) && pointer.length >= 2) {
const [type, value] = pointer;
return [type, value, pointerRelay];
}
if (pointer && typeof pointer === "object") {
return { ...pointer, relay: pointerRelay };
}
return pointer;
})();
const explicitAuthor =
typeof publishOptions.targetAuthorPubkey === "string" &&
publishOptions.targetAuthorPubkey.trim()
? publishOptions.targetAuthorPubkey.trim()
: "";
if (!explicitAuthor) {
const fallbackAuthor = (() => {
if (
typeof publishOptions.authorPubkey === "string" &&
publishOptions.authorPubkey.trim()
) {
return publishOptions.authorPubkey.trim();
}
if (
typeof publishOptions.currentVideoPubkey === "string" &&
publishOptions.currentVideoPubkey.trim()
) {
return publishOptions.currentVideoPubkey.trim();
}
if (
publishOptions.video &&
typeof publishOptions.video.pubkey === "string" &&
publishOptions.video.pubkey.trim()
) {
return publishOptions.video.pubkey.trim();
}
return "";
})();
if (fallbackAuthor) {
publishOptions.targetAuthorPubkey = fallbackAuthor;
}
}
const result = await nostrClient.publishVideoReaction(
enrichedPointer,
publishOptions
);
if (!result || !result.ok) {
userLogger.warn(
"[reactionCounter] Reaction publish rejected by relays:",
result?.results || result
);
} else if (Array.isArray(result.acceptedRelays)) {
devLogger.info(
`[reactionCounter] Reaction accepted by ${result.acceptedRelays.length} relay(s):`,
result.acceptedRelays.join(", ")
);
const eventPayload = result.event || {
pubkey:
typeof publishOptions.actorPubkey === "string"
? publishOptions.actorPubkey
: undefined,
content: publishOptions.content,
created_at: publishOptions.created_at
};
if (eventPayload && eventPayload.pubkey) {
ingestLocalReaction({
pointer: enrichedPointer,
event: eventPayload
});
}
}
return result;
} catch (error) {
userLogger.warn(
"[reactionCounter] Failed to publish reaction event:",
error
);
throw error;
}
}
};
reactionCounter.subscribe = subscribeToPointer;
reactionCounter.unsubscribe = unsubscribeFromPointer;
reactionCounter.getSnapshot = getPointerSnapshot;
export default reactionCounter;
export {
subscribeToPointer as subscribeToReactions,
unsubscribeFromPointer as unsubscribeFromReactions,
getPointerSnapshot as getReactionSnapshot
};