From cbc75f233b5349fb79218f98448800388e2fb689 Mon Sep 17 00:00:00 2001 From: Sivert Date: Wed, 7 Oct 2026 17:25:19 +0200 Subject: [PATCH] Pushes follow the phone's notification settings: every message at All, @everyone unless suppressed (GRYT-1696) Until now a push went out only for a DM or a mention of you or one of your roles. A channel set to "All messages" was silent with the app closed, and @everyone never pushed, though the desktop notifies for both. Now: - A channel the phone has at All pushes "New message" for every message. - @everyone and @here push to phones that don't suppress them. - Direct and role mentions, DMs and mutes work as before. The phone sends its settings with `push:register`: `all`, the conversations at All, and `everyone`, whether @everyone gets through. They go in two new `push_devices` columns, `loud` and `everyone`. A phone that sends neither gets what it got before, so older apps are unchanged. The phone half is in Gryt-chat/mobile and the docs in Gryt-chat/docs (linked below). What to look at: - `src/db/sqlite`: the two columns, their migration, and `loudPushAccounts`, which finds accounts with a loud phone for a conversation through `json_each`. It's one query per channel message, on every server, whether or not anyone has a phone. It's cheap with few devices, but it is new work on the send path. - `chat.ts`: the "every message" push runs after the mention push, so a mention takes the conversation's 15-second quiet window and arrives as one notification. Each candidate is checked with `mayViewChannel`, so a phone that lost access stops getting them, even though its list still names the channel. - Muted wins: if a phone sends a conversation in both `muted` and `all`, it's dropped from `all` before it's stored. - The id limit went from 1,000 to 2,000, because `all` can name every channel on a server. Tests: `pushAudience.test.ts` runs the real handlers against a fake relay. A phone at All gets one "message" push for a burst, and a phone left at mentions gets none. A mention in a loud channel arrives once, as a mention. The sender, someone at a screen, someone who blocked the sender and someone who lost access to the channel get nothing. Muted beats All. @everyone wakes only a phone with `everyone`, and bad settings are refused. Dropping either new push fails them. The helper that waited for the fake relay to go quiet for 60ms lost the first push under full-suite load. It now waits for every push in flight (`pushesSettled`), and the full suite passed twice in a row. Co-Authored-By: Claude Opus 5.5 --- src/db/sqlite/connection.ts | 9 ++ src/db/sqlite/pushDevices.test.ts | 2 +- src/db/sqlite/pushDevices.ts | 46 ++++++++-- src/services/push.test.ts | 10 +-- src/services/push.ts | 41 +++++++-- src/socket/handlers/chat.ts | 30 ++++++- src/socket/handlers/push.ts | 25 ++++-- src/socket/handlers/pushAudience.test.ts | 103 ++++++++++++++++++++--- 8 files changed, 221 insertions(+), 45 deletions(-) diff --git a/src/db/sqlite/connection.ts b/src/db/sqlite/connection.ts index 40699eb6..dfd63305 100644 --- a/src/db/sqlite/connection.ts +++ b/src/db/sqlite/connection.ts @@ -254,6 +254,8 @@ function createSchema(d: DatabaseSync): void { install_id TEXT NOT NULL, capability TEXT NOT NULL, muted TEXT NOT NULL DEFAULT '[]', + loud TEXT NOT NULL DEFAULT '[]', + everyone INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (server_user_id, install_id) @@ -715,6 +717,13 @@ function runMigrations(d: DatabaseSync): void { if (!hasColumn(d, "push_devices", "muted")) { d.exec("ALTER TABLE push_devices ADD COLUMN muted TEXT NOT NULL DEFAULT '[]'"); } + // Conversations at "All messages", and whether @everyone gets through (GRYT-1696). Old phones send neither. + if (!hasColumn(d, "push_devices", "loud")) { + d.exec("ALTER TABLE push_devices ADD COLUMN loud TEXT NOT NULL DEFAULT '[]'"); + } + if (!hasColumn(d, "push_devices", "everyone")) { + d.exec("ALTER TABLE push_devices ADD COLUMN everyone INTEGER NOT NULL DEFAULT 0"); + } const cols = d.prepare("PRAGMA table_info(users)").all() as { name: string }[]; const colNames = new Set(cols.map((c) => c.name)); diff --git a/src/db/sqlite/pushDevices.test.ts b/src/db/sqlite/pushDevices.test.ts index 1b5c8556..2c8bc8cc 100644 --- a/src/db/sqlite/pushDevices.test.ts +++ b/src/db/sqlite/pushDevices.test.ts @@ -20,7 +20,7 @@ describe("push devices", () => { it("keeps one row per install and replaces its capability", () => { savePushDevice("u1", "install-1", cap("a")); savePushDevice("u1", "install-1", cap("b")); - assert.deepEqual(listPushDevices("u1"), [{ installId: "install-1", capability: cap("b"), muted: new Set() }]); + assert.deepEqual(listPushDevices("u1"), [{ installId: "install-1", capability: cap("b"), muted: new Set(), loud: new Set(), everyone: false }]); removePushDevice("u1", "install-1"); assert.deepEqual(listPushDevices("u1"), []); }); diff --git a/src/db/sqlite/pushDevices.ts b/src/db/sqlite/pushDevices.ts index 0e831ec5..f1b4088a 100644 --- a/src/db/sqlite/pushDevices.ts +++ b/src/db/sqlite/pushDevices.ts @@ -6,6 +6,17 @@ export interface PushDevice { capability: string; /** Conversations muted on that phone, which never reach the relay (GRYT-1689). */ muted: ReadonlySet; + /** Conversations at "All messages" on that phone: every message there wakes it (GRYT-1696). */ + loud: ReadonlySet; + /** Whether @everyone and @here wake it, which "Suppress @everyone" turns off. */ + everyone: boolean; +} + +/** What the phone said about its notification settings when it last checked in. */ +export interface PushSettings { + muted?: readonly string[]; + loud?: readonly string[]; + everyone?: boolean; } /** More than this and the oldest goes. Nobody has ten phones; a reinstall loop might. */ @@ -16,7 +27,7 @@ export const PUSH_DEVICE_STALE_DAYS = 30; const DAY_MS = 24 * 60 * 60 * 1000; -function parseMuted(raw: string): ReadonlySet { +function parseIds(raw: string): ReadonlySet { try { const value = JSON.parse(raw) as unknown; return new Set(Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []); @@ -29,15 +40,20 @@ export function savePushDevice( serverUserId: string, installId: string, capability: string, - muted: readonly string[] = [], + settings: PushSettings = {}, now = new Date(), ): void { const db = getSqliteDb(); const at = toIso(now); db.prepare( - `INSERT INTO push_devices (server_user_id, install_id, capability, muted, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(server_user_id, install_id) DO UPDATE SET capability = excluded.capability, muted = excluded.muted, updated_at = excluded.updated_at`, - ).run(serverUserId, installId, capability, JSON.stringify(muted), at, at); + `INSERT INTO push_devices (server_user_id, install_id, capability, muted, loud, everyone, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(server_user_id, install_id) DO UPDATE SET capability = excluded.capability, muted = excluded.muted, + loud = excluded.loud, everyone = excluded.everyone, updated_at = excluded.updated_at`, + ).run( + serverUserId, installId, capability, + JSON.stringify(settings.muted ?? []), JSON.stringify(settings.loud ?? []), settings.everyone ? 1 : 0, + at, at, + ); db.prepare( `DELETE FROM push_devices WHERE server_user_id = ? AND install_id NOT IN ( SELECT install_id FROM push_devices WHERE server_user_id = ? ORDER BY updated_at DESC, install_id LIMIT ?)`, @@ -50,9 +66,23 @@ export function listPushDevices(serverUserId: string, now = new Date()): PushDev const cutoff = toIso(new Date(now.getTime() - PUSH_DEVICE_STALE_DAYS * DAY_MS)); db.prepare(`DELETE FROM push_devices WHERE server_user_id = ? AND updated_at < ?`).run(serverUserId, cutoff); const rows = db - .prepare(`SELECT install_id, capability, muted FROM push_devices WHERE server_user_id = ?`) - .all(serverUserId) as { install_id: string; capability: string; muted: string }[]; - return rows.map((r) => ({ installId: r.install_id, capability: r.capability, muted: parseMuted(r.muted) })); + .prepare(`SELECT install_id, capability, muted, loud, everyone FROM push_devices WHERE server_user_id = ?`) + .all(serverUserId) as { install_id: string; capability: string; muted: string; loud: string; everyone: number }[]; + return rows.map((r) => ({ + installId: r.install_id, + capability: r.capability, + muted: parseIds(r.muted), + loud: parseIds(r.loud), + everyone: r.everyone === 1, + })); +} + +/** Accounts with a phone that wants every message in this conversation. Stale ones are dropped later, per account. */ +export function loudPushAccounts(conversationId: string): string[] { + const rows = getSqliteDb() + .prepare(`SELECT DISTINCT p.server_user_id AS id FROM push_devices p, json_each(p.loud) j WHERE j.value = ?`) + .all(conversationId) as { id: string }[]; + return rows.map((r) => r.id); } export function removePushDevice(serverUserId: string, installId: string): void { diff --git a/src/services/push.test.ts b/src/services/push.test.ts index 9c36dab2..98c99dfb 100644 --- a/src/services/push.test.ts +++ b/src/services/push.test.ts @@ -57,14 +57,14 @@ describe("who is at a screen", () => { describe("pushing", () => { it("sends the capability in the header and only the kind in the body", async () => { - const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set() }] }); + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set(), loud: new Set(), everyone: false }] }); h.pusher.notify({}, ["u1"], "dm", "conv"); await flush(); assert.deepEqual(h.calls, [{ url: "https://push.test/v1/push", auth: `Bearer ${CAP_A}`, body: JSON.stringify({ kind: "dm" }) }]); }); it("skips somebody who is at a screen, and wakes them once they put the phone away", async () => { - const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set() }] }); + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set(), loud: new Set(), everyone: false }] }); h.pusher.notify({ s: client("u1") }, ["u1"], "mention", "conv"); await flush(); assert.equal(h.calls.length, 0); @@ -74,7 +74,7 @@ describe("pushing", () => { }); it("buzzes once per phone per conversation in fifteen seconds", async () => { - const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set() }, { installId: "tablet-12", capability: CAP_B, muted: new Set() }] }); + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set(), loud: new Set(), everyone: false }, { installId: "tablet-12", capability: CAP_B, muted: new Set(), loud: new Set(), everyone: false }] }); h.pusher.notify({}, ["u1"], "dm", "conv"); h.pusher.notify({}, ["u1", "u1"], "dm", "conv"); h.pusher.notify({}, ["u1"], "dm", "other"); @@ -86,7 +86,7 @@ describe("pushing", () => { it("forgets a capability the relay calls gone or unknown", async () => { for (const status of [404, 410]) { - const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set() }] }, status); + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set(), loud: new Set(), everyone: false }] }, status); h.pusher.notify({}, ["u1"], "dm", "conv"); await flush(); await flush(); @@ -95,7 +95,7 @@ describe("pushing", () => { }); it("keeps it when the relay is only busy", async () => { - const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set() }] }, 502); + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set(), loud: new Set(), everyone: false }] }, 502); h.pusher.notify({}, ["u1"], "dm", "conv"); await flush(); await flush(); diff --git a/src/services/push.ts b/src/services/push.ts index 81b688b2..813d1178 100644 --- a/src/services/push.ts +++ b/src/services/push.ts @@ -8,7 +8,10 @@ import type { Clients } from "../types"; * a kind, nothing else: it writes the notification text itself. */ -export type PushKind = "mention" | "dm"; +export type PushKind = "mention" | "dm" | "message"; + +/** Which of an account's phones want this push, by their own settings. Muted is checked on top. */ +export type PushAccept = (device: PushDevice) => boolean; export const DEFAULT_PUSH_RELAY = "https://push.gryt.chat"; @@ -53,6 +56,7 @@ export interface PusherDeps { export function createPusher(deps: PusherDeps) { const lastSent = new Map(); + const inFlight = new Set>(); async function send(device: PushDevice, kind: PushKind): Promise { try { @@ -71,7 +75,13 @@ export function createPusher(deps: PusherDeps) { } /** Fire and forget: called after delivery, and never awaited by a send. */ - function notify(clientsInfo: Clients, serverUserIds: Iterable, kind: PushKind, conversationId: string): void { + function notify( + clientsInfo: Clients, + serverUserIds: Iterable, + kind: PushKind, + conversationId: string, + accept: PushAccept = () => true, + ): void { if (!deps.relay) return; const now = deps.now(); for (const serverUserId of new Set(serverUserIds)) { @@ -84,11 +94,12 @@ export function createPusher(deps: PusherDeps) { continue; } for (const device of devices) { - if (device.muted.has(conversationId)) continue; + if (device.muted.has(conversationId) || !accept(device)) continue; const key = `${device.capability}:${conversationId}`; if (now - (lastSent.get(key) ?? 0) < QUIET_MS) continue; lastSent.set(key, now); - void send(device, kind); + const sending = send(device, kind).finally(() => inFlight.delete(sending)); + inFlight.add(sending); } } if (lastSent.size > 5_000) { @@ -96,7 +107,12 @@ export function createPusher(deps: PusherDeps) { } } - return { notify }; + /** Tests only: resolves once every push sent so far has had its answer. */ + async function settled(): Promise { + while (inFlight.size > 0) await Promise.all([...inFlight]); + } + + return { notify, settled }; } let shared: ReturnType | null = null; @@ -108,7 +124,13 @@ function configuredRelay(): string | null { return relay; } -export function pushNotify(clientsInfo: Clients, serverUserIds: Iterable, kind: PushKind, conversationId: string): void { +export function pushNotify( + clientsInfo: Clients, + serverUserIds: Iterable, + kind: PushKind, + conversationId: string, + accept?: PushAccept, +): void { shared ??= createPusher({ relay: configuredRelay(), listDevices: listPushDevices, @@ -116,7 +138,12 @@ export function pushNotify(clientsInfo: Clients, serverUserIds: Iterable fetch, now: Date.now, }); - shared.notify(clientsInfo, serverUserIds, kind, conversationId); + shared.notify(clientsInfo, serverUserIds, kind, conversationId, accept); +} + +/** Tests only: every push so far has reached the relay and been answered. */ +export function pushesSettled(): Promise { + return shared?.settled() ?? Promise.resolve(); } /** Tests only: forget the throttle and read the relay setting again. */ diff --git a/src/socket/handlers/chat.ts b/src/socket/handlers/chat.ts index a5d165a6..f059ab4c 100644 --- a/src/socket/handlers/chat.ts +++ b/src/socket/handlers/chat.ts @@ -49,6 +49,7 @@ import { setThreadStatus, setThreadTags, type ThreadRecord, + loudPushAccounts, } from "../../db"; import { isQuarantined } from "../../services/quarantineUpload"; import { processProfanity, type CensorStyle, type ProfanityMode } from "../../utils/profanityFilter"; @@ -822,23 +823,44 @@ export function registerChatHandlers(ctx: HandlerContext): EventHandlerMap { }); } - /* Direct and role mentions wake a phone. @everyone and @here would wake - the whole server, and a DM already pushed as a DM. */ + /* Direct and role mentions wake a phone. @everyone and @here only wake one that + doesn't suppress them, like the desktop (GRYT-1696). A DM already pushed as a DM. */ if (access.kind !== "dm") { const blockers = await blockersOfSender(auth.tokenPayload.serverUserId); const woken: string[] = []; + const crowd: string[] = []; for (const [id, kind] of kindOf) { - if ((kind !== "user" && kind !== "role") || blockers.has(id)) continue; + if (blockers.has(id)) continue; if (kind === "user" && !(await mayViewChannel(created.conversation_id, id))) continue; - woken.push(id); + (kind === "user" || kind === "role" ? woken : crowd).push(id); } pushNotify(clientsInfo, woken, "mention", created.conversation_id); + pushNotify(clientsInfo, crowd, "mention", created.conversation_id, (device) => device.everyone); } } catch (err) { consola.warn("recording mentions failed", created.message_id, err); } } + /* A phone at "All messages" here wakes for every message (GRYT-1696). After the mention + push, so a mention takes the conversation's quiet window and reads as one. */ + if (access.kind !== "dm") { + try { + const sender = auth.tokenPayload.serverUserId; + const blockers = await blockersOfSender(sender); + const loud: string[] = []; + for (const id of loudPushAccounts(created.conversation_id)) { + if (id === sender || blockers.has(id)) continue; + if (!(await mayViewChannel(created.conversation_id, id))) continue; + loud.push(id); + } + const conversationId = created.conversation_id; + pushNotify(clientsInfo, loud, "message", conversationId, (device) => device.loud.has(conversationId)); + } catch (err) { + consola.warn("message push failed", created.message_id, err); + } + } + // After the message is out, so a promotion can neither slow one down // nor stop one. One roles read on a server using none of this. const promoted = await applyAutoRoles( diff --git a/src/socket/handlers/push.ts b/src/socket/handlers/push.ts index 91a77a12..434dcc2b 100644 --- a/src/socket/handlers/push.ts +++ b/src/socket/handlers/push.ts @@ -15,12 +15,12 @@ type Reply = { ok: true } | { ok: false; error: string }; type Ack = (reply: Reply) => void; const INSTALL_ID = /^[A-Za-z0-9_-]{8,64}$/; -const MAX_MUTED = 1000; +const MAX_IDS = 2000; -/** Conversation ids the phone muted. Null when it is not a short list of short strings. */ -function mutedFrom(raw: unknown): string[] | null { +/** Conversation ids from the phone, muted or at All. Null when it is not a short list of short strings. */ +function idsFrom(raw: unknown): string[] | null { if (raw === undefined) return []; - if (!Array.isArray(raw) || raw.length > MAX_MUTED) return null; + if (!Array.isArray(raw) || raw.length > MAX_IDS) return null; if (!raw.every((id) => typeof id === "string" && id.length > 0 && id.length <= 128)) return null; return [...new Set(raw as string[])]; } @@ -35,7 +35,7 @@ export function registerPushHandlers(ctx: HandlerContext): EventHandlerMap { return { "push:register": async ( - payload: { accessToken: string; installId?: unknown; capability?: unknown; muted?: unknown }, + payload: { accessToken: string; installId?: unknown; capability?: unknown; muted?: unknown; all?: unknown; everyone?: unknown }, ack: Ack, ) => { ack = typeof ack === "function" ? ack : () => {}; @@ -46,11 +46,20 @@ export function registerPushHandlers(ctx: HandlerContext): EventHandlerMap { || typeof payload.capability !== "string" || !CAPABILITY_SHAPE.test(payload.capability)) { return ack({ ok: false, error: "invalid_payload" }); } - const muted = mutedFrom(payload.muted); - if (!muted) return ack({ ok: false, error: "invalid_payload" }); + const muted = idsFrom(payload.muted); + const loud = idsFrom(payload.all); + if (!muted || !loud || (payload.everyone !== undefined && typeof payload.everyone !== "boolean")) { + return ack({ ok: false, error: "invalid_payload" }); + } const auth = await requireAuth(socket, payload); if (!auth) return ack({ ok: false, error: "unauthorized" }); - savePushDevice(auth.tokenPayload.serverUserId, payload.installId, payload.capability, muted); + // Muted wins over All, so a list that names one conversation twice stays quiet. + const quiet = new Set(muted); + savePushDevice(auth.tokenPayload.serverUserId, payload.installId, payload.capability, { + muted, + loud: loud.filter((id) => !quiet.has(id)), + everyone: payload.everyone === true, + }); ack({ ok: true }); } catch (err) { consola.error("push:register failed", err); diff --git a/src/socket/handlers/pushAudience.test.ts b/src/socket/handlers/pushAudience.test.ts index 87722755..d9280fd5 100644 --- a/src/socket/handlers/pushAudience.test.ts +++ b/src/socket/handlers/pushAudience.test.ts @@ -16,7 +16,7 @@ import { createRoleDefinition } from "../../db/sqlite/roleDefinitions"; import { createServerConfigIfNotExists, setServerRole } from "../../db/sqlite/servers"; import { setUserInactive, upsertUser } from "../../db/sqlite/users"; import { resetChannelPermissionCache } from "../../services/channelPermissions"; -import { resetPushState } from "../../services/push"; +import { pushesSettled, resetPushState } from "../../services/push"; import type { Clients } from "../../types"; import { generateAccessToken } from "../../utils/jwt"; import { resetRateLimits } from "../../utils/rateLimiter"; @@ -106,10 +106,14 @@ async function member(name: string, roleId: string): Promise { return m; } -/** Through the handler, as the phone does it. */ -async function register(m: Member, muted?: string[]): Promise { +/** Through the handler, as the phone does it. A bare list is what it mutes. */ +async function register(m: Member, settings: string[] | { muted?: string[]; all?: string[]; everyone?: boolean } = {}): Promise { + const { muted, all, everyone } = Array.isArray(settings) ? { muted: settings, all: undefined, everyone: undefined } : settings; const reply = await new Promise((resolve) => - m.handlers["push:register"]({ accessToken: m.accessToken, installId: `install-${m.name}`, capability: m.capability, muted }, resolve), + m.handlers["push:register"]( + { accessToken: m.accessToken, installId: `install-${m.name}`, capability: m.capability, muted, all, everyone }, + resolve, + ), ); assert.deepEqual(reply, { ok: true }, `${m.name} could not register`); } @@ -127,13 +131,9 @@ function back(m: Member): void { } as Clients[string]; } -/** Pushes are fire and forget, so wait until the relay has been quiet for a moment. */ +/** Pushes are fire and forget, so wait for every one sent to have been answered. A quiet spell lost them under load. */ async function woken(): Promise { - let seen = -1; - while (seen !== hits.length) { - seen = hits.length; - await new Promise((r) => setTimeout(r, 60)); - } + await pushesSettled(); return hits.map((h) => `${members.get(h.capability)?.name ?? "?"}:${JSON.parse(h.body).kind}`).sort(); } @@ -255,12 +255,19 @@ describe("mentions", () => { assert.deepEqual(await woken(), []); }); - it("don't wake anybody for @everyone or @here", async () => { + it("don't wake anybody for @everyone or @here from a phone that suppresses them", async () => { for (const m of [bob, carol, dave, erin]) away(m); await send(alice, OPEN, line("@everyone and @here, hello")); assert.deepEqual(await woken(), []); }); + it("wake a phone that lets @everyone through, like the desktop does", async () => { + await register(carol, { everyone: true }); + for (const m of [bob, carol, dave, erin]) away(m); + await send(alice, OPEN, line("@everyone, meeting")); + assert.deepEqual(await woken(), ["Carol:mention"]); + }); + it("don't wake somebody who blocked the sender", async () => { await blockUser(erin.grytUserId, alice.grytUserId); away(erin); @@ -283,6 +290,78 @@ describe("mentions", () => { }); }); +describe("every message, for a phone at All", () => { + it("wakes a phone at All for a plain message, and one left at mentions stays quiet", async () => { + await register(carol, { all: [OPEN] }); + away(carol); + away(dave); + await send(alice, OPEN, line("nothing special")); + assert.deepEqual(await woken(), ["Carol:message"]); + assert.deepEqual(JSON.parse(hits[0].body), { kind: "message" }); + }); + + it("only for the conversations at All", async () => { + await register(carol, { all: [STAFF] }); + away(carol); + await send(alice, OPEN, line("in general")); + assert.deepEqual(await woken(), []); + }); + + it("buzzes once for a burst", async () => { + await register(carol, { all: [OPEN] }); + away(carol); + for (const word of ["one", "two", "three"]) await send(alice, OPEN, line(word)); + assert.deepEqual(await woken(), ["Carol:message"]); + }); + + it("a mention there arrives as a mention, not twice", async () => { + await register(carol, { all: [OPEN] }); + away(carol); + await send(alice, OPEN, line("@Carol in a loud channel")); + assert.deepEqual(await woken(), ["Carol:mention"]); + }); + + it("not somebody at a screen, nor the sender", async () => { + await register(carol, { all: [OPEN] }); + await register(alice, { all: [OPEN] }); + away(alice); + await send(alice, OPEN, line("my own message")); + assert.deepEqual(await woken(), []); + }); + + it("not for a channel they can no longer read", async () => { + await register(bob, { all: [STAFF] }); + away(bob); + await send(carol, STAFF, line("staff talk")); + assert.deepEqual(await woken(), []); + }); + + it("muted wins when a phone sends both", async () => { + await register(carol, { muted: [OPEN], all: [OPEN] }); + away(carol); + await send(alice, OPEN, line("muted and loud")); + assert.deepEqual(await woken(), []); + }); + + it("not somebody who blocked the sender", async () => { + await blockUser(erin.grytUserId, alice.grytUserId); + await register(erin, { all: [OPEN] }); + away(erin); + await send(alice, OPEN, line("blocked and loud")); + assert.deepEqual(await woken(), []); + }); + + it("refuses a setting that isn't a list of ids or a yes or no", async () => { + const reply = await new Promise((resolve) => + carol.handlers["push:register"]( + { accessToken: carol.accessToken, installId: "install-Carol", capability: carol.capability, all: "general", everyone: "yes" }, + resolve, + ), + ); + assert.deepEqual(reply, { ok: false, error: "invalid_payload" }); + }); +}); + describe("direct messages", () => { it("wake the other person and never the sender", async () => { const dm = await openDm(alice, dave); @@ -349,7 +428,7 @@ describe("taking it back", () => { describe("push:register", () => { it("refuses a muted list that isn't a short list of ids", async () => { - for (const muted of ["general", [1, 2], Array.from({ length: 1001 }, (_, i) => `c${i}`), ["x".repeat(129)]]) { + for (const muted of ["general", [1, 2], Array.from({ length: 2001 }, (_, i) => `c${i}`), ["x".repeat(129)]]) { const reply = await new Promise((resolve) => carol.handlers["push:register"]({ accessToken: carol.accessToken, installId: "install-Carol", capability: carol.capability, muted }, resolve), );