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>
164 lines
8.6 KiB
Markdown
164 lines
8.6 KiB
Markdown
# 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.
|