Skip to main content

Push Suppression

A push exists to reach a person who is not looking. If the same account has the conversation open in a focused window on a laptop, the message is on a screen in front of them before the buzz arrives, and the phone in their pocket should stay quiet.

BetweenUs already refused the pushes it could refuse locally: PushGate on Android drops a push for the channel that phone is showing. What it cannot see is the other devices — a client only knows its own screen. So the fact has to reach a server, and the server has to apply it.

There are two halves to that, and this document covers both: the push that is never sent, because the conversation is open somewhere else, and the notification already showing, which goes away when the conversation is read somewhere else.


The rule, exactly​

A message.created push is not sent to any of an account's devices while any of that account's windows has that exact channel open and focused.

Every word of that is load-bearing:

  • Exact channel. Reading #general on server A silences nothing on server B. That is what makes the rule bearable rather than a mute switch.
  • Any window silences all devices. The rule is per account, not per device. A desktop open on the channel is why the phone stays quiet.
  • message.created only. message.deleted exists to remove a notification that has become a lie, so suppressing it would leave the lie standing, and call.handled removes a ringer, so suppressing it would leave a phone ringing at somebody who is already talking, or who has already said no. call.roster, call.ring, friend.* and server.member.added are not channel-scoped news — and a ring least of all: somebody aimed it at a person, so having their channel open is not a reason to swallow it.
  • Mentions are not an exception. You are looking at the message as it arrives. There is also no way to make them one: bodies are sealed with the channel key, so no service has ever seen the word @you — the server would have to wake the phone for every message in the channel and let the client decide, which is the whole saving given away.

What counts as focused​

Both halves, on every client:

Desktop / webAndroid
The channel is openchat.activeChannelIdConversation.visibleChannelId
The window has attentiondocument.hasFocus() && !document.hiddenAppForeground.visible

A desktop left open on #general behind a browser is not somebody reading #general. A phone locked mid-conversation is not either — its chat screen is still composed behind the lock screen, which is precisely the case that would otherwise go silent forever.

The strict reading is deliberate. The two failure modes are not symmetric: a wrong "they are reading it" is a message nobody is ever told about, and a wrong "they are not" is one redundant buzz.


How it travels​

The wire​

Two client frames on /ws/presence, typed in packages/shared-types:

{ type: 'channel.focus'; channelId: string }
{ type: 'channel.blur'; channelId: string }

channel.focus is permission-checked with the same VIEW_CHANNEL bar as typing.start. Without that check, anybody could silence a channel they cannot even see, for everybody who can.

There is no server frame in reply. Focus is not something other people are told about — it is not presence, and publishing "Ana is reading #general" would be a read receipt nobody asked for.

The storage​

presence:focus:<channelId> is a Redis sorted set of user ids scored by the moment each was last asserted — the same shape, and the same reasoning, as presence:online. Stale entries are dropped as a side effect of reading, and the key carries a 90-second TTL of its own, so a channel nobody is reading leaves nothing behind.

Three things clear an entry, in descending order of how much they are trusted:

  1. channel.blur. The window moved on and said so.
  2. The socket closing. The gateway remembers what each socket had focused and removes it — unless another live window of the same user is still on that channel, which would otherwise open a gap of up to one heartbeat in which the phone buzzes for a conversation that is open on a second screen right now.
  3. The score ageing past 90 seconds. The backstop for a socket that died in a way the server never saw. This is why clients re-send channel.focus every 60 seconds, and why they re-assert it on every reconnect: the server keeps nothing across connections.

The lookup​

notification-service calls GET /internal/presence/focus once per message, with the audience it has already computed, and subtracts the answer from the fan-out. Not a request per recipient: a fan-out is a batch.

It is asked after the preference filter — muted channels, muted people, notifications off — so a conversation everybody has muted costs no request at all.

The path is not routed by nginx. /api/v1/internal is not a path the gateway forwards, so this is reachable on the internal Docker network and nowhere else, which is also why it has no guard: there is no user here to authenticate, only another service. server-service already reaches presence the same way for online counts.

Reading Redis directly from notification-service was the alternative and is the wrong one. The key belongs to presence-service; a second reader is a second thing to change when the key changes, and CLAUDE.md §12 says services do not read each other's data.

Failing​

focusedAmong answers "nobody" for every failure it can have — a timeout (1.5s), a refused connection, a non-2xx, a body that is not JSON, a body with no list. The push then goes.

