Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
377bac3
docs(plan): phase 2b — sync-queue implementation plan
intendednull Apr 21, 2026
2b8887a
ui(phase-2b): add DeliveryState to willow-messaging::store
intendednull Apr 21, 2026
804c323
ui(phase-2b): add pure queue primitives + derivation helpers
intendednull Apr 21, 2026
bc383ad
ui(phase-2b): expose relay_status + device_online on Network trait
intendednull Apr 21, 2026
3aa1323
ui(phase-2b): add QueueMeta actor + plumb into ClientHandle
intendednull Apr 21, 2026
abf83b0
ui(phase-2b): derive real QueueNote + close Phase 2a TODO
intendednull Apr 21, 2026
3f4a5e5
ui(phase-2b): add queue_view + retry_queue + mark_queue_read to Clien…
intendednull Apr 21, 2026
c5d3107
ui(phase-2b): plumb device_online + relay_status + queue_view into Ap…
intendednull Apr 21, 2026
357e128
ui(phase-2b): add OfflineStrip + QueuePill + InlineQueueNote + toast/…
intendednull Apr 21, 2026
9ac5a0e
ui(phase-2b): mount QueuePill on member rows + wire InlineQueueNote
intendednull Apr 21, 2026
99f8283
ui(phase-2b): add SyncQueueView screen — header, tabs, rows, footer
intendednull Apr 21, 2026
512722f
docs(phase-2b): tick remaining plan boxes + flag deferrals
intendednull Apr 21, 2026
e5adfaa
ci(phase-2b): cover new NotificationKind variants in e2e roundtrip test
intendednull Apr 21, 2026
f2a05a2
ci(phase-2b): gate wasm-bindgen call site for willow-client dual-target
intendednull Apr 21, 2026
731049b
ui(phase-2b): gate reconnection toast + welcome-back banner on 60 s o…
intendednull Apr 21, 2026
9535748
ui(phase-2b): route sync-queue components through sync_queue_copy module
intendednull Apr 21, 2026
e4a1a0d
ui(phase-2b): add RelaySignalButton with reachable / unreachable states
intendednull Apr 21, 2026
e93b5ea
test(phase-2b): add phase_2b_sync_queue browser test module
intendednull Apr 21, 2026
70db3c7
docs(phase-2b): tick browser tests + 60 s gate + RelaySignalButton boxes
intendednull Apr 21, 2026
6df03cc
merge: main into docs/plan-sync-queue
intendednull Apr 21, 2026
e1c6259
Merge remote-tracking branch 'origin/main' into docs/plan-sync-queue
intendednull Apr 25, 2026
fbd6ccd
Merge remote-tracking branch 'origin/main' into docs/plan-sync-queue
intendednull Apr 25, 2026
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
1 change: 1 addition & 0 deletions Cargo.lock

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

47 changes: 42 additions & 5 deletions crates/agent/src/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,32 @@ pub fn event_to_json(event: &ClientEvent) -> serde_json::Value {
"muted": muted,
}),
}),
ClientEvent::QueueChanged(view) => to_value(&NotificationPayload {
r#type: "QueueChanged",
data: serde_json::json!({
"depth": view.depth,
"peer_count": view.peer_count,
"device_online": view.device_online,
}),
}),
ClientEvent::RelayStatusChanged(status) => to_value(&NotificationPayload {
r#type: "RelayStatusChanged",
data: serde_json::json!({
"status": match status {
willow_client::RelayStatus::Reachable => "reachable",
willow_client::RelayStatus::Unreachable => "unreachable",
willow_client::RelayStatus::NotConfigured => "not_configured",
},
}),
}),
ClientEvent::DeviceOnlineChanged(online) => to_value(&NotificationPayload {
r#type: "DeviceOnlineChanged",
data: serde_json::json!({ "online": online }),
}),
}
}

