diff options
| author | Kit La Touche <kit@transneptune.net> | 2025-11-30 22:59:21 -0500 |
|---|---|---|
| committer | Kit La Touche <kit@transneptune.net> | 2025-11-30 22:59:21 -0500 |
| commit | 663dcb13a5b5085b58ebb10ee5f5eff4bb3d07ce (patch) | |
| tree | 957e310b190e5454bfc3301481bb6ff2a8def539 | |
| parent | 91c33501a315abe04aeed54aa27388ce0ad241ce (diff) | |
Show number of unread conversations in app badgenotifs-controls
| -rw-r--r-- | package-lock.json | 2 | ||||
| -rw-r--r-- | ui/lib/components/ChannelMeta.svelte | 44 | ||||
| -rw-r--r-- | ui/routes/(app)/c/[conversation]/+page.svelte | 15 | ||||
| -rw-r--r-- | ui/service-worker.js | 44 | ||||
| -rw-r--r-- | ui/styles/app-bar.css | 2 | ||||
| -rw-r--r-- | ui/styles/forms.css | 8 | ||||
| -rw-r--r-- | ui/styles/messages.css | 24 |
7 files changed, 138 insertions, 1 deletions
diff --git a/package-lock.json b/package-lock.json index 849a38c..211ad61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,7 @@ "vitest": "^4.0.8" }, "engines": { - "node": ">=22.0.0 <23.0.0", + "node": ">=22.12.0 <23.0.0", "npm": ">=10.0.0 <11.0.0" } }, diff --git a/ui/lib/components/ChannelMeta.svelte b/ui/lib/components/ChannelMeta.svelte new file mode 100644 index 0000000..2ee13b6 --- /dev/null +++ b/ui/lib/components/ChannelMeta.svelte @@ -0,0 +1,44 @@ +<script> + let { vapid, subscription, startExpanded = false, subscribe = async () => null } = $props(); + let pending = $state(false); + let expanded = $state(startExpanded); + + function onsubmit(callback) { + return async (evt) => { + evt.preventDefault(); + // Without this, the toggleExpanded function defined below will trigger: + evt.stopPropagation(); + + pending = true; + try { + // TODO: is this working? + await callback(); + } finally { + pending = false; + } + }; + } + + function toggleExpanded() { + // TODO: in messages.css we need to slide the whole div up, not leave its + // contents static. We need to show three little lines as a handle. We need + // to fix colours. We need to keep the meta consistently over the rest of + // the message list. We need to fix mobile click-area. Too easy to hit the + // title and go to / instead. + // Also: we can start expanded optionally, if there is a message we need to + // show. This can work for prompts like "activate your notifications". + expanded = !expanded; + } +</script> + +<div class="channel-meta {expanded ? 'expanded' : ''}" onclick={toggleExpanded}> + + <div class="inner"> + {#if !subscription} + <form class="form" onsubmit={onsubmit(subscribe)}> + <button disabled={pending} type="submit">create push subscription</button> + </form> + {/if} + </div> + <div class="handle">≡</div> +</div> diff --git a/ui/routes/(app)/c/[conversation]/+page.svelte b/ui/routes/(app)/c/[conversation]/+page.svelte index 24baa47..be97737 100644 --- a/ui/routes/(app)/c/[conversation]/+page.svelte +++ b/ui/routes/(app)/c/[conversation]/+page.svelte @@ -1,6 +1,7 @@ <script> import { DateTime } from 'luxon'; import { page } from '$app/state'; + import ChannelMeta from '$lib/components/ChannelMeta.svelte'; import MessageInput from '$lib/components/MessageInput.svelte'; import MessageRun from '$lib/components/MessageRun.svelte'; import Message from '$lib/components/Message.svelte'; @@ -10,6 +11,9 @@ const { session, outbox } = data; let activeConversation; + const subscription = $derived(session.push.subscription); + const vapid = $derived(session.push.vapidKey); + const conversationId = $derived(page.params.conversation); const conversation = $derived( session.conversations.find((conversation) => conversation.id === conversationId), @@ -56,6 +60,11 @@ if (!!at) { session.local.updateLastReadAt(conversationId, at); } + navigator.serviceWorker.controller.postMessage({ + type: 'CONVERSATION_READ', + conversationId, + at, + }); } $effect(() => { @@ -82,6 +91,11 @@ lastReadCallback = setTimeout(setLastRead, 2 * 1000); } + async function subscribe() { + // TODO: we need to provide specific subscription stuff, right? + await session.push.subscribe(); + } + async function sendMessage(message) { outbox.sendToConversation(conversationId, message); } @@ -93,6 +107,7 @@ <svelte:window onkeydown={handleKeydown} /> +<ChannelMeta {subscribe} {vapid} {subscription} /> <div class="active-conversation" {onscroll} bind:this={activeConversation}> {#each messageRuns as { sender, ownMessage, messages }} <MessageRun diff --git a/ui/service-worker.js b/ui/service-worker.js index cb32d0d..eee3397 100644 --- a/ui/service-worker.js +++ b/ui/service-worker.js @@ -53,11 +53,55 @@ self.addEventListener('fetch', (event) => { event.respondWith(cacheFirst(event.request)); }); +const conversationReadStatus = { + // Format: + // conversationId: { lastRead: Optional(Datetime), lastMessage: Datetime } +}; + +function countUnreadChannels() { + return Object.values(conversationReadStatus) + .map(({ lastRead, lastMessage }) => { + return !lastRead || lastRead < lastMessage ? 1 : 0; + }) + .reduce((total, current) => total + current, 0); +} + self.addEventListener('push', (event) => { + // Let's show a notification right away so Safari doesn't tell Apple to be + // mad at us: event.waitUntil( self.registration.showNotification('Test notification', { actions: [], body: event.data.text(), }), ); + // Now we can do slower things that might fail: + conversationReadStatus[event.conversationId] ||= { lastRead: null, lastMessage: null }; + conversationReadStatus[event.conversationId].lastMessage = new Date(); + event.waitUntil( + (async () => { + if (navigator.setAppBadge) { + navigator.setAppBadge(countUnreadChannels()); + } + })(), + ); +}); + +// The client has to tell us when it has read a conversation: +self.addEventListener('message', (event) => { + switch (event.data?.type) { + case 'CONVERSATION_READ': + conversationReadStatus[event.data.conversationId] ||= { lastRead: null, lastMessage: null }; + conversationReadStatus[event.data.conversationId].lastMessage = event.data.at || new Date(); + event.waitUntil( + (async () => { + if (navigator.setAppBadge) { + navigator.setAppBadge(countUnreadChannels()); + } + })(), + ); + break; + default: + break; + } }); diff --git a/ui/styles/app-bar.css b/ui/styles/app-bar.css index ce52712..272810a 100644 --- a/ui/styles/app-bar.css +++ b/ui/styles/app-bar.css @@ -7,6 +7,8 @@ align-items: stretch; background-color: var(--colour-header-bg); color: var(--light-text); + z-index: 2; + position: relative; } .app-bar > * { diff --git a/ui/styles/forms.css b/ui/styles/forms.css index f4d218f..19d31b7 100644 --- a/ui/styles/forms.css +++ b/ui/styles/forms.css @@ -26,3 +26,11 @@ form.form > button { .disabled { color: var(--light-text); } + +button { + cursor: pointer; +} +button:active { + border-color: red; + background-color: blue; +} diff --git a/ui/styles/messages.css b/ui/styles/messages.css index 5488fa5..52355b7 100644 --- a/ui/styles/messages.css +++ b/ui/styles/messages.css @@ -1,3 +1,27 @@ +.channel-meta { + border-width: 0 1px 1px 1px; + border-color: red; + border-style: solid; + border-radius: 0 0 4px 4px; + background-color: mistyrose; + overflow: clip; + transition: margin-top 0.25s ease-in; + z-index: 1; + margin-top: -5rem; +} + +.channel-meta.expanded { + margin-top: 0rem; +} + +.channel-meta .handle { + text-align: center; +} + +.channel-meta .inner { + padding: 1rem; +} + .message-run { border-radius: 0.25rem; margin-top: 1rem; |