That is the only defensible direction. A missed notification is a message somebody never learns about; a redundant one is a buzz.


The notification that is already there​

Focus stops a push being sent. It does nothing about one that was sent an hour ago and is still sitting in a pocket — and reading the message on a laptop should take that away too, which is what every messenger does.

That is channel.read, and it rides on a marker every client already sets:

desktop opens #general
│
├── POST /api/v1/notifications/read ──► notification-service
│ upserts ChannelRead
│ publishes channel.read
│ │
│ PushService.onChannelRead
│ │
└──────────────────────────────────── FCM ─────┴──► every device of
(normal priority) that account

On arrival Android cancels that conversation's notification outright and clears its unread badge. Two details matter:

  • It cancels the whole thread, where message.deleted removes one line. "Read up to now" means nothing in that conversation is still unseen.
  • The badge is cleared without posting a marker back. Workspace.noteReadElsewhere is markRead minus the API call. With the call, every device would answer every other device's read with one of its own, for as long as they were all awake.

The reader's own device gets the push too and does nothing with it — it cleared its notification when the channel opened. Excluding it would mean the server knowing which device sent the marker, which it does not and has no reason to.

Desktop and web take the same marker off a socket​

They hold a live /ws/chat connection, so they need no push for this. The same channel.read event is already fanned out to the channel room by chat.gateway.ts, and stores/chat.ts was already receiving it and returning early on the account's own marker — correctly, since a read receipt is about who else has seen your message. That early return is where the dismissal hooks in.

What was missing on both surfaces was a handle on the notification. The browser leaned on the tag option to collapse a run of messages into one toast, and the Electron main process leaned on the OS to do the same; collapsing is not dismissing, and neither side had kept anything it could close. Each now holds a per-channel map, and the main process's is electron/notification-registry.ts, which has a check beside it because every rule in it fails silently:

  • A new notification for a channel closes the one it replaces, or a conversation ends up showing two.
  • A close event is matched by identity before it forgets an entry. The event for a notification that has already been replaced arrives after its replacement is registered, so forgetting by key alone evicts the live one and leaves the app unable to dismiss what is on screen.
  • An entry the OS closed is dropped, or the map grows for the life of the app.

Like Android, the window that did the reading also handles its own marker and finds nothing standing. Paying for that no-op is cheaper than a rule about which device sent what.


What is still decided on the client​

PushGate.shouldSuppress on Android is unchanged and still runs. It is the same rule applied a second time, locally, for the case no server can win: a push already in flight when the chat is opened. Belt and braces, and the braces cost nothing.


Files​

packages/shared-types/src/index.tsthe two frames
apps/services/presence-service/src/presence.store.tsfocus, blur, focusedAmong
apps/services/presence-service/src/presence.gateway.tsthe handlers, the heartbeat refresh, the disconnect
apps/services/presence-service/src/presence.controller.tsGET internal/presence/focus
apps/services/notification-service/src/push/focus.tsthe lookup, and its failure direction
apps/services/notification-service/src/push/push.service.tsonMessage subtracts the readers; onChannelRead cancels what is already showing
apps/services/notification-service/src/modules/notifications/notifications.service.tsmarkRead publishes channel.read
apps/android/app/src/main/java/.../notifications/PushService.kthandleRead cancels the thread and the badge
apps/desktop/src/services/channel-focus.tsdesktop and web (one app, one module)
apps/android/core/src/main/java/.../store/ChannelFocus.ktAndroid

focus.check.ts covers the lookup's failure direction; pnpm --filter @betweenus/notification-service check runs it.


Trying it​

Two clients, one account.

  1. Sign in on desktop and on a phone. Open #general on the desktop and leave the window focused.
  2. From a second account, send to #general. The phone must not buzz. The desktop shows the message.
  3. Click away from the desktop window — another application, not another channel — and send again. The phone buzzes.
  4. Focus the desktop again, open a different channel, and send to #general. The phone buzzes: a different channel is a different key.
  5. Kill the desktop process outright, and send within a minute and a half. The phone buzzes as soon as the entry ages out — this is the 90-second backstop, and it is the longest anything should ever stay silent for.
  6. For the other half: with the desktop closed, send to #general and let the phone buzz. Now open #general on the desktop. The phone's notification should disappear on its own, and its unread badge with it.

redis-cli zrange presence:focus:<channelId> 0 -1 withscores shows exactly who the server thinks is reading.