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;
|
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;
|
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. */
|
/** Whether the account in front may have more beside it, or why not. */
|
||||||
async function addRefusal(c: Context, front: LiveSession): Promise<string | null> {
|
async function addRefusal(c: Context, front: LiveSession): Promise<string | null> {
|
||||||
if (1 + liveOthers(c).length >= MAX_ACCOUNTS) return "add_full";
|
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. */
|
/* inbuxa MA-B: bring another signed-in account to the front. */
|
||||||
api.post("/auth/accounts/:id/front", requireSession, async (c) => {
|
api.post("/auth/accounts/:id/front", requireSession, async (c) => {
|
||||||
const frontCookie = getCookie(c, config.cookieName)!;
|
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; }
|
.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. */
|
/* 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); }
|
.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 { 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); }
|
.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; }
|
.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 { toast } from "@/ui/toast";
|
||||||
import { DelegatedBar } from "./DelegatedBar";
|
import { DelegatedBar } from "./DelegatedBar";
|
||||||
import { AddAccountDialog } from "./AddAccountDialog";
|
import { AddAccountDialog } from "./AddAccountDialog";
|
||||||
|
import { useOtherAccountsUnread, useOtherUnread } from "@/lib/otherAccounts";
|
||||||
import { Avatar, useIsMobile } from "@/ui/misc";
|
import { Avatar, useIsMobile } from "@/ui/misc";
|
||||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||||
import { Splitter } from "@/ui/Splitter";
|
import { Splitter } from "@/ui/Splitter";
|
||||||
@@ -78,6 +79,10 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
const signedIn = useSession((s) => s.signedIn);
|
const signedIn = useSession((s) => s.signedIn);
|
||||||
const canAddAccount = useSession((s) => s.canAddAccount);
|
const canAddAccount = useSession((s) => s.canAddAccount);
|
||||||
const [addingAccount, setAddingAccount] = useState(false);
|
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) => {
|
const bringForward = (sessionId: string) => {
|
||||||
acctMenu.close();
|
acctMenu.close();
|
||||||
// A message being written belongs to the account it was started in
|
// 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} />
|
<Settings size={21} />
|
||||||
</Link>
|
</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" />
|
<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>
|
</button>
|
||||||
<Popover anchor={acctMenu.anchor} onClose={acctMenu.close} align="end" width={280}>
|
<Popover anchor={acctMenu.anchor} onClose={acctMenu.close} align="end" width={280}>
|
||||||
<div style={{ padding: "10px 10px 6px", display: "flex", gap: 10, alignItems: "center" }}>
|
<div style={{ padding: "10px 10px 6px", display: "flex", gap: 10, alignItems: "center" }}>
|
||||||
@@ -259,7 +271,16 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
key={account.id}
|
key={account.id}
|
||||||
icon={account.front ? <Check size={16} /> : <Mail size={16} />}
|
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}
|
active={account.front}
|
||||||
onClick={() => (account.front ? acctMenu.close() : bringForward(account.id))}
|
onClick={() => (account.front ? acctMenu.close() : bringForward(account.id))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in new issue
Block a user