mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add DM read receipts and typing indicators
This commit is contained in:
1 parent
14ace98991
commit
3c75ac8c3f
9 files changed
+1059
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user