Mobile — Push Notifications
Push notifications in the ThinkWork mobile app are delivered through Expo’s push notification service, which wraps APNs for iOS. The server decides when to fire; the client handles permissions, registration, payload routing, and the cold-launch “user tapped a notification while the app was closed” case.
Registration flow
Section titled “Registration flow”Push registration happens automatically at the root layout, after sign-in:
- On mount of the authenticated layout,
usePushNotifications()checks the current permission state. - If permissions aren’t granted yet, the app asks. (This is the only permission prompt the mobile app makes at startup — everything else is asked on-demand.)
- Once granted, the app requests an Expo push token via
Notifications.getExpoPushTokenAsync(). - The token is sent to the backend through a
RegisterPushTokenMutationthat stores it alongside the user’s record.
From that point on, the backend can fire a push to this user any time the server-side policy decides to. The token is refreshed on next cold start if Expo rotates it.
Foreground behavior
Section titled “Foreground behavior”When a push lands while the app is in the foreground, iOS normally suppresses the banner. The mobile app overrides that with setNotificationHandler() so foreground pushes still show a banner and sound. The rationale: an agent firing a push while the user is on the Threads tab is surfacing something the user probably wants to see without losing the current screen.
Tap handling
Section titled “Tap handling”Tapping a push opens the app. What happens next depends on the tap context:
- App is already running — the notification-response listener fires with the tapped payload. The app pulls
threadIdout of the payload’sdatafield and routes to/thread/[threadId]. - App was backgrounded — same as above. iOS hands the tap off to the still-running process.
- App was closed (cold launch) — the app boots, the root layout mounts, and then
getLastNotificationResponseAsync()is called once to retrieve the cold-launch tap, if any. If there is one, the app routes into the target thread after completing auth hydration.
All three paths land in the same thread-detail screen, so the user always ends up looking at the thing the push was about.
Payload shape
Section titled “Payload shape”External-task pushes carry a small, predictable payload:
{ "title": "Task assigned to you", "body": "Deploy to staging — due Friday", "data": { "threadId": "<thread uuid>", "eventKind": "task.assigned" }}The title and body are truncated to 150 characters. The data.threadId is what the tap handler uses to route. The data.eventKind is an echo of the webhook event kind and is useful for debugging but is not currently rendered in the UI.
Other push sources (agent-initiated notifications, admin-initiated messages) use the same data.threadId contract so the tap handler is shared across all push types.
Quiet hours and user control
Section titled “Quiet hours and user control”v1 does not implement per-user quiet hours or category-level push toggles. Users who want to silence ThinkWork pushes use iOS’s standard per-app notification settings. A more granular in-app preferences screen (mute specific task integrations, set quiet hours, daily digest) is on the roadmap.
Testing push locally
Section titled “Testing push locally”Three options for testing pushes during development:
- Real device + Expo push — the easiest path. Run a development build on an iPhone, trigger an action on the server that fires a push, and watch it land.
- Simulator +
xcrun simctl push— send an APNs-shaped payload directly to a running simulator. Useful for testing payload parsing and tap routing without a real server. - Expo push tool — Expo’s web tool lets you send a test push to any token. Useful for sanity-checking that registration is working end-to-end.
For payload routing specifically, the simulator path is enough: the mobile app doesn’t care where the push came from as long as the data shape is right.
Known limits
Section titled “Known limits”- Android not yet shipped. Push notifications are iOS-only in v1. The Expo push wrapper would work on Android out of the box, but no production Android build is published.
- No per-integration mute. Users can’t silence one integration’s pushes while keeping another’s. This is an in-app preferences page on the roadmap.
- Cold-launch deep link does not restore scroll state. Tapping a push while the app is closed opens the target thread at its default scroll position, not wherever the user had previously scrolled.
Related pages
Section titled “Related pages”- Mobile — Threads & Chat — where the tap handler lands
- Mobile — Authentication — why push registration waits for sign-in