From 3c75ac8c3f0f5772aec65a2ca3b2a5d68d4b66d8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:29:40 -0500 Subject: [PATCH] Add DM read receipts and typing indicators --- components/profile-modal.html | 36 ++++ docs/nostr-event-schemas.md | 2 + js/app.js | 2 + js/nostr/client.js | 18 ++ js/nostr/dmSignalEvents.js | 308 ++++++++++++++++++++++++++++ js/nostrEventSchemas.js | 116 +++++++++++ js/state/cache.js | 220 ++++++++++++++++++++ js/ui/applicationBootstrap.js | 5 + js/ui/profileModalController.js | 352 ++++++++++++++++++++++++++++++++ 9 files changed, 1059 insertions(+) create mode 100644 js/nostr/dmSignalEvents.js diff --git a/components/profile-modal.html b/components/profile-modal.html index d9e40350..9ba53b51 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -1139,6 +1139,42 @@ aria-live="polite" >

+
+
+

Message activity privacy

+

+ Read receipts and typing indicators are optional signals published to relays. +

+
+
+ Privacy warning: enabling these signals shares message activity metadata with anyone + who can access your relay events. +
+
+ + +
+
, ]` with optional address pointer `['a', , ]`, and `['p', ]` when the origin author is known; inherits schema append tags | JSON-serialized event being reposted | | Video comment (`NOTE_TYPES.VIDEO_COMMENT`) | `1111` | NIP-22 root scope tags `['A'\|`E`\|`I`, , ?], `['K', ]`, and `['P', , ?]` plus parent metadata `['E'\|`I`, , ?, ], `['K', ]`, `['P', , ?]`; inherits schema append tags | Plain text body sanitized to valid UTF-8 | | DM attachment (`NOTE_TYPES.DM_ATTACHMENT`) | `15` | `['p', ]`, `['x', ]`, `['url', ]`, optional `['name', ]`, `['type', ]`, `['size', ]`, optional `['k', ]`; inherits schema append tags | Empty content; attachment metadata is represented as tags | +| DM read receipt (`NOTE_TYPES.DM_READ_RECEIPT`) | `20001` | `['p', ]`, `['e', ]`, optional `['k', ]`; inherits schema append tags | Empty content; ephemeral receipt for message activity | +| DM typing indicator (`NOTE_TYPES.DM_TYPING`) | `20002` | `['p', ]`, optional `['e', ]`, `['t','typing']`, `['expiration', ]`; inherits schema append tags | Empty content; ephemeral typing indicator that expires quickly | > **Publishing note:** Session actors only emit passive telemetry (for example, view counters) and must **not** sign video comments. Require a logged-in Nostr signer for comment publishing via [`commentEvents`](../js/nostr/commentEvents.js). diff --git a/js/app.js b/js/app.js index f3599c02..271ddac0 100644 --- a/js/app.js +++ b/js/app.js @@ -145,6 +145,7 @@ import { setModerationSettings, resetModerationSettings, loadModerationSettingsFromStorage, + loadDmPrivacySettingsFromStorage, URL_PROBE_TIMEOUT_MS, urlHealthConstants, } from "./state/cache.js"; @@ -423,6 +424,7 @@ class Application { loadModerationOverridesFromStorage(); loadModerationSettingsFromStorage(); + loadDmPrivacySettingsFromStorage(); this.moderationSettings = this.normalizeModerationSettings( getModerationSettings(), ); diff --git a/js/nostr/client.js b/js/nostr/client.js index 0dc80874..1aeafe4b 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -79,6 +79,10 @@ import { listVideoComments as listVideoCommentsForClient, subscribeVideoComments as subscribeVideoCommentsForClient, } from "./commentEvents.js"; +import { + publishDmReadReceipt as publishDmReadReceiptForClient, + publishDmTypingIndicator as publishDmTypingIndicatorForClient, +} from "./dmSignalEvents.js"; import { logCountTimeoutCleanupFailure, logRelayCountFailure, @@ -3674,6 +3678,20 @@ export class NostrClient { }); } + async publishDmReadReceipt(payload, options = {}) { + return publishDmReadReceiptForClient(this, payload, options, { + shouldRequestExtensionPermissions, + DEFAULT_NIP07_PERMISSION_METHODS, + }); + } + + async publishDmTypingIndicator(payload, options = {}) { + return publishDmTypingIndicatorForClient(this, payload, options, { + shouldRequestExtensionPermissions, + DEFAULT_NIP07_PERMISSION_METHODS, + }); + } + async fetchVideoComments(target, options = {}) { return listVideoCommentsForClient(this, target, options); } diff --git a/js/nostr/dmSignalEvents.js b/js/nostr/dmSignalEvents.js new file mode 100644 index 00000000..b138d497 --- /dev/null +++ b/js/nostr/dmSignalEvents.js @@ -0,0 +1,308 @@ +import { + buildDmReadReceiptEvent, + buildDmTypingIndicatorEvent, + sanitizeAdditionalTags, +} from "../nostrEventSchemas.js"; +import { publishEventToRelay } from "../nostrPublish.js"; +import { RELAY_URLS } from "./toolkit.js"; +import { devLogger, userLogger } from "../utils/logger.js"; +import { queueSignEvent } from "./signRequestQueue.js"; +import { getActiveSigner } from "../nostrClientRegistry.js"; +import { isSessionActor } from "./sessionActor.js"; + +const DEFAULT_TYPING_EXPIRY_SECONDS = 15; + +function sanitizeRelayList(primary, fallback) { + if (Array.isArray(primary) && primary.length) { + return primary; + } + if (Array.isArray(fallback) && fallback.length) { + return fallback; + } + return RELAY_URLS; +} + +function normalizeString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +export async function publishDmReadReceipt( + client, + payload = {}, + options = {}, + { + shouldRequestExtensionPermissions, + DEFAULT_NIP07_PERMISSION_METHODS, + } = {}, +) { + if (!client?.pool) { + return { ok: false, error: "nostr-uninitialized" }; + } + + if (isSessionActor(client)) { + const error = new Error( + "Publishing read receipts is not allowed for session actors.", + ); + error.code = "session-actor-publish-blocked"; + return { ok: false, error }; + } + + const eventId = normalizeString(payload.eventId); + const recipientPubkey = normalizeString(payload.recipientPubkey); + + if (!eventId || !recipientPubkey) { + return { ok: false, error: "invalid-target" }; + } + + const actorPubkey = + typeof client?.pubkey === "string" && client.pubkey.trim() + ? client.pubkey.trim() + : ""; + if (!actorPubkey) { + return { ok: false, error: "auth-required" }; + } + + const createdAt = + typeof options.created_at === "number" && options.created_at > 0 + ? Math.floor(options.created_at) + : Math.floor(Date.now() / 1000); + + const additionalTags = sanitizeAdditionalTags(payload.additionalTags); + + const event = buildDmReadReceiptEvent({ + pubkey: actorPubkey, + created_at: createdAt, + recipientPubkey, + eventId, + messageKind: payload.messageKind, + additionalTags, + }); + + let signedEvent = null; + + const signer = getActiveSigner(); + + if (!signer || typeof signer.signEvent !== "function") { + const error = new Error( + "Login required: an active signer is needed to publish read receipts.", + ); + error.code = "auth-required"; + return { + ok: false, + error: "auth-required", + message: error.message, + details: error, + }; + } + + let permissionResult = { ok: true }; + const shouldRequestPermissions = + typeof shouldRequestExtensionPermissions === "function" + ? shouldRequestExtensionPermissions(signer) + : false; + + if (shouldRequestPermissions) { + permissionResult = await client.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS, + ); + } + + if (!permissionResult.ok) { + userLogger.warn( + "[nostr] Active signer permissions missing; read receipts require login.", + permissionResult.error, + ); + return { + ok: false, + error: "auth-required", + details: permissionResult, + }; + } + + try { + signedEvent = await queueSignEvent(signer, event, { + timeoutMs: options?.timeoutMs, + }); + } catch (error) { + userLogger.warn( + "[nostr] Failed to sign read receipt event with active signer:", + error, + ); + return { ok: false, error: "signing-failed", details: error }; + } + + const relayList = sanitizeRelayList( + payload.relays, + client.writeRelays || client.relays, + ); + + const publishResults = await Promise.all( + relayList.map((url) => publishEventToRelay(client.pool, url, signedEvent)), + ); + + const acceptedRelays = publishResults + .filter((result) => result.success) + .map((result) => result.url) + .filter((url) => typeof url === "string" && url); + + const success = acceptedRelays.length > 0; + + if (success) { + devLogger.info( + `[nostr] Read receipt accepted by ${acceptedRelays.length} relay(s):`, + acceptedRelays.join(", "), + ); + } else { + userLogger.warn("[nostr] Read receipt rejected by relays:", publishResults); + } + + return { + ok: success, + event: signedEvent, + results: publishResults, + acceptedRelays, + }; +} + +export async function publishDmTypingIndicator( + client, + payload = {}, + options = {}, + { + shouldRequestExtensionPermissions, + DEFAULT_NIP07_PERMISSION_METHODS, + } = {}, +) { + if (!client?.pool) { + return { ok: false, error: "nostr-uninitialized" }; + } + + if (isSessionActor(client)) { + const error = new Error( + "Publishing typing indicators is not allowed for session actors.", + ); + error.code = "session-actor-publish-blocked"; + return { ok: false, error }; + } + + const recipientPubkey = normalizeString(payload.recipientPubkey); + if (!recipientPubkey) { + return { ok: false, error: "invalid-target" }; + } + + const actorPubkey = + typeof client?.pubkey === "string" && client.pubkey.trim() + ? client.pubkey.trim() + : ""; + if (!actorPubkey) { + return { ok: false, error: "auth-required" }; + } + + const createdAt = + typeof options.created_at === "number" && options.created_at > 0 + ? Math.floor(options.created_at) + : Math.floor(Date.now() / 1000); + + const expiresInSeconds = Number.isFinite(payload.expiresInSeconds) + ? Math.max(1, Math.floor(payload.expiresInSeconds)) + : DEFAULT_TYPING_EXPIRY_SECONDS; + const expiresAt = createdAt + expiresInSeconds; + + const additionalTags = sanitizeAdditionalTags(payload.additionalTags); + + const event = buildDmTypingIndicatorEvent({ + pubkey: actorPubkey, + created_at: createdAt, + recipientPubkey, + eventId: payload.conversationEventId, + expiresAt, + additionalTags, + }); + + let signedEvent = null; + + const signer = getActiveSigner(); + + if (!signer || typeof signer.signEvent !== "function") { + const error = new Error( + "Login required: an active signer is needed to publish typing indicators.", + ); + error.code = "auth-required"; + return { + ok: false, + error: "auth-required", + message: error.message, + details: error, + }; + } + + let permissionResult = { ok: true }; + const shouldRequestPermissions = + typeof shouldRequestExtensionPermissions === "function" + ? shouldRequestExtensionPermissions(signer) + : false; + + if (shouldRequestPermissions) { + permissionResult = await client.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS, + ); + } + + if (!permissionResult.ok) { + userLogger.warn( + "[nostr] Active signer permissions missing; typing indicators require login.", + permissionResult.error, + ); + return { + ok: false, + error: "auth-required", + details: permissionResult, + }; + } + + try { + signedEvent = await queueSignEvent(signer, event, { + timeoutMs: options?.timeoutMs, + }); + } catch (error) { + userLogger.warn( + "[nostr] Failed to sign typing indicator event with active signer:", + error, + ); + return { ok: false, error: "signing-failed", details: error }; + } + + const relayList = sanitizeRelayList( + payload.relays, + client.writeRelays || client.relays, + ); + + const publishResults = await Promise.all( + relayList.map((url) => publishEventToRelay(client.pool, url, signedEvent)), + ); + + const acceptedRelays = publishResults + .filter((result) => result.success) + .map((result) => result.url) + .filter((url) => typeof url === "string" && url); + + const success = acceptedRelays.length > 0; + + if (success) { + devLogger.debug( + `[nostr] Typing indicator accepted by ${acceptedRelays.length} relay(s).`, + ); + } else { + devLogger.warn( + "[nostr] Typing indicator rejected by relays:", + publishResults, + ); + } + + return { + ok: success, + event: signedEvent, + results: publishResults, + acceptedRelays, + }; +} diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index d5f8ef2b..0694e3db 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -21,6 +21,8 @@ export const NOTE_TYPES = Object.freeze({ VIDEO_REACTION: "videoReaction", VIDEO_COMMENT: "videoComment", DM_ATTACHMENT: "dmAttachment", + DM_READ_RECEIPT: "dmReadReceipt", + DM_TYPING: "dmTypingIndicator", ZAP_REQUEST: "zapRequest", ZAP_RECEIPT: "zapReceipt", WATCH_HISTORY: "watchHistory", @@ -382,6 +384,34 @@ const BASE_SCHEMAS = { description: "Attachment metadata is carried in tags; content is empty.", }, }, + [NOTE_TYPES.DM_READ_RECEIPT]: { + type: NOTE_TYPES.DM_READ_RECEIPT, + label: "DM read receipt", + kind: 20001, + recipientTagName: "p", + eventTagName: "e", + kindTagName: "k", + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "empty", + description: "Ephemeral read receipt pointer for a DM event.", + }, + }, + [NOTE_TYPES.DM_TYPING]: { + type: NOTE_TYPES.DM_TYPING, + label: "DM typing indicator", + kind: 20002, + recipientTagName: "p", + eventTagName: "e", + statusTagName: "t", + statusTagValue: "typing", + expirationTagName: "expiration", + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "empty", + description: "Ephemeral typing indicator with an expiration timestamp.", + }, + }, [NOTE_TYPES.ZAP_REQUEST]: { type: NOTE_TYPES.ZAP_REQUEST, label: "Zap request", @@ -1155,6 +1185,92 @@ export function buildDmAttachmentEvent(params) { }; } +export function buildDmReadReceiptEvent(params) { + const { + pubkey, + created_at, + recipientPubkey, + eventId, + messageKind, + additionalTags = [], + } = params || {}; + const schema = getNostrEventSchema(NOTE_TYPES.DM_READ_RECEIPT); + const tags = []; + + const recipient = normalizePointerIdentifier(recipientPubkey); + if (recipient) { + tags.push([schema?.recipientTagName || "p", recipient]); + } + + const normalizedEventId = normalizePointerIdentifier(eventId); + if (normalizedEventId) { + tags.push([schema?.eventTagName || "e", normalizedEventId]); + } + + if (Number.isFinite(messageKind)) { + tags.push([schema?.kindTagName || "k", String(Math.floor(messageKind))]); + } + + appendSchemaTags(tags, schema); + const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags); + if (sanitizedAdditionalTags.length) { + tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice())); + } + + return { + kind: schema?.kind ?? 20001, + pubkey, + created_at, + tags, + content: "", + }; +} + +export function buildDmTypingIndicatorEvent(params) { + const { + pubkey, + created_at, + recipientPubkey, + eventId, + expiresAt, + additionalTags = [], + } = params || {}; + const schema = getNostrEventSchema(NOTE_TYPES.DM_TYPING); + const tags = []; + + const recipient = normalizePointerIdentifier(recipientPubkey); + if (recipient) { + tags.push([schema?.recipientTagName || "p", recipient]); + } + + const normalizedEventId = normalizePointerIdentifier(eventId); + if (normalizedEventId) { + tags.push([schema?.eventTagName || "e", normalizedEventId]); + } + + if (schema?.statusTagName && schema?.statusTagValue) { + tags.push([schema.statusTagName, schema.statusTagValue]); + } + + if (Number.isFinite(expiresAt)) { + tags.push([schema?.expirationTagName || "expiration", String(Math.floor(expiresAt))]); + } + + appendSchemaTags(tags, schema); + const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags); + if (sanitizedAdditionalTags.length) { + tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice())); + } + + return { + kind: schema?.kind ?? 20002, + pubkey, + created_at, + tags, + content: "", + }; +} + export function buildViewEvent(params) { const { pubkey, diff --git a/js/state/cache.js b/js/state/cache.js index f05109bb..2a1995e6 100644 --- a/js/state/cache.js +++ b/js/state/cache.js @@ -34,6 +34,8 @@ const LEGACY_MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v1"; const MODERATION_OVERRIDE_STORAGE_VERSION = 2; const MODERATION_SETTINGS_STORAGE_KEY = "bitvid:moderationSettings:v1"; const MODERATION_SETTINGS_STORAGE_VERSION = 3; +const DM_PRIVACY_SETTINGS_STORAGE_KEY = "bitvid:dmPrivacySettings:v1"; +const DM_PRIVACY_SETTINGS_STORAGE_VERSION = 1; function computeDefaultModerationSettings() { const defaultBlur = sanitizeModerationThreshold( @@ -114,6 +116,22 @@ moderationSettingsStore.set( createDefaultModerationSettings(), ); +function createDefaultDmPrivacySettings() { + return { + readReceiptsEnabled: false, + typingIndicatorsEnabled: false, + }; +} + +const GLOBAL_DM_PRIVACY_PROFILE_KEY = "__dmPrivacyDefault__"; +const dmPrivacySettingsStore = createProfileSettingsStore({ + clone: (value) => ({ ...value }), +}); +dmPrivacySettingsStore.set( + GLOBAL_DM_PRIVACY_PROFILE_KEY, + createDefaultDmPrivacySettings(), +); + function sanitizeModerationThreshold(value, fallback) { if (value === null || value === undefined) { return fallback; @@ -1120,6 +1138,208 @@ function persistModerationSettingsToStorage() { } } +function getDmPrivacyStoreKeyForPubkey(pubkey) { + const normalized = normalizeHexPubkey(pubkey); + return normalized || GLOBAL_DM_PRIVACY_PROFILE_KEY; +} + +function ensureDmPrivacySettingsForKey(key) { + const storeKey = key || GLOBAL_DM_PRIVACY_PROFILE_KEY; + if (!dmPrivacySettingsStore.has(storeKey)) { + dmPrivacySettingsStore.set(storeKey, createDefaultDmPrivacySettings()); + } + return dmPrivacySettingsStore.get(storeKey); +} + +function normalizeDmPrivacySettings(partial = {}) { + const defaults = createDefaultDmPrivacySettings(); + const resolved = partial && typeof partial === "object" ? partial : {}; + return { + readReceiptsEnabled: + typeof resolved.readReceiptsEnabled === "boolean" + ? resolved.readReceiptsEnabled + : defaults.readReceiptsEnabled, + typingIndicatorsEnabled: + typeof resolved.typingIndicatorsEnabled === "boolean" + ? resolved.typingIndicatorsEnabled + : defaults.typingIndicatorsEnabled, + }; +} + +export function getDefaultDmPrivacySettings() { + return createDefaultDmPrivacySettings(); +} + +export function getDmPrivacySettingsForPubkey(pubkey) { + const key = getDmPrivacyStoreKeyForPubkey(pubkey); + const settings = ensureDmPrivacySettingsForKey(key); + return { ...settings }; +} + +export function getDmPrivacySettings() { + return getDmPrivacySettingsForPubkey(activeProfilePubkey); +} + +export function setDmPrivacySettings(partial = {}, options = {}) { + const targetPubkey = + typeof options?.pubkey === "string" ? options.pubkey : activeProfilePubkey; + const key = getDmPrivacyStoreKeyForPubkey(targetPubkey); + const existing = ensureDmPrivacySettingsForKey(key); + const normalized = normalizeDmPrivacySettings({ ...existing, ...partial }); + dmPrivacySettingsStore.set(key, normalized); + persistDmPrivacySettingsToStorage(); + return { ...normalized }; +} + +export function loadDmPrivacySettingsFromStorage() { + dmPrivacySettingsStore.clear(); + dmPrivacySettingsStore.set( + GLOBAL_DM_PRIVACY_PROFILE_KEY, + createDefaultDmPrivacySettings(), + ); + + if (typeof localStorage === "undefined") { + return getDmPrivacySettings(); + } + + const raw = localStorage.getItem(DM_PRIVACY_SETTINGS_STORAGE_KEY); + if (!raw) { + return getDmPrivacySettings(); + } + + let shouldRewrite = false; + + try { + const payload = JSON.parse(raw); + if (!payload || typeof payload !== "object") { + shouldRewrite = true; + } else if (payload.version === DM_PRIVACY_SETTINGS_STORAGE_VERSION) { + const defaults = createDefaultDmPrivacySettings(); + const entries = + payload.entries && typeof payload.entries === "object" + ? payload.entries + : {}; + + for (const [key, entry] of Object.entries(entries)) { + if (!entry || typeof entry !== "object") { + shouldRewrite = true; + continue; + } + + const overrides = + entry.overrides && typeof entry.overrides === "object" + ? entry.overrides + : {}; + + const storeKey = + key === "default" + ? GLOBAL_DM_PRIVACY_PROFILE_KEY + : getDmPrivacyStoreKeyForPubkey(key); + + if (!storeKey) { + shouldRewrite = true; + continue; + } + + const settings = { + readReceiptsEnabled: + typeof overrides.readReceiptsEnabled === "boolean" + ? overrides.readReceiptsEnabled + : defaults.readReceiptsEnabled, + typingIndicatorsEnabled: + typeof overrides.typingIndicatorsEnabled === "boolean" + ? overrides.typingIndicatorsEnabled + : defaults.typingIndicatorsEnabled, + }; + + dmPrivacySettingsStore.set(storeKey, settings); + } + } else { + shouldRewrite = true; + } + } catch (error) { + userLogger.warn( + "[cache.loadDmPrivacySettingsFromStorage] Failed to parse payload:", + error, + ); + shouldRewrite = true; + } + + if (shouldRewrite) { + persistDmPrivacySettingsToStorage(); + } + + return getDmPrivacySettings(); +} + +function persistDmPrivacySettingsToStorage() { + if (typeof localStorage === "undefined") { + return; + } + + const defaults = createDefaultDmPrivacySettings(); + const entries = {}; + + for (const [key, value] of dmPrivacySettingsStore.entries()) { + const settings = + value && typeof value === "object" + ? value + : createDefaultDmPrivacySettings(); + + const overrides = {}; + + if (settings.readReceiptsEnabled !== defaults.readReceiptsEnabled) { + overrides.readReceiptsEnabled = settings.readReceiptsEnabled; + } + + if (settings.typingIndicatorsEnabled !== defaults.typingIndicatorsEnabled) { + overrides.typingIndicatorsEnabled = settings.typingIndicatorsEnabled; + } + + if (Object.keys(overrides).length === 0) { + continue; + } + + const storageKey = + key === GLOBAL_DM_PRIVACY_PROFILE_KEY ? "default" : key; + + entries[storageKey] = { + overrides, + savedAt: Date.now(), + }; + } + + if (Object.keys(entries).length === 0) { + try { + localStorage.removeItem(DM_PRIVACY_SETTINGS_STORAGE_KEY); + } catch (error) { + userLogger.warn( + "[cache.persistDmPrivacySettingsToStorage] Failed to clear overrides:", + error, + ); + } + return; + } + + const payload = { + version: DM_PRIVACY_SETTINGS_STORAGE_VERSION, + entries, + savedAt: Date.now(), + }; + + try { + localStorage.setItem( + DM_PRIVACY_SETTINGS_STORAGE_KEY, + JSON.stringify(payload), + ); + } catch (error) { + userLogger.warn( + "[cache.persistDmPrivacySettingsToStorage] Failed to persist overrides:", + error, + ); + } +} + export function setModerationSettings(partial = {}, { persist = true } = {}) { const key = getActiveModerationStoreKey(); const defaults = createDefaultModerationSettings(); diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index a2512848..948be196 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -41,6 +41,8 @@ import { getModerationSettings, setModerationSettings, resetModerationSettings, + getDmPrivacySettings, + setDmPrivacySettings, clearModerationOverride, persistSavedProfiles, getSavedProfiles, @@ -507,6 +509,9 @@ export default class ApplicationBootstrap { getDmRelayPreferences: (pubkey) => app.getDmRelayHints(pubkey), setDmRelayPreferences: (pubkey, hints) => app.setDmRelayHints(pubkey, hints), + getDmPrivacySettings: () => getDmPrivacySettings(), + setDmPrivacySettings: (settings, options = {}) => + setDmPrivacySettings(settings, options), }; const profileModalCallbacks = { diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 87927e93..c48577ce 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -52,6 +52,8 @@ const DEFAULT_MAX_WALLET_DEFAULT_ZAP = 100000000; const DEFAULT_SAVED_PROFILE_LABEL = "Saved profile"; const TRUSTED_MUTE_HIDE_HELPER_TEXT = "Reaching this count hides cards (with “Show anyway”); lower signals only blur thumbnails or block autoplay."; +const TYPING_INDICATOR_TTL_SECONDS = 15; +const TYPING_INDICATOR_COOLDOWN_MS = 4000; const ADD_PROFILE_CANCELLATION_CODES = new Set([ "login-cancelled", @@ -103,6 +105,15 @@ 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(); @@ -808,6 +819,43 @@ const STATE_CONTRACT = [ 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) { @@ -1239,6 +1287,8 @@ export class ProfileModalController { this.profileMessagesRelayAddButton = null; this.profileMessagesRelayPublishButton = null; this.profileMessagesRelayStatus = null; + this.profileMessagesReadReceiptsToggle = null; + this.profileMessagesTypingToggle = null; this.profileLinkPreviewAutoToggle = null; this.walletUriInput = null; this.walletDefaultZapInput = null; @@ -1310,6 +1360,8 @@ export class ProfileModalController { this.pendingMessagesRender = null; this.messagesStatusClearTimeout = null; this.dmPrivacyToggleTouched = false; + this.dmReadReceiptCache = new Set(); + this.dmTypingLastSentAt = 0; this.dmAttachmentQueue = []; this.dmAttachmentUploads = new Map(); @@ -1536,6 +1588,10 @@ export class ProfileModalController { document.getElementById("profileMessagesRelayPublish") || null; this.profileMessagesRelayStatus = document.getElementById("profileMessagesRelayStatus") || null; + this.profileMessagesReadReceiptsToggle = + document.getElementById("profileMessagesReadReceiptsToggle") || null; + this.profileMessagesTypingToggle = + document.getElementById("profileMessagesTypingToggle") || null; this.profileLinkPreviewAutoToggle = document.getElementById("profileLinkPreviewAutoToggle") || null; @@ -1566,6 +1622,7 @@ export class ProfileModalController { this.setMessagesLoadingState(this.messagesLoadingState || "idle"); this.updateMessagePrivacyModeDisplay(); this.populateDmRelayPreferences(); + this.syncDmPrivacySettingsUi(); this.walletUriInput = document.getElementById("profileWalletUri") || null; this.walletDefaultZapInput = @@ -2000,6 +2057,86 @@ export class ProfileModalController { : "NIP-04 sends a direct encrypted DM; relays can still see sender and recipient metadata."; } + getDmPrivacySettingsSnapshot() { + const fallback = createInternalDefaultDmPrivacySettings(); + if (typeof this.state.getDmPrivacySettings !== "function") { + return { ...fallback }; + } + + const settings = this.state.getDmPrivacySettings(); + return { + readReceiptsEnabled: + typeof settings?.readReceiptsEnabled === "boolean" + ? settings.readReceiptsEnabled + : fallback.readReceiptsEnabled, + typingIndicatorsEnabled: + typeof settings?.typingIndicatorsEnabled === "boolean" + ? settings.typingIndicatorsEnabled + : fallback.typingIndicatorsEnabled, + }; + } + + persistDmPrivacySettings(partial = {}) { + if (typeof this.state.setDmPrivacySettings !== "function") { + return this.getDmPrivacySettingsSnapshot(); + } + + const resolved = + partial && typeof partial === "object" ? partial : {}; + + const current = this.getDmPrivacySettingsSnapshot(); + const merged = { + readReceiptsEnabled: + typeof resolved.readReceiptsEnabled === "boolean" + ? resolved.readReceiptsEnabled + : current.readReceiptsEnabled, + typingIndicatorsEnabled: + typeof resolved.typingIndicatorsEnabled === "boolean" + ? resolved.typingIndicatorsEnabled + : current.typingIndicatorsEnabled, + }; + + return this.state.setDmPrivacySettings(merged); + } + + syncDmPrivacySettingsUi() { + const settings = this.getDmPrivacySettingsSnapshot(); + + if (this.profileMessagesReadReceiptsToggle instanceof HTMLInputElement) { + this.profileMessagesReadReceiptsToggle.checked = + settings.readReceiptsEnabled; + } + + if (this.profileMessagesTypingToggle instanceof HTMLInputElement) { + this.profileMessagesTypingToggle.checked = + settings.typingIndicatorsEnabled; + } + } + + handleReadReceiptsToggle(enabled) { + this.persistDmPrivacySettings({ + readReceiptsEnabled: Boolean(enabled), + }); + this.syncDmPrivacySettingsUi(); + this.showStatus( + enabled + ? "Read receipts enabled for direct messages." + : "Read receipts disabled.", + ); + } + + handleTypingIndicatorsToggle(enabled) { + this.persistDmPrivacySettings({ + typingIndicatorsEnabled: Boolean(enabled), + }); + this.syncDmPrivacySettingsUi(); + this.showStatus( + enabled + ? "Typing indicators enabled for direct messages." + : "Typing indicators disabled.", + ); + } + syncLinkPreviewSettingsUi() { if (!(this.profileLinkPreviewAutoToggle instanceof HTMLInputElement)) { return; @@ -2418,6 +2555,9 @@ export class ProfileModalController { this.setDirectMessageRecipient(null, { reason: "clear" }); this.resetAttachmentQueue({ clearInput: true }); + this.dmReadReceiptCache.clear(); + this.dmTypingLastSentAt = 0; + this.syncDmPrivacySettingsUi(); if ( this.directMessagesSubscription && @@ -2941,6 +3081,10 @@ export class ProfileModalController { emptyState.setAttribute("hidden", ""); } + void this.maybePublishReadReceipt(messages, { + recipientPubkey: recipient, + }); + messages.forEach((message) => { const item = document.createElement("div"); item.className = "card flex flex-col gap-2 p-3"; @@ -3046,6 +3190,193 @@ export class ProfileModalController { container.removeAttribute("hidden"); } + resolveDirectMessageEventId(message) { + if (!message || typeof message !== "object") { + return ""; + } + + const eventId = + typeof message.event?.id === "string" ? message.event.id.trim() : ""; + if (eventId) { + return eventId; + } + + const innerId = + typeof message.message?.id === "string" ? message.message.id.trim() : ""; + return innerId; + } + + resolveLatestDirectMessageForRecipient(recipientPubkey, actorPubkey = null) { + const normalizedRecipient = + typeof recipientPubkey === "string" + ? this.normalizeHexPubkey(recipientPubkey) + : ""; + if (!normalizedRecipient || !Array.isArray(this.directMessagesCache)) { + return null; + } + + const resolvedActor = actorPubkey + ? this.normalizeHexPubkey(actorPubkey) + : this.resolveActiveDmActor(); + + let latest = null; + let latestTimestamp = 0; + + for (const entry of this.directMessagesCache) { + if (this.resolveDirectMessageRemote(entry, resolvedActor) !== normalizedRecipient) { + continue; + } + const timestamp = Number(entry?.timestamp) || 0; + if (!latest || timestamp > latestTimestamp) { + latest = entry; + latestTimestamp = timestamp; + } + } + + return latest; + } + + resolveDirectMessageKind(message) { + if (!message || typeof message !== "object") { + return null; + } + + if (Number.isFinite(message?.event?.kind)) { + return Number(message.event.kind); + } + + if (Number.isFinite(message?.message?.kind)) { + return Number(message.message.kind); + } + + return null; + } + + async maybePublishReadReceipt(messages, { recipientPubkey } = {}) { + if (!Array.isArray(messages) || !messages.length) { + return; + } + + const settings = this.getDmPrivacySettingsSnapshot(); + if (!settings.readReceiptsEnabled) { + return; + } + + if ( + !this.services.nostrClient || + typeof this.services.nostrClient.publishDmReadReceipt !== "function" + ) { + return; + } + + const normalizedRecipient = + typeof recipientPubkey === "string" + ? this.normalizeHexPubkey(recipientPubkey) + : ""; + if (!normalizedRecipient) { + return; + } + + let latestMessage = null; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const entry = messages[index]; + if ( + entry && + typeof entry === "object" && + entry.direction === "incoming" + ) { + const eventId = this.resolveDirectMessageEventId(entry); + if (eventId) { + latestMessage = entry; + break; + } + } + } + + if (!latestMessage) { + return; + } + + const eventId = this.resolveDirectMessageEventId(latestMessage); + if (!eventId) { + return; + } + + const cacheKey = `${normalizedRecipient}:${eventId}`; + if (this.dmReadReceiptCache.has(cacheKey)) { + return; + } + + const relayHints = this.buildDmRecipientContext(normalizedRecipient)?.relayHints || []; + const messageKind = this.resolveDirectMessageKind(latestMessage); + + try { + const result = await this.services.nostrClient.publishDmReadReceipt({ + eventId, + recipientPubkey: normalizedRecipient, + messageKind, + relays: relayHints, + }); + + if (result?.ok) { + this.dmReadReceiptCache.add(cacheKey); + } + } catch (error) { + devLogger.warn("[profileModal] Failed to publish read receipt:", error); + } + } + + async maybePublishTypingIndicator() { + const settings = this.getDmPrivacySettingsSnapshot(); + if (!settings.typingIndicatorsEnabled) { + return; + } + + if ( + !this.services.nostrClient || + typeof this.services.nostrClient.publishDmTypingIndicator !== "function" + ) { + return; + } + + const input = this.profileMessageInput; + const messageText = + input instanceof HTMLTextAreaElement ? input.value.trim() : ""; + if (!messageText) { + return; + } + + const recipient = this.resolveActiveDmRecipient(); + if (!recipient) { + return; + } + + const now = Date.now(); + if (now - this.dmTypingLastSentAt < TYPING_INDICATOR_COOLDOWN_MS) { + return; + } + + this.dmTypingLastSentAt = now; + + const relayHints = this.buildDmRecipientContext(recipient)?.relayHints || []; + const latestMessage = this.resolveLatestDirectMessageForRecipient( + recipient, + this.resolveActiveDmActor(), + ); + const latestEventId = this.resolveDirectMessageEventId(latestMessage); + + try { + await this.services.nostrClient.publishDmTypingIndicator({ + recipientPubkey: recipient, + conversationEventId: latestEventId || null, + relays: relayHints, + expiresInSeconds: TYPING_INDICATOR_TTL_SECONDS, + }); + } catch (error) { + devLogger.warn("[profileModal] Failed to publish typing indicator:", error); + } + } + describeDirectMessageSendError(code) { switch (code) { case "sign-event-unavailable": @@ -3928,6 +4259,24 @@ export class ProfileModalController { }); } + if (this.profileMessagesReadReceiptsToggle instanceof HTMLElement) { + this.profileMessagesReadReceiptsToggle.addEventListener("change", (event) => { + const toggle = event.currentTarget; + if (toggle instanceof HTMLInputElement) { + this.handleReadReceiptsToggle(toggle.checked); + } + }); + } + + if (this.profileMessagesTypingToggle instanceof HTMLElement) { + this.profileMessagesTypingToggle.addEventListener("change", (event) => { + const toggle = event.currentTarget; + if (toggle instanceof HTMLInputElement) { + this.handleTypingIndicatorsToggle(toggle.checked); + } + }); + } + if (this.profileLinkPreviewAutoToggle instanceof HTMLElement) { this.profileLinkPreviewAutoToggle.addEventListener("change", (event) => { const toggle = event.currentTarget; @@ -3995,6 +4344,9 @@ export class ProfileModalController { void this.handleSendProfileMessage(); } }); + this.profileMessageInput.addEventListener("input", () => { + void this.maybePublishTypingIndicator(); + }); } if (this.walletUriInput instanceof HTMLElement) {