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

164 lines
8.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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