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 ? (
+
+ ) : (
+
@@ -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}
+
+ >
+ )}
-