Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
caede3f
docs(plan): phase 2c — profile-card implementation plan
intendednull Apr 21, 2026
406b90f
ui(phase-2c): extend Profile with pronouns/bio/crest/elsewhere fields
intendednull Apr 21, 2026
c940fb3
ui(phase-2c): add EventKind::UpdateProfile + materialize + caps
intendednull Apr 21, 2026
e867e02
ui(phase-2c): box UpdateProfile delta to keep EventKind lean
intendednull Apr 21, 2026
784c69e
ui(phase-2c): add local-only NicknameStore trait + MemNicknameStore
intendednull Apr 21, 2026
0fbcb46
ui(phase-2c): add ProfileView + ProfileDelta + peer_fingerprint
intendednull Apr 21, 2026
decc024
ui(phase-2c): scaffold profile module — bus + controller + crest + ni…
intendednull Apr 21, 2026
e8e01ff
ui(phase-2c): render 17-field ProfileCardContent leaf
intendednull Apr 21, 2026
3751b9b
ui(phase-2c): mount desktop popover + mobile sheet wrappers
intendednull Apr 21, 2026
b2ab2de
ui(phase-2c): wire avatar clicks on every surface to open_profile
intendednull Apr 21, 2026
78156f5
ui(phase-2c): add phase_2c_profile_card browser test module
intendednull Apr 21, 2026
c8684ae
ui(phase-2c): wire profile-card a11y — focus move + restore
intendednull Apr 21, 2026
cbb6b01
ui(phase-2c): acceptance sweep + tick plan checkboxes
intendednull Apr 21, 2026
11f693f
ui(phase-2c): fmt pass on tests + popover
intendednull Apr 21, 2026
b7f2de0
ci: re-trigger CI
intendednull Apr 21, 2026
2dfeba4
merge: main into phase-2c/profile-card
intendednull Apr 21, 2026
3fe9015
ci(phase-2c): swap deprecated EventInit.bubbles() for set_bubbles()
intendednull Apr 21, 2026
127fdd8
ci(phase-2c): drop unused mut on EventInit — set_bubbles takes &self
intendednull Apr 21, 2026
4caabff
Merge remote-tracking branch 'origin/main' into phase-2c/profile-card
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
8 changes: 8 additions & 0 deletions crates/client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub mod invite;
pub mod listeners;
pub mod mentions;
pub mod mutations;
pub mod nickname;
pub mod ops;
pub mod persistence_actor;
pub mod presence;
Expand All @@ -53,13 +54,18 @@ mod tests_trust_flow;
#[path = "tests/multi_peer_sync.rs"]
mod tests_multi_peer_sync;

#[cfg(test)]
#[path = "tests/profile_view.rs"]
mod tests_profile_view;

/// How long a typing indicator remains visible after the last typing event, in milliseconds.
pub const TYPING_INDICATOR_TTL_MS: u64 = 5_000;

// Re-export key types at crate root for convenience.
pub use event_receiver::EventReceiver;
pub use events::ClientEvent;
pub use mentions::mentions_me;
pub use nickname::{MemNicknameStore, NicknameStore, NicknameStoreHandle, NICKNAME_CAP};
pub use ops::{pack_wire, unpack_wire, VoiceSignalPayload, WireMessage};
pub use trust::{
ComparePreview, InMemoryTrustStore, PeerTrust, TrustStore, TrustStoreHandle, UnverifiedReason,
Expand Down Expand Up @@ -144,6 +150,8 @@ pub mod event_receiver {
}
}
pub use state::{DisplayMessage, QueueNote};
pub use views::{since_hint, ProfileDelta, ProfileView};
pub use willow_state::{CrestPattern, PinnedFragment, PinnedKind};

// ClientState, ServerContext, ChatState, ProfileStore are used internally
// during initialization only (loading from storage → populating domain actors).
Expand Down
17 changes: 17 additions & 0 deletions crates/client/src/mutations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,23 @@ impl<N: willow_network::Network> ClientMutations<N> {
.ok();
}

