New mail in the other signed-in accounts: counts, a dot, and a notification #51
No files matched your search
@@ -139,3 +139,18 @@ test("an organization that doesn't allow it keeps adding off", async () => {
|
||||
delete process.env.MOCK_NO_ADD_ACCOUNTS;
|
||||
}
|
||||
});
|
||||
|
||||
test("inbuxa MA-8: the accounts not in front report their Inbox unread count", async () => {
|
||||
const b = new Browser();
|
||||
await b.signIn("[email protected]", "first-password");
|
||||
// Alone, there is nothing to report
|
||||
assert.deepEqual((await b.call("/api/auth/accounts/unread")).body.accounts, []);
|
||||
await b.signIn("[email protected]", "second-password", true);
|
||||
const res = await b.call("/api/auth/accounts/unread");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.accounts.length, 1, "only the account not in front");
|
||||
const [other] = res.body.accounts;
|
||||
const listed = (await b.accounts()).find((a) => !a.front)!;
|
||||
assert.equal(other.id, listed.id);
|
||||
assert.equal(typeof other.unread, "number", JSON.stringify(res.body));
|
||||
});
|
||||
@@ -363,6 +363,30 @@ function liveOthers(c: Context): { cookie: string; session: LiveSession }[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** inbuxa MA-8: Inbox unread counts of accounts not in front, briefly kept. */
|
||||
const UNREAD_CACHE_MS = 60_000;
|
||||
const unreadCache = new Map<string, { unread: number | null; at: number }>();
|
||||
|
||||
/** The Inbox's unread count for one session's account, or null when it has none. */
|
||||
async function inboxUnread(session: LiveSession): Promise<number | null> {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
const accountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||
if (!accountId) return null;
|
||||
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
||||
method: "POST",
|
||||
headers: { authorization: session.authorization, "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||
methodCalls: [["Mailbox/get", { accountId, ids: null, properties: ["role", "unreadEmails"] }, "0"]],
|
||||
}),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { methodResponses?: [string, { list?: { role?: string | null; unreadEmails?: number }[] }, string][] };
|
||||
const inbox = body.methodResponses?.[0]?.[1]?.list?.find((m) => m.role === "inbox");
|
||||
return typeof inbox?.unreadEmails === "number" ? inbox.unreadEmails : null;
|
||||
}
|
||||
|
||||
/** Whether the account in front may have more beside it, or why not. */
|
||||
async function addRefusal(c: Context, front: LiveSession): Promise<string | null> {
|
||||
if (1 + liveOthers(c).length >= MAX_ACCOUNTS) return "add_full";
|
||||
@@ -756,6 +780,32 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* inbuxa MA-8: the Inbox unread count of each account not in front, asked
|
||||
* through that account's own session, so the menu can say where new mail
|
||||
* is. A minute's cache per account: the web app asks every few minutes, and
|
||||
* several tabs may ask at once.
|
||||
*/
|
||||
api.get("/auth/accounts/unread", requireSession, async (c) => {
|
||||
const answers = await Promise.all(
|
||||
liveOthers(c).map(async ({ cookie, session }) => {
|
||||
const cached = unreadCache.get(session.id);
|
||||
if (cached && Date.now() - cached.at < UNREAD_CACHE_MS) return { id: session.id, unread: cached.unread };
|
||||
try {
|
||||
let live: LiveSession | null = session;
|
||||
if (live.tokens && needsRefresh(live.tokens)) live = await refreshSession(cookie, live);
|
||||
if (!live) return { id: session.id, unread: null };
|
||||
const unread = await inboxUnread(live);
|
||||
unreadCache.set(session.id, { unread, at: Date.now() });
|
||||
return { id: session.id, unread };
|
||||
} catch {
|
||||
return { id: session.id, unread: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
return c.json({ accounts: answers });
|
||||
});
|
||||
|
||||
/* inbuxa MA-B: bring another signed-in account to the front. */
|
||||
api.post("/auth/accounts/:id/front", requireSession, async (c) => {
|
||||
const frontCookie = getCookie(c, config.cookieName)!;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/** inbuxa MA-8: new mail in the accounts not in front. */
|
||||
|
||||
const shown: { title: string; opts: Record<string, unknown> }[] = [];
|
||||
vi.mock("@/lib/notify/notify", () => ({
|
||||
showNotification: (title: string, opts: Record<string, unknown>) => shown.push({ title, opts }),
|
||||
}));
|
||||
|
||||
const { risen, pollOtherUnread, useOtherUnread } = await import("@/lib/otherAccounts");
|
||||
const { useSession } = await import("@/store/session");
|
||||
const { useSettings } = await import("@/store/settings");
|
||||
|
||||
let counts: Record<string, number>;
|
||||
|
||||
beforeEach(() => {
|
||||
shown.length = 0;
|
||||
counts = { b: 3 };
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
const body = { accounts: Object.entries(counts).map(([id, unread]) => ({ id, unread })) };
|
||||
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as Response;
|
||||
}),
|
||||
);
|
||||
useOtherUnread.getState().set({});
|
||||
useSession.setState({
|
||||
signedIn: [
|
||||
{ id: "a", username: "[email protected]", front: true },
|
||||
{ id: "b", username: "[email protected]", front: false },
|
||||
],
|
||||
});
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, desktopNotifications: true } }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("other accounts' unread mail", () => {
|
||||
it("counts only a rise, and never an account seen for the first time", () => {
|
||||
expect(risen({}, { b: 4 })).toEqual([]);
|
||||
expect(risen({ b: 4 }, { b: 4 })).toEqual([]);
|
||||
expect(risen({ b: 4 }, { b: 2 })).toEqual([]);
|
||||
expect(risen({ b: 4, c: 1 }, { b: 5, c: 1 })).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("keeps the counts, and names the account when new mail arrives", async () => {
|
||||
await pollOtherUnread();
|
||||
expect(useOtherUnread.getState().unread).toEqual({ b: 3 });
|
||||
expect(shown).toEqual([]);
|
||||
|
||||
counts = { b: 5 };
|
||||
await pollOtherUnread();
|
||||
expect(shown).toHaveLength(1);
|
||||
expect(shown[0]!.title).toContain("[email protected]");
|
||||
expect(shown[0]!.opts.tag).toBe("other-account-b");
|
||||
});
|
||||
|
||||
it("stays quiet when desktop notifications are off", async () => {
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, desktopNotifications: false } }));
|
||||
await pollOtherUnread();
|
||||
counts = { b: 9 };
|
||||
await pollOtherUnread();
|
||||
expect(shown).toEqual([]);
|
||||
expect(useOtherUnread.getState().unread).toEqual({ b: 9 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* inbuxa MA-8: new mail in the accounts not in front.
|
||||
*
|
||||
* The webmail server answers each one's Inbox unread count through that
|
||||
* account's own session (/api/auth/accounts/unread). The menu shows the counts;
|
||||
* when one rises while the app is open, a desktop notification names the
|
||||
* account, so mail for support@ isn't missed while someone works in their own.
|
||||
*
|
||||
* Only while a tab is open. A notification with the app closed needs each
|
||||
* added account's own Web Push subscription, which is still to come.
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { create } from "zustand";
|
||||
import { apiFetch } from "@/jmap/client";
|
||||
import { showNotification } from "@/lib/notify/notify";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useSettings } from "@/store/settings";
|
||||
|
||||
/** How often to ask. The server keeps each answer a minute. */
|
||||
export const POLL_MS = 2 * 60_000;
|
||||
|
||||
interface OtherUnreadState {
|
||||
/** Unread count per session id; absent until first asked, or when unknown. */
|
||||
unread: Record<string, number>;
|
||||
set(unread: Record<string, number>): void;
|
||||
}
|
||||
|
||||
export const useOtherUnread = create<OtherUnreadState>((set) => ({
|
||||
unread: {},
|
||||
set: (unread) => set({ unread }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Which accounts gained unread mail since the last answer. An account seen for
|
||||
* the first time is not "new": its mail was already there when it was added.
|
||||
*/
|
||||
export function risen(before: Record<string, number>, after: Record<string, number>): string[] {
|
||||
return Object.entries(after)
|
||||
.filter(([id, n]) => id in before && n > before[id]!)
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
export async function pollOtherUnread(): Promise<void> {
|
||||
const answer = await apiFetch<{ accounts: { id: string; unread: number | null }[] }>("/api/auth/accounts/unread");
|
||||
const next: Record<string, number> = {};
|
||||
for (const a of answer.accounts) if (typeof a.unread === "number") next[a.id] = a.unread;
|
||||
const before = useOtherUnread.getState().unread;
|
||||
useOtherUnread.getState().set(next);
|
||||
if (!useSettings.getState().settings.desktopNotifications) return;
|
||||
const names = new Map(useSession.getState().signedIn.map((a) => [a.id, a.username]));
|
||||
for (const id of risen(before, next)) {
|
||||
const name = names.get(id);
|
||||
if (!name) continue;
|
||||
showNotification(t("New mail for {name}", { name }), {
|
||||
body: t("Unread in the Inbox: {count}", { count: next[id]! }),
|
||||
tag: `other-account-${id}`,
|
||||
onClick: () => void useSession.getState().switchTo(id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps the counts fresh while more than one account is signed in. */
|
||||
export function useOtherAccountsUnread(): void {
|
||||
const others = useSession((s) => s.signedIn.filter((a) => !a.front).length);
|
||||
useEffect(() => {
|
||||
if (others === 0) {
|
||||
useOtherUnread.getState().set({});
|
||||
return;
|
||||
}
|
||||
const tick = () => void pollOtherUnread().catch(() => undefined);
|
||||
tick();
|
||||
const timer = setInterval(tick, POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [others]);
|
||||
}
|
||||
@@ -1259,6 +1259,8 @@ a.menu-item:hover { color: var(--fg); }
|
||||
.delegated-bar strong { font-weight: 700; }
|
||||
/* MA-A: a shared or group mailbox is the reader's to be in, so the palette's own color, not the locked account's red. */
|
||||
.delegated-bar.shared { background: var(--accent); color: var(--accent-fg); }
|
||||
/* MA-8: new mail in another signed-in account, on the avatar */
|
||||
.acct-dot { position: absolute; top: 0; right: 0; width: 9px; height: 9px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--bg); }
|
||||
.delegated-bar button { flex: none; border: 1px solid rgba(255, 255, 255, .7); background: transparent; color: #fff; border-radius: 999px; padding: 3px 12px; font: inherit; font-weight: 600; cursor: pointer; }
|
||||
.delegated-bar button:hover, .delegated-bar button:focus-visible { background: rgba(255, 255, 255, .15); }
|
||||
.topbar .brand.locked .brand-name, .topbar .brand.locked .brand-lock { color: #dc2626; }
|
||||
|
||||
@@ -12,6 +12,7 @@ import { delegatedAccounts } from "@/lib/delegation";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { DelegatedBar } from "./DelegatedBar";
|
||||
import { AddAccountDialog } from "./AddAccountDialog";
|
||||
import { useOtherAccountsUnread, useOtherUnread } from "@/lib/otherAccounts";
|
||||
import { Avatar, useIsMobile } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { Splitter } from "@/ui/Splitter";
|
||||
@@ -78,6 +79,10 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
const signedIn = useSession((s) => s.signedIn);
|
||||
const canAddAccount = useSession((s) => s.canAddAccount);
|
||||
const [addingAccount, setAddingAccount] = useState(false);
|
||||
// inbuxa MA-8: new mail in the accounts not in front
|
||||
useOtherAccountsUnread();
|
||||
const otherUnread = useOtherUnread((s) => s.unread);
|
||||
const anyOtherUnread = Object.values(otherUnread).some((n) => n > 0);
|
||||
const bringForward = (sessionId: string) => {
|
||||
acctMenu.close();
|
||||
// A message being written belongs to the account it was started in
|
||||
@@ -236,8 +241,15 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<Settings size={21} />
|
||||
</Link>
|
||||
)}
|
||||
<button className="icon-btn" style={{ width: "auto", padding: "0 2px", borderRadius: 999 }} onClick={acctMenu.open} aria-label={t("Account")}>
|
||||
<button
|
||||
className="icon-btn"
|
||||
style={{ width: "auto", padding: "0 2px", borderRadius: 999, position: "relative" }}
|
||||
onClick={acctMenu.open}
|
||||
aria-label={anyOtherUnread ? t("Account: new mail in another account") : t("Account")}
|
||||
>
|
||||
<Avatar who={{ name: session?.username, email: session?.username }} size="sm" />
|
||||
{/* inbuxa MA-8: another signed-in account has unread mail */}
|
||||
{anyOtherUnread && <span className="acct-dot" aria-hidden />}
|
||||
</button>
|
||||
<Popover anchor={acctMenu.anchor} onClose={acctMenu.close} align="end" width={280}>
|
||||
<div style={{ padding: "10px 10px 6px", display: "flex", gap: 10, alignItems: "center" }}>
|
||||
@@ -259,7 +271,16 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<MenuItem
|
||||
key={account.id}
|
||||
icon={account.front ? <Check size={16} /> : <Mail size={16} />}
|
||||
label={<span className="notranslate" translate="no">{account.username}</span>}
|
||||
label={
|
||||
<span className="row" style={{ gap: 8, alignItems: "center" }}>
|
||||
<span className="notranslate grow truncate" translate="no">{account.username}</span>
|
||||
{!account.front && (otherUnread[account.id] ?? 0) > 0 && (
|
||||
<span className="nav-count" aria-label={t("Unread in the Inbox: {count}", { count: otherUnread[account.id]! })}>
|
||||
{otherUnread[account.id]! > 9999 ? "9999+" : otherUnread[account.id]}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
active={account.front}
|
||||
onClick={() => (account.front ? acctMenu.close() : bringForward(account.id))}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user