From ce7077da2d543bdfb55d68de5f39a15995d38d34 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:48:05 -0500 Subject: [PATCH] Add mark-read actions to DM UI --- js/services/dmNotificationManager.js | 1 + js/ui/dm/AppShell.js | 4 + js/ui/dm/ConversationList.js | 16 +++ js/ui/dm/MessageThread.js | 18 ++++ js/ui/profileModalController.js | 154 ++++++++++++++++++++++++++- 5 files changed, 192 insertions(+), 1 deletion(-) diff --git a/js/services/dmNotificationManager.js b/js/services/dmNotificationManager.js index f9c192c6..5670071d 100644 --- a/js/services/dmNotificationManager.js +++ b/js/services/dmNotificationManager.js @@ -28,6 +28,7 @@ function normalizeConversationState(conversation) { conversation_id: conversation.conversation_id || "", opened_until: sanitizeTimestamp(conversation.opened_until), downloaded_until: sanitizeTimestamp(conversation.downloaded_until), + last_message_at: sanitizeTimestamp(conversation.last_message_at), unseen_count: Number.isFinite(Number(conversation.unseen_count)) ? Number(conversation.unseen_count) : 0, diff --git a/js/ui/dm/AppShell.js b/js/ui/dm/AppShell.js index 08afcf05..1ac096a1 100644 --- a/js/ui/dm/AppShell.js +++ b/js/ui/dm/AppShell.js @@ -127,6 +127,8 @@ export class AppShell { onSelectConversation, onSendMessage, onSendZap, + onMarkConversationRead, + onMarkAllRead, } = {}) { if (!doc) { throw new Error("AppShell requires a document reference."); @@ -165,6 +167,7 @@ export class AppShell { activeId: activeConversationId, state: conversationState, onSelect: onSelectConversation, + onMarkAllRead, }), ); @@ -187,6 +190,7 @@ export class AppShell { conversationZapTotalSats: totalsByConversation.get(activeConversation.id) || 0, profileZapTotalSats: totalsByProfile.get(activeConversation.pubkey) || 0, + onMarkRead: onMarkConversationRead, }), ); main.appendChild( diff --git a/js/ui/dm/ConversationList.js b/js/ui/dm/ConversationList.js index 8c64ed2a..1afc3018 100644 --- a/js/ui/dm/ConversationList.js +++ b/js/ui/dm/ConversationList.js @@ -21,6 +21,7 @@ export function ConversationList({ activeId = "", state = "idle", onSelect, + onMarkAllRead, } = {}) { if (!doc) { throw new Error("ConversationList requires a document reference."); @@ -29,6 +30,21 @@ export function ConversationList({ const root = createElement(doc, "section", "dm-conversation-list"); const header = createElement(doc, "div", "dm-conversation-list__header"); header.appendChild(createElement(doc, "h2", "dm-conversation-list__title", "Messages")); + if (typeof onMarkAllRead === "function") { + const markAllButton = createElement( + doc, + "button", + "btn-ghost", + "Mark all as read", + ); + markAllButton.type = "button"; + markAllButton.dataset.size = "sm"; + markAllButton.disabled = !conversations.length; + markAllButton.addEventListener("click", () => { + onMarkAllRead(); + }); + header.appendChild(markAllButton); + } root.appendChild(header); const list = createElement(doc, "div", "dm-conversation-list__items"); diff --git a/js/ui/dm/MessageThread.js b/js/ui/dm/MessageThread.js index 553af7dc..554d5aa9 100644 --- a/js/ui/dm/MessageThread.js +++ b/js/ui/dm/MessageThread.js @@ -24,6 +24,7 @@ export function MessageThread({ zapReceipts = [], conversationZapTotalSats = 0, profileZapTotalSats = 0, + onMarkRead, } = {}) { if (!doc) { throw new Error("MessageThread requires a document reference."); @@ -82,6 +83,23 @@ export function MessageThread({ ), ); header.appendChild(zapSummary); + + 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"); + markReadButton.type = "button"; + markReadButton.dataset.size = "sm"; + const conversationId = + typeof contact.id === "string" ? contact.id.trim() : ""; + if (!conversationId) { + markReadButton.disabled = true; + } + markReadButton.addEventListener("click", () => { + onMarkRead(contact); + }); + actions.appendChild(markReadButton); + header.appendChild(actions); + } 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 6b3f7e7b..cd164bf2 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -3879,6 +3879,40 @@ export class ProfileModalController { return "nip04"; } + getDirectMessagesForConversation(conversationId, actorPubkey = null) { + const actor = this.normalizeHexPubkey(actorPubkey || this.resolveActiveDmActor()); + if (!actor || !conversationId) { + return []; + } + + const remote = this.resolveRemoteForConversationId(conversationId, actor); + if (!remote) { + return []; + } + + return (Array.isArray(this.directMessagesCache) ? this.directMessagesCache : []) + .filter( + (entry) => + entry && + entry.ok === true && + this.resolveDirectMessageRemote(entry, actor) === remote, + ) + .sort((a, b) => (a?.timestamp || 0) - (b?.timestamp || 0)); + } + + getLatestDirectMessageTimestampForConversation(conversationId, actorPubkey = null) { + const messages = this.getDirectMessagesForConversation( + conversationId, + actorPubkey, + ); + if (!messages.length) { + return 0; + } + + const last = messages[messages.length - 1]; + return Number(last?.timestamp) || 0; + } + buildDmMessageTimeline(messages, { actorPubkey, remotePubkey } = {}) { const actor = this.normalizeHexPubkey(actorPubkey); const remote = this.normalizeHexPubkey(remotePubkey); @@ -4383,6 +4417,12 @@ export class ProfileModalController { onSendMessage: (messageText, payload) => { void this.handleDmAppShellSendMessage(messageText, payload); }, + onMarkConversationRead: (conversation) => { + void this.handleDmConversationMarkRead(conversation); + }, + onMarkAllRead: () => { + void this.handleDmMarkAllConversationsRead(); + }, }); } catch (error) { this.dmAppShell = null; @@ -4403,9 +4443,14 @@ export class ProfileModalController { container.appendChild(root); if (actor && activeConversationId) { + const renderedUntil = + this.getLatestDirectMessageTimestampForConversation( + activeConversationId, + actor, + ) || Date.now() / 1000; void this.nostrService?.acknowledgeRenderedDirectMessages?.( activeConversationId, - Date.now() / 1000, + renderedUntil, ); } } @@ -4460,6 +4505,113 @@ export class ProfileModalController { }); } + async handleDmConversationMarkRead(conversation) { + const conversationId = + conversation && typeof conversation.id === "string" + ? conversation.id.trim() + : this.activeDmConversationId; + if (!conversationId) { + return; + } + + const actor = this.resolveActiveDmActor(); + if ( + !actor || + !this.nostrService || + typeof this.nostrService.acknowledgeRenderedDirectMessages !== "function" + ) { + return; + } + + const renderedUntil = this.getLatestDirectMessageTimestampForConversation( + conversationId, + actor, + ); + + try { + await this.nostrService.acknowledgeRenderedDirectMessages( + conversationId, + renderedUntil, + ); + } catch (error) { + devLogger.warn("[profileModal] Failed to mark conversation read:", error); + } + + const recipient = this.resolveRemoteForConversationId(conversationId, actor); + const messages = this.getDirectMessagesForConversation(conversationId, actor); + if (recipient && messages.length) { + void this.maybePublishReadReceipt(messages, { + recipientPubkey: recipient, + }); + } + + await this.renderDmAppShell(this.directMessagesCache, { + actorPubkey: actor, + }); + } + + async handleDmMarkAllConversationsRead() { + if ( + !this.nostrService || + typeof this.nostrService.acknowledgeRenderedDirectMessages !== "function" + ) { + return; + } + + const actor = this.resolveActiveDmActor(); + if (!actor) { + return; + } + + const summaries = + typeof this.nostrService.listDirectMessageConversationSummaries === "function" + ? await this.nostrService.listDirectMessageConversationSummaries() + : []; + const list = Array.isArray(summaries) ? summaries : []; + + for (const summary of list) { + const conversationId = + typeof summary?.conversation_id === "string" + ? summary.conversation_id.trim() + : typeof summary?.conversationId === "string" + ? summary.conversationId.trim() + : ""; + if (!conversationId) { + continue; + } + + const renderedUntil = + Number(summary?.last_message_at) || + Number(summary?.downloaded_until) || + Number(summary?.opened_until) || + this.getLatestDirectMessageTimestampForConversation(conversationId, actor); + + try { + await this.nostrService.acknowledgeRenderedDirectMessages( + conversationId, + renderedUntil, + ); + } catch (error) { + devLogger.warn( + "[profileModal] Failed to mark conversation read:", + error, + ); + } + + const recipient = this.resolveRemoteForConversationId(conversationId, actor); + const messages = this.getDirectMessagesForConversation(conversationId, actor); + if (recipient && messages.length) { + void this.maybePublishReadReceipt(messages, { + recipientPubkey: recipient, + }); + } + } + + await this.renderDmAppShell(this.directMessagesCache, { + actorPubkey: actor, + }); + } + async handleDmAppShellSendMessage(messageText, payload = {}) { const normalizedPayload = payload && typeof payload === "object" ? payload : {};