Merge pull request #1407 from PR0M3TH3AN/codex/add-mark-read-controls-to-dm-ui

DM: add per-conversation and global mark-read actions
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-19 14:48:17 -05:00
commit 52a1b886f1
5 files changed
+192 -1

No files matched your search

+1
View File
@@ -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,
+4
View File
@@ -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(
+16
View File
@@ -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");
+18
View File
@@ -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");
+153 -1
View File
@@ -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 : {};