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
417 changes: 258 additions & 159 deletions desktop/src-tauri/src/commands/channels.rs

Large diffs are not rendered by default.

126 changes: 126 additions & 0 deletions desktop/src-tauri/src/commands/channels_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// channels.rs under the per-file line cap.

use super::*;
use crate::models::ChannelInfo;
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};

/// Build a signed event for testing with the given kind, content, and tags.
Expand Down Expand Up @@ -266,6 +267,131 @@ fn duplicate_channel_rejection_is_ensure_success_only() {
));
}

// ── compute_channels_hash ─────────────────────────────────────────────────────

fn make_channel(id: &str, name: &str, last_message_at: Option<String>) -> ChannelInfo {
ChannelInfo {
id: id.to_string(),
name: name.to_string(),
channel_type: "stream".to_string(),
visibility: "open".to_string(),
description: "".to_string(),
topic: None,
purpose: None,
member_count: 0,
member_pubkeys: Vec::new(),
last_message_at,
archived_at: None,
participants: Vec::new(),
participant_pubkeys: Vec::new(),
is_member: true,
ttl_seconds: None,
ttl_deadline: None,
}
}

#[test]
fn hash_is_order_insensitive() {
let c1 = make_channel("aaa", "Alpha", None);
let c2 = make_channel("bbb", "Beta", None);
let c3 = make_channel("aaa", "Alpha", None);
let c4 = make_channel("bbb", "Beta", None);

assert_eq!(
compute_channels_hash(&[c1, c2]),
compute_channels_hash(&[c4, c3]),
"hash must be insensitive to channel list ordering",
);
}

#[test]
fn hash_ignores_last_message_at() {
let c_none = make_channel("chan-1", "Alpha", None);
let c_some = make_channel("chan-1", "Alpha", Some("2026-01-01T00:00:00Z".to_string()));

assert_eq!(
compute_channels_hash(&[c_none]),
compute_channels_hash(&[c_some]),
"hash must be insensitive to last_message_at",
);
}

#[test]
fn hash_changes_on_metadata_change() {
let c1 = make_channel("chan-1", "Alpha", None);
let c2 = make_channel("chan-1", "AlphaRenamed", None);

assert_ne!(
compute_channels_hash(&[c1]),
compute_channels_hash(&[c2]),
"hash must change when channel name changes",
);
}

#[test]
fn hash_changes_on_membership_change() {
let mut c1 = make_channel("chan-1", "Alpha", None);
let mut c2 = make_channel("chan-1", "Alpha", None);
c1.member_pubkeys = vec![PK_A.to_string()];
c2.member_pubkeys = vec![PK_A.to_string(), PK_B.to_string()];

assert_ne!(
compute_channels_hash(&[c1]),
compute_channels_hash(&[c2]),
"hash must change when member_pubkeys changes",
);
}

#[test]
fn not_modified_returns_none_when_hash_matches() {
let channels = vec![make_channel("chan-1", "General", None)];
let hash = compute_channels_hash(&channels);

// Mirror the get_channels command decision logic.
let known_hash = Some(hash.clone());
let is_not_modified = known_hash.as_deref() == Some(hash.as_str());

assert!(
is_not_modified,
"identical hash must trigger the not-modified short-circuit",
);
}

#[test]
fn not_modified_does_not_trigger_on_hash_mismatch() {
let channels = vec![make_channel("chan-1", "General", None)];
let hash = compute_channels_hash(&channels);
let known_hash = Some("0000000000000000".to_string());

let is_not_modified = known_hash.as_deref() == Some(hash.as_str());

assert!(
!is_not_modified,
"stale hash must NOT trigger the not-modified short-circuit",
);
}

#[test]
fn hash_is_stable_for_same_input() {
// Verifies that the FNV-1a output is deterministic across calls within
// the same process (unlike std DefaultHasher which uses random seeds).
let channels = vec![
make_channel("aaa", "General", Some("2026-01-01T00:00:00Z".to_string())),
make_channel("bbb", "Random", None),
];
let first = compute_channels_hash(&channels);
let channels2 = vec![
make_channel("aaa", "General", None), // last_message_at change is ignored
make_channel("bbb", "Random", None),
];
let second = compute_channels_hash(&channels2);

assert_eq!(
first, second,
"hash must be deterministic and ignore last_message_at"
);
}

