Files
bitvid/js/ui/dm/AppShell.js
T
google-labs-jules[bot] fa8a24a0aa Refactor DM Zap button and stats to Composer area
Moves the Zap button and Zap statistics from the message thread header to the message composer area, adjacent to the Send button. Refactors the Zap interface creation logic into a standalone component `ZapInterface.js` to decouple it from `MessageThread.js`. Updates `Composer.js` to support an optional `onZap` handler and `zapStats` prop, rendering the button with an icon-only style and the stats pill reusing the existing CSS class. Updates `AppShell.js` to orchestrate the Zap interface display and pass the necessary statistics.
2026-01-21 21:39:27 +00:00

294 lines
9.5 KiB
JavaScript

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";
import { createZapInterface } from "./ZapInterface.js";
function createElement(doc, tag, className, text) {
const element = doc.createElement(tag);
if (className) {
element.className = className;
}
if (typeof text === "string") {
element.textContent = text;
}
return element;
}
const DEFAULT_CONVERSATIONS = [
{
id: "conv-1",
name: "Nia Nova",
preview: "The relay looks stable now.",
timestamp: "2m",
unreadCount: 2,
status: "Online",
avatarSrc: "",
pubkey: "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
lightningAddress: "[email protected]",
relayHints: ["wss://relay.damus.io", "wss://nostr.wine"],
},
{
id: "conv-2",
name: "Orbit Ops",
preview: "Can you share the stream link?",
timestamp: "1h",
unreadCount: 0,
status: "Away",
avatarSrc: "",
pubkey: "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
lightningAddress: "[email protected]",
relayHints: ["wss://relay.primal.net"],
},
];
const DEFAULT_MESSAGES = [
{ type: "day", label: "Today" },
{
id: "msg-1",
direction: "incoming",
body: "Hey! The new DM view looks promising.",
timestamp: "09:12",
},
{
id: "msg-2",
direction: "outgoing",
body: "Thanks! I'm wiring up the layout and keyboard flow now.",
timestamp: "09:14",
status: "pending",
},
{
id: "msg-3",
direction: "outgoing",
body: "Next up: privacy mode toggles and attachment rails.",
timestamp: "09:16",
status: "sent",
},
{
id: "msg-4",
direction: "outgoing",
body: "I can resend if this didn't go through.",
timestamp: "09:18",
status: "failed",
},
];
const DEFAULT_ZAP_RECEIPTS = [
{
id: "zap-1",
kind: 9735,
conversationId: "conv-1",
profileId: "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
senderName: "You",
amountSats: 120,
note: "For the quick relay fix!",
timestamp: "09:20",
status: "confirmed",
},
{
id: "zap-2",
kind: 9735,
conversationId: "conv-1",
profileId: "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
senderName: "Nia Nova",
amountSats: 42,
note: "Thanks for testing the DM flow.",
timestamp: "09:22",
status: "confirmed",
},
{
id: "zap-3",
kind: 9735,
conversationId: "conv-2",
profileId: "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
senderName: "You",
amountSats: 250,
note: "Ops support fund.",
timestamp: "Yesterday",
status: "pending",
},
];
export class AppShell {
constructor({
document: doc,
conversations = DEFAULT_CONVERSATIONS,
activeConversationId = DEFAULT_CONVERSATIONS[0]?.id || "",
conversationState = "idle",
messages = DEFAULT_MESSAGES,
zapReceipts = DEFAULT_ZAP_RECEIPTS,
threadState = "idle",
threadErrorType = "",
composerState = "idle",
privacyMode = "nip04",
dmPrivacySettings = { readReceiptsEnabled: false, typingIndicatorsEnabled: false },
notifications = [],
signingAdapter = null,
zapConfig = null,
mobileView = "list",
onSelectConversation,
onSendMessage,
onSendZap,
onMarkConversationRead,
onMarkAllRead,
onToggleReadReceipts,
onToggleTypingIndicators,
onBack,
} = {}) {
if (!doc) {
throw new Error("AppShell requires a document reference.");
}
this.document = doc;
this.root = createElement(doc, "div", "dm-app-shell");
if (typeof mobileView === "string") {
this.root.dataset.dmView = mobileView;
}
const { totalsByConversation, totalsByProfile } = aggregateZapTotals(zapReceipts);
const enrichedConversations = conversations.map((conversation) => ({
...conversation,
zapTotalSats: totalsByConversation.get(conversation.id) || 0,
profileZapTotalSats: totalsByProfile.get(conversation.pubkey) || 0,
}));
const activeConversation =
enrichedConversations.find((conversation) => conversation.id === activeConversationId) ||
enrichedConversations[0] ||
{};
const activeZapReceipts = zapReceipts.filter(
(receipt) => receipt.conversationId === activeConversation.id,
);
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 relative");
const sidebarTitle = createElement(
doc,
"h1",
"dm-app-shell__title text-lg font-bold",
"Direct Messages",
);
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.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,
conversations: enrichedConversations,
activeId: activeConversationId,
state: conversationState,
onSelect: onSelectConversation,
onMarkAllRead,
}),
);
const main = createElement(doc, "main", "dm-app-shell__main");
main.appendChild(
MessageThread({
document: doc,
contact: activeConversation,
messages,
state: threadState,
errorType: threadErrorType,
privacyMode,
zapReceipts: activeZapReceipts,
conversationZapTotalSats:
totalsByConversation.get(activeConversation.id) || 0,
profileZapTotalSats: totalsByProfile.get(activeConversation.pubkey) || 0,
onMarkRead: onMarkConversationRead,
onSendZap,
onBack,
zapConfig,
}),
);
const handleZapTrigger = () => {
const existingInterface = main.querySelector(".dm-zap-interface");
if (existingInterface) {
existingInterface.remove();
return;
}
const zapInterface = createZapInterface(doc, {
contact: activeConversation,
zapReceipts: activeZapReceipts,
onSendZap,
zapConfig
});
// Position it relative to the composer or just absolute in main
// The extracted createZapInterface uses 'absolute bottom-16 right-4' which positions it nicely
// relative to the container if the container is relative.
// main usually has relative positioning or we can append to root.
// AppShell root has absolute positioning context maybe?
// If main is the container, we append there.
main.appendChild(zapInterface);
};
const conversationZapTotalSats = totalsByConversation.get(activeConversation.id) || 0;
const profileZapTotalSats = totalsByProfile.get(activeConversation.pubkey) || 0;
main.appendChild(
Composer({
document: doc,
state: composerState,
privacyMode,
signingAdapter,
zapRecipient: activeConversation,
zapConfig,
onSend: onSendMessage,
onZap: handleZapTrigger,
zapStats: {
conversationTotal: conversationZapTotalSats,
profileTotal: profileZapTotalSats
}
}),
);
main.appendChild(
NotificationCenter({
document: doc,
notices: notifications,
}),
);
this.root.appendChild(sidebar);
this.root.appendChild(main);
}
getRoot() {
return this.root;
}
}