Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 0.1.20

- Hide the available-update banner and duplicate install action after Cloudflare accepts the update
build.
- Check for signed releases when HQBase opens, returns to the foreground, and periodically while it
remains open.
- Detect the replacement application worker during an active update and show the reload prompt
without requiring a manual browser refresh.

## 0.1.19

- Group replies into one inbox conversation using standard email reply headers, while keeping
Expand Down
35 changes: 9 additions & 26 deletions app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ import { SettingsPage } from "@/features/settings/settings-page";
import { getSetupStatus } from "@/features/setup/api";
import { SetupPage } from "@/features/setup/setup-page";
import type { SetupStatus } from "@/features/setup/types";
import { getUpdateStatus } from "@/features/updates/api";
import type { UpdateStatus } from "@/features/updates/types";
import { useUpdateMonitor } from "@/features/updates/use-update-monitor";
import { listUsers } from "@/features/users/api";
import type { WorkspaceUser } from "@/features/users/types";
import { playNotificationSound } from "@/lib/notification-sounds";
Expand All @@ -38,7 +37,6 @@ export function App(): React.ReactElement {
const [search, setSearch] = React.useState("");
const [composeOpen, setComposeOpen] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(true);
const [updateStatus, setUpdateStatus] = React.useState<UpdateStatus | null>(null);
const knownIncomingMessageIds = React.useRef<Set<string> | null>(null);
const { navigate, route } = useAppRoute(setup?.isComplete);
const activeFolder: FolderId = route.kind === "settings" ? "settings" : route.folder;
Expand All @@ -49,6 +47,8 @@ export function App(): React.ReactElement {
() => mailboxes.filter((mailbox) => mailbox.accessLevel !== null),
[mailboxes]
);
const canManageUpdates = user?.role === "owner" || user?.role === "admin";
const updateMonitor = useUpdateMonitor(canManageUpdates);

const loadWorkspace = React.useCallback(async (currentUser: CurrentUser) => {
const [nextSetup, nextMailboxes] = await Promise.all([getSetupStatus(), listMailboxes()]);
Expand All @@ -57,14 +57,8 @@ export function App(): React.ReactElement {

if (currentUser.role === "owner" || currentUser.role === "admin") {
setUsers(await listUsers());
void getUpdateStatus()
.then(setUpdateStatus)
.catch(() => {
// Update discovery never delays workspace startup.
});
} else {
setUsers([]);
setUpdateStatus(null);
}
}, []);

Expand Down Expand Up @@ -108,21 +102,6 @@ export function App(): React.ReactElement {
});
}, [reloadConversations]);

React.useEffect(() => {
if (!user || (user.role !== "owner" && user.role !== "admin")) return;
const interval = window.setInterval(
() => {
void getUpdateStatus()
.then(setUpdateStatus)
.catch(() => {
// Update discovery must never interrupt mail work.
});
},
6 * 60 * 60 * 1000
);
return () => window.clearInterval(interval);
}, [user]);

