Skip to content

feat: reply to chat messages straight from the notification - #75

Merged
GSTJ merged 3 commits into
mainfrom
GSTJ/chat-notifications
Jul 27, 2026
Merged

feat: reply to chat messages straight from the notification#75
GSTJ merged 3 commits into
mainfrom
GSTJ/chat-notifications

Conversation

@GSTJ

@GSTJ GSTJ commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Summary

Chat pushes get a Reply action you can answer without opening the app, land on their own "Messages" channel on Android, and render iMessage-style on iOS with the sender dog's photo.

Details

Reply action

  • Notification category with a text-input Reply action on both platforms, localised (Reply / Responder). The server tags chat pushes with the matching categoryId, which is the real expo-server-sdk field name; the docs' categoryIdentifier isn't on the type.
  • The reply goes through the same message.send mutation the chat screen uses, from a root-level listener, so no chat screen has to be mounted.
  • Replying from a killed app used to drop the message on the floor. Native buffers the launch response and replays it into the module the moment it's created, before any JS listener exists, so the emitted event goes nowhere and only getLastNotificationResponseAsync still has it. That path parsed the deep link and navigated into the chat without ever sending. Reply responses are handled there too now.
  • A failed send raises a local notification. Submitting the reply dismisses the original, so the in-app "failed" bubble has no off-screen equivalent and the message was vanishing with nothing but an error report behind it.
  • The swipe screen's separate listener skips reply responses so nothing double-fires.

Android channel

  • Dedicated channel for chat messages with a localised name. Channel, category and action ids live in @pegada/shared, so the push sender and the app that registers them read the same values.

