Show notification badge on Threads nav item (#360)

Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
This commit is contained in:
Aditya Chaudhary 2026-08-14 15:37:27 +00:00 committed by hodlbod
parent 0a85983633
commit f5159a3b5e
3 changed files with 82 additions and 22 deletions

View file

@ -72,27 +72,27 @@
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)} {#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
<SecondaryNavItem href={goalsPath}> <SecondaryNavItem href={goalsPath} notification={$notifications.has(goalsPath)}>
<Icon icon={StarFallMinimalistic} /> Goals <Icon icon={StarFallMinimalistic} /> Goals
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{#if $spaceKinds.has(THREAD)} {#if $spaceKinds.has(THREAD)}
<SecondaryNavItem href={threadsPath}> <SecondaryNavItem href={threadsPath} notification={$notifications.has(threadsPath)}>
<Icon icon={NotesMinimalistic} /> Threads <Icon icon={NotesMinimalistic} /> Threads
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{#if $spaceKinds.has(CLASSIFIED)} {#if $spaceKinds.has(CLASSIFIED)}
<SecondaryNavItem href={classifiedsPath}> <SecondaryNavItem href={classifiedsPath} notification={$notifications.has(classifiedsPath)}>
<Icon icon={CaseMinimalistic} /> Classifieds <Icon icon={CaseMinimalistic} /> Classifieds
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{#if $spaceKinds.has(EVENT_TIME)} {#if $spaceKinds.has(EVENT_TIME)}
<SecondaryNavItem href={calendarPath}> <SecondaryNavItem href={calendarPath} notification={$notifications.has(calendarPath)}>
<Icon icon={CalendarMinimalistic} /> Calendar <Icon icon={CalendarMinimalistic} /> Calendar
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{#if $spaceKinds.has(POLL)} {#if $spaceKinds.has(POLL)}
<SecondaryNavItem href={pollsPath}> <SecondaryNavItem href={pollsPath} notification={$notifications.has(pollsPath)}>
<Icon icon={Revote} /> Polls <Icon icon={Revote} /> Polls
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}

View file

@ -4,9 +4,11 @@ import {page} from "$app/stores"
import {assoc, prop, first, identity, groupBy, now, throttle, parseJson, gt} from "@welshman/lib" import {assoc, prop, first, identity, groupBy, now, throttle, parseJson, gt} from "@welshman/lib"
import type {SignedEvent, TrustedEvent} from "@welshman/util" import type {SignedEvent, TrustedEvent} from "@welshman/util"
import { import {
getIdOrAddress,
sortEventsDesc, sortEventsDesc,
tagSpec, tagSpec,
tagValue, tagValue,
COMMENT,
MESSAGE, MESSAGE,
makeHttpAuth, makeHttpAuth,
makeHttpAuthHeader, makeHttpAuthHeader,
@ -15,7 +17,13 @@ import {synced, throttled, withGetter} from "@welshman/store"
import {Relays, RoomLists} from "@welshman/app" import {Relays, RoomLists} from "@welshman/app"
import {deriveEventsByIdByUrl} from "@app/repository" import {deriveEventsByIdByUrl} from "@app/repository"
import {app, fromApp} from "@app/core" 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 {CONTENT_KINDS, makeCommentFilter} from "@app/content"
import {notificationSettings} from "@app/settings" import {notificationSettings} from "@app/settings"
import {chatsById} from "@app/chats" import {chatsById} from "@app/chats"
@ -174,6 +182,42 @@ export const syncCheckedRemote = () => {
// Derived notifications state // 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<string, {listPath: string; latestEvent: TrustedEvent}>()
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( export const allNotifications = derived(
throttled( throttled(
1000, 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 return paths

View file

@ -14,6 +14,7 @@ import {
THREAD, THREAD,
ZAP_GOAL, ZAP_GOAL,
getAddress, getAddress,
getIdOrAddress,
hexTags, hexTags,
tagSpec, tagSpec,
tagValue, tagValue,
@ -138,6 +139,24 @@ export const makePollPath = (url: string, id?: string) => makeSpacePath(url, "po
export const makeLibraryPath = (url: string, address?: string) => export const makeLibraryPath = (url: string, address?: string) =>
makeSpacePath(url, "library", address) 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) => { export const scrollToEvent = (id: string) => {
const element = document.querySelector(`[data-event="${id}"]`) as any 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})}` return `${PLATFORM_URL}${path}#${nip19.neventEncode({id: event.id, relays: urls})}`
} }
export const getRoomItemPath = (url: string, event: TrustedEvent) => { export const getRoomItemPath = (url: string, event: TrustedEvent) =>
switch (event.kind) { getContentPath(url, event.kind, getIdOrAddress(event))
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)
}
}