Push Notifications
Delivery is not this page's subject. Every notification in the repo — a message, a friend request, a reminder, a resource operation — is published once and delivered by one Function (notifications). What is specific to chat is who a message reaches, and that is the one question ProcessNotification asks a message-shaped resolver.
The send itself publishes unconditionally: the request path never asks whether anyone is subscribed, so no message send pays a recipient query.
Recipients
flowchart TD
M["Message published"] --> C["classifyMentions(message html)"]
C --> R["room members: notificationType = All<br/>OR a mention condition matches"]
M -->|"the send is a reply"| T["thread followers of the root<br/>minus unfollowed, minus Never"]
R --> U(("recipient user ids"))
T --> U
U --> D["getPushSubscriptionsForUsers"]
Recipients are resolved as user ids, not as device rows, and the devices are looked up once from the union. That ordering is what lets a recipient with no push subscription still receive the surfaces that do not need one, and it is why a follower the room already notifies is de-duplicated by a set rather than subtracted by a query.
getMessageRecipientUserIds(db, { message, partitionKey, threadRootRowKey, userId }):
- Parse mention
data-idattributes from the message HTML → split intoregularUserIds | @here | @everyone. - One SQL query over the room's members:
usersToRooms
LEFT JOIN userStatuses ON userId ← always joined (needed for @here)
WHERE roomId = partitionKey
AND userId != sender
AND (
notificationType = All
OR (DirectMessage AND userId IN regularIds)
OR (@everyone AND notificationType != Never)
OR (@here AND notificationType != Never AND status IN (Online, null))
)
userStatuses is always left-joined even when there is no @here mention, so the query shape stays consistent.
- When the send is a reply, union in the thread's followers — everyone following the root, minus the replier and minus anyone muted at room level. The follow model and its rules are thread follows.
A webhook message has no sender to exclude, so every All member is reached including the app user's own account.
NotificationType (on usersToRooms)
| Value | Behavior |
|---|---|
All | Notified for all messages in the room |
DirectMessage | Only when directly @mentioned by user ID |
Never | Muted — no notifications |
Rendering
The notification is shown as its author, resolved at delivery from the ids the event carries: the sender's per-room nickname when set (nicknames), otherwise their account name; a webhook message resolves the app user that posted on its behalf. The body is the message's first paragraph as plain text — a send that renders to nothing at all (an attachment-only message) resolves to no notification rather than an empty one.
A reply deep-links to the thread root rather than to itself: the thread is where the reply is read, and it is the one destination that is right for a room member and a thread follower alike.
Key files
| File | Role |
|---|---|
packages/db/src/services/notification/getMessageRecipientUserIds.ts | the room's recipients, thread followers unioned in |
packages/db/src/services/notification/getThreadFollowerUserIds.ts | the thread half of that union |
apps/functions/src/services/notification/resolveNotification.ts | the message case — body, deep link, recipients |
apps/web/server/services/message/createUserMessage.ts | publishes one notification per send, reply included |