Notifications with the app closed, for every signed-in account
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m3s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m3s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
Second of three for background push across accounts (multi-account spec, MA-8 part 2). Turning on "Notify me even when inbuxa is closed" now registers this browser in every signed-in account, each through its own session (the route from the previous change), and renewal keeps them all current. GET /api/auth/accounts says which mail account each session is, so the subscription can name it. The remembered endpoint is kept per account, and survives the clean-up that follows switching accounts. The service worker is told who the other accounts are. A push for one of them is titled with that account's address, shown even while a tab is focused (the tab only shows the front account's mail), and its Mark read and Archive act through that account's session. Clicking it brings that account to the front and opens the message. While push is on, the tab's own polling of other accounts stops notifying, so nothing arrives twice. Signing out of one account removes this device's subscription there only; signing out of all, or turning push off, removes every one. Also fixes where every background notification opened: the worker linked to /mail/inbox/<thread>, and the route takes a mailbox id there, so a click landed on the inbox list with "That folder no longer exists". The worker now gets each account's inbox id and links to the message. Checked end to end in Chrome against a local server with two accounts: both registered and verified, a message to the account not in front showed a notification under its address, and clicking it switched accounts and opened the message. No new strings. typecheck, tests (web 1547, server 279) and build pass.
This commit is contained in:
1 parent
6b979c5ac7
commit
9fcf4812f3
13 files changed
+394
-39
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)!;
|
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);
|
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));
|
setOthersCookie(c, others.map((o) => o.cookie));
|
||||||
}
|
}
|
||||||
const canAdd = (await addRefusal(c, front)) === null;
|
const canAdd = (await addRefusal(c, front)) === null;
|
||||||
return c.json({
|
// inbuxa MA-8: each one's mail account, which its push subscription and
|
||||||
accounts: [front, ...others.map((o) => o.session)].map((s, i) => ({ id: s.id, username: s.username, front: i === 0 })),
|
// a notification's buttons need
|
||||||
canAdd,
|
const accounts = await Promise.all(
|
||||||
max: MAX_ACCOUNTS,
|
[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
|
* rather than swallowed. A tap that silently does nothing is the failure worth
|
||||||
* avoiding here: the reader has already put the phone down.
|
* avoiding here: the reader has already put the phone down.
|
||||||
*/
|
*/
|
||||||
async function jmap(methodCalls) {
|
async function jmap(methodCalls, sessionId) {
|
||||||
const res = await fetch(`${BASE}/api/jmap`, {
|
// 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",
|
method: "POST",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" },
|
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 : [];
|
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
||||||
event.waitUntil((async () => {
|
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
|
* 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
|
* 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" });
|
const windows = await self.clients.matchAll({ type: "window" });
|
||||||
if (windows.some((w) => w.focused && w.visibilityState === "visible")) return;
|
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)" };
|
const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" };
|
||||||
/*
|
/*
|
||||||
* Mark the app icon, without claiming a number.
|
* Mark the app icon, without claiming a number.
|
||||||
@@ -502,8 +513,7 @@ self.addEventListener("push", (event) => {
|
|||||||
// be drawn.
|
// be drawn.
|
||||||
actions: email.id ? actionsFor(facts) : [],
|
actions: email.id ? actionsFor(facts) : [],
|
||||||
data: {
|
data: {
|
||||||
// The route names a conversation, and `m` the message in it.
|
url: messageUrl(facts && facts.inboxId, email),
|
||||||
url: email.id && email.threadId ? `${BASE}/mail/inbox/${email.threadId}?m=${encodeURIComponent(email.id)}` : `${BASE}/mail`,
|
|
||||||
id: email.id || null,
|
id: email.id || null,
|
||||||
title,
|
title,
|
||||||
accountId: facts?.accountId ?? null,
|
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.
|
* Do what the button said, without opening anything.
|
||||||
*
|
*
|
||||||
@@ -535,7 +609,7 @@ async function runAction(action, data) {
|
|||||||
: { "keywords/$seen": true };
|
: { "keywords/$seen": true };
|
||||||
try {
|
try {
|
||||||
if (action === "archive" && !archiveId) throw new Error("no archive mailbox");
|
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 {
|
} catch {
|
||||||
await self.registration.showNotification(data.title || "ihasmail", {
|
await self.registration.showNotification(data.title || "ihasmail", {
|
||||||
body: data.failed || "Could not do that — open ihasmail and try again",
|
body: data.failed || "Could not do that — open ihasmail and try again",
|
||||||
|
|||||||
+3
-2
@@ -330,13 +330,14 @@ function AuthedApp() {
|
|||||||
* See lib/swFacts.ts.
|
* See lib/swFacts.ts.
|
||||||
*/
|
*/
|
||||||
const archiveId = useMail((s) => s.roleId("archive"));
|
const archiveId = useMail((s) => s.roleId("archive"));
|
||||||
|
const inboxId = useMail((s) => s.roleId("inbox"));
|
||||||
const languageVersion = useLanguageVersion();
|
const languageVersion = useLanguageVersion();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// inbuxa AL-7: the worker acts on the reader's own mail; while a
|
// 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
|
// delegated account is in view, the archive folder here is its
|
||||||
if (viewing) return;
|
if (viewing) return;
|
||||||
void publishWorkerFacts(accountId, archiveId);
|
void publishWorkerFacts(accountId, archiveId, inboxId);
|
||||||
}, [accountId, archiveId, languageVersion, viewing]);
|
}, [accountId, archiveId, inboxId, languageVersion, viewing]);
|
||||||
|
|
||||||
// Request notification permission lazily when enabled
|
// Request notification permission lazily when enabled
|
||||||
const notif = useSettings((s) => s.settings.desktopNotifications);
|
const notif = useSettings((s) => s.settings.desktopNotifications);
|
||||||
|
|||||||
@@ -65,4 +65,21 @@ describe("other accounts' unread mail", () => {
|
|||||||
expect(shown).toEqual([]);
|
expect(shown).toEqual([]);
|
||||||
expect(useOtherUnread.getState().unread).toEqual({ b: 9 });
|
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";
|
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 {
|
try {
|
||||||
return localStorage.getItem(ENDPOINT_KEY);
|
return localStorage.getItem(endpointKey(accountId)) ?? (accountId ? localStorage.getItem(ENDPOINT_KEY) : null);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rememberEndpoint(endpoint: string | null): void {
|
export function rememberEndpoint(endpoint: string | null, accountId?: Id | null): void {
|
||||||
try {
|
try {
|
||||||
if (endpoint) localStorage.setItem(ENDPOINT_KEY, endpoint);
|
const key = endpointKey(accountId);
|
||||||
else localStorage.removeItem(ENDPOINT_KEY);
|
if (endpoint) localStorage.setItem(key, endpoint);
|
||||||
|
else localStorage.removeItem(key);
|
||||||
} catch {
|
} catch {
|
||||||
/* private mode: every start is then a fresh registration, which still works */
|
/* 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]);
|
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. */
|
/** Remove every subscription this browser registered. Used when signing out. */
|
||||||
export async function unsubscribeThisDevice(): Promise<void> {
|
export async function unsubscribeThisDevice(): Promise<void> {
|
||||||
const mine = deviceClientId();
|
const mine = deviceClientId();
|
||||||
|
|||||||
@@ -5,7 +5,11 @@
|
|||||||
* testable: everything here touches the browser's service worker and
|
* testable: everything here touches the browser's service worker and
|
||||||
* permission prompt, none of which exists under a test runner.
|
* 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 { withBase } from "../basePath";
|
||||||
import { SW_CACHE_NAME } from "../sw/swCache";
|
import { SW_CACHE_NAME } from "../sw/swCache";
|
||||||
import { isDeviceTrusted } from "@/lib/storage";
|
import { isDeviceTrusted } from "@/lib/storage";
|
||||||
@@ -18,6 +22,8 @@ import {
|
|||||||
destroySubscriptions,
|
destroySubscriptions,
|
||||||
deviceClientId,
|
deviceClientId,
|
||||||
extendSubscription,
|
extendSubscription,
|
||||||
|
frontCall,
|
||||||
|
type JmapCall,
|
||||||
findSubscription,
|
findSubscription,
|
||||||
listSubscriptions,
|
listSubscriptions,
|
||||||
mySubscriptions,
|
mySubscriptions,
|
||||||
@@ -29,6 +35,7 @@ import {
|
|||||||
roomToMake,
|
roomToMake,
|
||||||
setPushEnabledHere,
|
setPushEnabledHere,
|
||||||
subscriptionPayload,
|
subscriptionPayload,
|
||||||
|
unsubscribeAccount,
|
||||||
unsubscribeThisDevice,
|
unsubscribeThisDevice,
|
||||||
verifySubscription,
|
verifySubscription,
|
||||||
webPushAvailable,
|
webPushAvailable,
|
||||||
@@ -48,7 +55,7 @@ export function listenForVerification(): void {
|
|||||||
listening = true;
|
listening = true;
|
||||||
navigator.serviceWorker.addEventListener("message", (e: MessageEvent) => {
|
navigator.serviceWorker.addEventListener("message", (e: MessageEvent) => {
|
||||||
const d = e.data as { type?: string; id?: string; code?: string } | undefined;
|
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();
|
void collectStoredVerification();
|
||||||
}
|
}
|
||||||
@@ -64,12 +71,44 @@ async function collectStoredVerification(): Promise<void> {
|
|||||||
if (!hit) return;
|
if (!hit) return;
|
||||||
const { id, code } = (await hit.json()) as { id?: string; code?: string };
|
const { id, code } = (await hit.json()) as { id?: string; code?: string };
|
||||||
await cache.delete(key);
|
await cache.delete(key);
|
||||||
if (id && code) await verifySubscription(id, code);
|
if (id && code) await verifyAnywhere(id, code);
|
||||||
} catch {
|
} catch {
|
||||||
/* nothing waiting, or no cache: not a failure */
|
/* 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`.
|
* 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);
|
await registerThisBrowser(key);
|
||||||
setPushEnabledHere(true);
|
setPushEnabledHere(true);
|
||||||
listenForVerification();
|
listenForVerification();
|
||||||
|
// inbuxa MA-8: and every other account signed in here
|
||||||
|
await registerOtherAccounts(key);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, reason: (err as Error).message || "Could not subscribe to notifications." };
|
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
|
* gave up there, leaving push off for good with the switch still saying it was
|
||||||
* on.
|
* 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 reg = await navigator.serviceWorker.ready;
|
||||||
const sub = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({
|
const sub = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({
|
||||||
// Web Push requires it, and Chrome refuses a subscription without it.
|
// 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),
|
applicationServerKey: decodeApplicationServerKey(key),
|
||||||
}));
|
}));
|
||||||
const deviceId = deviceClientId();
|
const deviceId = deviceClientId();
|
||||||
const subs = await listSubscriptions();
|
const subs = await listSubscriptions(call);
|
||||||
const mine = mySubscriptions(subs, deviceId);
|
const mine = mySubscriptions(subs, deviceId);
|
||||||
const [newest, ...extra] = mine;
|
const [newest, ...extra] = mine;
|
||||||
|
|
||||||
if (newest && registeredEndpoint() === sub.endpoint) {
|
if (newest && registeredEndpoint(target.endpointOf) === sub.endpoint) {
|
||||||
if (extra.length) await destroySubscriptions(extra.map((s) => s.id));
|
if (extra.length) await destroySubscriptions(extra.map((s) => s.id), call);
|
||||||
const at = newest.expires ? Date.parse(newest.expires) : Number.NaN;
|
const at = newest.expires ? Date.parse(newest.expires) : Number.NaN;
|
||||||
if (!newest.expires || (!Number.isNaN(at) && at - Date.now() > RENEW_WITHIN_MS)) return;
|
if (!newest.expires || (!Number.isNaN(at) && at - Date.now() > RENEW_WITHIN_MS)) return;
|
||||||
try {
|
try {
|
||||||
await extendSubscription(newest.id);
|
await extendSubscription(newest.id, Date.now(), call);
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
/* not extendable: replaced below */
|
/* not extendable: replaced below */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mine.length) await destroySubscriptions(mine.map((s) => s.id));
|
if (mine.length) await destroySubscriptions(mine.map((s) => s.id), call);
|
||||||
// inbuxa AL-7: the reader's own inbox, never a delegated account's in view
|
const payload = subscriptionPayload(sub, target.accountId, target.inboxId);
|
||||||
const payload = subscriptionPayload(sub, useSession.getState().ownAccountFor(CAP.mail), ownInboxId());
|
|
||||||
try {
|
try {
|
||||||
await createSubscription(payload);
|
await createSubscription(payload, call);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!(err instanceof PushSetError) || err.type !== "overQuota") throw err;
|
if (!(err instanceof PushSetError) || err.type !== "overQuota") throw err;
|
||||||
const room = roomToMake(subs.filter((s) => !mine.includes(s)), deviceId);
|
const room = roomToMake(subs.filter((s) => !mine.includes(s)), deviceId);
|
||||||
if (!room.length) throw err;
|
if (!room.length) throw err;
|
||||||
await destroySubscriptions(room);
|
await destroySubscriptions(room, call);
|
||||||
await createSubscription(payload);
|
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.
|
// subscription is close to expiring, missing, or duplicated.
|
||||||
await registerThisBrowser(key);
|
await registerThisBrowser(key);
|
||||||
listenForVerification();
|
listenForVerification();
|
||||||
|
await registerOtherAccounts(key);
|
||||||
} catch {
|
} catch {
|
||||||
/* offline, or the server said no: the next start tries again */
|
/* 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> {
|
export async function disableWebPush(): Promise<void> {
|
||||||
|
await unsubscribeOtherAccounts();
|
||||||
await unsubscribeThisDevice();
|
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.
|
* 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
|
* 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.
|
* 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
|
* Only while a tab is open, and only where background notifications are off:
|
||||||
* added account's own Web Push subscription, which is still to come.
|
* 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 { useEffect } from "react";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { apiFetch } from "@/jmap/client";
|
import { apiFetch } from "@/jmap/client";
|
||||||
import { showNotification } from "@/lib/notify/notify";
|
import { showNotification } from "@/lib/notify/notify";
|
||||||
|
import { pushEnabledHere } from "@/lib/notify/webpush";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
@@ -48,6 +50,9 @@ export async function pollOtherUnread(): Promise<void> {
|
|||||||
const before = useOtherUnread.getState().unread;
|
const before = useOtherUnread.getState().unread;
|
||||||
useOtherUnread.getState().set(next);
|
useOtherUnread.getState().set(next);
|
||||||
if (!useSettings.getState().settings.desktopNotifications) return;
|
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]));
|
const names = new Map(useSession.getState().signedIn.map((a) => [a.id, a.username]));
|
||||||
for (const id of risen(before, next)) {
|
for (const id of risen(before, next)) {
|
||||||
const name = names.get(id);
|
const name = names.get(id);
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ function ownKeys(): string[] {
|
|||||||
export function clearSignedInData(): void {
|
export function clearSignedInData(): void {
|
||||||
for (const key of ownKeys()) {
|
for (const key of ownKeys()) {
|
||||||
if (KEEP_ON_SIGN_OUT.includes(key)) continue;
|
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);
|
removeKey(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ describe("the worker's briefing", () => {
|
|||||||
expect(facts.archiveId).toBe("mb-archive");
|
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 () => {
|
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
|
// 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.
|
// first, or a German reader gets English buttons on their lock screen.
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ export interface WorkerFacts {
|
|||||||
accountId: string;
|
accountId: string;
|
||||||
/** Where Archive files to; null where the account has no archive folder. */
|
/** Where Archive files to; null where the account has no archive folder. */
|
||||||
archiveId: string | null;
|
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. */
|
/** The worker's own user-visible text, in the language this tab is in. */
|
||||||
strings: {
|
strings: {
|
||||||
newMail: string;
|
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
|
* reading in a week's time. Rewriting it is one cache put; there is nothing to
|
||||||
* gain by working out whether it differs.
|
* 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;
|
if (typeof caches === "undefined" || !accountId) return;
|
||||||
const facts: WorkerFacts = {
|
const facts: WorkerFacts = {
|
||||||
accountId,
|
accountId,
|
||||||
archiveId,
|
archiveId,
|
||||||
|
inboxId,
|
||||||
|
others,
|
||||||
strings: {
|
strings: {
|
||||||
newMail: t("New mail"),
|
newMail: t("New mail"),
|
||||||
newMessage: t("New message"),
|
newMessage: t("New message"),
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import { accountForCapability, ownAccountForCapability } from "@/lib/accountRout
|
|||||||
import { setServerLocale } from "@/lib/datetime";
|
import { setServerLocale } from "@/lib/datetime";
|
||||||
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
|
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
|
||||||
import { reloadIfServerRebuilt } from "@/lib/sw/staleBuild";
|
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 { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage";
|
||||||
import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout";
|
import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout";
|
||||||
import { delegationOf, type Delegation } from "@/lib/delegation";
|
import { delegationOf, type Delegation } from "@/lib/delegation";
|
||||||
|
import { withBase } from "@/lib/basePath";
|
||||||
import { findSharedMail, sharedMailCandidates, type SharedMailAccount } from "@/lib/sharedMail";
|
import { findSharedMail, sharedMailCandidates, type SharedMailAccount } from "@/lib/sharedMail";
|
||||||
|
|
||||||
export type AuthStatus = "loading" | "anonymous" | "authenticated";
|
export type AuthStatus = "loading" | "anonymous" | "authenticated";
|
||||||
@@ -75,6 +76,31 @@ export interface SignedInAccount {
|
|||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
front: boolean;
|
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). */
|
/** 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
|
// without removing it leaves this browser notifying for a mailbox nobody is
|
||||||
// signed into. On a shared machine that is somebody else's mail.
|
// signed into. On a shared machine that is somebody else's mail.
|
||||||
try {
|
try {
|
||||||
await unsubscribeThisDevice();
|
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 {
|
} catch {
|
||||||
/* never block signing out over this */
|
/* never block signing out over this */
|
||||||
}
|
}
|
||||||
@@ -171,6 +209,7 @@ export const useSession = create<SessionState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
const answer = await apiFetch<{ accounts: SignedInAccount[]; canAdd: boolean }>("/api/auth/accounts");
|
const answer = await apiFetch<{ accounts: SignedInAccount[]; canAdd: boolean }>("/api/auth/accounts");
|
||||||
set({ signedIn: answer.accounts, canAddAccount: answer.canAdd });
|
set({ signedIn: answer.accounts, canAddAccount: answer.canAdd });
|
||||||
|
await openFromNotification(answer.accounts);
|
||||||
} catch {
|
} catch {
|
||||||
set({ signedIn: [], canAddAccount: false });
|
set({ signedIn: [], canAddAccount: false });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user