#[test]
fn starter_match_requires_open_unarchived_stream_by_normalized_name() {
let spec = &STARTER_CHANNELS[0];
Expand Down
53 changes: 46 additions & 7 deletions desktop/src-tauri/src/commands/window_vibrancy.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
//! Runtime macOS window vibrancy (blur-behind) toggle.
//!
//! **Invariant:** the main window is created opaque (`tauri.conf.json`
//! `transparent: false`) and the NSWindow is never made transparent at runtime.
//! Behind-window vibrancy renders correctly inside opaque windows — this is
//! exactly how Finder and Notes render vibrant sidebars — so glass only requires
//! runtime webview-canvas transparency, which this command sets on the enable
//! path. When glass is disabled the webview canvas may remain non-drawing
//! (wry's `drawsBackground` flag is one-way at runtime), but that is harmless:
//! glass-off CSS paints the full background opaque and the always-opaque NSWindow
//! is beneath it.
//!
//! Why not `transparent: true`? A creation-time transparent window causes tao to
//! call `NSWindow.setOpaque(false)` and `setBackgroundColor(clearColor)`. The
//! runtime `Window::set_background_color(None)` then resolves `None` to
//! `clearColor` instead of the opaque system default — and there is no runtime
//! `setOpaque(true)` path through tauri — leaving the compositor blending the
//! whole window even with glass off.
//!
//! Vibrancy applies an `NSVisualEffectView` behind the webview so the desktop
//! (and windows behind Buzz) blur through wherever the app's CSS is
//! (and windows behind Buzz) blurs through wherever the WKWebView canvas is
//! transparent. It is a native, macOS-only effect: there is no "intensity"
//! setting at the OS level, only a set of material presets. The frontend tunes
//! perceived intensity by changing CSS surface opacity while this command
//! handles the native material.
//!
//! This is fully reversible at runtime: enabling applies the chosen material,
//! disabling clears it. On non-macOS platforms the command is a no-op so the
//! shared frontend can call it unconditionally.
//! perceived intensity by adjusting CSS surface opacity while this command
//! handles the native material. On non-macOS platforms the command is a no-op
//! so the shared frontend can call it unconditionally.

#[cfg(target_os = "macos")]
use tauri::Manager;
Expand All @@ -35,6 +49,16 @@ pub fn set_window_vibrancy(
.ok_or_else(|| "main window not found".to_string())?;

if !enabled {
// The NSWindow layer is permanently opaque, so no window-layer
// reset is needed here. Skipping `set_background_color(None)` at
// the webview layer also avoids tauri mapping `None` to opaque
// white, which would still force `drawsBackground=false` on the
// WKWebView (counterproductive). After a glass session the webview
// canvas may stay non-drawing — wry's `drawsBackground` flag is
// one-way at runtime — but that is harmless: glass-off CSS paints
// the full background opaque and the always-opaque NSWindow is
// beneath it. If `clear_vibrancy` fails, the opaque CSS already
// covers everything, so no see-through state is reachable.
clear_vibrancy(&window).map_err(|e| e.to_string())?;
return Ok(());
}
Expand All @@ -58,7 +82,22 @@ pub fn set_window_vibrancy(
// clear is a no-op (returns `false`) when none is present.
let _ = clear_vibrancy(&window);

// Install the blur layer first: a failure of the canvas write leaves
// the window with vibrancy behind an opaque webview, not a see-through
// one. Either mixed state self-corrects on the next toggle.
apply_vibrancy(&window, material, None, None).map_err(|e| e.to_string())?;

// Make only the WKWebView canvas transparent so native vibrancy shows
// through; the NSWindow layer stays opaque by design. Targeting the
// webview layer directly (via `AsRef<Webview>`) avoids the
// `WebviewWindow::set_background_color` path, which also writes the
// NSWindow layer. Must follow `apply_vibrancy` so the blur layer is
// present before the canvas becomes see-through.
let webview: &tauri::Webview<_> = window.as_ref();
webview
.set_background_color(Some(tauri::window::Color(0, 0, 0, 0)))
Comment thread
wpfleger96 marked this conversation as resolved.
.map_err(|e| e.to_string())?;

Ok(())
}

Expand Down
17 changes: 14 additions & 3 deletions desktop/src-tauri/src/initial_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,16 @@ pub(crate) fn reveal_initial_window<R: tauri::Runtime>(window: &tauri::Window<R>

#[cfg(target_os = "macos")]
pub(crate) 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.
// Both this write and the deferred clear target the Window (NSWindow)
// backing color only; they never touch the webview canvas or the
// NSVisualEffectView, so they are not load-bearing for glass. Glass state
// — the effect view and webview-canvas transparency — is managed entirely
// by `set_window_vibrancy`, which the ThemeProvider calls after mount. The
// 250ms-delayed clear cannot clobber a persisted-glass-on cold boot
// regardless of ordering with that call.
//
// Write an opaque dark backing so the previous app cannot show through
// before WebKit submits its first composited 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}");
}
Expand All @@ -26,6 +33,10 @@ pub(crate) fn set_initial_window_backing<R: tauri::Runtime>(window: &tauri::Wind
#[cfg(target_os = "macos")]
pub(crate) async fn clear_initial_window_backing<R: tauri::Runtime>(window: &tauri::Window<R>) {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
// Restore the default system window background so fast-resize gutter
// flashes match the platform theme rather than the hardcoded dark color
// written at reveal. Targets the Window (NSWindow) layer only; webview
// canvas and glass state are unaffected.
if let Err(error) = window.set_background_color(None) {
eprintln!("buzz-desktop: failed to clear initial window backing: {error}");
}
Expand Down
7 changes: 0 additions & 7 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,6 @@ pub fn run() {
// on macOS/Windows.
linux_media::enable_media_capture(&webview);

#[cfg(target_os = "macos")]
if let Err(error) = webview
.set_background_color(Some(tauri::window::Color(0, 0, 0, 0)))
{
eprintln!("buzz-desktop: failed to make the macOS webview transparent: {error}");
}

// macOS applies the restored geometry asynchronously. Wait
// for several identical outer bounds and for React to
// commit the startup surface before revealing it.
Expand Down
15 changes: 15 additions & 0 deletions desktop/src-tauri/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,21 @@ fn default_true() -> bool {
true
}

/// Response payload for `get_channels`. When the caller supplies a hash that
/// matches the computed stable hash, `channels` is `None` so the multi-MB
/// channel list is not serialized across IPC. `last_messages` is always
/// included — it is cheap and changes frequently (every new message).
#[derive(Serialize)]
pub struct GetChannelsPayload {
pub hash: String,
/// `None` on a not-modified response (hash matched); `Some` with the full
/// sorted list otherwise.
pub channels: Option<Vec<ChannelInfo>>,
/// Map of channel id → ISO-8601 timestamp of its most recent message.
/// Empty for channels with no messages.
pub last_messages: std::collections::HashMap<String, String>,
}

// ── Social / Contact list ───────────────────────────────────────────────────

#[derive(Serialize, Deserialize)]
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"height": 600,
"maximized": true,
"visible": false,
"transparent": true,
"transparent": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"dragDropEnabled": false,
Expand Down
88 changes: 88 additions & 0 deletions desktop/src/features/agents/focusRefetchPolicy.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";

import {
focusManager,
QueryClient,
QueryObserver,
} from "@tanstack/react-query";

import {
agentsFocusRefetchPolicy,
managedAgentLogFocusRefetchPolicy,
} from "./hooks.ts";

afterEach(() => {
focusManager.setFocused(undefined);
});

async function focusRefetchCount({ ageMs, policy }) {
focusManager.setFocused(false);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.mount();

const queryKey = ["focus-refetch-policy", policy.staleTime, ageMs];
queryClient.setQueryData(queryKey, "cached", {
updatedAt: Date.now() - ageMs,
});
let fetchCount = 0;
const observer = new QueryObserver(queryClient, {
queryKey,
queryFn: async () => {
fetchCount += 1;
return "refetched";
},
refetchOnMount: false,
...policy,
});
const unsubscribe = observer.subscribe(() => {});

focusManager.setFocused(true);
await new Promise((resolve) => setImmediate(resolve));

unsubscribe();
queryClient.unmount();
return fetchCount;
}

test("agents: skips fresh focus refetch", async () => {
assert.equal(
await focusRefetchCount({
ageMs: agentsFocusRefetchPolicy.staleTime - 1_000,
policy: agentsFocusRefetchPolicy,
}),
0,
);
});

test("agents: refetches genuinely stale data on focus", async () => {
assert.equal(
await focusRefetchCount({
ageMs: agentsFocusRefetchPolicy.staleTime + 1,
policy: agentsFocusRefetchPolicy,
}),
1,
);
});

test("managed-agent-log: skips focus refetch when data is fresher than one poll tick", async () => {
assert.equal(
await focusRefetchCount({
ageMs: managedAgentLogFocusRefetchPolicy.staleTime - 1_000,
policy: managedAgentLogFocusRefetchPolicy,
}),
0,
);
});

test("managed-agent-log: refetches on focus when data is older than one poll tick", async () => {
assert.equal(
await focusRefetchCount({
ageMs: managedAgentLogFocusRefetchPolicy.staleTime + 1,
policy: managedAgentLogFocusRefetchPolicy,
}),
1,
);
});
Loading
Loading