fix(p2): one tap on Send sends on phones

Pressing Send blurred the composer's textarea, so a phone's keyboard closed, the page grew back and the button moved
out from under the finger before the click: the first tap only closed the keyboard. Send and Stop now keep the focus
in the textarea on pointerdown; the click still fires and the keyboard stays open.
This commit is contained in:
Mario Zechner 2026-11-05 19:52:12 +01:00
parent 20d86c3657
commit 52e9c17b00
2 changed files with 55 additions and 2 deletions

View file

@ -408,12 +408,24 @@ function Composer({
{children}
<span className="spacer" />
{busy && onStop && (
<button type="button" className="round stop" aria-label="Stop" onClick={onStop}>
<button
type="button"
className="round stop"
aria-label="Stop"
onPointerDown={keepComposerFocus}
onClick={onStop}
>
<StopIcon />
</button>
)}
{(!busy || content) && (
<button type="submit" className="round send" aria-label="Send" disabled={!content || disabled}>
<button
type="submit"
className="round send"
aria-label="Send"
disabled={!content || disabled}
onPointerDown={keepComposerFocus}
>
<SendIcon />
</button>
)}
@ -422,6 +434,15 @@ function Composer({
);
}
/**
* Keeps the focus in the composer's textarea when Send or Stop is pressed. Without this the press blurs the textarea, a
* phone's keyboard closes, the page shrinks back and the button moves out from under the finger before the click: the
* first tap only closed the keyboard. The click still fires; the keyboard stays open for the next message.
*/
function keepComposerFocus(event: React.PointerEvent<HTMLButtonElement>): void {
event.preventDefault();
}
/** The composer's settings from the screen in the frame; the frame owns its textarea (`Frame.composer`). */
type ComposerProps = Omit<Parameters<typeof Composer>[0], "textareaRef">;

View file

@ -433,4 +433,36 @@ describe("Pi Mobile audit fixes in the browser", { skip: !browserTests }, () =>
);
hosts.release();
});
it("sends with one tap on Send: the press keeps the composer focused, so a phone's keyboard does not close first", async () => {
const session = await (await holdIn(owner, Sessions, {}, "owner")).create(
{ cwd: fixture.workspace, title: "Tap send" },
BACKGROUND_CONTEXT,
);
const chat = await holdIn(owner, Chat, { session: session.id, conversation: 1 }, "participant");
const stop = follow(chat.view, () => {});
try {
await browser.metrics(390, 844);
await browser.open(url(`/s/${session.id}`));
await browser.wait(`document.querySelector('.composer textarea') && !document.querySelector('.offline')`);
await browser.evaluate(`document.querySelector('.composer textarea').focus()`);
await browser.call("Input.insertText", { text: "tap send" });
await browser.wait(`!document.querySelector('.composer .send').disabled`);
const [x, y] = await browser.evaluate<[number, number]>(
`(() => { const r = document.querySelector('.composer .send').getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; })()`,
);
// Pressing Send leaves the focus in the textarea (blurring it would close a phone's keyboard and move the button).
await browser.call("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 });
assert.equal(
await browser.evaluate<boolean>(`document.activeElement === document.querySelector('.composer textarea')`),
true,
);
// Releasing it sends: one tap, no second press.
await browser.call("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 });
await until(chat.view, answered("Echo: tap send"), 30_000);
} finally {
stop();
chat.release();
}
});
});