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 844a44f..b074289 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "app:build": "tauri build", "android": "node scripts/android.mjs dev", "android:build": "node scripts/android.mjs build", - "ios": "tauri ios dev", - "ios:build": "tauri ios build", + "ios": "PATH=\"$PWD/scripts/ios-shim:$PATH\" tauri ios dev", + "ios:build": "PATH=\"$PWD/scripts/ios-shim:$PATH\" tauri ios build", "test": "vitest run", "bindings": "node scripts/bindings.mjs", "test:watch": "vitest", @@ -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/scripts/ios-shim/swift b/scripts/ios-shim/swift new file mode 100755 index 0000000..4b7dead --- /dev/null +++ b/scripts/ios-shim/swift @@ -0,0 +1,13 @@ +#!/bin/sh +# Stand-in for `swift`, first on PATH for the iOS scripts in package.json. +# +# swift-rs 1.0.7 builds Tauri's Swift package with `swift build`, and SwiftPM +# 6.4's default swift-build backend appends its own macOS `-sdk`/`-target` +# after swift-rs's iOS ones, so the package compiles against AppKit and fails +# on 'OpenGLES/EAGL.h'. The old backend honours the overrides. Drop this once +# swift-rs is fixed upstream. +if [ "$1" = "build" ]; then + shift + exec /usr/bin/swift build --build-system native "$@" +fi +exec /usr/bin/swift "$@" 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/ChatPane.tsx b/src/components/ChatPane.tsx index d3e2582..eeeef03 100644 --- a/src/components/ChatPane.tsx +++ b/src/components/ChatPane.tsx @@ -96,99 +96,113 @@ export function ChatPane({ room, onBack }: { room: RoomSummary; onBack?: () => v )} -
- - {!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 ( + + ); +} 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) => (