/// All 28 event type names for validation.
/// All 31 event type names for validation.
pub const EVENT_TYPE_NAMES: &[&str] = &[
"MessageReceived",
"MessageEdited",
Expand Down Expand Up @@ -258,6 +280,10 @@ pub const EVENT_TYPE_NAMES: &[&str] = &[
"JoinLinkResponse",
"JoinLinkDenied",
"MuteChanged",
// Phase 2b sync-queue variants.
"QueueChanged",
"RelayStatusChanged",
"DeviceOnlineChanged",
];

#[derive(Serialize)]
Expand All @@ -276,8 +302,8 @@ mod tests {
use willow_identity::Identity;

#[test]
fn all_28_event_types_listed() {
assert_eq!(EVENT_TYPE_NAMES.len(), 28);
fn all_31_event_types_listed() {
assert_eq!(EVENT_TYPE_NAMES.len(), 31);
}

#[test]
Expand Down Expand Up @@ -402,9 +428,20 @@ mod tests {
ClientEvent::JoinLinkDenied {
reason: "no".into(),
},
ClientEvent::MuteChanged {
scope: willow_client::events::MuteScope::Grove,
muted: true,
},
ClientEvent::QueueChanged(willow_client::views::QueueView::default()),
ClientEvent::RelayStatusChanged(willow_client::RelayStatus::Reachable),
ClientEvent::DeviceOnlineChanged(true),
];
// All 27 events
assert_eq!(events.len(), 27, "should test all 27 event variants");
// One entry per `ClientEvent` variant — mirrors `EVENT_TYPE_NAMES`.
assert_eq!(
events.len(),
EVENT_TYPE_NAMES.len(),
"should test every ClientEvent variant"
);
for event in &events {
let json = event_to_json(event);
assert!(json.is_object(), "expected object for {event:?}");
Expand Down
12 changes: 8 additions & 4 deletions crates/agent/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -786,10 +786,14 @@ async fn read_voice_status_resource() {

#[tokio::test]
async fn notification_serialization_covers_all_variants() {
// Verify that event_to_json produces valid output for all 28 event types.
// This test complements the unit tests in notifications.rs by running
// in the integration test context.
assert_eq!(willow_agent::notifications::EVENT_TYPE_NAMES.len(), 28);
// Verify that event_to_json produces valid output for every event type
// in `EVENT_TYPE_NAMES`. This test complements the unit tests in
// `notifications.rs` by running in the integration test context.
//
// The count is pinned to 31 — one entry per `ClientEvent` variant.
// When a new variant is added, bump this assertion and extend the
// `notifications::event_to_json` match + `EVENT_TYPE_NAMES` list.
assert_eq!(willow_agent::notifications::EVENT_TYPE_NAMES.len(), 31);

for name in willow_agent::notifications::EVENT_TYPE_NAMES {
assert!(!name.is_empty(), "event type name should not be empty");
Expand Down
13 changes: 12 additions & 1 deletion crates/client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,19 @@ dirs = "6"
rusqlite = { version = "0.31", features = ["bundled"] }

[target.'cfg(target_arch = "wasm32")'.dependencies]
# `wasm-bindgen` (alongside `web-sys` / `wasm-bindgen-futures`) is required
# by the Phase 2b online / offline listener in `connect.rs` — the whole
# block is `#[cfg(target_arch = "wasm32")]`-gated so the dep stays
# WASM-only. Matches the pattern already established for `gloo-timers`
# and the presence / queue tick drivers.
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Storage"] }
web-sys = { version = "0.3", features = [
"Window",
"Storage",
"Navigator",
"EventTarget",
] }
js-sys = "0.3"
gloo-timers = { version = "0.3", features = ["futures"] }

Expand Down
94 changes: 94 additions & 0 deletions crates/client/src/connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,87 @@ async fn tick_once(
.await;
}

/// Phase 2b — queue tick driver. 1 tick / s. Advances
/// `QueueMeta::now`, then decays `recent_arrivals` entries older than
/// 24 h.
fn spawn_queue_tick(
queue_meta_addr: willow_actor::Addr<willow_actor::StateActor<state_actors::QueueMeta>>,
) {
const DECAY_TICKS: crate::presence::Tick = 86_400; // 24 h in seconds
#[cfg(not(target_arch = "wasm32"))]
{
if let Ok(rt) = tokio::runtime::Handle::try_current() {
rt.spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
queue_tick_once(&queue_meta_addr, DECAY_TICKS).await;
}
});
}
}
#[cfg(target_arch = "wasm32")]
{
wasm_bindgen_futures::spawn_local(async move {
loop {
gloo_timers::future::TimeoutFuture::new(1_000).await;
queue_tick_once(&queue_meta_addr, DECAY_TICKS).await;
}
});
}
}

async fn queue_tick_once(
queue_meta_addr: &willow_actor::Addr<willow_actor::StateActor<state_actors::QueueMeta>>,
decay_ticks: crate::presence::Tick,
) {
willow_actor::state::mutate(queue_meta_addr, move |qm| {
qm.now = qm.now.saturating_add(1);
qm.decay_arrivals(decay_ticks);
})
.await;
}

/// WASM-only: listen for `window.online` + `window.offline` events and
/// route them through `ClientMutations::set_device_online`. Called from
/// [`ClientHandle::connect`] once per connection.
#[cfg(target_arch = "wasm32")]
fn spawn_wasm_online_listener<N: willow_network::Network>(
mutations: crate::mutations::ClientMutations<N>,
) {
let Some(window) = web_sys::window() else {
return;
};
// Prime from `navigator.onLine`.
let online_now = window.navigator().on_line();
{
let mutations = mutations.clone();
wasm_bindgen_futures::spawn_local(async move {
mutations.set_device_online(online_now).await;
});
}
// Online listener.
let online_mutations = mutations.clone();
let online_cb = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
let mutations = online_mutations.clone();
wasm_bindgen_futures::spawn_local(async move {
mutations.set_device_online(true).await;
});
});
// Offline listener.
let offline_mutations = mutations;
let offline_cb = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
let mutations = offline_mutations.clone();
wasm_bindgen_futures::spawn_local(async move {
mutations.set_device_online(false).await;
});
});
use wasm_bindgen::JsCast;
let _ = window.add_event_listener_with_callback("online", online_cb.as_ref().unchecked_ref());
let _ = window.add_event_listener_with_callback("offline", offline_cb.as_ref().unchecked_ref());
online_cb.forget();
offline_cb.forget();
}

