diff --git a/css/tailwind.source.css b/css/tailwind.source.css index e5b4a7df..76d274f2 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -235,6 +235,23 @@ display: block; } + #profileUnreadDot { + position: absolute; + top: var(--space-3xs); + right: var(--space-3xs); + width: calc(var(--space-2xs) + var(--space-3xs)); + height: calc(var(--space-2xs) + var(--space-3xs)); + border-radius: 999px; + border: var(--border-width-hairline) solid var(--color-page); + background-color: var(--status-danger); + display: none; + pointer-events: none; + } + + #profileUnreadDot.is-visible { + display: block; + } + .header-icon-toggle[data-theme-state="light"]:hover, .header-icon-toggle[data-theme-state="light"]:focus-visible { color: var(--color-text); diff --git a/index.html b/index.html index 6b0f8402..b145f4b2 100644 --- a/index.html +++ b/index.html @@ -290,7 +290,7 @@ diff --git a/js/app.js b/js/app.js index 271ddac0..c12ddfe8 100644 --- a/js/app.js +++ b/js/app.js @@ -4429,6 +4429,9 @@ class Application { this.applyAuthenticatedUiState(); this.commentController?.refreshAuthState?.(); + if (typeof this.refreshUnreadDmIndicator === "function") { + void this.refreshUnreadDmIndicator({ reason: "auth-login" }); + } const currentView = getHashViewName(); const normalizedView = @@ -4681,6 +4684,9 @@ class Application { } this.applyLoggedOutUiState(); + if (this.appChromeController?.setUnreadDmIndicator) { + this.appChromeController.setUnreadDmIndicator(false); + } const logoutView = getHashViewName(); if ( diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 80a4ee79..f1f1f3e0 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -763,10 +763,16 @@ export class NostrService { } async acknowledgeRenderedDirectMessages(conversationId, renderedUntil) { - return this.dmNotificationManager.acknowledgeRenderedMessages({ + const conversation = await this.dmNotificationManager.acknowledgeRenderedMessages({ conversationId, renderedUntil, }); + if (conversation) { + this.emit("directMessages:conversationUpdated", { + conversation, + }); + } + return conversation; } getDirectMessageUnseenCount(conversationId) { diff --git a/js/ui/appChromeController.js b/js/ui/appChromeController.js index 1960bd20..a4e73e9c 100644 --- a/js/ui/appChromeController.js +++ b/js/ui/appChromeController.js @@ -18,6 +18,7 @@ export default class AppChromeController { this.elements = { logoutButton: this.normalizeElement(elements.logoutButton), profileButton: this.normalizeElement(elements.profileButton), + profileUnreadDot: this.normalizeElement(elements.profileUnreadDot), uploadButton: this.normalizeElement(elements.uploadButton), loginButton: this.normalizeElement(elements.loginButton), closeLoginModalButton: this.normalizeElement( @@ -206,6 +207,21 @@ export default class AppChromeController { } } + setUnreadDmIndicator(visible) { + const dot = this.normalizeElement(this.elements.profileUnreadDot); + if (!dot) { + return; + } + + const profileButton = this.normalizeElement(this.elements.profileButton); + const isProfileVisible = + profileButton && + !profileButton.classList.contains("hidden") && + !profileButton.hasAttribute("hidden"); + + dot.classList.toggle("is-visible", Boolean(visible) && isProfileVisible); + } + bindUploadButton() { const button = this.normalizeElement(this.elements.uploadButton); if (this.boundElements.uploadButton === button) { diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 9d21d8f3..bdc21cc9 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -409,6 +409,7 @@ export default class ApplicationBootstrap { app.profileButton = doc?.getElementById("profileButton") || null; app.profileAvatar = doc?.getElementById("profileAvatar") || null; + app.profileUnreadDot = doc?.getElementById("profileUnreadDot") || null; const modalManager = new ModalManager({ app, @@ -685,12 +686,79 @@ export default class ApplicationBootstrap { }; app.videosMap = app.nostrService.getVideosMap(); - app.unsubscribeFromNostrService = app.nostrService.on( - "subscription:changed", - ({ subscription }) => { + app.refreshUnreadDmIndicator = async ({ reason = "" } = {}) => { + if ( + !app.appChromeController || + typeof app.appChromeController.setUnreadDmIndicator !== "function" + ) { + return; + } + + if (typeof app.isUserLoggedIn === "function" && !app.isUserLoggedIn()) { + app.appChromeController.setUnreadDmIndicator(false); + return; + } + + if ( + !app.nostrService || + typeof app.nostrService.listDirectMessageConversationSummaries !== + "function" + ) { + app.appChromeController.setUnreadDmIndicator(false); + return; + } + + try { + const summaries = + await app.nostrService.listDirectMessageConversationSummaries(); + const totalUnseen = Array.isArray(summaries) + ? summaries.reduce((sum, summary) => { + const count = Number(summary?.unseen_count); + return sum + (Number.isFinite(count) ? count : 0); + }, 0) + : 0; + app.appChromeController.setUnreadDmIndicator(totalUnseen > 0); + } catch (error) { + devLogger.warn( + "[Application] Failed to refresh DM unread indicator", + reason, + error, + ); + app.appChromeController.setUnreadDmIndicator(false); + } + }; + + const nostrUnsubscribes = []; + nostrUnsubscribes.push( + app.nostrService.on("subscription:changed", ({ subscription }) => { app.videoSubscription = subscription || null; - }, + }), ); + nostrUnsubscribes.push( + app.nostrService.on("directMessages:notification", () => { + void app.refreshUnreadDmIndicator({ reason: "dm-notification" }); + }), + ); + nostrUnsubscribes.push( + app.nostrService.on("directMessages:conversationUpdated", () => { + void app.refreshUnreadDmIndicator({ reason: "dm-conversation-update" }); + }), + ); + nostrUnsubscribes.push( + app.nostrService.on("directMessages:cleared", () => { + if (app.appChromeController) { + app.appChromeController.setUnreadDmIndicator(false); + } + }), + ); + app.unsubscribeFromNostrService = () => { + while (nostrUnsubscribes.length) { + const unsubscribe = nostrUnsubscribes.pop(); + if (typeof unsubscribe === "function") { + unsubscribe(); + } + } + }; app.boundNwcSettingsToastHandler = null; Object.defineProperty(app, "savedProfiles", { configurable: false, @@ -926,6 +994,7 @@ export default class ApplicationBootstrap { elements: { logoutButton: app.logoutButton, profileButton: app.profileButton, + profileUnreadDot: app.profileUnreadDot, uploadButton: app.uploadButton, loginButton: app.loginButton, closeLoginModalButton: app.closeLoginModalBtn,