Fix unread notification count

This commit is contained in:
Jon Staab 2026-08-17 09:41:38 -07:00
parent 3d66fb3109
commit 4ee05fd21c
2 changed files with 89 additions and 84 deletions

View file

@ -1,7 +1,18 @@
import {derived, get, writable} from "svelte/store" import {derived, get, writable} from "svelte/store"
import {Badge} from "@capawesome/capacitor-badge" import {Badge} from "@capawesome/capacitor-badge"
import {page} from "$app/stores" 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,
remove,
throttle,
parseJson,
gt,
} from "@welshman/lib"
import type {SignedEvent, TrustedEvent} from "@welshman/util" import type {SignedEvent, TrustedEvent} from "@welshman/util"
import { import {
getIdOrAddress, getIdOrAddress,
@ -17,13 +28,7 @@ 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 { import {makeRoomPath, makeSpaceChatPath, makeChatPath, makeContentPath} from "@app/routes"
makeSpacePath,
makeRoomPath,
makeSpaceChatPath,
makeChatPath,
makeContentPath,
} 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"
@ -49,15 +54,15 @@ export const setChecked = (key: string) => checked.update(assoc(key, now()))
/** Room path while video call UI hides chat; checked + badge stay active until chat is shown. */ /** Room path while video call UI hides chat; checked + badge stay active until chat is shown. */
export const deferredRoomPath = writable<string | undefined>(undefined) export const deferredRoomPath = writable<string | undefined>(undefined)
const getPaths = (path: string) =>
path
.split("/")
.map((_, i, segments) => segments.slice(0, i + 1).join("/"))
.slice(1)
export const syncChecked = () => { export const syncChecked = () => {
let prev = "" let prev = ""
const getPaths = (path: string) =>
path
.split("/")
.map((_, i, segments) => segments.slice(0, i + 1).join("/"))
.slice(1)
return page.subscribe($page => { return page.subscribe($page => {
// Set checked when we leave a given page // Set checked when we leave a given page
checked.update($checked => { checked.update($checked => {
@ -200,7 +205,7 @@ const getContentTarget = (event: TrustedEvent) => {
// Assumes `events` is sorted descending, so the first event seen per content item wins. // Assumes `events` is sorted descending, so the first event seen per content item wins.
const latestEventByContentPath = (url: string, events: TrustedEvent[]) => { const latestEventByContentPath = (url: string, events: TrustedEvent[]) => {
const byPath = new Map<string, {listPath: string; latestEvent: TrustedEvent}>() const byPath = new Map<string, TrustedEvent>()
for (const event of events) { for (const event of events) {
const target = getContentTarget(event) const target = getContentTarget(event)
@ -208,23 +213,21 @@ const latestEventByContentPath = (url: string, events: TrustedEvent[]) => {
if (!target) continue if (!target) continue
const path = makeContentPath(url, target.kind, target.idOrAddress) const path = makeContentPath(url, target.kind, target.idOrAddress)
const listPath = makeContentPath(url, target.kind)
if (path && listPath && !byPath.has(path)) { if (path && !byPath.has(path)) {
byPath.set(path, {listPath, latestEvent: event}) byPath.set(path, event)
} }
} }
return byPath return byPath
} }
export const allNotifications = derived( export const latestActivityByPath = derived(
throttled( throttled(
1000, 1000,
derived( derived(
[ [
app, app,
checked,
chatsById, chatsById,
fromApp($app => $app.use(Relays).index.$), fromApp($app => $app.use(Relays).index.$),
fromApp($app => $app.use(RoomLists).index.$), fromApp($app => $app.use(RoomLists).index.$),
@ -236,17 +239,56 @@ export const allNotifications = derived(
identity, identity,
), ),
), ),
([$app, $checked, $chatsById, $relays, $roomLists, eventsByIdByUrl]) => { ([$app, $chatsById, $relays, $roomLists, eventsByIdByUrl]) => {
const hasNotification = (path: string, latestEvent?: TrustedEvent) => { const activity = new Map<string, TrustedEvent>()
if (!latestEvent || latestEvent.pubkey === $app.user?.pubkey) {
for (const {pubkeys, messages} of $chatsById.values()) {
if (messages[0]) {
activity.set(makeChatPath(pubkeys), messages[0])
}
}
const roomList = $app.user?.pubkey ? $roomLists.get($app.user.pubkey) : undefined
const urls = PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : (roomList?.urls() ?? [])
for (const url of urls) {
const events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values())
if ($relays.get(url)?.hasNip(29)) {
for (const [h, [latestEvent]] of groupBy(e => tagValue(tagSpec("h"), e.tags), events)) {
if (h) {
activity.set(makeRoomPath(url, h), latestEvent)
}
}
} else {
const latestEvent = first(events)
if (latestEvent) {
activity.set(makeSpaceChatPath(url), latestEvent)
}
}
for (const [path, latestEvent] of latestEventByContentPath(url, events)) {
activity.set(path, latestEvent)
}
}
return activity
},
)
export const allNotifications = derived(
[app, latestActivityByPath, checked],
([$app, $latestActivityByPath, $checked]) => {
const hasNotification = (path: string, latestEvent: TrustedEvent) => {
if (latestEvent.pubkey === $app.user?.pubkey) {
return false return false
} }
for (const [entryPath, ts] of Object.entries($checked)) { for (const [entryPath, ts] of Object.entries($checked)) {
const isMatch = const isMatch = entryPath.endsWith("*")
entryPath === "*" || ? path.startsWith(entryPath.slice(0, -1))
entryPath.startsWith(path) || : entryPath.startsWith(path)
(entryPath === "/chat/*" && path.startsWith("/chat/"))
if (isMatch && ts > latestEvent.created_at) { if (isMatch && ts > latestEvent.created_at) {
return false return false
@ -258,59 +300,13 @@ export const allNotifications = derived(
const paths = new Set<string>() const paths = new Set<string>()
for (const {pubkeys, messages} of $chatsById.values()) { for (const [path, latestEvent] of $latestActivityByPath) {
const chatPath = makeChatPath(pubkeys) if (hasNotification(path, latestEvent)) {
paths.add(path)
if (hasNotification(chatPath, messages[0])) { for (const branchPath of remove(path, getPaths(path.split("?")[0]))) {
paths.add("/chat") if (hasNotification(branchPath, latestEvent)) {
paths.add(chatPath) paths.add(branchPath)
}
}
const roomList = $app.user?.pubkey ? $roomLists.get($app.user?.pubkey) : undefined
const urls = PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : (roomList?.urls() ?? [])
for (const url of urls) {
const spacePath = makeSpacePath(url)
const events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values())
if ($relays.get(url)?.hasNip(29)) {
for (const [h, [latestEvent]] of groupBy(e => tagValue(tagSpec("h"), e.tags), events)) {
if (h) {
const roomPath = makeRoomPath(url, h)
if (hasNotification(roomPath, latestEvent)) {
paths.add(roomPath)
if (hasNotification(spacePath, latestEvent)) {
paths.add(spacePath)
}
}
}
}
} else {
const messagesPath = makeSpaceChatPath(url)
const latestEvent = first(events)
if (hasNotification(messagesPath, latestEvent)) {
paths.add(messagesPath)
if (hasNotification(spacePath, latestEvent)) {
paths.add(spacePath)
}
}
}
for (const [path, {listPath, latestEvent}] of latestEventByContentPath(url, events)) {
if (hasNotification(path, latestEvent)) {
paths.add(path)
if (hasNotification(listPath, latestEvent)) {
paths.add(listPath)
}
if (hasNotification(spacePath, latestEvent)) {
paths.add(spacePath)
} }
} }
} }
@ -335,11 +331,15 @@ export const notifications = derived(
// Badges // Badges
export const syncBadges = () => export const syncBadges = () =>
derived([notifications, notificationSettings], identity).subscribe( derived([latestActivityByPath, notifications, notificationSettings], identity).subscribe(
async ([$notifications, $notificationSettings]) => { async ([$latestActivityByPath, $notifications, $notificationSettings]) => {
if ($notificationSettings.badge) { if ($notificationSettings.badge) {
const count = [...$latestActivityByPath.keys()].filter(path =>
$notifications.has(path),
).length
try { try {
await Badge.set({count: $notifications.size}) await Badge.set({count})
} catch (err) { } catch (err) {
// pass - firefox doesn't support badges // pass - firefox doesn't support badges
} }

View file

@ -35,7 +35,11 @@
import {activeTheme, flTheme, theme} from "@app/theme" import {activeTheme, flTheme, theme} from "@app/theme"
import {toast, pushToast} from "@app/toast" import {toast, pushToast} from "@app/toast"
import * as notifications from "@app/notifications" import * as notifications from "@app/notifications"
import {notifications as notificationPaths, allNotifications} from "@app/notifications" import {
notifications as notificationPaths,
allNotifications,
latestActivityByPath,
} from "@app/notifications"
import {Push} from "@app/push" import {Push} from "@app/push"
import {onPushNotificationAction, pushState} from "@app/push/adapters/common" import {onPushNotificationAction, pushState} from "@app/push/adapters/common"
import {syncKeyboard} from "@app/keyboard" import {syncKeyboard} from "@app/keyboard"
@ -274,7 +278,8 @@
const title = getPageTitle({page: $page, pubkey: $app.user?.pubkey}) const title = getPageTitle({page: $page, pubkey: $app.user?.pubkey})
// While the tab isn't actively focused the user isn't actually looking at the // While the tab isn't actively focused the user isn't actually looking at the
// active page, so count notifications for it too rather than treating it as read. // active page, so count notifications for it too rather than treating it as read.
const unreadCount = $documentActive ? $notificationPaths.size : $allNotifications.size const paths = $documentActive ? $notificationPaths : $allNotifications
const unreadCount = [...$latestActivityByPath.keys()].filter(path => paths.has(path)).length
document.title = unreadCount > 0 ? `(${unreadCount}) ${title}` : title document.title = unreadCount > 0 ? `(${unreadCount}) ${title}` : title
}) })