Refactor DM UI: Move privacy settings, unify Zaps interface, and adjust layout

This commit is contained in:
google-labs-jules[bot] committed 2026-01-20 02:04:10 +00:00
1 parent f4cff7313a
commit 07abc8516f
6 files changed
+266 -212

No files matched your search

+51 -9
View File
@@ -2,6 +2,7 @@ import { Composer } from "./Composer.js";
import { ConversationList } from "./ConversationList.js";
import { MessageThread } from "./MessageThread.js";
import { NotificationCenter } from "./NotificationCenter.js";
import { DMPrivacySettings } from "./DMPrivacySettings.js";
import { aggregateZapTotals } from "./zapHelpers.js";
function createElement(doc, tag, className, text) {
@@ -121,6 +122,7 @@ export class AppShell {
threadErrorType = "",
composerState = "idle",
privacyMode = "nip04",
dmPrivacySettings = { readReceiptsEnabled: false, typingIndicatorsEnabled: false },
notifications = [],
signingAdapter = null,
zapConfig = null,
@@ -129,6 +131,8 @@ export class AppShell {
onSendZap,
onMarkConversationRead,
onMarkAllRead,
onToggleReadReceipts,
onToggleTypingIndicators,
} = {}) {
if (!doc) {
throw new Error("AppShell requires a document reference.");
@@ -153,13 +157,47 @@ export class AppShell {
);
const sidebar = createElement(doc, "aside", "dm-app-shell__sidebar");
const sidebarHeader = createElement(doc, "div", "dm-app-shell__sidebar-header flex items-center justify-between p-4");
const sidebarTitle = createElement(
doc,
"h1",
"dm-app-shell__title",
"dm-app-shell__title text-lg font-bold",
"Direct Messages",
);
sidebar.appendChild(sidebarTitle);
sidebarHeader.appendChild(sidebarTitle);
const settingsBtn = createElement(doc, "button", "btn-ghost btn-icon");
settingsBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`;
settingsBtn.ariaLabel = "Direct message settings";
settingsBtn.addEventListener("click", (e) => {
e.stopPropagation();
let popover = sidebar.querySelector(".dm-settings-popover");
if (popover) {
popover.remove();
return;
}
popover = DMPrivacySettings({
document: doc,
readReceiptsEnabled: dmPrivacySettings.readReceiptsEnabled,
typingIndicatorsEnabled: dmPrivacySettings.typingIndicatorsEnabled,
onToggleReadReceipts,
onToggleTypingIndicators,
});
popover.classList.add("dm-settings-popover", "absolute", "z-50", "right-4", "top-12", "bg-surface", "border", "border-border", "rounded-xl");
sidebarHeader.style.position = "relative";
sidebarHeader.appendChild(popover);
const closeHandler = (event) => {
if (!popover.contains(event.target) && event.target !== settingsBtn && !settingsBtn.contains(event.target)) {
popover.remove();
doc.removeEventListener("click", closeHandler);
}
};
doc.addEventListener("click", closeHandler);
});
sidebarHeader.appendChild(settingsBtn);
sidebar.appendChild(sidebarHeader);
sidebar.appendChild(
ConversationList({
document: doc,
@@ -172,12 +210,7 @@ export class AppShell {
);
const main = createElement(doc, "main", "dm-app-shell__main");
main.appendChild(
NotificationCenter({
document: doc,
notices: notifications,
}),
);
main.appendChild(
MessageThread({
document: doc,
@@ -191,6 +224,8 @@ export class AppShell {
totalsByConversation.get(activeConversation.id) || 0,
profileZapTotalSats: totalsByProfile.get(activeConversation.pubkey) || 0,
onMarkRead: onMarkConversationRead,
onSendZap,
zapConfig,
}),
);
main.appendChild(
@@ -202,7 +237,14 @@ export class AppShell {
zapRecipient: activeConversation,
zapConfig,
onSend: onSendMessage,
onSendZap,
// Zap button moved to MessageThread, so onSendZap here might be redundant if triggered from thread
// But Composer still handles text message sending.
}),
);
main.appendChild(
NotificationCenter({
document: doc,
notices: notifications,
}),
);
-181
View File
@@ -1,6 +1,3 @@
import { createAndPublishZapRequest, resolveZapRecipient } from "../../payments/zapRequests.js";
import { devLogger, userLogger } from "../../utils/logger.js";
function createElement(doc, tag, className, text) {
const element = doc.createElement(tag);
if (className) {
@@ -21,7 +18,6 @@ export function Composer({
zapRecipient = null,
zapConfig = null,
onSend,
onSendZap,
} = {}) {
if (!doc) {
throw new Error("Composer requires a document reference.");
@@ -87,15 +83,7 @@ export function Composer({
privacyToggle.setAttribute("aria-pressed", "false");
privacyToggle.setAttribute("aria-label", "Toggle privacy mode");
const zapToggle = createElement(doc, "button", "dm-composer__menu-item", "Zap");
zapToggle.type = "button";
zapToggle.setAttribute("role", "menuitem");
zapToggle.setAttribute("aria-expanded", "false");
zapToggle.setAttribute("aria-label", "Open zap composer");
zapToggle.setAttribute("aria-pressed", "false");
moreMenu.appendChild(privacyToggle);
moreMenu.appendChild(zapToggle);
tools.appendChild(attachButton);
tools.appendChild(moreButton);
@@ -126,64 +114,8 @@ export function Composer({
status.textContent = "Sending…";
}
const zapPanel = createElement(doc, "div", "dm-composer__zap-panel");
zapPanel.id = "dm-composer-zap-panel";
zapPanel.hidden = true;
zapToggle.setAttribute("aria-controls", zapPanel.id);
const zapRecipientLabel = createElement(
doc,
"span",
"dm-composer__zap-recipient",
zapRecipient?.name
? `Zap ${zapRecipient.name}`
: "Zap this contact",
);
zapPanel.appendChild(zapRecipientLabel);
const zapAddress = createElement(
doc,
"span",
"dm-composer__zap-address",
zapRecipient?.lightningAddress || "Lightning address required.",
);
zapPanel.appendChild(zapAddress);
const zapAmountLabel = createElement(doc, "label", "dm-composer__zap-label", "Amount (sats)");
zapAmountLabel.setAttribute("for", "dm-zap-amount-input");
const zapAmountInput = createElement(doc, "input", "dm-composer__zap-input");
zapAmountInput.type = "number";
zapAmountInput.min = "1";
zapAmountInput.step = "1";
zapAmountInput.inputMode = "numeric";
zapAmountInput.id = "dm-zap-amount-input";
zapAmountInput.placeholder = "100";
zapAmountInput.setAttribute("aria-label", "Zap amount in sats");
zapPanel.appendChild(zapAmountLabel);
zapPanel.appendChild(zapAmountInput);
const zapNoteLabel = createElement(doc, "label", "dm-composer__zap-label", "Zap note");
zapNoteLabel.setAttribute("for", "dm-zap-note-input");
const zapNoteInput = createElement(doc, "input", "dm-composer__zap-input");
zapNoteInput.type = "text";
zapNoteInput.id = "dm-zap-note-input";
zapNoteInput.placeholder = "Optional note for this zap";
zapNoteInput.setAttribute("aria-label", "Zap note");
zapPanel.appendChild(zapNoteLabel);
zapPanel.appendChild(zapNoteInput);
const zapActions = createElement(doc, "div", "dm-composer__zap-actions");
const zapSendButton = createElement(doc, "button", "dm-composer__zap-send", "Send zap");
zapSendButton.type = "button";
zapSendButton.setAttribute("aria-label", "Send zap request");
zapActions.appendChild(zapSendButton);
zapPanel.appendChild(zapActions);
const zapStatus = createElement(doc, "div", "dm-composer__zap-status");
zapPanel.appendChild(zapStatus);
form.appendChild(label);
form.appendChild(textarea);
form.appendChild(zapPanel);
form.appendChild(actions);
form.appendChild(status);
@@ -213,48 +145,6 @@ export function Composer({
onSend(textarea.value, payload);
};
let zapOpen = false;
let resolvedRecipient = null;
const setZapStatus = (message, variant = "info") => {
zapStatus.textContent = message || "";
zapStatus.dataset.state = variant;
};
const resolveRecipient = async () => {
if (!zapRecipient?.lightningAddress) {
setZapStatus("Add a Lightning address to enable zaps.", "error");
return null;
}
setZapStatus("Resolving LNURL…", "loading");
try {
const resolver =
typeof zapConfig?.resolveRecipient === "function"
? zapConfig.resolveRecipient
: resolveZapRecipient;
resolvedRecipient = await resolver(zapRecipient.lightningAddress, {
fetcher: zapConfig?.fetcher,
});
if (
!resolvedRecipient?.metadata?.allowsNostr &&
!resolvedRecipient?.metadata?.nostrPubkey
) {
setZapStatus("Recipient LNURL does not support Nostr zaps.", "error");
resolvedRecipient = null;
return null;
}
setZapStatus("LNURL ready for zaps.", "success");
return resolvedRecipient;
} catch (error) {
devLogger.warn("[dm] Failed to resolve LNURL for zap recipient.", error);
setZapStatus(
error instanceof Error ? error.message : "Failed to resolve LNURL.",
"error",
);
return null;
}
};
form.addEventListener("submit", (event) => {
event.preventDefault();
void handleSubmit();
@@ -281,77 +171,6 @@ export function Composer({
updatePrivacyLabel();
});
zapToggle.addEventListener("click", () => {
zapOpen = !zapOpen;
zapPanel.hidden = !zapOpen;
zapToggle.setAttribute("aria-expanded", zapOpen ? "true" : "false");
zapToggle.setAttribute("aria-pressed", zapOpen ? "true" : "false");
if (zapOpen) {
void resolveRecipient();
} else {
setZapStatus("");
}
});
zapSendButton.addEventListener("click", async () => {
const amount = Number(zapAmountInput.value);
if (!Number.isFinite(amount) || amount <= 0) {
setZapStatus("Enter a zap amount greater than zero.", "error");
return;
}
const resolved = resolvedRecipient || (await resolveRecipient());
if (!resolved) {
return;
}
const comment = zapNoteInput.value.trim();
const payload = {
amountSats: Math.round(amount),
comment,
recipient: zapRecipient,
resolved,
};
if (typeof onSendZap === "function") {
try {
await onSendZap(payload);
setZapStatus("Zap request sent. Awaiting receipt…", "success");
} catch (error) {
userLogger.error("[dm] Zap request failed.", error);
setZapStatus(
error instanceof Error ? error.message : "Zap request failed.",
"error",
);
}
return;
}
if (zapConfig?.signer) {
try {
await createAndPublishZapRequest({
address: zapRecipient?.lightningAddress,
recipientPubkey: zapRecipient?.pubkey,
relays: zapRecipient?.relayHints || zapConfig?.relays,
amountSats: Math.round(amount),
comment,
signer: zapConfig.signer,
pool: zapConfig.pool,
fetcher: zapConfig.fetcher,
});
setZapStatus("Zap request published. Awaiting receipt…", "success");
} catch (error) {
userLogger.error("[dm] Zap publish failed.", error);
setZapStatus(
error instanceof Error ? error.message : "Zap request failed.",
"error",
);
}
} else {
setZapStatus("Connect a Nostr signer to send zap requests.", "error");
}
});
textarea.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
+72
View File
@@ -0,0 +1,72 @@
export function DMPrivacySettings({
document: doc,
readReceiptsEnabled = false,
typingIndicatorsEnabled = false,
onToggleReadReceipts,
onToggleTypingIndicators,
} = {}) {
if (!doc) {
throw new Error("DMPrivacySettings requires a document reference.");
}
const root = doc.createElement("div");
root.className = "dm-privacy-settings card p-4 space-y-4 shadow-lg";
const title = doc.createElement("h4");
title.className = "text-sm font-semibold text-text";
title.textContent = "Direct message privacy";
root.appendChild(title);
const desc = doc.createElement("p");
desc.className = "text-xs text-muted mb-4";
desc.textContent = "Control read receipts and typing indicators for direct messages.";
root.appendChild(desc);
// Read Receipts Toggle
const readReceiptsRow = doc.createElement("div");
readReceiptsRow.className = "flex items-center justify-between";
const readReceiptsLabel = doc.createElement("label");
readReceiptsLabel.className = "text-sm text-text cursor-pointer";
readReceiptsLabel.textContent = "Read receipts";
readReceiptsLabel.setAttribute("for", "dm-read-receipts-toggle");
readReceiptsRow.appendChild(readReceiptsLabel);
const readReceiptsToggle = doc.createElement("input");
readReceiptsToggle.type = "checkbox";
readReceiptsToggle.id = "dm-read-receipts-toggle";
readReceiptsToggle.className = "toggle toggle-sm toggle-accent";
readReceiptsToggle.checked = readReceiptsEnabled;
readReceiptsToggle.addEventListener("change", (e) => {
if (typeof onToggleReadReceipts === "function") {
onToggleReadReceipts(e.target.checked);
}
});
readReceiptsRow.appendChild(readReceiptsToggle);
root.appendChild(readReceiptsRow);
// Typing Indicators Toggle
const typingIndicatorsRow = doc.createElement("div");
typingIndicatorsRow.className = "flex items-center justify-between";
const typingIndicatorsLabel = doc.createElement("label");
typingIndicatorsLabel.className = "text-sm text-text cursor-pointer";
typingIndicatorsLabel.textContent = "Typing indicators";
typingIndicatorsLabel.setAttribute("for", "dm-typing-indicators-toggle");
typingIndicatorsRow.appendChild(typingIndicatorsLabel);
const typingIndicatorsToggle = doc.createElement("input");
typingIndicatorsToggle.type = "checkbox";
typingIndicatorsToggle.id = "dm-typing-indicators-toggle";
typingIndicatorsToggle.className = "toggle toggle-sm toggle-accent";
typingIndicatorsToggle.checked = typingIndicatorsEnabled;
typingIndicatorsToggle.addEventListener("change", (e) => {
if (typeof onToggleTypingIndicators === "function") {
onToggleTypingIndicators(e.target.checked);
}
});
typingIndicatorsRow.appendChild(typingIndicatorsToggle);
root.appendChild(typingIndicatorsRow);
return root;
}
+3 -1
View File
@@ -33,7 +33,9 @@ export function MessageBubble({
const meta = createElement(doc, "div", "dm-message-bubble__meta");
if (timestamp) {
meta.appendChild(createElement(doc, "span", "dm-message-bubble__time", timestamp));
// Added 'text-muted' class for theme-respecting color (usually handles light/dark text)
const timeSpan = createElement(doc, "span", "dm-message-bubble__time text-muted", timestamp);
meta.appendChild(timeSpan);
}
if (variant === "outgoing") {
+127 -4
View File
@@ -2,6 +2,8 @@ import { DayDivider } from "./DayDivider.js";
import { MessageBubble } from "./MessageBubble.js";
import { ZapReceiptList } from "./ZapReceiptList.js";
import { formatZapAmount } from "./zapHelpers.js";
import { createAndPublishZapRequest, resolveZapRecipient } from "../../payments/zapRequests.js";
import { userLogger, devLogger } from "../../utils/logger.js";
function createElement(doc, tag, className, text) {
const element = doc.createElement(tag);
@@ -25,6 +27,8 @@ export function MessageThread({
conversationZapTotalSats = 0,
profileZapTotalSats = 0,
onMarkRead,
onSendZap,
zapConfig = null,
} = {}) {
if (!doc) {
throw new Error("MessageThread requires a document reference.");
@@ -84,6 +88,21 @@ export function MessageThread({
);
header.appendChild(zapSummary);
// Zap Button
const zapBtn = createElement(doc, "button", "btn-ghost btn-icon");
zapBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>`;
zapBtn.ariaLabel = "Zap";
zapBtn.addEventListener("click", () => {
const existingInterface = root.querySelector(".dm-zap-interface");
if (existingInterface) {
existingInterface.remove();
return;
}
const zapInterface = createZapInterface(doc, { contact, zapReceipts, onSendZap, zapConfig });
root.appendChild(zapInterface);
});
header.appendChild(zapBtn);
if (typeof onMarkRead === "function") {
const actions = createElement(doc, "div", "ml-auto flex items-center gap-2");
const markReadButton = createElement(doc, "button", "btn-ghost", "Mark read");
@@ -146,14 +165,118 @@ export function MessageThread({
});
}
root.appendChild(timeline);
// Scroll to bottom
if (messages.length > 0) {
setTimeout(() => {
timeline.scrollTop = timeline.scrollHeight;
}, 0);
}
root.appendChild(
root.appendChild(timeline);
return root;
}
function createZapInterface(doc, { contact, zapReceipts, onSendZap, zapConfig }) {
const container = createElement(doc, "div", "dm-zap-interface card p-4 absolute bottom-16 right-4 z-20 w-80 shadow-xl border border-border bg-surface");
const closeBtn = createElement(doc, "button", "absolute top-2 right-2 btn-ghost btn-xs", "✕");
closeBtn.addEventListener("click", () => container.remove());
container.appendChild(closeBtn);
const title = createElement(doc, "h4", "text-sm font-bold mb-2", "Send Zap");
container.appendChild(title);
// Zap Form
const form = createElement(doc, "form", "space-y-2 mb-4");
const amountInput = createElement(doc, "input", "input input-sm w-full");
amountInput.type = "number";
amountInput.placeholder = "Amount (sats)";
amountInput.min = "1";
const noteInput = createElement(doc, "input", "input input-sm w-full");
noteInput.type = "text";
noteInput.placeholder = "Comment (optional)";
const sendBtn = createElement(doc, "button", "btn btn-sm btn-primary w-full", "Zap ⚡");
const statusDiv = createElement(doc, "div", "text-xs text-center mt-1");
sendBtn.addEventListener("click", async (e) => {
e.preventDefault();
const amount = parseInt(amountInput.value);
const comment = noteInput.value;
if (!amount || amount <= 0) {
statusDiv.textContent = "Invalid amount";
statusDiv.className = "text-error text-xs text-center mt-1";
return;
}
statusDiv.textContent = "Sending...";
statusDiv.className = "text-muted text-xs text-center mt-1";
sendBtn.disabled = true;
try {
if (typeof onSendZap === "function") {
// Use provided callback if available (e.g. from controller)
if (!contact.lightningAddress) {
throw new Error("No lightning address for this contact");
}
const resolver = zapConfig?.resolveRecipient || resolveZapRecipient;
const resolved = await resolver(contact.lightningAddress, { fetcher: zapConfig?.fetcher });
if (!resolved) throw new Error("Could not resolve lightning address");
const payload = {
amountSats: amount,
comment,
recipient: contact,
resolved
};
await onSendZap(payload);
statusDiv.textContent = "Zap sent!";
statusDiv.className = "text-success text-xs text-center mt-1";
amountInput.value = "";
noteInput.value = "";
} else if (zapConfig?.signer) {
await createAndPublishZapRequest({
address: contact.lightningAddress,
recipientPubkey: contact.pubkey,
relays: contact.relayHints || zapConfig?.relays,
amountSats: amount,
comment,
signer: zapConfig.signer,
pool: zapConfig.pool,
fetcher: zapConfig.fetcher,
});
statusDiv.textContent = "Zap sent!";
statusDiv.className = "text-success text-xs text-center mt-1";
amountInput.value = "";
noteInput.value = "";
} else {
throw new Error("No signer available");
}
} catch (err) {
console.error(err);
statusDiv.textContent = "Failed to send zap";
statusDiv.className = "text-error text-xs text-center mt-1";
} finally {
sendBtn.disabled = false;
}
});
form.appendChild(amountInput);
form.appendChild(noteInput);
form.appendChild(sendBtn);
form.appendChild(statusDiv);
container.appendChild(form);
// Receipts List
container.appendChild(
ZapReceiptList({
document: doc,
receipts: zapReceipts,
emptyLabel: "No zap receipts published yet.",
emptyLabel: "No recent zaps.",
}),
);
return root;
return container;
}
+13 -17
View File
@@ -4421,6 +4421,8 @@ export class ProfileModalController {
container.innerHTML = "";
try {
const dmPrivacySettings = this.getDmPrivacySettingsSnapshot();
this.dmAppShell = new AppShell({
document,
conversations,
@@ -4429,8 +4431,12 @@ export class ProfileModalController {
messages: timeline,
threadState,
privacyMode,
dmPrivacySettings,
composerState: this.dmComposerState || "idle",
notifications: [],
zapConfig: {
signer: this.services.nostrClient,
},
onSelectConversation: (conversation) => {
void this.handleDmConversationSelect(conversation);
},
@@ -4443,6 +4449,12 @@ export class ProfileModalController {
onMarkAllRead: () => {
void this.handleDmMarkAllConversationsRead();
},
onToggleReadReceipts: (enabled) => {
this.handleReadReceiptsToggle(enabled);
},
onToggleTypingIndicators: (enabled) => {
this.handleTypingIndicatorsToggle(enabled);
},
});
} catch (error) {
this.dmAppShell = null;
@@ -5160,23 +5172,7 @@ 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);
}
});
}
// Legacy toggles removed - handled by DMPrivacySettings in AppShell
if (this.profileLinkPreviewAutoToggle instanceof HTMLElement) {
this.profileLinkPreviewAutoToggle.addEventListener("change", (event) => {