diff --git a/components/profile-modal.html b/components/profile-modal.html index be90b6fd..cc3c1bdb 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -1055,6 +1055,7 @@ + + + + Your DM relay hints + + Publish kind 10050 so contacts can deliver NIP-17 gift-wrapped DMs to you. + + + + Publish relays + + + + Add a DM relay + + + + Add + + + + + + Sign in to send messages. + + Privacy: NIP-04 + ]`, optional `['published_at', ]`, optional `['alt', ]`, repeated `['imeta', ...]` entries describing NIP-92 media variants, optional `['duration', ]`, repeated `['text-track', , , ]`, optional `['content-warning', ]`, repeated `['segment', , , , ]`, repeated hashtags `['t', ]`, repeated participants `['p', , ]`, repeated references `['r', ]` | Plain text summary carried in the content field. Publishing is gated by the `FEATURE_PUBLISH_NIP71` runtime flag while the rollout stabilizes. | | NIP-71 short video (`NOTE_TYPES.NIP71_SHORT_VIDEO`) | `22` | Same as `NOTE_TYPES.NIP71_VIDEO`; the kind differentiates short-form presentations. | Plain text summary; gated by `FEATURE_PUBLISH_NIP71`. | | Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', ]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content | +| DM relay hints (`NOTE_TYPES.DM_RELAY_LIST`) | `10050` | Repeating `['relay', ]` tags to advertise delivery relays for NIP-17 gift-wrapped DMs | Empty content | | View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', ]` or `['a', ]`), and a stable dedupe tag `['d', ]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message | | Watch history month (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` (default `30079`) | Replaceable list tag `['d', `${WATCH_HISTORY_LIST_IDENTIFIER}:`]` with optional `['month', ]` marker plus schema append tags; no chunk pointers required. | JSON payload `{ version, month: 'YYYY-MM', items: [{ id: , watched_at?: }] }` | | Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30000` | `['d', 'subscriptions']` | NIP-04/NIP-44 encrypted JSON array of NIP-51 follow-set tuples (e.g., `[['p', ], …]`) | diff --git a/js/app.js b/js/app.js index 22eb78b0..f3599c02 100644 --- a/js/app.js +++ b/js/app.js @@ -69,6 +69,11 @@ import createPopover from "./ui/overlay/popoverEngine.js"; import { createVideoSettingsMenuPanel } from "./ui/components/videoMenuRenderers.js"; import moderationService from "./services/moderationService.js"; import { sanitizeRelayList } from "./nostr/nip46Client.js"; +import { buildDmRelayListEvent } from "./nostrEventSchemas.js"; +import { + publishEventToRelays, + assertAnyRelayAccepted, +} from "./nostrPublish.js"; import { initViewCounter, subscribeToVideoViewCount, @@ -3093,6 +3098,132 @@ class Application { return stored.slice(); } + async publishDmRelayPreferences({ pubkey, relays } = {}) { + const normalized = this.normalizeHexPubkey(pubkey || this.pubkey); + if (!normalized) { + const error = new Error("A valid pubkey is required to publish DM relays."); + error.code = "invalid-pubkey"; + throw error; + } + + const relayHints = sanitizeRelayList(Array.isArray(relays) ? relays : []); + if (!relayHints.length) { + const error = new Error("Add at least one DM relay before publishing."); + error.code = "empty"; + throw error; + } + + if (!nostrClient?.pool) { + const error = new Error( + "Nostr is not connected yet. Please try again once relays are ready.", + ); + error.code = "nostr-uninitialized"; + throw error; + } + + const signer = await nostrClient.ensureActiveSignerForPubkey(normalized); + if (!signer || typeof signer.signEvent !== "function") { + const error = new Error( + "An active signer with signEvent support is required to publish DM relays.", + ); + error.code = "nostr-extension-missing"; + throw error; + } + + if (signer.type === "extension" && nostrClient.ensureExtensionPermissions) { + const permissionResult = await nostrClient.ensureExtensionPermissions(); + if (!permissionResult?.ok) { + userLogger.warn( + "[Application] Signer permissions denied while publishing DM relays.", + permissionResult?.error, + ); + const error = new Error( + "The active signer must allow signing before publishing DM relays.", + ); + error.code = "extension-permission-denied"; + error.cause = permissionResult?.error; + throw error; + } + } + + const event = buildDmRelayListEvent({ + pubkey: normalized, + created_at: Math.floor(Date.now() / 1000), + relays: relayHints, + }); + + const signedEvent = await signer.signEvent(event); + + const relayTargets = sanitizeRelayList( + Array.isArray(nostrClient.writeRelays) && nostrClient.writeRelays.length + ? nostrClient.writeRelays + : Array.isArray(nostrClient.relays) + ? nostrClient.relays + : [], + ); + + if (!relayTargets.length) { + const error = new Error("No relay targets are available for publishing."); + error.code = "no-targets"; + throw error; + } + + const publishResults = await publishEventToRelays( + nostrClient.pool, + relayTargets, + signedEvent, + ); + + let publishSummary; + try { + publishSummary = assertAnyRelayAccepted(publishResults, { + context: "dm relay hints update", + message: "No relays accepted the DM relay list.", + }); + } catch (publishError) { + if (publishError?.relayFailures?.length) { + publishError.relayFailures.forEach( + ({ url, error: relayError, reason }) => { + userLogger.error( + `[Application] Relay ${url} rejected DM relay list: ${reason}`, + relayError || reason, + ); + }, + ); + } + throw publishError; + } + + if (publishSummary.failed.length) { + publishSummary.failed.forEach(({ url, error: relayError }) => { + const reason = + relayError instanceof Error + ? relayError.message + : relayError + ? String(relayError) + : "publish failed"; + userLogger.warn( + `[Application] Relay ${url} did not acknowledge DM relay hints: ${reason}`, + relayError, + ); + }); + } + + return { + ok: true, + event: signedEvent, + accepted: publishSummary.accepted.map(({ url }) => url), + failed: publishSummary.failed.map(({ url, error: relayError }) => ({ + url, + error: relayError || null, + })), + }; + } + + handleProfilePublishDmRelayPreferences({ relays, pubkey } = {}) { + return this.publishDmRelayPreferences({ relays, pubkey }); + } + async fetchDmRelayHints(pubkey) { const normalized = this.normalizeHexPubkey(pubkey); if (!normalized) { diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index bfb5b76e..724c8e95 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -24,6 +24,7 @@ export const NOTE_TYPES = Object.freeze({ SUBSCRIPTION_LIST: "subscriptionList", USER_BLOCK_LIST: "userBlockList", HASHTAG_PREFERENCES: "hashtagPreferences", + DM_RELAY_LIST: "dmRelayList", ADMIN_MODERATION_LIST: "adminModerationList", ADMIN_BLACKLIST: "adminBlacklist", ADMIN_WHITELIST: "adminWhitelist", @@ -309,6 +310,14 @@ const BASE_SCHEMAS = { appendTags: DEFAULT_APPEND_TAGS, content: { format: "empty", description: "Content field unused." }, }, + [NOTE_TYPES.DM_RELAY_LIST]: { + type: NOTE_TYPES.DM_RELAY_LIST, + label: "DM relay hints", + kind: 10050, + relayTagName: "relay", + appendTags: DEFAULT_APPEND_TAGS, + content: { format: "empty", description: "Content field unused." }, + }, [NOTE_TYPES.VIEW_EVENT]: { type: NOTE_TYPES.VIEW_EVENT, label: "View counter", @@ -941,6 +950,42 @@ export function buildRelayListEvent(params) { }; } +export function buildDmRelayListEvent(params) { + const { + pubkey, + created_at, + relays = [], + additionalTags = [], + } = params || {}; + const schema = getNostrEventSchema(NOTE_TYPES.DM_RELAY_LIST); + const tags = []; + const relayTagName = schema?.relayTagName || "relay"; + + if (Array.isArray(relays)) { + relays.forEach((relay) => { + const normalized = typeof relay === "string" ? relay.trim() : ""; + if (normalized) { + tags.push([relayTagName, normalized]); + } + }); + } + + appendSchemaTags(tags, schema); + + const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags); + if (sanitizedAdditionalTags.length) { + tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice())); + } + + return { + kind: schema?.kind ?? 10050, + pubkey, + created_at, + tags, + content: "", + }; +} + export function buildProfileMetadataEvent(params) { const { pubkey, diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 3d9be7c9..a2512848 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -504,6 +504,9 @@ export default class ApplicationBootstrap { setDmRecipient: (pubkey) => app.setDmRecipientPubkey(pubkey), getDmRelayHints: (pubkey) => app.getDmRelayHints(pubkey), setDmRelayHints: (pubkey, hints) => app.setDmRelayHints(pubkey, hints), + getDmRelayPreferences: (pubkey) => app.getDmRelayHints(pubkey), + setDmRelayPreferences: (pubkey, hints) => + app.setDmRelayHints(pubkey, hints), }; const profileModalCallbacks = { @@ -541,6 +544,8 @@ export default class ApplicationBootstrap { onOpenRelays: (payload) => app.handleProfileUseDmRelays(payload), onTogglePrivacy: (payload) => app.handleProfilePrivacyToggle(payload), + onPublishDmRelayPreferences: (payload) => + app.handleProfilePublishDmRelayPreferences(payload), }; app.profileController = new ProfileModalController({ diff --git a/js/ui/dm/AppShell.js b/js/ui/dm/AppShell.js index e917a8e9..968d0fa2 100644 --- a/js/ui/dm/AppShell.js +++ b/js/ui/dm/AppShell.js @@ -76,6 +76,7 @@ export class AppShell { threadState = "idle", threadErrorType = "", composerState = "idle", + privacyMode = "nip04", notifications = [], signingAdapter = null, onSelectConversation, @@ -120,12 +121,14 @@ export class AppShell { messages, state: threadState, errorType: threadErrorType, + privacyMode, }), ); main.appendChild( Composer({ document: doc, state: composerState, + privacyMode, signingAdapter, onSend: onSendMessage, }), diff --git a/js/ui/dm/Composer.js b/js/ui/dm/Composer.js index 5fead6f4..b7d35fa5 100644 --- a/js/ui/dm/Composer.js +++ b/js/ui/dm/Composer.js @@ -13,7 +13,7 @@ export function Composer({ document: doc, placeholder = "Write a message…", state = "idle", - privacyMode: initialPrivacyMode = "standard", + privacyMode: initialPrivacyMode = "nip04", signingAdapter = null, onSend, } = {}) { @@ -60,7 +60,7 @@ export function Composer({ doc, "button", "dm-composer__privacy", - "Standard", + "NIP-04", ); privacyToggle.type = "button"; privacyToggle.setAttribute("aria-pressed", "false"); @@ -121,17 +121,23 @@ export function Composer({ }); const updatePrivacyLabel = () => { - privacyToggle.textContent = privacyMode === "private" ? "Private" : "Standard"; - privacyToggle.setAttribute( - "aria-pressed", - privacyMode === "private" ? "true" : "false", - ); + const normalized = + typeof privacyMode === "string" ? privacyMode.trim().toLowerCase() : ""; + const isNip17 = normalized === "nip17" || normalized === "private"; + privacyToggle.textContent = isNip17 ? "NIP-17" : "NIP-04"; + privacyToggle.setAttribute("aria-pressed", isNip17 ? "true" : "false"); + privacyToggle.title = isNip17 + ? "NIP-17 gift-wraps your DM so relays only see the wrapper and relay hints." + : "NIP-04 sends a direct encrypted DM; relays can still see sender and recipient metadata."; }; updatePrivacyLabel(); privacyToggle.addEventListener("click", () => { - privacyMode = privacyMode === "private" ? "standard" : "private"; + const normalized = + typeof privacyMode === "string" ? privacyMode.trim().toLowerCase() : ""; + const isNip17 = normalized === "nip17" || normalized === "private"; + privacyMode = isNip17 ? "nip04" : "nip17"; updatePrivacyLabel(); }); diff --git a/js/ui/dm/MessageThread.js b/js/ui/dm/MessageThread.js index de53b872..e79b3e62 100644 --- a/js/ui/dm/MessageThread.js +++ b/js/ui/dm/MessageThread.js @@ -18,6 +18,7 @@ export function MessageThread({ messages = [], state = "idle", errorType = "", + privacyMode = "nip04", } = {}) { if (!doc) { throw new Error("MessageThread requires a document reference."); @@ -39,6 +40,21 @@ export function MessageThread({ createElement(doc, "span", "dm-message-thread__status", contact.status), ); } + const normalizedPrivacy = + typeof privacyMode === "string" ? privacyMode.trim().toLowerCase() : ""; + const isNip17 = normalizedPrivacy === "nip17" || normalizedPrivacy === "private"; + const privacyLabel = `Privacy: ${isNip17 ? "NIP-17" : "NIP-04"}`; + const privacyHint = isNip17 + ? "NIP-17 gift-wraps your DM so relays only see the wrapper and relay hints." + : "NIP-04 sends a direct encrypted DM; relays can still see sender and recipient metadata."; + const privacyBadge = createElement( + doc, + "span", + "dm-message-thread__status", + privacyLabel, + ); + privacyBadge.title = privacyHint; + header.appendChild(privacyBadge); root.appendChild(header); const timeline = createElement(doc, "div", "dm-message-thread__timeline"); diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 44452bb5..cfa4ad1c 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -21,6 +21,7 @@ import { formatHashtag, } from "../utils/hashtagNormalization.js"; import { getActiveSigner } from "../nostr/client.js"; +import { sanitizeRelayList } from "../nostr/nip46Client.js"; import { buildPublicUrl, buildR2Key } from "../r2.js"; import { buildProfileMetadataEvent } from "../nostrEventSchemas.js"; @@ -758,6 +759,40 @@ const STATE_CONTRACT = [ return stored; }, }, + { + key: "getDmRelayPreferences", + type: "function", + description: + "Returns the cached DM relay hints for a given pubkey (publisher view).", + fallback: (internal) => (pubkey) => { + const normalized = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if (!normalized || !(internal.dmRelayHints instanceof Map)) { + return []; + } + const hints = internal.dmRelayHints.get(normalized); + return Array.isArray(hints) ? hints.slice() : []; + }, + }, + { + key: "setDmRelayPreferences", + type: "function", + description: + "Stores DM relay hints for a given pubkey (publisher view).", + fallback: (internal) => (pubkey, hints = []) => { + if (!(internal.dmRelayHints instanceof Map)) { + internal.dmRelayHints = new Map(); + } + const normalized = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if (!normalized) { + return []; + } + const stored = Array.isArray(hints) ? hints.slice() : []; + internal.dmRelayHints.set(normalized, stored); + return stored; + }, + }, ]; function buildServicesContract(services = {}, internalState) { @@ -1089,6 +1124,7 @@ export class ProfileModalController { onSendDm: callbacks.onSendDm || noop, onTogglePrivacy: callbacks.onTogglePrivacy || noop, onOpenRelays: callbacks.onOpenRelays || noop, + onPublishDmRelayPreferences: callbacks.onPublishDmRelayPreferences || noop, }; this.profileModal = null; @@ -1175,6 +1211,12 @@ export class ProfileModalController { this.profileMessagesSendDmButton = null; this.profileMessagesOpenRelaysButton = null; this.profileMessagesPrivacyToggle = null; + this.profileMessagesPrivacyMode = null; + this.profileMessagesRelayList = null; + this.profileMessagesRelayInput = null; + this.profileMessagesRelayAddButton = null; + this.profileMessagesRelayPublishButton = null; + this.profileMessagesRelayStatus = null; this.walletUriInput = null; this.walletDefaultZapInput = null; this.walletSaveButton = null; @@ -1443,6 +1485,18 @@ export class ProfileModalController { document.getElementById("profileMessagesOpenRelays") || null; this.profileMessagesPrivacyToggle = document.getElementById("profileMessagesPrivacyToggle") || null; + this.profileMessagesPrivacyMode = + document.getElementById("profileMessagesPrivacyMode") || null; + this.profileMessagesRelayList = + document.getElementById("profileMessagesRelayList") || null; + this.profileMessagesRelayInput = + document.getElementById("profileMessagesRelayInput") || null; + this.profileMessagesRelayAddButton = + document.getElementById("profileMessagesRelayAdd") || null; + this.profileMessagesRelayPublishButton = + document.getElementById("profileMessagesRelayPublish") || null; + this.profileMessagesRelayStatus = + document.getElementById("profileMessagesRelayStatus") || null; if (this.pendingMessagesRender) { const { messages, actorPubkey } = this.pendingMessagesRender; @@ -1469,6 +1523,8 @@ export class ProfileModalController { } this.setMessagesLoadingState(this.messagesLoadingState || "idle"); + this.updateMessagePrivacyModeDisplay(); + this.populateDmRelayPreferences(); this.walletUriInput = document.getElementById("profileWalletUri") || null; this.walletDefaultZapInput = @@ -1850,6 +1906,235 @@ export class ProfileModalController { return null; } + resolveActiveDmRelayOwner() { + const active = this.normalizeHexPubkey(this.getActivePubkey()); + if (active) { + return active; + } + + return this.resolveActiveDmActor(); + } + + getActiveDmRelayPreferences() { + const owner = this.resolveActiveDmRelayOwner(); + if (!owner || typeof this.state.getDmRelayPreferences !== "function") { + return []; + } + + const relays = this.state.getDmRelayPreferences(owner); + return Array.isArray(relays) ? relays.slice() : []; + } + + setActiveDmRelayPreferences(relays = []) { + const owner = this.resolveActiveDmRelayOwner(); + if (!owner || typeof this.state.setDmRelayPreferences !== "function") { + return []; + } + + return this.state.setDmRelayPreferences(owner, relays); + } + + setDmRelayPreferencesStatus(message = "") { + if (!(this.profileMessagesRelayStatus instanceof HTMLElement)) { + return; + } + + const text = typeof message === "string" ? message.trim() : ""; + this.profileMessagesRelayStatus.textContent = text; + } + + updateMessagePrivacyModeDisplay() { + if (!(this.profileMessagesPrivacyMode instanceof HTMLElement)) { + return; + } + + const isNip17 = + this.profileMessagesPrivacyToggle instanceof HTMLInputElement + ? this.profileMessagesPrivacyToggle.checked + : false; + const label = isNip17 ? "NIP-17" : "NIP-04"; + this.profileMessagesPrivacyMode.textContent = `Privacy: ${label}`; + this.profileMessagesPrivacyMode.title = isNip17 + ? "NIP-17 gift-wraps your DM so relays only see the wrapper and relay hints." + : "NIP-04 sends a direct encrypted DM; relays can still see sender and recipient metadata."; + } + + async refreshDmRelayPreferences({ force = false } = {}) { + const owner = this.resolveActiveDmRelayOwner(); + if (!owner) { + this.populateDmRelayPreferences(); + return; + } + + const existing = this.getActiveDmRelayPreferences(); + if (!existing.length || force) { + if (typeof this.services.fetchDmRelayHints === "function") { + try { + const hints = await this.services.fetchDmRelayHints(owner); + if (typeof this.state.setDmRelayPreferences === "function") { + this.state.setDmRelayPreferences(owner, hints); + } + } catch (error) { + devLogger.warn( + "[profileModal] Failed to refresh DM relay hints for profile:", + error, + ); + } + } + } + + this.populateDmRelayPreferences(); + } + + populateDmRelayPreferences() { + if (!(this.profileMessagesRelayList instanceof HTMLElement)) { + return; + } + + const owner = this.resolveActiveDmRelayOwner(); + const relays = this.getActiveDmRelayPreferences(); + + this.profileMessagesRelayList.innerHTML = ""; + + if (!owner) { + const emptyState = document.createElement("li"); + emptyState.className = + "card border border-dashed border-border/60 p-4 text-center text-xs text-muted"; + emptyState.textContent = "Sign in to add DM relay hints."; + this.profileMessagesRelayList.appendChild(emptyState); + this.setDmRelayPreferencesStatus(""); + return; + } + + if (!relays.length) { + const emptyState = document.createElement("li"); + emptyState.className = + "card border border-dashed border-border/60 p-4 text-center text-xs text-muted"; + emptyState.textContent = "No DM relay hints yet."; + this.profileMessagesRelayList.appendChild(emptyState); + return; + } + + relays.forEach((url) => { + const item = document.createElement("li"); + item.className = "card flex items-center justify-between gap-3 p-3"; + + const label = document.createElement("p"); + label.className = "text-xs font-medium text-text break-all"; + label.textContent = url; + + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "btn-ghost focus-ring text-xs"; + removeBtn.dataset.variant = "danger"; + removeBtn.textContent = "Remove"; + removeBtn.addEventListener("click", () => { + void this.handleRemoveDmRelayPreference(url); + }); + + item.appendChild(label); + item.appendChild(removeBtn); + this.profileMessagesRelayList.appendChild(item); + }); + } + + async handleAddDmRelayPreference() { + const input = this.profileMessagesRelayInput; + if (!(input instanceof HTMLInputElement)) { + return; + } + + const owner = this.resolveActiveDmRelayOwner(); + if (!owner) { + this.showError("Please sign in to save DM relay hints."); + return; + } + + const rawValue = typeof input.value === "string" ? input.value.trim() : ""; + const sanitized = sanitizeRelayList([rawValue]); + const relayUrl = sanitized[0]; + if (!relayUrl) { + this.showError("Enter a valid WSS relay URL."); + return; + } + + const current = this.getActiveDmRelayPreferences(); + const next = sanitizeRelayList([...current, relayUrl]); + this.setActiveDmRelayPreferences(next); + input.value = ""; + this.populateDmRelayPreferences(); + this.setDmRelayPreferencesStatus("DM relay hint added."); + } + + async handleRemoveDmRelayPreference(url) { + const owner = this.resolveActiveDmRelayOwner(); + if (!owner) { + this.showError("Please sign in to update DM relay hints."); + return; + } + + const target = typeof url === "string" ? url.trim() : ""; + if (!target) { + return; + } + + const current = this.getActiveDmRelayPreferences(); + const next = current.filter((entry) => entry !== target); + this.setActiveDmRelayPreferences(next); + this.populateDmRelayPreferences(); + this.setDmRelayPreferencesStatus("DM relay hint removed."); + } + + async handlePublishDmRelayPreferences() { + const owner = this.resolveActiveDmRelayOwner(); + if (!owner) { + this.showError("Please sign in to publish DM relay hints."); + return; + } + + const relays = this.getActiveDmRelayPreferences(); + if (!relays.length) { + this.showError("Add at least one DM relay before publishing."); + return; + } + + const callback = this.callbacks.onPublishDmRelayPreferences; + if (!callback || callback === noop) { + this.showError("DM relay publishing is unavailable right now."); + return; + } + + this.setDmRelayPreferencesStatus("Publishing DM relay hints…"); + + try { + const result = await callback({ + pubkey: owner, + relays, + controller: this, + }); + if (result?.ok) { + const acceptedCount = Array.isArray(result.accepted) + ? result.accepted.length + : 0; + const summary = acceptedCount + ? `Published to ${acceptedCount} relay${acceptedCount === 1 ? "" : "s"}.` + : "DM relay hints published."; + this.showSuccess("DM relay hints published."); + this.setDmRelayPreferencesStatus(summary); + return; + } + this.showError("Failed to publish DM relay hints."); + this.setDmRelayPreferencesStatus("DM relay hints publish failed."); + } catch (error) { + const message = + error && typeof error.message === "string" && error.message.trim() + ? error.message.trim() + : "Failed to publish DM relay hints."; + this.showError(message); + this.setDmRelayPreferencesStatus(message); + } + } + buildDmRecipientContext(pubkey) { const normalized = this.normalizeHexPubkey(pubkey); if (!normalized) { @@ -1996,6 +2281,7 @@ export class ProfileModalController { if (this.profileMessagesPrivacyToggle instanceof HTMLInputElement) { this.profileMessagesPrivacyToggle.checked = Boolean(enabled); } + this.updateMessagePrivacyModeDisplay(); } async ensureDirectMessageSubscription(actorPubkey = null) { @@ -2098,6 +2384,8 @@ export class ProfileModalController { if (!normalized) { this.setMessagesLoadingState("unauthenticated"); this.updateMessagesReloadState(); + this.populateDmRelayPreferences(); + this.setDmRelayPreferencesStatus(""); return; } @@ -2111,6 +2399,8 @@ export class ProfileModalController { reason: "identity-change", }); } + + void this.refreshDmRelayPreferences({ force: true }); } setMessagesLoadingState(state, options = {}) { @@ -2256,6 +2546,8 @@ export class ProfileModalController { helper.setAttribute("hidden", ""); } } + + this.updateMessagePrivacyModeDisplay(); } setMessagesAnnouncement(message) { @@ -2338,6 +2630,7 @@ export class ProfileModalController { } this.dmPrivacyToggleTouched = true; + this.updateMessagePrivacyModeDisplay(); const callback = this.callbacks.onTogglePrivacy; if (callback && callback !== noop) { callback({ @@ -2413,6 +2706,7 @@ export class ProfileModalController { this.profileMessagesPrivacyToggle instanceof HTMLInputElement ? this.profileMessagesPrivacyToggle.checked : false; + const senderRelayHints = this.getActiveDmRelayPreferences(); if (useNip17 && !recipientRelayHints.length) { this.showStatus( @@ -2443,6 +2737,7 @@ export class ProfileModalController { ? { useNip17: true, recipientRelayHints, + senderRelayHints, } : {}, ); @@ -3198,6 +3493,27 @@ export class ProfileModalController { }); } + if (this.profileMessagesRelayAddButton instanceof HTMLElement) { + this.profileMessagesRelayAddButton.addEventListener("click", () => { + void this.handleAddDmRelayPreference(); + }); + } + + if (this.profileMessagesRelayInput instanceof HTMLElement) { + this.profileMessagesRelayInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + void this.handleAddDmRelayPreference(); + } + }); + } + + if (this.profileMessagesRelayPublishButton instanceof HTMLElement) { + this.profileMessagesRelayPublishButton.addEventListener("click", () => { + void this.handlePublishDmRelayPreferences(); + }); + } + if (this.profileMessageSendButton instanceof HTMLElement) { this.profileMessageSendButton.addEventListener("click", () => { void this.handleSendProfileMessage(); @@ -4694,6 +5010,7 @@ export class ProfileModalController { } else if (target === "messages") { this.resumeProfileMessages(); void this.populateProfileMessages({ reason: "pane-select" }); + void this.refreshDmRelayPreferences(); } else if (target === "wallet") { this.refreshWalletPaneState(); } else if (target === "storage") { @@ -10331,6 +10648,7 @@ export class ProfileModalController { this.refreshWalletPaneState(); this.populateHashtagPreferences(); this.handleActiveDmIdentityChanged(activePubkey); + void this.refreshDmRelayPreferences({ force: true }); postLoginPromise .then(() => { @@ -10340,6 +10658,7 @@ export class ProfileModalController { this.populateProfileRelays(); this.refreshWalletPaneState(); this.populateHashtagPreferences(); + void this.refreshDmRelayPreferences({ force: true }); }) .catch((error) => { userLogger.warn( @@ -10407,6 +10726,8 @@ export class ProfileModalController { this.clearHashtagInputs(); this.setHashtagStatus("", "muted"); this.handleActiveDmIdentityChanged(null); + this.populateDmRelayPreferences(); + this.setDmRelayPreferencesStatus(""); return true; }
+ Publish kind 10050 so contacts can deliver NIP-17 gift-wrapped DMs to you. +
+ Privacy: NIP-04 +