feat(notification): deliver notifications to phones as well as sockets - #279
Merged
Conversation
A realtime socket only exists while the app is in the foreground - both mobile platforms close it the moment the app is backgrounded - so today a notification reaches a phone only if somebody happens to be looking at it. Every notification here follows the same two lines: store the row, emit new-notification. PushNotifyingRealtimeService wraps that emit and dispatches the push behind it, so notifications written later are delivered without anybody remembering to wire them up, and a dozen use cases stay untouched. DeviceToken.token is unique across the table rather than per user. A shared phone, or an account switched inside the app, produces the same token under a new user, and anything other than a move leaves one person's notifications arriving on another person's screen. Direct messages are deliberately not pushed. Their text is encrypted at rest and a preview in a push payload would route it through Google's servers; the payload carries ids and a type and nothing else. Dead tokens go two ways: Expo reports DeviceNotRegistered and those rows are deleted at once, while a phone that was simply abandoned is caught by a nightly sweep on lastSeenAt. PUSH_ENABLED is off by default, so devices register and nothing is delivered until there is a project behind it.
aquie00t
force-pushed
the
feature/push-notifications
branch
from
September 5, 2026 22:50
8ccf84f to
1980a09
Compare
github-actions Bot
pushed a commit
that referenced
this pull request
Sep 6, 2026
# [1.25.0](v1.24.0...v1.25.0) (2026-09-06) ### Features * **notification:** deliver notifications to phones as well as sockets ([#279](#279)) ([b6efdfa](b6efdfa))
|
🎉 This PR is included in version 1.25.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #274. Independent of #278 — both branch from
main.Summary
A realtime socket only exists while the app is in the foreground; both mobile platforms close it the moment the app is backgrounded. Push is the second transport, through Expo — which owns the FCM credentials, and the APNs ones when iOS arrives, all behind
PushPortso a direct FCM adapter is a sibling file rather than a rewrite.The seam is the emit, not the call sites. Every notification in this codebase is two lines: store the row, emit
new-notification.PushNotifyingRealtimeServiceis registered asrealtimeService, delegates to the socket transport and dispatchesSendPushNotificationUseCasebehind it. So the thirteenth notification somebody writes is delivered to phones without them knowing push exists, and a dozen use cases stay untouched. The socket is never held up — it is written first, and the push is fire-and-forget behind it.DeviceToken.tokenis unique across the table, not per user. A phone handed to somebody else, or an account switched inside the app, produces the same token under a new user; anything other than a move leaves one person's notifications arriving on another person's screen.POST /devicesregisters (call it at every launch — tokens get reissued, and it is what keeps the row from being swept),DELETE /devicesretires one, scoped to the owner because a push token travels through the app and is not a secret.Direct messages are not pushed at all. Their text is encrypted at rest, and putting even a truncated preview in a push payload would route it through Google's servers and undo that. Chat events share the realtime channel and the decorator ignores them by event name. The payload that does go out is ids and a type — nothing anybody wrote.
Copy is Turkish and English (
push-copy.ts), chosen from the device's locale rather than the profile's feed languages: a notification is read on a lock screen that is already in one language.Root cause
There was no way to reach a phone that was not being looked at, which makes an app that relies on notifications — messages, mentions, replies — largely useless in the background.
Dead tokens
Two mechanisms, because neither covers the other:
DeviceNotRegisteredin the ticket for that message; those rows are deleted as they are reported.DEVICE_RETENTION_DAYS(90). The app re-registers at every launch, so age is a sound signal here in a way it would not be for something a user does once.Not done, and stated plainly: Expo's receipts, which catch tokens that fail later at FCM rather than at ticket time. The retention sweep covers the same ground more slowly. It belongs in the roadmap rather than in this PR.
Tests
SendPushNotificationUseCase— one message per device, the device's own language, deep-link ids and only ids in the payload, nothing sent with no devices or an unnameable issuer, rejected tokens deleted, the table untouched when all are accepted, and a provider failure swallowed rather than surfaced.PushNotifyingRealtimeService— always emits on the socket, pushes a notification event, never pushes a chat event, ignores a malformed notification, and cannot leak an unhandled rejection.tests/e2e/device/device.test.ts: register, re-register, a device moving between accounts and the previous owner losing the ability to retire it, unknown platform, empty token, no session.tsc -p tsconfig.build.json --noEmit,eslint,prettier --checkclean.Rollout
PUSH_ENABLEDisfalseby default, which swaps in a service that sends nothing — devices register, nothing is delivered. So this merges and deploys with no Expo project in existence. Turning it on later needsPUSH_ENABLED=trueand, if the Expo project has push security enabled,EXPO_ACCESS_TOKEN.Migration
20260911000000_add_device_tokensadds one table and one enum, with a cascade on the user so a purged account cannot leave a live push token behind.AI Asistan: Opus 5