Add DM read receipts and typing indicators

This commit is contained in:
thePR0M3TH3AN committed 2026-01-19 13:29:40 -05:00
1 parent 14ace98991
commit 3c75ac8c3f
9 files changed
+1059

No files matched your search

+36
View File
@@ -1139,6 +1139,42 @@
aria-live="polite"
></p>
</div>
<div class="card space-y-4 p-4">
<div class="space-y-1">
<h3 class="text-sm font-semibold text-text">Message activity privacy</h3>
<p class="text-xs text-muted">
Read receipts and typing indicators are optional signals published to relays.
</p>
</div>
<div class="rounded-lg border border-border/60 bg-bg-muted px-3 py-2 text-xs text-muted">
Privacy warning: enabling these signals shares message activity metadata with anyone
who can access your relay events.
</div>
<div class="space-y-2">
<label
for="profileMessagesReadReceiptsToggle"
class="inline-flex items-center gap-2 text-xs font-medium text-muted-strong"
>
<input
id="profileMessagesReadReceiptsToggle"
type="checkbox"
class="checkbox"
/>
Send read receipts
</label>
<label
for="profileMessagesTypingToggle"
class="inline-flex items-center gap-2 text-xs font-medium text-muted-strong"
>
<input
id="profileMessagesTypingToggle"
type="checkbox"
class="checkbox"
/>
Share typing indicators
</label>
</div>
</div>
<div
id="profileMessagesStatus"
class="sr-only"
+2
View File
@@ -146,6 +146,8 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
| Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', <event id>, <relay?>]` with optional address pointer `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <pubkey>]` 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`, <pointer>, <relay?>?], `['K', <root kind>]`, and `['P', <root author pubkey>, <relay?>?]` plus parent metadata `['E'\|`I`, <parent pointer>, <relay?>?, <author?>], `['K', <parent kind>]`, `['P', <parent author>, <relay?>?]`; inherits schema append tags | Plain text body sanitized to valid UTF-8 |
| DM attachment (`NOTE_TYPES.DM_ATTACHMENT`) | `15` | `['p', <recipient pubkey>]`, `['x', <sha256 of uploaded bytes>]`, `['url', <download url>]`, optional `['name', <filename>]`, `['type', <mime>]`, `['size', <bytes>]`, optional `['k', <base64 key>]`; inherits schema append tags | Empty content; attachment metadata is represented as tags |
| DM read receipt (`NOTE_TYPES.DM_READ_RECEIPT`) | `20001` | `['p', <recipient pubkey>]`, `['e', <message event id>]`, optional `['k', <message kind>]`; inherits schema append tags | Empty content; ephemeral receipt for message activity |
| DM typing indicator (`NOTE_TYPES.DM_TYPING`) | `20002` | `['p', <recipient pubkey>]`, optional `['e', <conversation event id>]`, `['t','typing']`, `['expiration', <unix seconds>]`; 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).
+2
View File
@@ -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(),
);
+18
View File
@@ -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);
}
+308
View File
@@ -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,
};
}
+116
View File
@@ -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,
+220
View File
@@ -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();
+5
View File
@@ -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 = {
+352
View File
@@ -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) {