mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Fix desktop launch motion and reaction spacing (#1717)
Co-authored-by: Fizz <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -48,9 +48,11 @@ use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use tauri::{Emitter, Manager, RunEvent};
|
||||
use tauri::{Emitter, Listener, Manager, RunEvent};
|
||||
use tauri_plugin_window_state::StateFlags;
|
||||
|
||||
const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready";
|
||||
|
||||
#[tauri::command]
|
||||
fn perform_sidebar_default_haptic() {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -152,6 +154,70 @@ fn toggle_maximize(window: &tauri::Window) {
|
||||
}
|
||||
}
|
||||
|
||||
fn reveal_initial_window<R: tauri::Runtime>(window: &tauri::Window<R>) {
|
||||
if let Err(error) = window.show() {
|
||||
eprintln!("buzz-desktop: failed to reveal main window: {error}");
|
||||
return;
|
||||
}
|
||||
if let Err(error) = window.set_focus() {
|
||||
eprintln!("buzz-desktop: failed to focus main window: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_initial_window_backing<R: tauri::Runtime>(window: &tauri::Window<R>) {
|
||||
// The window remains transparent at runtime for vibrancy. Use an opaque
|
||||
// native backing only across the first visible frames so the previous app
|
||||
// cannot show through before WebKit has submitted its first surface.
|
||||
if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) {
|
||||
eprintln!("buzz-desktop: failed to set initial window backing: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn clear_initial_window_backing<R: tauri::Runtime>(window: &tauri::Window<R>) {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
if let Err(error) = window.set_background_color(None) {
|
||||
eprintln!("buzz-desktop: failed to clear initial window backing: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn wait_for_stable_initial_window_geometry<R: tauri::Runtime>(window: &tauri::Window<R>) {
|
||||
const MAX_POLLS: usize = 120;
|
||||
const REQUIRED_STABLE_POLLS: usize = 4;
|
||||
|
||||
let mut previous_bounds = None;
|
||||
let mut stable_polls = 0;
|
||||
|
||||
for _ in 0..MAX_POLLS {
|
||||
// Accept whatever geometry the window-state plugin restores — maximized
|
||||
// or a normal saved size. macOS applies the restore asynchronously, so
|
||||
// we only need consecutive identical outer bounds to know it settled.
|
||||
// Gating on `is_maximized()` here would leave `bounds` permanently
|
||||
// `None` for restored non-maximized windows and stall the reveal until
|
||||
// the poll timeout.
|
||||
let bounds = match (window.outer_position(), window.outer_size()) {
|
||||
(Ok(position), Ok(size)) => Some((position.x, position.y, size.width, size.height)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if bounds.is_some() && bounds == previous_bounds {
|
||||
stable_polls += 1;
|
||||
if stable_polls >= REQUIRED_STABLE_POLLS {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
stable_polls = 0;
|
||||
}
|
||||
previous_bounds = bounds;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(16)).await;
|
||||
}
|
||||
|
||||
eprintln!("buzz-desktop: initial window geometry did not settle before reveal timeout");
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default()
|
||||
@@ -172,11 +238,61 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(
|
||||
tauri_plugin_window_state::Builder::default()
|
||||
// Visibility is excluded: the window starts hidden and the
|
||||
// frontend shows it once ready.
|
||||
// Visibility is excluded: the native reveal plugin below
|
||||
// shows the window after saved geometry has been restored.
|
||||
.with_state_flags(StateFlags::all() & !StateFlags::VISIBLE)
|
||||
.build(),
|
||||
)
|
||||
.plugin(
|
||||
tauri::plugin::Builder::<_, ()>::new("initial-window-reveal")
|
||||
.on_webview_ready(|webview| {
|
||||
if webview.label() != "main" {
|
||||
return;
|
||||
}
|
||||
|
||||
// macOS applies the restored geometry asynchronously. Wait
|
||||
// for several identical outer bounds and for React to
|
||||
// commit the startup surface before revealing it.
|
||||
let window = webview.window();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
set_initial_window_backing(&window);
|
||||
|
||||
let (initial_render_tx, initial_render_rx) = tokio::sync::oneshot::channel();
|
||||
window
|
||||
.app_handle()
|
||||
.once(INITIAL_RENDER_READY_EVENT, move |_| {
|
||||
let _ = initial_render_tx.send(());
|
||||
});
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
wait_for_stable_initial_window_geometry(&window).await;
|
||||
|
||||
if tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
initial_render_rx,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
eprintln!(
|
||||
"buzz-desktop: initial render did not commit before reveal timeout"
|
||||
);
|
||||
}
|
||||
|
||||
reveal_initial_window(&window);
|
||||
clear_initial_window_backing(&window).await;
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
reveal_initial_window(&window);
|
||||
}
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_websocket::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_process::init());
|
||||
|
||||
@@ -20,15 +20,11 @@
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"maximized": true,
|
||||
"visible": true,
|
||||
"visible": false,
|
||||
"transparent": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"dragDropEnabled": false,
|
||||
"trafficLightPosition": {
|
||||
"x": 16,
|
||||
"y": 24
|
||||
},
|
||||
"backgroundThrottling": "disabled",
|
||||
"minWidth": 800,
|
||||
"minHeight": 500
|
||||
|
||||
+17
-9
@@ -1,4 +1,5 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { Hexagon } from "lucide-react";
|
||||
@@ -39,16 +40,26 @@ import { StepProgress } from "@/shared/ui/step-progress";
|
||||
const LOADING_TEXT = "Setting up your workspace...";
|
||||
|
||||
// Minimum time the cold-boot splash stays on screen. A real boot resolves the
|
||||
// workspace in well under 100ms, and the hidden Tauri window (visible:false
|
||||
// until getCurrentWindow().show()) takes longer than that to put its first
|
||||
// frame on screen — without a hold, the bee is unmounted before it is ever
|
||||
// visible. The hold runs as an overlay above the already-mounted app, so
|
||||
// workspace in well under 100ms, and the native window setup plus first paint
|
||||
// can take longer than that — without a hold, the bee is unmounted before it is
|
||||
// ever visible. The hold runs as an overlay above the already-mounted app, so
|
||||
// time-to-interactive is unchanged; only the reveal waits.
|
||||
const BOOT_SPLASH_MIN_VISIBLE_MS = 1_200;
|
||||
const BOOT_SPLASH_FADE_MS = 200;
|
||||
const INITIAL_RENDER_READY_EVENT = "initial-render-ready";
|
||||
|
||||
type BootSplashPhase = "holding" | "fading" | "done";
|
||||
|
||||
function useInitialRenderReady() {
|
||||
useLayoutEffect(() => {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void emit(INITIAL_RENDER_READY_EVENT);
|
||||
}, []);
|
||||
}
|
||||
|
||||
// E2E runs skip the hold (it would slow every spec's boot and block pointer
|
||||
// actionability); a spec can opt back in via __BUZZ_E2E__.bootSplashHoldMs.
|
||||
function bootSplashHoldMs(): number {
|
||||
@@ -323,10 +334,7 @@ export function App() {
|
||||
// Mounted at the root so Cmd/Ctrl+R reloads in every app state,
|
||||
// including the loading and first-run setup screens below.
|
||||
useReloadShortcut();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
void getCurrentWindow().show();
|
||||
}, []);
|
||||
useInitialRenderReady();
|
||||
|
||||
const [sharedIdentity, setSharedIdentity] = useState<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -65,11 +65,11 @@ export function AppTopChrome({
|
||||
}: AppTopChromeProps) {
|
||||
const topChromeRef = React.useRef<HTMLDivElement>(null);
|
||||
const isFullscreen = useIsFullscreen();
|
||||
// On macOS the traffic-light buttons overlay the chrome (see
|
||||
// `trafficLightPosition` in `tauri.conf.json`), so the nav row clears their
|
||||
// x-position. When the workspace rail is present it already occupies the far
|
||||
// left, so the nav row only needs to clear the lights past the rail edge
|
||||
// rather than the full offset. In fullscreen those buttons hide.
|
||||
// On macOS the native traffic-light buttons overlay the chrome, so the nav
|
||||
// row clears their x-position. When the workspace rail is present it already
|
||||
// occupies the far left, so the nav row only needs to clear the lights past
|
||||
// the rail edge rather than the full offset. In fullscreen those buttons
|
||||
// hide.
|
||||
//
|
||||
// Fixed px on purpose: the native traffic lights do not scale with the app's
|
||||
// Cmd +/- text zoom (rem), so rem-based clearance shrinks under them when
|
||||
@@ -80,7 +80,7 @@ export function AppTopChrome({
|
||||
? "pl-[32px]"
|
||||
: "pl-[80px]"
|
||||
: "pl-3";
|
||||
const navRowAlignmentClass = macChrome ? "translate-y-[3px]" : null;
|
||||
const navRowAlignmentClass = macChrome ? "-translate-y-[4px]" : null;
|
||||
|
||||
React.useEffect(() => {
|
||||
const topChrome = topChromeRef.current;
|
||||
|
||||
@@ -246,7 +246,7 @@ export function MessageReactions({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/reactions mt-1.5 flex flex-wrap items-center gap-1.5 pt-1",
|
||||
"group/reactions mt-1.5 flex flex-wrap items-center gap-1.5",
|
||||
className,
|
||||
)}
|
||||
data-testid="message-reactions"
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
|
||||
// Reaction ordering end-to-end guard.
|
||||
//
|
||||
@@ -52,6 +53,17 @@ async function getPillOrder(
|
||||
return labels;
|
||||
}
|
||||
|
||||
async function getBodyToReactionGap(
|
||||
row: import("@playwright/test").Locator,
|
||||
): Promise<number> {
|
||||
const body = await row.locator(".message-markdown").first().boundingBox();
|
||||
const reactions = await row.getByTestId("message-reactions").boundingBox();
|
||||
if (!body || !reactions) {
|
||||
throw new Error("message body or reactions are not laid out");
|
||||
}
|
||||
return Math.round(reactions.y - (body.y + body.height));
|
||||
}
|
||||
|
||||
/** Click a quick-reaction tray button by emoji (hover → click tray button). */
|
||||
async function addQuickReaction(
|
||||
row: import("@playwright/test").Locator,
|
||||
@@ -93,11 +105,13 @@ test("reaction pills render left-to-right in the order reactions were added", as
|
||||
|
||||
const pills = await getPillOrder(row);
|
||||
expect(pills).toEqual(["👍", "❤️", "😂"]);
|
||||
await expect.poll(() => getBodyToReactionGap(row)).toBe(6);
|
||||
|
||||
// Screenshot for PR visual proof.
|
||||
const screenshotDir = path.resolve("test-results/reaction-order-screenshots");
|
||||
fs.mkdirSync(screenshotDir, { recursive: true });
|
||||
const reactionBar = row.getByTestId("message-reactions");
|
||||
await waitForAnimations(page);
|
||||
await reactionBar.screenshot({
|
||||
path: path.join(screenshotDir, "reaction-order-chronological.png"),
|
||||
});
|
||||
@@ -154,6 +168,7 @@ test("a later emoji that accrues more reactors stays to the right of an earlier
|
||||
const screenshotDir = path.resolve("test-results/reaction-order-screenshots");
|
||||
fs.mkdirSync(screenshotDir, { recursive: true });
|
||||
const reactionBar = row.getByTestId("message-reactions");
|
||||
await waitForAnimations(page);
|
||||
await reactionBar.screenshot({
|
||||
path: path.join(screenshotDir, "reaction-order-count-stable.png"),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user