From af46f47bba1484845ef897abf8892aed00d1f87a Mon Sep 17 00:00:00 2001 From: Gintautas Kazlauskas Date: Sat, 17 Oct 2026 21:38:06 +0300 Subject: [PATCH 1/5] fix: emoji picker as a bottom sheet on mobile Full width behind a dimmed backdrop instead of a 340px popover clamped to the left edge, no keyboard on open, finger-sized cells and tone buttons, and lifted above the keyboard while searching. Co-Authored-By: Claude Opus 5.5 --- src/components/EmojiPicker.tsx | 194 +++++++++++++++++++++++++-------- 1 file changed, 151 insertions(+), 43 deletions(-) diff --git a/src/components/EmojiPicker.tsx b/src/components/EmojiPicker.tsx index 3e04b5a..c06d3d3 100644 --- a/src/components/EmojiPicker.tsx +++ b/src/components/EmojiPicker.tsx @@ -9,6 +9,10 @@ * then the room's, then stickers if the caller can send them, then Unicode by * category. The rail across the top jumps between them and search runs over all * of them at once, so a shortcode and an emoji name are found the same way. + * + * On a phone it is a sheet up from the bottom edge instead: full width, behind + * a dimmed backdrop, and without grabbing the keyboard. A popover sized for a + * pointer ends up off-centre and half under the keyboard on a 390px screen. */ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -33,6 +37,7 @@ import { } from "../lib/packs"; import type { ImagePack, PackImage } from "../lib/types"; import { useStore } from "../store"; +import { dismissKeyboard, useIsMobile } from "../lib/viewport"; import { Icon } from "./ui"; import { corner, space, textSize } from "../lib/scale"; @@ -78,10 +83,15 @@ export function EmojiPicker({ const root = useRef(null); const scroller = useRef(null); const search = useRef(null); + const mobile = useIsMobile(); + // On a phone, focusing the search raises the keyboard over the very grid you + // opened this to tap — so there it waits until you ask for it, and a keyboard + // already up for the composer is put away. useEffect(() => { - search.current?.focus(); - }, []); + if (mobile) dismissKeyboard(); + else search.current?.focus(); + }, [mobile]); // Escape closes, and so does a press anywhere else. Pointerdown rather than // click so a press that starts outside can't also land on something inside. @@ -148,6 +158,7 @@ export function EmojiPicker({ }, [query, lookup]); const position = usePlacement(anchor); + const keyboard = useKeyboardInset(mobile); function pickEmoji(emoji: Emoji) { onPick({ @@ -181,27 +192,58 @@ export function EmojiPicker({ ?.scrollIntoView({ block: "start" }); } - return ( + const frame: React.CSSProperties = mobile + ? { + position: "fixed", + left: 0, + right: 0, + bottom: keyboard, + // Room for the grid above the keyboard when searching, and about half + // the screen otherwise — enough to browse, while the conversation + // stays visible above it. + height: keyboard > 0 ? `min(60dvh, calc(100dvh - ${keyboard}px - 24px))` : "60dvh", + borderTopLeftRadius: 22, + borderTopRightRadius: 22, + borderTop: "1px solid var(--border-default)", + } + : { + position: "fixed", + ...position, + width: WIDTH, + height: HEIGHT, + borderRadius: corner(20), + border: "1px solid var(--border-default)", + }; + + const picker = (
-
+ {mobile && ( +
+ )} +
- + ))}
)} -
+
{results ? ( updateSettings({ skinTone: tone })} + // Nothing hovers on touch, so the name preview would only ever say + // "pick something~". The tones are still worth their row. + touch={mobile} />
); + + if (!mobile) return picker; + + // The backdrop sits outside `root`, so a tap on it is a press "anywhere + // else" and closes the sheet through the same listener as on desktop — and + // it soaks up the click, so that tap doesn't also land on a message. + return ( + <> +
+ {picker} + + ); +} + +/** + * How much of the bottom of the layout viewport the keyboard covers, in px. + * + * Zero when the web view shrinks for the keyboard itself, and zero off a phone, + * so a bottom-anchored sheet can simply sit this far up and stay above it. + */ +function useKeyboardInset(enabled: boolean): number { + const [inset, setInset] = useState(0); + + useEffect(() => { + const vv = window.visualViewport; + if (!enabled || !vv) return; + + const update = () => + setInset(Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop))); + + update(); + vv.addEventListener("resize", update); + vv.addEventListener("scroll", update); + return () => { + vv.removeEventListener("resize", update); + vv.removeEventListener("scroll", update); + }; + }, [enabled]); + + return inset; } // --------------------------------------------------------------------------- @@ -571,7 +668,7 @@ function Cell({ title={label} aria-label={label} style={{ - height: 34, + height: "var(--emoji-cell, 34px)", borderRadius: corner(8), fontSize: textSize(20), lineHeight: 1, @@ -593,10 +690,13 @@ function Footer({ hovered, skinTone, onTone, + touch = false, }: { hovered: Picked | null; skinTone: SkinTone; onTone: (tone: SkinTone) => void; + /** Bigger tone targets, no hover preview, and clear of the home indicator. */ + touch?: boolean; }) { const label = hovered === null @@ -611,36 +711,44 @@ function Footer({ display: "flex", alignItems: "center", gap: space(8), - padding: space("7px 12px"), + padding: touch ? space("6px 12px calc(var(--safe-bottom) + 6px)") : space("7px 12px"), borderTop: "1px solid var(--border-subtle)", background: "var(--surface-card)", }} > - - {hovered === null ? ( - "🫧" - ) : hovered.kind === "unicode" ? ( - hovered.emoji - ) : ( - - )} - - - {label} - + {touch ? ( + + skin tone + + ) : ( + <> + + {hovered === null ? ( + "🫧" + ) : hovered.kind === "unicode" ? ( + hovered.emoji + ) : ( + + )} + + + {label} + + + )} -
+
{TONE_SAMPLES.map((sample, tone) => ( )} -
- - {!room.isDirect && ( - +
+ - )} - {room.dmUserId && ( - - )} -
- -
-
- - {room.name} - - {/* A lock is the whole message. Spelling out "e2e" beside it said - the same thing twice, and did it in the row that has the least - space to spare. The title carries the detail for anyone who - wants it. */} - {room.isEncrypted && ( - - - + {!room.isDirect && ( + + )} + {room.dmUserId && ( + )}
- {/* A DM's subtitle is the person, not the room: a topic and an - alias are things group rooms have. */} - {room.dmUserId ? ( - - ) : ( + +
- {room.topic}{" "} - {room.canonicalAlias ?? ""} + {room.name} + {/* A lock is the whole message. Spelling out "e2e" beside it said + the same thing twice, and did it in the row that has the least + space to spare. The title carries the detail for anyone who + wants it. */} + {room.isEncrypted && ( + + + + )}
- )} -
+ {/* A DM's subtitle is the person, not the room: a topic and an + alias are things group rooms have. */} + {room.dmUserId ? ( + + ) : ( +
+ {room.topic}{" "} + + {room.canonicalAlias ?? ""} + +
+ )} +
+
v danger={inThisCall} onClick={() => void toggleCall()} /> - + {!onBack && ( + + )}
@@ -390,3 +406,29 @@ export function EmptyPane() {
); } + +/** + * The avatar and name in the header: a button into room info on a phone, plain + * layout on desktop, where the info button does that job. + */ +function Identity({ + onOpen, + style, + children, +}: { + onOpen?: () => void; + style: React.CSSProperties; + children: React.ReactNode; +}) { + if (!onOpen) return
{children}
; + + return ( + + ); +} From c26851b36c78158b74268111cff81c97f0d9cd11 Mon Sep 17 00:00:00 2001 From: Gintautas Kazlauskas Date: Sun, 18 Oct 2026 06:43:14 +0300 Subject: [PATCH 4/5] feat: haptic feedback for reactions on mobile A light impact on every reaction (chip, action sheet, picker) and a medium one when a long-press opens the message sheet. Uses tauri-plugin-haptics, compiled and permitted on Android and iOS only. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 16 +++++++++++++--- package.json | 1 + src-tauri/Cargo.lock | 15 +++++++++++++++ src-tauri/Cargo.toml | 6 ++++++ src-tauri/capabilities/mobile.json | 9 +++++++++ src-tauri/src/lib.rs | 4 ++++ src/components/MessageRow.tsx | 12 +++++++++++- src/lib/haptics.ts | 13 +++++++++++++ 8 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 src-tauri/capabilities/mobile.json create mode 100644 src/lib/haptics.ts diff --git a/package-lock.json b/package-lock.json index ed03751..103b935 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@phosphor-icons/web": "^2.1.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.2", + "@tauri-apps/plugin-haptics": "^2.4.2", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", @@ -1266,9 +1267,9 @@ "license": "MIT" }, "node_modules/@tauri-apps/api": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", - "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.4.tgz", + "integrity": "sha512-pVmfis+4zT8H0Qhp3xPZx9Ju7kpzvA6rr/PSJPyuwfvucTQ8sVt61NYU9KMkK703UFlYadFmjKA4HhybOtN0gg==", "license": "Apache-2.0 OR MIT", "funding": { "type": "opencollective", @@ -1501,6 +1502,15 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@tauri-apps/plugin-haptics": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-haptics/-/plugin-haptics-2.4.2.tgz", + "integrity": "sha512-ddoEZuUWxJORcZE6AzeRUEHx4WljV9X4gXU+r08pTfX5YTwGMTIoJcLzMV9WHZIWJVWthktKAolLVaedbZ4dhA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.12.0" + } + }, "node_modules/@tauri-apps/plugin-notification": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", diff --git a/package.json b/package.json index b2c9e97..b074289 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@phosphor-icons/web": "^2.1.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.2", + "@tauri-apps/plugin-haptics": "^2.4.2", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1d2bcc5..64af2eb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2265,6 +2265,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-haptics", "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-process", @@ -5967,6 +5968,20 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-haptics" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67ab59e8202362fb5fec1158bdb497466983ddac2483e606e4d5c1bf9b118c6c" +dependencies = [ + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", +] + [[package]] name = "tauri-plugin-notification" version = "2.3.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index af562b6..124d7b4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -85,6 +85,12 @@ tauri-plugin-updater = "2" # Only for `restart`, which is how an installed update actually takes effect. tauri-plugin-process = "2" +# Haptics are a phone thing: the plugin has no desktop implementation, and the +# web view on iOS has no `navigator.vibrate` to fall back on. Its capability is +# gated the same way — see `capabilities/mobile.json`. +[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] +tauri-plugin-haptics = "2" + # Reaching into the WebView to switch WebRTC on (see `enable_webrtc` in lib.rs). # The version is pinned to the exact one wry depends on — `with_webview` hands diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json new file mode 100644 index 0000000..74616b1 --- /dev/null +++ b/src-tauri/capabilities/mobile.json @@ -0,0 +1,9 @@ +{ + "$schema": "../gen/schemas/mobile-schema.json", + "identifier": "mobile", + "description": "What only a phone build can ask for.", + "comment": "Separate from `default` because of `platforms`: the haptics plugin is compiled on Android and iOS only (see Cargo.toml), and a capability naming permissions from a plugin that isn't registered fails at startup rather than being ignored.", + "platforms": ["iOS", "android"], + "windows": ["main"], + "permissions": ["haptics:default"] +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0af8f6c..f5e83a8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -39,6 +39,10 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()); + // The mirror image: haptics only exist on a phone (see Cargo.toml). + #[cfg(mobile)] + let builder = builder.plugin(tauri_plugin_haptics::init()); + builder .manage(AppState::default()) .register_asynchronous_uri_scheme_protocol("horo", handle_media_request) diff --git a/src/components/MessageRow.tsx b/src/components/MessageRow.tsx index 3636200..73c17e4 100644 --- a/src/components/MessageRow.tsx +++ b/src/components/MessageRow.tsx @@ -16,6 +16,7 @@ import { saveAttachment } from "../lib/download"; import { imageLookup, reactionImage, reactionKeyFor } from "../lib/packs"; import { linkify, renderFormattedBody } from "../lib/richText"; import type { Content, EventItem, MediaInfo, PackImage } from "../lib/types"; +import { haptic } from "../lib/haptics"; import { useIsMobile, useLongPress } from "../lib/viewport"; import { useStore } from "../store"; import { EmojiPicker } from "./EmojiPicker"; @@ -59,7 +60,13 @@ export function MessageRow({ /** The touch action sheet, which stands in for the hover bar. */ const [sheetOpen, setSheetOpen] = useState(false); const longPress = useLongPress( - isMobile && item.eventId ? () => setSheetOpen(true) : undefined, + isMobile && item.eventId + ? () => { + // The bump iOS gives a context menu, so the hold is felt landing. + haptic("medium"); + setSheetOpen(true); + } + : undefined, ); const author = displayNameFor(item.sender, item.senderName); @@ -123,6 +130,9 @@ export function MessageRow({ async function react(key: string) { if (!item.eventId) return; setPickerAnchor(null); + // Every way of reacting ends here — a chip, the sheet, the picker — so + // this is the one place that makes it felt on a phone. + if (isMobile) haptic(); // Remembered even if the send fails: picking it is the signal about what // this person reaches for, and a failure is usually the network, not the // choice. diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts new file mode 100644 index 0000000..a5bf208 --- /dev/null +++ b/src/lib/haptics.ts @@ -0,0 +1,13 @@ +/** + * A tap you can feel, on a phone. + * + * The plugin is only compiled into the Android and iOS builds (see Cargo.toml), + * so on desktop the call rejects with "plugin not found". Feedback is never + * worth an error banner, so every failure is swallowed — callers fire and + * forget. + */ +import { impactFeedback, type ImpactFeedbackStyle } from "@tauri-apps/plugin-haptics"; + +export function haptic(style: ImpactFeedbackStyle = "light"): void { + impactFeedback(style).catch(() => {}); +} From 0ea1890459b1bf7b46696b0c33a34ee3d9c15beb Mon Sep 17 00:00:00 2001 From: Gintautas Kazlauskas Date: Mon, 19 Oct 2026 00:38:02 +0300 Subject: [PATCH 5/5] fix: keep double-click selections inside the message WebKit's word/paragraph selection doesn't stop at the edge of a .selectable island in the user-select:none body, so multi-clicking the last message ran the selection into the composer and copied the draft. Clamp double/triple-click selections to the element they started in. Co-Authored-By: Claude Opus 5.5 --- src/lib/selection.ts | 32 ++++++++++++++++++++++++++++++++ src/main.tsx | 3 +++ 2 files changed, 35 insertions(+) create mode 100644 src/lib/selection.ts diff --git a/src/lib/selection.ts b/src/lib/selection.ts new file mode 100644 index 0000000..2c858e1 --- /dev/null +++ b/src/lib/selection.ts @@ -0,0 +1,32 @@ +/** + * Keep double/triple-click selections inside the text they started in. + * + * The body is `user-select: none` with `.selectable` islands, and WebKit's + * word/paragraph granularity doesn't treat an island's edge as a boundary: + * multi-clicking the last message runs the selection on through the + * unselectable chrome into the next selectable thing — the composer — so ⌘C + * copies the draft along with the message. A drag (single click) is left + * alone, since selecting across several messages on purpose is fine. + */ +export function installSelectionClamp(): void { + document.addEventListener("mouseup", (e) => { + if (e.detail < 2) return; + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return; + + const anchor = sel.anchorNode; + const el = anchor instanceof Element ? anchor : anchor?.parentElement; + const box = el?.closest(".selectable"); + if (!box) return; + + const range = sel.getRangeAt(0); + const startsInside = box.contains(range.startContainer); + const endsInside = box.contains(range.endContainer); + if (startsInside && endsInside) return; + + if (!startsInside) range.setStart(box, 0); + if (!endsInside) range.setEnd(box, box.childNodes.length); + sel.removeAllRanges(); + sel.addRange(range); + }); +} diff --git a/src/main.tsx b/src/main.tsx index 01fc06e..d7fe7aa 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -18,8 +18,11 @@ import "@phosphor-icons/web/fill"; import App from "./App"; import { ErrorBoundary } from "./components/ErrorBoundary"; +import { installSelectionClamp } from "./lib/selection"; import "./styles/index.css"; +installSelectionClamp(); + ReactDOM.createRoot(document.getElementById("root")!).render(