feat(tauri): proactive update notifications via toast (P5-40)

TauriUpdateFeature component in ClientNonUIFeatures checks for updates
on mount and every 12h (skips if checked within the window). On update
available, fires a Lotus toast: "Lotus Chat vX.Y.Z is ready to install."
Clicking the toast calls install(). No-op on web (isTauri guard).

Also adds optional onClick to ToastNotif type and wires it in
LotusToastContainer so custom click handlers can skip hash navigation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 15:39:23 -04:00
parent baa12823f7
commit a6bf4eb7e7
4 changed files with 50 additions and 3 deletions
+1 -1
View File
@@ -349,7 +349,7 @@ Themes:
--- ---
### [ ] P5-40 · Desktop — Proactive Update Notifications (Tauri) ### [x] P5-40 · Desktop — Proactive Update Notifications (Tauri) ⚠️ UNTESTED (requires Tauri build)
**What:** Automatically check for app updates on launch and periodically during long sessions. If an update is available, show an in-app toast or badge (e.g., on the Settings icon) to alert the user without requiring a manual check in settings. **What:** Automatically check for app updates on launch and periodically during long sessions. If an update is available, show an in-app toast or badge (e.g., on the Settings icon) to alert the user without requiring a manual check in settings.
**Mechanism:** Use the `useTauriUpdater` hook in a global component like `ClientNonUIFeatures.tsx`. **Mechanism:** Use the `useTauriUpdater` hook in a global component like `ClientNonUIFeatures.tsx`.
@@ -41,8 +41,12 @@ function ToastCard({ toast }: ToastCardProps) {
}, [dismiss, toast.id]); }, [dismiss, toast.id]);
const handleCardClick = () => { const handleCardClick = () => {
// window.location.hash setter auto-prepends '#', so values must not include it if (toast.onClick) {
window.location.hash = toast.hashPath ?? `/room/${toast.roomId}`; toast.onClick();
} else {
// window.location.hash setter auto-prepends '#', so values must not include it
window.location.hash = toast.hashPath ?? `/room/${toast.roomId}`;
}
dismiss(toast.id); dismiss(toast.id);
}; };
@@ -37,6 +37,7 @@ import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate'; import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast'; import { toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders'; import { useReminders } from '../../hooks/useReminders';
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
function isInQuietHours(start: string, end: string): boolean { function isInQuietHours(start: string, end: string): boolean {
const now = new Date(); const now = new Date();
@@ -429,6 +430,46 @@ function ReminderMonitor() {
return null; return null;
} }
const TAURI_UPDATE_CHECK_INTERVAL = 12 * 60 * 60_000; // 12 hours
const TAURI_UPDATE_LAST_CHECK_KEY = 'lotus.tauriUpdateLastCheck';
function TauriUpdateFeature() {
const { isTauri, status, check, install } = useTauriUpdater();
const setToast = useSetAtom(toastQueueAtom);
const firedRef = useRef<string | null>(null);
useEffect(() => {
if (!isTauri) return;
const runCheck = () => {
const last = Number(localStorage.getItem(TAURI_UPDATE_LAST_CHECK_KEY) ?? '0');
if (Date.now() - last < TAURI_UPDATE_CHECK_INTERVAL) return;
localStorage.setItem(TAURI_UPDATE_LAST_CHECK_KEY, String(Date.now()));
check();
};
runCheck();
const interval = setInterval(runCheck, TAURI_UPDATE_CHECK_INTERVAL);
return () => clearInterval(interval);
}, [isTauri, check]);
useEffect(() => {
if (status.state !== 'available') return;
if (firedRef.current === status.version) return;
firedRef.current = status.version;
setToast({
id: `tauri-update-${status.version}`,
displayName: 'Update Available',
body: `Lotus Chat ${status.version} is ready to install.`,
roomName: 'System',
roomId: '',
onClick: install,
});
}, [status, setToast, install]);
return null;
}
function LotusDenoiseFeature() { function LotusDenoiseFeature() {
const setToast = useSetAtom(toastQueueAtom); const setToast = useSetAtom(toastQueueAtom);
@@ -465,6 +506,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<InviteNotifications /> <InviteNotifications />
<MessageNotifications /> <MessageNotifications />
<ReminderMonitor /> <ReminderMonitor />
<TauriUpdateFeature />
<LotusDenoiseFeature /> <LotusDenoiseFeature />
<DeepLinkNavigator /> <DeepLinkNavigator />
{children} {children}
+1
View File
@@ -8,6 +8,7 @@ export type ToastNotif = {
roomName: string; roomName: string;
roomId: string; roomId: string;
hashPath?: string; // overrides window.location.hash navigation when set hashPath?: string; // overrides window.location.hash navigation when set
onClick?: () => void; // custom click handler; skips hash navigation when set
}; };
const baseAtom = atom<ToastNotif[]>([]); const baseAtom = atom<ToastNotif[]>([]);