Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/desktop-window-chrome.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Desktop: unify the macOS titlebar (native traffic lights float over the content), add a custom titlebar with window controls on Linux, surface sync/connection status as a pill in the custom titlebar, and suppress the webview's default context menu everywhere except editable fields.
5 changes: 5 additions & 0 deletions .changeset/fix-overlapping-panels-mobile-swipe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Smoother mobile navigation and faster room opening.
5 changes: 5 additions & 0 deletions .changeset/mobile-native-ux.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Mobile: bridge the Android hardware back button to the app (closes overlays and pops routes, backgrounds instead of exiting), add haptic feedback on swipe-to-reply, chat swipe and drawer commits, set the iOS keyboard inset so the composer stays above the keyboard, and scroll the composer into view on focus.
3 changes: 3 additions & 0 deletions src-tauri/gen/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<!-- Haptic feedback (navigator.vibrate) -->
<uses-permission android:name="android.permission.VIBRATE" />

<!-- WebRTC calls (camera/mic) -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,47 @@ package moe.sable.client

import android.graphics.Color
import android.os.Bundle
import android.webkit.WebView
import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge
import androidx.core.view.WindowCompat

class MainActivity : TauriActivity() {
private external fun nativeInitStatusBar()

// Route the hardware back button through the web app (see onWebViewCreate).
override val handleBackNavigation: Boolean = false
private var webView: WebView? = null

override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
instance = this
runCatching { nativeInitStatusBar() }
}

override fun onWebViewCreate(webView: WebView) {
this.webView = webView
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val wv = this@MainActivity.webView
if (wv == null) {
moveTaskToBack(true)
return
}
// If the web app didn't consume the back press, background the app.
wv.evaluateJavascript(
"(typeof window.__sableAndroidBack === 'function' && window.__sableAndroidBack() === true)"
) { result ->
if (result != "true") moveTaskToBack(true)
}
}
}
)
}

override fun onDestroy() {
if (instance === this) instance = null
super.onDestroy()
Expand Down
32 changes: 32 additions & 0 deletions src-tauri/src/ios.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

use std::ffi::CString;

use objc2::rc::{Allocated, Retained};
use objc2::runtime::{AnyClass, AnyObject, ClassBuilder, Sel};
use objc2::{msg_send, sel};
use tauri::webview::WebviewWindow;
Expand All @@ -23,6 +24,37 @@ pub fn enable_swipe_back_navigation(window: &WebviewWindow<crate::BrowserEngine>
});
}

// UIFeedbackGenerator must run on the main thread, where Tauri schedules mobile
// commands.
#[tauri::command]
pub fn haptic_feedback(style: String) {
unsafe {
if style == "selection" {
let Some(cls) = AnyClass::get(c"UISelectionFeedbackGenerator") else {
return;
};
let allocated: Allocated<AnyObject> = msg_send![cls, alloc];
let generator: Retained<AnyObject> = msg_send![allocated, init];
let _: () = msg_send![&*generator, prepare];
let _: () = msg_send![&*generator, selectionChanged];
} else {
// UIImpactFeedbackStyle: light = 0, medium = 1, heavy = 2.
let intensity: isize = match style.as_str() {
"medium" => 1,
"heavy" => 2,
_ => 0,
};
let Some(cls) = AnyClass::get(c"UIImpactFeedbackGenerator") else {
return;
};
let allocated: Allocated<AnyObject> = msg_send![cls, alloc];
let generator: Retained<AnyObject> = msg_send![allocated, initWithStyle: intensity];
let _: () = msg_send![&*generator, prepare];
let _: () = msg_send![&*generator, impactOccurred];
}
}
}

