flotilla/src/app/notifications.ts

388 lines
9.6 KiB
TypeScript
Raw Normal View History

import {derived, get, writable} from "svelte/store"
2025-11-12 01:49:22 +00:00
import {Badge} from "@capawesome/capacitor-badge"
2026-07-28 16:16:26 +00:00
import {page} from "$app/stores"
2026-08-17 16:41:38 +00:00
import {
assoc,
prop,
first,
identity,
groupBy,
now,
remove,
throttle,
parseJson,
gt,
} from "@welshman/lib"
import type {SignedEvent, TrustedEvent} from "@welshman/util"
import {
getIdOrAddress,
sortEventsDesc,
2026-07-28 16:16:26 +00:00
tagSpec,
tagValue,
COMMENT,
MESSAGE,
makeHttpAuth,
makeHttpAuthHeader,
} from "@welshman/util"
2026-07-28 16:16:26 +00:00
import {synced, throttled, withGetter} from "@welshman/store"
import {Relays, RoomLists} from "@welshman/app"
import {deriveEventsByIdByUrl} from "@app/repository"
import {app, fromApp} from "@app/core"
2026-08-17 16:41:38 +00:00
import {makeRoomPath, makeSpaceChatPath, makeChatPath, makeContentPath} from "@app/routes"
import {decodeRelay} from "@app/relays"
import {CONTENT_KINDS, makeCommentFilter} from "@app/content"
import {getIsMuted, notificationSettings, userSettingsValues} from "@app/settings"
import {chatsById} from "@app/chats"
2026-07-01 17:23:23 +00:00
import {dufflepud, DUFFLEPUD_URL, PLATFORM_RELAYS} from "@app/env"
import {kv} from "@app/storage"
2026-01-28 01:15:22 +00:00
2024-11-14 18:53:13 +00:00
// Checked state
export const checked = withGetter(
synced<Record<string, number>>({
key: "checked",
defaultValue: {},
storage: kv,
}),
)
export const getChecked = (key: string) => checked.get()[key]
export const deriveChecked = (key: string) => derived(checked, prop<number>(key))
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. */
export const deferredRoomPath = writable<string | undefined>(undefined)
2026-08-17 16:41:38 +00:00
const getPaths = (path: string) =>
path
.split("/")
.map((_, i, segments) => segments.slice(0, i + 1).join("/"))
.slice(1)
const getCheckedPaths = (pathname: string, relay?: string) => {
const paths = getPaths(pathname)
if (relay) {
const url = decodeRelay(relay)
if (CONTENT_KINDS.some(kind => makeContentPath(url, kind) === pathname)) {
paths.push(pathname + "*")
}
}
return paths
}
export const syncChecked = () => {
let prev: string[] = []
2024-11-14 18:53:13 +00:00
return page.subscribe($page => {
2026-03-30 20:22:22 +00:00
// Set checked when we leave a given page
checked.update($checked => {
for (const path of prev) {
$checked[path] = now()
}
return $checked
})
const paths = getCheckedPaths($page.url.pathname, $page.params.relay)
2026-03-30 20:22:22 +00:00
// Set checked when we visit a given page - but delay it a tad
setTimeout(() => {
const defer = get(deferredRoomPath)
2026-03-30 20:22:22 +00:00
checked.update($checked => {
for (const path of paths) {
if (defer && path === defer) continue
2026-03-30 20:22:22 +00:00
$checked[path] = now()
}
return $checked
})
}, 300)
prev = paths
})
}
2024-11-14 18:53:13 +00:00
const CHECKED_KV_KEY = "checked"
const NIP98_MAX_AGE = 23 * 60 * 60
let nip98Auth: SignedEvent | undefined
const nip98Header = async () => {
2026-07-28 16:16:26 +00:00
const $signer = app.get().user?.signer
if (!$signer) {
return undefined
}
if (!nip98Auth || now() - nip98Auth.created_at > NIP98_MAX_AGE) {
nip98Auth = await $signer.sign(await makeHttpAuth(DUFFLEPUD_URL, "GET"))
}
return makeHttpAuthHeader(nip98Auth)
}
const pullCheckedRemote = async () => {
const authorization = await nip98Header()
if (!authorization) {
return
}
const res = await fetch(dufflepud(`kv/${CHECKED_KV_KEY}`), {headers: {authorization}})
if (!res.ok) {
return
}
const remote = parseJson<Record<string, number>>(await res.text())
if (!remote) {
return
}
checked.update($checked => {
for (const [path, ts] of Object.entries(remote)) {
if (gt(ts, $checked[path])) {
$checked[path] = ts
}
}
return $checked
})
}
const pushCheckedRemote = throttle(3000, async () => {
const authorization = await nip98Header()
if (!authorization) {
return
}
try {
await fetch(dufflepud(`kv/${CHECKED_KV_KEY}`), {
method: "POST",
headers: {authorization},
body: JSON.stringify(checked.get()),
})
} catch {
// pass
}
})
export const syncCheckedRemote = () => {
let ready = false
2026-07-28 16:16:26 +00:00
const unsubscribeUser = app.subscribe($app => {
ready = false
nip98Auth = undefined
2026-07-28 16:16:26 +00:00
if ($app.user) {
pullCheckedRemote().then(() => {
ready = true
pushCheckedRemote()
})
}
})
const unsubscribeChecked = checked.subscribe(() => {
2026-07-28 16:16:26 +00:00
if (ready && app.get().user?.pubkey) {
pushCheckedRemote()
}
})
return () => {
2026-07-28 16:16:26 +00:00
unsubscribeUser()
unsubscribeChecked()
}
}
2024-12-16 19:24:10 +00:00
// Derived notifications state
2024-11-14 18:53:13 +00:00
// 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[]) => {
2026-08-17 16:41:38 +00:00
const byPath = new Map<string, TrustedEvent>()
for (const event of events) {
const target = getContentTarget(event)
if (!target) continue
2026-08-14 15:47:36 +00:00
const path = makeContentPath(url, target.kind, target.idOrAddress)
2026-08-17 16:41:38 +00:00
if (path && !byPath.has(path)) {
byPath.set(path, event)
}
}
return byPath
}
2026-08-17 16:41:38 +00:00
export const latestActivityByPath = derived(
2026-01-20 18:40:33 +00:00
throttled(
2026-03-30 20:22:22 +00:00
1000,
2026-01-20 18:40:33 +00:00
derived(
[
2026-07-28 16:16:26 +00:00
app,
2026-01-20 18:40:33 +00:00
chatsById,
2026-07-28 16:16:26 +00:00
fromApp($app => $app.use(Relays).index.$),
fromApp($app => $app.use(RoomLists).index.$),
deriveEventsByIdByUrl([
{kinds: [MESSAGE, ...CONTENT_KINDS]},
makeCommentFilter(CONTENT_KINDS),
]),
userSettingsValues,
2026-01-20 18:40:33 +00:00
],
identity,
),
2026-01-20 18:40:33 +00:00
),
([$app, $chatsById, $relays, $roomLists, eventsByIdByUrl, $settings]) => {
2026-08-17 16:41:38 +00:00
const activity = new Map<string, TrustedEvent>()
2025-10-06 18:23:19 +00:00
2026-01-20 18:40:33 +00:00
for (const {pubkeys, messages} of $chatsById.values()) {
2026-08-17 16:41:38 +00:00
if (messages[0]) {
activity.set(makeChatPath(pubkeys), messages[0])
2025-11-20 23:54:06 +00:00
}
2026-01-20 18:40:33 +00:00
}
2025-10-06 18:23:19 +00:00
2026-08-17 16:41:38 +00:00
const roomList = $app.user?.pubkey ? $roomLists.get($app.user.pubkey) : undefined
2026-07-28 16:16:26 +00:00
const urls = PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : (roomList?.urls() ?? [])
2026-07-01 17:23:23 +00:00
for (const url of urls) {
2026-03-16 20:46:00 +00:00
const events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values())
2026-02-27 20:25:16 +00:00
2026-07-28 16:16:26 +00:00
if ($relays.get(url)?.hasNip(29)) {
for (const [h, [latestEvent]] of groupBy(e => tagValue(tagSpec("h"), e.tags), events)) {
// A muted room is left out entirely, so it can't light up its own badge or the space's
if (h && !getIsMuted($settings, url, h)) {
2026-08-17 16:41:38 +00:00
activity.set(makeRoomPath(url, h), latestEvent)
2026-02-27 20:25:16 +00:00
}
}
} else {
const latestEvent = first(events)
2026-02-27 20:25:16 +00:00
2026-08-17 16:41:38 +00:00
if (latestEvent) {
activity.set(makeSpaceChatPath(url), latestEvent)
}
}
2026-08-17 16:41:38 +00:00
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
}
for (const [entryPath, ts] of Object.entries($checked)) {
const isMatch = entryPath.endsWith("*")
? path.startsWith(entryPath.slice(0, -1))
: entryPath.startsWith(path)
if (isMatch && ts > latestEvent.created_at) {
return false
}
}
2026-08-17 16:41:38 +00:00
return true
}
2026-08-17 16:41:38 +00:00
const paths = new Set<string>()
2026-08-17 16:41:38 +00:00
for (const [path, latestEvent] of $latestActivityByPath) {
if (hasNotification(path, latestEvent)) {
paths.add(path)
for (const branchPath of remove(path, getPaths(path.split("?")[0]))) {
if (hasNotification(branchPath, latestEvent)) {
paths.add(branchPath)
}
2026-02-27 20:25:16 +00:00
}
}
2026-01-20 18:40:33 +00:00
}
2024-12-16 18:30:42 +00:00
2026-01-20 18:40:33 +00:00
return paths
},
)
export const notifications = derived(
[page, allNotifications, deferredRoomPath],
([$page, $allNotifications, $deferredRoomPath]) =>
new Set(
[...$allNotifications].filter(p => {
if (!$page.url.pathname.startsWith(p)) return true
if ($deferredRoomPath && p === $deferredRoomPath) return true
return false
}),
),
)
const countActivity = (activity: Map<string, TrustedEvent>, paths: Set<string>) =>
[...activity.keys()].filter(path => paths.has(path)).length
export const notificationCount = derived(
[latestActivityByPath, notifications],
([$latestActivityByPath, $notifications]) => countActivity($latestActivityByPath, $notifications),
)
export const backgroundNotificationCount = derived(
[latestActivityByPath, allNotifications],
([$latestActivityByPath, $allNotifications]) =>
countActivity($latestActivityByPath, $allNotifications),
)
// Badges
export const syncBadges = () =>
derived([notificationCount, notificationSettings], identity).subscribe(
async ([count, $notificationSettings]) => {
2026-01-28 01:15:22 +00:00
if ($notificationSettings.badge) {
try {
2026-08-17 16:41:38 +00:00
await Badge.set({count})
} catch (err) {
// pass - firefox doesn't support badges
}
} else {
await clearBadges()
}
},
)
2025-09-23 15:34:53 +00:00
export const clearBadges = async () => {
2026-01-22 20:37:30 +00:00
try {
await Badge.clear()
} catch (e) {
// pass - firefox doesn't support this
2026-01-22 20:37:30 +00:00
}
2025-09-23 15:34:53 +00:00
}