Dragging everything right was wrong for your own messages: they sit on the right edge, so the gesture pushed them off their own side. A bubble now travels away from the side it belongs to — incoming right, your own left — and a drag towards its own side does nothing. The arrow follows, on the side the bubble is leaving, as a zero-width marker with an absolutely positioned glyph so revealing it moves nothing. The edge guard became symmetric: 28 px at the left for incoming bubbles and at the right for your own, where iOS keeps its back and forward gestures. ux-swipe-runner.cjs now sends its incoming fixture over XMPP rather than relying on history, and covers both directions: 11/11 pass with zero page errors, the held offsets measuring exactly +40 px and -40 px. Held-swipe screenshots of both sides were read back. npm run check clean, 86 tests, build succeeds. UX-SWIPE-RESULTS.md also records the push side now live as blunix generation 560, including that generation 559 could not load the module because its file was still untracked and a flake copies only tracked files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.6 KiB
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.spaceat 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.datholds exactly one,668d99ee-0048-43ac-9ecb-dbcf7651f6f4, andmod_push2_same_devicehas been keeping it that way since 2026-09-29. All 14 sessions share it, andmod_push2pushes 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]), butmod_smacksqueues ast.cloneper session (mod_smacks.lua:219) andutil.stanza's_clonecopies only name, attributes, namespaces and children — custom fields are dropped. This is the same limitationmod_push2already 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 <enable/>. 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
(<disable/>); 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-yon 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.