pub fn hide_form_accessory_bar(window: &WebviewWindow<crate::BrowserEngine>) {
let _ = window.with_webview(|webview| unsafe {
let webview: *mut AnyObject = webview.inner().cast();
Expand Down
10 changes: 8 additions & 2 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,14 @@ pub fn show_or_create_main_window(app: &AppHandle<crate::BrowserEngine>) -> taur
.inner_size(1280.0, 720.0)
.visible(false);

// Float the native traffic lights over the content for a unified look.
#[cfg(target_os = "macos")]
let builder = builder.hidden_title(true);
let builder = builder
.hidden_title(true)
.title_bar_style(tauri::TitleBarStyle::Transparent);

#[cfg(target_os = "windows")]
// Windows and Linux draw their own titlebar (DesktopTitleBar).
#[cfg(any(target_os = "windows", target_os = "linux"))]
let builder = builder.decorations(false);

let _webview_window = builder.build()?;
Expand Down Expand Up @@ -327,6 +331,8 @@ pub fn run() {
mobile::set_status_bar_color,
#[cfg(target_os = "android")]
mobile::set_navigation_bar_color,
#[cfg(target_os = "ios")]
ios::haptic_feedback,
#[cfg(desktop)]
desktop::download::save_download,
#[cfg(desktop)]
Expand Down
9 changes: 4 additions & 5 deletions src/app/components/BackRouteHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { matchPath, useLocation, useNavigate } from 'react-router-dom';
import { resolveSection } from '$pages/pathUtils';
import { HOME_ROOM_PATH, DIRECT_ROOM_PATH, SPACE_ROOM_PATH } from '$pages/paths';
import { lastVisitedRoomAtom } from '$state/room/lastRoom';
import { isRoomAlias, isRoomId } from '$utils/matrix';

type BackRouteHandlerProps = {
children: (onBack: () => void) => ReactNode;
Expand All @@ -24,11 +25,9 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) {
.find((match) => match !== null);

const currentRoomIdOrAlias = roomMatch?.params.roomIdOrAlias;
if (section.getRoomPath && currentRoomIdOrAlias) {
setLastRoom({
section: section.key,
roomId: decodeURIComponent(currentRoomIdOrAlias),
});
const decoded = currentRoomIdOrAlias && decodeURIComponent(currentRoomIdOrAlias);
if (section.getRoomPath && decoded && (isRoomId(decoded) || isRoomAlias(decoded))) {
setLastRoom((prev) => ({ ...prev, [section.key]: decoded }));
}

navigate(section.listPath);
Expand Down
2 changes: 2 additions & 0 deletions src/app/components/SwipeableChatWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { animate, motion, useMotionValue } from 'framer-motion';
import { useDrag } from '@use-gesture/react';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom, RightSwipeAction } from '$state/settings';
import { haptic } from '$utils/haptics';
import { mobileOrTablet } from '$utils/user-agent';

interface SwipeableChatWrapperProps {
Expand Down Expand Up @@ -48,6 +49,7 @@ export function SwipeableChatWrapper({
const velocityThreshold = 0.5;

if (val < -swipeThreshold || (vx > velocityThreshold && dx < 0 && val < 0)) {
haptic('light');
if (rightSwipeAction === RightSwipeAction.Members) {
onOpenMembers?.();
} else {
Expand Down
7 changes: 6 additions & 1 deletion src/app/components/SwipeableMessageWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useMemo, useState } from 'react';
import { useAtomValue } from 'jotai';
import { config } from 'folds';
import { ArrowBendUpLeftIcon, getPhosphorIconSize } from '$components/icons/phosphor';
import { haptic } from '$utils/haptics';
import { mobileOrTablet } from '$utils/user-agent';
import { RightSwipeAction, settingsAtom } from '$state/settings';

Expand All @@ -19,7 +20,11 @@ function ActiveSwipeWrapper({ children, onReply }: { children: ReactNode; onRepl
if (active) {
const val = mx < 0 ? mx : 0;
x.set(Math.max(-80, val));
if (mx < -50 !== isReady) setIsReady(mx < -50);
const nextReady = mx < -50;
if (nextReady !== isReady) {
setIsReady(nextReady);
if (nextReady) haptic('selection');
}
} else {
if (mx < -50) onReply();
x.set(0);
Expand Down
14 changes: 8 additions & 6 deletions src/app/components/app-shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { isTauri } from '@tauri-apps/api/core';
import { type as osType } from '@tauri-apps/plugin-os';

import { TauriFrontendReady } from '$components/tauri/TauriFrontendReady';
import { WindowsTitleBar } from '$components/tauri/WindowsTitleBar';
import { DesktopTitleBar } from '$components/tauri/DesktopTitleBar';
import { MacTitleBar } from '$components/tauri/MacTitleBar';
import { Toast } from '$components/toast/Toast';
import type { ScreenSize } from '$hooks/useScreenSize';
import { ScreenSizeProvider } from '$hooks/useScreenSize';
Expand All @@ -27,11 +28,11 @@ type AppShellProps = {

export function AppShell({ children, queryClient, screenSize }: AppShellProps) {
const tauriOs = isTauri() ? osType() : undefined;
const useCustomWindowsTitleBar = tauriOs === 'windows';
const useDesktopTitleBar = tauriOs === 'windows' || tauriOs === 'linux';
const useMacTitleBar = tauriOs === 'macos';
const hasCustomTitleBar = useDesktopTitleBar || useMacTitleBar;
const reactQueryDevtoolsEnabled = isReactQueryDevtoolsEnabled();
const contentHeight = useCustomWindowsTitleBar
? 'calc(100% - var(--tauri-titlebar-height))'
: '100%';
const contentHeight = hasCustomTitleBar ? 'calc(100% - var(--tauri-titlebar-height))' : '100%';
const [portalContainer, setPortalContainer] = useState<HTMLDivElement | null>(null);

return (
Expand All @@ -52,7 +53,8 @@ export function AppShell({ children, queryClient, screenSize }: AppShellProps) {
height: '100%',
}}
>
{useCustomWindowsTitleBar && <WindowsTitleBar />}
{useDesktopTitleBar && <DesktopTitleBar />}
{useMacTitleBar && <MacTitleBar />}
<div
style={{
display: 'flex',
Expand Down
8 changes: 8 additions & 0 deletions src/app/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,14 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
if (next && next !== editableRef.current && next.isContentEditable) return;
ReactEditor.focus(editor);
}}
// Once the virtual keyboard has settled, make sure the composer is
// not left hidden behind it.
onFocus={() => {
if (!mobileOrTablet()) return;
window.setTimeout(() => {
rootRef.current?.scrollIntoView({ block: 'nearest' });
}, 300);
}}
style={{ boxShadow: 'none' }}
/>
</Scroll>
Expand Down
7 changes: 7 additions & 0 deletions src/app/components/image-viewer/ImageViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
sizedIcon,
} from '$components/icons/phosphor';
import { useImageGestures } from '$hooks/useImageGestures';
import { useAndroidBackHandler } from '$utils/androidBack';
import { useSetting } from '$state/hooks/settings';
import { isPixelatedRendering, settingsAtom } from '$state/settings';
import { downloadMedia } from '$utils/matrix';
Expand All @@ -51,6 +52,12 @@ export const ImageViewer = as<'div', ImageViewerProps>(
const zoomInputRef = useRef<HTMLInputElement>(null);
const [pixelatedImageRendering] = useSetting(settingsAtom, 'pixelatedImageRendering');

// Android back closes the viewer instead of navigating away.
useAndroidBackHandler(() => {
requestClose();
return true;
});

const [isImageReady, setIsImageReady] = useState(false);
const [isEditingZoom, setIsEditingZoom] = useState(false);
const [zoomInput, setZoomInput] = useState('100');
Expand Down
Loading
Loading