Skip to content
Merged
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-shortcuts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Desktop: add keyboard accelerators for settings, close and quit (macOS via the native menu, Windows/Linux via Ctrl shortcuts) and a global show/hide-window hotkey.
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/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.
34 changes: 34 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ tauri-plugin-updater = { version = "2", optional = true }
tauri-plugin-process = "2"
tauri-plugin-dialog = "2"
tauri-plugin-window-state = "2.4.1"
tauri-plugin-global-shortcut = "2"

[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies]
gtk = "0.18"
Expand Down
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
72 changes: 72 additions & 0 deletions src-tauri/src/desktop/menu.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#[cfg(target_os = "macos")]
use tauri::Emitter;
use tauri::{AppHandle, Manager};

#[cfg(target_os = "macos")]
pub const SETTINGS_MENU_ID: &str = "settings";

pub const TOGGLE_WINDOW_ACCELERATOR: &str = "CmdOrCtrl+Shift+S";

// Extend the standard menu (Edit submenu for webview copy/paste, Quit, Close)
// with a Settings item.
#[cfg(target_os = "macos")]
pub fn build_app_menu(
handle: &AppHandle<crate::BrowserEngine>,
) -> tauri::Result<tauri::menu::Menu<crate::BrowserEngine>> {
use tauri::menu::{Menu, MenuItem, Submenu};

let menu = Menu::default(handle)?;
let settings = MenuItem::with_id(
handle,
SETTINGS_MENU_ID,
"Settings…",
true,
Some("CmdOrCtrl+,"),
)?;
let preferences = Submenu::with_items(handle, "Preferences", true, &[&settings])?;
menu.append(&preferences)?;
Ok(menu)
}

#[cfg(target_os = "macos")]
pub fn handle_menu_event(app: &AppHandle<crate::BrowserEngine>, event: &tauri::menu::MenuEvent) {
if event.id().as_ref() == SETTINGS_MENU_ID {
let _ = app.emit("open-settings", ());
}
}

pub fn toggle_main_window(app: &AppHandle<crate::BrowserEngine>) {
if let Some(window) = app.get_webview_window(crate::MAIN_WINDOW_LABEL) {
let visible = window.is_visible().unwrap_or(false);
let focused = window.is_focused().unwrap_or(false);
if visible && focused {
let _ = window.hide();
} else {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
} else {
let _ = crate::show_or_create_main_window(app);
}
}

pub fn global_shortcut_plugin() -> tauri::plugin::TauriPlugin<crate::BrowserEngine> {
use tauri_plugin_global_shortcut::{Builder, ShortcutState};

Builder::new()
.with_handler(|app, _shortcut, event| {
if event.state() == ShortcutState::Pressed {
toggle_main_window(app);
}
})
.build()
}

pub fn register_global_shortcuts(app: &AppHandle<crate::BrowserEngine>) {
use tauri_plugin_global_shortcut::GlobalShortcutExt;

if let Err(error) = app.global_shortcut().register(TOGGLE_WINDOW_ACCELERATOR) {
log::warn!("Failed to register global show/hide shortcut: {error}");
}
}
1 change: 1 addition & 0 deletions src-tauri/src/desktop/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod download;
pub mod menu;
pub mod runtime_state;
pub mod settings;
pub mod tray;
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
20 changes: 17 additions & 3 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 @@ -215,7 +219,12 @@ pub fn run() {
// copy/paste/select-all to work in the webview. It lives in the system menu
// bar, so it is macOS-only to avoid an in-window menu bar elsewhere.
#[cfg(target_os = "macos")]
let builder = builder.menu(|handle| tauri::menu::Menu::default(handle));
let builder = builder
.menu(|handle| desktop::menu::build_app_menu(handle))
.on_menu_event(|app, event| desktop::menu::handle_menu_event(app, &event));

#[cfg(desktop)]
let builder = builder.plugin(desktop::menu::global_shortcut_plugin());

#[cfg(desktop)]
let builder = builder.plugin(
Expand Down Expand Up @@ -309,6 +318,9 @@ pub fn run() {
#[cfg(desktop)]
desktop::tray::sync_desktop_settings_inner(app.handle())?;

#[cfg(desktop)]
desktop::menu::register_global_shortcuts(app.handle());

#[cfg(debug_assertions)]
{
let (log_plugin, _level, logger) = tauri_plugin_log::Builder::default()
Expand All @@ -334,6 +346,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
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
Loading
Loading