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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.1.24

- Consolidate conversation, unread-count, and incoming-mail refreshes into one coordinated path.
- Replace broad incoming-message polling with an access-scoped latest-message status query.
- Split signed deployment responsibilities and strengthen architecture and lifecycle coverage gates.

## 0.1.23

- Add explicit per-device Web Push notifications for installed iOS, Android, and desktop PWAs.
Expand Down
90 changes: 12 additions & 78 deletions app/app.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as React from "react";
import { toast } from "sonner";

import { AppShell } from "@/components/layout/app-shell";
import { Toaster } from "@/components/ui/sonner";
Expand All @@ -9,18 +8,14 @@ import type { CurrentUser } from "@/features/auth/types";
import { InboxPage } from "@/features/inbox/inbox-page";
import { listMailboxes } from "@/features/mailboxes/api";
import type { Mailbox } from "@/features/mailboxes/types";
import { listConversations, listMessages } from "@/features/messages/api";
import { mergeIncomingMessageIds } from "@/features/messages/incoming-sound";
import type { ConversationSummary } from "@/features/messages/types";
import { useNotifications } from "@/features/notifications/use-notifications";
import { useMailSync } from "@/features/messages/use-mail-sync";
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 { 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";
import type { FolderId, MailFolderId, SettingsTabId } from "@/lib/routes";
import { useAppRoute } from "@/lib/use-app-route";

Expand All @@ -33,12 +28,10 @@ export function App(): React.ReactElement {
const [user, setUser] = React.useState<CurrentUser | null>(null);
const [mailboxes, setMailboxes] = React.useState<Mailbox[]>([]);
const [users, setUsers] = React.useState<WorkspaceUser[]>([]);
const [conversations, setConversations] = React.useState<ConversationSummary[]>([]);
const [mailboxId, setMailboxId] = React.useState("all");
const [search, setSearch] = React.useState("");
const [composeOpen, setComposeOpen] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(true);
const knownIncomingMessageIds = React.useRef<Set<string> | null>(null);
const { navigate, route } = useAppRoute(setup?.isComplete);
const activeFolder: FolderId = route.kind === "settings" ? "settings" : route.folder;
const selectedId = route.kind === "mail" ? route.messageId : null;
Expand All @@ -50,6 +43,12 @@ export function App(): React.ReactElement {
);
const canManageUpdates = user?.role === "owner" || user?.role === "admin";
const updateMonitor = useUpdateMonitor(canManageUpdates);
const mailSync = useMailSync({
activeFolder,
mailboxId,
search,
userId: currentUserId
});

const loadWorkspace = React.useCallback(async (currentUser: CurrentUser) => {
const [nextSetup, nextMailboxes] = await Promise.all([getSetupStatus(), listMailboxes()]);
Expand Down Expand Up @@ -83,73 +82,10 @@ export function App(): React.ReactElement {
}
}, [loadWorkspace]);

const reloadConversations = React.useCallback(async () => {
if (!user || activeFolder === "settings") return;
const nextConversations = await listConversations({
folder: activeFolder,
mailboxId: mailboxId === "all" ? undefined : mailboxId,
search: search || undefined
});
setConversations(nextConversations);
}, [activeFolder, mailboxId, search, user]);
const notifications = useNotifications(currentUserId, () => {
void reloadConversations();
});

React.useEffect(() => {
void reload();
}, [reload]);

React.useEffect(() => {
void reloadConversations().catch((error) => {
toast.error(error instanceof Error ? error.message : "Conversations failed to load.");
});
}, [reloadConversations]);

React.useEffect(() => {
knownIncomingMessageIds.current = null;
if (!currentUserId) return;
let active = true;
let checking = false;
const checkIncomingMail = async () => {
if (checking) return;
checking = true;
try {
const latestMessages = await listMessages({});
if (!active) return;
const snapshot = mergeIncomingMessageIds(knownIncomingMessageIds.current, latestMessages);
knownIncomingMessageIds.current = snapshot.knownIds;
if (snapshot.hasNewMessages) playNotificationSound("incoming-email");
} catch {
// Incoming-mail discovery never interrupts the current workspace.
} finally {
checking = false;
}
};

void checkIncomingMail();
const interval = window.setInterval(() => {
if (active) void checkIncomingMail();
}, 10_000);

return () => {
active = false;
window.clearInterval(interval);
};
}, [currentUserId]);

React.useEffect(() => {
if (!user || activeFolder === "settings") return;

const interval = window.setInterval(() => {
void reloadConversations().catch(() => {
// Keep background refresh failures quiet; the next interval will retry.
});
}, 10_000);

return () => window.clearInterval(interval);
}, [activeFolder, reloadConversations, user]);

React.useEffect(() => {
if (!user || isLoading || route.kind !== "settings") return;
const canManage = user.role === "owner" || user.role === "admin";
Expand Down Expand Up @@ -188,7 +124,7 @@ export function App(): React.ReactElement {
immersiveOnCompact={activeFolder !== "settings" && selectedId !== null}
mailboxId={mailboxId}
mailboxes={contentMailboxes}
unread={notifications.unread}
unread={mailSync.notifications.unread}
search={search}
user={user}
updateInProgress={updateMonitor.progress !== null}
Expand All @@ -210,7 +146,6 @@ export function App(): React.ReactElement {
onSearchChange={setSearch}
onSignedOut={() => {
setUser(null);
setConversations([]);
}}
>
<div className="flex h-full flex-col">
Expand All @@ -220,7 +155,7 @@ export function App(): React.ReactElement {
activeTab={settingsTab}
canManage={user.role === "owner" || user.role === "admin"}
mailboxes={mailboxes}
notifications={notifications}
notifications={mailSync.notifications}
setup={setup}
users={users}
onRefresh={() => void reload()}
Expand All @@ -233,11 +168,10 @@ export function App(): React.ReactElement {
) : (
<InboxPage
activeFolder={activeFolder as MailFolderId}
conversations={conversations}
conversations={mailSync.conversations}
mailboxes={contentMailboxes}
selectedId={selectedId}
onRefresh={() => void reloadConversations()}
onUnreadChange={notifications.refresh}
onRefresh={() => void mailSync.refresh().catch(() => undefined)}
onMessageRouteChange={(folder, messageId) =>
navigate({ kind: "mail", folder, messageId })
}
Expand All @@ -256,7 +190,7 @@ export function App(): React.ReactElement {
mode="new"
open={composeOpen}
onOpenChange={setComposeOpen}
onSent={() => void reloadConversations()}
onSent={() => void mailSync.refresh().catch(() => undefined)}
/>
</React.Suspense>
) : null}
Expand Down
6 changes: 1 addition & 5 deletions app/features/inbox/inbox-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ type InboxPageProps = {
mailboxes: Mailbox[];
selectedId: string | null;
onRefresh: () => void;
onUnreadChange: () => void;
onMessageRouteChange: (folder: MailFolderId, messageId: string | null) => void;
onSelect: (messageId: string) => void;
};
Expand All @@ -29,7 +28,6 @@ export function InboxPage({
mailboxes,
selectedId,
onRefresh,
onUnreadChange,
onMessageRouteChange,
onSelect
}: InboxPageProps): React.ReactElement {
Expand Down Expand Up @@ -78,7 +76,6 @@ export function InboxPage({
);
}
onRefreshRef.current();
onUnreadChange();
})
.catch(() => undefined);
}
Expand All @@ -94,7 +91,7 @@ export function InboxPage({
return () => {
cancelled = true;
};
}, [activeFolder, onUnreadChange, selectedId]);
}, [activeFolder, selectedId]);

const selectedThreadId =
thread[0]?.threadId ??
Expand All @@ -120,7 +117,6 @@ export function InboxPage({
if (!selectedId) return;
await runConversationAction(selectedId, action, activeFolder);
onRefresh();
onUnreadChange();
if (
action === "archive" ||
action === "trash" ||
Expand Down
22 changes: 0 additions & 22 deletions app/features/messages/incoming-sound.ts

This file was deleted.

127 changes: 127 additions & 0 deletions app/features/messages/use-mail-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import * as React from "react";
import { toast } from "sonner";

import { useNotifications } from "@/features/notifications/use-notifications";
import { playNotificationSound } from "@/lib/notification-sounds";
import type { FolderId } from "@/lib/routes";

import { listConversations } from "./api";
import type { ConversationSummary } from "./types";

const refreshIntervalMs = 10_000;

type MailSyncOptions = {
activeFolder: FolderId;
mailboxId: string;
search: string;
userId: string | null;
};

export function useMailSync({ activeFolder, mailboxId, search, userId }: MailSyncOptions): {
conversations: ConversationSummary[];
notifications: ReturnType<typeof useNotifications>;
refresh: () => Promise<void>;
} {
const [conversations, setConversations] = React.useState<ConversationSummary[]>([]);
const notifications = useNotifications(userId);
const refreshNotifications = notifications.refresh;
const latestInboundId = React.useRef<string | null>(null);
const hasInboundSnapshot = React.useRef(false);
const currentUserId = React.useRef(userId);
const syncKey = [userId, activeFolder, mailboxId, search].join("\u0000");
const currentSyncKey = React.useRef(syncKey);
const inFlight = React.useRef<{ key: string; promise: Promise<void> } | null>(null);
currentUserId.current = userId;
currentSyncKey.current = syncKey;

const refresh = React.useCallback((): Promise<void> => {
if (inFlight.current?.key === syncKey) return inFlight.current.promise;

const promise = (async () => {
if (!userId) {
setConversations([]);
await refreshNotifications();
return;
}

const [notificationResult, conversationResult] = await Promise.allSettled([
refreshNotifications(),
activeFolder === "settings"
? Promise.resolve<ConversationSummary[] | null>(null)
: listConversations({
folder: activeFolder,
mailboxId: mailboxId === "all" ? undefined : mailboxId,
search: search || undefined
})
]);
if (currentSyncKey.current !== syncKey || currentUserId.current !== userId) return;

if (conversationResult.status === "fulfilled" && conversationResult.value !== null) {
setConversations(conversationResult.value);
}
if (notificationResult.status === "fulfilled") {
const nextInboundId = notificationResult.value.latestInboundMessageId;
if (
hasInboundSnapshot.current &&
nextInboundId !== null &&
nextInboundId !== latestInboundId.current
) {
playNotificationSound("incoming-email");
}
latestInboundId.current = nextInboundId;
hasInboundSnapshot.current = true;
}

if (conversationResult.status === "rejected") throw conversationResult.reason;
})();
inFlight.current = { key: syncKey, promise };
const clearInFlight = (): void => {
if (inFlight.current?.promise === promise) inFlight.current = null;
};
void promise.then(clearInFlight, clearInFlight);
return promise;
}, [activeFolder, mailboxId, refreshNotifications, search, syncKey, userId]);

React.useEffect(() => {
latestInboundId.current = null;
hasInboundSnapshot.current = false;
if (!userId) setConversations([]);
}, [userId]);

React.useEffect(() => {
if (!userId) {
void refresh();
return;
}

let active = true;
const runRefresh = (reportError = false): void => {
void refresh().catch((error: unknown) => {
if (active && reportError) {
toast.error(error instanceof Error ? error.message : "Mail could not be refreshed.");
}
});
};
const refreshWhenVisible = (): void => {
if (document.visibilityState === "visible") runRefresh();
};
const handleServiceWorkerMessage = (event: MessageEvent): void => {
if (event.data?.type === "hqbase:push-received") runRefresh();
};

runRefresh(true);
const interval = window.setInterval(runRefresh, refreshIntervalMs);
window.addEventListener("focus", refreshWhenVisible);
document.addEventListener("visibilitychange", refreshWhenVisible);
navigator.serviceWorker?.addEventListener("message", handleServiceWorkerMessage);
return () => {
active = false;
window.clearInterval(interval);
window.removeEventListener("focus", refreshWhenVisible);
document.removeEventListener("visibilitychange", refreshWhenVisible);
navigator.serviceWorker?.removeEventListener("message", handleServiceWorkerMessage);
};
}, [refresh, userId]);

return { conversations, notifications, refresh };
}
3 changes: 2 additions & 1 deletion app/features/notifications/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type UnreadCounts = {
};

export type NotificationStatus = {
latestInboundMessageId: string | null;
unread: UnreadCounts;
vapidPublicKey: string | null;
};
Expand All @@ -23,6 +24,6 @@ export type NotificationController = {
enable: () => Promise<void>;
error: string | null;
isBusy: boolean;
refresh: () => Promise<void>;
refresh: () => Promise<NotificationStatus>;
unread: UnreadCounts;
};
Loading