From f5159a3b5e4745307f592fec8670ca03f4a04fe1 Mon Sep 17 00:00:00 2001 From: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> Date: Fri, 14 Aug 2026 15:37:27 +0000 Subject: [PATCH] Show notification badge on Threads nav item (#360) Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> --- src/app/components/SpaceMenuNavItems.svelte | 10 ++-- src/app/notifications.ts | 57 ++++++++++++++++++++- src/app/routes.ts | 37 +++++++------ 3 files changed, 82 insertions(+), 22 deletions(-) diff --git a/src/app/components/SpaceMenuNavItems.svelte b/src/app/components/SpaceMenuNavItems.svelte index 36cedae9..e120f20a 100644 --- a/src/app/components/SpaceMenuNavItems.svelte +++ b/src/app/components/SpaceMenuNavItems.svelte @@ -72,27 +72,27 @@ {/if} {#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)} - + Goals {/if} {#if $spaceKinds.has(THREAD)} - + Threads {/if} {#if $spaceKinds.has(CLASSIFIED)} - + Classifieds {/if} {#if $spaceKinds.has(EVENT_TIME)} - + Calendar {/if} {#if $spaceKinds.has(POLL)} - + Polls {/if} diff --git a/src/app/notifications.ts b/src/app/notifications.ts index 5aa847e7..ffa96f61 100644 --- a/src/app/notifications.ts +++ b/src/app/notifications.ts @@ -4,9 +4,11 @@ import {page} from "$app/stores" import {assoc, prop, first, identity, groupBy, now, throttle, parseJson, gt} from "@welshman/lib" import type {SignedEvent, TrustedEvent} from "@welshman/util" import { + getIdOrAddress, sortEventsDesc, tagSpec, tagValue, + COMMENT, MESSAGE, makeHttpAuth, makeHttpAuthHeader, @@ -15,7 +17,13 @@ import {synced, throttled, withGetter} from "@welshman/store" import {Relays, RoomLists} from "@welshman/app" import {deriveEventsByIdByUrl} from "@app/repository" import {app, fromApp} from "@app/core" -import {makeSpacePath, makeRoomPath, makeSpaceChatPath, makeChatPath} from "@app/routes" +import { + makeSpacePath, + makeRoomPath, + makeSpaceChatPath, + makeChatPath, + getContentPath, +} from "@app/routes" import {CONTENT_KINDS, makeCommentFilter} from "@app/content" import {notificationSettings} from "@app/settings" import {chatsById} from "@app/chats" @@ -174,6 +182,42 @@ export const syncCheckedRemote = () => { // Derived notifications state +// The content item an event belongs to - either the content event itself, or the root of a comment +const getContentTarget = (event: TrustedEvent) => { + if (CONTENT_KINDS.includes(event.kind)) { + return {kind: event.kind, idOrAddress: getIdOrAddress(event)} + } + + if (event.kind === COMMENT) { + const kind = parseInt(tagValue(tagSpec("K"), event.tags) || "") + const idOrAddress = tagValue(tagSpec("A"), event.tags) || tagValue(tagSpec("E"), event.tags) + + if (CONTENT_KINDS.includes(kind) && idOrAddress) { + return {kind, idOrAddress} + } + } +} + +// Assumes `events` is sorted descending, so the first event seen per content item wins. +const latestEventByContentPath = (url: string, events: TrustedEvent[]) => { + const byPath = new Map() + + for (const event of events) { + const target = getContentTarget(event) + + if (!target) continue + + const path = getContentPath(url, target.kind, target.idOrAddress) + const listPath = getContentPath(url, target.kind) + + if (path && listPath && !byPath.has(path)) { + byPath.set(path, {listPath, latestEvent: event}) + } + } + + return byPath +} + export const allNotifications = derived( throttled( 1000, @@ -256,6 +300,17 @@ export const allNotifications = derived( } } } + + for (const [path, {listPath, latestEvent}] of latestEventByContentPath(url, events)) { + if (hasNotification(path, latestEvent)) { + paths.add(path) + paths.add(listPath) + + if (hasNotification(spacePath, latestEvent)) { + paths.add(spacePath) + } + } + } } return paths diff --git a/src/app/routes.ts b/src/app/routes.ts index c3829b3d..2f08156d 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -14,6 +14,7 @@ import { THREAD, ZAP_GOAL, getAddress, + getIdOrAddress, hexTags, tagSpec, tagValue, @@ -138,6 +139,24 @@ export const makePollPath = (url: string, id?: string) => makeSpacePath(url, "po export const makeLibraryPath = (url: string, address?: string) => makeSpacePath(url, "library", address) +/** Path to a content item by kind, or to its listing when `idOrAddress` is omitted. */ +export const getContentPath = (url: string, kind: number, idOrAddress?: string) => { + switch (kind) { + case ZAP_GOAL: + return makeGoalPath(url, idOrAddress) + case THREAD: + return makeThreadPath(url, idOrAddress) + case CLASSIFIED: + return makeClassifiedPath(url, idOrAddress) + case EVENT_TIME: + return makeCalendarPath(url, idOrAddress) + case PINBOARD: + return makeLibraryPath(url, idOrAddress) + case POLL: + return makePollPath(url, idOrAddress) + } +} + export const scrollToEvent = (id: string) => { const element = document.querySelector(`[data-event="${id}"]`) as any @@ -249,19 +268,5 @@ export const makeEventPermalink = (event: TrustedEvent, url?: string) => { return `${PLATFORM_URL}${path}#${nip19.neventEncode({id: event.id, relays: urls})}` } -export const getRoomItemPath = (url: string, event: TrustedEvent) => { - switch (event.kind) { - case THREAD: - return makeThreadPath(url, event.id) - case CLASSIFIED: - return makeClassifiedPath(url, getAddress(event)) - case ZAP_GOAL: - return makeGoalPath(url, event.id) - case EVENT_TIME: - return makeCalendarPath(url, getAddress(event)) - case PINBOARD: - return makeLibraryPath(url, getAddress(event)) - case POLL: - return makePollPath(url, event.id) - } -} +export const getRoomItemPath = (url: string, event: TrustedEvent) => + getContentPath(url, event.kind, getIdOrAddress(event))