monal-rebuild/UX-SWIPE-RESULTS.md
pijukka 85cb943f44 fix(ux): swipe each bubble towards the middle to quote it
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>
2026-10-08 19:08:39 +02:00

8.6 KiB
Raw Permalink Blame History

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