diff --git a/src/app/components/RoomChat.svelte b/src/app/components/RoomChat.svelte index 67ece432..c0608f12 100644 --- a/src/app/components/RoomChat.svelte +++ b/src/app/components/RoomChat.svelte @@ -15,6 +15,7 @@ import Login2 from "@assets/icons/login-3.svg?dataurl" import {fade, fly} from "@lib/transition" import {popKey} from "@lib/implicit" + import {documentActive} from "@lib/html" import Button from "@lib/components/Button.svelte" import Divider from "@lib/components/Divider.svelte" import Icon from "@lib/components/Icon.svelte" @@ -301,8 +302,10 @@ } } - const onVisibilityChange = () => { - if (document.hidden) { + // A tab can be `visible` but unfocused (user alt-tabbed to another app), so we + // can't rely on document.hidden alone to know the room is actually being watched. + const onActiveChange = (active: boolean) => { + if (!active) { lastVisibleAt = now() } else if ($events.some(e => e.pubkey !== $user.pubkey && e.created_at > lastVisibleAt)) { newMessagesAfter = lastVisibleAt @@ -450,12 +453,12 @@ onMount(() => { start() - document.addEventListener("visibilitychange", onVisibilityChange) + const unsubscribeActive = documentActive.subscribe(onActiveChange) return () => { // Wrap in a closure to avoid calling a stale cleanup function cleanup?.() - document.removeEventListener("visibilitychange", onVisibilityChange) + unsubscribeActive() } }) diff --git a/src/lib/html.ts b/src/lib/html.ts index 3a571ca1..d59e4b58 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -1,7 +1,32 @@ +import {readable} from "svelte/store" import {sleep, randomId} from "@welshman/lib" import {Capacitor} from "@capacitor/core" 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 // escapes scroll-container clipping (e.g. inside modals). Call when the picker // opens; returns a cleanup function that removes the listeners. diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index cc9f4249..08ad01ac 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -15,7 +15,7 @@ import * as util from "@welshman/util" import * as lib from "@welshman/lib" import {Logger} from "@welshman/app" - import {isMobile} from "@lib/html" + import {isMobile, documentActive} from "@lib/html" import AppContainer from "@app/components/AppContainer.svelte" import ModalContainer from "@app/components/ModalContainer.svelte" import {app} from "@app/core" @@ -33,6 +33,7 @@ import {theme} from "@app/theme" import {toast, pushToast} from "@app/toast" import * as notifications from "@app/notifications" + import {notifications as notificationPaths, allNotifications} from "@app/notifications" import {Push} from "@app/push" import {onPushNotificationAction, pushState} from "@app/push/adapters/common" import {syncKeyboard} from "@app/keyboard" @@ -239,7 +240,12 @@ }) $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 })