React.useEffect(() => {
knownIncomingMessageIds.current = null;
if (!currentUserId) return;
Expand Down Expand Up @@ -207,7 +186,8 @@ export function App(): React.ReactElement {
mailboxes={contentMailboxes}
search={search}
user={user}
updateStatus={updateStatus}
updateInProgress={updateMonitor.progress !== null}
updateStatus={updateMonitor.status}
onOpenUpdates={() => {
navigate({ kind: "settings", tab: "updates" });
}}
Expand Down Expand Up @@ -239,7 +219,10 @@ export function App(): React.ReactElement {
users={users}
onRefresh={() => void reload()}
onTabChange={(tab) => navigate({ kind: "settings", tab })}
updateStatus={updateStatus}
onUpdateStarted={updateMonitor.start}
onUpdateStatusChange={updateMonitor.acceptStatus}
updateProgress={updateMonitor.progress}
updateStatus={updateMonitor.status}
/>
) : (
<InboxPage
Expand Down
7 changes: 6 additions & 1 deletion app/components/layout/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type AppShellProps = {
immersiveOnCompact?: boolean;
search: string;
user: CurrentUser;
updateInProgress: boolean;
updateStatus: UpdateStatus | null;
onCompose: () => void;
onFolderChange: (folder: FolderId) => void;
Expand Down Expand Up @@ -48,7 +49,11 @@ export function AppShell(props: AppShellProps): React.ReactElement {
onSearchChange={props.onSearchChange}
onSignedOut={props.onSignedOut}
/>
<UpdateBanner status={props.updateStatus} onOpen={props.onOpenUpdates} />
<UpdateBanner
inProgress={props.updateInProgress}
status={props.updateStatus}
onOpen={props.onOpenUpdates}
/>
</div>
<main className="min-h-0 flex-1 overflow-hidden bg-card/30">{props.children}</main>
</div>
Expand Down
6 changes: 5 additions & 1 deletion app/features/pwa/pwa-lifecycle.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from "react";

import { readUpdateProgress } from "@/features/updates/update-progress";
import { type PwaUpdate, registerPwa } from "./register";

export function PwaLifecycle(): React.ReactElement | null {
Expand All @@ -13,7 +14,10 @@ export function PwaLifecycle(): React.ReactElement | null {
window.addEventListener("offline", handleOffline);

const unregisterLifecycle = import.meta.env.PROD
? registerPwa({ onUpdateReady: setUpdate })
? registerPwa({
onUpdateReady: setUpdate,
watchForUpdate: readUpdateProgress() !== null
})
: () => undefined;

return () => {
Expand Down
39 changes: 37 additions & 2 deletions app/features/pwa/register.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
import { UPDATE_STARTED_EVENT } from "@/features/updates/update-progress";

export type PwaUpdate = {
activate: () => void;
};

type RegisterPwaOptions = {
onUpdateReady: (update: PwaUpdate) => void;
watchForUpdate?: boolean;
};

const UPDATE_INTERVAL_MS = 60 * 60 * 1000;
const UPDATE_INTERVAL_MS = 15 * 60 * 1000;
const ACTIVE_UPDATE_INTERVAL_MS = 10_000;
const ACTIVE_UPDATE_LIFETIME_MS = 30 * 60 * 1000;

export function registerPwa({ onUpdateReady }: RegisterPwaOptions): () => void {
export function registerPwa({
onUpdateReady,
watchForUpdate = false
}: RegisterPwaOptions): () => void {
if (!("serviceWorker" in navigator)) return () => undefined;

let registration: ServiceWorkerRegistration | undefined;
let refreshAfterActivation = false;
let disposed = false;
let activeUpdateInterval: number | undefined;
let activeUpdateDeadline = 0;

const activate = (): void => {
if (!registration?.waiting) return;
Expand All @@ -23,6 +33,7 @@ export function registerPwa({ onUpdateReady }: RegisterPwaOptions): () => void {

const announceWaitingWorker = (): void => {
if (registration?.waiting && navigator.serviceWorker.controller) {
stopActiveUpdateWatch();
onUpdateReady({ activate });
}
};
Expand All @@ -34,6 +45,25 @@ export function registerPwa({ onUpdateReady }: RegisterPwaOptions): () => void {
});
};

const startActiveUpdateWatch = (): void => {
activeUpdateDeadline = Date.now() + ACTIVE_UPDATE_LIFETIME_MS;
checkForUpdate();
if (activeUpdateInterval !== undefined) return;
activeUpdateInterval = window.setInterval(() => {
if (Date.now() >= activeUpdateDeadline) {
stopActiveUpdateWatch();
return;
}
checkForUpdate();
}, ACTIVE_UPDATE_INTERVAL_MS);
};

function stopActiveUpdateWatch(): void {
if (activeUpdateInterval === undefined) return;
window.clearInterval(activeUpdateInterval);
activeUpdateInterval = undefined;
}

const handleControllerChange = (): void => {
if (!refreshAfterActivation) return;
refreshAfterActivation = false;
Expand All @@ -42,18 +72,21 @@ export function registerPwa({ onUpdateReady }: RegisterPwaOptions): () => void {

const handleFocus = (): void => checkForUpdate();
const handleOnline = (): void => checkForUpdate();
const handleUpdateStarted = (): void => startActiveUpdateWatch();
const interval = window.setInterval(checkForUpdate, UPDATE_INTERVAL_MS);

navigator.serviceWorker.addEventListener("controllerchange", handleControllerChange);
window.addEventListener("focus", handleFocus);
window.addEventListener("online", handleOnline);
window.addEventListener(UPDATE_STARTED_EVENT, handleUpdateStarted);

void navigator.serviceWorker
.register("/service-worker.js", { scope: "/", updateViaCache: "none" })
.then((nextRegistration) => {
if (disposed) return;
registration = nextRegistration;
announceWaitingWorker();
if (watchForUpdate) startActiveUpdateWatch();
registration.addEventListener("updatefound", () => {
const installing = registration?.installing;
if (!installing) return;
Expand All @@ -69,8 +102,10 @@ export function registerPwa({ onUpdateReady }: RegisterPwaOptions): () => void {
return () => {
disposed = true;
window.clearInterval(interval);
stopActiveUpdateWatch();
navigator.serviceWorker.removeEventListener("controllerchange", handleControllerChange);
window.removeEventListener("focus", handleFocus);
window.removeEventListener("online", handleOnline);
window.removeEventListener(UPDATE_STARTED_EVENT, handleUpdateStarted);
};
}
14 changes: 13 additions & 1 deletion app/features/settings/settings-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { DebugSettings } from "@/features/settings/debug-settings";
import { SettingsSection } from "@/features/settings/settings-section";
import type { SetupStatus } from "@/features/setup/types";
import type { UpdateStatus } from "@/features/updates/types";
import type { UpdateProgress } from "@/features/updates/update-progress";
import { UpdateSettings } from "@/features/updates/update-settings";
import type { WorkspaceUser } from "@/features/users/types";
import { UserSettings } from "@/features/users/user-settings";
Expand All @@ -21,6 +22,9 @@ type SettingsPageProps = {
users: WorkspaceUser[];
onRefresh: () => void;
onTabChange: (tab: SettingsTabId) => void;
onUpdateStarted: (buildId: string) => void;
onUpdateStatusChange: (status: UpdateStatus) => void;
updateProgress: UpdateProgress | null;
updateStatus: UpdateStatus | null;
};

Expand All @@ -32,6 +36,9 @@ export function SettingsPage({
users,
onRefresh,
onTabChange,
onUpdateStarted,
onUpdateStatusChange,
updateProgress,
updateStatus
}: SettingsPageProps): React.ReactElement {
return (
Expand Down Expand Up @@ -72,7 +79,12 @@ export function SettingsPage({
) : null}
{canManage ? (
<TabsContent className="mt-5" value="updates">
<UpdateSettings initialStatus={updateStatus} />
<UpdateSettings
initialStatus={updateStatus}
progress={updateProgress}
onStatusChange={onUpdateStatusChange}
onUpdateStarted={onUpdateStarted}
/>
</TabsContent>
) : null}
<TabsContent className="mt-5" value="debug">
Expand Down
4 changes: 3 additions & 1 deletion app/features/updates/update-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { Button } from "@/components/ui/button";
import type { UpdateStatus } from "./types";

export function UpdateBanner({
inProgress,
status,
onOpen
}: {
inProgress: boolean;
status: UpdateStatus | null;
onOpen: () => void;
}): React.ReactElement | null {
if (!status?.available) return null;
if (inProgress || !status?.available) return null;
return (
<div
aria-live="polite"
Expand Down
52 changes: 52 additions & 0 deletions app/features/updates/update-progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
export const UPDATE_STARTED_EVENT = "hqbase:update-started";

const storageKey = "hqbase:update-progress";
const progressLifetimeMs = 30 * 60 * 1000;

export type UpdateProgress = {
buildId: string;
startedAt: number;
};

export function beginUpdateProgress(buildId: string, now = Date.now()): UpdateProgress {
const progress = { buildId, startedAt: now };
storage()?.setItem(storageKey, JSON.stringify(progress));
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent<UpdateProgress>(UPDATE_STARTED_EVENT, { detail: progress })
);
}
return progress;
}

export function readUpdateProgress(now = Date.now()): UpdateProgress | null {
const sessionStorage = storage();
const serialized = sessionStorage?.getItem(storageKey);
if (!serialized) return null;

try {
const progress = JSON.parse(serialized) as Partial<UpdateProgress>;
if (
typeof progress.buildId !== "string" ||
!progress.buildId ||
typeof progress.startedAt !== "number" ||
now - progress.startedAt >= progressLifetimeMs
) {
sessionStorage?.removeItem(storageKey);
return null;
}
return { buildId: progress.buildId, startedAt: progress.startedAt };
} catch {
sessionStorage?.removeItem(storageKey);
return null;
}
}

export function clearUpdateProgress(): void {
storage()?.removeItem(storageKey);
}

function storage(): Storage | null {
if (typeof window === "undefined") return null;
return window.sessionStorage;
}
Loading