mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-30 17:32:26 +00:00
Add hashtag preferences service and tests
This commit is contained in:
1 parent
f66e979767
commit
50de07fa7a
4 files changed
+952
-1
No files matched your search
@@ -52,6 +52,7 @@ import NwcSettingsService from "./services/nwcSettingsService.js";
|
||||
import nostrService from "./services/nostrService.js";
|
||||
import DiscussionCountService from "./services/discussionCountService.js";
|
||||
import CommentThreadService from "./services/commentThreadService.js";
|
||||
import hashtagPreferences from "./services/hashtagPreferencesService.js";
|
||||
import { initQuickR2Upload } from "./r2-quick.js";
|
||||
import { createWatchHistoryRenderer } from "./historyView.js";
|
||||
import WatchHistoryController from "./ui/watchHistoryController.js";
|
||||
@@ -298,6 +299,8 @@ class Application {
|
||||
notifyError: (message) => this.showError(message),
|
||||
maxWalletDefaultZap: MAX_WALLET_DEFAULT_ZAP,
|
||||
});
|
||||
this.hashtagPreferences =
|
||||
services.hashtagPreferences || hashtagPreferences;
|
||||
if (
|
||||
this.nwcSettingsService &&
|
||||
typeof this.nwcSettingsService.setActivePubkeyGetter === "function"
|
||||
|
||||
Vendored
+5
-1
@@ -11,6 +11,7 @@ import moderationService from "./services/moderationService.js";
|
||||
import nostrService from "./services/nostrService.js";
|
||||
import r2Service from "./services/r2Service.js";
|
||||
import { loadView } from "./viewManager.js";
|
||||
import hashtagPreferences from "./services/hashtagPreferencesService.js";
|
||||
|
||||
function normalizeNpub(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
@@ -97,7 +98,7 @@ async function bootstrapTrustedSeeds() {
|
||||
bootstrapTrustedSeeds();
|
||||
|
||||
function mergeServices(overrides = {}) {
|
||||
const merged = { nostrService, r2Service };
|
||||
const merged = { nostrService, r2Service, hashtagPreferences };
|
||||
|
||||
if (overrides && typeof overrides === "object") {
|
||||
if (overrides.nostrService) {
|
||||
@@ -106,6 +107,9 @@ function mergeServices(overrides = {}) {
|
||||
if (overrides.r2Service) {
|
||||
merged.r2Service = overrides.r2Service;
|
||||
}
|
||||
if (overrides.hashtagPreferences) {
|
||||
merged.hashtagPreferences = overrides.hashtagPreferences;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
|
||||
@@ -0,0 +1,736 @@
|
||||
// js/services/hashtagPreferencesService.js
|
||||
import {
|
||||
nostrClient,
|
||||
requestDefaultExtensionPermissions,
|
||||
} from "../nostrClientFacade.js";
|
||||
import { getActiveSigner } from "../nostr/index.js";
|
||||
import { buildHashtagPreferenceEvent } from "../nostrEventSchemas.js";
|
||||
import {
|
||||
publishEventToRelays,
|
||||
assertAnyRelayAccepted,
|
||||
} from "../nostrPublish.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
|
||||
const LOG_PREFIX = "[HashtagPreferences]";
|
||||
const HASHTAG_IDENTIFIER = "bitvid:tag-preferences";
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const DEFAULT_VERSION = 1;
|
||||
|
||||
class TinyEventEmitter {
|
||||
constructor() {
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
if (typeof eventName !== "string" || typeof handler !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!this.listeners.has(eventName)) {
|
||||
this.listeners.set(eventName, new Set());
|
||||
}
|
||||
|
||||
const handlers = this.listeners.get(eventName);
|
||||
handlers.add(handler);
|
||||
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
if (!handlers.size) {
|
||||
this.listeners.delete(eventName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emit(eventName, detail) {
|
||||
const handlers = this.listeners.get(eventName);
|
||||
if (!handlers || !handlers.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const handler of Array.from(handlers)) {
|
||||
try {
|
||||
handler(detail);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
`${LOG_PREFIX} listener for "${eventName}" threw an error`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const EVENTS = Object.freeze({
|
||||
CHANGE: "change",
|
||||
});
|
||||
|
||||
function normalizeHexPubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function sanitizeRelayList(candidate) {
|
||||
return Array.isArray(candidate)
|
||||
? candidate
|
||||
.map((url) => (typeof url === "string" ? url.trim() : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeTag(input) {
|
||||
if (typeof input !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = input.trim().replace(/^#+/, "");
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
function extractEncryptionHints(event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tags = Array.isArray(event.tags) ? event.tags : [];
|
||||
const hints = [];
|
||||
|
||||
const pushUnique = (scheme) => {
|
||||
if (!scheme || hints.includes(scheme)) {
|
||||
return;
|
||||
}
|
||||
hints.push(scheme);
|
||||
};
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const label = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
|
||||
if (label !== "encrypted" && label !== "encryption") {
|
||||
continue;
|
||||
}
|
||||
const rawValue = typeof tag[1] === "string" ? tag[1] : "";
|
||||
if (!rawValue) {
|
||||
continue;
|
||||
}
|
||||
const parts = rawValue
|
||||
.split(/[\s,]+/)
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
for (const part of parts) {
|
||||
if (part.startsWith("nip44")) {
|
||||
if (/^nip44(?:[_-]?v2|v2)/.test(part)) {
|
||||
pushUnique("nip44_v2");
|
||||
} else {
|
||||
pushUnique("nip44");
|
||||
}
|
||||
} else if (part === "nip04" || part === "nip-04") {
|
||||
pushUnique("nip04");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hints;
|
||||
}
|
||||
|
||||
function determineDecryptionOrder(event, availableSchemes) {
|
||||
const available = Array.isArray(availableSchemes) ? availableSchemes : [];
|
||||
const availableSet = new Set(available);
|
||||
const prioritized = [];
|
||||
|
||||
const hints = extractEncryptionHints(event);
|
||||
const aliasMap = {
|
||||
nip04: ["nip04"],
|
||||
nip44: ["nip44", "nip44_v2"],
|
||||
nip44_v2: ["nip44_v2", "nip44"],
|
||||
};
|
||||
|
||||
for (const hint of hints) {
|
||||
const candidates = Array.isArray(aliasMap[hint]) ? aliasMap[hint] : [hint];
|
||||
for (const candidate of candidates) {
|
||||
if (availableSet.has(candidate) && !prioritized.includes(candidate)) {
|
||||
prioritized.push(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const fallback of ["nip04", "nip44", "nip44_v2"]) {
|
||||
if (availableSet.has(fallback) && !prioritized.includes(fallback)) {
|
||||
prioritized.push(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
return prioritized.length ? prioritized : available;
|
||||
}
|
||||
|
||||
class HashtagPreferencesService {
|
||||
constructor() {
|
||||
this.emitter = new TinyEventEmitter();
|
||||
this.interests = new Set();
|
||||
this.disinterests = new Set();
|
||||
this.activePubkey = null;
|
||||
this.eventId = null;
|
||||
this.eventCreatedAt = null;
|
||||
this.loaded = false;
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
return this.emitter.on(eventName, handler);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.interests = new Set();
|
||||
this.disinterests = new Set();
|
||||
this.eventId = null;
|
||||
this.eventCreatedAt = null;
|
||||
this.loaded = false;
|
||||
this.emitChange("reset");
|
||||
}
|
||||
|
||||
getInterests() {
|
||||
return Array.from(this.interests).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
getDisinterests() {
|
||||
return Array.from(this.disinterests).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
addInterest(tag) {
|
||||
const normalized = normalizeTag(tag);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hadInterest = this.interests.has(normalized);
|
||||
const removedFromDisinterests = this.disinterests.delete(normalized);
|
||||
this.interests.add(normalized);
|
||||
|
||||
if (!hadInterest || removedFromDisinterests) {
|
||||
this.emitChange("interest-added", { tag: normalized });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
removeInterest(tag) {
|
||||
const normalized = normalizeTag(tag);
|
||||
if (!normalized || !this.interests.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.interests.delete(normalized);
|
||||
this.emitChange("interest-removed", { tag: normalized });
|
||||
return true;
|
||||
}
|
||||
|
||||
addDisinterest(tag) {
|
||||
const normalized = normalizeTag(tag);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hadDisinterest = this.disinterests.has(normalized);
|
||||
const removedFromInterests = this.interests.delete(normalized);
|
||||
this.disinterests.add(normalized);
|
||||
|
||||
if (!hadDisinterest || removedFromInterests) {
|
||||
this.emitChange("disinterest-added", { tag: normalized });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
removeDisinterest(tag) {
|
||||
const normalized = normalizeTag(tag);
|
||||
if (!normalized || !this.disinterests.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.disinterests.delete(normalized);
|
||||
this.emitChange("disinterest-removed", { tag: normalized });
|
||||
return true;
|
||||
}
|
||||
|
||||
emitChange(action, detail = {}) {
|
||||
try {
|
||||
this.emitter.emit(EVENTS.CHANGE, {
|
||||
action,
|
||||
interests: this.getInterests(),
|
||||
disinterests: this.getDisinterests(),
|
||||
eventId: this.eventId,
|
||||
createdAt: this.eventCreatedAt,
|
||||
...detail,
|
||||
});
|
||||
} catch (error) {
|
||||
userLogger.warn(`${LOG_PREFIX} Failed to emit change event`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async load(pubkey) {
|
||||
const normalized = normalizeHexPubkey(pubkey);
|
||||
this.activePubkey = normalized;
|
||||
|
||||
if (!normalized) {
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!nostrClient ||
|
||||
!nostrClient.pool ||
|
||||
typeof nostrClient.pool.list !== "function"
|
||||
) {
|
||||
userLogger.warn(
|
||||
`${LOG_PREFIX} nostrClient.pool.list unavailable; treating preferences as empty.`,
|
||||
);
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const relays = sanitizeRelayList(
|
||||
Array.isArray(nostrClient.relays)
|
||||
? nostrClient.relays
|
||||
: nostrClient.writeRelays,
|
||||
);
|
||||
if (!relays.length) {
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = {
|
||||
kinds: [30005],
|
||||
authors: [normalized],
|
||||
"#d": [HASHTAG_IDENTIFIER],
|
||||
limit: 50,
|
||||
};
|
||||
|
||||
let events = [];
|
||||
try {
|
||||
const result = await nostrClient.pool.list(relays, [filter]);
|
||||
if (Array.isArray(result)) {
|
||||
events = result.filter((event) => event && event.pubkey === normalized);
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
`${LOG_PREFIX} Failed to load hashtag preferences from relays`,
|
||||
error,
|
||||
);
|
||||
events = [];
|
||||
}
|
||||
|
||||
if (!events.length) {
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const latest = events.reduce((current, candidate) => {
|
||||
if (!candidate) {
|
||||
return current;
|
||||
}
|
||||
if (!current) {
|
||||
return candidate;
|
||||
}
|
||||
const candidateTs = Number(candidate.created_at) || 0;
|
||||
const currentTs = Number(current.created_at) || 0;
|
||||
if (candidateTs === currentTs) {
|
||||
return candidate.id > current.id ? candidate : current;
|
||||
}
|
||||
return candidateTs > currentTs ? candidate : current;
|
||||
}, null);
|
||||
|
||||
if (!latest) {
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const decryptResult = await this.decryptEvent(latest, normalized);
|
||||
if (!decryptResult.ok) {
|
||||
userLogger.warn(
|
||||
`${LOG_PREFIX} Failed to decrypt hashtag preferences`,
|
||||
decryptResult.error,
|
||||
);
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(decryptResult.plaintext);
|
||||
const rawInterests = Array.isArray(payload?.interests)
|
||||
? payload.interests
|
||||
: [];
|
||||
const rawDisinterests = Array.isArray(payload?.disinterests)
|
||||
? payload.disinterests
|
||||
: [];
|
||||
|
||||
const nextInterests = new Set();
|
||||
for (const tag of rawInterests) {
|
||||
const normalizedTag = normalizeTag(tag);
|
||||
if (normalizedTag) {
|
||||
nextInterests.add(normalizedTag);
|
||||
}
|
||||
}
|
||||
|
||||
const nextDisinterests = new Set();
|
||||
for (const tag of rawDisinterests) {
|
||||
const normalizedTag = normalizeTag(tag);
|
||||
if (!normalizedTag) {
|
||||
continue;
|
||||
}
|
||||
nextDisinterests.add(normalizedTag);
|
||||
nextInterests.delete(normalizedTag);
|
||||
}
|
||||
|
||||
this.interests = nextInterests;
|
||||
this.disinterests = nextDisinterests;
|
||||
this.eventId = latest.id || null;
|
||||
this.eventCreatedAt = Number.isFinite(latest?.created_at)
|
||||
? latest.created_at
|
||||
: null;
|
||||
this.loaded = true;
|
||||
this.emitChange("sync", { scheme: decryptResult.scheme });
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
`${LOG_PREFIX} Failed to parse decrypted hashtag preferences`,
|
||||
error,
|
||||
);
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
async decryptEvent(event, userPubkey) {
|
||||
const ciphertext = typeof event?.content === "string" ? event.content : "";
|
||||
if (!ciphertext) {
|
||||
const error = new Error("Preference event missing ciphertext content.");
|
||||
error.code = "hashtag-preferences-empty";
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
const signer = getActiveSigner();
|
||||
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
|
||||
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
|
||||
|
||||
const hints = extractEncryptionHints(event);
|
||||
const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2");
|
||||
const requiresNip04 = !hints.length || hints.includes("nip04");
|
||||
|
||||
if (
|
||||
(!signerHasNip44 && requiresNip44) ||
|
||||
(!signerHasNip04 && requiresNip04)
|
||||
) {
|
||||
try {
|
||||
await requestDefaultExtensionPermissions();
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
`${LOG_PREFIX} Extension permissions request failed while loading preferences`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const decryptors = new Map();
|
||||
const registerDecryptor = (scheme, handler) => {
|
||||
if (!scheme || typeof handler !== "function" || decryptors.has(scheme)) {
|
||||
return;
|
||||
}
|
||||
decryptors.set(scheme, handler);
|
||||
};
|
||||
|
||||
if (signerHasNip44) {
|
||||
registerDecryptor("nip44", (payload) => signer.nip44Decrypt(userPubkey, payload));
|
||||
registerDecryptor("nip44_v2", (payload) =>
|
||||
signer.nip44Decrypt(userPubkey, payload),
|
||||
);
|
||||
}
|
||||
|
||||
if (signerHasNip04) {
|
||||
registerDecryptor("nip04", (payload) => signer.nip04Decrypt(userPubkey, payload));
|
||||
}
|
||||
|
||||
const nostrApi =
|
||||
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
|
||||
if (nostrApi) {
|
||||
const nip04 =
|
||||
nostrApi.nip04 && typeof nostrApi.nip04.decrypt === "function"
|
||||
? nostrApi.nip04
|
||||
: null;
|
||||
if (nip04 && !decryptors.has("nip04")) {
|
||||
registerDecryptor("nip04", (payload) => nip04.decrypt(userPubkey, payload));
|
||||
}
|
||||
|
||||
const nip44 =
|
||||
nostrApi.nip44 && typeof nostrApi.nip44 === "object"
|
||||
? nostrApi.nip44
|
||||
: null;
|
||||
if (nip44) {
|
||||
if (typeof nip44.decrypt === "function" && !decryptors.has("nip44")) {
|
||||
registerDecryptor("nip44", (payload) => nip44.decrypt(userPubkey, payload));
|
||||
}
|
||||
const nip44v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
|
||||
if (nip44v2 && typeof nip44v2.decrypt === "function") {
|
||||
registerDecryptor("nip44_v2", (payload) =>
|
||||
nip44v2.decrypt(userPubkey, payload),
|
||||
);
|
||||
if (!decryptors.has("nip44")) {
|
||||
registerDecryptor("nip44", (payload) =>
|
||||
nip44v2.decrypt(userPubkey, payload),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!decryptors.size) {
|
||||
const error = new Error(
|
||||
"No decryptors available for hashtag preferences payload.",
|
||||
);
|
||||
error.code = "hashtag-preferences-no-decryptors";
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
const order = determineDecryptionOrder(event, Array.from(decryptors.keys()));
|
||||
const attemptErrors = [];
|
||||
|
||||
for (const scheme of order) {
|
||||
const decryptFn = decryptors.get(scheme);
|
||||
if (!decryptFn) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const plaintext = await decryptFn(ciphertext);
|
||||
if (typeof plaintext !== "string") {
|
||||
attemptErrors.push({
|
||||
scheme,
|
||||
error: new Error("Decryption returned non-string payload."),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
return { ok: true, plaintext, scheme };
|
||||
} catch (error) {
|
||||
attemptErrors.push({ scheme, error });
|
||||
}
|
||||
}
|
||||
|
||||
const error = new Error("Failed to decrypt hashtag preferences.");
|
||||
error.code = "hashtag-preferences-decrypt-failed";
|
||||
if (attemptErrors.length) {
|
||||
error.cause = attemptErrors;
|
||||
}
|
||||
return { ok: false, error, errors: attemptErrors };
|
||||
}
|
||||
|
||||
async publish(options = {}) {
|
||||
const targetPubkey = normalizeHexPubkey(
|
||||
typeof options?.pubkey === "string" ? options.pubkey : this.activePubkey,
|
||||
);
|
||||
if (!targetPubkey) {
|
||||
const error = new Error("Active pubkey is required to publish preferences.");
|
||||
error.code = "hashtag-preferences-missing-pubkey";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const signer = getActiveSigner();
|
||||
if (!signer || typeof signer.signEvent !== "function") {
|
||||
const error = new Error("An active signer is required to publish preferences.");
|
||||
error.code = "hashtag-preferences-missing-signer";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (signer.type === "extension") {
|
||||
const permissionResult = await requestDefaultExtensionPermissions();
|
||||
if (!permissionResult?.ok) {
|
||||
const error = new Error(
|
||||
"The active signer must allow encryption and signing before publishing preferences.",
|
||||
);
|
||||
error.code = "hashtag-preferences-extension-denied";
|
||||
error.cause = permissionResult?.error || null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const interests = this.getInterests();
|
||||
const disinterests = this.getDisinterests().filter(
|
||||
(tag) => !interests.includes(tag),
|
||||
);
|
||||
|
||||
const payload = {
|
||||
version: DEFAULT_VERSION,
|
||||
interests,
|
||||
disinterests,
|
||||
};
|
||||
const plaintext = JSON.stringify(payload);
|
||||
|
||||
const encryptors = [];
|
||||
const registerEncryptor = (scheme, handler) => {
|
||||
if (!scheme || typeof handler !== "function") {
|
||||
return;
|
||||
}
|
||||
encryptors.push({ scheme, handler });
|
||||
};
|
||||
|
||||
if (typeof signer.nip44Encrypt === "function") {
|
||||
registerEncryptor("nip44_v2", (value) => signer.nip44Encrypt(targetPubkey, value));
|
||||
registerEncryptor("nip44", (value) => signer.nip44Encrypt(targetPubkey, value));
|
||||
}
|
||||
|
||||
if (typeof signer.nip04Encrypt === "function") {
|
||||
registerEncryptor("nip04", (value) => signer.nip04Encrypt(targetPubkey, value));
|
||||
}
|
||||
|
||||
const nostrApi =
|
||||
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
|
||||
if (nostrApi) {
|
||||
const nip44 =
|
||||
nostrApi.nip44 && typeof nostrApi.nip44 === "object"
|
||||
? nostrApi.nip44
|
||||
: null;
|
||||
if (nip44) {
|
||||
if (typeof nip44.encrypt === "function") {
|
||||
registerEncryptor("nip44", (value) => nip44.encrypt(targetPubkey, value));
|
||||
}
|
||||
const nip44v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
|
||||
if (nip44v2 && typeof nip44v2.encrypt === "function") {
|
||||
registerEncryptor("nip44_v2", (value) =>
|
||||
nip44v2.encrypt(targetPubkey, value),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (typeof nostrApi.nip04?.encrypt === "function") {
|
||||
registerEncryptor("nip04", (value) =>
|
||||
nostrApi.nip04.encrypt(targetPubkey, value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!encryptors.length) {
|
||||
const error = new Error("No encryptors available to publish preferences.");
|
||||
error.code = "hashtag-preferences-no-encryptor";
|
||||
throw error;
|
||||
}
|
||||
|
||||
let ciphertext = "";
|
||||
let schemeUsed = "";
|
||||
const encryptionErrors = [];
|
||||
const triedSchemes = new Set();
|
||||
|
||||
for (const candidate of encryptors) {
|
||||
if (triedSchemes.has(candidate.scheme)) {
|
||||
continue;
|
||||
}
|
||||
triedSchemes.add(candidate.scheme);
|
||||
try {
|
||||
const encrypted = await candidate.handler(plaintext);
|
||||
if (typeof encrypted === "string" && encrypted) {
|
||||
ciphertext = encrypted;
|
||||
schemeUsed = candidate.scheme;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
encryptionErrors.push({ scheme: candidate.scheme, error });
|
||||
}
|
||||
}
|
||||
|
||||
if (!ciphertext) {
|
||||
const error = new Error("Failed to encrypt hashtag preferences.");
|
||||
error.code = "hashtag-preferences-encrypt-failed";
|
||||
error.cause = encryptionErrors;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
const event = buildHashtagPreferenceEvent({
|
||||
pubkey: targetPubkey,
|
||||
created_at: createdAt,
|
||||
content: ciphertext,
|
||||
});
|
||||
|
||||
if (!Array.isArray(event.tags)) {
|
||||
event.tags = [];
|
||||
}
|
||||
|
||||
let updatedSchemeTag = false;
|
||||
for (const tag of event.tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
if (typeof tag[0] === "string" && tag[0].toLowerCase() === "encrypted") {
|
||||
tag[1] = schemeUsed;
|
||||
updatedSchemeTag = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!updatedSchemeTag) {
|
||||
event.tags.push(["encrypted", schemeUsed]);
|
||||
}
|
||||
|
||||
const signedEvent = await signer.signEvent(event);
|
||||
|
||||
const writeRelays = sanitizeRelayList(nostrClient.writeRelays);
|
||||
const relayFallback = writeRelays.length
|
||||
? writeRelays
|
||||
: sanitizeRelayList(nostrClient.relays);
|
||||
|
||||
if (!relayFallback.length) {
|
||||
const error = new Error("No relays configured for publishing preferences.");
|
||||
error.code = "hashtag-preferences-no-relays";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const publishResults = await publishEventToRelays(
|
||||
nostrClient.pool,
|
||||
relayFallback,
|
||||
signedEvent,
|
||||
);
|
||||
|
||||
const publishSummary = assertAnyRelayAccepted(publishResults, {
|
||||
context: "hashtag-preferences",
|
||||
});
|
||||
|
||||
if (publishSummary.failed?.length) {
|
||||
publishSummary.failed.forEach(({ url, error }) => {
|
||||
userLogger.warn(
|
||||
`${LOG_PREFIX} Preferences not accepted by ${url}: ${error}`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
this.eventId = signedEvent.id || null;
|
||||
this.eventCreatedAt = Number.isFinite(signedEvent?.created_at)
|
||||
? signedEvent.created_at
|
||||
: createdAt;
|
||||
this.emitChange("published", { event: signedEvent, scheme: schemeUsed });
|
||||
return signedEvent;
|
||||
}
|
||||
}
|
||||
|
||||
const hashtagPreferences = new HashtagPreferencesService();
|
||||
|
||||
export default hashtagPreferences;
|
||||
export { HashtagPreferencesService, hashtagPreferences, EVENTS as HASHTAG_PREFERENCES_EVENTS };
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const hashtagPreferencesModule = await import(
|
||||
"../js/services/hashtagPreferencesService.js"
|
||||
);
|
||||
const hashtagPreferences = hashtagPreferencesModule.default;
|
||||
const {
|
||||
nostrClient,
|
||||
setActiveSigner,
|
||||
clearActiveSigner,
|
||||
} = await import("../js/nostr.js");
|
||||
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
globalThis.window = {};
|
||||
}
|
||||
|
||||
const originalPool = nostrClient.pool;
|
||||
const originalRelays = Array.isArray(nostrClient.relays)
|
||||
? [...nostrClient.relays]
|
||||
: nostrClient.relays;
|
||||
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
|
||||
? [...nostrClient.writeRelays]
|
||||
: nostrClient.writeRelays;
|
||||
const originalWindowNostr = window.nostr;
|
||||
|
||||
function restoreNostrClient() {
|
||||
nostrClient.pool = originalPool;
|
||||
nostrClient.relays = Array.isArray(originalRelays)
|
||||
? [...originalRelays]
|
||||
: originalRelays;
|
||||
nostrClient.writeRelays = Array.isArray(originalWriteRelays)
|
||||
? [...originalWriteRelays]
|
||||
: originalWriteRelays;
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
hashtagPreferences.reset();
|
||||
restoreNostrClient();
|
||||
window.nostr = originalWindowNostr;
|
||||
clearActiveSigner();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
restoreNostrClient();
|
||||
window.nostr = originalWindowNostr;
|
||||
clearActiveSigner();
|
||||
});
|
||||
|
||||
test(
|
||||
"load decrypts nip44 payloads and normalizes tags",
|
||||
{ concurrency: false },
|
||||
async (t) => {
|
||||
const pubkey = "a".repeat(64);
|
||||
const decryptCalls = [];
|
||||
|
||||
setActiveSigner({
|
||||
nip44Decrypt: async (target, ciphertext) => {
|
||||
decryptCalls.push({ target, ciphertext });
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
interests: ["TagOne", " #TagTwo "],
|
||||
disinterests: ["TagThree"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
nostrClient.pool = {
|
||||
async list(relays, filters) {
|
||||
assert.deepEqual(relays, ["wss://relay.one"]);
|
||||
assert.equal(filters.length, 1);
|
||||
assert.equal(filters[0]["#d"][0], "bitvid:tag-preferences");
|
||||
return [
|
||||
{
|
||||
id: "evt1",
|
||||
created_at: 100,
|
||||
pubkey,
|
||||
content: "ciphertext",
|
||||
tags: [["encrypted", "nip44_v2"]],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
nostrClient.relays = ["wss://relay.one"];
|
||||
nostrClient.writeRelays = ["wss://relay.one"];
|
||||
|
||||
await hashtagPreferences.load(pubkey);
|
||||
|
||||
assert.deepEqual(hashtagPreferences.getInterests(), ["tagone", "tagtwo"]);
|
||||
assert.deepEqual(hashtagPreferences.getDisinterests(), ["tagthree"]);
|
||||
assert.equal(decryptCalls.length, 1);
|
||||
assert.equal(decryptCalls[0].target, pubkey);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"load falls back to nip04 decryption",
|
||||
{ concurrency: false },
|
||||
async () => {
|
||||
const pubkey = "b".repeat(64);
|
||||
|
||||
setActiveSigner({
|
||||
nip04Decrypt: async () =>
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
interests: ["Alpha"],
|
||||
disinterests: ["alpha", "Beta"],
|
||||
}),
|
||||
});
|
||||
|
||||
nostrClient.pool = {
|
||||
async list() {
|
||||
return [
|
||||
{
|
||||
id: "evt2",
|
||||
created_at: 200,
|
||||
pubkey,
|
||||
content: "legacycipher",
|
||||
tags: [["encrypted", "nip04"]],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
nostrClient.relays = ["wss://relay.two"];
|
||||
nostrClient.writeRelays = ["wss://relay.two"];
|
||||
|
||||
await hashtagPreferences.load(pubkey);
|
||||
|
||||
assert.deepEqual(hashtagPreferences.getInterests(), []);
|
||||
assert.deepEqual(hashtagPreferences.getDisinterests(), ["alpha", "beta"]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"interest and disinterest lists remain exclusive",
|
||||
{ concurrency: false },
|
||||
() => {
|
||||
hashtagPreferences.reset();
|
||||
|
||||
assert.equal(hashtagPreferences.addInterest("Focus"), true);
|
||||
assert.equal(hashtagPreferences.addDisinterest("focus"), true);
|
||||
assert.deepEqual(hashtagPreferences.getInterests(), []);
|
||||
assert.deepEqual(hashtagPreferences.getDisinterests(), ["focus"]);
|
||||
|
||||
assert.equal(hashtagPreferences.removeDisinterest("focus"), true);
|
||||
assert.equal(hashtagPreferences.getDisinterests().length, 0);
|
||||
assert.equal(hashtagPreferences.removeInterest("focus"), false);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"publish encrypts payload and builds event via builder",
|
||||
{ concurrency: false },
|
||||
async (t) => {
|
||||
const pubkey = "c".repeat(64);
|
||||
const plaintextCalls = [];
|
||||
|
||||
setActiveSigner({
|
||||
nip44Encrypt: async (target, plaintext) => {
|
||||
plaintextCalls.push({ target, plaintext });
|
||||
return "ciphertext";
|
||||
},
|
||||
signEvent: async (event) => ({ ...event, id: "signed-event" }),
|
||||
});
|
||||
|
||||
const publishedEvents = [];
|
||||
nostrClient.pool = {
|
||||
list: async () => [],
|
||||
publish(urls, event) {
|
||||
publishedEvents.push({ urls, event });
|
||||
return {
|
||||
on(eventName, handler) {
|
||||
if (eventName === "ok") {
|
||||
setImmediate(() => handler());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
nostrClient.relays = ["wss://relay.publish"];
|
||||
nostrClient.writeRelays = ["wss://relay.publish"];
|
||||
|
||||
await hashtagPreferences.load(pubkey);
|
||||
hashtagPreferences.addInterest("TagA");
|
||||
hashtagPreferences.addDisinterest("TagB");
|
||||
|
||||
const signedEvent = await hashtagPreferences.publish();
|
||||
assert.equal(signedEvent.id, "signed-event");
|
||||
|
||||
assert.equal(plaintextCalls.length, 1);
|
||||
assert.equal(plaintextCalls[0].target, pubkey);
|
||||
const payload = JSON.parse(plaintextCalls[0].plaintext);
|
||||
assert.equal(payload.version, 1);
|
||||
assert.deepEqual(payload.interests, ["taga"]);
|
||||
assert.deepEqual(payload.disinterests, ["tagb"]);
|
||||
|
||||
assert.equal(publishedEvents.length, 1);
|
||||
const publishedEvent = publishedEvents[0].event;
|
||||
assert.deepEqual(publishedEvents[0].urls, ["wss://relay.publish"]);
|
||||
const encryptedTag = publishedEvent.tags.find(
|
||||
(tag) => Array.isArray(tag) && tag[0] === "encrypted",
|
||||
);
|
||||
assert.ok(encryptedTag);
|
||||
assert.equal(encryptedTag[1], "nip44_v2");
|
||||
},
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user