mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add "trusted creator" overrides that suppress the web-of-trust warning/blur for ALL of a creator's videos until reset — the account-level companion to the existing per-video "Show anyway". - cache.js: per-author override store (bitvid:moderationAuthorOverrides:v1), keyed by author hex, with get/set/has/clear/list + persist/load, mirroring the per-video store. - moderationDecorator: overrideActive now also honors an account-level override for the video's author, so the whole class of WoT warnings clears for every video by that creator. - app.js: load the author overrides on startup. - Services + contract: getAuthorModerationOverrides / setAuthorModerationOverride / clearAuthorModerationOverride; set/clear refresh the feed + fire the shared moderation-override event so the Safety tab list updates. - Safety tab: a "Trusted creators" section — add by npub, list each with a Reset, mirroring the Active-overrides section. - tests: storage set/get/has/clear/list, hex normalization, persist→load, invalid-input guard. Next: the "Always show this creator" button on the video warning overlay (second entry point) — a larger multi-component UI change, done separately. Co-Authored-By: Claude Opus 4.8 <[email protected]>
938 lines
28 KiB
JavaScript
938 lines
28 KiB
JavaScript
import {
|
|
DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
|
|
DEFAULT_BLUR_THRESHOLD,
|
|
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
|
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
|
} from "../constants.js";
|
|
|
|
const noop = () => {};
|
|
|
|
export const NWC_URI_SCHEME = "nostr+walletconnect://";
|
|
const PROVIDER_BADGE_BASE_CLASS =
|
|
"text-3xs font-semibold uppercase tracking-extra-wide";
|
|
const PROVIDER_BADGE_VARIANT_CLASS_MAP = Object.freeze({
|
|
info: "text-status-info",
|
|
success: "text-status-success",
|
|
warning: "text-status-warning",
|
|
danger: "text-status-danger",
|
|
neutral: "text-muted",
|
|
accent: "text-accent",
|
|
primary: "text-accent",
|
|
});
|
|
|
|
function resolveProviderBadgeClass(variant) {
|
|
if (typeof variant === "string" && variant.trim()) {
|
|
const normalized = variant.trim().toLowerCase();
|
|
if (Object.prototype.hasOwnProperty.call(PROVIDER_BADGE_VARIANT_CLASS_MAP, normalized)) {
|
|
return PROVIDER_BADGE_VARIANT_CLASS_MAP[normalized];
|
|
}
|
|
}
|
|
|
|
return PROVIDER_BADGE_VARIANT_CLASS_MAP.neutral;
|
|
}
|
|
|
|
const DEFAULT_INTERNAL_NWC_SETTINGS = Object.freeze({
|
|
nwcUri: "",
|
|
defaultZap: null,
|
|
lastChecked: null,
|
|
});
|
|
|
|
function createInternalDefaultNwcSettings() {
|
|
return { ...DEFAULT_INTERNAL_NWC_SETTINGS };
|
|
}
|
|
|
|
const DEFAULT_INTERNAL_MODERATION_SETTINGS = Object.freeze({
|
|
blurThreshold: DEFAULT_BLUR_THRESHOLD,
|
|
autoplayBlockThreshold: DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
|
|
trustedMuteHideThreshold: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
|
trustedSpamHideThreshold: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
|
});
|
|
|
|
function createInternalDefaultModerationSettings() {
|
|
return { ...DEFAULT_INTERNAL_MODERATION_SETTINGS };
|
|
}
|
|
|
|
const DEFAULT_INTERNAL_DM_PRIVACY_SETTINGS = Object.freeze({
|
|
readReceiptsEnabled: false,
|
|
typingIndicatorsEnabled: false,
|
|
});
|
|
|
|
function createInternalDefaultDmPrivacySettings() {
|
|
return { ...DEFAULT_INTERNAL_DM_PRIVACY_SETTINGS };
|
|
}
|
|
|
|
function ensureInternalModerationSettings(internalState) {
|
|
if (!internalState || typeof internalState !== "object") {
|
|
return createInternalDefaultModerationSettings();
|
|
}
|
|
|
|
if (
|
|
!internalState.moderationSettings ||
|
|
typeof internalState.moderationSettings !== "object" ||
|
|
internalState.moderationSettings === null
|
|
) {
|
|
internalState.moderationSettings = createInternalDefaultModerationSettings();
|
|
}
|
|
|
|
return internalState.moderationSettings;
|
|
}
|
|
|
|
function ensureInternalWalletSettings(internalState) {
|
|
if (!internalState || typeof internalState !== "object") {
|
|
return createInternalDefaultNwcSettings();
|
|
}
|
|
|
|
if (
|
|
!internalState.walletSettings ||
|
|
typeof internalState.walletSettings !== "object" ||
|
|
internalState.walletSettings === null
|
|
) {
|
|
internalState.walletSettings = createInternalDefaultNwcSettings();
|
|
}
|
|
|
|
return internalState.walletSettings;
|
|
}
|
|
|
|
const SERVICE_CONTRACT = [
|
|
{
|
|
key: "normalizeHexPubkey",
|
|
type: "function",
|
|
description:
|
|
"Normalizes a provided pubkey (hex or npub) so profile lookups are stable.",
|
|
fallback: () => (value) => {
|
|
if (typeof value !== "string") {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
|
return trimmed.toLowerCase();
|
|
}
|
|
return trimmed;
|
|
},
|
|
},
|
|
{
|
|
key: "safeEncodeNpub",
|
|
type: "function",
|
|
description:
|
|
"Encodes a hex pubkey as an npub for display. Expected to be resilient to bad input.",
|
|
fallback: () => (pubkey) => {
|
|
if (typeof pubkey === "string" && pubkey.trim()) {
|
|
return pubkey.trim();
|
|
}
|
|
return null;
|
|
},
|
|
},
|
|
{
|
|
key: "safeDecodeNpub",
|
|
type: "function",
|
|
description:
|
|
"Decodes an npub back to its hex form. Should return null/undefined for invalid payloads.",
|
|
fallback: () => () => null,
|
|
},
|
|
{
|
|
key: "truncateMiddle",
|
|
type: "function",
|
|
description:
|
|
"Utility used for shortening long identifiers in the modal without losing context.",
|
|
fallback: () => (value) => value,
|
|
},
|
|
{
|
|
key: "formatShortNpub",
|
|
type: "function",
|
|
description:
|
|
"Formats npub strings for display using the canonical short representation (npubXXXX…XXXX).",
|
|
fallback: () => (value) => (typeof value === "string" ? value : ""),
|
|
},
|
|
{
|
|
key: "requestAddProfileLogin",
|
|
type: "function",
|
|
description:
|
|
"Opens the login modal and resolves with the authentication result for the add-profile flow.",
|
|
fallback: () => async () => {
|
|
throw new Error("Login service unavailable.");
|
|
},
|
|
},
|
|
{
|
|
key: "describeLoginError",
|
|
type: "function",
|
|
description:
|
|
"Maps authentication errors to human-readable strings for add-profile messaging.",
|
|
fallback: () => (_, fallbackMessage) =>
|
|
typeof fallbackMessage === "string" && fallbackMessage.trim()
|
|
? fallbackMessage.trim()
|
|
: "Failed to login. Please try again.",
|
|
},
|
|
{
|
|
key: "r2Service",
|
|
type: "object",
|
|
description: "Service for handling file uploads (R2/S3).",
|
|
optional: true,
|
|
},
|
|
{
|
|
key: "nostrService",
|
|
type: "object",
|
|
description:
|
|
"Provides encrypted direct message helpers for loading and subscribing to inbox updates.",
|
|
optional: true,
|
|
fallback: () => ({
|
|
getDirectMessages: () => [],
|
|
loadDirectMessages: async () => [],
|
|
ensureDirectMessageSubscription: () => null,
|
|
stopDirectMessageSubscription: () => {},
|
|
clearDirectMessages: () => {},
|
|
on: () => () => {},
|
|
}),
|
|
},
|
|
{
|
|
key: "hashtagPreferences",
|
|
type: "object",
|
|
description:
|
|
"Service providing access to interest and disinterest hashtag collections.",
|
|
fallback: () => ({
|
|
getInterests: () => [],
|
|
getDisinterests: () => [],
|
|
addInterest: () => false,
|
|
removeInterest: () => false,
|
|
addDisinterest: () => false,
|
|
removeDisinterest: () => false,
|
|
publish: async () => false,
|
|
on: () => () => {},
|
|
}),
|
|
},
|
|
{
|
|
key: "getHashtagPreferences",
|
|
type: "function",
|
|
description:
|
|
"Returns the cached hashtag preferences snapshot (interests, disinterests, metadata).",
|
|
fallback: ({ resolved }) => () => {
|
|
const service = resolved.hashtagPreferences || {};
|
|
const interests =
|
|
typeof service.getInterests === "function"
|
|
? service.getInterests()
|
|
: [];
|
|
const disinterests =
|
|
typeof service.getDisinterests === "function"
|
|
? service.getDisinterests()
|
|
: [];
|
|
return {
|
|
interests,
|
|
disinterests,
|
|
eventId: null,
|
|
createdAt: null,
|
|
loaded: false,
|
|
};
|
|
},
|
|
},
|
|
{
|
|
key: "describeHashtagPreferencesError",
|
|
type: "function",
|
|
description:
|
|
"Maps hashtag preference errors to user-facing status messages for the modal.",
|
|
fallback: () => (_, fallbackMessage) =>
|
|
typeof fallbackMessage === "string" && fallbackMessage.trim()
|
|
? fallbackMessage.trim()
|
|
: "Failed to update hashtag preferences. Please try again.",
|
|
},
|
|
{
|
|
key: "getProfileCacheEntry",
|
|
type: "function",
|
|
description:
|
|
"Returns the cached profile metadata for a normalized pubkey (if available).",
|
|
fallback: () => () => null,
|
|
},
|
|
{
|
|
key: "batchFetchProfiles",
|
|
type: "function",
|
|
description:
|
|
"Fetches and caches profile metadata for a set of pubkeys so the modal can hydrate avatars/names in bulk.",
|
|
fallback: () => async () => [],
|
|
},
|
|
{
|
|
key: "fetchDmRelayHints",
|
|
type: "function",
|
|
description:
|
|
"Fetches DM relay hints (kind 10050) for a given pubkey.",
|
|
fallback: () => async () => [],
|
|
},
|
|
{
|
|
key: "switchProfile",
|
|
type: "function",
|
|
description:
|
|
"Switches the active application profile to the provided pubkey and updates state accordingly.",
|
|
fallback: () => async () => ({ switched: false }),
|
|
},
|
|
{
|
|
key: "removeSavedProfile",
|
|
type: "function",
|
|
description:
|
|
"Removes a saved profile entry and persists the updated collection to storage.",
|
|
fallback: () => () => ({ removed: false }),
|
|
},
|
|
{
|
|
key: "relayManager",
|
|
type: "object",
|
|
description:
|
|
"Shared relay manager instance responsible for the user\'s relay configuration.",
|
|
},
|
|
{
|
|
key: "userBlocks",
|
|
type: "object",
|
|
description: "User blocks helper used to read and mutate the local block list.",
|
|
},
|
|
{
|
|
key: "nostrClient",
|
|
type: "object",
|
|
description: "Reference to the nostr client powering subscriptions and profile fetches.",
|
|
},
|
|
{
|
|
key: "subscriptions",
|
|
type: "object",
|
|
optional: true,
|
|
description:
|
|
"Subscriptions manager used by the profile modal to inspect or refresh the viewer's channel subscriptions.",
|
|
},
|
|
{
|
|
key: "moderation",
|
|
type: "object",
|
|
optional: true,
|
|
description:
|
|
"Moderation service exposing the viewer's Nostr contacts and trust graph metadata.",
|
|
},
|
|
{
|
|
key: "accessControl",
|
|
type: "object",
|
|
description:
|
|
"Access control module required for admin list permission checks and updates.",
|
|
},
|
|
{
|
|
key: "getCurrentUserNpub",
|
|
type: "function",
|
|
description: "Returns the active user\'s npub so UI actions can target the correct actor.",
|
|
fallback: () => () => null,
|
|
},
|
|
{
|
|
key: "nwcSettings",
|
|
type: "object",
|
|
description:
|
|
"Service that manages Nostr Wallet Connect settings and interactions for the active profile.",
|
|
fallback: ({ internalState }) => {
|
|
const ensure = () => ensureInternalWalletSettings(internalState);
|
|
const service = {};
|
|
|
|
service.createDefaultNwcSettings = () => createInternalDefaultNwcSettings();
|
|
|
|
service.getActiveNwcSettings = () => {
|
|
const current = ensure();
|
|
return { ...current };
|
|
};
|
|
|
|
service.updateActiveNwcSettings = (partial = {}) => {
|
|
const current = ensure();
|
|
const next = {
|
|
...current,
|
|
...(partial && typeof partial === "object" ? partial : {}),
|
|
};
|
|
internalState.walletSettings = next;
|
|
return { ...next };
|
|
};
|
|
|
|
service.hydrateNwcSettingsForPubkey = async () => {
|
|
return service.getActiveNwcSettings();
|
|
};
|
|
|
|
service.handleProfileWalletPersist = async (options = {}) => {
|
|
const { nwcUri, defaultZap, lastChecked } =
|
|
options && typeof options === "object" ? options : {};
|
|
const partial = {};
|
|
if (nwcUri !== undefined) {
|
|
partial.nwcUri = nwcUri;
|
|
}
|
|
if (defaultZap !== undefined) {
|
|
partial.defaultZap = defaultZap;
|
|
}
|
|
if (lastChecked !== undefined) {
|
|
partial.lastChecked = lastChecked;
|
|
}
|
|
if (!Object.keys(partial).length) {
|
|
return service.getActiveNwcSettings();
|
|
}
|
|
return service.updateActiveNwcSettings(partial);
|
|
};
|
|
|
|
service.hasActiveWalletConnection = () => {
|
|
const settings = ensure();
|
|
const candidate =
|
|
typeof settings?.nwcUri === "string" ? settings.nwcUri.trim() : "";
|
|
return candidate.length > 0;
|
|
};
|
|
|
|
service.validateWalletUri = (uri, { requireValue = false } = {}) => {
|
|
const value = typeof uri === "string" ? uri.trim() : "";
|
|
|
|
if (!value) {
|
|
if (requireValue) {
|
|
return {
|
|
valid: false,
|
|
sanitized: "",
|
|
message: "Enter a wallet connect URI before continuing.",
|
|
};
|
|
}
|
|
return { valid: true, sanitized: "" };
|
|
}
|
|
|
|
if (!value.toLowerCase().startsWith(NWC_URI_SCHEME)) {
|
|
return {
|
|
valid: false,
|
|
sanitized: value,
|
|
message: `Wallet URI must start with ${NWC_URI_SCHEME}.`,
|
|
};
|
|
}
|
|
|
|
return { valid: true, sanitized: value };
|
|
};
|
|
|
|
service.ensureWallet = async ({ nwcUri } = {}) => {
|
|
const settings = service.getActiveNwcSettings();
|
|
const candidate =
|
|
typeof nwcUri === "string" && nwcUri.trim()
|
|
? nwcUri.trim()
|
|
: settings.nwcUri || "";
|
|
const { valid, sanitized, message } = service.validateWalletUri(
|
|
candidate,
|
|
{ requireValue: true },
|
|
);
|
|
if (!valid) {
|
|
throw new Error(message || "Invalid wallet URI provided.");
|
|
}
|
|
return { ...settings, nwcUri: sanitized };
|
|
};
|
|
|
|
service.clearStoredNwcSettings = async () => true;
|
|
|
|
service.onLogin = async () => service.getActiveNwcSettings();
|
|
|
|
service.onLogout = async () => service.createDefaultNwcSettings();
|
|
|
|
service.onIdentityChanged = () => {
|
|
internalState.walletSettings = createInternalDefaultNwcSettings();
|
|
};
|
|
|
|
return service;
|
|
},
|
|
},
|
|
{
|
|
key: "moderationSettings",
|
|
type: "object",
|
|
description:
|
|
"Service that manages Safety & Moderation threshold overrides for the active profile.",
|
|
fallback: ({ internalState }) => {
|
|
const ensure = () => ensureInternalModerationSettings(internalState);
|
|
const sanitize = (value, fallback) => {
|
|
if (value === null || value === undefined) {
|
|
return fallback;
|
|
}
|
|
const numeric = Number(value);
|
|
if (!Number.isFinite(numeric)) {
|
|
return fallback;
|
|
}
|
|
return Math.max(0, Math.floor(numeric));
|
|
};
|
|
|
|
const service = {};
|
|
|
|
service.getDefaultModerationSettings = () =>
|
|
createInternalDefaultModerationSettings();
|
|
|
|
service.getActiveModerationSettings = () => {
|
|
const current = ensure();
|
|
const defaults = service.getDefaultModerationSettings();
|
|
return {
|
|
blurThreshold: sanitize(current.blurThreshold, defaults.blurThreshold),
|
|
autoplayBlockThreshold: sanitize(
|
|
current.autoplayBlockThreshold,
|
|
defaults.autoplayBlockThreshold,
|
|
),
|
|
};
|
|
};
|
|
|
|
service.updateModerationSettings = (partial = {}) => {
|
|
const defaults = service.getDefaultModerationSettings();
|
|
const current = ensure();
|
|
const next = { ...current };
|
|
|
|
if (Object.prototype.hasOwnProperty.call(partial, "blurThreshold")) {
|
|
const value = partial.blurThreshold;
|
|
next.blurThreshold =
|
|
value === null
|
|
? defaults.blurThreshold
|
|
: sanitize(value, defaults.blurThreshold);
|
|
}
|
|
|
|
if (
|
|
Object.prototype.hasOwnProperty.call(
|
|
partial,
|
|
"autoplayBlockThreshold",
|
|
)
|
|
) {
|
|
const value = partial.autoplayBlockThreshold;
|
|
next.autoplayBlockThreshold =
|
|
value === null
|
|
? defaults.autoplayBlockThreshold
|
|
: sanitize(value, defaults.autoplayBlockThreshold);
|
|
}
|
|
|
|
internalState.moderationSettings = next;
|
|
return service.getActiveModerationSettings();
|
|
};
|
|
|
|
service.resetModerationSettings = () => {
|
|
internalState.moderationSettings = createInternalDefaultModerationSettings();
|
|
return service.getActiveModerationSettings();
|
|
};
|
|
|
|
return service;
|
|
},
|
|
},
|
|
{
|
|
key: "getModerationOverrides",
|
|
type: "function",
|
|
description:
|
|
"Returns a list of stored moderation overrides for the active viewer.",
|
|
fallback: () => () => [],
|
|
},
|
|
{
|
|
key: "clearModerationOverride",
|
|
type: "function",
|
|
description:
|
|
"Clears a specific moderation override when the viewer resets a decision.",
|
|
fallback: () => () => false,
|
|
},
|
|
{
|
|
key: "getAuthorModerationOverrides",
|
|
type: "function",
|
|
description:
|
|
"Returns the account-level (per-creator) web-of-trust overrides for the active viewer.",
|
|
fallback: () => () => [],
|
|
},
|
|
{
|
|
key: "setAuthorModerationOverride",
|
|
type: "function",
|
|
description:
|
|
"Adds an account-level override that suppresses WoT warnings for all of a creator's videos.",
|
|
fallback: () => () => null,
|
|
},
|
|
{
|
|
key: "clearAuthorModerationOverride",
|
|
type: "function",
|
|
description:
|
|
"Clears an account-level (per-creator) override so the WoT warnings return.",
|
|
fallback: () => () => false,
|
|
},
|
|
{
|
|
key: "loadVideos",
|
|
type: "function",
|
|
description: "Triggers a video reload so UI reflects profile or permission changes.",
|
|
fallback: () => async () => undefined,
|
|
},
|
|
{
|
|
key: "onVideosShouldRefresh",
|
|
type: "function",
|
|
description:
|
|
"Signals that video listings should be refreshed after a profile-driven mutation.",
|
|
fallback: ({ services, resolved }) => async (context = {}) => {
|
|
const loader =
|
|
typeof services.loadVideos === "function"
|
|
? services.loadVideos
|
|
: typeof resolved.loadVideos === "function"
|
|
? resolved.loadVideos
|
|
: null;
|
|
if (loader) {
|
|
return loader(true, context);
|
|
}
|
|
return undefined;
|
|
},
|
|
},
|
|
{
|
|
key: "sendAdminListNotification",
|
|
type: "function",
|
|
description:
|
|
"Sends an administrative notification when moderators/whitelist/blacklist entries change.",
|
|
optional: true,
|
|
},
|
|
{
|
|
key: "describeAdminError",
|
|
type: "function",
|
|
description:
|
|
"Maps low-level admin errors to readable copy for surfaced error messages.",
|
|
fallback: () => () => "",
|
|
},
|
|
{
|
|
key: "describeNotificationError",
|
|
type: "function",
|
|
description:
|
|
"Maps notification send errors to human readable descriptions for toast/banner messaging.",
|
|
fallback: () => () => "",
|
|
},
|
|
{
|
|
key: "onAccessControlUpdated",
|
|
type: "function",
|
|
description:
|
|
"Callback invoked after access control mutations so the app can refresh dependent UI.",
|
|
fallback: () => async () => undefined,
|
|
},
|
|
{
|
|
key: "persistSavedProfiles",
|
|
type: "function",
|
|
description:
|
|
"Persists the saved profile collection (and optionally the active profile) to storage.",
|
|
fallback: () => () => undefined,
|
|
},
|
|
];
|
|
|
|
const STATE_CONTRACT = [
|
|
{
|
|
key: "getSavedProfiles",
|
|
type: "function",
|
|
description:
|
|
"Returns the saved profile entries that populate the account switcher UI.",
|
|
fallback: (internal) => () => internal.savedProfiles.slice(),
|
|
},
|
|
{
|
|
key: "setSavedProfiles",
|
|
type: "function",
|
|
description:
|
|
"Replaces the saved profile entries and returns the newly stored collection.",
|
|
fallback: (internal) => (profiles = []) => {
|
|
internal.savedProfiles = Array.isArray(profiles)
|
|
? profiles.slice()
|
|
: [];
|
|
return internal.savedProfiles;
|
|
},
|
|
},
|
|
{
|
|
key: "persistSavedProfiles",
|
|
type: "function",
|
|
description:
|
|
"Persists the saved profile collection (and optionally the active profile) to storage.",
|
|
fallback: () => noop,
|
|
},
|
|
{
|
|
key: "getActivePubkey",
|
|
type: "function",
|
|
description: "Returns the currently active profile pubkey.",
|
|
fallback: (internal) => () => internal.activePubkey,
|
|
},
|
|
{
|
|
key: "setActivePubkey",
|
|
type: "function",
|
|
description: "Updates the active profile pubkey and returns the stored value.",
|
|
fallback: (internal) => (pubkey) => {
|
|
if (typeof pubkey === "string") {
|
|
const trimmed = pubkey.trim();
|
|
internal.activePubkey = trimmed ? trimmed : null;
|
|
} else {
|
|
internal.activePubkey = null;
|
|
}
|
|
return internal.activePubkey;
|
|
},
|
|
},
|
|
{
|
|
key: "getCachedSelection",
|
|
type: "function",
|
|
description:
|
|
"Reads the cached profile selection used to restore switcher focus after reloads.",
|
|
fallback: (internal) => () => internal.cachedSelection,
|
|
},
|
|
{
|
|
key: "setCachedSelection",
|
|
type: "function",
|
|
description:
|
|
"Caches the last selected profile identifier and returns the stored value.",
|
|
fallback: (internal) => (value) => {
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
internal.cachedSelection = trimmed || null;
|
|
} else {
|
|
internal.cachedSelection = null;
|
|
}
|
|
return internal.cachedSelection;
|
|
},
|
|
},
|
|
{
|
|
key: "getActivePane",
|
|
type: "function",
|
|
description: "Returns the identifier for the currently visible profile modal pane.",
|
|
fallback: (internal) => () => internal.activePane,
|
|
},
|
|
{
|
|
key: "setActivePane",
|
|
type: "function",
|
|
description: "Updates the active pane identifier and returns the stored value.",
|
|
fallback: (internal) => (pane) => {
|
|
if (typeof pane === "string") {
|
|
const trimmed = pane.trim().toLowerCase();
|
|
internal.activePane = trimmed || "account";
|
|
} else {
|
|
internal.activePane = "account";
|
|
}
|
|
return internal.activePane;
|
|
},
|
|
},
|
|
{
|
|
key: "getWalletBusy",
|
|
type: "function",
|
|
description:
|
|
"Indicates whether wallet-related actions are currently in-flight (disables UI as needed).",
|
|
fallback: (internal) => () => internal.walletBusy,
|
|
},
|
|
{
|
|
key: "setWalletBusy",
|
|
type: "function",
|
|
description: "Updates the wallet busy flag and returns the new boolean state.",
|
|
fallback: (internal) => (isBusy) => {
|
|
internal.walletBusy = Boolean(isBusy);
|
|
return internal.walletBusy;
|
|
},
|
|
},
|
|
{
|
|
key: "getDmRecipient",
|
|
type: "function",
|
|
description:
|
|
"Returns the currently selected direct message recipient pubkey.",
|
|
fallback: (internal) => () => internal.dmRecipient,
|
|
},
|
|
{
|
|
key: "setDmRecipient",
|
|
type: "function",
|
|
description:
|
|
"Updates the selected direct message recipient pubkey and returns the stored value.",
|
|
fallback: (internal) => (pubkey) => {
|
|
if (typeof pubkey === "string") {
|
|
const trimmed = pubkey.trim();
|
|
internal.dmRecipient = trimmed || null;
|
|
} else {
|
|
internal.dmRecipient = null;
|
|
}
|
|
return internal.dmRecipient;
|
|
},
|
|
},
|
|
{
|
|
key: "getDmRelayHints",
|
|
type: "function",
|
|
description:
|
|
"Returns the cached DM relay hints for a given pubkey.",
|
|
fallback: (internal) => (pubkey) => {
|
|
const normalized =
|
|
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
|
|
if (!normalized || !(internal.dmRelayHints instanceof Map)) {
|
|
return [];
|
|
}
|
|
const hints = internal.dmRelayHints.get(normalized);
|
|
return Array.isArray(hints) ? hints.slice() : [];
|
|
},
|
|
},
|
|
{
|
|
key: "setDmRelayHints",
|
|
type: "function",
|
|
description:
|
|
"Stores DM relay hints for a given pubkey and returns the stored list.",
|
|
fallback: (internal) => (pubkey, hints = []) => {
|
|
if (!(internal.dmRelayHints instanceof Map)) {
|
|
internal.dmRelayHints = new Map();
|
|
}
|
|
const normalized =
|
|
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
|
|
if (!normalized) {
|
|
return [];
|
|
}
|
|
const stored = Array.isArray(hints) ? hints.slice() : [];
|
|
internal.dmRelayHints.set(normalized, stored);
|
|
return stored;
|
|
},
|
|
},
|
|
{
|
|
key: "getDmRelayPreferences",
|
|
type: "function",
|
|
description:
|
|
"Returns the cached DM relay hints for a given pubkey (publisher view).",
|
|
fallback: (internal) => (pubkey) => {
|
|
const normalized =
|
|
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
|
|
if (!normalized || !(internal.dmRelayHints instanceof Map)) {
|
|
return [];
|
|
}
|
|
const hints = internal.dmRelayHints.get(normalized);
|
|
return Array.isArray(hints) ? hints.slice() : [];
|
|
},
|
|
},
|
|
{
|
|
key: "setDmRelayPreferences",
|
|
type: "function",
|
|
description:
|
|
"Stores DM relay hints for a given pubkey (publisher view).",
|
|
fallback: (internal) => (pubkey, hints = []) => {
|
|
if (!(internal.dmRelayHints instanceof Map)) {
|
|
internal.dmRelayHints = new Map();
|
|
}
|
|
const normalized =
|
|
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
|
|
if (!normalized) {
|
|
return [];
|
|
}
|
|
const stored = Array.isArray(hints) ? hints.slice() : [];
|
|
internal.dmRelayHints.set(normalized, stored);
|
|
return stored;
|
|
},
|
|
},
|
|
{
|
|
key: "getDmPrivacySettings",
|
|
type: "function",
|
|
description:
|
|
"Returns the DM privacy settings (read receipts and typing indicators) for the active profile.",
|
|
fallback: (internal) => () => {
|
|
if (!(internal.dmPrivacySettings instanceof Map)) {
|
|
internal.dmPrivacySettings = new Map();
|
|
}
|
|
const settings = internal.dmPrivacySettings.get("default");
|
|
return settings ? { ...settings } : createInternalDefaultDmPrivacySettings();
|
|
},
|
|
},
|
|
{
|
|
key: "setDmPrivacySettings",
|
|
type: "function",
|
|
description:
|
|
"Updates the DM privacy settings (read receipts and typing indicators) for the active profile.",
|
|
fallback: (internal) => (settings = {}) => {
|
|
if (!(internal.dmPrivacySettings instanceof Map)) {
|
|
internal.dmPrivacySettings = new Map();
|
|
}
|
|
const base = createInternalDefaultDmPrivacySettings();
|
|
const normalized = {
|
|
readReceiptsEnabled:
|
|
typeof settings.readReceiptsEnabled === "boolean"
|
|
? settings.readReceiptsEnabled
|
|
: base.readReceiptsEnabled,
|
|
typingIndicatorsEnabled:
|
|
typeof settings.typingIndicatorsEnabled === "boolean"
|
|
? settings.typingIndicatorsEnabled
|
|
: base.typingIndicatorsEnabled,
|
|
};
|
|
internal.dmPrivacySettings.set("default", normalized);
|
|
return { ...normalized };
|
|
},
|
|
},
|
|
];
|
|
|
|
function buildServicesContract(services = {}, internalState) {
|
|
const resolved = {};
|
|
const missing = [];
|
|
|
|
SERVICE_CONTRACT.forEach((definition) => {
|
|
const provided = services[definition.key];
|
|
|
|
if (provided == null) {
|
|
if (typeof definition.fallback === "function") {
|
|
const fallbackValue = definition.fallback({
|
|
services,
|
|
resolved,
|
|
internalState,
|
|
definition,
|
|
});
|
|
|
|
if (definition.type === "function" && typeof fallbackValue === "function") {
|
|
resolved[definition.key] = fallbackValue;
|
|
return;
|
|
}
|
|
if (
|
|
definition.type === "object" &&
|
|
typeof fallbackValue === "object" &&
|
|
fallbackValue !== null
|
|
) {
|
|
resolved[definition.key] = fallbackValue;
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (definition.optional) {
|
|
return;
|
|
}
|
|
|
|
missing.push(`- ${definition.key}: ${definition.description}`);
|
|
return;
|
|
}
|
|
|
|
if (definition.type === "function" && typeof provided !== "function") {
|
|
throw new TypeError(
|
|
`Expected service \"${definition.key}\" to be a function. Received ${typeof provided}.`,
|
|
);
|
|
}
|
|
if (
|
|
definition.type === "object" &&
|
|
(typeof provided !== "object" || provided === null)
|
|
) {
|
|
throw new TypeError(
|
|
`Expected service \"${definition.key}\" to be an object. Received ${typeof provided}.`,
|
|
);
|
|
}
|
|
|
|
resolved[definition.key] = provided;
|
|
});
|
|
|
|
if (missing.length) {
|
|
throw new Error(
|
|
[
|
|
"[ProfileModalController] Missing required services for profile modal controller:",
|
|
...missing,
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
return Object.freeze({ ...services, ...resolved });
|
|
}
|
|
|
|
function buildStateContract(state = {}, internalState) {
|
|
const resolved = {};
|
|
|
|
STATE_CONTRACT.forEach((definition) => {
|
|
const value = state[definition.key];
|
|
if (typeof value === definition.type) {
|
|
resolved[definition.key] = value;
|
|
return;
|
|
}
|
|
|
|
if (definition.fallback) {
|
|
const fallback = definition.fallback(internalState);
|
|
if (typeof fallback === definition.type) {
|
|
resolved[definition.key] = fallback;
|
|
return;
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
`[ProfileModalController] Missing state handler \"${definition.key}\" (${definition.description}).`,
|
|
);
|
|
});
|
|
|
|
return { ...state, ...resolved };
|
|
}
|
|
|
|
|
|
export {
|
|
PROVIDER_BADGE_BASE_CLASS,
|
|
PROVIDER_BADGE_VARIANT_CLASS_MAP,
|
|
DEFAULT_INTERNAL_NWC_SETTINGS,
|
|
DEFAULT_INTERNAL_MODERATION_SETTINGS,
|
|
DEFAULT_INTERNAL_DM_PRIVACY_SETTINGS,
|
|
resolveProviderBadgeClass,
|
|
createInternalDefaultNwcSettings,
|
|
createInternalDefaultModerationSettings,
|
|
createInternalDefaultDmPrivacySettings,
|
|
ensureInternalModerationSettings,
|
|
ensureInternalWalletSettings,
|
|
buildServicesContract,
|
|
buildStateContract
|
|
};
|