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) {