/// Build + apply + broadcast an [`EventKind::UpdateProfile`].
///
/// Spec: `docs/specs/2026-04-19-ui-design/profile-card.md`
/// §Editing — self. Called from the Settings Profile tab; the
/// popover itself never inlines edits.
pub async fn update_profile_fields(
&self,
delta: crate::views::ProfileDelta,
) -> anyhow::Result<()> {
let event = self
.build_event(EventKind::UpdateProfile(Box::new(delta)))
.await?;
self.apply_event(&event).await;
self.broadcast_event(&event);
Ok(())
}

/// Update a peer's display name from a profile broadcast.
pub async fn update_profile(&self, peer_id: EndpointId, display_name: String) {
let name = display_name.clone();
Expand Down
155 changes: 155 additions & 0 deletions crates/client/src/nickname.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//! Local-only peer nicknames.
//!
//! Spec: `docs/specs/2026-04-19-ui-design/profile-card.md`
//! §Private nickname. Nicknames never propagate — they live alongside
//! the trust store in browser localStorage. This crate owns the trait;
//! the web crate ships the `WebNicknameStore` impl.

use std::collections::HashMap;
use std::sync::{Arc, RwLock};

/// Cap on nickname length in UTF-8 characters. Spec §Private nickname.
pub const NICKNAME_CAP: usize = 32;

/// Trait for an opaque, local-only per-peer nickname store.
///
/// Implementations MUST persist writes durably within the lifetime of
/// the session (e.g. localStorage on web, on-disk file natively). The
/// `version` counter increments on every successful mutation so
/// reactive UIs can bump a signal.
pub trait NicknameStore: Send + Sync {
/// Return the stored nickname for `peer_id`, or `None`.
fn get(&self, peer_id: &str) -> Option<String>;
/// Persist `value` (truncated to [`NICKNAME_CAP`]). Pass empty to clear.
fn set(&self, peer_id: &str, value: &str);
/// Remove the entry for `peer_id`. Equivalent to `set(peer_id, "")`.
fn clear(&self, peer_id: &str);
/// Current version counter — bumps on every mutation.
fn version(&self) -> u64;
/// Full snapshot as `(peer_id, nickname)` pairs.
fn snapshot(&self) -> Vec<(String, String)>;
}

/// Handle type matching the `TrustStoreHandle` shape.
pub type NicknameStoreHandle = Arc<dyn NicknameStore>;

/// In-memory implementation for tests + native builds.
#[derive(Default)]
pub struct MemNicknameStore {
inner: RwLock<HashMap<String, String>>,
version: RwLock<u64>,
}

impl NicknameStore for MemNicknameStore {
fn get(&self, peer_id: &str) -> Option<String> {
self.inner.read().ok()?.get(peer_id).cloned()
}

fn set(&self, peer_id: &str, value: &str) {
let trimmed: String = value.chars().take(NICKNAME_CAP).collect();
if trimmed.is_empty() {
self.clear(peer_id);
return;
}
if let Ok(mut guard) = self.inner.write() {
guard.insert(peer_id.to_string(), trimmed);
}
if let Ok(mut v) = self.version.write() {
*v += 1;
}
}

fn clear(&self, peer_id: &str) {
let mut did_remove = false;
if let Ok(mut guard) = self.inner.write() {
did_remove = guard.remove(peer_id).is_some();
}
if did_remove {
if let Ok(mut v) = self.version.write() {
*v += 1;
}
}
}

fn version(&self) -> u64 {
self.version.read().map(|g| *g).unwrap_or(0)
}

fn snapshot(&self) -> Vec<(String, String)> {
self.inner
.read()
.map(|g| g.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default()
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn mem_store_set_and_get_round_trip() {
let s = MemNicknameStore::default();
s.set("alice", "mira");
assert_eq!(s.get("alice").as_deref(), Some("mira"));
}

#[test]
fn mem_store_get_missing_is_none() {
let s = MemNicknameStore::default();
assert!(s.get("ghost").is_none());
}

#[test]
fn mem_store_clear_removes_entry() {
let s = MemNicknameStore::default();
s.set("alice", "mira");
s.clear("alice");
assert_eq!(s.get("alice"), None);
}

#[test]
fn mem_store_empty_value_clears() {
let s = MemNicknameStore::default();
s.set("alice", "mira");
s.set("alice", "");
assert!(s.get("alice").is_none());
}

#[test]
fn mem_store_version_bumps_on_mutation() {
let s = MemNicknameStore::default();
let v0 = s.version();
s.set("alice", "mira");
let v1 = s.version();
s.clear("alice");
let v2 = s.version();
assert!(v1 > v0);
assert!(v2 > v1);
}

#[test]
fn mem_store_caps_at_nickname_cap_chars() {
let s = MemNicknameStore::default();
// 100 x 'a' — should truncate to NICKNAME_CAP chars on set.
let long = "a".repeat(100);
s.set("alice", &long);
assert_eq!(s.get("alice").unwrap().chars().count(), NICKNAME_CAP);
}

#[test]
fn mem_store_snapshot_returns_all_entries() {
let s = MemNicknameStore::default();
s.set("alice", "mira");
s.set("bob", "rob");
let mut snap = s.snapshot();
snap.sort();
assert_eq!(
snap,
vec![
("alice".to_string(), "mira".to_string()),
("bob".to_string(), "rob".to_string()),
]
);
}
}
155 changes: 155 additions & 0 deletions crates/client/src/tests/profile_view.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//! Tests for `ProfileView` derivation + `since_hint` + the
//! `update_profile_fields` mutation.
//!
//! Spec: `docs/specs/2026-04-19-ui-design/profile-card.md`.

use crate::{since_hint, test_client, ProfileDelta, ProfileView};
use willow_state::{CrestPattern, PinnedFragment, PinnedKind};

/// Apply a `ProfileDelta` through the real mutation path, then return a
/// freshly-built `ProfileView` for the local peer.
async fn apply_and_view(delta: ProfileDelta) -> ProfileView {
let (client, _broker) = test_client();
client
.mutations()
.update_profile_fields(delta)
.await
.expect("update_profile_fields must succeed");
let local = client.identity().endpoint_id();
client.views().profile_view_of(&local, &local).await
}

#[tokio::test]
async fn profile_view_reads_updated_fields() {
let v = apply_and_view(ProfileDelta {
display_name: Some("mira".into()),
pronouns: Some(Some("she/her".into())),
bio: Some(Some("gardener".into())),
tagline: Some(Some("tending the moss".into())),
crest_pattern: Some(Some(CrestPattern::Fronds)),
crest_color: Some(Some("#6b8e4e".into())),
pinned: Some(Some(PinnedFragment {
kind: PinnedKind::Quote,
body: "quiet is a kind of music".into(),
})),
elsewhere: Some(vec!["coast · west".into()]),
since: Some(Some("spring · yr 2".into())),
})
.await;
assert_eq!(v.display_name, "mira");
assert_eq!(v.pronouns.as_deref(), Some("she/her"));
assert_eq!(v.bio.as_deref(), Some("gardener"));
assert_eq!(v.tagline.as_deref(), Some("tending the moss"));
assert_eq!(v.crest_pattern, Some(CrestPattern::Fronds));
assert_eq!(v.crest_color.as_deref(), Some("#6b8e4e"));
assert_eq!(v.elsewhere, vec!["coast · west".to_string()]);
assert_eq!(v.since.as_deref(), Some("spring · yr 2"));
assert!(v.is_self);
// Fingerprint is 6 words joined by ` · ` — short form is the first 3.
assert_eq!(v.fingerprint_full.split(" · ").count(), 6);
assert_eq!(v.fingerprint_short.split(" · ").count(), 3);
}

#[tokio::test]
async fn profile_view_defaults_crest_to_none_for_missing_fields() {
// No UpdateProfile ever applied — every optional field starts None.
let (client, _broker) = test_client();
let local = client.identity().endpoint_id();
let v = client.views().profile_view_of(&local, &local).await;
assert!(v.crest_pattern.is_none());
assert!(v.crest_color.is_none());
assert!(v.bio.is_none());
assert!(v.pinned.is_none());
assert!(v.elsewhere.is_empty());
// UI falls back to Leaf / --moss-2 at render time; the derivation
// itself preserves the "unset" signal.
}

#[tokio::test]
async fn profile_view_is_self_matches_local_peer() {
let (client, _broker) = test_client();
let local = client.identity().endpoint_id();
let v = client.views().profile_view_of(&local, &local).await;
assert!(v.is_self);
// Querying for a different peer should clear is_self.
let other = willow_identity::Identity::generate().endpoint_id();
let v2 = client.views().profile_view_of(&other, &local).await;
assert!(!v2.is_self);
}

#[tokio::test]
async fn profile_view_handle_derives_from_peer_id() {
let (client, _broker) = test_client();
let local = client.identity().endpoint_id();
let v = client.views().profile_view_of(&local, &local).await;
// The handle is derived from the peer id string; it must be non-empty
// and shorter than the full 64-hex peer id.
assert!(!v.handle.is_empty());
assert!(v.handle.len() < v.peer_id.len());
}

#[test]
fn since_hint_format_contains_season_and_year() {
let earliest = 1_714_000_000_000u64; // somewhere in 2024
let now = earliest + 2 * 365 * 86_400_000;
let s = since_hint(earliest, now);
assert!(
s.starts_with("spring")
|| s.starts_with("summer")
|| s.starts_with("fall")
|| s.starts_with("winter"),
"season missing from '{s}'"
);
assert!(s.contains("yr 2"), "year offset missing from '{s}'");
}

#[test]
fn since_hint_defaults_to_yr_1_when_earliest_equals_now() {
// A just-joined peer still renders at least "yr 1" — spec §Soft time.
let now = 1_714_000_000_000u64;
let s = since_hint(now, now);
assert!(s.ends_with("yr 1"), "expected yr 1, got '{s}'");
}

#[test]
fn profile_delta_default_is_noop_shape() {
let d = ProfileDelta::default();
assert!(d.display_name.is_none());
assert!(d.pronouns.is_none());
assert!(d.bio.is_none());
assert!(d.elsewhere.is_none());
}

#[tokio::test]
async fn update_profile_fields_broadcasts_event() {
// Subscribing directly to the broker and then firing a mutation
// should yield a ProfileUpdated / DAG-level event — we rely on the
// full apply path working: this test just checks the mutation call
// does not error and produces the expected state change.
let (client, _broker) = test_client();
let local = client.identity().endpoint_id();
client
.mutations()
.update_profile_fields(ProfileDelta {
display_name: Some("mira".into()),
..ProfileDelta::default()
})
.await
.expect("update_profile_fields must succeed");
let v = client.views().profile_view_of(&local, &local).await;
assert_eq!(v.display_name, "mira");
}

#[tokio::test]
async fn shared_groves_empty_when_server_entries_lack_membership() {
// Today the client tracks member lists separately from
// `ServerEntry`; the helper therefore returns an empty Vec until
// the multi-grove plumbing lands. This test pins the contract so
// callers know to handle the empty case.
let (client, _broker) = test_client();
let local = client.identity().endpoint_id();
let other = willow_identity::Identity::generate().endpoint_id();
let registry = client.views().server_registry.get().await;
let shared = registry.shared_groves(&local, &other);
assert!(shared.is_empty());
}
Loading
Loading