# Swipe to reply, and the notification flood — 2026-10-08
Two operator reports from the same session: one message produced three to
seven notifications on the iPhone, and quoting should work by dragging a
message aside, as in Telegram. They turned out to be unrelated — one is a
Prosody session problem on blunix, the other a client gesture.
## Too many notifications: measured, not guessed
Each incoming message was reaching the phone several times over. The cause
is in the Prosody journal, not in the client:
- A message to `lukas@0ff.space` at 17:26:46 was delivered to **14 c2s
sessions** of that account, all hibernating (since 2026-10-07 17:20
through 2026-10-08 15:26) and all from the same device — their resources
all end in the same SASL2 client id, `~aYjbeplyZ55c`.
- Three seconds later the relay logged **13 `Pushed to web.push.apple.com
(201)`** for that one message.
- It is not stale registrations: `push2_registrations/lukas.dat` holds
exactly one, `668d99ee-0048-43ac-9ecb-dbcf7651f6f4`, and
`mod_push2_same_device` has been keeping it that way since 2026-09-29.
All 14 sessions share it, and `mod_push2` pushes once per hibernated
session that carries a registration.
- Its own dedupe cannot catch this. The marker is a field on the stanza
(`stanza._push_notify2[registration_id]`), but `mod_smacks` queues a
`st.clone` per session (`mod_smacks.lua:219`) and `util.stanza`'s
`_clone` copies only name, attributes, namespaces and children — custom
fields are dropped. This is the same limitation `mod_push2` already
documents for its archive hook, reaching one hook further than the
comment claims.
- Why the sessions pile up: 0ff chat takes a fresh resource per connect,
and iOS kills and relaunches a home-screen app all day, so each
relaunch leaves another session hibernating for up to 72 hours
(`push_max_hibernation_timeout`). The archive hook is *not* a second
source here — with a session still alive it logs "Not pushing … (session
still alive)" and stays quiet.
The count varies with how many stale sessions exist at the time, which is
exactly the reported "3–7".
### The fix
`capabilities/xmpp/prosody/plugins/mod_push2_one_session.lua` on blunix:
when a client enables push, the account's *other* sessions claiming that
registration lose `push_registration_id`. The newest session of a device is
then the only one that pushes, on every path at once — smacks queue,
delayed ack, hibernation start, offline store, MAM archive.
Deliberately not done: destroying the stale sessions. Their queues would go
through offline handling, which pushes again. They expire with the
hibernation timeout, silently.
Known trade-off, written up in the plugin README: a client that *resumes* a
session it had already replaced with a fresh login loses push on it until
the next ``. 0ff chat logs in fresh and re-enables every time.
### Live on blunix, and what that did and did not prove
Deployed as **generation 560** (18:50). Generation 559 is worth recording:
it enabled the module but could not load it —
`Unable to load module 'push2_one_session'` — because the `.lua` file was
still untracked in Git at that moment, and a flake only copies tracked
files. Committing it and switching again fixed it; the live
`/etc/prosody/prosody.cfg.lua` now carries `push2_one_session` and the
file is in the built `plugin_paths`.
The module fires. Driven deliberately, because the Prosody restart had
wiped every hibernated session and left it nothing to do: two sessions of
one account with the same SASL2 user-agent id, both enabling push, produced
`push2_one_session: Took the push registration from 1 older session(s)`
at 18:57:07 and again at 18:59:47/48. A message to that account then
produced exactly one push attempt — the relay POSTed once and Apple
answered `400 BadWebPushToken`, which is the probe's fake endpoint, not a
fault. The probe's registration was removed again afterwards
(``); no failed pushes remain in the journal.
What this does **not** prove is the 13 → 1 reduction on the real phone,
because the probe's sessions never hibernated: `@xmpp/client` did not
negotiate resumption there, so killing the socket destroyed them instead
and the message took the offline path. The mechanism the module interrupts
is the one measured above; the count on the phone is the operator's
observation over the next hours. The journal line to watch for is
`older session(s) of lukas`.
Worth knowing: between 18:50:16 and 18:50:27, on generation 559 with the
module not loaded, the phone had already enabled push twice from the same
device. The pile-up starts within seconds of a reload.
## Swipe to reply
Dragging a bubble aside now quotes it, as in Telegram. The gesture sits on
the message row in `src/ui/Chat.svelte` and ends in the same
`selectReply` the footer button calls, so the reply wire format, the
preview and the OMEMO fallback are unchanged.
The decisions behind it:
- **Each bubble travels towards the middle**: an incoming one right, your
own left. The first version dragged everything right, which the operator
found wrong on their own messages — those sit on the right edge, so
dragging them further right pushes them off their own side. Dragging
away from the side a bubble belongs to is the motion that reads
correctly for both. A drag towards a bubble's own side does nothing.
- **Touch and pen only.** A mouse drag across a bubble is a text
selection, so `pointerType === "mouse"` is ignored. The footer's "Reply"
button stays for pointer and keyboard; the gesture is never the only
route.
- **56 px commits, 76 px is the end of the travel**, with the bubble giving
way at a third of the speed past the trigger so a thumb feels the stop.
`navigator.vibrate(8)` fires once on arming (iOS Safari ignores it).
- **10 px of slop decides the axis.** Below it nothing moves; a mostly
vertical drag hands the gesture back to the scroller and a leftward one
is abandoned. `touch-action: pan-y` on the row keeps vertical panning
with the browser.
- **28 px at each screen edge are left alone**, because iOS puts its own
back and forward gestures there: the left edge is guarded for incoming
bubbles, the right edge for your own.
- **The arrow appears on the side the bubble is leaving.** It is a
zero-width flex marker with an absolutely positioned glyph, so revealing
it never moves the bubble or anything around it.
- **The synthesised click after a committed drag is swallowed** for 400 ms,
so a swipe that happens to cross a link or a quote does not also open it.
### Checks
`ux-swipe-runner.cjs`, Chromium on a 402×874 touch viewport against a local
`npm run preview` of the production build, websocket proxied to the real
server. Touch is driven through CDP `Input.dispatchTouchEvent`: Playwright's
mouse would be ignored by design, and only real touch yields the pointer id
`setPointerCapture` needs.
The incoming fixture is sent over XMPP by the second audit account, so
neither direction depends on what happens to be in the history.
All eleven pass, zero page errors: an incoming bubble dragged 64 px right
quotes it and dragged left does nothing; your own dragged 64 px left quotes
it and dragged right does nothing; while the finger holds them the offsets
are exactly +40 px and −40 px, so each really moves towards the middle; a
30 px drag neither replies nor leaves a bubble offset; a vertical drag
scrolls the list (5380 → 5170) and does not reply; drags from either screen
edge are ignored; a swipe across a link quotes without opening it; the
resulting reply goes out as a `urn:xmpp:reply:0` stanza and the preview
clears; the Reply button is still there.
Held-swipe screenshots of both directions were read back rather than
assumed: the arrow sits outside the bubble on the side it is leaving, and
no other row shifts.
`npm run check` clean, 86 tests pass, `npm run build` succeeds.
One check failed on the first run and it was the runner, not the client:
the vertical drag started with the list pinned at the bottom and pulled the
finger *upwards*, where there is nothing left to scroll. It pulls downwards
into the history now. Same lesson as the group check in
`UX-MEDIA-RESULTS.md`: re-read a red run before believing what it says.
### Still open, and only the operator can close it
- The feel on the actual iPhone: travel, snap-back, and whether either
direction ever fights iOS's back- or forward-swipe in the installed PWA.
A Linux touch viewport cannot answer that.
- The notification count on the phone: one message, one notification.
- VoiceOver: the gesture is additive and the button remains, but the
swipe has no spoken equivalent of its own.