impl<N: willow_network::Network> ClientHandle<N> {
/// Connect to the P2P network.
pub async fn connect(
Expand Down Expand Up @@ -247,6 +328,19 @@ impl<N: willow_network::Network> ClientHandle<N> {
// climbs past the idle / gone thresholds in due course.
spawn_presence_tick(self.presence_meta_addr.clone(), self.chat_meta_addr.clone());

// Sync-queue tick driver (Phase 2b). Advances `QueueMeta::now`
// and decays `recent_arrivals` entries older than 24 h so the
// sync-queue screen's Recent section rolls forward even when
// nothing else mutates the actor.
spawn_queue_tick(self.queue_meta_addr.clone());

// WASM-only: bridge `window.online` / `window.offline` events to
// `QueueMeta::set_device_online`. Native iroh doesn't expose a
// connectivity probe yet; `Network::device_online` default-stays
// `true` there.
#[cfg(target_arch = "wasm32")]
spawn_wasm_online_listener(self.mutation_handle.clone());

self.broadcast_profile_via_network();
// Also announce via SERVER_OPS_TOPIC for peers that have a sync path
// but may not have received the PROFILE_TOPIC broadcast.
Expand Down
12 changes: 12 additions & 0 deletions crates/client/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,18 @@ pub enum ClientEvent {
scope: MuteScope,
muted: bool,
},
/// Sync-queue aggregate snapshot changed (Phase 2b). Re-emitted
/// after any `QueueMeta` mutation the UI surfaces care about
/// (enqueue, ack, retry, arrival bucket, relay / device signal).
///
/// Payload is the fresh `QueueView`. The web crate pipes this into
/// `AppState.queue.view` via `event_processing.rs`.
QueueChanged(crate::views::QueueView),
/// Relay reachability transitioned (Phase 2b).
RelayStatusChanged(crate::queue::RelayStatus),
/// Device-online signal transitioned (Phase 2b). Consumed by the
/// reconnection-toast + welcome-back-banner components.
DeviceOnlineChanged(bool),
}

impl willow_actor::Message for ClientEvent {
Expand Down
Loading
Loading