diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 3832108ef6d..9b5c9e020c9 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.22", - "base_sha": "75f101d8b4f5b4b26f9891b73b87c67fedac25a0", - "previous_tag": "desktop-v0.5.20", - "previous_base_sha": "8471049c430073474939336dfc6aa98272bc8762", - "previous_merge_sha": "52621c09bea503f4d5860030dfabfaf9ade71bfa", - "tag": "desktop-v0.5.22", - "commit_count": 114 + "version": "0.5.23", + "base_sha": "dad5a33865fc81a2e55b3b60746632f615ec1e3a", + "previous_tag": "desktop-v0.5.22", + "previous_base_sha": "75f101d8b4f5b4b26f9891b73b87c67fedac25a0", + "previous_merge_sha": "01bacb8df3d2f5718e0a468828e07ae874a38eae", + "tag": "desktop-v0.5.23", + "commit_count": 16 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 50c056f46c4..092d0f94520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## v0.5.23 + +### Desktop and shared changes + +- fix(desktop): keep packaged frontendDist relative so Windows embeds assets ([#7177](https://github.com/block/buzz/pull/7177)) ([`dad5a33865fc81a2e55b3b60746632f615ec1e3a`](https://github.com/block/buzz/commit/dad5a33865fc81a2e55b3b60746632f615ec1e3a)) +- fix(sidebar): simplify unread indicators and emphasize priority activity ([#7134](https://github.com/block/buzz/pull/7134)) ([`f038cbbb0d4092a72ffd93f17916f84d2b39bb43`](https://github.com/block/buzz/commit/f038cbbb0d4092a72ffd93f17916f84d2b39bb43)) +- feat(buzz-acp): update base prompt; add buzz context and skills to Pi agents ([#7335](https://github.com/block/buzz/pull/7335)) ([`4beffef6979347f7ebdc760705865bdd04d23508`](https://github.com/block/buzz/commit/4beffef6979347f7ebdc760705865bdd04d23508)) +- fix(desktop): restore mention chip identity icons ([#7338](https://github.com/block/buzz/pull/7338)) ([`4afef8649ab11d60b423893e9e4f8cd36868d35a`](https://github.com/block/buzz/commit/4afef8649ab11d60b423893e9e4f8cd36868d35a)) +- Persist video playback speed preference ([#7336](https://github.com/block/buzz/pull/7336)) ([`5d107836c6bd6a57e2da28560ec0acc0d016b8d9`](https://github.com/block/buzz/commit/5d107836c6bd6a57e2da28560ec0acc0d016b8d9)) +- feat(desktop): invite owned agents from standalone forums ([#7125](https://github.com/block/buzz/pull/7125)) ([`e7e29937a145aca7a3c7f5436b07e32c6c20087f`](https://github.com/block/buzz/commit/e7e29937a145aca7a3c7f5436b07e32c6c20087f)) +- fix(desktop): authorize remote mentions at publication ([#7124](https://github.com/block/buzz/pull/7124)) ([`d595806fc3b9c9758992e39b9b51cbb5f55791b0`](https://github.com/block/buzz/commit/d595806fc3b9c9758992e39b9b51cbb5f55791b0)) +- fix(acp): rename system tag to agent-instructions ([#7332](https://github.com/block/buzz/pull/7332)) ([`ce9decb235f628c484631ac923db96466460fc3f`](https://github.com/block/buzz/commit/ce9decb235f628c484631ac923db96466460fc3f)) +- fix(desktop): bind duplicate mention selections to exact recipients ([#7133](https://github.com/block/buzz/pull/7133)) ([`ee883d73fb84752d26f52c6606ad3cadc9f12cc7`](https://github.com/block/buzz/commit/ee883d73fb84752d26f52c6606ad3cadc9f12cc7)) +- feat(desktop): preserve mentions across copy and paste ([#7228](https://github.com/block/buzz/pull/7228)) ([`b1f6b7ef770dddbb7f33c9f5861c379a47bca1d6`](https://github.com/block/buzz/commit/b1f6b7ef770dddbb7f33c9f5861c379a47bca1d6)) +- test(desktop): await Bestie drag and profile hover endpoints ([#7294](https://github.com/block/buzz/pull/7294)) ([`4b0744d7f3161d9f045dd67a73e014e4440c2b47`](https://github.com/block/buzz/commit/4b0744d7f3161d9f045dd67a73e014e4440c2b47)) +- Collapse contiguous join messages ([#7262](https://github.com/block/buzz/pull/7262)) ([`2ac0aa1dd18c0b9d4fa722658c4692b0c79a496f`](https://github.com/block/buzz/commit/2ac0aa1dd18c0b9d4fa722658c4692b0c79a496f)) + +### Other repository changes + +- Add generic information-flow control core ([#7293](https://github.com/block/buzz/pull/7293)) ([`4d447b9c20a23fb33c94778e6cf309424abea6c8`](https://github.com/block/buzz/commit/4d447b9c20a23fb33c94778e6cf309424abea6c8)) +- Verify ACP relay events before prompt routing ([#7010](https://github.com/block/buzz/pull/7010)) ([`e09f715c9d0ee2cb7bf8a39061e601f3a502f588`](https://github.com/block/buzz/commit/e09f715c9d0ee2cb7bf8a39061e601f3a502f588)) +- fix(buzz-acp): bound busy-owner hold to prevent cross-channel starvation ([#7337](https://github.com/block/buzz/pull/7337)) ([`b17c0776b7438d59904e8c38926148bf692fa5f5`](https://github.com/block/buzz/commit/b17c0776b7438d59904e8c38926148bf692fa5f5)) +- refactor(relay): extract NIP-29 membership authorization ([#7285](https://github.com/block/buzz/pull/7285)) ([`cd02b693aae86444b0b5636474a3d202c6fc8f26`](https://github.com/block/buzz/commit/cd02b693aae86444b0b5636474a3d202c6fc8f26)) + +[Compare desktop-v0.5.22...desktop-v0.5.23](https://github.com/block/buzz/compare/desktop-v0.5.22...desktop-v0.5.23) + ## v0.5.22 ### Desktop and shared changes diff --git a/Cargo.lock b/Cargo.lock index 1b262121712..735f1220444 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3940,6 +3940,13 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ifc-core" +version = "0.1.0" +dependencies = [ + "proptest", +] + [[package]] name = "igd-next" version = "0.17.0" diff --git a/Cargo.toml b/Cargo.toml index 386d6db85e8..dddf1abe1b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/buzz-relay", "crates/buzz-core", + "crates/ifc-core", "crates/buzz-conformance", "crates/buzz-push-gateway", "crates/buzz-db", diff --git a/crates/ifc-core/Cargo.toml b/crates/ifc-core/Cargo.toml new file mode 100644 index 00000000000..0a14e1a0abd --- /dev/null +++ b/crates/ifc-core/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ifc-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Generic reader-set information-flow control primitives" + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/ifc-core/src/lib.rs b/crates/ifc-core/src/lib.rs new file mode 100644 index 00000000000..a95241a0f34 --- /dev/null +++ b/crates/ifc-core/src/lib.rs @@ -0,0 +1,507 @@ +//! Generic information-flow control over reader-set confidentiality labels. +//! +//! This crate knows nothing about Buzz, Nostr, etc., and should stay that way. +//! +//! ``` +//! use std::collections::BTreeSet; +//! use ifc_core::{ConfidentialityLabel, EgressError, FlowState}; +//! +//! let universe = "example"; +//! let private = ConfidentialityLabel::restricted( +//! universe, +//! BTreeSet::from(["alice", "bob"]), +//! )?; +//! let alice_only = ConfidentialityLabel::restricted_to(universe, "alice"); +//! let public = ConfidentialityLabel::public(universe); +//! let mut flow = FlowState::default(); +//! flow.observe(&private); +//! +//! assert_eq!(flow.check_egress(&alice_only), Ok(())); +//! assert_eq!( +//! flow.check_egress(&public), +//! Err(EgressError::DestinationWidensReaders), +//! ); +//! # Ok::<(), ifc_core::LabelError>(()) +//! ``` + +#![forbid(unsafe_code)] + +use std::collections::BTreeSet; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +/// The people or systems allowed to learn a value within one universe. +/// +/// `Everyone` is for public information. `Only` holds the set of +/// principals allowed to read restricted information. Sending information from +/// one reader set to another is safe only when the destination adds no new +/// readers. For example, information readable by Alice and Bob may be narrowed +/// to Alice, but it must not be widened to Alice, Bob, and Carol. +/// +/// When a computation combines inputs, their reader sets are intersected so +/// its output is restricted to principals allowed to read every input. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ReaderSet { + /// Every principal in the universe may read the value. + Everyone, + /// Only these principals may read the value. + Only(BTreeSet), +} + +impl ReaderSet { + /// Whether information readable by `self` may flow to `destination`. + pub fn can_flow_to(&self, destination: &Self) -> bool { + match (self, destination) { + (Self::Everyone, _) => true, + (Self::Only(_), Self::Everyone) => false, + (Self::Only(source), Self::Only(destination)) => destination.is_subset(source), + } + } + + /// Combine the restrictions of two contributing inputs. + /// + /// A derived value may be read only by principals authorized for both + /// inputs, so explicit reader sets are intersected. `Everyone` adds no + /// restriction. + pub fn join(&self, other: &Self) -> Self + where + Principal: Clone, + { + match (self, other) { + (Self::Everyone, value) | (value, Self::Everyone) => value.clone(), + (Self::Only(left), Self::Only(right)) => { + Self::Only(left.intersection(right).cloned().collect()) + } + } + } + + /// Return the greatest label that can flow to both inputs. + pub fn meet(&self, other: &Self) -> Self + where + Principal: Clone, + { + match (self, other) { + (Self::Everyone, _) | (_, Self::Everyone) => Self::Everyone, + (Self::Only(left), Self::Only(right)) => { + Self::Only(left.union(right).cloned().collect()) + } + } + } + + /// Return the explicit readers, or `None` when everyone may read the value. + pub fn explicit_readers(&self) -> Option<&BTreeSet> { + match self { + Self::Everyone => None, + Self::Only(readers) => Some(readers), + } + } + + /// Return the explicit number of readers, or `None` for public data. + pub fn explicit_count(&self) -> Option { + self.explicit_readers().map(BTreeSet::len) + } +} + +/// A reader-set confidentiality label inside one isolated universe. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfidentialityLabel { + universe: Universe, + readers: ReaderSet, +} + +impl ConfidentialityLabel { + /// Label a value readable by every principal in `universe`. + pub fn public(universe: Universe) -> Self { + Self { + universe, + readers: ReaderSet::Everyone, + } + } + + /// Label a value with an explicit non-empty reader set. + pub fn restricted( + universe: Universe, + readers: BTreeSet, + ) -> Result { + if readers.is_empty() { + return Err(LabelError::EmptyReaderSet); + } + Ok(Self { + universe, + readers: ReaderSet::Only(readers), + }) + } + + /// Label a value for exactly one principal. + pub fn restricted_to(universe: Universe, principal: Principal) -> Self + where + Principal: Ord, + { + Self { + universe, + readers: ReaderSet::Only(BTreeSet::from([principal])), + } + } + + /// Return the universe in which this label is meaningful. + pub fn universe(&self) -> &Universe { + &self.universe + } + + /// Return the authorized reader set. + pub fn reader_set(&self) -> &ReaderSet { + &self.readers + } + + /// Whether every principal in the universe may read the value. + pub fn is_public(&self) -> bool { + matches!(self.readers, ReaderSet::Everyone) + } + + /// Return the explicit number of readers, or `None` for public data. + pub fn reader_count(&self) -> Option + where + Principal: Ord, + { + self.readers.explicit_count() + } +} + +impl ConfidentialityLabel { + /// Whether information with this label may flow to `destination`. + pub fn can_flow_to(&self, destination: &Self) -> bool { + self.universe == destination.universe && self.readers.can_flow_to(&destination.readers) + } + + /// Combine the influence of two inputs. + pub fn join(&self, other: &Self) -> Result { + if self.universe != other.universe { + return Err(LabelError::CrossUniverse); + } + Ok(Self { + universe: self.universe.clone(), + readers: self.readers.join(&other.readers), + }) + } +} + +/// A confidentiality label violates the reader-set lattice invariants. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LabelError { + /// Restricted information must name at least one authorized reader. + EmptyReaderSet, + /// Labels from different universes cannot be combined. + CrossUniverse, +} + +impl Display for LabelError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::EmptyReaderSet => { + formatter.write_str("restricted label has no authorized readers") + } + Self::CrossUniverse => formatter.write_str("labels belong to different universes"), + } + } +} + +impl Error for LabelError {} + +/// Monotonic confidentiality state for one computation boundary. +/// +/// Every admitted label is joined into the accumulated label. Unknown or +/// cross-universe input permanently prevents ordinary egress. The state is not +/// cloneable because a caller must not retain a clean copy and later use it to +/// forget observed input. +/// +/// ```compile_fail +/// let state = ifc_core::FlowState::::default(); +/// let _clean_copy = state.clone(); +/// ``` +#[derive(Debug, Eq, PartialEq)] +pub struct FlowState { + accumulated: Option>, + unresolved_input: bool, +} + +impl Default for FlowState { + fn default() -> Self { + Self { + accumulated: None, + unresolved_input: false, + } + } +} + +impl FlowState { + /// Record a labeled input that entered the computation. + pub fn observe(&mut self, label: &ConfidentialityLabel) { + self.accumulated = match self.accumulated.take() { + None => Some(label.clone()), + Some(existing) => match existing.join(label) { + Ok(combined) => Some(combined), + Err(LabelError::CrossUniverse | LabelError::EmptyReaderSet) => { + self.unresolved_input = true; + Some(existing) + } + }, + }; + } + + /// Permanently record input whose label could not be established. + pub fn mark_unknown(&mut self) { + self.unresolved_input = true; + } + + /// Check whether accumulated information may flow to `destination`. + pub fn check_egress( + &self, + destination: &ConfidentialityLabel, + ) -> Result<(), EgressError> { + if self.unresolved_input { + return Err(EgressError::UnresolvedInput); + } + let Some(accumulated) = &self.accumulated else { + return Ok(()); + }; + if accumulated.universe() != destination.universe() { + return Err(EgressError::DestinationUniverseMismatch); + } + if !accumulated + .reader_set() + .can_flow_to(destination.reader_set()) + { + return Err(EgressError::DestinationWidensReaders); + } + Ok(()) + } + + /// Whether any labeled input has entered the computation. + pub fn has_observed_input(&self) -> bool { + self.accumulated.is_some() + } + + /// Return the label accumulated from all observed inputs. + pub fn accumulated_label(&self) -> Option<&ConfidentialityLabel> { + self.accumulated.as_ref() + } + + /// Whether unknown or cross-universe input has entered the computation. + pub fn has_unresolved_input(&self) -> bool { + self.unresolved_input + } + + /// Capture state for detecting changes before a checked sink executes. + pub fn snapshot(&self) -> FlowSnapshot { + FlowSnapshot { + accumulated: self.accumulated.clone(), + unresolved_input: self.unresolved_input, + } + } +} + +/// An inert copy used only to detect changes before sink execution. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FlowSnapshot { + accumulated: Option>, + unresolved_input: bool, +} + +/// Why accumulated information cannot use an ordinary egress path. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EgressError { + /// Some input had unknown provenance or belonged to another universe. + UnresolvedInput, + /// The destination belongs to a different confidentiality universe. + DestinationUniverseMismatch, + /// The destination introduces readers not authorized for every input. + DestinationWidensReaders, +} + +impl Display for EgressError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnresolvedInput => formatter.write_str("input provenance is unresolved"), + Self::DestinationUniverseMismatch => { + formatter.write_str("destination belongs to a different universe") + } + Self::DestinationWidensReaders => { + formatter.write_str("destination widens the accumulated reader set") + } + } + } +} + +impl Error for EgressError {} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + fn readers(mask: u8) -> BTreeSet { + (0..8).filter(|bit| mask & (1 << bit) != 0).collect() + } + + fn label(mask: u8) -> ConfidentialityLabel { + ConfidentialityLabel::restricted(1, readers(mask.max(1))).expect("non-empty readers") + } + + #[derive(Clone, Copy, Debug)] + enum Audience { + Public, + Restricted(u8), + } + + impl Audience { + fn label(self) -> ConfidentialityLabel { + match self { + Self::Public => ConfidentialityLabel::public(1), + Self::Restricted(mask) => label(mask), + } + } + + fn can_flow_to(self, destination: Self) -> bool { + match (self, destination) { + (Self::Public, _) => true, + (Self::Restricted(_), Self::Public) => false, + (Self::Restricted(source), Self::Restricted(destination)) => { + destination & !source == 0 + } + } + } + } + + fn audience_strategy() -> impl Strategy { + prop_oneof![ + Just(Audience::Public), + (1_u8..=u8::MAX).prop_map(Audience::Restricted), + ] + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Checks reader-set inclusion against an independent bit-set model and + /// verifies that join is commutative, associative, idempotent, and + /// admits exactly the destinations admitted by both inputs. This catches + /// a reversed subset check, union in place of intersection, and special + /// handling of public data that accidentally widens readers. + #[test] + fn reader_sets_obey_flow_and_join_laws( + a in audience_strategy(), + b in audience_strategy(), + c in audience_strategy(), + destination in audience_strategy(), + ) { + let a_label = a.label(); + let b_label = b.label(); + let c_label = c.label(); + let destination_label = destination.label(); + + prop_assert_eq!( + a_label.can_flow_to(&destination_label), + a.can_flow_to(destination), + ); + prop_assert!(ConfidentialityLabel::public(1).can_flow_to(&a_label)); + if !a_label.is_public() { + prop_assert!(!a_label.can_flow_to(&ConfidentialityLabel::public(1))); + } + + let ab = a_label.join(&b_label).expect("same universe"); + prop_assert_eq!(&ab, &b_label.join(&a_label).expect("same universe")); + prop_assert_eq!( + a_label.join(&a_label).expect("same universe"), + a_label.clone(), + ); + prop_assert_eq!( + a_label + .join(&b_label.join(&c_label).expect("same universe")) + .expect("same universe"), + ab.join(&c_label).expect("same universe"), + ); + prop_assert_eq!( + ab.can_flow_to(&destination_label), + a_label.can_flow_to(&destination_label) + && b_label.can_flow_to(&destination_label), + ); + } + } + + /// Checks the two absorption laws linking join and meet. These catch a + /// locally plausible implementation where each operation works alone but + /// they do not form one consistent lattice. + #[test] + fn reader_set_join_and_meet_satisfy_absorption() { + let values = [ + ReaderSet::Everyone, + ReaderSet::Only(readers(0b0001)), + ReaderSet::Only(readers(0b0010)), + ReaderSet::Only(readers(0b0011)), + ]; + + for left in &values { + for right in &values { + assert_eq!(left.join(&left.meet(right)), *left); + assert_eq!(left.meet(&left.join(right)), *left); + } + } + } + + /// Checks that every observed input permanently restricts later egress and + /// that unknown provenance cannot be cleared. This catches taint rollback + /// and mistakenly replacing an accumulated label instead of joining it. + #[test] + fn flow_state_accumulates_restrictions_and_never_forgets_unknown_input() { + let mut state = FlowState::default(); + assert_eq!(state.check_egress(&label(0b0011)), Ok(())); + state.observe(&label(0b0011)); + state.observe(&label(0b0110)); + + assert_eq!(state.accumulated_label(), Some(&label(0b0010))); + assert_eq!(state.check_egress(&label(0b0010)), Ok(())); + assert_eq!( + state.check_egress(&label(0b0011)), + Err(EgressError::DestinationWidensReaders) + ); + + state.mark_unknown(); + assert_eq!( + state.check_egress(&label(0b0010)), + Err(EgressError::UnresolvedInput) + ); + } + + /// Checks that an egress destination in another universe is distinguished + /// from a destination that widens the reader set. + #[test] + fn cross_universe_destination_reports_universe_mismatch() { + let mut state = FlowState::default(); + state.observe(&label(0b0011)); + let destination = + ConfidentialityLabel::restricted(2, readers(0b0001)).expect("non-empty readers"); + + assert_eq!( + state.check_egress(&destination), + Err(EgressError::DestinationUniverseMismatch) + ); + } + + /// Checks that combining labels from distinct universes fails closed + /// for all later output. This catches accidental comparison of otherwise + /// identical reader identifiers across unrelated confidentiality universes. + #[test] + fn cross_universe_input_permanently_blocks_egress() { + let mut state = FlowState::default(); + state.observe(&label(0b0011)); + state.observe( + &ConfidentialityLabel::restricted(2, readers(0b0011)).expect("non-empty readers"), + ); + + assert!(state.has_unresolved_input()); + assert_eq!( + state.check_egress(&label(0b0001)), + Err(EgressError::UnresolvedInput) + ); + } +} diff --git a/desktop/package.json b/desktop/package.json index 914b7591135..fb8e3138719 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.22", + "version": "0.5.23", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/scripts/tauri-command.mjs b/desktop/scripts/tauri-command.mjs index dc1d8691e96..bc53d607e72 100644 --- a/desktop/scripts/tauri-command.mjs +++ b/desktop/scripts/tauri-command.mjs @@ -36,11 +36,28 @@ export function runTauriCommand(args) { // Tauri runs beforeBuildCommand and then consumes frontendDist. Give the // entire invocation a private directory so concurrent OSS/internal packages // cannot replace one another's assets between those two operations. - const invocationRoot = mkdtempSync( + let invocationRoot = mkdtempSync( path.join(tmpdir(), "buzz-tauri-package-assets-"), ); + // `frontendDist` deserializes into an untagged enum whose first variant is a + // URL, and a Windows absolute path parses as one -- `C:` becomes the scheme. + // Tauri then embeds zero assets, exits 0, and the app boots to + // ERR_FILE_NOT_FOUND. Hand it a path relative to the config's own directory, + // which can never parse as a URL. If the temp dir is on another drive there + // is no relative form, so put the scratch root beside the config instead. + const configDir = path.join(desktopRoot, "src-tauri"); + const relativeTo = (root) => + path.relative(configDir, path.join(root, "dist")); + if (path.isAbsolute(relativeTo(invocationRoot))) { + rmSync(invocationRoot, { recursive: true, force: true }); + invocationRoot = mkdtempSync( + path.join(desktopRoot, ".buzz-tauri-package-assets-"), + ); + } const frontendDist = path.join(invocationRoot, "dist"); - const outputOverride = JSON.stringify({ build: { frontendDist } }); + const outputOverride = JSON.stringify({ + build: { frontendDist: relativeTo(invocationRoot) }, + }); try { const delimiterIndex = args.indexOf("--"); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 94015065bbb..5facb7439bb 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1082,7 +1082,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.22" +version = "0.5.23" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 7a8b5da279f..0292e6f8467 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.22" +version = "0.5.23" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/src/unread_catch_up.rs b/desktop/src-tauri/src/unread_catch_up.rs index f8609ef1f60..96a740638b9 100644 --- a/desktop/src-tauri/src/unread_catch_up.rs +++ b/desktop/src-tauri/src/unread_catch_up.rs @@ -300,6 +300,7 @@ fn classify_batch( let broadcast = has_exact_tag(&event.tags, "broadcast", "1"); let threaded = reference.parent_id.is_some() && !broadcast; let high_priority = item.channel.channel_type == "dm" + || threaded || broadcast || has_tag_value(&event.tags, "p", &self_pubkey); max_trigger = max_trigger.max(event.created_at); @@ -518,9 +519,9 @@ mod tests { assert_eq!( observed_events .iter() - .map(|event| event.id.as_str()) + .map(|event| (event.id.as_str(), event.high_priority)) .collect::>(), - ["external-reply"] + [("external-reply", true)] ); assert_eq!(discovered.participated, ["root"]); } diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index d69fb0a2385..71c5fc2e049 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.22", + "version": "0.5.23", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index f2d9a89e6b5..60209ac91de 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -908,6 +908,7 @@ export function AppShell() { selectedChannelId={selectedChannelId} selectedView={selectedView} unreadChannelIds={unreadChannelIds} + {...{ highPriorityUnreadChannelIds }} previewActivityChannelIds={unreadThreadChannelIds} unreadChannelCounts={unreadChannelCounts} mutedChannelIds={mutedChannelIds} diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index ceab544d8cb..9e224670622 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -5,7 +5,6 @@ import { } from "@/features/channels/useLiveChannelUpdates"; import { countUnreadAppBadgeObservedEvents, - countUnreadBadgeObservedEvents, countUnreadHighPriorityObservedEvents, countUnreadObservedEvents, hasUnreadTopLevelObservedEvent, @@ -426,13 +425,14 @@ export function useUnreadChannels( const handleChannelMessage = React.useCallback( (channelId: string, event: RelayEvent) => { const channel = channelsRef.current.find((ch) => ch.id === channelId); + const isThreadedReply = + getThreadReference(event.tags).parentId !== null && + !isBroadcastReply(event.tags); const isHighPriority = channel?.channelType === "dm" || + isThreadedReply || (normalizedPubkey !== null && isHighPriorityEventForUser(event, normalizedPubkey)); - const isThreadedReply = - getThreadReference(event.tags).parentId !== null && - !isBroadcastReply(event.tags); const didRecordUnreadEvent = recordUnreadEvent( channelId, makeObservedUnreadEvent({ @@ -852,38 +852,24 @@ export function useUnreadChannels( ) { topLevelUnread.add(channel.id); } - const badgeCount = - nativeProjection?.badgeCount ?? - countUnreadBadgeObservedEvents( - observedEvents, - readAtForObservedEvent, - ); const appBadgeCount = nativeProjection?.appBadgeCount ?? countUnreadAppBadgeObservedEvents( observedEvents, readAtForObservedEvent, ); - // Sidebar numerals on non-DM rows count every unread mention and - // broadcast, including threaded ones. The Dock projection - // (appBadgeCount) keeps excluding threaded replies because Home's - // badge subtotal already counts those; reusing it here would hide - // thread mentions from the channel row. const highPriorityCount = nativeProjection?.highPriorityCount ?? countUnreadHighPriorityObservedEvents( observedEvents, readAtForObservedEvent, ); - counts.set( - channel.id, - channel.channelType === "dm" ? badgeCount : highPriorityCount, - ); + counts.set(channel.id, unreadCount); unreadChannelNotificationCount += appBadgeCount; // DM channels: any unread DM is high-priority. Non-DM: high-priority - // only if at least one mention/broadcast remains unread in its own - // channel/thread context. + // only if at least one mention, broadcast, or relevant thread reply + // remains unread in its own channel/thread context. if (channel.channelType === "dm" || highPriorityCount > 0) { highPriority.add(channel.id); } diff --git a/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs b/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs deleted file mode 100644 index 34f696aa515..00000000000 --- a/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs +++ /dev/null @@ -1,52 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { getOffscreenActivityChannelIds } from "./useOffscreenActivityChannelIds.ts"; -import { getSidebarActivityOverflowLabel } from "./useSidebarActivityOverflow.ts"; - -test("keeps every unread channel navigable while adding working activity", () => { - const activity = getOffscreenActivityChannelIds({ - activeWorkingByChannelId: new Map([["working", {}]]), - previewActivityChannelIds: new Set(["preview"]), - unreadChannelIds: new Set(["dm", "forum", "stream"]), - }); - - assert.deepEqual([...activity.messageChannelIds].sort(), [ - "dm", - "forum", - "preview", - "stream", - ]); - assert.deepEqual([...activity.channelIds].sort(), [ - "dm", - "forum", - "preview", - "stream", - "working", - ]); -}); - -test("keeps working-only channels out of message overflow prioritization", () => { - const activity = getOffscreenActivityChannelIds({ - activeWorkingByChannelId: new Map([["read-working-dm", {}]]), - previewActivityChannelIds: new Set(), - unreadChannelIds: new Set(["unread-channel"]), - }); - - assert.deepEqual([...activity.messageChannelIds], ["unread-channel"]); - assert.deepEqual( - [...activity.channelIds], - ["unread-channel", "read-working-dm"], - ); -}); - -test("uses an activity-neutral overflow label when work contributes", () => { - assert.equal( - getSidebarActivityOverflowLabel({ activityCount: 2, messageCount: 1 }), - "2 new activity", - ); - assert.equal( - getSidebarActivityOverflowLabel({ activityCount: 1, messageCount: 1 }), - undefined, - ); -}); diff --git a/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts b/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts deleted file mode 100644 index aef953efffe..00000000000 --- a/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as React from "react"; - -type OffscreenActivityChannelIds = { - messageChannelIds: ReadonlySet; - channelIds: ReadonlySet; -}; - -export function getOffscreenActivityChannelIds({ - activeWorkingByChannelId, - previewActivityChannelIds, - unreadChannelIds, -}: { - activeWorkingByChannelId: ReadonlyMap; - previewActivityChannelIds: ReadonlySet; - unreadChannelIds: ReadonlySet; -}): OffscreenActivityChannelIds { - // Every unread row must remain navigable, including top-level stream and - // forum unreads that do not have thread-preview activity. - const messageChannelIds = new Set([ - ...unreadChannelIds, - ...previewActivityChannelIds, - ]); - - return { - messageChannelIds, - channelIds: new Set([ - ...messageChannelIds, - ...activeWorkingByChannelId.keys(), - ]), - }; -} - -export function useOffscreenActivityChannelIds(args: { - activeWorkingByChannelId: ReadonlyMap; - previewActivityChannelIds: ReadonlySet; - unreadChannelIds: ReadonlySet; -}) { - const { - activeWorkingByChannelId, - previewActivityChannelIds, - unreadChannelIds, - } = args; - - return React.useMemo( - () => - getOffscreenActivityChannelIds({ - activeWorkingByChannelId, - previewActivityChannelIds, - unreadChannelIds, - }), - [activeWorkingByChannelId, previewActivityChannelIds, unreadChannelIds], - ); -} diff --git a/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts b/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts deleted file mode 100644 index 2dac012bca4..00000000000 --- a/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useOffscreenActivityChannelIds } from "@/features/sidebar/lib/useOffscreenActivityChannelIds"; -import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow"; - -type ActivityOptions = Parameters[0]; -type ScrollRef = Parameters[0]["scrollRef"]; - -export function getSidebarActivityOverflowLabel({ - activityCount, - messageCount, -}: { - activityCount: number; - messageCount: number; -}) { - return activityCount === messageCount - ? undefined - : `${activityCount} new activity`; -} - -export function useSidebarActivityOverflow({ - scrollRef, - ...activityOptions -}: ActivityOptions & { scrollRef: ScrollRef }) { - const { channelIds, messageChannelIds } = - useOffscreenActivityChannelIds(activityOptions); - const activityOverflow = useUnreadOverflow({ - scrollRef, - unreadChannelIds: channelIds, - }); - const messageOverflow = useUnreadOverflow({ - scrollRef, - unreadChannelIds: messageChannelIds, - }); - - return { - ...activityOverflow, - unreadMessageAboveChannelIds: messageOverflow.unreadAboveChannelIds, - unreadMessageBelowChannelIds: messageOverflow.unreadBelowChannelIds, - unreadAboveLabel: getSidebarActivityOverflowLabel({ - activityCount: activityOverflow.unreadAboveCount, - messageCount: messageOverflow.unreadAboveCount, - }), - unreadBelowLabel: getSidebarActivityOverflowLabel({ - activityCount: activityOverflow.unreadBelowCount, - messageCount: messageOverflow.unreadBelowCount, - }), - }; -} diff --git a/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs new file mode 100644 index 00000000000..187586a3e3f --- /dev/null +++ b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + hasHighPriorityOverflow, + sidebarOverflowUnreadLabel, +} from "./useSidebarUnreadOverflow.ts"; + +test("labels the destination total as unread", () => { + assert.equal(sidebarOverflowUnreadLabel(3), "3 unread"); +}); + +test("promotes actionable unread and every offscreen DM", () => { + const actionable = new Set(["mention"]); + const dms = new Set(["dm"]); + + assert.equal(hasHighPriorityOverflow(["channel"], actionable, dms), false); + assert.equal(hasHighPriorityOverflow(["mention"], actionable, dms), true); + assert.equal(hasHighPriorityOverflow(["dm"], actionable, dms), true); + assert.equal( + hasHighPriorityOverflow(["channel", "dm"], actionable, dms), + true, + ); +}); diff --git a/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts new file mode 100644 index 00000000000..7e59c712a4d --- /dev/null +++ b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts @@ -0,0 +1,69 @@ +import * as React from "react"; + +import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow"; + +type ScrollRef = Parameters[0]["scrollRef"]; + +/** + * Returns whether any offscreen destination is a DM or has directed unread + * activity, which should keep the sidebar overflow control emphasized. + */ +export function hasHighPriorityOverflow( + offscreenChannelIds: readonly string[], + highPriorityUnreadChannelIds: ReadonlySet, + dmChannelIds: ReadonlySet, +) { + return offscreenChannelIds.some( + (channelId) => + dmChannelIds.has(channelId) || + highPriorityUnreadChannelIds.has(channelId), + ); +} + +/** Formats the accessible label for a distinct unread destination count. */ +export function sidebarOverflowUnreadLabel(count: number) { + return `${count} unread`; +} + +/** + * Projects unread message and thread activity into offscreen destination sets. + * Message and preview destinations are unioned and deduplicated; DMs and + * destinations with directed unread activity receive high-priority treatment. + */ +export function useSidebarUnreadOverflow({ + dmChannelIds, + highPriorityUnreadChannelIds, + previewActivityChannelIds, + scrollRef, + unreadChannelIds, +}: { + dmChannelIds: ReadonlySet; + highPriorityUnreadChannelIds: ReadonlySet; + previewActivityChannelIds: ReadonlySet; + scrollRef: ScrollRef; + unreadChannelIds: ReadonlySet; +}) { + const messageChannelIds = React.useMemo( + () => new Set([...unreadChannelIds, ...previewActivityChannelIds]), + [previewActivityChannelIds, unreadChannelIds], + ); + const messageOverflow = useUnreadOverflow({ + scrollRef, + unreadChannelIds: messageChannelIds, + }); + + return { + ...messageOverflow, + unreadMessageBelowChannelIds: messageOverflow.unreadBelowChannelIds, + hasHighPriorityAbove: hasHighPriorityOverflow( + messageOverflow.unreadAboveChannelIds, + highPriorityUnreadChannelIds, + dmChannelIds, + ), + hasHighPriorityBelow: hasHighPriorityOverflow( + messageOverflow.unreadBelowChannelIds, + highPriorityUnreadChannelIds, + dmChannelIds, + ), + }; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 647ed632644..93b6defe487 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -20,7 +20,10 @@ import { import { useChannelSortPreference } from "@/features/sidebar/lib/useChannelSortPreference"; import { useSidebarScrollLock } from "@/features/sidebar/lib/useSidebarScrollLock"; import { isSidebarBackgroundTarget } from "@/features/sidebar/lib/sidebarBackgroundTarget"; -import { useSidebarActivityOverflow } from "@/features/sidebar/lib/useSidebarActivityOverflow"; +import { + sidebarOverflowUnreadLabel, + useSidebarUnreadOverflow, +} from "@/features/sidebar/lib/useSidebarUnreadOverflow"; import { CreateSectionDialog, DeleteSectionAlertDialog, @@ -38,7 +41,6 @@ import { MoreUnreadButton, preferredUnreadTarget, } from "@/features/sidebar/ui/MoreUnreadButton"; -import { unreadCountLabel } from "@/shared/ui/UnreadPill"; import { SidebarSection } from "@/features/sidebar/ui/SidebarSection"; import { ChannelGroupSection, @@ -96,6 +98,7 @@ export function AppSidebar({ selectedView, unreadChannelCounts, unreadChannelIds, + highPriorityUnreadChannelIds, previewActivityChannelIds, communities, onAddCommunity, @@ -151,10 +154,22 @@ export function AppSidebar({ const showSidebarUpdateCard = canShowSidebarUpdateCard && !isSidebarUpdateCardDismissed; const [dmActionsMenuOpen, setDmActionsMenuOpen] = React.useState(false); + const allDirectMessages = React.useMemo( + () => channels.filter((channel) => channel.channelType === "dm"), + [channels], + ); + const directMessages = useProtectedVisibleDirectMessages( + allDirectMessages, + currentPubkey, + ); + const dmChannelIds = React.useMemo( + () => new Set(directMessages.map(({ id }) => id)), + [directMessages], + ); const scrollRef = React.useRef(null); useSidebarScrollLock(scrollRef); // biome-ignore format: keep compact to stay within file size limit - const { scrollToChannel, scrollToNextAbove, scrollToNextBelow, unreadAboveCount, unreadBelowCount, unreadMessageBelowChannelIds, unreadAboveLabel, unreadBelowLabel } = useSidebarActivityOverflow({ activeWorkingByChannelId, previewActivityChannelIds, scrollRef, unreadChannelIds }); + const { hasHighPriorityAbove, hasHighPriorityBelow, scrollToChannel, scrollToNextAbove, scrollToNextBelow, unreadAboveCount, unreadBelowCount, unreadMessageBelowChannelIds } = useSidebarUnreadOverflow({ dmChannelIds, highPriorityUnreadChannelIds, previewActivityChannelIds, scrollRef, unreadChannelIds }); React.useEffect(() => { const scrollElement = scrollRef.current; @@ -368,14 +383,6 @@ export function AppSidebar({ ), [channels, sortModeFor], ); - const allDirectMessages = React.useMemo( - () => channels.filter((channel) => channel.channelType === "dm"), - [channels], - ); - const directMessages = useProtectedVisibleDirectMessages( - allDirectMessages, - currentPubkey, - ); const isSelectedDirectMessage = selectedView === "channel" && directMessages.some((channel) => channel.id === selectedChannelId); @@ -540,7 +547,8 @@ export function AppSidebar({ {unreadAboveCount > 0 ? ( toggleCollapsedGroup("starred")} selectedChannelId={selectedChannelId} title="Starred" - unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} @@ -634,7 +641,6 @@ export function AppSidebar({ isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} selectedChannelId={selectedChannelId} - unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} sections={channelSections} assignments={channelAssignments} @@ -705,7 +711,6 @@ export function AppSidebar({ onToggleCollapsed={() => toggleCollapsedGroup("channels")} selectedChannelId={selectedChannelId} title="Channels" - unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} sections={channelSections} assignments={channelAssignments} @@ -744,7 +749,6 @@ export function AppSidebar({ onToggleCollapsed={() => toggleCollapsedGroup("forums")} selectedChannelId={selectedChannelId} title="Forums" - unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} @@ -814,7 +818,8 @@ export function AppSidebar({ bottomClassName="bottom-full" count={unreadBelowCount} dmPreviews={unreadDmPreviewsBelow} - label={unreadBelowLabel ?? unreadCountLabel(unreadBelowCount)} + emphasis={hasHighPriorityBelow ? "primary" : "default"} + label={sidebarOverflowUnreadLabel(unreadBelowCount)} onClick={() => nextUnreadDmBelowId ? scrollToChannel(nextUnreadDmBelowId) diff --git a/desktop/src/features/sidebar/ui/AppSidebar.types.ts b/desktop/src/features/sidebar/ui/AppSidebar.types.ts index 43eb094b3a4..d884c6d5536 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.types.ts +++ b/desktop/src/features/sidebar/ui/AppSidebar.types.ts @@ -48,6 +48,7 @@ export type AppSidebarProps = { | "projects"; unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; + highPriorityUnreadChannelIds: ReadonlySet; previewActivityChannelIds: ReadonlySet; communities: Community[]; onAddCommunity: (community: Community) => void; diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index 9885f130850..7666e704d45 100644 --- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -358,7 +358,6 @@ export function ChannelGroupSection({ onSortModeChange, actionsTestId, title, - unreadChannelCounts, unreadChannelIds, sections, assignments, @@ -406,7 +405,6 @@ export function ChannelGroupSection({ onSortModeChange?: (mode: ChannelSortMode) => void; actionsTestId?: string; title: string; - unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; hasUnread?: boolean; onMarkAllRead?: () => void; @@ -440,7 +438,6 @@ export function ChannelGroupSection({ channel={channel} activeWorking={activeWorkingByChannelId?.get(channel.id)} hasUnread={unreadChannelIds.has(channel.id)} - unreadCount={unreadChannelCounts.get(channel.id) ?? 0} isMuted={mutedChannelIds?.has(channel.id)} isActive={ isActiveChannel && selectedChannelId === channel.id @@ -453,7 +450,6 @@ export function ChannelGroupSection({ channel={channel} activeWorking={activeWorkingByChannelId?.get(channel.id)} hasUnread={unreadChannelIds.has(channel.id)} - unreadCount={unreadChannelCounts.get(channel.id) ?? 0} isMuted={mutedChannelIds?.has(channel.id)} isActive={ isActiveChannel && selectedChannelId === channel.id @@ -548,7 +544,6 @@ export function CustomChannelSection({ isActiveChannel, activeWorkingByChannelId, selectedChannelId, - unreadChannelCounts, unreadChannelIds, sections, assignments, @@ -585,7 +580,6 @@ export function CustomChannelSection({ isActiveChannel: boolean; activeWorkingByChannelId?: ReadonlyMap; selectedChannelId: string | null; - unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; sections: ChannelSection[]; assignments: Record; @@ -744,9 +738,6 @@ export function CustomChannelSection({ channel.id, )} hasUnread={unreadChannelIds.has(channel.id)} - unreadCount={ - unreadChannelCounts.get(channel.id) ?? 0 - } isMuted={mutedChannelIds?.has(channel.id)} isActive={ isActiveChannel && diff --git a/desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs b/desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs index 45b14c31ff2..a1316c96df6 100644 --- a/desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs +++ b/desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs @@ -77,17 +77,7 @@ describe("MoreUnreadButton model", () => { position: "bottom", targetChannelId: "dm", }), - "Go to unread direct message from Alice. 2 new messages below.", - ); - assert.equal( - unreadDmAccessibleLabel({ - count: 2, - dmPreviews: [preview("dm", "Alice")], - label: "2 new activity", - position: "bottom", - targetChannelId: "dm", - }), - "Go to unread direct message from Alice. 2 new activity below.", + "Go to unread direct message from Alice. 2 unread below.", ); assert.equal( unreadDmAccessibleLabel({ @@ -96,7 +86,7 @@ describe("MoreUnreadButton model", () => { position: "bottom", targetChannelId: "near-group", }), - "2 new messages below", + "2 unread below", ); assert.equal( unreadDmAccessibleLabel({ @@ -104,7 +94,7 @@ describe("MoreUnreadButton model", () => { dmPreviews: [], position: "top", }), - "1 new message above", + "1 unread above", ); }); @@ -118,6 +108,7 @@ describe("MoreUnreadButton model", () => { preview("dm-three", "Group DM"), preview("dm-four", "Dana"), ], + emphasis: "primary", onClick() {}, position: "bottom", targetChannelId: "dm-one", @@ -126,13 +117,10 @@ describe("MoreUnreadButton model", () => { ); assert.match(markup, /class="[^"]*overflow-hidden[^"]*"/); + assert.match(markup, /5 unread<\/span>/); assert.match( markup, - /5 new messages<\/span>/, - ); - assert.match( - markup, - /aria-label="Go to unread direct message from Alice\. 5 new messages below\."/, + /aria-label="Go to unread direct message from Alice\. 5 unread below\."/, ); assert.doesNotMatch(markup, />Next<\/span>/); assert.match(markup, />·<\/span>/); diff --git a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx index 581f2e59868..eec2cea303b 100644 --- a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx +++ b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx @@ -1,6 +1,6 @@ import { topChromeInset } from "@/shared/layout/chromeLayout"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill"; +import { UnreadPill } from "@/shared/ui/UnreadPill"; export type UnreadDmPreview = { accessibleLabel: string; @@ -35,7 +35,7 @@ export function unreadDmAccessibleLabel({ targetChannelId?: string; }) { const direction = position === "top" ? "above" : "below"; - const resolvedLabel = label ?? unreadCountLabel(count); + const resolvedLabel = label ?? `${count} unread`; const targetPreview = dmPreviews.find( ({ channelId }) => channelId === targetChannelId, ); @@ -58,6 +58,7 @@ export function MoreUnreadButton({ bottomClassName = "bottom-0", count, dmPreviews = [], + emphasis, label, onClick, position, @@ -67,6 +68,7 @@ export function MoreUnreadButton({ bottomClassName?: string; count: number; dmPreviews?: UnreadDmPreview[]; + emphasis: "default" | "primary"; label?: string; onClick: () => void; position: "top" | "bottom"; @@ -76,7 +78,7 @@ export function MoreUnreadButton({ const positionClassName = position === "top" ? topChromeInset.top : bottomClassName; const visibleDmPreviews = visibleUnreadDmPreviews(dmPreviews); - const resolvedLabel = label ?? unreadCountLabel(count); + const resolvedLabel = label ?? `${count} unread`; const accessibleLabel = unreadDmAccessibleLabel({ count, dmPreviews, @@ -91,9 +93,9 @@ export function MoreUnreadButton({ > 0 ? ( diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 6386d9b3def..0100ffce855 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -247,7 +247,6 @@ export function ChannelMenuButton({ label, isActive, hasUnread, - unreadCount = 0, activeWorking, isMuted, dmParticipants, @@ -258,7 +257,6 @@ export function ChannelMenuButton({ label?: string; isActive: boolean; hasUnread: boolean; - unreadCount?: number; activeWorking?: ActiveChannelTurnSummary; isMuted?: boolean; dmParticipants?: SidebarDmParticipant[]; @@ -267,35 +265,19 @@ export function ChannelMenuButton({ }) { const resolvedLabel = label ?? channel.name; const ephemeralDisplay = getEphemeralChannelDisplay(channel); - const { - hasSidebarUnreadProjections, - topLevelUnreadChannelIds, - unreadThreadChannelIds, - } = useAppShell(); - const hasTopLevelUnread = - channel.channelType === "dm" - ? hasUnread - : hasSidebarUnreadProjections - ? topLevelUnreadChannelIds.has(channel.id) - : hasUnread; + const { hasSidebarUnreadProjections, unreadThreadChannelIds } = useAppShell(); const hasThreadUnread = channel.channelType !== "dm" && (hasSidebarUnreadProjections ? unreadThreadChannelIds.has(channel.id) : hasUnread); - const showsUnreadCount = - !isActive && channel.channelType !== "dm" && unreadCount > 0; const showsEphemeralBadge = - Boolean(ephemeralDisplay) && - !activeWorking && - !isMuted && - !showsUnreadCount && - !hasThreadUnread; + Boolean(ephemeralDisplay) && !activeWorking && !isMuted && !hasThreadUnread; const inactiveContentOpacity = cn( - !isActive && !hasTopLevelUnread && !isMuted && "opacity-80", + !isActive && !hasUnread && !isMuted && "opacity-80", !isActive && isMuted && - !hasTopLevelUnread && + !hasUnread && !hasThreadUnread && "sidebar-muted-content opacity-50 dark:opacity-45", ); @@ -307,7 +289,7 @@ export function ChannelMenuButton({ isActive ? "group-hover/menu-item:bg-sidebar-active group-hover/menu-item:text-sidebar-active-foreground" : "group-hover/menu-item:bg-sidebar-accent group-hover/menu-item:text-sidebar-foreground", - hasTopLevelUnread && + hasUnread && "font-bold text-sidebar-foreground hover:text-sidebar-foreground data-[active=true]:font-bold", )} data-channel-id={channel.id} @@ -373,13 +355,7 @@ export function ChannelMenuButton({ )} /> ) : null} - {showsUnreadCount ? ( - - ) : hasThreadUnread ? ( + {hasThreadUnread ? ( ) : null} @@ -502,7 +478,6 @@ export function SidebarSection({ activeWorking={activeWorkingByChannelId?.get(channel.id)} dmParticipants={dmParticipantsByChannelId?.[channel.id]} hasUnread={unreadChannelIds.has(channel.id)} - unreadCount={unreadChannelCounts.get(channel.id) ?? 0} isMuted={mutedChannelIds?.has(channel.id)} isActive={ isActiveChannel && selectedChannelId === channel.id diff --git a/desktop/src/protectedFeatures/tauriCommand.test.mjs b/desktop/src/protectedFeatures/tauriCommand.test.mjs index e3e1532af45..0902be92b4f 100644 --- a/desktop/src/protectedFeatures/tauriCommand.test.mjs +++ b/desktop/src/protectedFeatures/tauriCommand.test.mjs @@ -10,6 +10,7 @@ const desktopRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../..", ); +const configDir = path.join(desktopRoot, "src-tauri"); const wrapper = path.join(desktopRoot, "scripts/tauri-command.mjs"); const fakeCli = path.join(tmpdir(), `buzz-fake-tauri-${process.pid}.mjs`); @@ -20,14 +21,21 @@ import path from "node:path"; const args = process.argv.slice(2); const configIndex = args.lastIndexOf("--config"); const override = JSON.parse(args[configIndex + 1]); -const output = override.build.frontendDist; -mkdirSync(output, { recursive: true }); -writeFileSync(path.join(output, "variant.txt"), process.env.VITE_BUZZ_BESTIE); +const configured = override.build.frontendDist; +// Tauri resolves frontendDist against the directory holding tauri.conf.json +// (config_parent.join(path) in tauri-codegen), not against the process cwd. +const output = path.resolve(process.env.BUZZ_TEST_CONFIG_DIR, configured); +// Write through the producer path the wrapper publishes and read back through +// the config-resolved consumer path. Doing both against one path would make the +// fake agree with itself no matter where the wrapper pointed frontendDist. +const producer = process.env.BUZZ_PROTECTED_BUILD_OUTPUT; +mkdirSync(producer, { recursive: true }); +writeFileSync(path.join(producer, "variant.txt"), process.env.VITE_BUZZ_BESTIE); await new Promise((resolve) => setTimeout(resolve, 100)); const observed = readFileSync(path.join(output, "variant.txt"), "utf8"); writeFileSync( process.env.BUZZ_TEST_RESULT, - JSON.stringify({ args, output, observed }), + JSON.stringify({ args, configured, output, observed }), ); `, ); @@ -42,6 +50,7 @@ function packageVariant(variant, result, runnerArguments = []) { env: { ...process.env, BUZZ_TAURI_CLI_ENTRYPOINT: fakeCli, + BUZZ_TEST_CONFIG_DIR: configDir, BUZZ_TEST_RESULT: result, VITE_BUZZ_BESTIE: variant, }, @@ -92,6 +101,30 @@ test("private config precedes Cargo runner arguments", async () => { assert.equal(invocation.args[delimiterIndex + 1], "--locked"); assert.equal( JSON.parse(invocation.args[privateConfigIndex + 1]).build.frontendDist, - invocation.output, + invocation.configured, ); }); + +test("private frontendDist is never mistaken for a URL", async () => { + const result = path.join( + tmpdir(), + `buzz-tauri-frontend-dist-${process.pid}.json`, + ); + await packageVariant("0", result); + const invocation = JSON.parse(readFileSync(result, "utf8")); + + // `FrontendDist` is an untagged enum whose first variant is `Url(Url)`, and + // tauri-codegen embeds *no assets without erroring* for that variant. A + // Windows absolute path parses as a URL -- `C:` becomes the scheme -- so an + // absolute frontendDist produces a UI-less app that still exits 0. + assert.ok( + !path.isAbsolute(invocation.configured), + `frontendDist must stay relative, got ${invocation.configured}`, + ); + // Rust's `url` crate and Node's `URL` both implement the WHATWG standard, so + // this is the same parse serde performs. It only rejects absolute paths on + // Windows, which is why the assertion above carries the check on Linux/macOS. + assert.throws(() => new URL(invocation.configured)); + // The relative path still has to reach the directory the wrapper published. + assert.equal(invocation.observed, "0"); +}); diff --git a/desktop/src/shared/ui/UnreadPill.tsx b/desktop/src/shared/ui/UnreadPill.tsx index f24f762f1da..7afab32210b 100644 --- a/desktop/src/shared/ui/UnreadPill.tsx +++ b/desktop/src/shared/ui/UnreadPill.tsx @@ -4,10 +4,12 @@ import type { ReactNode } from "react"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; -const UNREAD_PILL_CLASS = - "pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/70 bg-background/95 px-2 py-1 text-2xs font-medium tracking-[0.02em] text-muted-foreground/70 shadow-xs backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-4"; -const PRIMARY_UNREAD_PILL_CLASS = - "pointer-events-auto h-7 min-h-7 max-w-[calc(100%_-_1rem)] overflow-hidden gap-1.5 rounded-full px-2 py-1 text-xs font-medium shadow-sm [&_svg]:size-4"; +const UNREAD_PILL_COMPOSITION_CLASS = + "pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border px-2 py-1 text-2xs font-medium tracking-[0.02em] shadow-xs [&_svg]:size-4"; +const DEFAULT_UNREAD_PILL_TREATMENT_CLASS = + "border-border/70 bg-background/95 text-muted-foreground/70 backdrop-blur-sm hover:bg-muted/70 hover:text-foreground"; +const PRIMARY_UNREAD_PILL_TREATMENT_CLASS = + "border-primary bg-primary text-primary-foreground hover:bg-primary/90"; export function unreadCountLabel(count: number) { return `${count} new message${count === 1 ? "" : "s"}`; @@ -37,7 +39,10 @@ export function UnreadPill({