Merge pull request 'Notifications with the app closed, for every signed-in account' (#53) from feat/other-accounts-push-b into main
This commit was merged in pull request #53.
This commit is contained in:
commit
a58ea326d9
13 files changed
+393
-38
No files matched your search
@@ -190,3 +190,11 @@ test("inbuxa MA-8: only push, mailboxes and marking mail reach an account not in
|
||||
const front = (await b.accounts()).find((a) => a.front)!;
|
||||
assert.equal((await b.call(`/api/auth/accounts/${front.id}/jmap`, { method: "POST", body: { methodCalls: [] } })).status, 404);
|
||||
});
|
||||
|
||||
test("inbuxa MA-8: each listed account says which mail account it is", async () => {
|
||||
const b = new Browser();
|
||||
await b.signIn("[email protected]", "first-password");
|
||||
await b.signIn("[email protected]", "second-password", true);
|
||||
const res = await b.call("/api/auth/accounts");
|
||||
for (const a of res.body.accounts) assert.equal(typeof a.mailAccountId, "string", JSON.stringify(a));
|
||||
});
|
||||
+15
-5
@@ -802,11 +802,21 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
setOthersCookie(c, others.map((o) => o.cookie));
|
||||
}
|
||||
const canAdd = (await addRefusal(c, front)) === null;
|
||||
return c.json({
|
||||
accounts: [front, ...others.map((o) => o.session)].map((s, i) => ({ id: s.id, username: s.username, front: i === 0 })),
|
||||
canAdd,
|
||||
max: MAX_ACCOUNTS,
|
||||
});
|
||||
// inbuxa MA-8: each one's mail account, which its push subscription and
|
||||
// a notification's buttons need
|
||||
const accounts = await Promise.all(
|
||||
[front, ...others.map((o) => o.session)].map(async (s, i) => {
|
||||
let mailAccountId: string | null = null;
|
||||
try {
|
||||
const upstream = await getUpstreamSession(s.id, s.authorization, upstreamFor(s.username));
|
||||
mailAccountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? null;
|
||||
} catch {
|
||||
/* unknown for now: push for it waits for the next start */
|
||||
}
|
||||
return { id: s.id, username: s.username, front: i === 0, mailAccountId };
|
||||
}),
|
||||
);
|
||||
return c.json({ accounts, canAdd, max: MAX_ACCOUNTS });
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
+80
-6
@@ -386,8 +386,11 @@ async function readFacts() {
|
||||
* rather than swallowed. A tap that silently does nothing is the failure worth
|
||||
* avoiding here: the reader has already put the phone down.
|
||||
*/
|
||||
async function jmap(methodCalls) {
|
||||
const res = await fetch(`${BASE}/api/jmap`, {
|
||||
async function jmap(methodCalls, sessionId) {
|
||||
// inbuxa MA-8: an account not in front is reached through its own session,
|
||||
// on the webmail server's narrow route for it
|
||||
const path = sessionId ? `${BASE}/api/auth/accounts/${encodeURIComponent(sessionId)}/jmap` : `${BASE}/api/jmap`;
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" },
|
||||
@@ -456,6 +459,15 @@ self.addEventListener("push", (event) => {
|
||||
|
||||
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
||||
event.waitUntil((async () => {
|
||||
const facts = await readFacts();
|
||||
/*
|
||||
* inbuxa MA-8: whose mail this is. The payload names its account; one that
|
||||
* isn't the account in front is one of the others signed in here, or one
|
||||
* that has been signed out since (said plainly, with nothing to act on).
|
||||
*/
|
||||
const forAccount = data && data.accountId && facts && data.accountId !== facts.accountId ? data.accountId : null;
|
||||
const other = forAccount ? (facts.others || []).find((o) => o.accountId === forAccount) || null : null;
|
||||
if (forAccount) return showOtherAccount(emails, facts, other);
|
||||
/*
|
||||
* Someone reading the app already knows. A focused, visible window of this
|
||||
* app gets its new mail from its own event stream, so a notification on
|
||||
@@ -464,7 +476,6 @@ self.addEventListener("push", (event) => {
|
||||
*/
|
||||
const windows = await self.clients.matchAll({ type: "window" });
|
||||
if (windows.some((w) => w.focused && w.visibilityState === "visible")) return;
|
||||
const facts = await readFacts();
|
||||
const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" };
|
||||
/*
|
||||
* Mark the app icon, without claiming a number.
|
||||
@@ -502,8 +513,7 @@ self.addEventListener("push", (event) => {
|
||||
// be drawn.
|
||||
actions: email.id ? actionsFor(facts) : [],
|
||||
data: {
|
||||
// The route names a conversation, and `m` the message in it.
|
||||
url: email.id && email.threadId ? `${BASE}/mail/inbox/${email.threadId}?m=${encodeURIComponent(email.id)}` : `${BASE}/mail`,
|
||||
url: messageUrl(facts && facts.inboxId, email),
|
||||
id: email.id || null,
|
||||
title,
|
||||
accountId: facts?.accountId ?? null,
|
||||
@@ -515,6 +525,70 @@ self.addEventListener("push", (event) => {
|
||||
})());
|
||||
});
|
||||
|
||||
/*
|
||||
* inbuxa MA-8: new mail for a signed-in account that isn't in front.
|
||||
*
|
||||
* Shown even while a tab is focused: that tab's own stream only carries the
|
||||
* account in front, so nothing else would tell. The account's address is the
|
||||
* title, so it can't be taken for the front account's mail; the tag carries
|
||||
* the account, so two accounts' notifications don't replace each other; the
|
||||
* buttons act through that account's session; and opening it brings that
|
||||
* account forward before showing the message.
|
||||
*/
|
||||
async function showOtherAccount(emails, facts, other) {
|
||||
const strings = facts.strings;
|
||||
const icon = `${BASE}/img/icon-192.png?v=${BRAND_V}`;
|
||||
const badge = `${BASE}/img/favicon-64.png?v=${BRAND_V}`;
|
||||
if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
|
||||
if (!other || !emails.length) {
|
||||
// Signed out since, or nothing to show: say only what is true
|
||||
await self.registration.showNotification(other ? other.username : strings.newMail, {
|
||||
body: other ? strings.newMail : undefined,
|
||||
icon, badge,
|
||||
tag: `ihasmail-other-${other ? other.accountId : "unknown"}`,
|
||||
data: { url: other ? openUrl(other, `${BASE}/mail`) : `${BASE}/mail` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (const email of emails.slice(0, 5)) {
|
||||
const { title, body, preview } = textOf(email, strings);
|
||||
const at = messageUrl(other.inboxId, email);
|
||||
await self.registration.showNotification(other.username, {
|
||||
body: `${title}: ${body}${preview ? `\n${preview}` : ""}`,
|
||||
icon, badge,
|
||||
tag: `ihasmail-${other.accountId}-${email.id || body}`,
|
||||
actions: email.id ? actionsFor({ ...facts, archiveId: other.archiveId }) : [],
|
||||
data: {
|
||||
url: openUrl(other, at),
|
||||
id: email.id || null,
|
||||
title: other.username,
|
||||
accountId: other.accountId,
|
||||
archiveId: other.archiveId,
|
||||
sessionId: other.sessionId,
|
||||
failed: strings.failed ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Where opening a notification for an account not in front goes: it comes forward first. */
|
||||
/**
|
||||
* Where a notification opens. The route names a mailbox by id and then a
|
||||
* conversation, and `m` the message in it. It used to say `inbox` where the id
|
||||
* goes, which the app reads as a folder that no longer exists, so every click
|
||||
* landed on the inbox list with "That folder no longer exists" instead of the
|
||||
* message. Without an inbox id from the briefing, the inbox is the honest
|
||||
* landing.
|
||||
*/
|
||||
function messageUrl(inboxId, email) {
|
||||
if (!inboxId || !email.id || !email.threadId) return `${BASE}/mail`;
|
||||
return `${BASE}/mail/${encodeURIComponent(inboxId)}/${encodeURIComponent(email.threadId)}?m=${encodeURIComponent(email.id)}`;
|
||||
}
|
||||
|
||||
function openUrl(other, next) {
|
||||
return `${BASE}/?account=${encodeURIComponent(other.sessionId)}&next=${encodeURIComponent(next)}`;
|
||||
}
|
||||
|
||||
/*
|
||||
* Do what the button said, without opening anything.
|
||||
*
|
||||
@@ -535,7 +609,7 @@ async function runAction(action, data) {
|
||||
: { "keywords/$seen": true };
|
||||
try {
|
||||
if (action === "archive" && !archiveId) throw new Error("no archive mailbox");
|
||||
await jmap([["Email/set", { accountId, update: { [id]: patch } }, "0"]]);
|
||||
await jmap([["Email/set", { accountId, update: { [id]: patch } }, "0"]], data.sessionId || null);
|
||||
} catch {
|
||||
await self.registration.showNotification(data.title || "ihasmail", {
|
||||
body: data.failed || "Could not do that — open ihasmail and try again",
|
||||
|
||||
+3
-2
@@ -330,13 +330,14 @@ function AuthedApp() {
|
||||
* See lib/swFacts.ts.
|
||||
*/
|
||||
const archiveId = useMail((s) => s.roleId("archive"));
|
||||
const inboxId = useMail((s) => s.roleId("inbox"));
|
||||
const languageVersion = useLanguageVersion();
|
||||
useEffect(() => {
|
||||
// inbuxa AL-7: the worker acts on the reader's own mail; while a
|
||||
// delegated account is in view, the archive folder here is its
|
||||
if (viewing) return;
|
||||
void publishWorkerFacts(accountId, archiveId);
|
||||
}, [accountId, archiveId, languageVersion, viewing]);
|
||||
void publishWorkerFacts(accountId, archiveId, inboxId);
|
||||
}, [accountId, archiveId, inboxId, languageVersion, viewing]);
|
||||
|
||||
// Request notification permission lazily when enabled
|
||||
const notif = useSettings((s) => s.settings.desktopNotifications);
|
||||
|
||||
@@ -65,4 +65,21 @@ describe("other accounts' unread mail", () => {
|
||||
expect(shown).toEqual([]);
|
||||
expect(useOtherUnread.getState().unread).toEqual({ b: 9 });
|
||||
});
|
||||
|
||||
it("leaves telling to the worker where background notifications are on", async () => {
|
||||
const { setDeviceTrusted } = await import("@/lib/storage");
|
||||
const { setPushEnabledHere } = await import("@/lib/notify/webpush");
|
||||
setDeviceTrusted(true);
|
||||
setPushEnabledHere(true);
|
||||
try {
|
||||
await pollOtherUnread();
|
||||
counts = { b: 12 };
|
||||
await pollOtherUnread();
|
||||
expect(shown).toEqual([]);
|
||||
expect(useOtherUnread.getState().unread).toEqual({ b: 12 });
|
||||
} finally {
|
||||
setPushEnabledHere(false);
|
||||
setDeviceTrusted(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { clearSignedInData, setDeviceTrusted } from "@/lib/storage";
|
||||
import { deviceClientId, registeredEndpoint, rememberEndpoint, unsubscribeAccount, type JmapCall } from "@/lib/notify/webpush";
|
||||
|
||||
/** inbuxa MA-8 part 2: every signed-in account on this device has its own push subscription. */
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
describe("push for every signed-in account", () => {
|
||||
it("remembers each account's endpoint, and keeps them through a switch", () => {
|
||||
rememberEndpoint("https://push.example/old");
|
||||
// An account with nothing of its own yet reads the endpoint from before
|
||||
expect(registeredEndpoint("acc-a")).toBe("https://push.example/old");
|
||||
rememberEndpoint("https://push.example/a", "acc-a");
|
||||
rememberEndpoint("https://push.example/b", "acc-b");
|
||||
expect(registeredEndpoint("acc-a")).toBe("https://push.example/a");
|
||||
expect(registeredEndpoint("acc-b")).toBe("https://push.example/b");
|
||||
localStorage.setItem("ihasmail:cached-mail", "x");
|
||||
clearSignedInData();
|
||||
expect(registeredEndpoint("acc-a")).toBe("https://push.example/a");
|
||||
expect(localStorage.getItem("ihasmail:cached-mail")).toBeNull();
|
||||
rememberEndpoint(null, "acc-a");
|
||||
expect(localStorage.getItem("ihasmail:pushEndpoint:acc-a")).toBeNull();
|
||||
});
|
||||
|
||||
it("removes only this device's subscription from one account", async () => {
|
||||
// A device id is kept only where the device is trusted
|
||||
setDeviceTrusted(true);
|
||||
const mine = deviceClientId();
|
||||
const destroyed: string[] = [];
|
||||
const call: JmapCall = async <T,>(method: string, args: Record<string, unknown>) => {
|
||||
if (method === "PushSubscription/get") {
|
||||
return { list: [{ id: "p1", deviceClientId: mine }, { id: "p2", deviceClientId: "ihasmail-other-phone" }] } as T;
|
||||
}
|
||||
destroyed.push(...((args.destroy as string[]) ?? []));
|
||||
return {} as T;
|
||||
};
|
||||
rememberEndpoint("https://push.example/a", "acc-a");
|
||||
await unsubscribeAccount(call, "acc-a");
|
||||
expect(destroyed).toEqual(["p1"]);
|
||||
expect(localStorage.getItem("ihasmail:pushEndpoint:acc-a")).toBeNull();
|
||||
setDeviceTrusted(false);
|
||||
});
|
||||
});
|
||||
@@ -337,18 +337,29 @@ export async function destroySubscriptions(ids: Id[], call: JmapCall = frontCall
|
||||
*/
|
||||
const ENDPOINT_KEY = "ihasmail:pushEndpoint";
|
||||
|
||||
export function registeredEndpoint(): string | null {
|
||||
/*
|
||||
* inbuxa MA-8: one per account, since each signed-in account on this device
|
||||
* has its own subscription, and kept when switching between them (see
|
||||
* KEEP_ON_SIGN_OUT in lib/storage) so a switch doesn't register them afresh.
|
||||
* Without an account, the key from before: the account in front.
|
||||
*/
|
||||
function endpointKey(accountId?: Id | null): string {
|
||||
return accountId ? `${ENDPOINT_KEY}:${accountId}` : ENDPOINT_KEY;
|
||||
}
|
||||
|
||||
export function registeredEndpoint(accountId?: Id | null): string | null {
|
||||
try {
|
||||
return localStorage.getItem(ENDPOINT_KEY);
|
||||
return localStorage.getItem(endpointKey(accountId)) ?? (accountId ? localStorage.getItem(ENDPOINT_KEY) : null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberEndpoint(endpoint: string | null): void {
|
||||
export function rememberEndpoint(endpoint: string | null, accountId?: Id | null): void {
|
||||
try {
|
||||
if (endpoint) localStorage.setItem(ENDPOINT_KEY, endpoint);
|
||||
else localStorage.removeItem(ENDPOINT_KEY);
|
||||
const key = endpointKey(accountId);
|
||||
if (endpoint) localStorage.setItem(key, endpoint);
|
||||
else localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* private mode: every start is then a fresh registration, which still works */
|
||||
}
|
||||
@@ -376,6 +387,20 @@ export async function destroySubscription(id: Id): Promise<void> {
|
||||
await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: [id] }, [CAP.core, VAPID_CAP]);
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa MA-8: remove this device's subscription in one account only, leaving
|
||||
* the browser's push subscription and the switch alone -- for signing out of
|
||||
* the account in front while others stay signed in and keep notifying.
|
||||
*/
|
||||
export async function unsubscribeAccount(call: JmapCall = frontCall, accountId?: Id | null): Promise<void> {
|
||||
try {
|
||||
await destroySubscriptions(mySubscriptions(await listSubscriptions(call), deviceClientId()).map((s) => s.id), call);
|
||||
} catch {
|
||||
/* signing out must not fail over this */
|
||||
}
|
||||
rememberEndpoint(null, accountId);
|
||||
}
|
||||
|
||||
/** Remove every subscription this browser registered. Used when signing out. */
|
||||
export async function unsubscribeThisDevice(): Promise<void> {
|
||||
const mine = deviceClientId();
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
* testable: everything here touches the browser's service worker and
|
||||
* permission prompt, none of which exists under a test runner.
|
||||
*/
|
||||
import { CAP } from "@/jmap/client";
|
||||
import { apiFetch, CAP } from "@/jmap/client";
|
||||
import type { GetResponse, Id, Mailbox } from "@/jmap/types";
|
||||
import { otherAccountCall } from "@/lib/notify/otherAccount";
|
||||
import { setOtherAccountFacts, type OtherAccountFacts } from "@/lib/sw/swFacts";
|
||||
import type { SignedInAccount } from "@/store/session";
|
||||
import { withBase } from "../basePath";
|
||||
import { SW_CACHE_NAME } from "../sw/swCache";
|
||||
import { isDeviceTrusted } from "@/lib/storage";
|
||||
@@ -18,6 +22,8 @@ import {
|
||||
destroySubscriptions,
|
||||
deviceClientId,
|
||||
extendSubscription,
|
||||
frontCall,
|
||||
type JmapCall,
|
||||
findSubscription,
|
||||
listSubscriptions,
|
||||
mySubscriptions,
|
||||
@@ -29,6 +35,7 @@ import {
|
||||
roomToMake,
|
||||
setPushEnabledHere,
|
||||
subscriptionPayload,
|
||||
unsubscribeAccount,
|
||||
unsubscribeThisDevice,
|
||||
verifySubscription,
|
||||
webPushAvailable,
|
||||
@@ -48,7 +55,7 @@ export function listenForVerification(): void {
|
||||
listening = true;
|
||||
navigator.serviceWorker.addEventListener("message", (e: MessageEvent) => {
|
||||
const d = e.data as { type?: string; id?: string; code?: string } | undefined;
|
||||
if (d?.type === "push-verification" && d.id && d.code) void verifySubscription(d.id, d.code).catch(() => {});
|
||||
if (d?.type === "push-verification" && d.id && d.code) void verifyAnywhere(d.id, d.code);
|
||||
});
|
||||
void collectStoredVerification();
|
||||
}
|
||||
@@ -64,12 +71,44 @@ async function collectStoredVerification(): Promise<void> {
|
||||
if (!hit) return;
|
||||
const { id, code } = (await hit.json()) as { id?: string; code?: string };
|
||||
await cache.delete(key);
|
||||
if (id && code) await verifySubscription(id, code);
|
||||
if (id && code) await verifyAnywhere(id, code);
|
||||
} catch {
|
||||
/* nothing waiting, or no cache: not a failure */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa MA-8: a verification code belongs to one account's subscription, and
|
||||
* the worker doesn't say which: the account in front first, then each other
|
||||
* signed-in account until one takes it.
|
||||
*/
|
||||
async function verifyAnywhere(id: Id, code: string): Promise<void> {
|
||||
try {
|
||||
await verifySubscription(id, code);
|
||||
return;
|
||||
} catch {
|
||||
/* not the front account's */
|
||||
}
|
||||
for (const account of await otherAccounts()) {
|
||||
try {
|
||||
await verifySubscription(id, code, otherAccountCall(account.id));
|
||||
return;
|
||||
} catch {
|
||||
/* not this one's either */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The signed-in accounts not in front, as the server lists them now. */
|
||||
async function otherAccounts(): Promise<SignedInAccount[]> {
|
||||
try {
|
||||
const answer = await apiFetch<{ accounts: SignedInAccount[] }>("/api/auth/accounts");
|
||||
return answer.accounts.filter((a) => !a.front);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe this browser. Safe to call again: see `registerThisBrowser`.
|
||||
*
|
||||
@@ -97,6 +136,8 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
|
||||
await registerThisBrowser(key);
|
||||
setPushEnabledHere(true);
|
||||
listenForVerification();
|
||||
// inbuxa MA-8: and every other account signed in here
|
||||
await registerOtherAccounts(key);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: (err as Error).message || "Could not subscribe to notifications." };
|
||||
@@ -128,7 +169,23 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
|
||||
* gave up there, leaving push off for good with the switch still saying it was
|
||||
* on.
|
||||
*/
|
||||
async function registerThisBrowser(key: string): Promise<void> {
|
||||
interface PushTarget {
|
||||
call: JmapCall;
|
||||
/** The account's mail account, which the subscription names. */
|
||||
accountId: Id | null;
|
||||
inboxId: Id | null;
|
||||
/** Whose remembered endpoint to compare with: the account's own (MA-8). */
|
||||
endpointOf: Id | null;
|
||||
}
|
||||
|
||||
function frontTarget(): PushTarget {
|
||||
// inbuxa AL-7: the reader's own inbox, never a delegated account's in view
|
||||
const accountId = useSession.getState().ownAccountFor(CAP.mail);
|
||||
return { call: frontCall, accountId, inboxId: ownInboxId(), endpointOf: accountId };
|
||||
}
|
||||
|
||||
async function registerThisBrowser(key: string, target: PushTarget = frontTarget()): Promise<void> {
|
||||
const { call } = target;
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({
|
||||
// Web Push requires it, and Chrome refuses a subscription without it.
|
||||
@@ -136,35 +193,61 @@ async function registerThisBrowser(key: string): Promise<void> {
|
||||
applicationServerKey: decodeApplicationServerKey(key),
|
||||
}));
|
||||
const deviceId = deviceClientId();
|
||||
const subs = await listSubscriptions();
|
||||
const subs = await listSubscriptions(call);
|
||||
const mine = mySubscriptions(subs, deviceId);
|
||||
const [newest, ...extra] = mine;
|
||||
|
||||
if (newest && registeredEndpoint() === sub.endpoint) {
|
||||
if (extra.length) await destroySubscriptions(extra.map((s) => s.id));
|
||||
if (newest && registeredEndpoint(target.endpointOf) === sub.endpoint) {
|
||||
if (extra.length) await destroySubscriptions(extra.map((s) => s.id), call);
|
||||
const at = newest.expires ? Date.parse(newest.expires) : Number.NaN;
|
||||
if (!newest.expires || (!Number.isNaN(at) && at - Date.now() > RENEW_WITHIN_MS)) return;
|
||||
try {
|
||||
await extendSubscription(newest.id);
|
||||
await extendSubscription(newest.id, Date.now(), call);
|
||||
return;
|
||||
} catch {
|
||||
/* not extendable: replaced below */
|
||||
}
|
||||
}
|
||||
|
||||
if (mine.length) await destroySubscriptions(mine.map((s) => s.id));
|
||||
// inbuxa AL-7: the reader's own inbox, never a delegated account's in view
|
||||
const payload = subscriptionPayload(sub, useSession.getState().ownAccountFor(CAP.mail), ownInboxId());
|
||||
if (mine.length) await destroySubscriptions(mine.map((s) => s.id), call);
|
||||
const payload = subscriptionPayload(sub, target.accountId, target.inboxId);
|
||||
try {
|
||||
await createSubscription(payload);
|
||||
await createSubscription(payload, call);
|
||||
} catch (err) {
|
||||
if (!(err instanceof PushSetError) || err.type !== "overQuota") throw err;
|
||||
const room = roomToMake(subs.filter((s) => !mine.includes(s)), deviceId);
|
||||
if (!room.length) throw err;
|
||||
await destroySubscriptions(room);
|
||||
await createSubscription(payload);
|
||||
await destroySubscriptions(room, call);
|
||||
await createSubscription(payload, call);
|
||||
}
|
||||
rememberEndpoint(sub.endpoint);
|
||||
rememberEndpoint(sub.endpoint, target.endpointOf);
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa MA-8: register this browser in every other account signed in here,
|
||||
* each through its own session, and tell the worker who they are so their
|
||||
* notifications say whose they are and their buttons act on the right mail.
|
||||
* One account failing doesn't stop the rest; the next start tries it again.
|
||||
*/
|
||||
async function registerOtherAccounts(key: string): Promise<void> {
|
||||
const facts: OtherAccountFacts[] = [];
|
||||
for (const account of await otherAccounts()) {
|
||||
if (!account.mailAccountId) continue;
|
||||
const call = otherAccountCall(account.id);
|
||||
try {
|
||||
const boxes = await call<GetResponse<Mailbox>>(
|
||||
"Mailbox/get",
|
||||
{ accountId: account.mailAccountId, ids: null, properties: ["role"] },
|
||||
[CAP.mail],
|
||||
);
|
||||
const roleId = (role: string) => boxes.list.find((m) => m.role === role)?.id ?? null;
|
||||
await registerThisBrowser(key, { call, accountId: account.mailAccountId, inboxId: roleId("inbox"), endpointOf: account.mailAccountId });
|
||||
facts.push({ accountId: account.mailAccountId, sessionId: account.id, username: account.username, archiveId: roleId("archive"), inboxId: roleId("inbox") });
|
||||
} catch {
|
||||
/* this one waits for the next start */
|
||||
}
|
||||
}
|
||||
await setOtherAccountFacts(facts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,16 +273,26 @@ export async function renewWebPush(): Promise<void> {
|
||||
// subscription is close to expiring, missing, or duplicated.
|
||||
await registerThisBrowser(key);
|
||||
listenForVerification();
|
||||
await registerOtherAccounts(key);
|
||||
} catch {
|
||||
/* offline, or the server said no: the next start tries again */
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove this browser's subscription, at the browser and at the server. */
|
||||
/** Remove this browser's subscription, at the browser and at the server, in every signed-in account. */
|
||||
export async function disableWebPush(): Promise<void> {
|
||||
await unsubscribeOtherAccounts();
|
||||
await unsubscribeThisDevice();
|
||||
}
|
||||
|
||||
/** inbuxa MA-8: remove this device's subscription from every account not in front. */
|
||||
export async function unsubscribeOtherAccounts(): Promise<void> {
|
||||
for (const account of await otherAccounts()) {
|
||||
await unsubscribeAccount(otherAccountCall(account.id), account.mailAccountId);
|
||||
}
|
||||
await setOtherAccountFacts([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether *this browser* has a subscription registered at the server.
|
||||
*
|
||||
|
||||
@@ -6,13 +6,15 @@
|
||||
* 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.
|
||||
* Only while a tab is open, and only where background notifications are off:
|
||||
* with them on, each account has its own Web Push subscription and the worker
|
||||
* notifies (lib/notify/webpushEnable, public/sw.js).
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { create } from "zustand";
|
||||
import { apiFetch } from "@/jmap/client";
|
||||
import { showNotification } from "@/lib/notify/notify";
|
||||
import { pushEnabledHere } from "@/lib/notify/webpush";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useSettings } from "@/store/settings";
|
||||
@@ -48,6 +50,9 @@ export async function pollOtherUnread(): Promise<void> {
|
||||
const before = useOtherUnread.getState().unread;
|
||||
useOtherUnread.getState().set(next);
|
||||
if (!useSettings.getState().settings.desktopNotifications) return;
|
||||
// With background notifications on here, the worker tells about these
|
||||
// accounts already (MA-8 part 2): the counts stay, a second telling doesn't
|
||||
if (pushEnabledHere()) 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);
|
||||
|
||||
@@ -87,6 +87,9 @@ function ownKeys(): string[] {
|
||||
export function clearSignedInData(): void {
|
||||
for (const key of ownKeys()) {
|
||||
if (KEEP_ON_SIGN_OUT.includes(key)) continue;
|
||||
// inbuxa MA-8: each account's registered push endpoint, which is not mail
|
||||
// and is cleared with its subscription (webpush.ts)
|
||||
if (key.startsWith("pushEndpoint:")) continue;
|
||||
removeKey(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,14 @@ describe("the worker's briefing", () => {
|
||||
expect(facts.archiveId).toBe("mb-archive");
|
||||
});
|
||||
|
||||
it("names the inbox a notification opens in", async () => {
|
||||
// The route takes a mailbox id. The worker used to put the word `inbox`
|
||||
// there, and every click landed on "That folder no longer exists".
|
||||
const { store } = fakeCaches();
|
||||
await publishWorkerFacts("a1", "mb-archive", "mb-inbox");
|
||||
expect(written(store).inboxId).toBe("mb-inbox");
|
||||
});
|
||||
|
||||
it("carries the worker's text in the language the tab is in", async () => {
|
||||
// The worker has no catalog. Everything it will say has to be said here
|
||||
// first, or a German reader gets English buttons on their lock screen.
|
||||
|
||||
@@ -28,6 +28,13 @@ export interface WorkerFacts {
|
||||
accountId: string;
|
||||
/** Where Archive files to; null where the account has no archive folder. */
|
||||
archiveId: string | null;
|
||||
/** The inbox a notification opens in; the route names a mailbox by id. */
|
||||
inboxId?: string | null;
|
||||
/**
|
||||
* inbuxa MA-8: the other accounts signed in here, so a push for one of them
|
||||
* says whose it is and its buttons act through that account's session.
|
||||
*/
|
||||
others?: OtherAccountFacts[];
|
||||
/** The worker's own user-visible text, in the language this tab is in. */
|
||||
strings: {
|
||||
newMail: string;
|
||||
@@ -47,11 +54,31 @@ export interface WorkerFacts {
|
||||
* reading in a week's time. Rewriting it is one cache put; there is nothing to
|
||||
* gain by working out whether it differs.
|
||||
*/
|
||||
export async function publishWorkerFacts(accountId: string | null, archiveId: string | null): Promise<void> {
|
||||
export interface OtherAccountFacts {
|
||||
accountId: string;
|
||||
sessionId: string;
|
||||
username: string;
|
||||
archiveId: string | null;
|
||||
inboxId?: string | null;
|
||||
}
|
||||
|
||||
let lastFront: { accountId: string | null; archiveId: string | null; inboxId: string | null } = { accountId: null, archiveId: null, inboxId: null };
|
||||
let others: OtherAccountFacts[] = [];
|
||||
|
||||
/** inbuxa MA-8: record the other accounts and write the briefing again with them. */
|
||||
export async function setOtherAccountFacts(list: OtherAccountFacts[]): Promise<void> {
|
||||
others = list;
|
||||
await publishWorkerFacts(lastFront.accountId, lastFront.archiveId, lastFront.inboxId);
|
||||
}
|
||||
|
||||
export async function publishWorkerFacts(accountId: string | null, archiveId: string | null, inboxId: string | null = null): Promise<void> {
|
||||
lastFront = { accountId, archiveId, inboxId };
|
||||
if (typeof caches === "undefined" || !accountId) return;
|
||||
const facts: WorkerFacts = {
|
||||
accountId,
|
||||
archiveId,
|
||||
inboxId,
|
||||
others,
|
||||
strings: {
|
||||
newMail: t("New mail"),
|
||||
newMessage: t("New message"),
|
||||
|
||||
@@ -6,10 +6,11 @@ import { accountForCapability, ownAccountForCapability } from "@/lib/accountRout
|
||||
import { setServerLocale } from "@/lib/datetime";
|
||||
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
|
||||
import { reloadIfServerRebuilt } from "@/lib/sw/staleBuild";
|
||||
import { unsubscribeThisDevice } from "@/lib/notify/webpush";
|
||||
import { unsubscribeAccount, unsubscribeThisDevice } from "@/lib/notify/webpush";
|
||||
import { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage";
|
||||
import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout";
|
||||
import { delegationOf, type Delegation } from "@/lib/delegation";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { findSharedMail, sharedMailCandidates, type SharedMailAccount } from "@/lib/sharedMail";
|
||||
|
||||
export type AuthStatus = "loading" | "anonymous" | "authenticated";
|
||||
@@ -75,6 +76,31 @@ export interface SignedInAccount {
|
||||
id: string;
|
||||
username: string;
|
||||
front: boolean;
|
||||
/** Its mail account, for its push subscription (MA-8); null when not known yet. */
|
||||
mailAccountId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa MA-8: a notification for an account not in front opens
|
||||
* `?account=<session>&next=<where>`: bring that account forward, then go
|
||||
* there. Only a path inside the app is followed.
|
||||
*/
|
||||
// Read as the app starts: the router sends `/` on to `/mail` without its query.
|
||||
let launchParams: URLSearchParams | null = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null;
|
||||
|
||||
async function openFromNotification(accounts: SignedInAccount[]): Promise<void> {
|
||||
const params = launchParams;
|
||||
launchParams = null;
|
||||
const wanted = params?.get("account");
|
||||
if (!params || !wanted) return;
|
||||
const raw = params.get("next") ?? "";
|
||||
const next = raw.startsWith("/") && !raw.startsWith("//") ? raw : withBase("/mail");
|
||||
const account = accounts.find((a) => a.id === wanted);
|
||||
if (account && !account.front) {
|
||||
await apiFetch(`/api/auth/accounts/${encodeURIComponent(wanted)}/front`, { method: "POST" });
|
||||
clearSignedInData();
|
||||
}
|
||||
window.location.replace(next);
|
||||
}
|
||||
|
||||
/** Which sign-out: the account in front, or every one (MA-B). */
|
||||
@@ -126,7 +152,19 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
// without removing it leaves this browser notifying for a mailbox nobody is
|
||||
// signed into. On a shared machine that is somebody else's mail.
|
||||
try {
|
||||
const everyone = signOutPath.endsWith("logout-all");
|
||||
const othersRemain = !everyone && get().signedIn.some((a) => !a.front);
|
||||
if (everyone) {
|
||||
// inbuxa MA-8: every account's subscription goes, the others' first
|
||||
const { unsubscribeOtherAccounts } = await import("@/lib/notify/webpushEnable");
|
||||
await unsubscribeOtherAccounts();
|
||||
}
|
||||
if (othersRemain) {
|
||||
// inbuxa MA-8: only this account's; the browser keeps notifying for the rest
|
||||
await unsubscribeAccount(undefined, get().ownAccountFor(CAP.mail));
|
||||
} else {
|
||||
await unsubscribeThisDevice();
|
||||
}
|
||||
} catch {
|
||||
/* never block signing out over this */
|
||||
}
|
||||
@@ -171,6 +209,7 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
try {
|
||||
const answer = await apiFetch<{ accounts: SignedInAccount[]; canAdd: boolean }>("/api/auth/accounts");
|
||||
set({ signedIn: answer.accounts, canAddAccount: answer.canAdd });
|
||||
await openFromNotification(answer.accounts);
|
||||
} catch {
|
||||
set({ signedIn: [], canAddAccount: false });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user