# 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.