the browser bell and phone push are now two subscribers to one signal. both
ask shouldNotify(); neither owns the rule.
deliberately a second subscriber rather than a branch inside notify-delivery.
the two channels share only the question "does this account want to hear about
this" — the browser needs an open tab and sends the chat's real title over a
loopback socket on the owner's own tailnet, the phone needs no tab and must
send NO title, because that hop goes through apple. one function doing both
means an `if (isPush)` at every step, and the first thing to go wrong is a
title leaking because the branch was missed in one place.
two codes, officer/core:chat.turn-ended and chat.input-needed. the route is
written out with its scheme rather than derived from the target: the hub has
no chat screen — chat is Butler, a sibling app — so these deep-link OUT over
officer-chat://, and two of the suite's twenty-one schemes already break the
officer-<app> pattern (officer-videos, officer-readaloud). deriving that works
until it silently does not.
`thread` interpolates now, so `chat:{id}` groups per conversation. a static
thread forces a choice between "three pings from one chat collapse" and "two
busy chats stay apart", and the wrong answer is that every chat is the same
chat.
dropped officer/core:agent: chat.turn-ended supersedes it and its route
pointed at officer://chat/{id}, a hub screen that does not exist.
the banner cannot name which conversation. a chat's title is content and
content does not travel; it says what happened, and tapping opens the chat,
which is where the name lives.
54 lines
2.7 KiB
TypeScript
54 lines
2.7 KiB
TypeScript
import * as sidecar from '@@/sidecar-registry';
|
|
import { getNotifyServerUrl } from '../notify/router';
|
|
import { shouldNotify, type NotifyKind } from './notify-decision';
|
|
|
|
// The phone half of the bell: the same signal the browser socket listens to, delivered to APNs/FCM.
|
|
//
|
|
// ── Why this is a second subscriber and not a branch inside notify-delivery ──
|
|
//
|
|
// The two channels have nothing in common except the question "does this account want to hear about
|
|
// this". That question lives in `notify-decision.ts` and both ask it; everything else differs. The
|
|
// browser needs an open tab and sends the chat's real title over a loopback socket on the owner's own
|
|
// tailnet. The phone needs no tab at all and must send NO title, because that hop goes through Apple.
|
|
//
|
|
// Folding both into one function would mean a `if (isPush)` inside every step of it, and the first thing
|
|
// to go wrong would be a title leaking into a payload because the branch was missed in one place.
|
|
|
|
/** Which code a kind maps to. Both live in `notifications/catalogue.ts` as `officer/core:chat.*`. */
|
|
const CODE_FOR: Record<NotifyKind, string> = {
|
|
'turn-ended': 'officer/core:chat.turn-ended',
|
|
'input-needed': 'officer/core:chat.input-needed',
|
|
};
|
|
|
|
/**
|
|
* Subscribe the push channel to chat's notify signal.
|
|
*
|
|
* Called explicitly from `server.tsx` rather than run on import: a module whose only purpose is a side
|
|
* effect is one an unused-import sweep deletes, and the failure would be silent.
|
|
*/
|
|
export function registerChatPushNotifications(): void {
|
|
sidecar.onClaudeNotify(async (sessionKey, userId, kind) => {
|
|
try {
|
|
if (!(await shouldNotify(userId, sessionKey, kind))) return;
|
|
|
|
const base = getNotifyServerUrl();
|
|
// The sidecar has not reported in — either it is starting, or push is not running on this install.
|
|
// Not an error: the browser bell still works, and this is the channel that is allowed to be absent.
|
|
if (!base) return;
|
|
|
|
// `id` is the session key so the device can deep-link straight to the conversation. It is an
|
|
// opaque uuid, which is the only reason it may travel: NO title, and no message content, because
|
|
// this payload is read by Apple and Google. See docs/push-notifications.md.
|
|
await fetch(`${base}/_officer/notify`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', 'X-Officer-User': String(userId) },
|
|
body: JSON.stringify({ code: CODE_FOR[kind], userId, id: sessionKey }),
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
} catch (err) {
|
|
// A notification that fails must never disturb the turn that produced it.
|
|
console.error('[chat:push] failed to dispatch:', err instanceof Error ? err.message : err);
|
|
}
|
|
});
|
|
}
|