iOS communication notifications (this is #80, merged into this branch)

  • Notification Service Extension in Swift, via @bacons/apple-targets, that intercepts chat pushes, downloads the sender dog's avatar and donates an INSendMessageIntent.
  • Falls back to the untouched notification on anything going wrong: missing fields, avatar download, intent donation, the extension's time budget.
  • The server marks chat pushes mutable-content and ships senderName and senderAvatarUrl, using only the first APPROVED photo, which also stops unapproved sender images leaking into the payload.
  • Notifications group per conversation on the match id.

Before this ships

  • com.apple.developer.usernotifications.communication has to be enabled on both App IDs, the app and the notification service, in the Apple Developer portal. EAS in non-interactive mode can only reuse an existing provisioning profile, so a release build fails to sign until that's done. I'm the only one with portal access, so that's on me.
  • Native changed, so this needs a binary. An OTA won't pick it up. main is already on 1.5.0 with no 1.5.0 build behind it, so this rides along with whatever ships that version.

Proof

iPhone 17 Pro Max simulator, iOS 26.5, Release build, with EXPO_PUBLIC_API_URL pointed at a local stub that logs every tRPC call.

Chat push arriving with the app backgrounded, and the same notification sitting on the lock screen:

chat push banner
chat push on the lock screen

The reply itself I drove from a temporary in-app button that hands handleReplyAction the response object expo-notifications produces for a text-input action. The simulator's SpringBoard ignores synthesised touches on a banner, so nothing can type into the real Reply field from a script. That covers everything downstream of the handler getting a reply response, including the request leaving the device:

[2026-07-26T23:59:43.566Z] POST /api/trpc/message.send?batch=1
  body: {"0":{"json":{"matchId":"match-1","content":"on my way"}}}

reply handler run

The gesture itself still wants a real device.

Testing steps

Needs a real device and a fresh install, since Android only registers the new channel on first launch.

  1. From a second test account, send yourself a chat message with the app in the background.
  2. Expand the notification, type an answer, send it from there. The other account receives it and your phone stays where it was.
  3. Force-close the app and reply from the notification again. The app opens briefly, which is an expo-notifications limitation, and the message still goes out.
  4. Turn on airplane mode and reply. You should get a "your reply didn't send" notification.
  5. Tap the body of a chat notification: the app opens that exact conversation.
  6. iOS: the notification shows the sender dog's photo and name, iMessage-style, and several messages from two matches stack per conversation.
  7. Android: chat messages appear under their own "Messages" category in the app's notification settings.

GSTJ added 3 commits July 26, 2026 20:15
Chat-message pushes now use a dedicated Android "messages" channel and
carry a "Reply" text-input action on both platforms, so the user can
answer without opening the app. The reply is sent through the existing
message.send tRPC mutation from a root-level listener so it works
whether the app is foregrounded or backgrounded. Tapping the
notification body still deep-links into the right conversation
(existing behavior, verified working, left untouched).

Server sets channelId/categoryId on the push payload; no native config
changed, so no version bump.
… dog's photo (#80)

* feat(mobile): render chat pushes as iOS communication notifications

Notification Service Extension (Swift, via @bacons/apple-targets) that
intercepts chat pushes, downloads the sender dog's avatar and donates an
INSendMessageIntent so iOS styles them like iMessage/WhatsApp messages.

- server marks chat pushes mutable-content and ships senderName +
  senderAvatarUrl (first approved photo) in the data payload
- communication entitlement wired on both the app and the extension
- NSE always falls back to the untouched notification on any failure
  (missing fields, avatar download, intent donation)

Stacks on GSTJ/chat-notifications (reply action + messages channel).

* chore: dedupe lockfile
The root listener never sees the response that launched the app. Native
buffers it and replays it into the module the moment it is created, which
is before any JS listener exists, so the event goes nowhere and only
`getLastNotificationResponseAsync` still has it. Replying from a
force-closed app therefore parsed the deep link, navigated into the chat
and dropped the typed message on the floor. Route reply responses from
there too.

A failed send now raises a local notification. Submitting the reply
dismisses the original notification, so the in-app "failed" bubble has no
equivalent once the app is off-screen and the message was vanishing with
nothing but an error report behind it.

Channel, category and action ids move to @pegada/shared, so the push
sender and the app that registers them can no longer drift apart.

Claude-Session: https://claude.ai/code/session_01VfiCAFzqYFLkBWfQuQWCvK
@GSTJ
GSTJ force-pushed the GSTJ/chat-notifications branch from b5119be to 15ab9eb Compare July 26, 2026 23:21
@GSTJ

GSTJ commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto main and fixed one thing that made the headline feature not work.

The root listener never sees the response that launched the app. NotificationCenterManager on iOS buffers the launch response in pendingResponses and replays it into EmitterModule from its OnCreate, which happens before React has mounted anything, so sendEvent finds no JS listener and the event is dropped; only lastResponse survives. Android's NotificationsEmitter does the same via NotificationManager.addListener. So on both platforms a reply typed on a force-closed app only ever reaches getLastNotificationResponseAsync, and that path was parsing the deep link and navigating into the chat without sending anything. Testing step 3 in the original description was wrong. Reply responses are routed from there now.

Two smaller things while I was in here: a failed send raises a local notification instead of disappearing (iOS dismisses the original the moment you submit, so there's no bubble left to mark as failed), and the channel, category and action ids moved to @pegada/shared so MessageService and getPushNotificationToken read the same values.

The rebase also dropped a duplicate @bacons/apple-targets plugin entry and a duplicate dependency on it, both of which main had already added, and merged the communication entitlement next to the existing app-group one instead of replacing it.

pnpm --filter @pegada/mobile test:

PASS src/services/linking/handlers/reply.test.ts
  isReplyAction
    ✓ only claims a reply on the chat-message category
  getMatchIdFromUrl
    ✓ reads "chat/match-1/dog-2" as "match-1"
    ✓ reads "match/match-1/dog-2" as undefined
    ✓ reads undefined as undefined
  handleReplyAction
    ✓ sends the typed text to the match the push came from
    ✓ reports empty text instead of sending
    ✓ reports a non-chat url instead of sending
    ✓ tells the user when the send fails, since the notification is already gone

PASS src/views/(auth)/SignIn/components/HeroText/index.test.jsx
  ✓ renders en-US without React key warnings
  ✓ renders pt-BR without React key warnings

Test Suites: 2 passed, 2 total
Tests:       10 passed, 10 total

Plus pnpm typecheck (5 packages), oxlint (0 errors), oxfmt --check, pnpm dedupe --check, and the Release simulator build behind the screenshots in the description.

I couldn't verify the gesture itself. The simulator's SpringBoard ignores synthesised touches on a notification banner, so nothing can type into the Reply field from a script. The screenshots show the push arriving with its category, and the server log shows the request leaving the device once the handler gets a reply response, though I called the handler from a temporary button rather than the OS delivering it. Steps 1-4 still want a real device.

@GSTJ
GSTJ merged commit 69d0d85 into main Jul 27, 2026
11 checks passed
GSTJ added a commit that referenced this pull request Aug 2, 2026
…124)

* Revert "fix(mobile): give the widget and notification-service targets a team id (#109)"

This reverts commit 2777a62.

* Revert "feat: reply to chat messages straight from the notification (#75)"

This reverts commit 69d0d85.

* Revert "feat(mobile): home-screen quick actions (#76)"

This reverts commit 64210c9.

* Revert "feat(mobile): dark iOS icon variant and Android themed icon (#77)"

This reverts commit 7721cb0.

* Revert "feat: home-screen widgets with matches waiting for a reply (WidgetKit + Glance) (#83)"

This reverts commit b375c66.

* Revert "feat(mobile): add Liquid Glass to translucent overlays (#78)"

This reverts commit 49fbcb3.

* chore: sync lockfile after native feature reverts

* fix: point restored Glassmorphism at the renamed blur-view module

* chore: satisfy oxlint/oxfmt on the restored pre-glass files
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant