From 68c9ae118761ec57d91292f03b36cd7ba580537c Mon Sep 17 00:00:00 2001 From: DeFiDude <59237470+DeFiDude@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:59:13 -0600 Subject: [PATCH] channels: refine hub activity and hosting UX --- crates/ratspeak-runtime/src/channel_hub.rs | 213 +++- crates/ratspeak-runtime/src/channels.rs | 210 ++-- crates/ratspeak-runtime/src/lib.rs | 30 +- .../src/commands/channel_hub.rs | 172 ++- .../ratspeak-tauri/src/commands/interfaces.rs | 33 +- .../ratspeak-tauri/tests/source_contracts.rs | 198 ++- dashboard/index.html | 55 +- dashboard/scripts/test_channel_hub_admin.js | 38 +- dashboard/scripts/test_channel_hub_ui.js | 58 +- dashboard/scripts/test_channels_directory.js | 3 +- dashboard/scripts/test_channels_history.js | 19 +- .../scripts/test_channels_hub_switcher.js | 101 +- dashboard/scripts/test_channels_presence.js | 128 +- dashboard/scripts/test_channels_share.js | 2 + .../scripts/test_channels_snapshot_order.js | 2 + dashboard/static/css/00-tokens.css | 7 + dashboard/static/css/09-channels.css | 812 ++++++++++--- dashboard/static/css/10-views.css | 22 +- dashboard/static/css/13-responsive.css | 2 +- dashboard/static/js/channel_hub.js | 316 +++-- dashboard/static/js/channels.js | 1068 ++++++++++++----- dashboard/static/js/modals.js | 2 +- dashboard/static/js/propagation.js | 8 +- dashboard/static/js/settings.js | 122 +- src-tauri/src/lib.rs | 1 + 25 files changed, 2757 insertions(+), 865 deletions(-) diff --git a/crates/ratspeak-runtime/src/channel_hub.rs b/crates/ratspeak-runtime/src/channel_hub.rs index 018a0bd..92d7a6c 100644 --- a/crates/ratspeak-runtime/src/channel_hub.rs +++ b/crates/ratspeak-runtime/src/channel_hub.rs @@ -85,8 +85,11 @@ const RES_KIND_NOTICE: &str = "notice"; const THROTTLE_REPORT_INTERVAL: Duration = Duration::from_secs(60); /// Local operator evidence is deliberately not hub history. It exists only in /// the live actor, is returned only by an explicit owner read, and is bounded -/// independently by age, count, and estimated payload. -pub const CHANNEL_HUB_EVIDENCE_RETENTION_SECS: u64 = 15 * 60; +/// independently by age, count, and estimated payload. Core RRC behavior is +/// the default: no evidence is retained unless the operator opts in. +pub const CHANNEL_HUB_EVIDENCE_RETENTION_DEFAULT_SECS: u64 = 0; +pub const CHANNEL_HUB_EVIDENCE_RETENTION_MIN_SECS: u64 = 60 * 60; +pub const CHANNEL_HUB_EVIDENCE_RETENTION_MAX_SECS: u64 = 24 * 60 * 60; pub const CHANNEL_HUB_EVIDENCE_MAX_EVENTS: usize = 128; pub const CHANNEL_HUB_EVIDENCE_MAX_BYTES: usize = 64 * 1024; pub const CHANNEL_HUB_EVIDENCE_EXCERPT_BYTES: usize = 256; @@ -94,20 +97,50 @@ pub const CHANNEL_HUB_ADMIN_MODEL_VERSION: u16 = 1; const HUB_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const DEFAULT_HUB_NAME: &str = "Ratspeak hub"; +pub const DEFAULT_ANNOUNCE_INTERVAL_SECS: u64 = 15 * 60; pub const DEFAULT_PING_INTERVAL_SECS: u64 = 55; pub const DEFAULT_PING_TIMEOUT_SECS: u64 = 120; /// Short enough to bound access after an unexpected disconnect, long enough /// for Reticulum path rediscovery and reconnect backoff on constrained links. pub const DEFAULT_REJOIN_GRACE_SECS: u64 = 5 * 60; -pub const CHANNEL_HUB_SETTING_KEYS: [&str; 6] = [ +/// User-facing capability gate. This is intentionally separate from +/// `channel_hub_enabled`: the preference reveals hosting tools, while the hub +/// setting records whether the service itself should be running. +pub const CHANNEL_HOSTING_ENABLED_KEY: &str = "channel_hosting_enabled"; +/// Marks the default-Off preference contract introduced with the dedicated +/// Channels setting. Earlier development builds inferred this capability from +/// `channel_hub_enabled`, which could silently opt an upgraded profile in. +pub const CHANNEL_HOSTING_PREFERENCE_VERSION_KEY: &str = "channel_hosting_preference_version"; +pub const CHANNEL_HOSTING_PREFERENCE_VERSION: &str = "1"; +pub const CHANNEL_HUB_SETTING_KEYS: [&str; 5] = [ "channel_hub_enabled", "channel_hub_name", "channel_hub_greeting", "channel_hub_announce_interval", - "channel_hub_resource_send", - "channel_hub_resource_accept", + "channel_hub_recent_activity_retention", ]; +/// Channel hosting is an explicit opt-in. The version marker lets profiles +/// created by earlier development builds shed the old implicit opt-in once; +/// both the capability and requested-running state are reset together so the +/// UI can never say Off while a hub is scheduled to start. +pub fn channel_hosting_enabled(pool: &db::DbPool) -> bool { + let version = db::get_setting(pool, CHANNEL_HOSTING_PREFERENCE_VERSION_KEY); + if version.as_deref() != Some(CHANNEL_HOSTING_PREFERENCE_VERSION) { + let defaults = [ + (CHANNEL_HOSTING_ENABLED_KEY.to_string(), "0".to_string()), + ("channel_hub_enabled".to_string(), "0".to_string()), + ( + CHANNEL_HOSTING_PREFERENCE_VERSION_KEY.to_string(), + CHANNEL_HOSTING_PREFERENCE_VERSION.to_string(), + ), + ]; + let _ = db::try_set_settings(pool, &defaults); + return false; + } + db::get_setting(pool, CHANNEL_HOSTING_ENABLED_KEY).is_some_and(|value| value.trim() == "1") +} + /// Operator-editable hub settings. This is deliberately separate from the /// live snapshot: saved configuration must remain readable while the network /// and hub actor are stopped. @@ -117,8 +150,7 @@ pub struct ChannelHubSettings { pub hub_name: String, pub greeting: String, pub announce_interval_secs: u64, - pub resource_send_enabled: bool, - pub resource_accept_enabled: bool, + pub recent_activity_retention_secs: u64, } impl Default for ChannelHubSettings { @@ -127,9 +159,8 @@ impl Default for ChannelHubSettings { enabled: false, hub_name: DEFAULT_HUB_NAME.to_string(), greeting: String::new(), - announce_interval_secs: 0, - resource_send_enabled: true, - resource_accept_enabled: false, + announce_interval_secs: DEFAULT_ANNOUNCE_INTERVAL_SECS, + recent_activity_retention_secs: CHANNEL_HUB_EVIDENCE_RETENTION_DEFAULT_SECS, } } } @@ -147,7 +178,7 @@ impl ChannelHubSettings { let announce_interval_secs = values .get("channel_hub_announce_interval") .and_then(|value| value.parse::().ok()) - .filter(|value| *value == 0 || (300..=86_400).contains(value)) + .filter(|value| valid_channel_hub_announce_interval_secs(*value)) .unwrap_or(defaults.announce_interval_secs); Ok(Self { @@ -160,14 +191,11 @@ impl ChannelHubSettings { .map(|value| value.trim().to_string()) .unwrap_or_default(), announce_interval_secs, - resource_send_enabled: values - .get("channel_hub_resource_send") - .map(|value| value.trim() == "1") - .unwrap_or(defaults.resource_send_enabled), - resource_accept_enabled: values - .get("channel_hub_resource_accept") - .map(|value| value.trim() == "1") - .unwrap_or(defaults.resource_accept_enabled), + recent_activity_retention_secs: values + .get("channel_hub_recent_activity_retention") + .and_then(|value| value.parse::().ok()) + .filter(|value| valid_evidence_retention_secs(*value)) + .unwrap_or(defaults.recent_activity_retention_secs), }) } @@ -184,12 +212,8 @@ impl ChannelHubSettings { self.announce_interval_secs.to_string(), ), ( - "channel_hub_resource_send".to_string(), - bool_setting(self.resource_send_enabled), - ), - ( - "channel_hub_resource_accept".to_string(), - bool_setting(self.resource_accept_enabled), + "channel_hub_recent_activity_retention".to_string(), + self.recent_activity_retention_secs.to_string(), ), ] } @@ -199,13 +223,23 @@ impl ChannelHubSettings { hub_name: self.hub_name.clone(), greeting: (!self.greeting.is_empty()).then(|| self.greeting.clone()), announce_interval_secs: self.announce_interval_secs, - resource_send_enabled: self.resource_send_enabled, - resource_accept_enabled: self.resource_accept_enabled, + evidence_retention_secs: self.recent_activity_retention_secs, ..ChannelHubConfig::default() } } } +pub const fn valid_channel_hub_announce_interval_secs(value: u64) -> bool { + matches!(value, 900 | 1_800 | 3_600 | 43_200 | 86_400) +} + +pub const fn valid_evidence_retention_secs(value: u64) -> bool { + value == 0 + || (value >= CHANNEL_HUB_EVIDENCE_RETENTION_MIN_SECS + && value <= CHANNEL_HUB_EVIDENCE_RETENTION_MAX_SECS + && value.is_multiple_of(60 * 60)) +} + fn bool_setting(enabled: bool) -> String { if enabled { "1" } else { "0" }.to_string() } @@ -237,6 +271,9 @@ pub struct ChannelHubConfig { /// directions carry very different risk, so they are not one flag. pub resource_send_enabled: bool, pub resource_accept_enabled: bool, + /// Memory-only operator context. 0 preserves core RRC's immediate-forget + /// behavior; non-zero values are explicit local moderation policy. + pub evidence_retention_secs: u64, /// Trigger ceiling for the outbound resource path. Both reference clients /// expire the expectation 30s after the advertisement, so a payload that /// cannot conclude inside that window is better chunked than advertised. @@ -280,6 +317,7 @@ impl Default for ChannelHubConfig { include_member_list: true, resource_send_enabled: true, resource_accept_enabled: false, + evidence_retention_secs: CHANNEL_HUB_EVIDENCE_RETENTION_DEFAULT_SECS, max_outbound_resource_bytes: 16 * 1024, max_resource_notice_bytes: 4096, max_resource_bytes: 256 * 1024, @@ -1549,7 +1587,13 @@ impl HubCore { } fn prune_evidence(&mut self, now: Instant) { - let retention = Duration::from_secs(CHANNEL_HUB_EVIDENCE_RETENTION_SECS); + if self.config.evidence_retention_secs == 0 { + while !self.evidence.is_empty() { + self.evict_oldest_evidence(); + } + return; + } + let retention = Duration::from_secs(self.config.evidence_retention_secs); while self .evidence .front() @@ -1573,6 +1617,9 @@ impl HubCore { target_identity: Option<[u8; 16]>, excerpt: Option<&str>, ) { + if self.config.evidence_retention_secs == 0 { + return; + } self.prune_evidence(now); if self.evidence_sequence == u64::MAX { while !self.evidence.is_empty() { @@ -2012,7 +2059,7 @@ impl HubCore { hub_bans, stats: admin_stats(&self.stats, self.admission.rejected()), limits: admin_limits(&self.config), - evidence_policy: admin_evidence_policy(), + evidence_policy: admin_evidence_policy(&self.config), evidence, evidence_evicted: self.evidence_evicted, } @@ -5990,9 +6037,9 @@ fn admin_limits(config: &ChannelHubConfig) -> ChannelHubAdminLimits { } } -const fn admin_evidence_policy() -> ChannelHubEvidencePolicy { +const fn admin_evidence_policy(config: &ChannelHubConfig) -> ChannelHubEvidencePolicy { ChannelHubEvidencePolicy { - retention_secs: CHANNEL_HUB_EVIDENCE_RETENTION_SECS, + retention_secs: config.evidence_retention_secs, max_events: CHANNEL_HUB_EVIDENCE_MAX_EVENTS, max_estimated_bytes: CHANNEL_HUB_EVIDENCE_MAX_BYTES, max_excerpt_bytes: CHANNEL_HUB_EVIDENCE_EXCERPT_BYTES, @@ -6112,7 +6159,7 @@ fn stopped_admin_snapshot( hub_bans: hub_bans.into_iter().collect(), stats: admin_stats(&empty_stats, 0), limits: admin_limits(config), - evidence_policy: admin_evidence_policy(), + evidence_policy: admin_evidence_policy(config), evidence: Vec::new(), evidence_evicted: 0, } @@ -6946,21 +6993,71 @@ mod tests { assert!(!defaults.enabled); assert_eq!(defaults.hub_name, DEFAULT_HUB_NAME); assert!(defaults.greeting.is_empty()); - assert!(defaults.resource_send_enabled); - assert!(!defaults.resource_accept_enabled); + assert_eq!( + defaults.announce_interval_secs, + DEFAULT_ANNOUNCE_INTERVAL_SECS + ); + assert_eq!(defaults.recent_activity_retention_secs, 0); let configured = ChannelHubSettings { enabled: true, hub_name: "Mountain relay".to_string(), greeting: "Welcome".to_string(), - announce_interval_secs: 900, - resource_send_enabled: false, - resource_accept_enabled: true, + announce_interval_secs: 43_200, + recent_activity_retention_secs: 21_600, }; db::try_set_settings(&pool, &configured.setting_rows()).unwrap(); assert_eq!(ChannelHubSettings::load(&pool).unwrap(), configured); } + #[test] + fn periodic_announce_choices_are_intentional_and_startup_remains_separate() { + for interval in [900, 1_800, 3_600, 43_200, 86_400] { + assert!(valid_channel_hub_announce_interval_secs(interval)); + } + for interval in [0, 300, 21_600, 86_401] { + assert!(!valid_channel_hub_announce_interval_secs(interval)); + } + } + + #[test] + fn hosting_capability_defaults_off_and_resets_legacy_opt_in() { + let fresh = settings_pool(); + assert!(!channel_hosting_enabled(&fresh)); + assert_eq!( + db::get_setting(&fresh, CHANNEL_HOSTING_ENABLED_KEY).as_deref(), + Some("0") + ); + assert_eq!( + db::get_setting(&fresh, CHANNEL_HOSTING_PREFERENCE_VERSION_KEY).as_deref(), + Some(CHANNEL_HOSTING_PREFERENCE_VERSION) + ); + + let legacy = settings_pool(); + db::try_set_settings( + &legacy, + &[ + (CHANNEL_HOSTING_ENABLED_KEY.to_string(), "1".to_string()), + ("channel_hub_enabled".to_string(), "1".to_string()), + ], + ) + .unwrap(); + assert!(!channel_hosting_enabled(&legacy)); + assert_eq!( + db::get_setting(&legacy, CHANNEL_HOSTING_ENABLED_KEY).as_deref(), + Some("0") + ); + assert_eq!( + db::get_setting(&legacy, "channel_hub_enabled").as_deref(), + Some("0") + ); + + db::try_set_setting(&legacy, CHANNEL_HOSTING_ENABLED_KEY, "1").unwrap(); + assert!(channel_hosting_enabled(&legacy)); + db::try_set_setting(&legacy, CHANNEL_HOSTING_ENABLED_KEY, "0").unwrap(); + assert!(!channel_hosting_enabled(&legacy)); + } + #[test] fn owned_hub_destination_remains_available_while_stopped() { let root = std::env::temp_dir().join(format!( @@ -6992,11 +7089,11 @@ mod tests { &[ ("channel_hub_enabled".to_string(), "yes".to_string()), ("channel_hub_name".to_string(), " ".to_string()), + ("channel_hub_announce_interval".to_string(), "0".to_string()), ( - "channel_hub_announce_interval".to_string(), - "42".to_string(), + "channel_hub_recent_activity_retention".to_string(), + "900".to_string(), ), - ("channel_hub_resource_accept".to_string(), "yes".to_string()), ], ) .unwrap(); @@ -7004,8 +7101,11 @@ mod tests { let settings = ChannelHubSettings::load(&pool).unwrap(); assert!(!settings.enabled); assert_eq!(settings.hub_name, DEFAULT_HUB_NAME); - assert_eq!(settings.announce_interval_secs, 0); - assert!(!settings.resource_accept_enabled); + assert_eq!( + settings.announce_interval_secs, + DEFAULT_ANNOUNCE_INTERVAL_SECS + ); + assert_eq!(settings.recent_activity_retention_secs, 0); } /// ID_A is always a server operator, mirroring production: `start` seeds @@ -7559,6 +7659,7 @@ mod tests { // ID_A is a server operator, so it is an implicit op everywhere. let config = ChannelHubConfig { server_operators: vec![ID_A], + evidence_retention_secs: 3600, ..ChannelHubConfig::default() }; let mut core = core_with(config); @@ -8145,7 +8246,10 @@ mod tests { #[test] fn evidence_ring_enforces_count_and_retention_caps() { - let mut core = core_with(ChannelHubConfig::default()); + let mut core = core_with(ChannelHubConfig { + evidence_retention_secs: 3600, + ..ChannelHubConfig::default() + }); let base = Instant::now(); for index in 0..(CHANNEL_HUB_EVIDENCE_MAX_EVENTS + 7) { core.push_evidence( @@ -8167,7 +8271,7 @@ mod tests { let after_retention = base + Duration::from_secs( - CHANNEL_HUB_EVIDENCE_RETENTION_SECS + CHANNEL_HUB_EVIDENCE_MAX_EVENTS as u64 + 7, + core.config.evidence_retention_secs + CHANNEL_HUB_EVIDENCE_MAX_EVENTS as u64 + 7, ); let snapshot = core.admin_snapshot_at(after_retention, 1_800_001_000_000, 1_800_001_000.0); assert!(snapshot.evidence.is_empty()); @@ -8178,6 +8282,27 @@ mod tests { assert!(!snapshot.evidence_policy.persistent); } + #[test] + fn evidence_is_disabled_by_default() { + let mut core = core_with(ChannelHubConfig::default()); + core.push_evidence( + Instant::now(), + 1_800_000_000_000, + ChannelHubEvidenceKind::Message, + None, + None, + Some("lobby"), + Some(ID_A), + Some("alpha"), + None, + Some("not retained"), + ); + + let snapshot = core.admin_snapshot(); + assert!(snapshot.evidence.is_empty()); + assert_eq!(snapshot.evidence_policy.retention_secs, 0); + } + #[test] fn who_and_list_replies_match_reference_wire_text() { let mut core = op_core(); diff --git a/crates/ratspeak-runtime/src/channels.rs b/crates/ratspeak-runtime/src/channels.rs index be49ce7..60ec15d 100644 --- a/crates/ratspeak-runtime/src/channels.rs +++ b/crates/ratspeak-runtime/src/channels.rs @@ -5017,11 +5017,14 @@ fn apply_joined(active: &mut ActiveSession, envelope: &Envelope) { if room.phase == ChannelRoomPhase::Parting { return; } + let room_was_joined = room.phase == ChannelRoomPhase::Joined; let identities = rrc::member_identities(envelope); let identity_count = identities.len(); let includes_self = identities.contains(&active.source); - let joining_self = room.phase == ChannelRoomPhase::Joining || includes_self; - if room.phase == ChannelRoomPhase::Error && !joining_self { + let single_identity_hash = (identity_count == 1).then(|| hex::encode(identities[0])); + let confirming_self = room.phase == ChannelRoomPhase::Joining + || (room.phase == ChannelRoomPhase::Error && includes_self); + if room.phase == ChannelRoomPhase::Error && !confirming_self { return; } room.phase = ChannelRoomPhase::Joined; @@ -5029,7 +5032,7 @@ fn apply_joined(active: &mut ActiveSession, envelope: &Envelope) { room.last_error = None; if !identities.is_empty() { - if joining_self || includes_self { + if confirming_self || includes_self { room.members.clear(); } let single_member_nickname = (identities.len() == 1) @@ -5047,8 +5050,8 @@ fn apply_joined(active: &mut ActiveSession, envelope: &Envelope) { identity == active.source, ); } - room.members_complete = joining_self || includes_self; - } else if joining_self { + room.members_complete = confirming_self || includes_self; + } else if confirming_self { upsert_member( &mut room.members, Some(active.source), @@ -5060,31 +5063,41 @@ fn apply_joined(active: &mut ActiveSession, envelope: &Envelope) { upsert_member(&mut room.members, None, Some(nickname), false); } - let nickname = if joining_self { + let nickname = if confirming_self { Some(active.nickname.clone()) } else { envelope.nickname.clone() }; - let join_already_visible = joining_self && self_join_transition_visible(room); + let join_already_visible = confirming_self && self_join_transition_visible(room); // A multi-identity JOINED that does not include us is a roster fragment, // never a join event: hubs split large rosters across packets, and // treating a continuation as an arrival invents "A member joined" lines. - let is_join_event = joining_self || identity_count == 1; - if !join_already_visible && is_join_event { - let item = if reconnecting_room && joining_self { - reconnected_transcript_item(envelope) + let nickname_only_join = room_was_joined + && identity_count == 0 + && envelope + .nickname + .as_ref() + .is_some_and(|nick| !nick.is_empty()); + let is_join_event = + confirming_self || (identity_count == 1 && !includes_self) || nickname_only_join; + if !join_already_visible && is_join_event && !(reconnecting_room && confirming_self) { + let mut item = transcript_item( + envelope, + ChannelItemKind::Join, + nickname.clone(), + if confirming_self { + "You joined".into() + } else { + format!("{} joined", nickname.unwrap_or_else(|| "A member".into())) + }, + confirming_self, + ); + item.source_hash = if confirming_self { + Some(hex::encode(active.source)) + } else if identity_count == 1 { + single_identity_hash } else { - transcript_item( - envelope, - ChannelItemKind::Join, - nickname.clone(), - if joining_self { - "You joined".into() - } else { - format!("{} joined", nickname.unwrap_or_else(|| "A member".into())) - }, - joining_self, - ) + None }; append_room_item( &mut active.history_events, @@ -5336,20 +5349,16 @@ fn apply_rrcd_room_status_notice(active: &mut ActiveSession, envelope: &Envelope Some(active.nickname.clone()), true, ); - if !self_join_transition_visible(room) { - let item = if reconnecting_room { - reconnected_transcript_item(envelope) - } else { - ChannelTranscriptItem { - id: format!("{}-joined", hex::encode(envelope.message_id)), - kind: ChannelItemKind::Join, - timestamp_ms: envelope.timestamp_ms, - source_hash: Some(hex::encode(active.source)), - nickname: Some(active.nickname.clone()), - text: "You joined".into(), - ours: true, - mentioned: false, - } + if !reconnecting_room && !self_join_transition_visible(room) { + let item = ChannelTranscriptItem { + id: format!("{}-joined", hex::encode(envelope.message_id)), + kind: ChannelItemKind::Join, + timestamp_ms: envelope.timestamp_ms, + source_hash: Some(hex::encode(active.source)), + nickname: Some(active.nickname.clone()), + text: "You joined".into(), + ours: true, + mentioned: false, }; append_room_item( &mut active.history_events, @@ -5363,24 +5372,9 @@ fn apply_rrcd_room_status_notice(active: &mut ActiveSession, envelope: &Envelope } fn self_join_transition_visible(room: &ChannelRoomSnapshot) -> bool { - room.transcript.iter().any(|item| { - item.ours - && (item.kind == ChannelItemKind::Join - || (item.kind == ChannelItemKind::System && item.text == "Reconnected to hub")) - }) -} - -fn reconnected_transcript_item(envelope: &Envelope) -> ChannelTranscriptItem { - ChannelTranscriptItem { - id: format!("{}-reconnected", hex::encode(envelope.message_id)), - kind: ChannelItemKind::System, - timestamp_ms: envelope.timestamp_ms, - source_hash: None, - nickname: None, - text: "Reconnected to hub".into(), - ours: true, - mentioned: false, - } + room.transcript + .iter() + .any(|item| item.ours && item.kind == ChannelItemKind::Join) } fn apply_parted(active: &mut ActiveSession, envelope: &Envelope) { @@ -5425,17 +5419,19 @@ fn apply_parted(active: &mut ActiveSession, envelope: &Envelope) { room.members.remove(index); } let nickname = envelope.nickname.clone(); + let mut item = transcript_item( + envelope, + ChannelItemKind::Part, + nickname.clone(), + format!("{} left", nickname.unwrap_or_else(|| "A member".into())), + false, + ); + item.source_hash = (identities.len() == 1).then(|| hex::encode(identities[0])); append_room_item( &mut active.history_events, active.destination_hash, room, - transcript_item( - envelope, - ChannelItemKind::Part, - nickname.clone(), - format!("{} left", nickname.unwrap_or_else(|| "A member".into())), - false, - ), + item, ); } @@ -8315,17 +8311,18 @@ mod tests { snapshot.hubs[0].recovery.phase == ChannelRecoveryPhase::Idle && ["field", "general"].iter().all(|room_name| { snapshot.rooms.iter().any(|room| { - room.name == *room_name - && room.phase == ChannelRoomPhase::Joined - && room.transcript.iter().any(|item| { - item.kind == ChannelItemKind::System - && item.text == "Reconnected to hub" - }) + room.name == *room_name && room.phase == ChannelRoomPhase::Joined }) }) }) .await; assert_eq!(recovered.hubs[0].recovery.phase, ChannelRecoveryPhase::Idle); + assert!( + recovered + .rooms + .iter() + .all(|room| room.transcript.is_empty()) + ); manager.disconnect().await.unwrap(); manager.shutdown().await; @@ -9092,6 +9089,85 @@ mod tests { .all(|item| !item.text.starts_with("room general: registered;")) ); + // A nickname-only or one-member JOINED/PARTED fanout is human room + // activity, not a roster refresh. Preserve it in the bounded + // transcript so clients can render membership changes with messages. + let mut member_joined = Envelope::new(MessageType::Joined, hub_identity.hash); + member_joined.room = Some("general".into()); + member_joined.nickname = Some("v6z".into()); + send_server_envelope(&delivery_tx, &mut responder, &member_joined).await; + let member_visible = wait_snapshot(&manager, |snapshot| { + snapshot.rooms.first().is_some_and(|room| { + room.members + .iter() + .any(|member| member.nickname.as_deref() == Some("v6z")) + && room.transcript.iter().any(|item| { + item.kind == ChannelItemKind::Join + && !item.ours + && item.nickname.as_deref() == Some("v6z") + && item.source_hash.is_none() + && item.text == "v6z joined" + }) + }) + }) + .await; + assert_eq!(member_visible.rooms[0].members.len(), 2); + + let mut member_parted = Envelope::new(MessageType::Parted, hub_identity.hash); + member_parted.room = Some("general".into()); + member_parted.nickname = Some("v6z".into()); + send_server_envelope(&delivery_tx, &mut responder, &member_parted).await; + let member_left = wait_snapshot(&manager, |snapshot| { + snapshot.rooms.first().is_some_and(|room| { + !room + .members + .iter() + .any(|member| member.nickname.as_deref() == Some("v6z")) + && room.transcript.iter().any(|item| { + item.kind == ChannelItemKind::Part + && item.nickname.as_deref() == Some("v6z") + && item.source_hash.is_none() + && item.text == "v6z left" + }) + }) + }) + .await; + assert_eq!(member_left.rooms[0].members.len(), 1); + + let identified_member = [0x45; 16]; + let identified_hash = hex::encode(identified_member); + let mut identified_joined = Envelope::new(MessageType::Joined, hub_identity.hash); + identified_joined.room = Some("general".into()); + identified_joined.nickname = Some("Ada".into()); + identified_joined.body = Some(Value::Array(vec![Value::Bytes(identified_member.to_vec())])); + send_server_envelope(&delivery_tx, &mut responder, &identified_joined).await; + wait_snapshot(&manager, |snapshot| { + snapshot.rooms.first().is_some_and(|room| { + room.transcript.iter().any(|item| { + item.kind == ChannelItemKind::Join + && item.nickname.as_deref() == Some("Ada") + && item.source_hash.as_deref() == Some(identified_hash.as_str()) + }) + }) + }) + .await; + + let mut identified_parted = Envelope::new(MessageType::Parted, hub_identity.hash); + identified_parted.room = Some("general".into()); + identified_parted.nickname = Some("Ada".into()); + identified_parted.body = Some(Value::Array(vec![Value::Bytes(identified_member.to_vec())])); + send_server_envelope(&delivery_tx, &mut responder, &identified_parted).await; + wait_snapshot(&manager, |snapshot| { + snapshot.rooms.first().is_some_and(|room| { + room.transcript.iter().any(|item| { + item.kind == ChannelItemKind::Part + && item.nickname.as_deref() == Some("Ada") + && item.source_hash.as_deref() == Some(identified_hash.as_str()) + }) + }) + }) + .await; + // A room message is live evidence that its hub-reported source is // present even when the optional JOINED roster was not delivered. // ACTION updates the same observed member instead of duplicating it, diff --git a/crates/ratspeak-runtime/src/lib.rs b/crates/ratspeak-runtime/src/lib.rs index 5b33faa..910dce4 100644 --- a/crates/ratspeak-runtime/src/lib.rs +++ b/crates/ratspeak-runtime/src/lib.rs @@ -909,6 +909,17 @@ pub async fn start_channel_hub_service(state: &Arc) -> bool { tracing::warn!(reason = "unsupported_platform", "channel hub not started"); return false; } + let hub_settings = match channel_hub::ChannelHubSettings::load(&state.db) { + Ok(settings) => settings, + Err(_) => { + tracing::warn!(reason = "settings_unavailable", "channel hub not started"); + return false; + } + }; + if !hub_settings.enabled || !channel_hub::channel_hosting_enabled(&state.db) { + tracing::info!(reason = "hosting_disabled", "channel hub not started"); + return false; + } if let Some(existing) = state.channel_hub_handle() { if existing.snapshot().running { return true; @@ -968,13 +979,7 @@ pub async fn start_channel_hub_service(state: &Arc) -> bool { return false; } }; - let config = match channel_hub::ChannelHubSettings::load(&state.db) { - Ok(settings) => settings.runtime_config(), - Err(_) => { - tracing::warn!(reason = "settings_unavailable", "channel hub not started"); - return false; - } - }; + let config = hub_settings.runtime_config(); match channel_hub::ChannelHubHandle::start( transport_tx, hub_identity, @@ -1856,12 +1861,13 @@ pub async fn init_rns_lxmf(state: Arc, data_dir: std::path::PathBuf) { )); tracing::info!("Channels runtime initialized"); } - if channel_hub::channel_hub_hosting_supported() - && channel_hub::ChannelHubSettings::load(&state.db) - .is_ok_and(|settings| settings.enabled) - { + if channel_hub::channel_hub_hosting_supported() { let _hub_control = state.channel_hub_control_lock.lock().await; - start_channel_hub_service(&state).await; + if channel_hub::ChannelHubSettings::load(&state.db).is_ok_and(|settings| { + settings.enabled && channel_hub::channel_hosting_enabled(&state.db) + }) { + start_channel_hub_service(&state).await; + } } tracing::info!("RNS runtime initialized"); #[cfg(feature = "lxst-voice")] diff --git a/crates/ratspeak-tauri/src/commands/channel_hub.rs b/crates/ratspeak-tauri/src/commands/channel_hub.rs index d109e65..c7b77eb 100644 --- a/crates/ratspeak-tauri/src/commands/channel_hub.rs +++ b/crates/ratspeak-tauri/src/commands/channel_hub.rs @@ -5,11 +5,15 @@ use std::sync::Arc; use ratspeak_runtime::channel_hub::{ - ChannelHubAdminKeyChange, ChannelHubAdminMutation, ChannelHubAdminRoomPolicy, - ChannelHubAdminRoomRole, ChannelHubAdminSecret, ChannelHubAdminSnapshot, ChannelHubSettings, - ChannelHubSnapshot, HubStore, channel_hub_hosting_supported, + CHANNEL_HOSTING_ENABLED_KEY, CHANNEL_HOSTING_PREFERENCE_VERSION, + CHANNEL_HOSTING_PREFERENCE_VERSION_KEY, ChannelHubAdminKeyChange, ChannelHubAdminMutation, + ChannelHubAdminRoomPolicy, ChannelHubAdminRoomRole, ChannelHubAdminSecret, + ChannelHubAdminSnapshot, ChannelHubSettings, ChannelHubSnapshot, HubStore, + channel_hosting_enabled, channel_hub_hosting_supported, + valid_channel_hub_announce_interval_secs, valid_evidence_retention_secs, }; use serde::{Deserialize, Serialize}; +use serde_json::json; use tauri::State; use crate::error::{AppError, AppResult}; @@ -119,12 +123,10 @@ pub struct ChannelHubConfigArgs { pub greeting: Option, #[serde(default)] pub announce_interval_secs: Option, - /// Send oversized greetings as a resource, and advertise the capability. + /// Memory-only operator context. 0 disables it; otherwise whole hours + /// between one and 24 are accepted. #[serde(default)] - pub resource_send: Option, - /// Accept inbound resource notices. Off by default. - #[serde(default)] - pub resource_accept: Option, + pub recent_activity_retention_secs: Option, } /// Stable read model for the desktop hosting surface. Saved settings remain @@ -132,6 +134,8 @@ pub struct ChannelHubConfigArgs { #[derive(Debug, Serialize)] pub struct ChannelHubOverview { pub supported: bool, + /// Explicit opt-in for the operator UI and hosting command surface. + pub hosting_enabled: bool, /// True once this Ratspeak identity has created a dedicated hub identity. pub created: bool, /// Stable public address, available even while the hub is stopped. @@ -172,6 +176,23 @@ async fn load_settings(state: &State<'_, Arc>) -> AppResult>) -> AppResult { + let pool = state.db.clone(); + crate::db::spawn_db(pool, move |pool| channel_hosting_enabled(&pool)) + .await + .map_err(|_| AppError::internal("channel hosting preference task panicked")) +} + +async fn ensure_hosting_enabled(state: &State<'_, Arc>) -> AppResult<()> { + if load_hosting_enabled(state).await? { + Ok(()) + } else { + Err(AppError::bad_request( + "Turn on Channel hosting in Settings first", + )) + } +} + async fn persist_settings( state: &State<'_, Arc>, settings: &ChannelHubSettings, @@ -186,6 +207,19 @@ async fn persist_settings( .map_err(AppError::database_unavailable) } +async fn shutdown_channel_hub(state: &State<'_, Arc>) -> AppResult<()> { + let Some(hub) = state.channel_hub_handle() else { + return Ok(()); + }; + if !hub.shutdown().await { + return Err(AppError::service_unavailable( + "Channel hub is still shutting down", + )); + } + state.take_channel_hub(); + Ok(()) +} + fn active_operator_identity(state: &State<'_, Arc>) -> AppResult<(String, [u8; 16])> { let identity_id = crate::helpers::active_identity_id(state); if !crate::helpers::validate_hex(&identity_id, 32, 32) { @@ -305,6 +339,7 @@ fn admin_mutation(args: ChannelHubAdminMutationArgs) -> AppResult>, settings: ChannelHubSettings, + hosting_enabled: bool, ) -> ChannelHubOverview { let status = current_snapshot(state).await; let identity_id = crate::helpers::active_identity_id(state); @@ -322,6 +357,7 @@ async fn overview( }); ChannelHubOverview { supported: channel_hub_hosting_supported(), + hosting_enabled, created, destination_hash, settings, @@ -344,18 +380,20 @@ fn apply_config_args( settings.greeting = sanitize_text(&greeting, MAX_GREETING_CHARS); } if let Some(interval) = args.announce_interval_secs { - if interval != 0 && !(300..=86_400).contains(&interval) { + if !valid_channel_hub_announce_interval_secs(interval) { return Err(AppError::bad_request( - "Announce interval must be 0 or between 5 minutes and 24 hours", + "Announce interval must be 15 minutes, 30 minutes, 1 hour, 12 hours, or 24 hours", )); } settings.announce_interval_secs = interval; } - if let Some(enabled) = args.resource_send { - settings.resource_send_enabled = enabled; - } - if let Some(enabled) = args.resource_accept { - settings.resource_accept_enabled = enabled; + if let Some(retention) = args.recent_activity_retention_secs { + if !valid_evidence_retention_secs(retention) { + return Err(AppError::bad_request( + "Recent activity must be off or between 1 and 24 whole hours", + )); + } + settings.recent_activity_retention_secs = retention; } Ok(settings) } @@ -364,7 +402,8 @@ fn apply_config_args( pub async fn api_channel_hub(state: State<'_, Arc>) -> AppResult { let _control = state.channel_hub_control_lock.lock().await; let settings = load_settings(&state).await?; - Ok(overview(&state, settings).await) + let hosting_enabled = load_hosting_enabled(&state).await?; + Ok(overview(&state, settings, hosting_enabled).await) } #[tauri::command] @@ -373,13 +412,14 @@ pub async fn api_channel_hub_admin( ) -> AppResult { ensure_supported()?; let _control = state.channel_hub_control_lock.lock().await; + let settings = load_settings(&state).await?; + ensure_hosting_enabled(&state).await?; let (identity_id, operator_identity) = active_operator_identity(&state)?; if let Some(hub) = state.channel_hub_handle() { return hub.admin_snapshot().await.map_err(|_| { AppError::service_unavailable("Channel hub administration is temporarily unavailable") }); } - let settings = load_settings(&state).await?; HubStore::new(state.db.clone(), identity_id) .admin_snapshot(settings.runtime_config(), operator_identity) .await @@ -394,8 +434,9 @@ pub async fn channel_hub_admin_mutate( ensure_supported()?; let _control = state.channel_hub_control_lock.lock().await; // Convert first so any join key becomes zeroizing input even when the - // identity or live actor check below rejects the request. + // preference, identity, or live actor checks below reject the request. let mutation = admin_mutation(args)?; + ensure_hosting_enabled(&state).await?; let (_, actor_identity) = active_operator_identity(&state)?; let hub = state.channel_hub_handle().ok_or_else(|| { AppError::service_unavailable("Start the channel hub before making administrative changes") @@ -410,6 +451,7 @@ pub async fn channel_hub_start(state: State<'_, Arc>) -> AppResult = state.inner().clone(); @@ -418,7 +460,7 @@ pub async fn channel_hub_start(state: State<'_, Arc>) -> AppResult>) -> AppResult>, + enabled: bool, +) -> AppResult { + if enabled { + ensure_supported()?; } - Ok(overview(&state, settings).await) + let _control = state.channel_hub_control_lock.lock().await; + let mut settings = load_settings(&state).await?; + if !enabled { + // Keep the preference On until teardown is acknowledged. The UI must + // never claim hosting is Off while a relay actor may still be live. + shutdown_channel_hub(&state).await?; + settings.enabled = false; + } + + let mut values = if enabled { + Vec::new() + } else { + settings.setting_rows() + }; + values.push(( + CHANNEL_HOSTING_ENABLED_KEY.to_string(), + if enabled { "1" } else { "0" }.to_string(), + )); + values.push(( + CHANNEL_HOSTING_PREFERENCE_VERSION_KEY.to_string(), + CHANNEL_HOSTING_PREFERENCE_VERSION.to_string(), + )); + let pool = state.db.clone(); + crate::db::spawn_db(pool, move |pool| { + crate::db::try_set_settings(&pool, &values) + }) + .await + .map_err(|_| AppError::internal("channel hosting preference task panicked"))? + .map_err(AppError::database_unavailable)?; + + state.emit_to_all( + "app_settings_updated", + json!({ "channel_hosting_enabled": enabled }), + ); + Ok(overview(&state, settings, enabled).await) } #[tauri::command] @@ -446,7 +530,9 @@ pub async fn channel_hub_set_config( ) -> AppResult { ensure_supported()?; let _control = state.channel_hub_control_lock.lock().await; - let settings = apply_config_args(load_settings(&state).await?, args)?; + let current = load_settings(&state).await?; + ensure_hosting_enabled(&state).await?; + let settings = apply_config_args(current, args)?; persist_settings(&state, &settings).await?; if let Some(hub) = state.channel_hub_handle() { @@ -463,7 +549,7 @@ pub async fn channel_hub_set_config( )); } } - Ok(overview(&state, settings).await) + Ok(overview(&state, settings, true).await) } #[cfg(test)] @@ -479,8 +565,7 @@ mod tests { hub_name: Some("New name".to_string()), greeting: None, announce_interval_secs: Some(42), - resource_send: None, - resource_accept: None, + recent_activity_retention_secs: None, }, ) .unwrap_err(); @@ -490,6 +575,18 @@ mod tests { original.hub_name, ratspeak_runtime::channel_hub::DEFAULT_HUB_NAME ); + + let error = apply_config_args( + original, + ChannelHubConfigArgs { + hub_name: None, + greeting: None, + announce_interval_secs: None, + recent_activity_retention_secs: Some(900), + }, + ) + .unwrap_err(); + assert_eq!(error.code, "bad_request"); } #[test] @@ -499,17 +596,15 @@ mod tests { hub_name: "Existing".to_string(), greeting: "Hello".to_string(), announce_interval_secs: 900, - resource_send_enabled: true, - resource_accept_enabled: false, + recent_activity_retention_secs: 3600, }; let updated = apply_config_args( original, ChannelHubConfigArgs { hub_name: Some(" Mountain relay ".to_string()), greeting: None, - announce_interval_secs: None, - resource_send: None, - resource_accept: Some(true), + announce_interval_secs: Some(43_200), + recent_activity_retention_secs: Some(21_600), }, ) .unwrap(); @@ -517,9 +612,8 @@ mod tests { assert!(updated.enabled); assert_eq!(updated.hub_name, "Mountain relay"); assert_eq!(updated.greeting, "Hello"); - assert_eq!(updated.announce_interval_secs, 900); - assert!(updated.resource_send_enabled); - assert!(updated.resource_accept_enabled); + assert_eq!(updated.announce_interval_secs, 43_200); + assert_eq!(updated.recent_activity_retention_secs, 21_600); } fn room_policy_args() -> ChannelHubAdminRoomPolicyArgs { diff --git a/crates/ratspeak-tauri/src/commands/interfaces.rs b/crates/ratspeak-tauri/src/commands/interfaces.rs index a738c11..8f0a7fb 100644 --- a/crates/ratspeak-tauri/src/commands/interfaces.rs +++ b/crates/ratspeak-tauri/src/commands/interfaces.rs @@ -953,18 +953,26 @@ pub async fn set_auto_announce(state: State<'_, Arc>, interval: u64) - #[tauri::command] pub async fn api_app_settings(state: State<'_, Arc>) -> AppResult { - let (hw_timeout, developer_mode, window_decorations) = db::spawn_db(state.db.clone(), |p| { - let hw_timeout = db::get_setting(&p, "hardware_session_timeout") - .and_then(|v| v.parse::().ok()) - .unwrap_or(0); - let developer_mode = - db::get_setting(&p, "developer_mode_enabled").is_some_and(|v| v == "true"); - let window_decorations = - db::get_setting(&p, "window_decorations").unwrap_or_else(|| "auto".to_string()); - (hw_timeout, developer_mode, window_decorations) - }) - .await - .unwrap_or((0, false, "auto".to_string())); + let (hw_timeout, developer_mode, window_decorations, channel_hosting_enabled) = + db::spawn_db(state.db.clone(), |p| { + let hw_timeout = db::get_setting(&p, "hardware_session_timeout") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let developer_mode = + db::get_setting(&p, "developer_mode_enabled").is_some_and(|v| v == "true"); + let window_decorations = + db::get_setting(&p, "window_decorations").unwrap_or_else(|| "auto".to_string()); + let channel_hosting_enabled = + ratspeak_runtime::channel_hub::channel_hosting_enabled(&p); + ( + hw_timeout, + developer_mode, + window_decorations, + channel_hosting_enabled, + ) + }) + .await + .unwrap_or((0, false, "auto".to_string(), false)); Ok(json!({ "auto_announce_interval": *state.announce_interval_rx.borrow(), "announce_ratspeak_usage": state.announce_ratspeak_usage_enabled(), @@ -972,6 +980,7 @@ pub async fn api_app_settings(state: State<'_, Arc>) -> AppResult")); assert!(runtime.contains("channel_hub::hub_identity_path")); assert!(commands.contains("ChannelHubSettings::load")); + assert!(commands.contains("valid_channel_hub_announce_interval_secs")); assert!(commands.contains("try_set_settings")); assert!(commands.contains("hub.status()")); assert!(commands.contains("hub.admin_snapshot()")); @@ -823,6 +864,7 @@ fn channel_hub_persists_policy_only_and_gates_room_creation() { "pub async fn channel_hub_admin_mutate", "pub async fn channel_hub_start", "pub async fn channel_hub_stop", + "pub async fn set_channel_hosting_enabled", "pub async fn channel_hub_set_config", ] { let body = commands @@ -836,7 +878,8 @@ fn channel_hub_persists_policy_only_and_gates_room_creation() { // memory, and kept off the content-free Activity/event path. assert!(hub.contains("HubCommand::AdminSnapshot")); assert!(hub.contains("result_tx.send(core.admin_snapshot())")); - assert!(hub.contains("CHANNEL_HUB_EVIDENCE_RETENTION_SECS")); + assert!(hub.contains("CHANNEL_HUB_EVIDENCE_RETENTION_DEFAULT_SECS: u64 = 0")); + assert!(hub.contains("valid_evidence_retention_secs")); assert!(hub.contains("CHANNEL_HUB_EVIDENCE_MAX_EVENTS")); assert!(hub.contains("CHANNEL_HUB_EVIDENCE_MAX_BYTES")); assert!(hub.contains("persistent: false")); @@ -3257,6 +3300,101 @@ fn developer_mode_persists_in_sqlite_not_only_localstorage() { assert!(tauri_lib.contains("ratspeak_tauri::commands::interfaces::set_developer_mode")); } +#[test] +fn channel_hosting_is_an_explicit_durable_settings_capability() { + let root = repo_root(); + let index = read_source(root.join("dashboard/index.html")).expect("dashboard index"); + let settings_js = + read_source(root.join("dashboard/static/js/settings.js")).expect("settings js"); + let hub_ui = + read_source(root.join("dashboard/static/js/channel_hub.js")).expect("channel hub frontend"); + let channels_css = + read_source(root.join("dashboard/static/css/09-channels.css")).expect("channels css"); + let commands = read_source(root.join("crates/ratspeak-tauri/src/commands/channel_hub.rs")) + .expect("channel hub commands"); + let interfaces = read_source(root.join("crates/ratspeak-tauri/src/commands/interfaces.rs")) + .expect("interfaces commands"); + let runtime_hub = read_source(root.join("crates/ratspeak-runtime/src/channel_hub.rs")) + .expect("channel hub runtime"); + let runtime = + read_source(root.join("crates/ratspeak-runtime/src/lib.rs")).expect("runtime lifecycle"); + let tauri_lib = read_source(root.join("src-tauri/src/lib.rs")).expect("src-tauri lib"); + + let general_nav = index + .find(r#"data-settings-panel="panel-settings-general""#) + .expect("General settings navigation"); + let channels_nav = index + .find(r#"data-settings-panel="panel-settings-channels""#) + .expect("Channels settings navigation"); + let identity_nav = index + .find(r#"data-settings-panel="panel-settings-identity""#) + .expect("Identity settings navigation"); + assert!(general_nav < channels_nav && channels_nav < identity_nav); + assert!(index.contains(r#"id="panel-settings-channels""#)); + assert!(index.contains(r#""#)); + assert!(index.contains(r#"role="radiogroup" aria-label="Channel hosting""#)); + assert!(index.contains(r#"id="settings-channel-hosting-desc" aria-live="polite""#)); + assert!(index.contains( + r#"type="radio" name="settings-channel-hosting" id="settings-channel-hosting-off" value="off" checked"# + )); + assert!(index.contains( + r#"type="radio" name="settings-channel-hosting" id="settings-channel-hosting-on" value="on""# + )); + + assert!(settings_js.contains("function initChannelHostingToggle()")); + assert!(settings_js.contains("RS.invoke('set_channel_hosting_enabled'")); + assert!(settings_js.contains("adoptChannelHostingFromBackend(data.channel_hosting_enabled)")); + assert!(settings_js.contains("var _settingsChannelHostingRequested = null;")); + assert!(settings_js.contains("Stopping your hub and hiding hosting controls…")); + assert!(settings_js.contains("document.documentElement.dataset.channelHosting")); + assert!(settings_js.contains("channelHubRenderHome(channelHubOverview)")); + assert!(channels_css.contains(r#"html[data-channel-hosting="off"] .channel-owned-hub"#)); + let hosting_toggle = settings_js + .split("function setChannelHostingEnabled(enabled)") + .nth(1) + .and_then(|tail| tail.split("function initChannelHostingToggle").next()) + .expect("channel hosting toggle"); + assert!(!hosting_toggle.contains("_settingsChannelHostingEnabled = !!enabled;")); + assert!(hosting_toggle.contains("RS.invoke('api_channel_hub')")); + assert!(!settings_js.contains("ratspeak-channel-hosting")); + assert!(hub_ui.contains("overview.supported && _channelHubHostingEnabled(overview)")); + assert!( + hub_ui.contains("_channelHubHostingEnabled(overview) && _channelHubHasOwnedHub(overview)") + ); + + assert!(commands.contains("pub async fn set_channel_hosting_enabled")); + assert!(commands.contains("CHANNEL_HOSTING_ENABLED_KEY")); + assert!(commands.contains("CHANNEL_HOSTING_PREFERENCE_VERSION_KEY")); + assert!(commands.contains("settings.enabled = false;")); + assert!(commands.contains("hub.shutdown().await")); + let preference_command = commands + .split("pub async fn set_channel_hosting_enabled") + .nth(1) + .and_then(|tail| tail.split("#[tauri::command]").next()) + .expect("channel hosting preference command"); + let teardown = preference_command + .find("shutdown_channel_hub(&state).await?") + .expect("preference Off waits for hub teardown"); + let persist = preference_command + .find("crate::db::try_set_settings") + .expect("preference persistence"); + assert!(teardown < persist); + assert!(commands.contains("ensure_hosting_enabled(&state")); + assert!(interfaces.contains(r#""channel_hosting_enabled": channel_hosting_enabled"#)); + assert!( + runtime_hub + .contains("pub const CHANNEL_HOSTING_ENABLED_KEY: &str = \"channel_hosting_enabled\";") + ); + assert!(runtime_hub.contains("pub const CHANNEL_HOSTING_PREFERENCE_VERSION_KEY: &str =")); + assert!(runtime_hub.contains("channel_hub_enabled\".to_string(), \"0\".to_string()")); + assert!(!runtime_hub.contains("legacy_hub_enabled")); + assert!(runtime.contains("channel_hub::channel_hosting_enabled(")); + assert!(runtime.contains("reason = \"hosting_disabled\"")); + assert!( + tauri_lib.contains("ratspeak_tauri::commands::channel_hub::set_channel_hosting_enabled") + ); +} + #[test] fn interface_pause_resume_is_config_backed_and_visible() { let root = repo_root(); @@ -4349,10 +4487,44 @@ fn settings_system_panel_has_developer_mode_and_reset_group() { assert!(views_css.contains(".settings-radio-option input:checked + span")); assert!( responsive_css - .contains(".settings-radio-option span { min-height: 38px; min-width: 58px; }") + .contains(".settings-radio-option span { min-height: 40px; min-width: 58px; }") ); } +#[test] +fn settings_machine_states_share_uppercase_outfit_typography() { + let root = repo_root(); + let index = read_source(root.join("dashboard/index.html")).expect("dashboard index"); + let tokens = read_source(root.join("dashboard/static/css/00-tokens.css")).expect("type tokens"); + let views = read_source(root.join("dashboard/static/css/10-views.css")).expect("views css"); + let settings = read_source(root.join("dashboard/static/js/settings.js")).expect("settings js"); + let propagation = + read_source(root.join("dashboard/static/js/propagation.js")).expect("propagation js"); + let modals = read_source(root.join("dashboard/static/js/modals.js")).expect("modals js"); + + assert!(tokens.contains("--type-state-size: var(--text-xs);")); + assert!(tokens.contains("--type-state-weight: var(--type-weight-semibold);")); + assert!(tokens.contains("--type-state-tracking: 0.04em;")); + assert!(views.contains(".settings-radio-option span {")); + assert!(views.contains("font-family: var(--font-sans);")); + assert!(views.contains("font-size: var(--type-state-size);")); + assert!(views.contains("text-transform: uppercase;")); + assert!(views.contains(".settings-state-value {")); + assert!(views.contains(".relay-mode-btn {")); + + assert!( + index.contains( + r#"class="selector-badge settings-state-value" id="transport-mode-select">OFF"# + ) + ); + assert!(index.contains(r#"id="hw-lock-timeout-select">OFF"#)); + assert!(settings.contains("if (!secs || secs <= 0) return 'OFF';")); + assert!(settings.contains("{ label: 'OFF', value: '0'")); + assert!(propagation.contains("? ('Cost ' + cost) : 'OFF'")); + assert!(propagation.contains("relayBadge.textContent = 'OFF';")); + assert!(modals.contains("{ label: 'Always on', value: '0' }")); +} + #[test] fn mobile_primary_lists_share_readable_row_scale() { let root = repo_root(); diff --git a/dashboard/index.html b/dashboard/index.html index 3d17272..c9575f2 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -1,5 +1,5 @@ - + @@ -11,7 +11,7 @@ injects pixel values via evaluateJavascript; desktop falls back to 0. --> Ratspeak - Dashboard - + @@ -557,14 +557,13 @@

Channels

-
+
@@ -667,7 +666,7 @@
-
The hub may provide only part of the member list.
+
Partial list
@@ -1128,6 +1127,10 @@ General Theme, vibration, notifications, and blocks + + @@ -1299,7 +1324,7 @@ Transport Mode Relay packets for other nodes on the network - +
@@ -1846,17 +1871,17 @@ - + - - - - + + + + diff --git a/dashboard/scripts/test_channel_hub_admin.js b/dashboard/scripts/test_channel_hub_admin.js index 98a5d3e..f668b8a 100644 --- a/dashboard/scripts/test_channel_hub_admin.js +++ b/dashboard/scripts/test_channel_hub_admin.js @@ -166,7 +166,7 @@ function adminSnapshot(overrides) { max_resource_bytes: 262144 }, evidence_policy: { - retention_secs: 900, + retention_secs: 3600, max_events: 128, max_estimated_bytes: 65536, max_excerpt_bytes: 256, @@ -226,7 +226,6 @@ vm.runInNewContext( '\nthis.renderPeople = _channelHubRenderAdminPeople;' + '\nthis.renderAccess = _channelHubRenderAdminAccess;' + '\nthis.renderActivity = _channelHubRenderAdminActivity;' + - '\nthis.renderLimits = _channelHubRenderAdminLimits;' + '\nthis.identityValue = _channelHubAdminIdentityValue;' + '\nthis.utf8Length = _channelHubAdminUtf8Length;' + '\nthis.keyModeOptions = _channelHubAdminKeyModeOptions;' + @@ -284,9 +283,10 @@ var activityData = adminSnapshot({ var activityRoot = new FakeElement('section'); context.renderActivity(activityRoot, activityData, function() {}); var activityText = textTree(activityRoot); -assert(activityText.indexOf('Recent context, not a transcript') !== -1); -assert(activityText.indexOf('Memory-only and incomplete') !== -1); -assert(activityText.indexOf('display-sanitized and capped at 256 B') !== -1); +assert(activityText.indexOf('Last 1 hour') !== -1); +assert(activityText.indexOf('Memory only') !== -1); +assert(activityText.indexOf('Recent context, not a transcript') === -1); +assert(activityText.indexOf('display-sanitized') === -1); assert(activityText.indexOf(malicious) !== -1, 'hostile-looking evidence must remain visible as literal text'); assert.strictEqual(descendants(activityRoot).filter(function(node) { @@ -300,14 +300,31 @@ context.renderActivity(stoppedActivity, adminSnapshot({ evidence: [] }), function() {}); assert(textTree(stoppedActivity).indexOf('No activity while stopped') !== -1); -assert(textTree(stoppedActivity).indexOf('never persisted') !== -1); +assert(textTree(stoppedActivity).indexOf('clears whenever the hub stops') !== -1); + +var disabledActivity = new FakeElement('section'); +context.renderActivity(disabledActivity, adminSnapshot({ + evidence_policy: { + retention_secs: 0, + max_events: 128, + max_estimated_bytes: 65536, + max_excerpt_bytes: 256, + persistent: false + }, + evidence: [] +}), function() {}); +assert(textTree(disabledActivity).indexOf('Recent activity is off') !== -1); +assert(textTree(disabledActivity).indexOf('Enable it in Settings') !== -1); var overviewRoot = new FakeElement('section'); context.renderOverview(overviewRoot, adminSnapshot(), function() {}); var overviewText = textTree(overviewRoot); assert(overviewText.indexOf('Unique identities') !== -1); assert(overviewText.indexOf('Live sessions') !== -1); -assert(overviewText.indexOf('Policy is durable. Conversation traffic is not.') !== -1); +assert(overviewText.indexOf('This run') === -1); +assert(overviewText.indexOf('Room relays') === -1); +assert(overviewText.indexOf('Policy is durable. Conversation traffic is not.') === -1); +assert(overviewText.indexOf('Hub stopped') === -1); var channelsRoot = new FakeElement('section'); context.renderChannels(channelsRoot, adminSnapshot(), function() {}); @@ -529,13 +546,6 @@ assert.deepStrictEqual( 'live-only channels expose only the actor-owned live kick' ); -var limitsRoot = new FakeElement('section'); -context.renderLimits(limitsRoot, adminSnapshot()); -var limitsText = textTree(limitsRoot); -assert(limitsText.indexOf('Operating limits') !== -1); -assert(limitsText.indexOf('256 KiB') !== -1); -assert(limitsText.indexOf('not editable in this release') !== -1); - var managerSource = sourceRange('channelHubOpenManager', 'channelHubOpenOwnHub'); assert(managerSource.indexOf("RS.invoke('api_channel_hub_admin')") !== -1); assert(managerSource.indexOf("RS.invoke('channel_hub_admin_mutate', { args: args })") !== -1); diff --git a/dashboard/scripts/test_channel_hub_ui.js b/dashboard/scripts/test_channel_hub_ui.js index 7b6a07c..0ed5abe 100644 --- a/dashboard/scripts/test_channel_hub_ui.js +++ b/dashboard/scripts/test_channel_hub_ui.js @@ -17,11 +17,15 @@ function sourceRange(firstName, lastName) { return source.slice(start, end); } -var statusContext = {}; +var statusContext = { + window: { + ratspeakChannelHostingEnabled: function() { return true; } + } +}; vm.runInNewContext( sourceRange('_channelHubPlural', '_channelHubApplyOverview') + '\nthis.statusModel = _channelHubStatusModel;' + - '\nthis.announceLabel = _channelHubAnnounceLabel;', + '\nthis.hostingEnabled = _channelHubHostingEnabled;', statusContext, { filename: 'channel-hub-status.js' } ); @@ -56,11 +60,22 @@ assert.strictEqual(stopped.label, 'Not running'); assert.strictEqual(stopped.detail, 'Create a place for your community'); assert.strictEqual(stopped.action, 'start'); -assert.strictEqual(statusContext.announceLabel(0), 'When started'); -assert.strictEqual(statusContext.announceLabel(900), 'Every 15 min'); -assert.strictEqual(statusContext.announceLabel(3600), 'Every hour'); -assert.strictEqual(statusContext.announceLabel(21600), 'Every 6 hours'); -assert.strictEqual(statusContext.announceLabel(86400), 'Every day'); +assert.strictEqual(statusContext.hostingEnabled({ hosting_enabled: false }), true); +assert.strictEqual(statusContext.hostingEnabled({ hosting_enabled: true }), true); +assert.strictEqual(statusContext.hostingEnabled({}), true); +statusContext.window.ratspeakChannelHostingEnabled = undefined; +assert.strictEqual(statusContext.hostingEnabled({ hosting_enabled: true }), false, + 'hosting defaults closed until Settings establishes an explicit preference'); +statusContext.window.ratspeakChannelHostingEnabled = function() { return false; }; +assert.strictEqual(statusContext.hostingEnabled({ hosting_enabled: true }), false, + 'a stale overview must not resurrect hosting after Settings is Off'); + +assert(source.indexOf( + 'var visible = _channelHubHostingEnabled(overview) && _channelHubHasOwnedHub(overview);' +) !== -1); +assert(source.indexOf( + 'if (overview.supported && _channelHubHostingEnabled(overview))' +) !== -1); var configContext = {}; vm.runInNewContext( @@ -75,29 +90,42 @@ var args = configContext.configArgs( { value: ' Mountain hub ' }, { value: ' Welcome ' }, { value: '900' }, - { checked: true }, - { checked: false } + { value: '21600' } ); assert.deepStrictEqual(JSON.parse(JSON.stringify(args)), { hub_name: 'Mountain hub', greeting: 'Welcome', announce_interval_secs: 900, - resource_send: true, - resource_accept: false + recent_activity_retention_secs: 21600 }); assert.strictEqual(configContext.settingsEqual({ hub_name: 'Mountain hub', greeting: 'Welcome', announce_interval_secs: 900, - resource_send_enabled: true, - resource_accept_enabled: false + recent_activity_retention_secs: 21600 }, args), true); assert.strictEqual(configContext.settingsEqual({ hub_name: 'Mountain hub', greeting: 'Different', announce_interval_secs: 900, - resource_send_enabled: true, - resource_accept_enabled: false + recent_activity_retention_secs: 21600 }, args), false); +[ + "[900, 'Every 15 minutes']", + "[1800, 'Every 30 minutes']", + "[3600, 'Every hour']", + "[43200, 'Every 12 hours']", + "[86400, 'Every 24 hours']" +].forEach(function(option) { + assert(source.indexOf(option) !== -1, 'missing announce choice ' + option); +}); +assert(source.indexOf("[0, 'When started']") === -1); +assert(source.indexOf("[300, 'Every 5 min']") === -1); +assert(source.indexOf("[21600, 'Every 6 hours']") === -1); +assert(source.indexOf('At startup and on this schedule, so nearby people can find it') !== -1); +assert(source.indexOf('Large welcome messages') === -1); +assert(source.indexOf('Large room notices') === -1); +assert(source.indexOf('Operating limits') === -1); + console.log('channel hub UI tests passed'); diff --git a/dashboard/scripts/test_channels_directory.js b/dashboard/scripts/test_channels_directory.js index 99373a1..45059d3 100644 --- a/dashboard/scripts/test_channels_directory.js +++ b/dashboard/scripts/test_channels_directory.js @@ -245,7 +245,8 @@ async function main() { Array: Array }; vm.runInNewContext( - sourceFunction('_channelsRenderList', '_channelsListSection') + '\n' + + sourceFunction('_channelsRoomDisplayName', '_channelsTimelineHubName') + '\n' + + sourceFunction('_channelsRenderList', '_channelsListSection') + '\n' + sourceFunction('_channelsListSection', '_channelsEmptyList') + '\n' + sourceFunction('_channelsBuildDirectoryRoomRow', '_channelsBuildRoomRow'), renderContext, diff --git a/dashboard/scripts/test_channels_history.js b/dashboard/scripts/test_channels_history.js index c1203b6..eb9a747 100644 --- a/dashboard/scripts/test_channels_history.js +++ b/dashboard/scripts/test_channels_history.js @@ -167,7 +167,11 @@ async function main() { history: { phase: 'ready' } }, _channelsLocalRoomEvents: { general: [] }, - _channelsIsHubNotice: function() { return false; } + _channelsIsHubNotice: function() { return false; }, + _channelsIsConnectionLifecycleItem: function(item) { + return !!item && item.kind === 'system' && + item.text === 'Reconnected to hub'; + } }; vm.runInNewContext( sourceRange('_channelsTimelineEntries', '_channelsBuildHistoryRail'), @@ -198,6 +202,15 @@ async function main() { nickname: 'Pending', text: 'pending', ours: false + }, + { + id: 'legacy-reconnect', + kind: 'system', + timestamp_ms: 3, + source_hash: null, + nickname: null, + text: 'Reconnected to hub', + ours: true } ] }, { @@ -209,7 +222,7 @@ async function main() { assert.deepStrictEqual( Array.from(merged, function(entryValue) { return entryValue.item.id; }), ['older', 'current', 'pending'], - 'receive sequence, not peer timestamps, determines the merged timeline' + 'receive sequence, not peer timestamps, orders human activity and hides legacy Link lifecycle rows' ); assert.strictEqual(merged[1].item.recorded_at_ms, 11_000, 'a live overlap inherits its trusted local receive time'); @@ -220,6 +233,8 @@ async function main() { 'the offline room browser must use the bookmark/history union'); assert(channelsSource.indexOf('latest_recorded_at_ms') !== -1, 'retained history must stay discoverable and sort by local receive time'); + assert(channelsSource.indexOf('Local timeline') === -1, + 'a healthy local history store must not occupy a persistent transcript rail'); assert(channelsSource.indexOf('api_saved_channel_room_index') === -1, 'a bookmark-only index would hide history after forgetting a hub'); console.log('channel history tests passed'); diff --git a/dashboard/scripts/test_channels_hub_switcher.js b/dashboard/scripts/test_channels_hub_switcher.js index 8d20449..057dd26 100644 --- a/dashboard/scripts/test_channels_hub_switcher.js +++ b/dashboard/scripts/test_channels_hub_switcher.js @@ -13,6 +13,10 @@ var channelsSource = fs.readFileSync( path.join(root, 'static', 'js', 'channels.js'), 'utf8' ); +var channelHubSource = fs.readFileSync( + path.join(root, 'static', 'js', 'channel_hub.js'), + 'utf8' +); var indexSource = fs.readFileSync(path.join(root, 'index.html'), 'utf8'); var cssSource = fs.readFileSync( path.join(root, 'static', 'css', '09-channels.css'), @@ -154,19 +158,82 @@ assert.strictEqual(mode('error', HUB_A, HUB_A), 'connect', 'an ended attempt remains retryable'); assert.strictEqual(mode('offline', HUB_B, null), 'connect'); +var hubStripClasses = new Set(); +var hubStrip = { + dataset: { phase: 'offline' }, + classList: { + add: function(name) { hubStripClasses.add(name); }, + remove: function(name) { hubStripClasses.delete(name); } + } +}; +var hubSwitcher = { + attributes: {}, + setAttribute: function(name, value) { this.attributes[name] = value; }, + title: '' +}; +var hubMenu = { hidden: true }; +var hubStripText = {}; +var animationFrames = []; +var stripContext = { + channelsSnapshot: { + phase: 'active', + nickname: 'Bob', + last_error: null, + hub: { destination_hash: HUB_A, name: 'MichMesh.hub' } + }, + _channelsEl: function(id) { + if (id === 'channel-hub-strip') return hubStrip; + if (id === 'channel-hub-switcher-btn') return hubSwitcher; + if (id === 'channel-hub-menu-btn') return hubMenu; + return null; + }, + _channelsSetText: function(id, value) { hubStripText[id] = value; }, + _channelsPhaseLabel: function(phase) { return phase; }, + _channelsHubName: function(hub) { return hub.name; }, + _channelsIsConnecting: function() { return false; }, + _channelsShortHash: function(hash) { return hash.slice(0, 8); }, + requestAnimationFrame: function(callback) { animationFrames.push(callback); } +}; +vm.runInNewContext( + sourceRange('_channelsRenderHubStrip', '_channelsRenderList'), + stripContext, + { filename: 'channels-hub-strip.js' } +); + +stripContext._channelsRenderHubStrip(); +assert.strictEqual(hubStrip.dataset.phase, 'active'); +assert.strictEqual(animationFrames.length, 1, + 'entering active queues one signal lap'); +animationFrames.shift()(); +assert(hubStripClasses.has('link-arrived')); +assert.strictEqual( + hubSwitcher.attributes['aria-label'], + 'Current channel hub: MichMesh.hub. Connected as Bob. Choose another hub' +); + +stripContext._channelsRenderHubStrip(); +assert.strictEqual(animationFrames.length, 0, + 'routine active snapshots must not replay the signal lap'); +stripContext.channelsSnapshot.phase = 'stale'; +stripContext._channelsRenderHubStrip(); +assert(!hubStripClasses.has('link-arrived'), + 'leaving active clears any pending trace state'); + var stripPosition = indexSource.indexOf('id="channel-hub-switcher-btn"'); var listPosition = indexSource.indexOf('id="channels-list"'); assert(stripPosition !== -1 && stripPosition < listPosition, 'the hub selector must remain visibly above the selected hub channel list'); assert(indexSource.indexOf('aria-haspopup="dialog"') !== -1); +assert(indexSource.indexOf('channel-live-beacon') === -1, + 'the connected perimeter replaces the redundant status dot'); assert(channelsSource.indexOf('function channelsOpenHubSwitcher()') !== -1); assert(channelsSource.indexOf("hubSwitcher.addEventListener('click', channelsOpenHubSwitcher)") !== -1); -assert(channelsSource.indexOf('Ratspeak keeps one live hub at a time.') !== -1); -assert(channelsSource.indexOf('Saved channels and local history stay on this device.') !== -1); +assert(channelsSource.indexOf('One hub can be live at a time.') !== -1); +assert(channelsSource.indexOf('history stays on this device.') !== -1); assert(channelsSource.indexOf("list.setAttribute('aria-live', 'polite')") !== -1); assert(channelsSource.indexOf("list.setAttribute('aria-busy', 'true')") !== -1); -assert(channelsSource.indexOf("titleElement.textContent = 'Switch channel hub'") !== -1); -assert(channelsSource.indexOf("'Switch hub'") !== -1); +assert(channelsSource.indexOf("titleElement.textContent = 'Switch to '") !== -1); +assert(channelsSource.indexOf("sharedRoom ? 'Switch and review' : 'Switch'") !== -1); assert(channelsSource.indexOf("'Switching channel hub\\u2026'") !== -1); assert(channelsSource.indexOf("switching: connectMode.kind === 'switch'") !== -1); assert(channelsSource.indexOf("'Could not switch channel hubs.'") !== -1); @@ -180,15 +247,41 @@ assert(channelsSource.indexOf('CHANNELS_CONNECTION_BUDGET') === -1, 'the frontend must not invent or raise the runtime connection budget'); var switcherSource = sourceRange('channelsOpenHubSwitcher', 'channelsOpenConnectSheet'); +var connectSource = sourceRange('channelsOpenConnectSheet', '_channelsSheetField'); assert(switcherSource.indexOf('localStorage') === -1); assert(switcherSource.indexOf("RS.invoke('connect_channel_hub'") === -1, 'choosing a hub must open explicit review instead of connecting from the switcher'); assert(switcherSource.indexOf('channelsRefreshAvailableHubs()') !== -1); assert(switcherSource.indexOf('Scan') === -1, 'the recent announce cache must not be presented as an active network scan'); +assert(connectSource.indexOf('Available hubs') === -1, + 'connection review must not repeat the hub picker'); +assert(connectSource.indexOf('Open a shared channel') === -1, + 'link acquisition belongs in Add channels, not connection review'); +assert(connectSource.indexOf('Encrypted in transit') === -1, + 'connection review must not repeat transport trust copy'); +assert(connectSource.indexOf('Ends live rooms') === -1, + 'the switch title and action are sufficient confirmation'); +assert(connectSource.indexOf('channel-connection-trust') === -1, + 'removed connection copy must not leave an empty layout row'); +assert(connectSource.indexOf("initialMode.kind === 'current'") !== -1 && + connectSource.indexOf('channelsOpenHubOptions();') !== -1, + 'reviewing the current hub must lead to hub actions instead of a disabled dead end'); +assert(channelHubSource.indexOf("title: 'Add channels'") !== -1); +assert(channelHubSource.indexOf("'Use a link or QR'") !== -1); +assert(channelHubSource.indexOf( + 'if (overview.supported && _channelHubHostingEnabled(overview))' +) !== -1, + 'hosting requires Settings opt-in, but Add channels must remain available everywhere'); assert(cssSource.indexOf('.channel-hub-switcher-btn') !== -1); +assert(cssSource.indexOf('.channel-live-beacon') === -1); +assert(cssSource.indexOf('@keyframes channelHubSignalLap') !== -1); +assert(cssSource.indexOf('@media (prefers-reduced-motion: reduce)') !== -1); +assert(channelsSource.indexOf("previousPhase !== 'active'") !== -1, + 'the signal lap must run only on a transition into the active state'); assert(cssSource.indexOf('.channel-hub-switcher-list .channel-hub-row.current') !== -1); assert(cssSource.indexOf('.channel-hub-switch-impact') !== -1); +assert(cssSource.indexOf('.channel-connection-trust') === -1); console.log('channel hub switcher tests passed'); diff --git a/dashboard/scripts/test_channels_presence.js b/dashboard/scripts/test_channels_presence.js index 4a7dca3..06ff846 100644 --- a/dashboard/scripts/test_channels_presence.js +++ b/dashboard/scripts/test_channels_presence.js @@ -10,7 +10,7 @@ var vm = require('vm'); var channelsPath = path.join(__dirname, '..', 'static', 'js', 'channels.js'); var channelsSource = fs.readFileSync(channelsPath, 'utf8'); -var constantsStart = channelsSource.indexOf('var CHANNEL_PRESENCE_GROUP_WINDOW_MS'); +var constantsStart = channelsSource.indexOf('var CHANNEL_PRESENCE_REJOIN_WINDOW_MS'); var constantsEnd = channelsSource.indexOf('\n\nfunction _channelsEl', constantsStart); var activityStart = channelsSource.indexOf('function _channelsActivityTime'); var activityEnd = channelsSource.indexOf('\nfunction _channelsBuildDaySeparator', activityStart); @@ -40,6 +40,32 @@ vm.runInNewContext( ); var presence = context.window.ChannelsPresence; +var rosterStart = channelsSource.indexOf('function _channelsRosterMemberKey'); +var rosterEnd = channelsSource.indexOf('\nfunction _channelsSavedHub', rosterStart); +assert(rosterStart !== -1 && rosterEnd !== -1, 'roster reconciliation helpers must exist'); +var rosterEvents = []; +var rosterContext = { + window: {}, + _channelsRosterBaselines: {}, + channelsSnapshot: { rooms: [] }, + _channelsHistoryKey: function(hub, room) { return hub + '|' + room; }, + _channelsMemberName: function(member) { + return member.nickname || member.identity_hash || 'Channel member'; + }, + _channelsPresenceIdentityKey: function(item) { + if (item.source_hash) return 'source:' + String(item.source_hash).toLowerCase(); + return item.nickname ? 'nickname:' + String(item.nickname).toLowerCase() : ''; + }, + _channelsAddLocalRoomItem: function(room, item) { + rosterEvents.push({ room: room, item: item }); + } +}; +vm.runInNewContext( + channelsSource.slice(rosterStart, rosterEnd) + + '\nwindow.reconcile = _channelsReconcileRosterPresence;', + rosterContext, + { filename: 'channels-roster-presence.js' } +); var tests = []; function test(name, fn) { @@ -156,6 +182,49 @@ test('mixed uninterrupted presence activity becomes one truthful summary', funct assert.strictEqual(summary.text, '6 people joined and 3 left'); }); +test('membership activity stays grouped until a message even when events are minutes apart', function() { + var entries = [ + event('join', 1_000, null, 'DhC'), + event('part', 23_000, null, 'DhC'), + event('join', 387_000, null, 'Brongus') + ]; + var result = presence.group(entries, 'lobby'); + assert.strictEqual(result.length, 1); + assert.ok(result[0].presenceGroup); + assert.strictEqual(result[0].presenceGroup.entries.length, 3); + + var summary = presence.summary(result[0].presenceGroup); + assert.strictEqual(summary.joined.length, 2); + assert.strictEqual(summary.left.length, 1); + assert.strictEqual(summary.text, '2 people joined and 1 left'); +}); + +test('members discovered in the entry roster are described as here, not newly joined', function() { + var entries = [ + event('present', 1000, 'present-1', 'v6z'), + event('present', 2000, 'present-2', 'Ada') + ]; + var result = presence.group(entries, 'general'); + assert.strictEqual(result.length, 1); + assert.ok(result[0].presenceGroup); + var summary = presence.summary(result[0].presenceGroup); + assert.strictEqual(summary.present.length, 2); + assert.strictEqual(summary.text, '2 people here'); +}); + +test('entry roster context does not merge with later join activity', function() { + var entries = [ + event('present', 1000, 'present-1', 'v6z'), + event('join', 2000, 'join-1', 'Ada') + ]; + var result = presence.group(entries, 'general'); + assert.strictEqual(result.length, 2); + assert.strictEqual(presence.summary(result[0].presenceGroup || { + entries: [result[0]] + }).text, '1 person here'); + assert.strictEqual(result[1].item.kind, 'join'); +}); + test('a message ends a mixed presence group', function() { var entries = [ event('join', 1000, 'join-1', 'Ada'), @@ -182,9 +251,66 @@ test('count hover text prefers names and retains a full hash fallback', function ); }); +test('roster reconciliation supplies honest context and only fills missing deltas', function() { + function member(identity, nickname, isSelf) { + return { identity_hash: identity, nickname: nickname, is_self: !!isSelf }; + } + var bob = member('self', 'Bob', true); + var v6z = member('v6z-id', 'v6z', false); + var ada = member('ada-id', 'Ada', false); + var grace = member('grace-id', 'Grace', false); + var linus = member('linus-id', 'Linus', false); + var room = { + name: 'lobby', + phase: 'joined', + members: [bob, v6z], + transcript: [] + }; + rosterContext.channelsSnapshot.rooms = [room]; + rosterContext.window.reconcile('hub'); + assert.strictEqual(rosterEvents.length, 1); + assert.strictEqual(rosterEvents[0].item.kind, 'present'); + assert.strictEqual(rosterEvents[0].item.text, 'v6z is here'); + + room.members = [bob, v6z, ada]; + room.transcript = [{ + id: 'joined-ada', + kind: 'join', + source_hash: 'hub-id', + nickname: 'Ada' + }]; + rosterContext.window.reconcile('hub'); + assert.strictEqual(rosterEvents.length, 1, + 'a native join event must not be duplicated by roster inference'); + + room.members = [bob, v6z, ada, grace]; + rosterContext.window.reconcile('hub'); + assert.strictEqual(rosterEvents[1].item.text, 'Grace joined'); + + room.members = [bob, v6z, ada, grace, linus]; + room.transcript.push({ + id: 'message-linus', + kind: 'message', + source_hash: 'linus-id', + nickname: 'Linus' + }); + rosterContext.window.reconcile('hub'); + assert.strictEqual(rosterEvents.length, 2, + 'a member first observed through a message must not get a fabricated join'); + + room.members = [bob, v6z, ada, linus]; + rosterContext.window.reconcile('hub'); + assert.strictEqual(rosterEvents[2].item.text, 'Grace left'); +}); + tests.forEach(function(entry) { entry.fn(); process.stdout.write('\u2713 ' + entry.name + '\n'); }); +assert(channelsSource.indexOf('function _channelsIsConnectionLifecycleItem') !== -1, + 'legacy reconnect markers must be recognized outside the human timeline'); +assert(channelsSource.indexOf("item.text === 'Reconnected to hub'") !== -1, + 'legacy reconnect copy must remain presentation-filtered'); + process.stdout.write('\n' + tests.length + ' Channels presence tests passed.\n'); diff --git a/dashboard/scripts/test_channels_share.js b/dashboard/scripts/test_channels_share.js index 66b8a89..53e90fd 100644 --- a/dashboard/scripts/test_channels_share.js +++ b/dashboard/scripts/test_channels_share.js @@ -77,6 +77,7 @@ function applyContext(initial) { _channelsDirectoryRequestSeq: 0, _channelsDirectoryRefreshPromise: null, _channelsLocalRoomEvents: {}, + _channelsRosterBaselines: {}, _channelsExpandedPresenceGroups: {}, _channelsSelectedMemberKey: null, _channelsMemberReturnFocusKey: null, @@ -89,6 +90,7 @@ function applyContext(initial) { _channelsHistoryContext: function() { return null; }, _channelsScheduleHistorySync: function() {}, _channelsPersistConveniences: function() {}, + _channelsReconcileRosterPresence: function() {}, _channelsViewVisible: function() { return false; }, _channelsDirectoryNeedsRefresh: function() { return false; }, channelsRefreshDirectory: function() {}, diff --git a/dashboard/scripts/test_channels_snapshot_order.js b/dashboard/scripts/test_channels_snapshot_order.js index 0ca1c17..300aba8 100644 --- a/dashboard/scripts/test_channels_snapshot_order.js +++ b/dashboard/scripts/test_channels_snapshot_order.js @@ -56,6 +56,7 @@ async function main() { channelsRoomIndex: [], channelsPendingShareJoin: null, _channelsLocalRoomEvents: {}, + _channelsRosterBaselines: {}, _channelsExpandedPresenceGroups: {}, _channelsSelectedMemberKey: null, _channelsMemberReturnFocusKey: null, @@ -74,6 +75,7 @@ async function main() { _channelsHistoryContext: function() { return null; }, _channelsScheduleHistorySync: function() {}, _channelsPersistConveniences: function() {}, + _channelsReconcileRosterPresence: function() {}, _channelsViewVisible: function() { return false; }, _channelsDirectoryNeedsRefresh: function() { return false; }, channelsRefreshDirectory: function() {}, diff --git a/dashboard/static/css/00-tokens.css b/dashboard/static/css/00-tokens.css index 6f4f01c..d9b7bb1 100644 --- a/dashboard/static/css/00-tokens.css +++ b/dashboard/static/css/00-tokens.css @@ -46,6 +46,7 @@ --font-mono: 'JetBrains Mono', 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace; --font-sans: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + --font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; --glass-bg: rgba(250, 247, 243, 0.86); --glass-border: rgba(222, 212, 200, 0.82); @@ -281,6 +282,12 @@ --type-badge-weight: var(--type-weight-semibold); --type-badge-tracking: var(--tracking-wide); + /* Compact machine states such as OFF / ON / AUTO. These stay distinct + from prose labels and mixed-value selector controls. */ + --type-state-size: var(--text-xs); + --type-state-weight: var(--type-weight-semibold); + --type-state-tracking: 0.04em; + --radius-3xl: 16px; --radius-pill: 20px; --radius-full: 50%; diff --git a/dashboard/static/css/09-channels.css b/dashboard/static/css/09-channels.css index 1ddf5da..1d8d295 100644 --- a/dashboard/static/css/09-channels.css +++ b/dashboard/static/css/09-channels.css @@ -6,11 +6,13 @@ } .channels-layout { + --font-sans: var(--font-ui); display: grid; grid-template-columns: minmax(248px, 286px) minmax(360px, 1fr) 224px; height: 100%; min-height: 0; overflow: hidden; + font-family: var(--font-ui); } .channels-sidebar, @@ -63,13 +65,14 @@ align-items: center; justify-content: space-between; gap: var(--space-6); - padding: var(--space-8) var(--space-8) var(--space-6); + padding: var(--space-8) var(--space-8) var(--space-5); } .channels-sidebar-header h2 { margin: 0; color: var(--text-primary); font-size: var(--text-lg); + font-family: 'Outfit', var(--font-ui); font-weight: var(--type-weight-semibold); line-height: var(--type-leading-tight); } @@ -112,24 +115,63 @@ } .channel-hub-strip { + --channel-hub-frame: var(--border-surface-soft); + --channel-hub-surface: var(--surface-panel-muted); + position: relative; + isolation: isolate; display: flex; align-items: center; - min-height: 54px; - margin: 0 var(--space-5) var(--space-5); + min-height: 50px; + margin: 0 var(--space-5) var(--space-4); + padding: 1px; overflow: hidden; - border: 1px solid var(--border-surface-soft); + border: 0; border-radius: var(--radius-lg); - background: var(--surface-panel-muted); + background: var(--channel-hub-frame); + box-shadow: 0 0 0 1px transparent; + transition: box-shadow var(--transition-base); +} + +.channel-hub-strip::before { + content: ''; + position: absolute; + z-index: 0; + top: 50%; + left: 50%; + width: 220%; + aspect-ratio: 1; + background: conic-gradient( + from 0deg, + transparent 0deg 318deg, + rgba(var(--status-online-rgb), 0.08) 324deg, + rgba(var(--status-online-rgb), 0.92) 346deg, + transparent 360deg + ); + opacity: 0; + pointer-events: none; + transform: translate(-50%, -50%) rotate(0turn); +} + +.channel-hub-strip::after { + content: ''; + position: absolute; + z-index: 1; + inset: 1px; + border-radius: calc(var(--radius-lg) - 1px); + background: var(--channel-hub-surface); + pointer-events: none; } .channel-hub-switcher-btn { + position: relative; + z-index: 2; display: flex; min-width: 0; - min-height: 52px; + min-height: 48px; flex: 1; align-items: center; gap: var(--space-5); - padding: var(--space-5) var(--space-4) var(--space-5) var(--space-6); + padding: var(--space-4) var(--space-4) var(--space-4) var(--space-7); border: 0; background: transparent; color: inherit; @@ -149,49 +191,39 @@ .channel-hub-strip[data-phase="active"], .channel-hub-strip[data-phase="stale"] { - border-color: var(--status-online-border); - background: var(--status-online-bg); + --channel-hub-frame: rgba(var(--status-online-rgb), 0.34); + --channel-hub-surface: linear-gradient( + 110deg, + rgba(var(--status-online-rgb), 0.025), + transparent 48% + ), var(--surface-panel-muted); + box-shadow: 0 0 0 1px rgba(var(--status-online-rgb), 0.06), + 0 0 14px rgba(var(--status-online-rgb), 0.055); } .channel-hub-strip[data-phase="stale"] { - border-color: var(--status-warning-border); - background: var(--status-warning-bg); + --channel-hub-frame: rgba(var(--status-warning-rgb), 0.34); + --channel-hub-surface: linear-gradient( + 110deg, + rgba(var(--status-warning-rgb), 0.025), + transparent 48% + ), var(--surface-panel-muted); + box-shadow: 0 0 0 1px rgba(var(--status-warning-rgb), 0.05), + 0 0 12px rgba(var(--status-warning-rgb), 0.045); } .channel-hub-strip[data-phase="error"] { - border-color: var(--status-error-border); - background: var(--status-error-bg); + --channel-hub-frame: rgba(var(--status-error-rgb), 0.34); + --channel-hub-surface: linear-gradient( + 110deg, + rgba(var(--status-error-rgb), 0.025), + transparent 48% + ), var(--surface-panel-muted); + box-shadow: 0 0 0 1px rgba(var(--status-error-rgb), 0.05); } -.channel-live-beacon { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - flex: 0 0 16px; -} - -.channel-live-beacon::before { - content: ''; - position: absolute; - width: 7px; - height: 7px; - border-radius: var(--radius-full); - background: var(--text-muted); -} - -[data-phase="active"] .channel-live-beacon::before { - background: var(--status-online); -} - -[data-phase="stale"] .channel-live-beacon::before { - background: var(--status-warning); -} - -[data-phase="error"] .channel-live-beacon::before { - background: var(--status-error); +.channel-hub-strip.link-arrived::before { + animation: channelHubSignalLap 1.25s cubic-bezier(0.22, 0.61, 0.36, 1); } .channel-hub-strip-copy { @@ -205,6 +237,7 @@ overflow: hidden; color: var(--text-primary); font-size: var(--text-sm); + font-family: 'Outfit', var(--font-ui); font-weight: var(--type-weight-semibold); line-height: 1.25; text-overflow: ellipsis; @@ -221,12 +254,31 @@ } .channel-hub-menu-btn { + z-index: 2; width: 30px; height: 30px; flex-basis: 30px; margin-right: var(--space-3); border-color: transparent; background: transparent; + box-shadow: none; + opacity: 0.82; + transform: none; + transition: background var(--transition-fast), color var(--transition-fast), + opacity var(--transition-fast); +} + +.channel-hub-menu-btn::after { + display: none; +} + +.channel-hub-menu-btn:hover, +.channel-hub-menu-btn:focus-visible { + border-color: transparent; + background: var(--surface-row-hover); + box-shadow: none; + opacity: 1; + transform: none; } .channel-hub-switcher-chevron { @@ -242,17 +294,24 @@ } .channel-owned-hub { - padding: 0 var(--space-4) var(--space-4); - border-bottom: 1px solid var(--border-row); + padding: 0 var(--space-5) var(--space-2); + border-bottom: 0; } .channel-owned-hub[hidden] { display: none; } +/* The Settings preference is the visual authority as well as the service + gate. This prevents an early or stale hub overview from exposing hosting + controls while the radio group says OFF. */ +html[data-channel-hosting="off"] .channel-owned-hub { + display: none !important; +} + .channel-owned-hub-heading { display: flex; - min-height: 30px; + min-height: 24px; align-items: center; padding: 0 var(--space-3); } @@ -260,17 +319,16 @@ .channel-owned-hub-card { display: flex; min-width: 0; - min-height: 58px; + min-height: 44px; align-items: stretch; overflow: hidden; - border: 1px solid var(--border-surface-soft); - border-radius: var(--radius-lg); - background: var(--surface-panel-muted); + border: 0; + border-radius: var(--radius-md); + background: transparent; transition: border-color var(--transition-fast), background var(--transition-fast); } .channel-owned-hub-card[data-current="true"] { - border-color: var(--accent-border); background: var(--accent-bg); } @@ -292,7 +350,7 @@ min-width: 0; align-items: center; gap: var(--space-4); - padding: var(--space-4) var(--space-3) var(--space-4) var(--space-4); + padding: var(--space-3); text-align: left; } @@ -316,14 +374,14 @@ .channel-owned-hub-icon { display: inline-flex; - width: 36px; - height: 36px; - flex: 0 0 36px; + width: 28px; + height: 28px; + flex: 0 0 28px; align-items: center; justify-content: center; - border: 1px solid var(--accent-border); - border-radius: var(--radius-lg); - background: var(--accent-bg-strong); + border: 0; + border-radius: var(--radius-md); + background: var(--accent-bg); color: var(--accent); } @@ -380,24 +438,44 @@ .channel-owned-hub-manage { width: 38px; flex: 0 0 38px; - border-left: 1px solid var(--border-row); + border-left: 0; color: var(--text-muted); } .channels-list-toolbar { display: flex; align-items: center; - min-height: 34px; + min-height: 32px; padding: 0 var(--space-7); border-bottom: 1px solid var(--border-row); } +@keyframes channelHubSignalLap { + 0% { + opacity: 0; + transform: translate(-50%, -50%) rotate(0.08turn); + } + 14% { opacity: 0.55; } + 76% { opacity: 0.55; } + 100% { + opacity: 0; + transform: translate(-50%, -50%) rotate(-0.92turn); + } +} + +@media (prefers-reduced-motion: reduce) { + .channel-hub-strip.link-arrived::before { + animation: none !important; + opacity: 0; + } +} + .channels-section-label { color: var(--text-muted); - font-size: var(--type-section-label-size); - font-weight: var(--type-section-label-weight); - letter-spacing: var(--type-section-label-tracking); - text-transform: uppercase; + font-size: var(--text-xs); + font-weight: var(--type-weight-medium); + letter-spacing: 0; + text-transform: none; } .channels-list-toolbar .channels-section-label { @@ -418,6 +496,10 @@ font-size: var(--text-xs); } +.channels-text-btn::after { + display: none; +} + .channels-text-btn:disabled { opacity: var(--opacity-disabled); cursor: default; @@ -451,10 +533,10 @@ gap: var(--space-3); margin: var(--space-6) var(--space-5) var(--space-3); color: var(--text-muted); - font-size: var(--text-2xs); - font-weight: var(--type-weight-semibold); - letter-spacing: var(--type-section-label-tracking); - text-transform: uppercase; + font-size: var(--text-xs); + font-weight: var(--type-weight-medium); + letter-spacing: 0; + text-transform: none; } .channels-list-section > span { @@ -469,7 +551,7 @@ border: 0; border-radius: var(--radius-sm); background: transparent; - color: var(--accent); + color: var(--text-muted); font-family: var(--font-sans); font-size: var(--text-2xs); font-weight: var(--type-weight-semibold); @@ -673,6 +755,25 @@ color: var(--status-warning); } +.channel-row-add { + display: inline-flex; + width: 26px; + height: 26px; + flex: 0 0 26px; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + color: var(--accent); + font-size: var(--text-lg); + font-weight: var(--type-weight-regular); + line-height: 1; +} + +.channel-directory-room:hover .channel-row-add, +.channel-directory-room:focus-visible .channel-row-add { + background: var(--accent-bg); +} + .channel-room-row.has-unread .channel-room-row-title { font-weight: var(--type-weight-semibold); } @@ -751,6 +852,7 @@ overflow: hidden; margin: 0; color: var(--text-primary); + font-family: 'Outfit', var(--font-ui); font-size: var(--text-base); font-weight: var(--type-weight-semibold); text-overflow: ellipsis; @@ -822,17 +924,16 @@ display: flex; min-height: 32px; align-items: center; + justify-content: space-between; gap: var(--space-4); margin: 0 0 var(--space-6); + padding: 0 var(--space-4); color: var(--text-muted); font-size: var(--text-2xs); } -.channel-history-rail::before { - content: ''; - height: 1px; - flex: 1 1 28px; - background: var(--border-row); +.channel-history-rail.action-only { + justify-content: center; } .channel-history-label { @@ -842,10 +943,6 @@ gap: var(--space-2); } -.channel-history-label svg { - flex: 0 0 auto; -} - .channel-history-rail[data-phase="degraded"], .channel-history-rail[data-phase="error"] { color: var(--status-warning); @@ -1095,10 +1192,15 @@ margin: 0 0 var(--space-6); padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); + transition: background var(--transition-fast); } .channel-event.ours { - background: var(--accent-a05); + background: transparent; +} + +.channel-event:hover { + background: var(--surface-row-hover); } .channel-event.mentioned { @@ -1108,36 +1210,42 @@ .channel-event[data-tone="0"], .channel-member-row[data-tone="0"], +.channel-presence-event[data-tone="0"], .channel-presence-item[data-tone="0"] { --channel-identity-color: var(--status-info); } .channel-event[data-tone="1"], .channel-member-row[data-tone="1"], +.channel-presence-event[data-tone="1"], .channel-presence-item[data-tone="1"] { --channel-identity-color: var(--status-purple); } .channel-event[data-tone="2"], .channel-member-row[data-tone="2"], +.channel-presence-event[data-tone="2"], .channel-presence-item[data-tone="2"] { --channel-identity-color: var(--status-online); } .channel-event[data-tone="3"], .channel-member-row[data-tone="3"], +.channel-presence-event[data-tone="3"], .channel-presence-item[data-tone="3"] { --channel-identity-color: var(--ble-accent); } .channel-event[data-tone="4"], .channel-member-row[data-tone="4"], +.channel-presence-event[data-tone="4"], .channel-presence-item[data-tone="4"] { --channel-identity-color: var(--status-error); } .channel-event[data-tone="5"], .channel-member-row[data-tone="5"], +.channel-presence-event[data-tone="5"], .channel-presence-item[data-tone="5"], .channel-event[data-tone="self"], .channel-member-row[data-tone="self"], @@ -1213,7 +1321,22 @@ background: transparent; color: var(--text-muted); cursor: pointer; + opacity: 0; + pointer-events: none; + transform: translateX(2px); + transition: opacity var(--transition-fast), transform var(--transition-fast), + visibility var(--transition-fast); + visibility: hidden; +} + +.channel-event:hover .channel-quote-button, +.channel-event:focus-within .channel-quote-button, +.channel-hub-notice:hover .channel-quote-button, +.channel-hub-notice:focus-within .channel-quote-button { opacity: 0.62; + pointer-events: auto; + transform: none; + visibility: visible; } .channel-quote-button:hover, @@ -1221,6 +1344,9 @@ background: var(--surface-control-hover); color: var(--text-primary); opacity: 1; + pointer-events: auto; + transform: none; + visibility: visible; } .channel-quote-button:focus-visible { @@ -1324,23 +1450,52 @@ align-items: center; gap: var(--space-4); margin: var(--space-5) 0; + padding: 0 var(--space-4) 0 calc(var(--space-4) + 7px + var(--space-3)); color: var(--text-muted); font-size: var(--text-xs); line-height: 1.4; } -.channel-system-event::before { - content: ''; - width: 14px; - height: 1px; - flex: 0 0 14px; - background: var(--border-surface-strong); -} - .channel-system-event.error { color: var(--status-error); } +.channel-presence-event { + display: flex; + align-items: center; + min-height: 32px; + gap: var(--space-3); + margin: var(--space-5) 0; + padding: 0 var(--space-4); + color: var(--text-muted); + font-size: var(--text-xs); + line-height: 1.4; +} + +.channel-presence-event-copy { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.channel-presence-event-copy strong { + color: var(--text-secondary); + font-weight: var(--type-weight-semibold); +} + +.channel-presence-event .channel-event-time { + flex: 0 0 auto; + opacity: 0.65; + transition: opacity var(--transition-fast); +} + +.channel-presence-event:hover .channel-event-time, +.channel-presence-event:focus-within .channel-event-time { + opacity: 1; +} + .channel-presence-group { margin: var(--space-5) 0; color: var(--text-muted); @@ -1354,7 +1509,7 @@ width: 100%; min-height: 32px; gap: var(--space-2); - padding: 0; + padding: 0 var(--space-4) 0 calc(var(--space-4) + 7px + var(--space-3)); border: 0; background: transparent; color: inherit; @@ -1363,15 +1518,6 @@ cursor: pointer; } -.channel-presence-summary::before { - content: ''; - width: 14px; - height: 1px; - flex: 0 0 14px; - margin-right: var(--space-2); - background: var(--border-surface-strong); -} - .channel-presence-summary:hover, .channel-presence-summary:focus-visible { color: var(--text-secondary); @@ -1408,9 +1554,8 @@ display: flex; flex-direction: column; gap: var(--space-2); - margin: var(--space-2) 0 0 30px; - padding: var(--space-2) 0 var(--space-2) var(--space-4); - border-left: 1px solid var(--border-row); + margin: var(--space-2) 0 0; + padding: var(--space-2) var(--space-4); } .channel-presence-list[hidden] { @@ -1437,6 +1582,15 @@ flex: 0 0 auto; } +@media (hover: none) { + .channel-quote-button { + opacity: 0.62; + pointer-events: auto; + transform: none; + visibility: visible; + } +} + .channel-compose { display: flex; align-items: flex-end; @@ -1536,13 +1690,29 @@ } .channel-members-note { - padding: var(--space-5) var(--space-7); + padding: var(--space-3) var(--space-7); border-bottom: 1px solid var(--border-row); color: var(--text-muted); font-size: var(--text-2xs); line-height: 1.4; } +.channel-members-note::before { + content: 'i'; + display: inline-flex; + width: 14px; + height: 14px; + align-items: center; + justify-content: center; + margin-right: var(--space-2); + border: 1px solid var(--border-surface-strong); + border-radius: var(--radius-full); + color: var(--text-muted); + font-family: var(--font-ui); + font-size: 9px; + line-height: 1; +} + .channel-members-list { flex: 1; min-height: 0; @@ -1731,6 +1901,38 @@ line-height: var(--type-leading-copy); } +.channel-hub-launch-sheet, +.channel-hub-switcher-sheet, +.channel-connect-sheet, +.channel-open-link-sheet, +.channel-share-sheet, +.channel-share-preview-sheet, +.channel-join-sheet, +.channel-hub-profile-sheet, +.channel-room-options-sheet, +.channel-host-admin-sheet, +.channel-host-admin-edit-sheet { + --font-sans: var(--font-ui); + font-family: var(--font-ui); +} + +.channel-hub-launch-sheet .bottom-sheet-title, +.channel-hub-switcher-sheet .bottom-sheet-title, +.channel-connect-sheet .bottom-sheet-title, +.channel-open-link-sheet .bottom-sheet-title, +.channel-share-sheet .bottom-sheet-title, +.channel-share-preview-sheet .bottom-sheet-title, +.channel-join-sheet .bottom-sheet-title, +.channel-hub-profile-sheet .bottom-sheet-title, +.channel-room-options-sheet .bottom-sheet-title, +.channel-host-admin-sheet .bottom-sheet-title, +.channel-host-admin-edit-sheet .bottom-sheet-title { + color: var(--text-primary); + font-family: 'Outfit', var(--font-ui); + font-size: var(--text-lg); + font-weight: var(--type-weight-semibold); +} + .channel-hub-profile { gap: var(--space-7); } @@ -1747,9 +1949,9 @@ margin-bottom: var(--space-3); color: var(--text-muted); font-size: var(--text-2xs); - font-weight: var(--type-weight-semibold); - letter-spacing: var(--type-section-label-tracking); - text-transform: uppercase; + font-weight: var(--type-weight-medium); + letter-spacing: 0; + text-transform: none; } .channel-hub-profile-status-dot { @@ -1913,6 +2115,216 @@ line-height: 1.4; } +.channel-hub-profile-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.channel-hub-technical { + border-top: 1px solid var(--border-row); + border-bottom: 1px solid var(--border-row); +} + +.channel-hub-technical > summary, +.channel-private-access > summary { + display: flex; + min-height: 44px; + align-items: center; + justify-content: space-between; + gap: var(--space-5); + color: var(--text-secondary); + font-size: var(--text-sm); + font-weight: var(--type-weight-medium); + list-style: none; + cursor: pointer; +} + +.channel-hub-technical > summary::-webkit-details-marker, +.channel-private-access > summary::-webkit-details-marker { + display: none; +} + +.channel-hub-technical > summary::after, +.channel-private-access > summary::after { + content: '\203A'; + color: var(--text-muted); + font-size: var(--text-lg); + transform: rotate(0deg); + transition: transform var(--transition-fast); +} + +.channel-hub-technical[open] > summary::after, +.channel-private-access[open] > summary::after { + transform: rotate(90deg); +} + +.channel-hub-technical > .channel-hub-profile-section { + margin-bottom: var(--space-7); +} + +.channel-hub-technical > .channel-hub-profile-section:first-of-type { + padding-top: var(--space-3); +} + +.channel-connection-context { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-5); + padding: var(--space-3) 0; + border-bottom: 1px solid var(--border-row); +} + +.channel-connection-context span { + color: var(--text-muted); + font-size: var(--text-xs); +} + +.channel-connection-context strong { + color: var(--text-primary); + font-size: var(--text-sm); + font-weight: var(--type-weight-medium); +} + +.channel-connection-summary { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-5); + padding: var(--space-3) 0 var(--space-7); + border-bottom: 1px solid var(--border-row); +} + +.channel-connection-summary-copy { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: var(--space-1); +} + +.channel-connection-summary-copy strong, +.channel-connection-summary-copy span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.channel-connection-summary-copy strong { + color: var(--text-primary); + font-family: 'Outfit', var(--font-ui); + font-size: var(--text-base); + font-weight: var(--type-weight-semibold); +} + +.channel-connection-summary-copy span { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: var(--text-2xs); +} + +.channel-connection-identity { + display: flex; + min-height: 48px; + align-items: center; + justify-content: space-between; + gap: var(--space-5); +} + +.channel-connection-identity[hidden] { + display: none; +} + +.channel-connection-identity > span { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--space-1); +} + +.channel-connection-identity span span { + color: var(--text-muted); + font-size: var(--text-xs); +} + +.channel-connection-identity strong { + overflow: hidden; + color: var(--text-primary); + font-size: var(--text-sm); + font-weight: var(--type-weight-medium); + text-overflow: ellipsis; + white-space: nowrap; +} + +.channel-connection-change { + min-height: 32px; + padding: 0 var(--space-4); + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: var(--accent); + font: inherit; + font-size: var(--text-xs); + font-weight: var(--type-weight-medium); + cursor: pointer; +} + +.channel-connection-change:hover, +.channel-connection-change:focus-visible { + background: var(--accent-bg); +} + +.channel-connection-change:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 1px; +} + +.channel-join-preview { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding-bottom: var(--space-7); + border-bottom: 1px solid var(--border-row); +} + +.channel-join-preview strong { + color: var(--text-primary); + font-family: 'Outfit', var(--font-ui); + font-size: var(--text-lg); + font-weight: var(--type-weight-semibold); +} + +.channel-join-preview > span { + color: var(--text-secondary); + font-size: var(--text-sm); + line-height: 1.4; +} + +.channel-join-preview small { + color: var(--text-muted); + font-size: var(--text-xs); +} + +.channel-private-access { + border-top: 1px solid var(--border-row); + border-bottom: 1px solid var(--border-row); +} + +.channel-private-access-body { + display: flex; + flex-direction: column; + gap: var(--space-4); + padding: var(--space-3) 0 var(--space-6); +} + +.channel-private-access-note { + margin: 0; + color: var(--text-muted); + font-size: var(--text-2xs); + line-height: 1.45; +} + .channel-share-qr-shell { display: flex; width: min(100%, 292px); @@ -1942,6 +2354,80 @@ flex: 1 1 132px; } +.channel-link-methods { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.channel-link-method { + display: flex; + width: 100%; + min-height: 64px; + align-items: center; + gap: var(--space-5); + padding: var(--space-4) var(--space-5); + border: 1px solid var(--border-surface-soft); + border-radius: var(--radius-lg); + background: transparent; + color: var(--text-primary); + font: inherit; + text-align: left; + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast); +} + +.channel-link-method:hover, +.channel-link-method:focus-visible { + border-color: var(--accent-border); + background: var(--surface-row-hover); +} + +.channel-link-method:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +.channel-link-method[hidden] { + display: none; +} + +.channel-link-method-icon { + display: inline-flex; + width: 34px; + height: 34px; + flex: 0 0 34px; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + background: var(--accent-bg); + color: var(--accent); +} + +.channel-link-method-icon svg { + width: 19px; + height: 19px; +} + +.channel-link-method-copy { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: var(--space-1); +} + +.channel-link-method-copy strong { + color: var(--text-primary); + font-size: var(--text-sm); + font-weight: var(--type-weight-medium); +} + +.channel-link-method-copy span { + color: var(--text-muted); + font-size: var(--text-xs); +} + .channel-share-input { width: 100%; min-height: 92px; @@ -1953,10 +2439,6 @@ grid-template-columns: minmax(96px, auto) minmax(0, 1fr); } -.channel-open-share-btn { - align-self: flex-start; -} - .channel-room-details { overflow: hidden; border: 1px solid var(--border-surface-soft); @@ -1992,18 +2474,19 @@ .channel-sheet-trust-note { display: flex; gap: var(--space-5); - padding: var(--space-5) var(--space-6); - border: 1px solid var(--status-warning-border); - border-radius: var(--radius-lg); - background: var(--status-warning-bg); - color: var(--text-secondary); + padding: var(--space-5) 0 0; + border: 0; + border-top: 1px solid var(--border-row); + border-radius: 0; + background: transparent; + color: var(--text-muted); font-size: var(--text-xs); line-height: 1.45; } .channel-sheet-trust-note svg { flex: 0 0 auto; - color: var(--status-warning); + color: var(--text-muted); margin-top: 1px; } @@ -2027,6 +2510,10 @@ gap: var(--space-3); } +.channel-sheet-field[hidden] { + display: none; +} + .channel-sheet-field label { color: var(--text-secondary); font-size: var(--text-xs); @@ -2067,6 +2554,15 @@ flex-direction: column; } +.channel-hub-switcher-note { + margin: 0; + padding-top: var(--space-4); + border-top: 1px solid var(--border-row); + color: var(--text-muted); + font-size: var(--text-xs); + line-height: 1.45; +} + .channel-hub-switcher-list .channels-list-section { margin-top: var(--space-3); } @@ -2235,6 +2731,27 @@ width: 430px; } + .bottom-sheet.open.channel-hub-switcher-sheet { + width: 520px; + } + + .bottom-sheet.open.channel-connect-sheet, + .bottom-sheet.open.channel-open-link-sheet, + .bottom-sheet.open.channel-share-preview-sheet, + .bottom-sheet.open.channel-join-sheet, + .bottom-sheet.open.channel-room-options-sheet { + width: 440px; + } + + .bottom-sheet.open.channel-share-sheet { + width: 480px; + } + + .bottom-sheet.open.channel-hub-profile-sheet { + width: 620px; + max-height: min(820px, 92vh); + } + .bottom-sheet.open.channel-host-admin-sheet { width: 820px; max-height: min(820px, 92vh); @@ -2838,14 +3355,6 @@ font-size: var(--text-xs); } -.channel-host-admin-settings-limits > .channel-host-admin-panel-header { - margin-top: var(--space-7); -} - -.channel-host-limits { - border-bottom: 0; -} - .channel-host-hero { display: flex; align-items: center; @@ -2915,8 +3424,7 @@ .channel-host-address { display: flex; min-height: 54px; - align-items: center; - gap: var(--space-5); + align-items: flex-start; padding: var(--space-4) 0; border-top: 1px solid var(--border-row); } @@ -2924,11 +3432,20 @@ .channel-host-address-copy { display: flex; min-width: 0; - flex: 1; + max-width: 100%; + flex: 0 1 auto; flex-direction: column; gap: var(--space-1); } +.channel-host-address-value-row { + display: flex; + min-width: 0; + max-width: 100%; + align-items: center; + gap: var(--space-2); +} + .channel-host-address-copy span { color: var(--text-muted); font-size: var(--text-2xs); @@ -2936,6 +3453,8 @@ .channel-host-address-copy code { overflow: hidden; + min-width: 0; + flex: 0 1 auto; color: var(--text-secondary); font-family: var(--font-mono); font-size: var(--text-xs); @@ -3101,52 +3620,14 @@ gap: var(--space-1); } -.channel-host-announce-select { +.channel-host-announce-select, +.channel-host-recent-activity-select { width: 150px; flex: 0 0 150px; font-family: var(--font-sans); font-size: var(--text-xs); } -.channel-host-advanced { - border-bottom: 1px solid var(--border-row); -} - -.channel-host-advanced summary { - position: relative; - padding: var(--space-6) var(--space-7) var(--space-6) 0; - color: var(--text-secondary); - font-size: var(--text-xs); - font-weight: var(--type-weight-medium); - list-style: none; - cursor: pointer; -} - -.channel-host-advanced summary::-webkit-details-marker { - display: none; -} - -.channel-host-advanced summary::after { - content: ''; - position: absolute; - top: 50%; - right: var(--space-2); - width: 7px; - height: 7px; - border-right: 1.5px solid currentColor; - border-bottom: 1.5px solid currentColor; - transform: translateY(-65%) rotate(45deg); - transition: transform var(--transition-fast); -} - -.channel-host-advanced[open] summary::after { - transform: translateY(-35%) rotate(225deg); -} - -.channel-host-advanced-body { - padding: 0 0 var(--space-5); -} - .channel-host-toggle-row { display: flex; align-items: center; @@ -3226,7 +3707,8 @@ gap: var(--space-4); } - .channel-host-announce-select { + .channel-host-announce-select, + .channel-host-recent-activity-select { width: 100%; flex-basis: auto; } diff --git a/dashboard/static/css/10-views.css b/dashboard/static/css/10-views.css index 8d9fea0..ab1c7db 100644 --- a/dashboard/static/css/10-views.css +++ b/dashboard/static/css/10-views.css @@ -1893,8 +1893,7 @@ align-items: center; justify-content: center; color: var(--text-muted); - font-size: var(--text-sm); - font-weight: 600; + font-family: var(--font-sans); cursor: pointer; transition: background var(--transition-fast), color var(--transition-fast); -webkit-tap-highlight-color: transparent; @@ -1912,6 +1911,10 @@ min-width: 54px; padding: 0 var(--space-4); border-radius: var(--radius-md); + font-size: var(--type-state-size); + font-weight: var(--type-state-weight); + letter-spacing: var(--type-state-tracking); + text-transform: uppercase; transition: background var(--transition-fast), color var(--transition-fast); } .settings-radio-option input:checked + span { @@ -2358,8 +2361,11 @@ color: var(--text-muted); border-radius: var(--radius-md); cursor: pointer; - font-size: var(--type-row-meta-size); - font-weight: var(--type-weight-medium); + font-family: var(--font-sans); + font-size: var(--type-state-size); + font-weight: var(--type-state-weight); + letter-spacing: var(--type-state-tracking); + text-transform: uppercase; transition: background 0.12s, color 0.12s, border-color 0.12s; } .relay-mode-btn:hover { background: var(--hover-subtle); } @@ -2412,6 +2418,14 @@ white-space: nowrap; -webkit-tap-highlight-color: transparent; } + +.settings-state-value { + font-family: var(--font-sans); + font-size: var(--type-state-size); + font-weight: var(--type-state-weight); + letter-spacing: var(--type-state-tracking); + text-transform: uppercase; +} .selector-badge::after { content: ""; display: inline-block; diff --git a/dashboard/static/css/13-responsive.css b/dashboard/static/css/13-responsive.css index 28affe8..faf078b 100644 --- a/dashboard/static/css/13-responsive.css +++ b/dashboard/static/css/13-responsive.css @@ -691,7 +691,7 @@ .theme-toggle-btn { min-width: 44px; min-height: 44px; } .selector-badge { min-height: 44px; padding: var(--space-4) var(--space-6); } .settings-row { min-height: 48px; } - .settings-radio-option span { min-height: 38px; min-width: 58px; } + .settings-radio-option span { min-height: 40px; min-width: 58px; } input[type="text"], input[type="number"], textarea, select, .modal-input { min-height: 44px; diff --git a/dashboard/static/js/channel_hub.js b/dashboard/static/js/channel_hub.js index c4e4bce..dfa04e7 100644 --- a/dashboard/static/js/channel_hub.js +++ b/dashboard/static/js/channel_hub.js @@ -15,6 +15,17 @@ function _channelHubPlural(count, singular, plural) { return count + ' ' + (count === 1 ? singular : (plural || singular + 's')); } +function _channelHubHostingEnabled(overview) { + // The Settings preference is the current UI authority. An overview request + // that began before the user toggled Off must not resurrect hosting tools. + if (typeof window.ratspeakChannelHostingEnabled === 'function') { + return window.ratspeakChannelHostingEnabled(); + } + // settings.js loads after this module. Until it establishes the explicit + // preference, default closed rather than inheriting legacy overview state. + return false; +} + function _channelHubStatusModel(overview) { overview = overview || {}; var settings = overview.settings || {}; @@ -47,15 +58,6 @@ function _channelHubStatusModel(overview) { }; } -function _channelHubAnnounceLabel(seconds) { - var value = Number(seconds) || 0; - if (value === 0) return 'When started'; - if (value < 3600) return 'Every ' + Math.round(value / 60) + ' min'; - if (value === 3600) return 'Every hour'; - if (value < 86400) return 'Every ' + Math.round(value / 3600) + ' hours'; - return 'Every day'; -} - function channelHubOwnDestinationHash() { if (!channelHubOverview) return ''; var status = channelHubOverview.status || {}; @@ -78,7 +80,7 @@ function channelHubRenderHome(overview) { overview = overview || channelHubOverview; var section = document.getElementById('channel-owned-hub'); if (!section) return; - var visible = _channelHubHasOwnedHub(overview); + var visible = _channelHubHostingEnabled(overview) && _channelHubHasOwnedHub(overview); section.hidden = !visible; if (!visible) return; @@ -93,7 +95,7 @@ function channelHubRenderHome(overview) { ' · ' + _channelHubPlural(Number(status.registered_rooms) || 0, 'channel'); var statusText = model.label; if (status.running) { - statusText = connected ? 'Connected · ' + counts : (connecting ? 'Connecting… · Hosting' : 'Hosting · ' + counts); + statusText = connected ? 'Connected · ' + counts : (connecting ? 'Connecting…' : counts); } var card = document.getElementById('channel-owned-hub-card'); @@ -155,6 +157,9 @@ function _channelHubIcon(kind) { if (kind === 'join') { return ''; } + if (kind === 'link') { + return ''; + } return ''; } @@ -193,43 +198,58 @@ function _channelHubChoice(kind, titleText, detailText, statusText) { function channelsOpenAddSheet() { if (typeof _rsBuildSheet !== 'function') return; - channelHubLoad(true).then(function(overview) { - if (!overview) return; - if (!overview.supported) { - channelsOpenConnectSheet(); - return; - } - var built = _rsBuildSheet({ title: 'Channels' }, function() {}); + function present(overview) { + overview = overview || { supported: false }; + var built = _rsBuildSheet({ title: 'Add channels' }, function() {}); built.sheet.classList.add('channel-hub-launch-sheet'); var intro = document.createElement('p'); intro.className = 'channel-sheet-copy'; - intro.textContent = 'Join a conversation or make a place of your own.'; + intro.textContent = 'Choose how you want to connect.'; built.body.appendChild(intro); var join = _channelHubChoice( 'join', 'Join a hub', - 'Find a nearby conversation or enter an address' + 'Choose a nearby or saved hub, or enter an address' ); join.addEventListener('click', function() { built.dismiss(); - setTimeout(function() { channelsOpenConnectSheet(); }, 220); + setTimeout(function() { + if (typeof channelsOpenHubSwitcher === 'function') { + channelsOpenHubSwitcher(); + } else { + channelsOpenConnectSheet(); + } + }, 220); }); built.body.appendChild(join); - var model = _channelHubStatusModel(overview); - var host = _channelHubChoice( - 'host', - overview.created || (overview.settings && overview.settings.enabled) ? 'Manage your hub' : 'Host your own', - model.detail, - model.label + var shared = _channelHubChoice( + 'link', + 'Use a link or QR', + 'Preview a shared hub or channel before connecting' ); - host.addEventListener('click', function() { + shared.addEventListener('click', function() { built.dismiss(); - setTimeout(function() { channelHubOpenManager(overview); }, 220); + setTimeout(function() { channelsOpenSharedChannel(); }, 220); }); - built.body.appendChild(host); + built.body.appendChild(shared); + + if (overview.supported && _channelHubHostingEnabled(overview)) { + var model = _channelHubStatusModel(overview); + var host = _channelHubChoice( + 'host', + overview.created || (overview.settings && overview.settings.enabled) ? 'Manage your hub' : 'Host a hub', + model.detail, + model.label + ); + host.addEventListener('click', function() { + built.dismiss(); + setTimeout(function() { channelHubOpenManager(overview); }, 220); + }); + built.body.appendChild(host); + } var cancel = document.createElement('button'); cancel.type = 'button'; @@ -238,11 +258,12 @@ function channelsOpenAddSheet() { cancel.addEventListener('click', function() { built.dismiss(); }); built.footer.appendChild(cancel); _channelsPresentSheet(built, join); - }).catch(function(error) { - if (typeof showToast === 'function') { - showToast((error && error.message) || 'Hub hosting is unavailable', 'toast-orange', 3000); - } - channelsOpenConnectSheet(); + } + + channelHubLoad(true).then(function(overview) { + present(overview); + }).catch(function() { + present({ supported: false }); }); } @@ -291,13 +312,17 @@ function _channelHubToggle(labelText, detailText, checked) { return { row: row, input: input }; } -function _channelHubConfigArgs(nameInput, greetingInput, announceInput, sendInput, acceptInput) { +function _channelHubConfigArgs( + nameInput, + greetingInput, + announceInput, + recentActivityInput +) { return { hub_name: nameInput.value.trim(), greeting: greetingInput.value.trim(), announce_interval_secs: Number(announceInput.value) || 0, - resource_send: !!sendInput.checked, - resource_accept: !!acceptInput.checked + recent_activity_retention_secs: Number(recentActivityInput.value) || 0 }; } @@ -306,8 +331,8 @@ function _channelHubSettingsEqual(settings, args) { return String(settings.hub_name || '') === args.hub_name && String(settings.greeting || '') === args.greeting && Number(settings.announce_interval_secs || 0) === args.announce_interval_secs && - !!settings.resource_send_enabled === args.resource_send && - !!settings.resource_accept_enabled === args.resource_accept; + Number(settings.recent_activity_retention_secs || 0) === + args.recent_activity_retention_secs; } function _channelHubAdminNode(tagName, className, text) { @@ -331,13 +356,6 @@ function _channelHubAdminDuration(seconds) { return Math.round(seconds / 86400) + ' days'; } -function _channelHubAdminBytes(bytes) { - bytes = Math.max(0, Number(bytes) || 0); - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KiB'; - return Math.round(bytes / (1024 * 1024)) + ' MiB'; -} - function _channelHubAdminDate(timestampMs) { var value = Number(timestampMs); if (!Number.isFinite(value) || value <= 0) return null; @@ -551,14 +569,6 @@ function _channelHubRenderAdminOverview(root, admin, refreshHandler) { ' \u00b7 ' + _channelHubAdminGeneratedLabel(admin), refreshHandler ); - root.appendChild(_channelHubAdminNotice( - admin.running ? 'online' : 'neutral', - admin.running ? 'Live mesh community' : 'Hub stopped', - admin.running - ? 'People and recent context exist only while this process is running.' - : 'Channel policy and access lists remain available. People and recent context do not.' - )); - var people = admin.running && Array.isArray(admin.people) ? admin.people : []; var rooms = Array.isArray(admin.rooms) ? admin.rooms : []; var sessions = people.reduce(function(total, person) { @@ -576,33 +586,8 @@ function _channelHubRenderAdminOverview(root, admin, refreshHandler) { ) }, { value: rooms.length, label: 'Channels', detail: registered + ' registered' }, - { value: _channelHubAdminDuration(admin.uptime_secs), label: 'Uptime', detail: admin.running ? 'This run' : 'Not running' } + { value: _channelHubAdminDuration(admin.uptime_secs), label: 'Uptime', detail: admin.running ? '' : 'Not running' } ])); - - var stats = admin.stats || {}; - var forwarded = (Number(stats.messages_forwarded) || 0) + - (Number(stats.notices_forwarded) || 0) + - (Number(stats.actions_forwarded) || 0); - var refused = (Number(stats.rate_limited) || 0) + - (Number(stats.bad_packets) || 0) + - (Number(stats.duplicates) || 0) + - (Number(stats.resources_rejected) || 0) + - (Number(stats.oversize) || 0); - var activity = _channelHubAdminNode('section', 'channel-host-admin-section'); - activity.appendChild(_channelHubAdminNode('h4', '', 'This run')); - activity.appendChild(_channelHubAdminMetricGrid([ - { value: forwarded, label: 'Room relays' }, - { value: (Number(stats.joins) || 0) + (Number(stats.parts) || 0), label: 'Membership changes' }, - { value: refused, label: 'Refused or dropped' }, - { value: Number(stats.resources_received) || 0, label: 'Large notices received' } - ])); - root.appendChild(activity); - - root.appendChild(_channelHubAdminNotice( - 'privacy', - 'Policy is durable. Conversation traffic is not.', - 'The hub stores registered channel settings and access lists, never transcripts or rosters.' - )); } function _channelHubRenderAdminChannels(root, admin, refreshHandler, actions) { @@ -919,26 +904,29 @@ function _channelHubRenderAdminActivity(root, admin, refreshHandler) { root.textContent = ''; var evidence = Array.isArray(admin.evidence) ? admin.evidence : []; var policy = admin.evidence_policy || {}; + var retention = Number(policy.retention_secs) || 0; + var retentionHours = Math.round(retention / 3600); _channelHubAdminHeader( root, 'Activity', - 'Recent context for moderation decisions \u00b7 ' + + (retention + ? 'Last ' + retentionHours + ' ' + (retentionHours === 1 ? 'hour' : 'hours') + + ' \u00b7 Memory only' + : 'Off') + ' \u00b7 ' + _channelHubAdminGeneratedLabel(admin), refreshHandler ); - root.appendChild(_channelHubAdminNotice( - 'privacy', - 'Recent context, not a transcript', - 'Memory-only and incomplete: up to ' + _channelHubAdminDuration(policy.retention_secs) + - ', ' + (Number(policy.max_events) || 0) + ' events, ' + - _channelHubAdminBytes(policy.max_estimated_bytes) + ' total. ' + - 'Excerpts are display-sanitized and capped at ' + - _channelHubAdminBytes(policy.max_excerpt_bytes) + '.' - )); + if (!retention) { + root.appendChild(_channelHubAdminEmpty( + 'Recent activity is off', + 'Enable it in Settings when you need a temporary moderation view.' + )); + return; + } if (!admin.running) { root.appendChild(_channelHubAdminEmpty( 'No activity while stopped', - 'Evidence is never persisted across a hub stop or restart.' + 'Recent activity clears whenever the hub stops.' )); return; } @@ -951,8 +939,8 @@ function _channelHubRenderAdminActivity(root, admin, refreshHandler) { } if (!evidence.length) { root.appendChild(_channelHubAdminEmpty( - 'No recent room activity', - 'Accepted room messages and moderation changes will appear here for a short time.' + 'No recent activity', + 'Nothing to review yet.' )); return; } @@ -988,29 +976,6 @@ function _channelHubRenderAdminActivity(root, admin, refreshHandler) { root.appendChild(list); } -function _channelHubRenderAdminLimits(root, admin) { - root.textContent = ''; - var limits = admin && admin.limits || {}; - var section = _channelHubAdminNode('section', 'channel-host-section channel-host-limits'); - section.appendChild(_channelHubAdminNode('h3', '', 'Operating limits')); - section.appendChild(_channelHubAdminMetricGrid([ - { value: Number(limits.max_registered_rooms) || 0, label: 'Registered channels' }, - { value: Number(limits.max_rooms_per_session) || 0, label: 'Channels per session' }, - { value: _channelHubAdminBytes(limits.max_message_body_bytes), label: 'Message body' }, - { value: (Number(limits.rate_messages_per_minute) || 0) + '/min', label: 'Per-session rate' }, - { value: _channelHubAdminDuration(limits.invite_timeout_secs), label: 'Invitation lifetime' }, - { value: _channelHubAdminDuration(limits.rejoin_grace_secs), label: 'Reconnect grace' }, - { value: _channelHubAdminBytes(limits.max_resource_notice_bytes), label: 'Large room notice' }, - { value: _channelHubAdminBytes(limits.max_resource_bytes), label: 'Resource ceiling' } - ])); - section.appendChild(_channelHubAdminNode( - 'p', - 'channel-host-admin-muted', - 'These safety limits are enforced by the hub and are not editable in this release.' - )); - root.appendChild(section); -} - function _channelHubRenderAdminLoading(root, titleText) { root.textContent = ''; _channelHubAdminHeader(root, titleText, 'Loading local owner state'); @@ -1929,6 +1894,12 @@ function channelHubOpenManager(initialOverview) { }); return; } + if (!_channelHubHostingEnabled(overview)) { + if (typeof showToast === 'function') { + showToast('Turn on Channel hosting in Settings first', 'toast-orange', 3200); + } + return; + } var identityGeneration = _channelHubIdentityGeneration; var built = _rsBuildSheet({ title: 'Hub administration' }, function() { @@ -1974,9 +1945,10 @@ function channelHubOpenManager(initialOverview) { addressCopy.className = 'channel-host-address-copy'; var addressLabel = document.createElement('span'); addressLabel.textContent = 'Hub address'; + var addressValueRow = document.createElement('div'); + addressValueRow.className = 'channel-host-address-value-row'; var addressValue = document.createElement('code'); addressCopy.appendChild(addressLabel); - addressCopy.appendChild(addressValue); var copyAddress = document.createElement('button'); copyAddress.type = 'button'; copyAddress.className = 'channel-host-copy-btn'; @@ -1990,8 +1962,10 @@ function channelHubOpenManager(initialOverview) { if (typeof showToast === 'function') showToast(ok ? 'Hub address copied' : 'Could not copy', ok ? 'toast-green' : 'toast-orange', 1800); }); }); + addressValueRow.appendChild(addressValue); + addressValueRow.appendChild(copyAddress); + addressCopy.appendChild(addressValueRow); address.appendChild(addressCopy); - address.appendChild(copyAddress); built.body.appendChild(address); var registryWarning = document.createElement('div'); @@ -2059,7 +2033,7 @@ function channelHubOpenManager(initialOverview) { _channelHubAdminHeader( panels.settings, 'Settings', - 'Hub identity, discovery, and resource policy' + 'Hub profile, discovery, and moderation' ); var profile = document.createElement('section'); profile.className = 'channel-host-section'; @@ -2101,26 +2075,24 @@ function channelHubOpenManager(initialOverview) { var discoveryLabel = document.createElement('label'); discoveryLabel.textContent = 'Announce this hub'; var discoveryHint = document.createElement('span'); - discoveryHint.textContent = 'Help nearby people find it without an address'; + discoveryHint.textContent = 'At startup and on this schedule, so nearby people can find it'; discoveryCopy.appendChild(discoveryLabel); discoveryCopy.appendChild(discoveryHint); var announceInput = document.createElement('select'); announceInput.className = 'nr-select channel-host-announce-select'; [ - [0, 'When started'], - [300, 'Every 5 min'], - [900, 'Every 15 min'], - [1800, 'Every 30 min'], + [900, 'Every 15 minutes'], + [1800, 'Every 30 minutes'], [3600, 'Every hour'], - [21600, 'Every 6 hours'], - [86400, 'Every day'] + [43200, 'Every 12 hours'], + [86400, 'Every 24 hours'] ].forEach(function(optionValue) { var option = document.createElement('option'); option.value = String(optionValue[0]); option.textContent = optionValue[1]; announceInput.appendChild(option); }); - announceInput.value = String(Number(settings.announce_interval_secs) || 0); + announceInput.value = String(Number(settings.announce_interval_secs) || 900); discoveryLabel.htmlFor = 'channel-host-announce-' + sequence; announceInput.id = discoveryLabel.htmlFor; discoveryRow.appendChild(discoveryCopy); @@ -2128,31 +2100,42 @@ function channelHubOpenManager(initialOverview) { discovery.appendChild(discoveryRow); panels.settings.appendChild(discovery); - var advanced = document.createElement('details'); - advanced.className = 'channel-host-advanced'; - var advancedSummary = document.createElement('summary'); - advancedSummary.textContent = 'Advanced'; - advanced.appendChild(advancedSummary); - var advancedBody = document.createElement('div'); - advancedBody.className = 'channel-host-advanced-body'; - var sendToggle = _channelHubToggle( - 'Large welcome messages', - 'Deliver longer welcome text when it cannot fit in one packet', - settings.resource_send_enabled - ); - var acceptToggle = _channelHubToggle( - 'Large room notices', - 'Accept larger notices from people already allowed to post', - settings.resource_accept_enabled - ); - advancedBody.appendChild(sendToggle.row); - advancedBody.appendChild(acceptToggle.row); - advanced.appendChild(advancedBody); - panels.settings.appendChild(advanced); - - var limitsHost = document.createElement('div'); - limitsHost.className = 'channel-host-admin-settings-limits'; - panels.settings.appendChild(limitsHost); + var moderation = document.createElement('section'); + moderation.className = 'channel-host-section'; + var moderationTitle = document.createElement('h3'); + moderationTitle.textContent = 'Moderation'; + moderation.appendChild(moderationTitle); + var recentActivityRow = document.createElement('div'); + recentActivityRow.className = 'channel-host-discovery-row'; + var recentActivityCopy = document.createElement('div'); + recentActivityCopy.className = 'channel-host-discovery-copy'; + var recentActivityLabel = document.createElement('label'); + recentActivityLabel.textContent = 'Recent activity'; + var recentActivityHint = document.createElement('span'); + recentActivityHint.textContent = 'Temporary moderation context, held only in memory'; + recentActivityCopy.appendChild(recentActivityLabel); + recentActivityCopy.appendChild(recentActivityHint); + var recentActivityInput = document.createElement('select'); + recentActivityInput.className = 'nr-select channel-host-recent-activity-select'; + [ + [0, 'OFF'], + [3600, '1 hour'], + [21600, '6 hours'], + [43200, '12 hours'], + [86400, '24 hours'] + ].forEach(function(optionValue) { + var option = document.createElement('option'); + option.value = String(optionValue[0]); + option.textContent = optionValue[1]; + recentActivityInput.appendChild(option); + }); + recentActivityInput.value = String(Number(settings.recent_activity_retention_secs) || 0); + recentActivityLabel.htmlFor = 'channel-host-recent-activity-' + sequence; + recentActivityInput.id = recentActivityLabel.htmlFor; + recentActivityRow.appendChild(recentActivityCopy); + recentActivityRow.appendChild(recentActivityInput); + moderation.appendChild(recentActivityRow); + panels.settings.appendChild(moderation); var impact = document.createElement('p'); impact.className = 'channel-host-impact'; @@ -2178,7 +2161,12 @@ function channelHubOpenManager(initialOverview) { built.footer.appendChild(close); built.footer.appendChild(save); - var controls = [nameInput, greetingInput, announceInput, sendToggle.input, acceptToggle.input]; + var controls = [ + nameInput, + greetingInput, + announceInput, + recentActivityInput + ]; var busy = false; var activeTab = 'overview'; var adminSnapshot = null; @@ -2214,7 +2202,6 @@ function channelHubOpenManager(initialOverview) { Object.keys(adminPanelTitles).forEach(function(tabId) { _channelHubRenderAdminLoading(panels[tabId], adminPanelTitles[tabId]); }); - _channelHubRenderAdminLoading(limitsHost, 'Operating limits'); } function managerCurrent() { @@ -2306,7 +2293,6 @@ function channelHubOpenManager(initialOverview) { _channelHubRenderAdminPeople(panels.people, nextAdmin, refreshHandler, actions); _channelHubRenderAdminAccess(panels.access, nextAdmin, refreshHandler, actions); _channelHubRenderAdminActivity(panels.activity, nextAdmin, refreshHandler); - _channelHubRenderAdminLimits(limitsHost, nextAdmin); } function renderAdminError(loadError) { @@ -2318,12 +2304,6 @@ function channelHubOpenManager(initialOverview) { refreshAdmin ); }); - _channelHubRenderAdminError( - limitsHost, - 'Operating limits', - loadError, - refreshAdmin - ); } function loadAdmin(allowDuringMutation) { @@ -2420,8 +2400,7 @@ function channelHubOpenManager(initialOverview) { nameInput, greetingInput, announceInput, - sendToggle.input, - acceptToggle.input + recentActivityInput ); } @@ -2492,9 +2471,12 @@ function channelHubOpenManager(initialOverview) { if (!updated) return null; nameInput.value = updated.settings.hub_name || ''; greetingInput.value = updated.settings.greeting || ''; - announceInput.value = String(Number(updated.settings.announce_interval_secs) || 0); - sendToggle.input.checked = !!updated.settings.resource_send_enabled; - acceptToggle.input.checked = !!updated.settings.resource_accept_enabled; + announceInput.value = String( + Number(updated.settings.announce_interval_secs) || 900 + ); + recentActivityInput.value = String( + Number(updated.settings.recent_activity_retention_secs) || 0 + ); renderDirty(); return updated; }); diff --git a/dashboard/static/js/channels.js b/dashboard/static/js/channels.js index 4348d24..79c5ff0 100644 --- a/dashboard/static/js/channels.js +++ b/dashboard/static/js/channels.js @@ -66,6 +66,7 @@ var _channelsSendPending = false; var _channelsFieldSeq = 0; var _channelsLocalRoomEvents = {}; var _channelsLocalEventSeq = 0; +var _channelsRosterBaselines = {}; var _channelsExpandedPresenceGroups = {}; var _channelsPresenceGroupSeq = 0; var _channelsSelectedMemberKey = null; @@ -77,7 +78,6 @@ var _channelsRoomIndexRequestSeq = 0; var _channelsUnreadRequestSeq = 0; var _channelsRenderedRoomKey = ''; var _channelsHubSwitcherDismiss = null; -var CHANNEL_PRESENCE_GROUP_WINDOW_MS = 5 * 60 * 1000; // Brief leave/rejoin churn is one continuous presence when nothing happens between it. var CHANNEL_PRESENCE_REJOIN_WINDOW_MS = 15 * 1000; var CHANNEL_HISTORY_PAGE_SIZE = 100; @@ -276,6 +276,11 @@ function _channelsHubName(hub) { return hub.name || hub.announced_name || hub.label || 'Channel hub'; } +function _channelsRoomDisplayName(roomName) { + var name = String(roomName || ''); + return name.charAt(0) === '#' ? name : '#' + name; +} + function _channelsTimelineHubName(hub) { var name = _channelsHubName(hub); if (name !== 'Channel hub' || !hub || !hub.destination_hash) return name; @@ -495,24 +500,129 @@ function _channelsHistoryContext(room) { }; } -function _channelsAddLocalRoomEvent(roomName, text) { +function _channelsAddLocalRoomItem(roomName, item) { var room = String(roomName || '').trim().toLowerCase(); - if (!room || !text) return; + if (!room || !item || !String(item.text || '').trim()) return; var events = _channelsLocalRoomEvents[room] || []; - events.push({ + events.push(Object.assign({ id: 'local-channel-' + (++_channelsLocalEventSeq), kind: 'system', timestamp_ms: Date.now(), source_hash: null, nickname: null, - text: text, + text: '', ours: true, mentioned: false - }); + }, item)); if (events.length > 20) events.splice(0, events.length - 20); _channelsLocalRoomEvents[room] = events; } +function _channelsAddLocalRoomEvent(roomName, text) { + _channelsAddLocalRoomItem(roomName, { text: text }); +} + +function _channelsRosterMemberKey(member) { + var identity = String(member && member.identity_hash || '').trim().toLowerCase(); + if (identity) return 'source:' + identity; + var nickname = String(member && member.nickname || '').trim().toLowerCase(); + return nickname ? 'nickname:' + nickname : ''; +} + +function _channelsRosterMemberMap(room) { + var members = {}; + (room && Array.isArray(room.members) ? room.members : []).forEach(function(member) { + var key = _channelsRosterMemberKey(member); + if (key) members[key] = member; + }); + return members; +} + +function _channelsRosterEventIds(room) { + var ids = {}; + (room && Array.isArray(room.transcript) ? room.transcript : []).forEach(function(item) { + if (!item || !item.id) return; + ids[String(item.id)] = true; + }); + return ids; +} + +function _channelsRosterHasFreshEvent(room, previousIds, kind, member) { + var memberIdentity = String(member && member.identity_hash || '').trim().toLowerCase(); + var memberNickname = String(member && member.nickname || '').trim().toLowerCase(); + if (!memberIdentity && !memberNickname) return false; + var transcript = room && Array.isArray(room.transcript) ? room.transcript : []; + return transcript.some(function(item) { + if (!item || (kind && item.kind !== kind) || !item.id || + previousIds[String(item.id)]) { + return false; + } + var itemIdentity = String(item.source_hash || '').trim().toLowerCase(); + var itemNickname = String(item.nickname || '').trim().toLowerCase(); + return !!(memberIdentity && itemIdentity && memberIdentity === itemIdentity) || + !!(memberNickname && itemNickname && memberNickname === itemNickname); + }); +} + +function _channelsAddRosterPresence(roomName, kind, member) { + if (!member || member.is_self) return; + var name = _channelsMemberName(member); + var action = kind === 'part' ? ' left' : (kind === 'present' ? ' is here' : ' joined'); + _channelsAddLocalRoomItem(roomName, { + kind: kind, + source_hash: member.identity_hash || null, + nickname: member.nickname || null, + text: name + action, + ours: false + }); +} + +// JOINED is both a membership fanout and, on entry/recovery, a roster. Keep a +// session-local baseline so a roster-only hub still produces human presence +// changes without pretending that members discovered on entry just joined. +function _channelsReconcileRosterPresence(hubDestinationHash) { + var hub = String(hubDestinationHash || '').trim().toLowerCase(); + if (!hub) return; + (Array.isArray(channelsSnapshot.rooms) ? channelsSnapshot.rooms : []).forEach(function(room) { + if (!room || room.phase !== 'joined') return; + var key = _channelsHistoryKey(hub, room.name); + var currentMembers = _channelsRosterMemberMap(room); + var currentEventIds = _channelsRosterEventIds(room); + var previous = _channelsRosterBaselines[key]; + if (!previous) { + Object.keys(currentMembers).forEach(function(memberKey) { + var member = currentMembers[memberKey]; + var transcriptAlreadyExplainsPresence = + _channelsRosterHasFreshEvent(room, {}, null, member); + if (!transcriptAlreadyExplainsPresence) { + _channelsAddRosterPresence(room.name, 'present', member); + } + }); + } else { + Object.keys(currentMembers).forEach(function(memberKey) { + if (previous.members[memberKey]) return; + var member = currentMembers[memberKey]; + if (!_channelsRosterHasFreshEvent( + room, previous.event_ids, null, member)) { + _channelsAddRosterPresence(room.name, 'join', member); + } + }); + Object.keys(previous.members).forEach(function(memberKey) { + if (currentMembers[memberKey]) return; + var member = previous.members[memberKey]; + if (!_channelsRosterHasFreshEvent( + room, previous.event_ids, 'part', member)) { + _channelsAddRosterPresence(room.name, 'part', member); + } + }); + } + _channelsRosterBaselines[key] = { + members: currentMembers, + event_ids: currentEventIds + }; + }); +} + function _channelsSavedHub(destinationHash) { for (var i = 0; i < channelsSavedHubs.length; i++) { if (channelsSavedHubs[i].destination_hash === destinationHash) return channelsSavedHubs[i]; @@ -796,6 +906,10 @@ function _channelsSnapshotIsNewer(snapshot, current) { function channelsApplySnapshot(snapshot) { if (!_channelsSnapshotIsNewer(snapshot, channelsSnapshot)) return false; var oldHub = channelsSnapshot.hub && channelsSnapshot.hub.destination_hash; + var oldGeneration = Number(channelsSnapshot.generation) || 0; + var oldContextHub = String( + channelsSnapshot.selected_hub_destination || oldHub || '' + ).toLowerCase(); channelsSnapshot = snapshot; if (!Array.isArray(channelsSnapshot.rooms)) channelsSnapshot.rooms = []; if (!Array.isArray(channelsSnapshot.hubs)) channelsSnapshot.hubs = []; @@ -829,6 +943,11 @@ function channelsApplySnapshot(snapshot) { } var newHub = channelsSnapshot.hub && channelsSnapshot.hub.destination_hash; + var newContextHub = String( + channelsSnapshot.selected_hub_destination || newHub || '' + ).toLowerCase(); + var hubContextChanged = newContextHub !== oldContextHub; + var managerContextChanged = Number(channelsSnapshot.generation) !== oldGeneration; if (channelsPendingShareJoin && Number(channelsSnapshot.generation) !== channelsPendingShareJoin.generation) { channelsPendingShareJoin = null; @@ -843,7 +962,10 @@ function channelsApplySnapshot(snapshot) { if (newHub !== oldHub) { _channelsDirectoryRequestSeq += 1; _channelsDirectoryRefreshPromise = null; + } + if (hubContextChanged || managerContextChanged) { _channelsLocalRoomEvents = {}; + _channelsRosterBaselines = {}; _channelsExpandedPresenceGroups = {}; _channelsSelectedMemberKey = null; _channelsMemberReturnFocusKey = null; @@ -869,9 +991,7 @@ function channelsApplySnapshot(snapshot) { if (!channelsActiveRoom && !channelsHistorySelection && channelsSnapshot.rooms.length) { channelsActiveRoom = channelsSnapshot.rooms[0].name; } - Object.keys(_channelsLocalRoomEvents).forEach(function(roomName) { - if (!_channelsRoomByName(roomName)) delete _channelsLocalRoomEvents[roomName]; - }); + _channelsReconcileRosterPresence(newContextHub); _channelsPersistConveniences(); renderChannels(); @@ -1406,7 +1526,17 @@ function _channelsRenderHubStrip() { var menu = _channelsEl('channel-hub-menu-btn'); if (!strip) return; - strip.dataset.phase = channelsSnapshot.phase || 'unavailable'; + var previousPhase = strip.dataset.phase || ''; + var nextPhase = channelsSnapshot.phase || 'unavailable'; + strip.dataset.phase = nextPhase; + if (nextPhase === 'active' && previousPhase !== 'active') { + strip.classList.remove('link-arrived'); + requestAnimationFrame(function() { + if (strip.dataset.phase === 'active') strip.classList.add('link-arrived'); + }); + } else if (nextPhase !== 'active') { + strip.classList.remove('link-arrived'); + } if (menu) menu.hidden = channelsSnapshot.phase === 'offline' || channelsSnapshot.phase === 'unavailable'; var hub = channelsSnapshot.hub; @@ -1433,7 +1563,7 @@ function _channelsRenderHubStrip() { switcher.setAttribute( 'aria-label', currentName - ? 'Choose a channel hub. Current selection: ' + currentName + ? 'Current channel hub: ' + currentName + '. ' + meta + '. Choose another hub' : 'Choose a channel hub' ); switcher.title = currentName ? 'Switch channel hub' : 'Choose a channel hub'; @@ -1448,8 +1578,12 @@ function _channelsRenderList() { list.textContent = ''; if (_channelsIsConnected()) { - if (label) label.textContent = 'Channels'; - if (join) join.hidden = false; + if (label) label.textContent = 'Joined'; + if (join) { + join.hidden = false; + join.textContent = 'Add'; + join.setAttribute('aria-label', 'Join a channel'); + } var liveNames = {}; channelsSnapshot.rooms.forEach(function(room) { liveNames[room.name] = true; @@ -1457,9 +1591,6 @@ function _channelsRenderList() { var savedOnlyRooms = channelsSavedRooms.filter(function(saved) { return !liveNames[saved.room_name]; }); - if (channelsSnapshot.rooms.length || savedOnlyRooms.length) { - list.appendChild(_channelsListSection('Your channels')); - } channelsSnapshot.rooms.forEach(function(room) { list.appendChild(_channelsBuildRoomRow(room, false)); }); @@ -1481,7 +1612,7 @@ function _channelsRenderList() { var availableRooms = directoryRooms.filter(function(room) { return room && room.name && !knownNames[room.name]; }); - list.appendChild(_channelsListSection('Public on this hub', { + list.appendChild(_channelsListSection('Discover', { className: 'channel-directory-section', actionText: directory.phase === 'loading' ? 'Checking\u2026' : 'Refresh', actionDisabled: directory.phase === 'loading', @@ -1701,7 +1832,7 @@ function _channelsBuildDirectoryRoomRow(room) { row.type = 'button'; row.className = 'channel-room-row channel-directory-room'; row.dataset.room = room.name; - row.setAttribute('aria-label', 'Join ' + room.name); + row.setAttribute('aria-label', 'Join ' + _channelsRoomDisplayName(room.name)); var icon = document.createElement('span'); icon.className = 'channel-room-row-icon'; @@ -1712,7 +1843,7 @@ function _channelsBuildDirectoryRoomRow(room) { copy.className = 'channel-room-row-copy'; var title = document.createElement('span'); title.className = 'channel-room-row-title'; - title.textContent = room.name; + title.textContent = _channelsRoomDisplayName(room.name); var meta = document.createElement('span'); meta.className = 'channel-room-row-meta'; meta.textContent = room.topic || 'Public channel on this hub'; @@ -1721,8 +1852,9 @@ function _channelsBuildDirectoryRoomRow(room) { row.appendChild(copy); var action = document.createElement('span'); - action.className = 'channel-row-status joined'; - action.textContent = 'Join'; + action.className = 'channel-row-add'; + action.textContent = '+'; + action.setAttribute('aria-hidden', 'true'); row.appendChild(action); row.addEventListener('click', function() { channelsOpenJoinSheet(room.name); @@ -1760,7 +1892,7 @@ function _channelsBuildRoomRow(room, savedOnly, options) { copy.className = 'channel-room-row-copy'; var title = document.createElement('span'); title.className = 'channel-room-row-title'; - title.textContent = room.name; + title.textContent = _channelsRoomDisplayName(room.name); var meta = document.createElement('span'); meta.className = 'channel-room-row-meta'; if (savedOnly) { @@ -1850,7 +1982,7 @@ function _channelsRenderRoom(scrollRestore) { if (membersToggle) membersToggle.hidden = room.phase !== 'joined'; var roomMenu = _channelsEl('channel-room-menu-btn'); if (roomMenu) roomMenu.hidden = false; - _channelsSetText('channel-room-title', room.name); + _channelsSetText('channel-room-title', _channelsRoomDisplayName(room.name)); var phase = _channelsEl('channel-room-phase'); if (phase) { phase.dataset.phase = room.phase || 'joining'; @@ -1869,8 +2001,9 @@ function _channelsRenderRoom(scrollRestore) { historyContext.hub_destination_hash )) + ' \u00b7 stored on this device'; } else { - roomMeta = memberCount ? memberCount + (memberCount === 1 ? ' person here' : ' people here') : 'No member list'; - if (room.topic) roomMeta = room.topic + (memberCount ? ' \u00b7 ' + roomMeta : ''); + roomMeta = room.topic || (memberCount + ? memberCount + (memberCount === 1 ? ' person here' : ' people here') + : _channelsHubName(channelsSnapshot.hub)); } _channelsSetText('channel-room-meta', roomMeta); @@ -1882,7 +2015,8 @@ function _channelsRenderRoom(scrollRestore) { }); } if (historyContext && historyEntry) { - transcript.appendChild(_channelsBuildHistoryRail(historyContext, historyEntry)); + var historyRail = _channelsBuildHistoryRail(historyContext, historyEntry); + if (historyRail) transcript.appendChild(historyRail); } var items = _channelsTimelineEntries(room, historyEntry); var renderedItems = _channelsGroupPresenceEvents(items, room.name); @@ -1948,6 +2082,7 @@ function _channelsTimelineEntries(room, historyEntry) { function append(item, hubNotice) { if (!item) return; + if (_channelsIsConnectionLifecycleItem(item)) return; var id = String(item.id || ''); if (id && seen[id]) return; if (id) seen[id] = true; @@ -1987,30 +2122,31 @@ function _channelsTimelineEntries(room, historyEntry) { } function _channelsBuildHistoryRail(context, entry) { + var writer = channelsSnapshot.history || {}; + var statusText = ''; + if (entry.error) { + statusText = entry.error; + } else if (!entry.loaded && entry.loading) { + statusText = 'Loading recent activity\u2026'; + } else if (writer.phase === 'degraded') { + statusText = writer.last_error || 'Some recent activity could not be saved.'; + } else if (writer.phase === 'pending' && Number(writer.pending_events) > 0) { + statusText = 'Saving recent activity on this device\u2026'; + } + if (!statusText && !entry.has_more) return null; + var rail = document.createElement('div'); - rail.className = 'channel-history-rail'; + rail.className = 'channel-history-rail' + (statusText ? '' : ' action-only'); rail.dataset.phase = entry.error ? 'error' : (channelsSnapshot.history && channelsSnapshot.history.phase) || 'ready'; rail.setAttribute('role', entry.error ? 'alert' : 'status'); - var label = document.createElement('span'); - label.className = 'channel-history-label'; - label.innerHTML = ''; - var text = document.createElement('span'); - var writer = channelsSnapshot.history || {}; - if (entry.error) { - text.textContent = entry.error; - } else if (!entry.loaded && entry.loading) { - text.textContent = 'Loading local timeline\u2026'; - } else if (writer.phase === 'degraded') { - text.textContent = writer.last_error || 'Some recent activity could not be saved.'; - } else if (writer.phase === 'pending' && Number(writer.pending_events) > 0) { - text.textContent = 'Saving recent activity on this device\u2026'; - } else { - text.textContent = 'Local timeline'; + if (statusText) { + var label = document.createElement('span'); + label.className = 'channel-history-label'; + label.textContent = statusText; + rail.appendChild(label); } - label.appendChild(text); - rail.appendChild(label); if (entry.error || entry.has_more) { var action = document.createElement('button'); @@ -2302,6 +2438,12 @@ function _channelsIsHubNotice(item) { return !!hubIdentity && String(item.source_hash || '').toLowerCase() === String(hubIdentity).toLowerCase(); } +// Recovery is a Link state, not room activity. Keep legacy persisted markers +// out of the human timeline while older local databases age them out normally. +function _channelsIsConnectionLifecycleItem(item) { + return !!item && item.kind === 'system' && item.text === 'Reconnected to hub'; +} + function _channelsBuildHubNotice(item) { var notice = document.createElement('aside'); notice.className = 'channel-hub-notice'; @@ -2316,9 +2458,9 @@ function _channelsBuildHubNotice(item) { time.textContent = _channelsFormatTime(item.timestamp_ms); var meta = document.createElement('span'); meta.className = 'channel-event-meta'; - meta.appendChild(time); var quote = _channelsBuildQuoteButton(item, 'Hub'); if (quote) meta.appendChild(quote); + meta.appendChild(time); var body = document.createElement('div'); body.className = 'channel-hub-notice-text'; body.textContent = item.text || ''; @@ -2347,7 +2489,8 @@ function _channelsBuildQuoteButton(item, authorText) { function _channelsIsPresenceEvent(entry) { if (!entry || entry.hubNotice || !entry.item || entry.item.ours) return false; - return entry.item.kind === 'join' || entry.item.kind === 'part'; + return entry.item.kind === 'join' || entry.item.kind === 'part' || + entry.item.kind === 'present'; } function _channelsPresenceIdentityKey(item) { @@ -2409,13 +2552,9 @@ function _channelsGroupPresenceEvents(entries, roomName) { return; } var previous = run.length ? run[run.length - 1].item : null; - var timestamp = _channelsActivityTime(entry.item); - var previousTimestamp = previous ? _channelsActivityTime(previous) : timestamp; - var elapsed = timestamp - previousTimestamp; var sameRun = previous && _channelsDayKey(previous) === _channelsDayKey(entry.item) && - elapsed >= 0 && - elapsed <= CHANNEL_PRESENCE_GROUP_WINDOW_MS; + (previous.kind === 'present') === (entry.item.kind === 'present'); if (previous && !sameRun) flushRun(); run.push(entry); }); @@ -2426,29 +2565,35 @@ function _channelsGroupPresenceEvents(entries, roomName) { function _channelsPresenceGroupSummary(group) { var joined = []; var left = []; + var present = []; group.entries.forEach(function(entry) { if (entry.item.kind === 'part') left.push(entry); + else if (entry.item.kind === 'present') present.push(entry); else joined.push(entry); }); var joinedNoun = joined.length === 1 ? 'person' : 'people'; var leftNoun = left.length === 1 ? 'person' : 'people'; + var presentNoun = present.length === 1 ? 'person' : 'people'; var text; - if (joined.length && left.length) { + if (present.length) { + text = present.length + ' ' + presentNoun + ' here'; + } else if (joined.length && left.length) { text = joined.length + ' ' + joinedNoun + ' joined and ' + left.length + ' left'; } else if (left.length) { text = left.length + ' ' + leftNoun + ' left'; } else { text = joined.length + ' ' + joinedNoun + ' joined'; } - return { joined: joined, left: left, text: text }; + return { joined: joined, left: left, present: present, text: text }; } function _channelsPresenceName(item) { if (item.nickname) return item.nickname; if (item.source_hash) return _channelsShortHash(item.source_hash); var text = String(item.text || ''); - var suffix = item.kind === 'part' ? ' left' : ' joined'; + var suffix = item.kind === 'part' ? ' left' : + (item.kind === 'present' ? ' is here' : ' joined'); return text.slice(-suffix.length) === suffix ? text.slice(0, -suffix.length) : 'A member'; } @@ -2468,7 +2613,7 @@ function _channelsPresenceTooltip(entries) { function _channelsAppendPresenceCount(label, entries, kind, includeNoun) { var count = entries.length; var noun = count === 1 ? 'person' : 'people'; - var action = kind === 'part' ? 'left' : 'joined'; + var action = kind === 'part' ? 'left' : (kind === 'present' ? 'here' : 'joined'); var countLabel = count + ' ' + noun + ' ' + action; var countElement = document.createElement('span'); countElement.className = 'channel-presence-count'; @@ -2495,7 +2640,9 @@ function _channelsBuildPresenceGroup(group) { var label = document.createElement('span'); label.className = 'channel-presence-label'; - if (summary.joined.length && summary.left.length) { + if (summary.present.length) { + _channelsAppendPresenceCount(label, summary.present, 'present', true); + } else if (summary.joined.length && summary.left.length) { _channelsAppendPresenceCount(label, summary.joined, 'join', true); label.appendChild(document.createTextNode(' and ')); _channelsAppendPresenceCount(label, summary.left, 'part', false); @@ -2528,7 +2675,8 @@ function _channelsBuildPresenceGroup(group) { marker.setAttribute('aria-hidden', 'true'); var copy = document.createElement('span'); copy.className = 'channel-presence-item-copy'; - copy.textContent = nameText + (item.kind === 'part' ? ' left' : ' joined'); + copy.textContent = nameText + (item.kind === 'part' ? ' left' : + (item.kind === 'present' ? ' is here' : ' joined')); var time = document.createElement('time'); time.className = 'channel-event-time'; time.dateTime = new Date(Number(item.timestamp_ms) || Date.now()).toISOString(); @@ -2552,8 +2700,42 @@ function _channelsBuildPresenceGroup(group) { return wrapper; } +function _channelsBuildPresenceEvent(item) { + var event = document.createElement('div'); + event.className = 'channel-presence-event'; + event.dataset.tone = _channelsIdentityTone( + item.source_hash || item.nickname || item.text + ); + + var marker = document.createElement('span'); + marker.className = 'channel-identity-marker'; + marker.setAttribute('aria-hidden', 'true'); + var copy = document.createElement('span'); + copy.className = 'channel-presence-event-copy'; + var name = document.createElement('strong'); + name.textContent = _channelsPresenceName(item); + var action = document.createElement('span'); + action.textContent = item.kind === 'part' ? ' left' : + (item.kind === 'present' ? ' is here' : ' joined'); + copy.appendChild(name); + copy.appendChild(action); + + var time = document.createElement('time'); + time.className = 'channel-event-time'; + time.dateTime = new Date(Number(item.timestamp_ms) || Date.now()).toISOString(); + time.textContent = _channelsFormatTime(item.timestamp_ms); + + event.appendChild(marker); + event.appendChild(copy); + event.appendChild(time); + return event; +} + function _channelsBuildTranscriptItem(item, hubNotice) { var kind = item.kind || 'message'; + if ((kind === 'join' || kind === 'part' || kind === 'present') && !item.ours) { + return _channelsBuildPresenceEvent(item); + } if (kind === 'join' || kind === 'part' || kind === 'error' || kind === 'system') { var system = document.createElement('div'); system.className = 'channel-system-event' + (kind === 'error' ? ' error' : ''); @@ -2590,9 +2772,9 @@ function _channelsBuildTranscriptItem(item, hubNotice) { time.textContent = _channelsFormatTime(item.timestamp_ms); var meta = document.createElement('span'); meta.className = 'channel-event-meta'; - meta.appendChild(time); var quote = _channelsBuildQuoteButton(item, authorText); if (quote) meta.appendChild(quote); + meta.appendChild(time); var body = document.createElement('div'); body.className = 'channel-event-text'; body.textContent = kind === 'action' ? authorText + ' ' + (item.text || '') : (item.text || ''); @@ -2843,7 +3025,10 @@ function _channelsRenderMembers(room) { _channelsSetText('channel-members-label', 'People here'); _channelsSetText('channel-members-count', members.length + ' visible'); if (room && room.phase !== 'joined') { - if (note) note.textContent = 'Member details appear after the hub confirms your join.'; + if (note) { + note.textContent = 'Available after joining'; + note.title = 'Member details appear after the hub confirms your join.'; + } var waiting = document.createElement('div'); waiting.className = 'channel-members-empty'; waiting.textContent = 'Waiting for channel membership.'; @@ -2851,9 +3036,9 @@ function _channelsRenderMembers(room) { return; } if (note) { - note.textContent = room && room.members_complete - ? 'Member list supplied by this hub.' - : 'The hub may provide only part of the member list.'; + note.hidden = !!(room && room.members_complete); + note.textContent = 'Partial list'; + note.title = 'The hub may provide only part of the member list.'; } if (!members.length) { var empty = document.createElement('div'); @@ -3071,13 +3256,14 @@ function channelsOpenChannelShare(hubDestinationHash, roomName) { } }).then(function(target) { var built = _rsBuildSheet({ - title: target.room ? 'Share Channel' : 'Share Hub' + title: target.room ? 'Share channel' : 'Share hub' }, function() {}); + built.sheet.classList.add('channel-share-sheet'); var copy = document.createElement('p'); copy.className = 'channel-sheet-copy'; copy.textContent = target.room - ? 'Share ' + target.room + ' on this hub.' - : 'Share this channel hub.'; + ? 'Invite someone to ' + _channelsRoomDisplayName(target.room) + '.' + : 'Invite someone to connect to this hub.'; built.body.appendChild(copy); var details = document.createElement('div'); @@ -3170,7 +3356,7 @@ function channelsOpenChannelShare(hubDestinationHash, roomName) { var note = document.createElement('div'); note.className = 'channel-sheet-trust-note'; - note.textContent = 'This share contains only the hub destination and optional channel name. It never includes a channel key or joins on open.'; + note.textContent = 'The link contains no channel key and never joins automatically.'; built.body.appendChild(note); var done = document.createElement('button'); @@ -3204,12 +3390,15 @@ function _channelsPresentSharedTarget(target) { var sameHub = _channelsIsConnected() && activeDestination === destination; var joinedRoom = sameHub && target.room && _channelsRoomByName(target.room); - var built = _rsBuildSheet({ title: 'Shared Channel' }, function() {}); + var built = _rsBuildSheet({ + title: target.room ? 'Shared channel' : 'Shared hub' + }, function() {}); + built.sheet.classList.add('channel-share-preview-sheet'); var copy = document.createElement('p'); copy.className = 'channel-sheet-copy'; copy.textContent = target.room - ? 'Someone shared ' + target.room + ' on ' + _channelsTimelineHubName(hub) + '.' - : 'Someone shared ' + _channelsTimelineHubName(hub) + '.'; + ? _channelsRoomDisplayName(target.room) + ' on ' + _channelsTimelineHubName(hub) + : _channelsTimelineHubName(hub); built.body.appendChild(copy); var details = document.createElement('div'); @@ -3234,7 +3423,7 @@ function _channelsPresentSharedTarget(target) { var trust = document.createElement('div'); trust.className = 'channel-sheet-trust-note'; - trust.textContent = 'A share names a destination; it is not proof of who operates it. Ratspeak authenticates the Reticulum Link before any channel action. No channel key is present.'; + trust.textContent = 'This link identifies a destination; it does not prove who runs it or contain a channel key.'; built.body.appendChild(trust); var cancel = document.createElement('button'); @@ -3308,12 +3497,12 @@ function channelsScanSharedChannel() { return; } RS.qr.openScanner({ - title: 'Scan Channel QR', - checkingText: 'Checking channel share\u2026', + title: 'Scan Ratspeak QR', + checkingText: 'Checking link\u2026', previewCommand: 'api_preview_channel_share', - invalidText: 'That QR is not a valid Ratspeak channel share.', - invalidImageText: 'That image does not contain a valid Ratspeak channel QR.', - emptyImageText: 'No Ratspeak channel QR found in that image.', + invalidText: 'That QR is not a valid Ratspeak link.', + invalidImageText: 'That image does not contain a valid Ratspeak QR.', + emptyImageText: 'No Ratspeak QR found in that image.', onPreview: function(_body, _payload, target, closeAll) { closeAll(); setTimeout(function() { _channelsPresentSharedTarget(target); }, 220); @@ -3321,12 +3510,33 @@ function channelsScanSharedChannel() { }); } +function _channelsBuildLinkMethod(titleText, detailText, iconMarkup) { + var button = document.createElement('button'); + button.type = 'button'; + button.className = 'channel-link-method'; + var icon = document.createElement('span'); + icon.className = 'channel-link-method-icon'; + icon.innerHTML = iconMarkup; + var copy = document.createElement('span'); + copy.className = 'channel-link-method-copy'; + var title = document.createElement('strong'); + title.textContent = titleText; + var detail = document.createElement('span'); + detail.textContent = detailText; + copy.appendChild(title); + copy.appendChild(detail); + button.appendChild(icon); + button.appendChild(copy); + return button; +} + function channelsOpenSharedChannel(payload) { if (typeof _rsBuildSheet !== 'function') return; - var built = _rsBuildSheet({ title: 'Open Shared Channel' }, function() {}); + var built = _rsBuildSheet({ title: 'Open link or QR' }, function() {}); + built.sheet.classList.add('channel-open-link-sheet'); var copy = document.createElement('p'); copy.className = 'channel-sheet-copy'; - copy.textContent = 'Paste a Ratspeak channel link or scan its QR. Opening it only previews the destination.'; + copy.textContent = 'Open an invitation without connecting automatically.'; built.body.appendChild(copy); var input = document.createElement('textarea'); @@ -3340,37 +3550,52 @@ function channelsOpenSharedChannel(payload) { input.setAttribute('spellcheck', 'false'); input.setAttribute('writingsuggestions', 'false'); input.value = payload || ''; - built.body.appendChild(_channelsSheetField('Channel link', input)); var tools = document.createElement('div'); - tools.className = 'channel-share-actions'; - var scan = document.createElement('button'); - scan.type = 'button'; - scan.className = 'nr-btn nr-btn-secondary'; - scan.textContent = 'Scan QR'; + tools.className = 'channel-link-methods'; + var scan = _channelsBuildLinkMethod( + 'Scan QR', + 'Use the camera or choose an image', + '' + ); scan.hidden = !RS.qr || typeof RS.qr.openScanner !== 'function'; scan.addEventListener('click', function() { built.dismiss(); setTimeout(channelsScanSharedChannel, 220); }); tools.appendChild(scan); - var paste = document.createElement('button'); - paste.type = 'button'; - paste.className = 'nr-btn nr-btn-secondary'; - paste.textContent = 'Paste'; + var paste = _channelsBuildLinkMethod( + 'Paste from clipboard', + 'Review a copied Ratspeak link', + _channelsActionIcon('copy') + ); paste.hidden = !navigator.clipboard || typeof navigator.clipboard.readText !== 'function'; paste.addEventListener('click', function() { navigator.clipboard.readText().then(function(text) { input.value = text || ''; - input.focus(); + manual.open = true; + preview(); }).catch(function() { error.textContent = 'Clipboard access was unavailable. Paste the link into the field.'; + manual.open = true; input.focus(); }); }); tools.appendChild(paste); built.body.appendChild(tools); + var manual = document.createElement('details'); + manual.className = 'channel-private-access channel-link-manual'; + var manualSummary = document.createElement('summary'); + manualSummary.textContent = 'Enter link manually'; + manual.appendChild(manualSummary); + var manualBody = document.createElement('div'); + manualBody.className = 'channel-private-access-body'; + manualBody.appendChild(_channelsSheetField('Ratspeak link', input)); + manual.appendChild(manualBody); + manual.open = !!payload || (scan.hidden && paste.hidden); + built.body.appendChild(manual); + var error = document.createElement('div'); error.className = 'channel-sheet-error'; error.setAttribute('aria-live', 'polite'); @@ -3378,7 +3603,8 @@ function channelsOpenSharedChannel(payload) { function preview() { var raw = input.value.trim(); if (!raw) { - error.textContent = 'Paste or scan a Ratspeak channel link.'; + error.textContent = 'Paste a Ratspeak link or scan its QR.'; + manual.open = true; input.focus(); return; } @@ -3390,9 +3616,10 @@ function channelsOpenSharedChannel(payload) { setTimeout(function() { _channelsPresentSharedTarget(target); }, 220); }).catch(function(previewError) { error.textContent = (previewError && previewError.message) || - 'That is not a valid Ratspeak channel share.'; + 'That is not a valid Ratspeak link.'; + manual.open = true; open.disabled = false; - open.textContent = 'Preview'; + open.textContent = 'Review'; }); } @@ -3404,11 +3631,11 @@ function channelsOpenSharedChannel(payload) { var open = document.createElement('button'); open.type = 'button'; open.className = 'nr-btn nr-btn-primary'; - open.textContent = 'Preview'; + open.textContent = 'Review'; open.addEventListener('click', preview); built.footer.appendChild(cancel); built.footer.appendChild(open); - _channelsPresentSheet(built, input); + _channelsPresentSheet(built, scan.hidden ? (paste.hidden ? input : paste) : scan); if (payload) setTimeout(preview, 0); } window.channelsOpenSharedChannel = channelsOpenSharedChannel; @@ -3430,7 +3657,11 @@ function channelsOpenHubSwitcher() { } var openedEpoch = _channelsHistoryEpoch; var openedGeneration = Number(channelsSnapshot.generation) || 0; - var built = _rsBuildSheet({ title: 'Channel hubs' }, function() { + var built = _rsBuildSheet({ + title: _channelsIsConnected() || _channelsIsConnecting() + ? 'Switch hub' + : 'Choose a hub' + }, function() { if (_channelsHubSwitcherDismiss === built.dismiss) { _channelsHubSwitcherDismiss = null; } @@ -3438,11 +3669,6 @@ function channelsOpenHubSwitcher() { _channelsHubSwitcherDismiss = built.dismiss; built.sheet.classList.add('channel-hub-switcher-sheet'); - var copy = document.createElement('p'); - copy.className = 'channel-sheet-copy'; - copy.textContent = 'Ratspeak keeps one live hub at a time. Switching ends the current live rooms; saved channels and local history stay on this device.'; - built.body.appendChild(copy); - var list = document.createElement('div'); list.className = 'channel-hub-switcher-list'; list.setAttribute('aria-live', 'polite'); @@ -3473,7 +3699,9 @@ function channelsOpenHubSwitcher() { return; } if (current && channelsSnapshot.hub) channelsOpenHubOptions(); - else channelsOpenConnectSheet(hub); + else channelsOpenConnectSheet(Object.assign({}, hub, { + return_to_switcher: true + })); }, 220); } @@ -3546,13 +3774,13 @@ function channelsOpenHubSwitcher() { var blocked = _channelsConnectCommandBlocked(); if (model.current) { var currentStatus = _channelsHubCurrentStatus(); - appendHubSection('Current selection', [model.current], { + appendHubSection('Current', [model.current], { current: true, status: currentStatus.label, statusTone: currentStatus.tone }); } - appendHubSection('Recently heard', model.nearby, { + appendHubSection('Nearby', model.nearby, { always: true, actionText: refreshInFlight ? 'Checking\u2026' : 'Refresh', actionDisabled: refreshInFlight, @@ -3566,13 +3794,13 @@ function channelsOpenHubSwitcher() { 'Saved hubs remain available below' )); } - appendHubSection('Saved on this device', model.saved, { + appendHubSection('Saved', model.saved, { disabled: blocked }); if (!model.current && !model.nearby.length && !model.saved.length) { list.appendChild(_channelsDirectoryStatus( 'No channel hubs yet', - 'Add a trusted destination address to begin' + 'Enter a trusted hub address to begin' )); } if (blocked && (model.nearby.length || model.saved.length)) { @@ -3584,21 +3812,28 @@ function channelsOpenHubSwitcher() { } } + var note = document.createElement('p'); + note.className = 'channel-hub-switcher-note'; + note.textContent = 'One hub can be live at a time. Switching ends live channel sessions; history stays on this device.'; + built.body.appendChild(note); + var add = document.createElement('button'); add.type = 'button'; - add.className = 'nr-btn nr-btn-secondary'; - add.textContent = 'Add by address'; + add.className = 'nr-btn nr-btn-primary'; + add.textContent = 'Enter address'; add.addEventListener('click', function() { built.dismiss(); - setTimeout(function() { channelsOpenConnectSheet(); }, 220); + setTimeout(function() { + channelsOpenConnectSheet({ return_to_switcher: true }); + }, 220); }); var done = document.createElement('button'); done.type = 'button'; - done.className = 'nr-btn nr-btn-primary'; - done.textContent = 'Done'; + done.className = 'nr-btn nr-btn-secondary'; + done.textContent = 'Close'; done.addEventListener('click', built.dismiss); - built.footer.appendChild(add); built.footer.appendChild(done); + built.footer.appendChild(add); renderList(); _channelsPresentSheet(built, modelCurrentFocus()); @@ -3613,112 +3848,108 @@ function channelsOpenHubSwitcher() { function channelsOpenConnectSheet(prefill) { if (typeof _rsBuildSheet !== 'function') return; prefill = prefill || {}; - var hubs = _channelsMergedHubs(); - var selectedHash = prefill.destination_hash || (channelsSnapshot.hub && channelsSnapshot.hub.destination_hash) || ''; + var selectedHash = String(prefill.destination_hash || '').trim().toLowerCase(); var selectedLabel = prefill.label || prefill.announced_name || ''; - var sharedDestination = prefill.shared_room - ? String(prefill.destination_hash || '').toLowerCase() - : ''; + var sharedDestination = prefill.shared_room ? selectedHash : ''; var sharedRoom = prefill.shared_room || ''; - var defaultNick = prefill.nickname || (_channelsSavedHub(selectedHash) || {}).nickname || channelsSnapshot.nickname || _channelsDefaultNickname(); - var built = _rsBuildSheet({ title: 'Connect to Channels' }, function() {}); + var manualEntry = !selectedHash; + var initialMode = selectedHash ? _channelsHubConnectMode(selectedHash) : null; + if (initialMode && initialMode.kind === 'current' && !sharedRoom) { + channelsOpenHubOptions(); + return; + } + + var selectedHub = selectedHash ? _channelsHubByDestination(selectedHash) : null; + var defaultNick = prefill.nickname || + (_channelsSavedHub(selectedHash) || {}).nickname || + channelsSnapshot.nickname || _channelsDefaultNickname(); + var built = _rsBuildSheet({ title: manualEntry ? 'Connect to a hub' : 'Review hub' }, function() {}); + built.sheet.classList.add('channel-connect-sheet'); var titleElement = built.sheet.querySelector('.bottom-sheet-title'); - var copy = document.createElement('p'); - copy.className = 'channel-sheet-copy'; - copy.textContent = sharedRoom - ? 'This share points to ' + sharedRoom + '. Review and authenticate the hub first; Ratspeak will then open a separate join review.' - : 'Choose a recently heard hub or enter its destination. Ratspeak will authenticate the connection.'; - built.body.appendChild(copy); + if (sharedRoom) { + var shareContext = document.createElement('div'); + shareContext.className = 'channel-connection-context'; + var shareLabel = document.createElement('span'); + shareLabel.textContent = 'Shared channel'; + var shareName = document.createElement('strong'); + shareName.textContent = _channelsRoomDisplayName(sharedRoom); + shareContext.appendChild(shareLabel); + shareContext.appendChild(shareName); + built.body.appendChild(shareContext); + } - var openShared = document.createElement('button'); - openShared.type = 'button'; - openShared.className = 'nr-btn nr-btn-secondary channel-open-share-btn'; - openShared.textContent = 'Open a shared channel'; - openShared.addEventListener('click', function() { - built.dismiss(); - setTimeout(function() { channelsOpenSharedChannel(); }, 220); - }); - built.body.appendChild(openShared); - - var trust = document.createElement('div'); - trust.className = 'channel-sheet-trust-note'; - trust.innerHTML = 'Choose a hub you trust. The Link is encrypted in transit, but the hub relays and can read channel messages.'; - built.body.appendChild(trust); - - var switchImpact = document.createElement('div'); - switchImpact.className = 'channel-hub-switch-impact'; - switchImpact.hidden = true; - built.body.appendChild(switchImpact); + if (selectedHub) { + var summary = document.createElement('div'); + summary.className = 'channel-connection-summary'; + summary.appendChild(_channelsBuildHubMark(selectedHub)); + var summaryCopy = document.createElement('span'); + summaryCopy.className = 'channel-connection-summary-copy'; + var summaryTitle = document.createElement('strong'); + summaryTitle.textContent = selectedLabel || _channelsTimelineHubName(selectedHub); + var summaryMeta = document.createElement('span'); + var summaryParts = [_channelsShortHash(selectedHash)]; + var distance = _channelsHubDistance(selectedHub); + if (distance) summaryParts.push(distance); + summaryMeta.textContent = summaryParts.join(' \u00b7 '); + summaryCopy.appendChild(summaryTitle); + summaryCopy.appendChild(summaryMeta); + summary.appendChild(summaryCopy); + built.body.appendChild(summary); + } var destinationInput = document.createElement('input'); destinationInput.type = 'text'; destinationInput.className = 'nr-input-sm mono'; - destinationInput.placeholder = '32-character destination hash'; + destinationInput.placeholder = '32-character hub address'; destinationInput.autocapitalize = 'none'; destinationInput.autocomplete = 'off'; destinationInput.spellcheck = false; destinationInput.maxLength = 32; destinationInput.value = selectedHash; - destinationInput.addEventListener('input', function() { - var entered = destinationInput.value.trim().toLowerCase(); - if (entered !== selectedHash) selectedLabel = ''; - if (entered !== sharedDestination) sharedRoom = ''; - updateConnectionChoice(); - }); - - if (hubs.length) { - var availableLabel = document.createElement('div'); - availableLabel.className = 'channels-section-label'; - availableLabel.textContent = 'Available hubs'; - built.body.appendChild(availableLabel); - var available = document.createElement('div'); - available.className = 'channel-sheet-hubs'; - hubs.forEach(function(hub) { - var row = document.createElement('button'); - row.type = 'button'; - row.className = 'channel-sheet-hub' + (hub.destination_hash === selectedHash ? ' selected' : ''); - var rowCopy = document.createElement('span'); - rowCopy.className = 'channel-sheet-hub-copy'; - var title = document.createElement('strong'); - title.textContent = _channelsHubName(hub); - var hash = document.createElement('span'); - hash.textContent = _channelsHubMeta(hub); - rowCopy.appendChild(title); - rowCopy.appendChild(hash); - row.appendChild(_channelsBuildHubMark(hub)); - row.appendChild(rowCopy); - var distance = document.createElement('span'); - distance.className = 'channel-hub-row-distance'; - distance.textContent = _channelsHubDistance(hub); - if (distance.textContent) row.appendChild(distance); - row.addEventListener('click', function() { - selectedHash = hub.destination_hash; - selectedLabel = hub.label || hub.announced_name || ''; - sharedRoom = hub.destination_hash === sharedDestination - ? prefill.shared_room || '' - : ''; - destinationInput.value = selectedHash; - if (hub.nickname) nicknameInput.value = hub.nickname; - available.querySelectorAll('.channel-sheet-hub').forEach(function(el) { el.classList.remove('selected'); }); - row.classList.add('selected'); - error.textContent = ''; - updateConnectionChoice(); - }); - available.appendChild(row); - }); - built.body.appendChild(available); - } - - var destinationField = _channelsSheetField('Hub destination', destinationInput); + var destinationField = _channelsSheetField('Hub address', destinationInput); + destinationField.hidden = !manualEntry; built.body.appendChild(destinationField); + var nicknameInput = document.createElement('input'); nicknameInput.type = 'text'; nicknameInput.className = 'nr-input-sm'; nicknameInput.placeholder = 'Nickname for this session'; nicknameInput.maxLength = 32; nicknameInput.value = defaultNick; - built.body.appendChild(_channelsSheetField('Your channel nickname', nicknameInput)); + var nicknameField = _channelsSheetField('Nickname', nicknameInput); + nicknameField.hidden = !manualEntry; + + var identityRow = document.createElement('div'); + identityRow.className = 'channel-connection-identity'; + identityRow.hidden = manualEntry; + var identityCopy = document.createElement('span'); + var identityLabel = document.createElement('span'); + identityLabel.textContent = 'Joining as'; + var identityName = document.createElement('strong'); + identityName.textContent = defaultNick; + identityCopy.appendChild(identityLabel); + identityCopy.appendChild(identityName); + var changeNickname = document.createElement('button'); + changeNickname.type = 'button'; + changeNickname.className = 'channel-connection-change'; + changeNickname.textContent = 'Change'; + changeNickname.addEventListener('click', function() { + identityRow.hidden = true; + nicknameField.hidden = false; + nicknameInput.focus(); + nicknameInput.select(); + }); + identityRow.appendChild(identityCopy); + identityRow.appendChild(changeNickname); + built.body.appendChild(identityRow); + built.body.appendChild(nicknameField); + + var switchImpact = document.createElement('div'); + switchImpact.className = 'channel-hub-switch-impact'; + switchImpact.hidden = true; + built.body.appendChild(switchImpact); + var error = document.createElement('div'); error.className = 'channel-sheet-error'; error.setAttribute('aria-live', 'polite'); @@ -3727,59 +3958,59 @@ function channelsOpenConnectSheet(prefill) { var cancel = document.createElement('button'); cancel.type = 'button'; cancel.className = 'nr-btn nr-btn-secondary'; - cancel.textContent = 'Cancel'; - cancel.addEventListener('click', function() { built.dismiss(); }); + cancel.textContent = prefill.return_to_switcher ? 'Back' : 'Cancel'; + cancel.addEventListener('click', function() { + built.dismiss(); + if (prefill.return_to_switcher) { + setTimeout(channelsOpenHubSwitcher, 220); + } + }); var connect = document.createElement('button'); connect.type = 'button'; connect.className = 'nr-btn nr-btn-primary'; var connectBusy = false; + function destinationName(destination) { + var hub = _channelsHubByDestination(destination); + return selectedLabel || _channelsTimelineHubName(hub); + } + function updateConnectionChoice() { var destination = destinationInput.value.trim().toLowerCase(); var mode = _channelsHubConnectMode(destination); - var currentHub = mode.current_destination - ? _channelsHubByDestination(mode.current_destination) - : null; - var currentName = currentHub - ? _channelsTimelineHubName(currentHub) - : 'the current hub'; + var nextName = destination && /^[0-9a-f]{32}$/.test(destination) + ? destinationName(destination) + : 'this hub'; switchImpact.hidden = true; if (mode.kind === 'switch') { - if (titleElement) titleElement.textContent = 'Switch channel hub'; - copy.textContent = sharedRoom - ? 'This share points to ' + sharedRoom + '. Review and authenticate the new hub first; Ratspeak will then open a separate join review.' - : 'Review the next hub and nickname before replacing the current live session.'; - switchImpact.hidden = false; - switchImpact.textContent = channelsSnapshot.phase === 'reconnecting' - ? 'Switching stops recovery for ' + currentName + '. Saved channels and local history stay on this device.' - : 'Switching ends your live rooms on ' + currentName + '. Saved channels and local history stay on this device.'; + if (titleElement) titleElement.textContent = 'Switch to ' + nextName + '?'; connect.textContent = connectBusy ? 'Switching\u2026' - : (sharedRoom ? 'Switch and review' : 'Switch hub'); + : (sharedRoom ? 'Switch and review' : 'Switch'); connect.disabled = connectBusy; } else if (mode.kind === 'current') { - if (titleElement) titleElement.textContent = 'Current channel hub'; - copy.textContent = 'You are already connected to this hub. Choose another destination to switch, or use Hub options to manage this session.'; - connect.textContent = 'Connected'; - connect.disabled = true; + if (titleElement) titleElement.textContent = nextName; + connect.textContent = 'View hub'; + connect.disabled = false; } else if (mode.kind === 'recovering') { - if (titleElement) titleElement.textContent = 'Hub recovery in progress'; - copy.textContent = 'Ratspeak is recovering this hub connection. Choose another destination to stop recovery and switch.'; - connect.textContent = 'Recovering\u2026'; - connect.disabled = true; + if (titleElement) titleElement.textContent = 'Reconnecting to ' + nextName; + switchImpact.hidden = false; + switchImpact.textContent = 'Open hub details to stop or review this connection.'; + connect.textContent = 'View hub'; + connect.disabled = false; } else if (mode.kind === 'pending') { if (titleElement) titleElement.textContent = 'Connection in progress'; - copy.textContent = 'Finish or cancel the current connection before choosing another hub.'; switchImpact.hidden = false; - switchImpact.textContent = 'Open the current hub from the switcher to cancel this attempt.'; + switchImpact.textContent = 'Finish or cancel the current connection before choosing another hub.'; connect.textContent = 'Connection in progress'; connect.disabled = true; } else { - if (titleElement) titleElement.textContent = 'Connect to Channels'; - copy.textContent = sharedRoom - ? 'This share points to ' + sharedRoom + '. Review and authenticate the hub first; Ratspeak will then open a separate join review.' - : 'Choose a recently heard hub or enter its destination. Ratspeak will authenticate the connection.'; + if (titleElement) { + titleElement.textContent = manualEntry + ? 'Connect to a hub' + : 'Connect to ' + nextName + '?'; + } connect.textContent = connectBusy ? 'Connecting\u2026' : (sharedRoom ? 'Connect and review' : 'Connect'); @@ -3787,17 +4018,33 @@ function channelsOpenConnectSheet(prefill) { } } + destinationInput.addEventListener('input', function() { + var entered = destinationInput.value.trim().toLowerCase(); + if (entered !== selectedHash) selectedLabel = ''; + if (entered !== sharedDestination) sharedRoom = ''; + error.textContent = ''; + updateConnectionChoice(); + }); + connect.addEventListener('click', function() { var destination = destinationInput.value.trim().toLowerCase(); var nickname = nicknameInput.value.trim(); var connectMode = _channelsHubConnectMode(destination); + if (connectMode.kind === 'current' || connectMode.kind === 'recovering') { + built.dismiss(); + setTimeout(function() { channelsOpenHubOptions(); }, 220); + return; + } if (!/^[0-9a-f]{32}$/.test(destination)) { - error.textContent = 'Enter a 32-character hexadecimal destination hash.'; + error.textContent = 'Enter a 32-character hexadecimal hub address.'; + destinationField.hidden = false; destinationInput.focus(); return; } if (!nickname) { error.textContent = 'Choose a nickname for this session.'; + identityRow.hidden = true; + nicknameField.hidden = false; nicknameInput.focus(); return; } @@ -3836,7 +4083,7 @@ function channelsOpenConnectSheet(prefill) { built.footer.appendChild(cancel); built.footer.appendChild(connect); updateConnectionChoice(); - _channelsPresentSheet(built, selectedHash ? nicknameInput : destinationInput); + _channelsPresentSheet(built, manualEntry ? destinationInput : connect); } function _channelsSheetField(labelText, input) { @@ -3901,11 +4148,35 @@ function _channelsPresentSheet(built, initialFocus) { function channelsOpenJoinSheet(prefillRoom) { if (!_channelsIsConnected() || typeof _rsBuildSheet !== 'function') return; - var built = _rsBuildSheet({ title: 'Join a Channel' }, function() {}); - var copy = document.createElement('p'); - copy.className = 'channel-sheet-copy'; - copy.textContent = 'Join a channel on ' + _channelsHubName(channelsSnapshot.hub) + '. Names are case-insensitive.'; - built.body.appendChild(copy); + var normalizedPrefill = String(prefillRoom || '').trim().toLowerCase(); + var directoryRooms = channelsSnapshot.directory && + Array.isArray(channelsSnapshot.directory.rooms) + ? channelsSnapshot.directory.rooms + : []; + var directoryRoom = directoryRooms.find(function(room) { + return room && String(room.name || '').toLowerCase() === normalizedPrefill; + }) || null; + var built = _rsBuildSheet({ + title: normalizedPrefill ? 'Join ' + _channelsRoomDisplayName(normalizedPrefill) + '?' : 'Join a channel' + }, function() {}); + built.sheet.classList.add('channel-join-sheet'); + + if (normalizedPrefill) { + var preview = document.createElement('div'); + preview.className = 'channel-join-preview'; + var previewName = document.createElement('strong'); + previewName.textContent = _channelsRoomDisplayName(normalizedPrefill); + var previewTopic = document.createElement('span'); + previewTopic.textContent = directoryRoom && directoryRoom.topic + ? directoryRoom.topic + : 'Channel on ' + _channelsHubName(channelsSnapshot.hub); + var previewMeta = document.createElement('small'); + previewMeta.textContent = _channelsHubName(channelsSnapshot.hub); + preview.appendChild(previewName); + preview.appendChild(previewTopic); + preview.appendChild(previewMeta); + built.body.appendChild(preview); + } var roomInput = document.createElement('input'); roomInput.type = 'text'; @@ -3918,17 +4189,28 @@ function channelsOpenJoinSheet(prefillRoom) { roomInput.setAttribute('writingsuggestions', 'false'); if (typeof disableAutoCorrect === 'function') disableAutoCorrect(roomInput); roomInput.maxLength = (channelsSnapshot.hub && channelsSnapshot.hub.limits && channelsSnapshot.hub.limits.max_room_name_bytes) || 64; - roomInput.value = prefillRoom || ''; - built.body.appendChild(_channelsSheetField('Channel', roomInput)); + roomInput.value = normalizedPrefill; + var roomField = _channelsSheetField('Channel name', roomInput); + roomField.hidden = !!normalizedPrefill; + built.body.appendChild(roomField); + + var privateAccess = document.createElement('details'); + privateAccess.className = 'channel-private-access'; + var privateSummary = document.createElement('summary'); + privateSummary.textContent = 'Private channel?'; + privateAccess.appendChild(privateSummary); + var privateBody = document.createElement('div'); + privateBody.className = 'channel-private-access-body'; var keyInput = document.createElement('input'); keyInput.type = 'password'; keyInput.className = 'nr-input-sm'; - keyInput.placeholder = 'Only if this channel requires one'; + keyInput.placeholder = 'Access key'; keyInput.autocomplete = 'off'; keyInput.maxLength = 1024; - built.body.appendChild(_channelsSheetField('Channel key (optional)', keyInput)); + privateBody.appendChild(_channelsSheetField('Access key', keyInput)); var rememberRow = document.createElement('label'); rememberRow.className = 'rs-dialog-checkbox-row channel-key-remember'; + rememberRow.hidden = true; var rememberKey = document.createElement('input'); rememberKey.type = 'checkbox'; rememberKey.checked = true; @@ -3937,21 +4219,39 @@ function channelsOpenJoinSheet(prefillRoom) { rememberLabel.textContent = 'Remember for reconnect'; rememberRow.appendChild(rememberKey); rememberRow.appendChild(rememberLabel); - built.body.appendChild(rememberRow); + privateBody.appendChild(rememberRow); var note = document.createElement('p'); - note.className = 'channel-sheet-copy'; - built.body.appendChild(note); + note.className = 'channel-private-access-note'; + note.hidden = true; + privateBody.appendChild(note); + privateAccess.appendChild(privateBody); + built.body.appendChild(privateAccess); function updateKeyPolicy() { rememberKey.disabled = !keyInput.value; var savedRoom = _channelsDurableRoom(roomInput.value.trim().toLowerCase()); if (!keyInput.value && savedRoom && savedRoom.has_stored_join_key) { - note.textContent = 'A saved identity-sealed key is available. Leave this blank to use it, or enter a new key to replace it after the hub confirms membership.'; + privateAccess.open = true; + rememberRow.hidden = true; + note.hidden = false; + note.textContent = 'A saved access key is ready. Leave this blank to use it.'; + } else if (keyInput.value) { + rememberRow.hidden = false; + note.hidden = false; + note.textContent = rememberKey.checked + ? 'Ratspeak saves only identity-sealed ciphertext, and only after the hub confirms membership.' + : 'This key will be used for this session only.'; } else { - note.textContent = 'Keys cross the authenticated Link. Ratspeak saves only identity-sealed ciphertext, and only after the hub confirms membership.'; + rememberRow.hidden = true; + note.hidden = true; + note.textContent = ''; } } keyInput.addEventListener('input', updateKeyPolicy); roomInput.addEventListener('input', updateKeyPolicy); + rememberKey.addEventListener('change', updateKeyPolicy); + if (directoryRoom && String(directoryRoom.modes || '').indexOf('k') !== -1) { + privateAccess.open = true; + } updateKeyPolicy(); var error = document.createElement('div'); error.className = 'channel-sheet-error'; @@ -3997,13 +4297,14 @@ function channelsOpenJoinSheet(prefillRoom) { channelsSelectRoom(channelsActiveRoom); }).catch(function(err) { error.textContent = (err && err.message) || 'Could not join this channel.'; + if (/key|invite|private/i.test(error.textContent)) privateAccess.open = true; join.disabled = false; join.textContent = 'Join'; }); }); built.footer.appendChild(cancel); built.footer.appendChild(join); - _channelsPresentSheet(built, roomInput); + _channelsPresentSheet(built, normalizedPrefill ? join : roomInput); } function _channelsHubProfileDetail(labelText, value, mono) { @@ -4048,11 +4349,105 @@ function _channelsHubCapability(labelText, description, enabled) { return capability; } -function channelsOpenHubOptions() { +function _channelsActionIcon(kind) { + if (kind === 'share') { + return ''; + } + if (kind === 'copy') { + return ''; + } + if (kind === 'leave') { + return ''; + } + return ''; +} + +function _channelsDisconnectFromHub(control) { + channelsPendingShareJoin = null; + if (control) control.disabled = true; + return RS.invoke('disconnect_channel_hub').then(function(snapshot) { + channelsActiveRoom = null; + channelsHistorySelection = null; + channelsApplySnapshot(snapshot); + if (typeof showToast === 'function') { + showToast('Channel session ended', 'toast-green', 2200); + } + return snapshot; + }).catch(function(err) { + if (control) control.disabled = false; + if (typeof showToast === 'function') { + showToast((err && err.message) || 'Could not disconnect', 'toast-red', 3200); + } + return null; + }); +} + +function channelsOpenHubOptions(eventOrTrigger) { + if (!channelsSnapshot.hub) return; + var trigger = eventOrTrigger && eventOrTrigger.currentTarget + ? eventOrTrigger.currentTarget + : null; + if (!trigger || !RS.ui || typeof RS.ui.openActionMenu !== 'function') { + channelsOpenHubDetails(); + return; + } + var hub = channelsSnapshot.hub; + var displayName = _channelsHubName(hub); + var canceling = _channelsIsConnecting(); + RS.ui.openActionMenu(trigger, [ + { + label: 'View hub details', + icon: _channelsActionIcon('info'), + onSelect: channelsOpenHubDetails + }, + { + label: 'Share hub', + icon: _channelsActionIcon('share'), + onSelect: function() { + channelsOpenChannelShare(hub.destination_hash, null); + } + }, + { + label: 'Copy address', + icon: _channelsActionIcon('copy'), + onSelect: function() { + RS.copyText(hub.destination_hash).then(function(ok) { + if (typeof showToast === 'function') { + showToast(ok ? 'Hub address copied' : 'Could not copy', ok ? 'toast-green' : 'toast-orange', 1800); + } + }); + } + }, + { separator: true }, + { + label: canceling ? 'Cancel connection' : 'Disconnect', + icon: _channelsActionIcon('leave'), + danger: true, + onSelect: function() { + var proceed = typeof rsConfirm === 'function' + ? rsConfirm({ + title: canceling ? 'Cancel connection?' : 'Disconnect from ' + displayName + '?', + message: canceling + ? 'Stop this connection attempt.' + : 'Live channel sessions will end. Local history stays on this device.', + confirmText: canceling ? 'Cancel connection' : 'Disconnect', + danger: true + }) + : Promise.resolve(true); + proceed.then(function(confirmed) { + if (confirmed) _channelsDisconnectFromHub(); + }); + } + } + ], { title: displayName }); +} + +function channelsOpenHubDetails() { if (!channelsSnapshot.hub || typeof _rsBuildSheet !== 'function') return; var profile = _channelsHubProfileModel(); var hub = profile.hub; - var built = _rsBuildSheet({ title: 'Hub information' }, function() {}); + var built = _rsBuildSheet({ title: profile.display_name }, function() {}); + built.sheet.classList.add('channel-hub-profile-sheet'); built.body.classList.add('channel-hub-profile'); var hero = document.createElement('header'); @@ -4069,8 +4464,6 @@ function channelsOpenHubOptions() { : 'Channel hub connection'; eyebrow.appendChild(statusDot); eyebrow.appendChild(status); - var name = document.createElement('h2'); - name.textContent = profile.display_name; var summary = document.createElement('p'); var summaryParts = [_channelsPhaseLabel(profile.phase)]; if (profile.hops != null) { @@ -4079,7 +4472,6 @@ function channelsOpenHubOptions() { if (profile.hub_version) summaryParts.push('Hub software ' + profile.hub_version); summary.textContent = summaryParts.join(' \u00b7 '); hero.appendChild(eyebrow); - hero.appendChild(name); hero.appendChild(summary); built.body.appendChild(hero); @@ -4108,6 +4500,12 @@ function channelsOpenHubOptions() { built.body.appendChild(noGuidance); } + var technical = document.createElement('details'); + technical.className = 'channel-hub-technical'; + var technicalSummary = document.createElement('summary'); + technicalSummary.textContent = 'Technical details'; + technical.appendChild(technicalSummary); + var identitySection = _channelsHubProfileSection( 'Connection identity', profile.authenticated_session @@ -4163,7 +4561,7 @@ function channelsOpenHubOptions() { )); } identitySection.appendChild(identityDetails); - built.body.appendChild(identitySection); + technical.appendChild(identitySection); var directorySection = _channelsHubProfileSection( 'Public directory', @@ -4174,7 +4572,7 @@ function channelsOpenHubOptions() { directoryNote.textContent = 'Only public channels disclosed on this Link appear here. Private or secret channels may be intentionally hidden.'; directorySection.appendChild(directoryNote); - built.body.appendChild(directorySection); + technical.appendChild(directorySection); var capabilitySection = _channelsHubProfileSection( 'Session capabilities', @@ -4203,7 +4601,7 @@ function channelsOpenHubOptions() { profile.capabilities.rejoin_grace )); capabilitySection.appendChild(capabilityGrid); - built.body.appendChild(capabilitySection); + technical.appendChild(capabilitySection); var limitRows = []; if (profile.limits.max_message_body_bytes != null) { @@ -4244,7 +4642,8 @@ function channelsOpenHubOptions() { }); limitsSection.appendChild(limitDetails); } - built.body.appendChild(limitsSection); + technical.appendChild(limitsSection); + built.body.appendChild(technical); var trust = document.createElement('div'); trust.className = 'channel-sheet-trust-note'; @@ -4272,47 +4671,135 @@ function channelsOpenHubOptions() { channelsOpenChannelShare(hub.destination_hash, null); }, 220); }); + var hubActions = document.createElement('div'); + hubActions.className = 'channel-hub-profile-actions'; + hubActions.appendChild(shareButton); + hubActions.appendChild(copyButton); + built.body.insertBefore(hubActions, technical); + + var close = document.createElement('button'); + close.type = 'button'; + close.className = 'nr-btn nr-btn-secondary'; + close.textContent = 'Close'; + close.addEventListener('click', built.dismiss); var disconnect = document.createElement('button'); disconnect.type = 'button'; disconnect.className = 'nr-btn nr-btn-danger'; disconnect.textContent = _channelsIsConnecting() ? 'Cancel connection' : 'Disconnect'; disconnect.addEventListener('click', function() { - channelsPendingShareJoin = null; - disconnect.disabled = true; - RS.invoke('disconnect_channel_hub').then(function(snapshot) { - built.dismiss(); - channelsActiveRoom = null; - channelsHistorySelection = null; - channelsApplySnapshot(snapshot); - if (typeof showToast === 'function') showToast('Channel session ended', 'toast-green', 2200); - }).catch(function(err) { - disconnect.disabled = false; - if (typeof showToast === 'function') showToast((err && err.message) || 'Could not disconnect', 'toast-red', 3200); + var canceling = _channelsIsConnecting(); + var proceed = typeof rsConfirm === 'function' + ? rsConfirm({ + title: canceling ? 'Cancel connection?' : 'Disconnect from ' + profile.display_name + '?', + message: canceling + ? 'Stop this connection attempt.' + : 'Live channel sessions will end. Local history stays on this device.', + confirmText: canceling ? 'Cancel connection' : 'Disconnect', + danger: true + }) + : Promise.resolve(true); + proceed.then(function(confirmed) { + if (!confirmed) return; + _channelsDisconnectFromHub(disconnect).then(function(snapshot) { + if (snapshot) built.dismiss(); + }); }); }); - built.footer.appendChild(shareButton); - built.footer.appendChild(copyButton); + built.footer.appendChild(close); built.footer.appendChild(disconnect); _channelsPresentSheet(built, copyButton); } -function channelsOpenRoomOptions() { +function channelsOpenRoomOptions(eventOrTrigger) { + var room = _channelsSelectedRoomView(); + var context = _channelsHistoryContext(room); + if (!room || !context) return; + var trigger = eventOrTrigger && eventOrTrigger.currentTarget + ? eventOrTrigger.currentTarget + : null; + if (!trigger || !RS.ui || typeof RS.ui.openActionMenu !== 'function') { + channelsOpenRoomDetails(); + return; + } + var items = [ + { + label: 'Channel settings', + icon: _channelsActionIcon('info'), + onSelect: channelsOpenRoomDetails + }, + { + label: 'Share channel', + icon: _channelsActionIcon('share'), + onSelect: function() { + channelsOpenChannelShare( + context.hub_destination_hash, + context.room_name + ); + } + } + ]; + + if (room.history_only) { + var activeHub = channelsSnapshot.hub && channelsSnapshot.hub.destination_hash; + var sameHub = _channelsIsConnected() && + String(activeHub || '').toLowerCase() === context.hub_destination_hash; + items.push({ separator: true }); + items.push({ + label: sameHub ? 'Rejoin channel' : 'Connect to hub', + icon: _channelsActionIcon('leave'), + onSelect: function() { + if (sameHub) channelsOpenJoinSheet(context.room_name); + else channelsOpenConnectSheet(_channelsHubByDestination(context.hub_destination_hash)); + } + }); + } else { + items.push({ separator: true }); + items.push({ + label: room.phase === 'joining' ? 'Cancel join' : 'Leave channel', + icon: _channelsActionIcon('leave'), + danger: room.phase === 'joined', + disabled: room.phase === 'parting', + onSelect: function() { + var proceed = room.phase === 'joined' && typeof rsConfirm === 'function' + ? rsConfirm({ + title: 'Leave ' + _channelsRoomDisplayName(room.name) + '?', + message: 'Live membership will end. Local history stays on this device.', + confirmText: 'Leave channel', + danger: true + }) + : Promise.resolve(true); + proceed.then(function(confirmed) { + if (!confirmed) return; + _channelsPartRoom(room.name).catch(function(err) { + if (typeof showToast === 'function') { + showToast((err && err.message) || 'Could not leave channel', 'toast-red', 3200); + } + }); + }); + } + }); + } + RS.ui.openActionMenu(trigger, items, { + title: _channelsRoomDisplayName(room.name) + }); +} + +function channelsOpenRoomDetails() { var room = _channelsSelectedRoomView(); var context = _channelsHistoryContext(room); if (!room || !context || typeof _rsBuildSheet !== 'function') return; - var built = _rsBuildSheet({ title: room.name }, function() {}); + var built = _rsBuildSheet({ title: _channelsRoomDisplayName(room.name) }, function() {}); + built.sheet.classList.add('channel-room-options-sheet'); var copy = document.createElement('p'); copy.className = 'channel-sheet-copy'; if (room.history_only) { - copy.textContent = 'This is activity retained on this device. Notification choices apply if new activity arrives for this room again.'; + copy.textContent = 'Stored on this device.'; } else if (room.phase === 'joining') { - copy.textContent = 'Canceling sends a leave request in case the hub already accepted your join. This attempt will then disappear.'; + copy.textContent = 'Waiting for the hub to confirm your join.'; } else if (room.phase === 'error') { copy.textContent = room.last_error || 'The hub did not confirm this join. Try again without reconnecting, or leave this channel.'; - } else { - copy.textContent = 'Leaving ends live membership. Activity already stored on this device remains in Local history, and the channel stays in Recents for easy rejoining.'; } - built.body.appendChild(copy); + if (copy.textContent) built.body.appendChild(copy); if (room.phase === 'joined' && (room.registered != null || room.topic || room.modes)) { var details = document.createElement('div'); details.className = 'channel-room-details'; @@ -4348,11 +4835,11 @@ function channelsOpenRoomOptions() { notificationSelect.setAttribute('aria-describedby', policyNoteId); function renderPolicyNote() { if (notificationSelect.value === 'all') { - policyNote.textContent = 'Show app attention and native alerts for new messages, actions, and notices.'; + policyNote.textContent = 'Notify for every new post.'; } else if (notificationSelect.value === 'mute') { - policyNote.textContent = 'Keep unread counts in the room list, but suppress app attention and native alerts.'; + policyNote.textContent = 'Keep unread counts without alerts.'; } else { - policyNote.textContent = 'Show app attention and native alerts only when this identity is mentioned.'; + policyNote.textContent = 'Notify only when you are mentioned.'; } } renderPolicyNote(); @@ -4445,12 +4932,23 @@ function channelsOpenRoomOptions() { leave.textContent = room.phase === 'joining' ? 'Cancel join' : (room.phase === 'parting' ? 'Leaving\u2026' : 'Leave channel'); leave.disabled = room.phase === 'parting'; leave.addEventListener('click', function() { - leave.disabled = true; - _channelsPartRoom(room.name).then(function() { - built.dismiss(); - }).catch(function(err) { - leave.disabled = false; - if (typeof showToast === 'function') showToast((err && err.message) || 'Could not leave channel', 'toast-red', 3200); + var proceed = room.phase === 'joined' && typeof rsConfirm === 'function' + ? rsConfirm({ + title: 'Leave ' + _channelsRoomDisplayName(room.name) + '?', + message: 'Live membership will end. Local history stays on this device.', + confirmText: 'Leave channel', + danger: true + }) + : Promise.resolve(true); + proceed.then(function(confirmed) { + if (!confirmed) return; + leave.disabled = true; + _channelsPartRoom(room.name).then(function() { + built.dismiss(); + }).catch(function(err) { + leave.disabled = false; + if (typeof showToast === 'function') showToast((err && err.message) || 'Could not leave channel', 'toast-red', 3200); + }); }); }); built.footer.insertBefore(share, built.footer.firstChild); @@ -4563,7 +5061,7 @@ function _channelsBindUI() { var actionEl = event.target.closest && event.target.closest('[data-channel-action]'); if (!actionEl) return; var action = actionEl.dataset.channelAction; - if (action === 'connect') channelsOpenConnectSheet(); + if (action === 'connect') channelsOpenHubSwitcher(); else if (action === 'open-owned-hub' && typeof channelHubOpenOwnHub === 'function') channelHubOpenOwnHub(); else if (action === 'join') channelsOpenJoinSheet(); else if (action === 'hub-info') channelsOpenHubOptions(); @@ -4587,6 +5085,12 @@ function _channelsBindUI() { if (join) join.addEventListener('click', function() { channelsOpenJoinSheet(); }); var hubSwitcher = _channelsEl('channel-hub-switcher-btn'); if (hubSwitcher) hubSwitcher.addEventListener('click', channelsOpenHubSwitcher); + var hubStrip = _channelsEl('channel-hub-strip'); + if (hubStrip) hubStrip.addEventListener('animationend', function(event) { + if (event.animationName === 'channelHubSignalLap') { + hubStrip.classList.remove('link-arrived'); + } + }); var hubMenu = _channelsEl('channel-hub-menu-btn'); if (hubMenu) hubMenu.addEventListener('click', channelsOpenHubOptions); var roomMenu = _channelsEl('channel-room-menu-btn'); diff --git a/dashboard/static/js/modals.js b/dashboard/static/js/modals.js index 2eab4af..c5bf597 100644 --- a/dashboard/static/js/modals.js +++ b/dashboard/static/js/modals.js @@ -2753,7 +2753,7 @@ function toggleBlePeer() { { label: '10 minutes', value: '600' }, { label: '30 minutes', value: '1800' }, { label: '60 minutes', value: '3600' }, - { label: 'Always On', value: '0' } + { label: 'Always on', value: '0' } ] }).then(function(duration) { if (duration === null) return; diff --git a/dashboard/static/js/propagation.js b/dashboard/static/js/propagation.js index 80673dc..68cda8d 100644 --- a/dashboard/static/js/propagation.js +++ b/dashboard/static/js/propagation.js @@ -315,7 +315,7 @@ function renderHostingSettings() { function renderStampSettings() { var enforce = !!propagationStatus.enforce_stamps; var cost = propagationStatus.required_stamp_cost || 0; - var label = enforce && cost > 0 ? ('Cost ' + cost) : 'Off'; + var label = enforce && cost > 0 ? ('Cost ' + cost) : 'OFF'; return '
' + 'Message stamp protection' + '
' + @@ -333,7 +333,7 @@ function renderStampSettings() { 'Required work' + 'Higher values make spam harder but slow down senders.' + '
' + - '' + + '' + '
' + ''; } @@ -403,7 +403,7 @@ function wireUpHandlers(container, mode) { } function stampCostChoice(title, current, includeOff) { - var choices = includeOff ? [{ label: 'Off', value: '0', hint: 'Do not require proof-of-work.' }] : []; + var choices = includeOff ? [{ label: 'OFF', value: '0', hint: 'Do not require proof-of-work.' }] : []; choices = choices.concat([ { label: 'Cost 8', value: '8', hint: 'Light protection.' }, { label: 'Cost 12', value: '12', hint: 'Balanced protection.' }, @@ -632,7 +632,7 @@ RS.listen('propagation_update', function(data) { if (relayBadge) { var modeLabel = propagationStatus.mode || 'auto'; if (propagationStatus.mode === 'off') { - relayBadge.textContent = 'Off'; + relayBadge.textContent = 'OFF'; relayBadge.className = 'settings-relay-badge'; } else if (propagationStatus.connected) { relayBadge.textContent = (modeLabel === 'auto' ? 'Auto: ' : '') + 'Ready'; diff --git a/dashboard/static/js/settings.js b/dashboard/static/js/settings.js index 56c5308..daefe63 100644 --- a/dashboard/static/js/settings.js +++ b/dashboard/static/js/settings.js @@ -3,6 +3,7 @@ function openSettings() { initSettingsSectionNav(); showSettingsMobileSectionIndex({ restoreFocus: false }); initHapticsToggle(); + initChannelHostingToggle(); initDeveloperModeToggle(); initWindowDecorationsToggle(); syncSettingsIdentityActions(); @@ -19,9 +20,121 @@ var _settingsUpdateCheckInFlight = false; var _settingsDeveloperModeBound = false; var _settingsDeveloperModeStorageKey = 'ratspeak-developer-mode-enabled'; var _settingsDeveloperModeEnabled = readDeveloperModePreference(); +var _settingsChannelHostingBound = false; +var _settingsChannelHostingBusy = false; +var _settingsChannelHostingEnabled = false; +var _settingsChannelHostingRequested = null; +var _settingsChannelHostingSupported = null; var RATSPEAK_RELEASE_LATEST_URL = 'https://api.github.com/repos/ratspeak/Ratspeak/releases/latest'; var RATSPEAK_RELEASES_URL = 'https://github.com/ratspeak/Ratspeak/releases'; +window.ratspeakChannelHostingEnabled = function() { + return !!_settingsChannelHostingEnabled; +}; + +function syncChannelHostingRadioState() { + var off = document.getElementById('settings-channel-hosting-off'); + var on = document.getElementById('settings-channel-hosting-on'); + var desc = document.getElementById('settings-channel-hosting-desc'); + var group = on && on.closest('.settings-radio-group'); + var displayedEnabled = _settingsChannelHostingRequested === null + ? _settingsChannelHostingEnabled + : _settingsChannelHostingRequested; + if (document.documentElement) { + document.documentElement.dataset.channelHosting = displayedEnabled ? 'on' : 'off'; + } + if (off) { + off.checked = !displayedEnabled; + off.disabled = _settingsChannelHostingBusy; + } + if (on) { + on.checked = displayedEnabled; + on.disabled = _settingsChannelHostingBusy || _settingsChannelHostingSupported === false; + } + if (group) group.setAttribute('aria-busy', _settingsChannelHostingBusy ? 'true' : 'false'); + if (desc) { + if (_settingsChannelHostingBusy) { + desc.textContent = _settingsChannelHostingRequested + ? 'Enabling hosting controls…' + : 'Stopping your hub and hiding hosting controls…'; + } else { + desc.textContent = _settingsChannelHostingSupported === false + ? 'Channel hosting is available in the desktop app.' + : 'Show hub controls in Channels and allow this device to host.'; + } + } +} + +function adoptChannelHostingFromBackend(enabled, supported) { + _settingsChannelHostingEnabled = !!enabled; + if (supported !== undefined) _settingsChannelHostingSupported = !!supported; + if (typeof channelHubOverview !== 'undefined' && channelHubOverview) { + channelHubOverview.hosting_enabled = _settingsChannelHostingEnabled; + } + syncChannelHostingRadioState(); + if (typeof channelHubRenderHome === 'function') channelHubRenderHome(channelHubOverview); +} + +function setChannelHostingEnabled(enabled) { + if (_settingsChannelHostingBusy) return; + if (enabled && _settingsChannelHostingSupported === false) return; + _settingsChannelHostingRequested = !!enabled; + _settingsChannelHostingBusy = true; + syncChannelHostingRadioState(); + + RS.invoke('set_channel_hosting_enabled', { enabled: !!enabled }).then(function(overview) { + if (overview && typeof _channelHubApplyOverview === 'function') { + _channelHubApplyOverview(overview); + } + adoptChannelHostingFromBackend( + overview && overview.hosting_enabled !== undefined + ? overview.hosting_enabled + : enabled, + overview ? overview.supported : undefined + ); + }).catch(function(error) { + if (typeof showToast === 'function') { + showToast((error && error.message) || 'Could not update channel hosting', 'toast-red', 3200); + } + return Promise.all([ + RS.invoke('api_app_settings').then(applyAppSettingsPayload).catch(function() {}), + RS.invoke('api_channel_hub').then(function(overview) { + if (!overview) return; + if (typeof _channelHubApplyOverview === 'function') { + _channelHubApplyOverview(overview); + } + adoptChannelHostingFromBackend(overview.hosting_enabled, overview.supported); + }).catch(function() {}) + ]); + }).then(function() { + _settingsChannelHostingRequested = null; + _settingsChannelHostingBusy = false; + syncChannelHostingRadioState(); + }); +} + +function initChannelHostingToggle() { + var off = document.getElementById('settings-channel-hosting-off'); + var on = document.getElementById('settings-channel-hosting-on'); + if (!off || !on) return; + syncChannelHostingRadioState(); + if (!_settingsChannelHostingBound) { + _settingsChannelHostingBound = true; + off.addEventListener('change', function() { + if (off.checked) setChannelHostingEnabled(false); + }); + on.addEventListener('change', function() { + if (on.checked) setChannelHostingEnabled(true); + }); + } + if (typeof channelHubLoad === 'function') { + channelHubLoad(false).then(function(overview) { + if (!overview) return; + adoptChannelHostingFromBackend(overview.hosting_enabled, overview.supported); + }).catch(function() {}); + } +} + function readDeveloperModePreference() { try { return window.localStorage.getItem(_settingsDeveloperModeStorageKey) === 'true'; @@ -1302,6 +1415,7 @@ function applyAppSettingsPayload(data) { var t = parseInt(data.hardware_session_timeout, 10); hwBadge.textContent = _hwLockLabel(t); hwBadge.setAttribute('data-value', t); + hwBadge.classList.toggle('settings-state-value', !t || t <= 0); } if (data.developer_mode !== undefined) { adoptDeveloperModeFromBackend(data.developer_mode); @@ -1309,10 +1423,13 @@ function applyAppSettingsPayload(data) { if (data.window_decorations !== undefined) { adoptWindowDecorationsFromBackend(data.window_decorations); } + if (data.channel_hosting_enabled !== undefined) { + adoptChannelHostingFromBackend(data.channel_hosting_enabled); + } } function _hwLockLabel(secs) { - if (!secs || secs <= 0) return 'Off'; + if (!secs || secs <= 0) return 'OFF'; if (secs % 3600 === 0) { var h = secs / 3600; return h + (h === 1 ? ' hour' : ' hours'); } if (secs % 60 === 0) return (secs / 60) + ' min'; return secs + 's'; @@ -1336,7 +1453,7 @@ function _initHwLockSetting() { title: 'Hardware Key Auto-Lock', message: 'Lock your hardware identity after this much idle time. You’ll re-enter the PIN to resume.', choices: [ - { label: 'Off', value: '0', hint: 'Only locks when you quit Ratspeak.' }, + { label: 'OFF', value: '0', hint: 'Only locks when you quit Ratspeak.' }, { label: '5 minutes', value: '300', hint: 'Tightest; frequent PIN prompts.' }, { label: '15 minutes', value: '900' }, { label: '30 minutes', value: '1800' }, @@ -1347,6 +1464,7 @@ function _initHwLockSetting() { var secs = parseInt(val, 10); badge.textContent = _hwLockLabel(secs); badge.setAttribute('data-value', secs); + badge.classList.toggle('settings-state-value', !secs || secs <= 0); RS.invoke('set_hardware_lock_timeout', { seconds: secs }).catch(function(err) { showToast((err && err.message) || 'Failed to update auto-lock', 'toast-red', 8000); }); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0c2cd24..ea35a9c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -715,6 +715,7 @@ pub fn run() { ratspeak_tauri::commands::channel_hub::channel_hub_admin_mutate, ratspeak_tauri::commands::channel_hub::channel_hub_start, ratspeak_tauri::commands::channel_hub::channel_hub_stop, + ratspeak_tauri::commands::channel_hub::set_channel_hosting_enabled, ratspeak_tauri::commands::channel_hub::channel_hub_set_config, ratspeak_tauri::commands::messaging::api_conversation, ratspeak_tauri::commands::messaging::api_lxmf_conversations,