2026-05-19 15:36:22 +00:00
|
|
|
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-05-29 21:15:56 +00:00
|
|
|
import {assoc, prop, first, identity, groupBy, now, throttle, parseJson, gt} from "@welshman/lib"
|
|
|
|
|
import type {SignedEvent, TrustedEvent} from "@welshman/util"
|
|
|
|
|
import {
|
2026-08-14 15:37:27 +00:00
|
|
|
getIdOrAddress,
|
2026-05-29 21:15:56 +00:00
|
|
|
sortEventsDesc,
|
2026-07-28 16:16:26 +00:00
|
|
|
tagSpec,
|
|
|
|
|
tagValue,
|
2026-08-14 15:37:27 +00:00
|
|
|
COMMENT,
|
2026-05-29 21:15:56 +00:00
|
|
|
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-14 15:37:27 +00:00
|
|
|
import {
|
|
|
|
|
makeSpacePath,
|
|
|
|
|
makeRoomPath,
|
|
|
|
|
makeSpaceChatPath,
|
|
|
|
|
makeChatPath,
|
2026-08-14 15:47:36 +00:00
|
|
|
makeContentPath,
|
2026-08-14 15:37:27 +00:00
|
|
|
} from "@app/routes"
|
2026-06-08 22:33:38 +00:00
|
|
|
import {CONTENT_KINDS, makeCommentFilter} from "@app/content"
|
|
|
|
|
import {notificationSettings} 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"
|
2026-06-08 22:33:38 +00:00
|
|
|
import {kv} from "@app/storage"
|
2026-01-28 01:15:22 +00:00
|
|
|
|
2024-11-14 18:53:13 +00:00
|
|
|
// Checked state
|
|
|
|
|
|
2026-02-09 19:44:32 +00:00
|
|
|
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()))
|
|
|
|
|
|
2026-05-19 15:36:22 +00:00
|
|
|
/** Room path while video call UI hides chat; checked + badge stay active until chat is shown. */
|
|
|
|
|
export const deferredRoomPath = writable<string | undefined>(undefined)
|
|
|
|
|
|
2026-02-09 19:44:32 +00:00
|
|
|
export const syncChecked = () => {
|
|
|
|
|
let prev = ""
|
2024-11-14 18:53:13 +00:00
|
|
|
|
2026-02-09 19:44:32 +00:00
|
|
|
const getPaths = (path: string) =>
|
|
|
|
|
path
|
|
|
|
|
.split("/")
|
|
|
|
|
.map((_, i, segments) => segments.slice(0, i + 1).join("/"))
|
|
|
|
|
.slice(1)
|
2024-11-14 18:53:13 +00:00
|
|
|
|
2026-02-09 19:44:32 +00:00
|
|
|
return page.subscribe($page => {
|
2026-03-30 20:22:22 +00:00
|
|
|
// Set checked when we leave a given page
|
2026-02-09 19:44:32 +00:00
|
|
|
checked.update($checked => {
|
|
|
|
|
for (const path of getPaths(prev)) {
|
|
|
|
|
$checked[path] = now()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $checked
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-30 20:22:22 +00:00
|
|
|
// Set checked when we visit a given page - but delay it a tad
|
|
|
|
|
setTimeout(() => {
|
2026-05-19 15:36:22 +00:00
|
|
|
const defer = get(deferredRoomPath)
|
|
|
|
|
|
2026-03-30 20:22:22 +00:00
|
|
|
checked.update($checked => {
|
|
|
|
|
for (const path of getPaths($page.url.pathname)) {
|
2026-05-19 15:36:22 +00:00
|
|
|
if (defer && path === defer) continue
|
2026-03-30 20:22:22 +00:00
|
|
|
$checked[path] = now()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $checked
|
|
|
|
|
})
|
|
|
|
|
}, 300)
|
|
|
|
|
|
2026-02-09 19:44:32 +00:00
|
|
|
prev = $page.url.pathname
|
|
|
|
|
})
|
|
|
|
|
}
|
2024-11-14 18:53:13 +00:00
|
|
|
|
2026-05-29 21:15:56 +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
|
2026-05-29 21:15:56 +00:00
|
|
|
|
|
|
|
|
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 => {
|
2026-05-29 21:15:56 +00:00
|
|
|
ready = false
|
|
|
|
|
nip98Auth = undefined
|
|
|
|
|
|
2026-07-28 16:16:26 +00:00
|
|
|
if ($app.user) {
|
2026-05-29 21:15:56 +00:00
|
|
|
pullCheckedRemote().then(() => {
|
|
|
|
|
ready = true
|
|
|
|
|
pushCheckedRemote()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const unsubscribeChecked = checked.subscribe(() => {
|
2026-07-28 16:16:26 +00:00
|
|
|
if (ready && app.get().user?.pubkey) {
|
2026-05-29 21:15:56 +00:00
|
|
|
pushCheckedRemote()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return () => {
|
2026-07-28 16:16:26 +00:00
|
|
|
unsubscribeUser()
|
2026-05-29 21:15:56 +00:00
|
|
|
unsubscribeChecked()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-12-16 19:24:10 +00:00
|
|
|
// Derived notifications state
|
2024-11-14 18:53:13 +00:00
|
|
|
|
2026-08-14 15:37:27 +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[]) => {
|
|
|
|
|
const byPath = new Map<string, {listPath: string; latestEvent: 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)
|
|
|
|
|
const listPath = makeContentPath(url, target.kind)
|
2026-08-14 15:37:27 +00:00
|
|
|
|
|
|
|
|
if (path && listPath && !byPath.has(path)) {
|
|
|
|
|
byPath.set(path, {listPath, latestEvent: event})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return byPath
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 19:44:32 +00:00
|
|
|
export const allNotifications = 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
|
|
|
checked,
|
|
|
|
|
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),
|
|
|
|
|
]),
|
2026-01-20 18:40:33 +00:00
|
|
|
],
|
|
|
|
|
identity,
|
2025-07-17 22:35:49 +00:00
|
|
|
),
|
2026-01-20 18:40:33 +00:00
|
|
|
),
|
2026-07-28 16:16:26 +00:00
|
|
|
([$app, $checked, $chatsById, $relays, $roomLists, eventsByIdByUrl]) => {
|
2026-02-09 19:44:32 +00:00
|
|
|
const hasNotification = (path: string, latestEvent?: TrustedEvent) => {
|
2026-07-28 16:16:26 +00:00
|
|
|
if (!latestEvent || latestEvent.pubkey === $app.user?.pubkey) {
|
2026-01-20 18:40:33 +00:00
|
|
|
return false
|
|
|
|
|
}
|
2024-11-14 18:53:13 +00:00
|
|
|
|
2026-01-20 18:40:33 +00:00
|
|
|
for (const [entryPath, ts] of Object.entries($checked)) {
|
|
|
|
|
const isMatch =
|
|
|
|
|
entryPath === "*" ||
|
|
|
|
|
entryPath.startsWith(path) ||
|
|
|
|
|
(entryPath === "/chat/*" && path.startsWith("/chat/"))
|
2024-11-14 20:16:44 +00:00
|
|
|
|
2026-01-20 18:40:33 +00:00
|
|
|
if (isMatch && ts > latestEvent.created_at) {
|
|
|
|
|
return false
|
2025-11-20 23:54:06 +00:00
|
|
|
}
|
2024-12-16 19:24:10 +00:00
|
|
|
}
|
2025-02-06 17:29:30 +00:00
|
|
|
|
2026-01-20 18:40:33 +00:00
|
|
|
return true
|
|
|
|
|
}
|
2025-02-06 17:29:30 +00:00
|
|
|
|
2026-01-20 18:40:33 +00:00
|
|
|
const paths = new Set<string>()
|
2025-10-06 18:23:19 +00:00
|
|
|
|
2026-01-20 18:40:33 +00:00
|
|
|
for (const {pubkeys, messages} of $chatsById.values()) {
|
|
|
|
|
const chatPath = makeChatPath(pubkeys)
|
|
|
|
|
|
|
|
|
|
if (hasNotification(chatPath, messages[0])) {
|
|
|
|
|
paths.add("/chat")
|
|
|
|
|
paths.add(chatPath)
|
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-07-28 16:16:26 +00:00
|
|
|
const roomList = $app.user?.pubkey ? $roomLists.get($app.user?.pubkey) : undefined
|
|
|
|
|
const urls = PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : (roomList?.urls() ?? [])
|
2026-07-01 17:23:23 +00:00
|
|
|
|
|
|
|
|
for (const url of urls) {
|
2026-01-20 18:40:33 +00:00
|
|
|
const spacePath = makeSpacePath(url)
|
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)) {
|
2026-03-25 20:44:25 +00:00
|
|
|
if (h) {
|
|
|
|
|
const roomPath = makeRoomPath(url, h)
|
|
|
|
|
|
|
|
|
|
if (hasNotification(roomPath, latestEvent)) {
|
|
|
|
|
paths.add(roomPath)
|
2026-07-21 18:05:39 +00:00
|
|
|
|
|
|
|
|
if (hasNotification(spacePath, latestEvent)) {
|
|
|
|
|
paths.add(spacePath)
|
|
|
|
|
}
|
2026-03-25 20:44:25 +00:00
|
|
|
}
|
2026-02-27 20:25:16 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const messagesPath = makeSpaceChatPath(url)
|
2026-07-21 18:05:39 +00:00
|
|
|
const latestEvent = first(events)
|
2026-02-27 20:25:16 +00:00
|
|
|
|
2026-07-21 18:05:39 +00:00
|
|
|
if (hasNotification(messagesPath, latestEvent)) {
|
2026-02-27 20:25:16 +00:00
|
|
|
paths.add(messagesPath)
|
2026-07-21 18:05:39 +00:00
|
|
|
|
2026-08-14 15:37:27 +00:00
|
|
|
if (hasNotification(spacePath, latestEvent)) {
|
|
|
|
|
paths.add(spacePath)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [path, {listPath, latestEvent}] of latestEventByContentPath(url, events)) {
|
|
|
|
|
if (hasNotification(path, latestEvent)) {
|
|
|
|
|
paths.add(path)
|
2026-08-17 15:55:54 +00:00
|
|
|
|
|
|
|
|
if (hasNotification(listPath, latestEvent)) {
|
|
|
|
|
paths.add(listPath)
|
|
|
|
|
}
|
2026-08-14 15:37:27 +00:00
|
|
|
|
2026-07-21 18:05:39 +00:00
|
|
|
if (hasNotification(spacePath, latestEvent)) {
|
|
|
|
|
paths.add(spacePath)
|
|
|
|
|
}
|
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
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-19 15:36:22 +00:00
|
|
|
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
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-02-09 19:44:32 +00:00
|
|
|
|
2026-01-22 22:23:46 +00:00
|
|
|
// Badges
|
|
|
|
|
|
|
|
|
|
export const syncBadges = () =>
|
2026-01-28 01:15:22 +00:00
|
|
|
derived([notifications, notificationSettings], identity).subscribe(
|
|
|
|
|
async ([$notifications, $notificationSettings]) => {
|
|
|
|
|
if ($notificationSettings.badge) {
|
2026-01-22 22:23:46 +00:00
|
|
|
try {
|
|
|
|
|
await Badge.set({count: $notifications.size})
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// pass - firefox doesn't support badges
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
await clearBadges()
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-09-23 15:34:53 +00:00
|
|
|
|
2025-09-24 17:32:55 +00:00
|
|
|
export const clearBadges = async () => {
|
2026-01-22 20:37:30 +00:00
|
|
|
try {
|
|
|
|
|
await Badge.clear()
|
|
|
|
|
} catch (e) {
|
2026-01-22 22:23:46 +00:00
|
|
|
// pass - firefox doesn't support this
|
2026-01-22 20:37:30 +00:00
|
|
|
}
|
2025-09-23 15:34:53 +00:00
|
|
|
}
|