Simplify and optimize notifications

This commit is contained in:
Jon Staab
2024-12-16 11:24:10 -08:00
parent 85e5413951
commit 3d3ffaf406
12 changed files with 120 additions and 151 deletions
+3 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {page} from "$app/stores"
import {remove, assoc} from "@welshman/lib"
import {remove} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {pubkey, loadInboxRelaySelections} from "@welshman/app"
import {fade} from "@lib/transition"
@@ -10,7 +10,7 @@
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {makeChatPath} from "@app/routes"
import {CHAT_FILTERS, deriveNotification} from "@app/notifications"
import {notifications} from "@app/notifications"
export let id: string
export let pubkeys: string[]
@@ -19,7 +19,6 @@
const others = remove($pubkey!, pubkeys)
const active = $page.params.chat === id
const path = makeChatPath(pubkeys)
const notification = deriveNotification(path, CHAT_FILTERS.map(assoc("authors", pubkeys)))
onMount(() => {
for (const pk of others) {
@@ -47,7 +46,7 @@
</p>
{/if}
</div>
{#if !active && $notification}
{#if !active && $notifications.has(path)}
<div class="h-2 w-2 rounded-full bg-primary" transition:fade />
{/if}
</div>
+2 -3
View File
@@ -22,7 +22,7 @@
deriveUserRooms,
deriveOtherRooms,
} from "@app/state"
import {deriveNotification, THREAD_FILTERS} from "@app/notifications"
import {notifications} from "@app/notifications"
import {pullConservatively} from "@app/requests"
import {pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes"
@@ -30,7 +30,6 @@
export let url
const threadsPath = makeSpacePath(url, "threads")
const threadsNotification = deriveNotification(threadsPath, THREAD_FILTERS, url)
const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url)
@@ -114,7 +113,7 @@
<SecondaryNavItem href={makeSpacePath(url)}>
<Icon icon="home-smile" /> Home
</SecondaryNavItem>
<SecondaryNavItem href={threadsPath} notification={$threadsNotification}>
<SecondaryNavItem href={threadsPath} notification={$notifications.has(threadsPath)}>
<Icon icon="notes-minimalistic" /> Threads
</SecondaryNavItem>
<div class="h-2" />
+5 -32
View File
@@ -1,48 +1,21 @@
<script lang="ts">
import {page} from "$app/stores"
import {derived} from "svelte/store"
import {max} from "@welshman/lib"
import {matchFilter, MESSAGE} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import MenuSpace from "@app/components/MenuSpace.svelte"
import {checked, getNotification, deriveNotification, THREAD_FILTERS} from "@app/notifications"
import {userMembership, getMembershipRoomsByUrl, deriveEventsForUrl, GENERAL} from "@app/state"
import {makeRoomPath, makeSpacePath} from "@app/routes"
import {notifications} from "@app/notifications"
import {makeSpacePath} from "@app/routes"
import {pushDrawer} from "@app/modal"
export let url
const path = makeSpacePath(url)
const openMenu = () => pushDrawer(MenuSpace, {url})
const events = deriveEventsForUrl(url, [{kinds: [MESSAGE]}])
const threadsPath = makeSpacePath(url, "threads")
const threadsNotification = deriveNotification(threadsPath, THREAD_FILTERS, url)
const notification = derived(
[page, events, checked, userMembership],
([$page, $events, $checked, $userMembership]) =>
getMembershipRoomsByUrl(url, $userMembership)
.concat(GENERAL)
.some(room => {
const path = makeRoomPath(url, room)
if ($page.url.pathname === path) return false
const lastChecked = max([$checked["*"], $checked[path]])
const roomEvents = $events.filter(e => matchFilter({"#h": [room]}, e))
return getNotification($pubkey, lastChecked, roomEvents)
}),
)
</script>
<Button on:click={openMenu} class="btn btn-neutral btn-sm relative md:hidden">
<Icon icon="menu-dots" />
{#if $threadsNotification || $notification}
{#if $notifications.has(path)}
<div class="absolute right-0 top-0 -mr-1 -mt-1 h-2 w-2 rounded-full bg-primary" />
{/if}
</Button>
+2 -6
View File
@@ -1,11 +1,10 @@
<script lang="ts">
import {readable} from "svelte/store"
import Icon from "@lib/components/Icon.svelte"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import {makeSpacePath} from "@app/routes"
import {deriveChannel, channelIsLocked} from "@app/state"
import {deriveNotification, getRoomFilters} from "@app/notifications"
import {notifications} from "@app/notifications"
export let url
export let room
@@ -13,12 +12,9 @@
const path = makeSpacePath(url, room)
const channel = deriveChannel(url, room)
const notification = notify
? deriveNotification(path, getRoomFilters(room), url)
: readable(false)
</script>
<SecondaryNavItem href={path} notification={$notification}>
<SecondaryNavItem href={path} notification={notify ? $notifications.has(path) : false}>
{#if channelIsLocked($channel)}
<Icon icon="lock" size={4} />
{:else}
+2 -2
View File
@@ -5,7 +5,7 @@
import RelayName from "@app/components/RelayName.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte"
import {makeSpacePath} from "@app/routes"
import {inactiveSpacesNotifications} from "@app/notifications"
import {inactiveNotifications} from "@app/notifications"
export let url
@@ -17,7 +17,7 @@
<div slot="icon"><SpaceAvatar {url} /></div>
<div slot="title" class="flex gap-1">
<RelayName {url} />
{#if $inactiveSpacesNotifications.includes(path)}
{#if $inactiveNotifications.has(path)}
<div class="relative top-1 h-2 w-2 rounded-full bg-primary" />
{/if}
</div>
+14 -12
View File
@@ -17,20 +17,22 @@
PLATFORM_LOGO,
} from "@app/state"
import {pushModal} from "@app/modal"
import {deriveNotification, inactiveSpacesNotifications, CHAT_FILTERS} from "@app/notifications"
const chatNotification = deriveNotification("/chat", CHAT_FILTERS)
import {makeSpacePath} from "@app/routes"
import {notifications, inactiveNotifications} from "@app/notifications"
const addSpace = () => pushModal(SpaceAdd)
const showSpacesMenu = () =>
getMembershipUrls($userMembership).length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd)
const showSpacesMenu = () => (spacePaths.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd))
const showSettingsMenu = () => pushModal(MenuSettings)
const openNotes = () => ($canDecrypt ? goto("/notes") : pushModal(ChatEnable, {next: "/notes"}))
const openChat = () => ($canDecrypt ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
$: spaceUrls = getMembershipUrls($userMembership)
$: spacePaths = spaceUrls.map(url => makeSpacePath(url))
$: anySpaceNotifications = spacePaths.some(path => $inactiveNotifications.has(path))
</script>
<div class="relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
@@ -43,7 +45,7 @@
<Avatar src={PLATFORM_LOGO} class="!h-10 !w-10" />
</PrimaryNavItem>
<Divider />
{#each getMembershipUrls($userMembership) as url (url)}
{#each spaceUrls as url (url)}
<PrimaryNavItemSpace {url} />
{/each}
<PrimaryNavItem title="Add Space" on:click={addSpace} class="tooltip-right">
@@ -66,7 +68,7 @@
title="Messages"
on:click={openChat}
class="tooltip-right"
notification={$chatNotification}>
notification={$notifications.has("/chat")}>
<Avatar icon="letter" class="!h-10 !w-10" />
</PrimaryNavItem>
<PrimaryNavItem title="Search" href="/people" class="tooltip-right">
@@ -88,13 +90,13 @@
<PrimaryNavItem title="Notes" href="/notes">
<Avatar icon="notes-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem>
<PrimaryNavItem title="Messages" on:click={openChat} notification={$chatNotification}>
<PrimaryNavItem
title="Messages"
on:click={openChat}
notification={$notifications.has("/chat")}>
<Avatar icon="letter" class="!h-10 !w-10" />
</PrimaryNavItem>
<PrimaryNavItem
title="Spaces"
on:click={showSpacesMenu}
notification={$inactiveSpacesNotifications.length > 0}>
<PrimaryNavItem title="Spaces" on:click={showSpacesMenu} notification={anySpaceNotifications}>
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem>
</div>
@@ -3,7 +3,7 @@
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import SpaceAvatar from "@app/components/SpaceAvatar.svelte"
import {makeSpacePath} from "@app/routes"
import {spacesNotifications} from "@app/notifications"
import {notifications} from "@app/notifications"
export let url
@@ -14,6 +14,6 @@
title={displayRelayUrl(url)}
href={path}
class="tooltip-right"
notification={$spacesNotifications.includes(path)}>
notification={$notifications.has(path)}>
<SpaceAvatar {url} />
</PrimaryNavItem>
+2 -3
View File
@@ -15,7 +15,7 @@
import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ThreadMenu from "@app/components/ThreadMenu.svelte"
import {publishDelete, publishReaction} from "@app/commands"
import {deriveNotification} from "@app/notifications"
import {notifications} from "@app/notifications"
import {makeSpacePath} from "@app/routes"
import {PROTECTED} from "@app/state"
@@ -28,7 +28,6 @@
const path = makeSpacePath(url, "threads", event.id)
const filters = [{kinds: [COMMENT], "#E": [event.id]}]
const replies = deriveEvents(repository, {filters})
const notification = deriveNotification(path, filters, url)
const showPopover = () => popover.show()
@@ -70,7 +69,7 @@
<span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span>
</div>
<div class="btn btn-neutral btn-xs relative hidden rounded-full sm:flex">
{#if $notification}
{#if $notifications.has(path)}
<div class="h-2 w-2 rounded-full bg-primary" />
{/if}
Active {formatTimestampRelative(lastActive)}
+63 -75
View File
@@ -1,16 +1,15 @@
import {writable, derived} from "svelte/store"
import {page} from "$app/stores"
import {deriveEvents} from "@welshman/store"
import {repository, pubkey} from "@welshman/app"
import {prop, max, sortBy, assoc, lt, now} from "@welshman/lib"
import type {Filter, TrustedEvent} from "@welshman/util"
import {DIRECT_MESSAGE, MESSAGE, THREAD, COMMENT} from "@welshman/util"
import {makeSpacePath, makeThreadPath, makeRoomPath} from "@app/routes"
import {pubkey} from "@welshman/app"
import {prop, max, sortBy, now} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {MESSAGE} from "@welshman/util"
import {makeSpacePath, makeChatPath, makeThreadPath, makeRoomPath} from "@app/routes"
import {
LEGACY_THREAD,
THREAD_FILTER,
COMMENT_FILTER,
chats,
getEventsForUrl,
deriveEventsForUrl,
getMembershipUrls,
userRoomsByUrl,
repositoryStore,
} from "@app/state"
@@ -19,85 +18,74 @@ import {
export const checked = writable<Record<string, number>>({})
checked.subscribe(v => console.log("====== checked", v))
export const deriveChecked = (key: string) => derived(checked, prop(key))
export const setChecked = (key: string, ts = now()) =>
Boolean(console.trace("====== setChecked", key)) ||
checked.update(state => ({...state, [key]: ts}))
export const setChecked = (key: string) =>
checked.update(state => ({...state, [key]: now()}))
// Filters for various routes
// Derived notifications state
export const CHAT_FILTERS: Filter[] = [{kinds: [DIRECT_MESSAGE]}]
export const notifications = derived(
[pubkey, checked, chats, userRoomsByUrl, repositoryStore],
([$pubkey, $checked, $chats, $userRoomsByUrl, $repository]) => {
const hasNotification = (path: string, events: TrustedEvent[]) => {
const [latestEvent] = sortBy($e => -$e.created_at, events)
export const SPACE_FILTERS: Filter[] = [{kinds: [THREAD, MESSAGE, COMMENT]}]
if (!latestEvent || latestEvent.pubkey === $pubkey) {
return false
}
export const ROOM_FILTERS: Filter[] = [{kinds: [MESSAGE]}]
let checkPath = ""
let lastChecked = $checked["*"]
export const THREAD_FILTERS: Filter[] = [
{kinds: [THREAD, LEGACY_THREAD]},
{kinds: [COMMENT], "#K": [String(THREAD), String(LEGACY_THREAD)]},
]
for (const segment of path.slice(1).split("/")) {
checkPath += "/" + segment
lastChecked = max([lastChecked, $checked[checkPath]])
}
export const getNotificationFilters = (since: number): Filter[] =>
[...CHAT_FILTERS, ...SPACE_FILTERS, ...THREAD_FILTERS].map(assoc("since", since))
export const getRoomFilters = (room: string): Filter[] => ROOM_FILTERS.map(assoc("#h", [room]))
// Notification derivation
export const getNotification = (
pubkey: string | null,
lastChecked: number,
events: TrustedEvent[],
) => {
const [latestEvent] = sortBy($e => -$e.created_at, events)
return latestEvent?.pubkey !== pubkey && lt(lastChecked, latestEvent?.created_at)
}
export const deriveNotification = (path: string, filters: Filter[], url?: string) => {
const events = url ? deriveEventsForUrl(url, filters) : deriveEvents(repository, {filters})
return derived(
[pubkey, deriveChecked("*"), deriveChecked(path), events],
([$pubkey, $allChecked, $checked, $events]) => {
return getNotification($pubkey, max([$allChecked, $checked]), $events)
},
)
}
export const spacesNotifications = derived(
[pubkey, checked, userRoomsByUrl, repositoryStore],
([$pubkey, $checked, $userRoomsByUrl, $repository]) => {
const hasNotification = (url: string, path: string, filters: Filter[]) => {
const lastChecked = max([$checked["*"], $checked[path]])
const events = getEventsForUrl($repository, url, filters)
return getNotification($pubkey, lastChecked, events)
return lastChecked < latestEvent.created_at
}
return Array.from($userRoomsByUrl.entries())
.filter(([url, rooms]) => {
if (hasNotification(url, makeThreadPath(url), THREAD_FILTERS)) {
return true
}
const paths = new Set<string>()
for (const room of rooms) {
if (hasNotification(url, makeRoomPath(url, room), [{kinds: [MESSAGE], "#h": [room]}])) {
return true
}
}
for (const {pubkeys, messages} of $chats) {
const chatPath = makeChatPath(pubkeys)
return false
})
.map(([url]) => makeSpacePath(url))
if (hasNotification(chatPath, messages)) {
paths.add("/chat")
paths.add(chatPath)
}
}
for (const [url, rooms] of $userRoomsByUrl.entries()) {
const spacePath = makeSpacePath(url)
const threadPath = makeThreadPath(url)
const threadFilters = [THREAD_FILTER, COMMENT_FILTER]
const threadEvents = getEventsForUrl($repository, url, threadFilters)
if (hasNotification(threadPath, threadEvents)) {
paths.add(spacePath)
paths.add(threadPath)
}
for (const room of rooms) {
const roomPath = makeRoomPath(url, room)
const roomFilters = [{kinds: [MESSAGE], "#h": [room]}]
const roomEvents = getEventsForUrl($repository, url, roomFilters)
if (hasNotification(roomPath, roomEvents)) {
paths.add(spacePath)
paths.add(roomPath)
}
}
}
return paths
},
)
export const inactiveSpacesNotifications = derived(
[page, spacesNotifications],
([$page, $spacesNotifications]) =>
$spacesNotifications.filter(path => !$page.url.pathname.startsWith(path)),
export const inactiveNotifications = derived(
[page, notifications],
([$page, $notifications]) =>
new Set(Array.from($notifications).filter(path => !$page.url.pathname.startsWith(path))),
)
+16 -2
View File
@@ -40,7 +40,14 @@ import {
asDecryptedEvent,
normalizeRelayUrl,
} from "@welshman/util"
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util"
import type {
TrustedEvent,
Repository,
SignedEvent,
PublishedList,
List,
Filter,
} from "@welshman/util"
import {Nip59} from "@welshman/signer"
import {
pubkey,
@@ -63,7 +70,7 @@ import {
thunks,
walkThunks,
} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import type {Thunk, Relay} from "@welshman/app"
import type {SubscribeRequestWithHandlers} from "@welshman/net"
import {deriveEvents, deriveEventsMapped, withGetter, synced} from "@welshman/store"
@@ -107,6 +114,13 @@ export const IMGPROXY_URL = "https://imgproxy.coracle.social"
export const REACTION_KINDS = [REACTION, ZAP_RESPONSE]
export const THREAD_FILTER: Filter = {kinds: [THREAD, LEGACY_THREAD]}
export const COMMENT_FILTER: Filter = {
kinds: [COMMENT],
"#K": [String(THREAD), String(LEGACY_THREAD)],
}
export const NIP46_PERMS =
"nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt," +
[CLIENT_AUTH, AUTH_JOIN, MESSAGE, THREAD, COMMENT, GROUPS, WRAP, REACTION]
+2 -2
View File
@@ -13,7 +13,7 @@
import {setChecked} from "@app/notifications"
import {checkRelayConnection, checkRelayAuth, checkRelayAccess} from "@app/commands"
import {decodeRelay} from "@app/state"
import {spacesNotifications} from "@app/notifications"
import {notifications} from "@app/notifications"
const url = decodeRelay($page.params.relay)
@@ -35,7 +35,7 @@
// We have to watch this one, since on mobile the badge will be visible when active
$: {
if ($spacesNotifications.includes($page.url.pathname)) {
if ($notifications.has($page.url.pathname)) {
setChecked($page.url.pathname)
}
}
@@ -3,7 +3,7 @@
import {derived} from "svelte/store"
import {page} from "$app/stores"
import {sortBy, min, nthEq, sleep} from "@welshman/lib"
import {getListTags, getPubkeyTagValues, THREAD, COMMENT} from "@welshman/util"
import {getListTags, getPubkeyTagValues} from "@welshman/util"
import {throttled} from "@welshman/store"
import {feedsFromFilters, makeIntersectionFeed, makeRelayFeed} from "@welshman/feeds"
import {createFeedController, userMutes} from "@welshman/app"
@@ -16,15 +16,14 @@
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import {LEGACY_THREAD, decodeRelay, deriveEventsForUrl} from "@app/state"
import {THREAD_FILTERS, setChecked} from "@app/notifications"
import {THREAD_FILTER, COMMENT_FILTER, decodeRelay, deriveEventsForUrl} from "@app/state"
import {setChecked} from "@app/notifications"
import {pushModal} from "@app/modal"
const url = decodeRelay($page.params.relay)
const threads = deriveEventsForUrl(url, [{kinds: [THREAD, LEGACY_THREAD]}])
const comments = deriveEventsForUrl(url, [
{kinds: [COMMENT], "#K": [String(THREAD), String(LEGACY_THREAD)]},
])
const feeds = feedsFromFilters([THREAD_FILTER, COMMENT_FILTER])
const threads = deriveEventsForUrl(url, [THREAD_FILTER])
const comments = deriveEventsForUrl(url, [COMMENT_FILTER])
const mutedPubkeys = getPubkeyTagValues(getListTags($userMutes))
const events = throttled(
@@ -51,7 +50,7 @@
const ctrl = createFeedController({
useWindowing: true,
feed: makeIntersectionFeed(makeRelayFeed(url), feedsFromFilters(THREAD_FILTERS)),
feed: makeIntersectionFeed(makeRelayFeed(url), feeds),
onExhausted: () => {
loading = false
},