Show new-messages badge in tab title when re-foregrounding (#332)

Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
This commit is contained in:
Aditya Chaudhary 2026-08-10 16:16:30 +00:00 committed by hodlbod
parent b2f58b11c0
commit 65c9eac5e5
3 changed files with 40 additions and 6 deletions

View file

@ -15,6 +15,7 @@
import Login2 from "@assets/icons/login-3.svg?dataurl" import Login2 from "@assets/icons/login-3.svg?dataurl"
import {fade, fly} from "@lib/transition" import {fade, fly} from "@lib/transition"
import {popKey} from "@lib/implicit" import {popKey} from "@lib/implicit"
import {documentActive} from "@lib/html"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@ -301,8 +302,10 @@
} }
} }
const onVisibilityChange = () => { // A tab can be `visible` but unfocused (user alt-tabbed to another app), so we
if (document.hidden) { // can't rely on document.hidden alone to know the room is actually being watched.
const onActiveChange = (active: boolean) => {
if (!active) {
lastVisibleAt = now() lastVisibleAt = now()
} else if ($events.some(e => e.pubkey !== $user.pubkey && e.created_at > lastVisibleAt)) { } else if ($events.some(e => e.pubkey !== $user.pubkey && e.created_at > lastVisibleAt)) {
newMessagesAfter = lastVisibleAt newMessagesAfter = lastVisibleAt
@ -450,12 +453,12 @@
onMount(() => { onMount(() => {
start() start()
document.addEventListener("visibilitychange", onVisibilityChange) const unsubscribeActive = documentActive.subscribe(onActiveChange)
return () => { return () => {
// Wrap in a closure to avoid calling a stale cleanup function // Wrap in a closure to avoid calling a stale cleanup function
cleanup?.() cleanup?.()
document.removeEventListener("visibilitychange", onVisibilityChange) unsubscribeActive()
} }
}) })
</script> </script>

View file

@ -1,7 +1,32 @@
import {readable} from "svelte/store"
import {sleep, randomId} from "@welshman/lib" import {sleep, randomId} from "@welshman/lib"
import {Capacitor} from "@capacitor/core" import {Capacitor} from "@capacitor/core"
export {preventDefault, stopPropagation} from "svelte/legacy" export {preventDefault, stopPropagation} from "svelte/legacy"
/** Whether the user is actually looking at this tab right now. Display-only concern,
* kept separate from any data store — consult it wherever "is someone watching" should
* affect what's rendered. `document.hidden` alone misses window blur: switching to
* another app without switching tabs leaves visibilityState "visible", so we also track
* focus and require both. */
export const documentActive = readable(
typeof document === "undefined" ? true : !document.hidden && document.hasFocus(),
set => {
if (typeof document === "undefined") return
const update = () => set(!document.hidden && document.hasFocus())
document.addEventListener("visibilitychange", update)
window.addEventListener("blur", update)
window.addEventListener("focus", update)
return () => {
document.removeEventListener("visibilitychange", update)
window.removeEventListener("blur", update)
window.removeEventListener("focus", update)
}
},
)
// Anchors an @svelte-plugins/datepicker popup with fixed positioning so it // Anchors an @svelte-plugins/datepicker popup with fixed positioning so it
// escapes scroll-container clipping (e.g. inside modals). Call when the picker // escapes scroll-container clipping (e.g. inside modals). Call when the picker
// opens; returns a cleanup function that removes the listeners. // opens; returns a cleanup function that removes the listeners.

View file

@ -15,7 +15,7 @@
import * as util from "@welshman/util" import * as util from "@welshman/util"
import * as lib from "@welshman/lib" import * as lib from "@welshman/lib"
import {Logger} from "@welshman/app" import {Logger} from "@welshman/app"
import {isMobile} from "@lib/html" import {isMobile, documentActive} from "@lib/html"
import AppContainer from "@app/components/AppContainer.svelte" import AppContainer from "@app/components/AppContainer.svelte"
import ModalContainer from "@app/components/ModalContainer.svelte" import ModalContainer from "@app/components/ModalContainer.svelte"
import {app} from "@app/core" import {app} from "@app/core"
@ -33,6 +33,7 @@
import {theme} from "@app/theme" import {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 {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"
@ -239,7 +240,12 @@
}) })
$effect(() => { $effect(() => {
document.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
// active page, so count notifications for it too rather than treating it as read.
const unreadCount = $documentActive ? $notificationPaths.size : $allNotifications.size
document.title = unreadCount > 0 ? `(${unreadCount}) ${title}` : title
}) })
</script> </script>