channels: refine hub activity and hosting UX

This commit is contained in:
DeFiDude 2026-08-02 00:59:13 -06:00
parent 460aa92903
commit 68c9ae1187
25 changed files with 2757 additions and 865 deletions

View file

@ -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::<u64>().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::<u64>().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();

View file

@ -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,

View file

@ -909,6 +909,17 @@ pub async fn start_channel_hub_service(state: &Arc<AppState>) -> 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<AppState>) -> 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<AppState>, 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")]

View file

@ -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<String>,
#[serde(default)]
pub announce_interval_secs: Option<u64>,
/// 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<bool>,
/// Accept inbound resource notices. Off by default.
#[serde(default)]
pub resource_accept: Option<bool>,
pub recent_activity_retention_secs: Option<u64>,
}
/// 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<AppState>>) -> AppResult<ChannelHub
.map_err(AppError::database_unavailable)
}
async fn load_hosting_enabled(state: &State<'_, Arc<AppState>>) -> AppResult<bool> {
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<AppState>>) -> 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<AppState>>,
settings: &ChannelHubSettings,
@ -186,6 +207,19 @@ async fn persist_settings(
.map_err(AppError::database_unavailable)
}
async fn shutdown_channel_hub(state: &State<'_, Arc<AppState>>) -> 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<AppState>>) -> 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<ChannelHubAdmi
async fn overview(
state: &State<'_, Arc<AppState>>,
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<AppState>>) -> AppResult<ChannelHubOverview> {
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<ChannelHubAdminSnapshot> {
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<AppState>>) -> AppResult<Cha
ensure_supported()?;
let _control = state.channel_hub_control_lock.lock().await;
let mut settings = load_settings(&state).await?;
ensure_hosting_enabled(&state).await?;
settings.enabled = true;
persist_settings(&state, &settings).await?;
let app_state: Arc<AppState> = state.inner().clone();
@ -418,7 +460,7 @@ pub async fn channel_hub_start(state: State<'_, Arc<AppState>>) -> AppResult<Cha
"Channel hub requires an active network session",
));
}
Ok(overview(&state, settings).await)
Ok(overview(&state, settings, true).await)
}
#[tauri::command]
@ -426,17 +468,59 @@ pub async fn channel_hub_stop(state: State<'_, Arc<AppState>>) -> AppResult<Chan
ensure_supported()?;
let _control = state.channel_hub_control_lock.lock().await;
let mut settings = load_settings(&state).await?;
let hosting_enabled = load_hosting_enabled(&state).await?;
settings.enabled = false;
persist_settings(&state, &settings).await?;
if let Some(hub) = state.channel_hub_handle() {
if !hub.shutdown().await {
return Err(AppError::service_unavailable(
"Channel hub is still shutting down",
));
}
state.take_channel_hub();
shutdown_channel_hub(&state).await?;
Ok(overview(&state, settings, hosting_enabled).await)
}
/// Master opt-in for hub hosting. Disabling it also stops the live service so
/// the UI can never hide a hub that is still relaying traffic. Hub identity,
/// configuration, and channel policy remain available for a later opt-in.
#[tauri::command]
pub async fn set_channel_hosting_enabled(
state: State<'_, Arc<AppState>>,
enabled: bool,
) -> AppResult<ChannelHubOverview> {
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<ChannelHubOverview> {
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 {

View file

@ -953,18 +953,26 @@ pub async fn set_auto_announce(state: State<'_, Arc<AppState>>, interval: u64) -
#[tauri::command]
pub async fn api_app_settings(state: State<'_, Arc<AppState>>) -> AppResult<Value> {
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::<u64>().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::<u64>().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<AppState>>) -> AppResult<Valu
"hardware_session_timeout": hw_timeout,
"developer_mode": developer_mode,
"window_decorations": window_decorations,
"channel_hosting_enabled": channel_hosting_enabled,
}))
}

View file

@ -149,13 +149,14 @@ fn channels_keep_hubs_live_only_and_wire_bounded_local_history_across_the_produc
assert!(index.contains("Available hubs"));
assert!(index.contains("id=\"channel-hub-switcher-btn\""));
assert!(index.contains("aria-haspopup=\"dialog\""));
assert!(!index.contains("channel-live-beacon"));
assert!(index.contains("id=\"channel-owned-hub\""));
assert!(index.contains("id=\"channel-owned-hub-manage\""));
assert!(!index.contains("channels-refresh-btn"));
assert!(index.contains("id=\"channel-members-back\""));
assert!(!index.contains("Messages are not saved and disappear when this session ends."));
assert!(!index.contains("id=\"channel-session-banner\""));
assert!(channels_js.contains("hub relays and can read channel messages"));
assert!(!channels_js.contains("hub relays and can read channel messages"));
assert!(channels_js.contains("Ratspeak saves only identity-sealed ciphertext"));
assert!(channels_js.contains("only after the hub confirms membership"));
assert!(channels_js.contains("has_stored_join_key"));
@ -173,11 +174,12 @@ fn channels_keep_hubs_live_only_and_wire_bounded_local_history_across_the_produc
assert!(channels_js.contains("function _channelsHubConnectMode"));
assert!(channels_js.contains("function channelsOpenHubSwitcher"));
assert!(channels_js.contains("hubSwitcher.addEventListener('click', channelsOpenHubSwitcher)"));
assert!(channels_js.contains("Ratspeak keeps one live hub at a time"));
assert!(channels_js.contains("Saved channels and local history stay on this device"));
assert!(channels_js.contains("One hub can be live at a time"));
assert!(channels_js.contains("history stays on this device"));
assert!(channels_js.contains("list.setAttribute('aria-live', 'polite')"));
assert!(channels_js.contains("list.setAttribute('aria-busy', 'true')"));
assert!(channels_js.contains("titleElement.textContent = 'Switch channel hub'"));
assert!(channels_js.contains("? 'Switch hub'"));
assert!(channels_js.contains(": 'Choose a hub'"));
assert!(channels_js.contains("switching: connectMode.kind === 'switch'"));
assert!(channels_js.contains("'Could not switch channel hubs.'"));
assert!(channels_js.contains("openedEpoch === _channelsHistoryEpoch"));
@ -199,8 +201,11 @@ fn channels_keep_hubs_live_only_and_wire_bounded_local_history_across_the_produc
assert!(channels_css.contains(".channel-hub-row-mark"));
assert!(channels_css.contains(".channel-hub-row-distance"));
assert!(channels_css.contains(".channel-hub-switcher-btn"));
assert!(channels_css.contains("@keyframes channelHubSignalLap"));
assert!(channels_css.contains(".channel-hub-strip.link-arrived::before"));
assert!(channels_css.contains(".channel-hub-switcher-list .channel-hub-row.current"));
assert!(channels_css.contains(".channel-hub-switch-impact"));
assert!(!channels_css.contains(".channel-connection-trust"));
assert!(channels_js.contains("function _channelsBuildHubNotice"));
assert!(channels_js.contains("function _channelsBuildHubGreeting"));
assert!(channels_js.contains("function _channelsGroupPresenceEvents"));
@ -222,7 +227,7 @@ fn channels_keep_hubs_live_only_and_wire_bounded_local_history_across_the_produc
assert!(channels_js.contains("function channelsOpenNotificationRoute"));
assert!(channels_js.contains("api_channel_room_index"));
assert!(channels_js.contains("latest_recorded_at_ms"));
assert!(channels_js.contains("Local timeline"));
assert!(!channels_js.contains("Local timeline"));
assert!(channels_js.contains("Load earlier"));
assert!(!channels_js.contains("localStorage.setItem"));
assert!(channels_js.contains("function _channelsRenderMemberDetail"));
@ -249,6 +254,8 @@ fn channels_keep_hubs_live_only_and_wire_bounded_local_history_across_the_produc
assert!(channels_css.contains(".channel-hub-profile-capabilities"));
assert!(channels_css.contains(".channel-hub-greeting-delivery"));
assert!(channels_css.contains(".channel-presence-summary"));
assert!(!channels_css.contains(".channel-presence-event::before"));
assert!(!channels_css.contains(".channel-presence-summary::before"));
assert!(channels_css.contains(".channel-history-rail"));
assert!(channels_css.contains(".channel-day-separator"));
assert!(channels_css.contains(".channel-member-detail-fields"));
@ -306,7 +313,10 @@ fn channels_keep_hubs_live_only_and_wire_bounded_local_history_across_the_produc
assert!(runtime.contains("RECONNECT_MAX_DELAY"));
assert!(runtime.contains("RECONNECT_STABLE_RESET"));
assert!(runtime.contains("prepare_auto_rejoin"));
assert!(runtime.contains("\"Reconnected to hub\""));
assert!(!runtime.contains("\"Reconnected to hub\""));
assert!(runtime.contains("nickname_only_join"));
assert!(channels_js.contains("function _channelsIsConnectionLifecycleItem"));
assert!(channels_js.contains("item.text === 'Reconnected to hub'"));
assert!(runtime.contains("ROOM_SECRET_SEAL_SCHEME"));
assert!(runtime.contains(".encrypt(&plaintext, None)"));
assert!(runtime.contains("complete_pending_join_secret"));
@ -695,6 +705,7 @@ fn channel_hub_persists_policy_only_and_gates_room_creation() {
"channel_hub::channel_hub_admin_mutate",
"channel_hub::channel_hub_start",
"channel_hub::channel_hub_stop",
"channel_hub::set_channel_hosting_enabled",
"channel_hub::channel_hub_set_config",
] {
assert!(
@ -709,13 +720,14 @@ fn channel_hub_persists_policy_only_and_gates_room_creation() {
assert!(hub.contains("target_os = \"android\", target_os = \"ios\""));
assert_eq!(
commands.matches("ensure_supported()?;").count(),
5,
6,
"every hosting-specific hub command must reject mobile hosting"
);
assert!(runtime.contains("channel_hub_hosting_supported()"));
// Desktop hosting is discoverable from the existing Channels add action,
// but stays separate from client session state and obeys backend support.
// Desktop hosting becomes discoverable from the Channels add action after
// explicit Settings opt-in, stays separate from client session state, and
// obeys backend support.
assert!(index.contains("/static/js/channel_hub.js"));
assert!(hub_ui.contains("if (!overview || !overview.supported)"));
assert!(hub_ui.contains("RS.invoke('api_channel_hub')"));
@ -725,6 +737,10 @@ fn channel_hub_persists_policy_only_and_gates_room_creation() {
assert!(hub_ui.contains("RS.invoke('channel_hub_set_config'"));
assert!(hub_ui.contains("RS.listen('channel_hub_snapshot'"));
assert!(hub_ui.contains("function channelHubRenderHome"));
assert!(hub_ui.contains("overview.supported && _channelHubHostingEnabled(overview)"));
assert!(
hub_ui.contains("_channelHubHostingEnabled(overview) && _channelHubHasOwnedHub(overview)")
);
assert!(hub_ui.contains("function channelHubOpenOwnHub"));
assert!(hub_ui.contains("overview.created"));
assert!(hub_ui.contains("Some channel changes are still waiting to be saved."));
@ -793,8 +809,32 @@ fn channel_hub_persists_policy_only_and_gates_room_creation() {
assert!(admin_renderers.contains("roomInput.value.trim().toLowerCase();"));
assert!(admin_renderers.contains("cancel.textContent = 'Close and review'"));
assert!(!admin_renderers.contains("action: '/"));
assert!(hub_ui.contains("Recent context, not a transcript"));
assert!(hub_ui.contains("Memory-only and incomplete"));
assert!(!hub_ui.contains("Recent context, not a transcript"));
assert!(!hub_ui.contains("Memory-only and incomplete"));
assert!(!hub_ui.contains("Policy is durable. Conversation traffic is not."));
assert!(hub_ui.contains("Recent activity is off"));
assert!(hub_ui.contains("recent_activity_retention_secs"));
assert!(hub_ui.contains("[86400, '24 hours']"));
assert!(hub_ui.contains("At startup and on this schedule, so nearby people can find it"));
for interval in [
"[900, 'Every 15 minutes']",
"[1800, 'Every 30 minutes']",
"[3600, 'Every hour']",
"[43200, 'Every 12 hours']",
"[86400, 'Every 24 hours']",
] {
assert!(hub_ui.contains(interval));
}
for removed in [
"Operating limits",
"Large welcome messages",
"Large room notices",
"[0, 'When started']",
"[300, 'Every 5 min']",
"[21600, 'Every 6 hours']",
] {
assert!(!hub_ui.contains(removed));
}
assert!(hub_ui.contains("var _channelHubIdentityGeneration = 0;"));
assert!(hub_ui.contains("identityGeneration !== _channelHubIdentityGeneration"));
assert!(hub_ui.contains("_channelHubIdentityGeneration += 1;"));
@ -809,6 +849,7 @@ fn channel_hub_persists_policy_only_and_gates_room_creation() {
assert!(commands.contains("pub destination_hash: Option<String>"));
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#"<html lang="en" data-channel-hosting="off">"#));
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</button>"#));
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();

View file

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-channel-hosting="off">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-visual">
@ -11,7 +11,7 @@
injects pixel values via evaluateJavascript; desktop falls back to 0. -->
<title>Ratspeak - Dashboard</title>
<link rel="stylesheet" href="/static/fonts/fonts.css">
<link rel="stylesheet" href="/static/style.css?v=1.0.29">
<link rel="stylesheet" href="/static/style.css?v=1.0.37">
</head>
<body class="checking-setup">
@ -557,14 +557,13 @@
<div class="channels-sidebar-header">
<h2>Channels</h2>
<button class="nr-btn nr-btn-ghost channels-icon-btn" id="channels-connect-btn" type="button" title="Add channels" aria-label="Join or host channels">
<button class="nr-btn nr-btn-ghost channels-icon-btn" id="channels-connect-btn" type="button" title="Add channels" aria-label="Add channels">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div class="channel-hub-strip" id="channel-hub-strip" data-phase="offline">
<button class="channel-hub-switcher-btn" id="channel-hub-switcher-btn" type="button" aria-haspopup="dialog" aria-label="Choose a channel hub">
<span class="channel-live-beacon" aria-hidden="true"></span>
<span class="channel-hub-strip-copy">
<strong id="channel-hub-strip-title">Not connected</strong>
<span id="channel-hub-strip-meta">Choose a hub to begin</span>
@ -580,7 +579,7 @@
<section class="channel-owned-hub" id="channel-owned-hub" aria-labelledby="channel-owned-hub-label" hidden>
<div class="channel-owned-hub-heading">
<span class="channels-section-label" id="channel-owned-hub-label">Your hub</span>
<span class="channels-section-label" id="channel-owned-hub-label">Hosting</span>
</div>
<div class="channel-owned-hub-card" id="channel-owned-hub-card" data-tone="offline">
<button class="channel-owned-hub-open" id="channel-owned-hub-open" type="button">
@ -600,7 +599,7 @@
<div class="channels-list-toolbar">
<span class="channels-section-label" id="channels-list-label">Available hubs</span>
<button class="nr-btn nr-btn-ghost nr-btn-sm channels-text-btn" id="channels-join-btn" type="button" hidden>Join</button>
<button class="nr-btn nr-btn-ghost nr-btn-sm channels-text-btn" id="channels-join-btn" type="button" aria-label="Join a channel" hidden>Add</button>
</div>
<div class="channels-list" id="channels-list" aria-live="polite">
<div class="channels-list-empty">
@ -667,7 +666,7 @@
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="channel-members-note" id="channel-members-note">The hub may provide only part of the member list.</div>
<div class="channel-members-note" id="channel-members-note" title="The hub may provide only part of the member list.">Partial list</div>
<div class="channel-members-list" id="channel-members-list"></div>
</aside>
</div>
@ -1128,6 +1127,10 @@
<span class="settings-nav-label">General</span>
<span class="settings-nav-desc">Theme, vibration, notifications, and blocks</span>
</button>
<button class="settings-nav-item" type="button" data-settings-panel="panel-settings-channels" data-settings-title="Channels" data-settings-desc="Hosting and channel preferences.">
<span class="settings-nav-label">Channels</span>
<span class="settings-nav-desc">Hosting and channel preferences</span>
</button>
<button class="settings-nav-item" type="button" data-settings-panel="panel-settings-identity" data-settings-title="Identity" data-settings-desc="Active identity, status, backup, and recovery.">
<span class="settings-nav-label">Identity</span>
<span class="settings-nav-desc">Active identity, status, backup, and recovery</span>
@ -1231,6 +1234,28 @@
</div>
</div>
<div class="panel settings-panel" id="panel-settings-channels">
<div class="panel-header">Channels</div>
<div class="panel-body">
<div class="settings-row">
<div class="settings-row-info">
<span class="settings-row-label">Channel hosting</span>
<span class="settings-row-desc" id="settings-channel-hosting-desc" aria-live="polite">Show hub controls in Channels and allow this device to host.</span>
</div>
<div class="settings-radio-group" role="radiogroup" aria-label="Channel hosting">
<label class="settings-radio-option">
<input type="radio" name="settings-channel-hosting" id="settings-channel-hosting-off" value="off" checked>
<span>Off</span>
</label>
<label class="settings-radio-option">
<input type="radio" name="settings-channel-hosting" id="settings-channel-hosting-on" value="on">
<span>On</span>
</label>
</div>
</div>
</div>
</div>
<div class="panel settings-panel" id="panel-settings-identity">
<div class="panel-header">Identity</div>
<div class="panel-body">
@ -1268,9 +1293,9 @@
<div class="settings-row" id="hw-lock-row" style="display:none;border-bottom:none;">
<div class="settings-row-info">
<span class="settings-row-label">Hardware Key Auto-Lock</span>
<span class="settings-row-desc">Lock a YubiKey identity after inactivity; PIN required to resume. Off relies on lock-on-quit. Applies on next unlock.</span>
<span class="settings-row-desc">Lock a YubiKey identity after inactivity; PIN required to resume. When disabled, it locks only when you quit. Applies on next unlock.</span>
</div>
<button class="selector-badge" id="hw-lock-timeout-select">Off</button>
<button class="selector-badge settings-state-value" id="hw-lock-timeout-select">OFF</button>
</div>
</div>
</div>
@ -1299,7 +1324,7 @@
<span class="settings-row-label">Transport Mode</span>
<span class="settings-row-desc">Relay packets for other nodes on the network</span>
</div>
<button class="selector-badge" id="transport-mode-select">OFF</button>
<button class="selector-badge settings-state-value" id="transport-mode-select">OFF</button>
</div>
<div class="settings-row">
<div class="settings-row-info">
@ -1846,17 +1871,17 @@
<script src="/static/js/events.js"></script>
<script src="/static/js/activity.js"></script>
<script src="/static/js/health.js"></script>
<script src="/static/js/modals.js"></script>
<script src="/static/js/modals.js?v=1.0.1"></script>
<script src="/static/js/emoji_data.js"></script>
<script src="/static/js/emoji_picker.js"></script>
<script src="/static/js/voice_ringtones.js"></script>
<script src="/static/js/vendor/jsQR.js"></script>
<script src="/static/js/contact_card.js"></script>
<script src="/static/js/lxmf.js"></script>
<script src="/static/js/channels.js?v=1.0.34"></script>
<script src="/static/js/channel_hub.js?v=1.0.29"></script>
<script src="/static/js/propagation.js"></script>
<script src="/static/js/settings.js"></script>
<script src="/static/js/channels.js?v=1.0.39"></script>
<script src="/static/js/channel_hub.js?v=1.0.35"></script>
<script src="/static/js/propagation.js?v=1.0.1"></script>
<script src="/static/js/settings.js?v=1.0.3"></script>
<script src="/static/js/identity.js"></script>
<script src="/static/js/confetti.js"></script>
<script src="/static/js/games_tab.js"></script>

View file

@ -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);

View file

@ -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');

View file

@ -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,

View file

@ -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');

View file

@ -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');

View file

@ -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');

View file

@ -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() {},

View file

@ -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() {},

View file

@ -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%;

File diff suppressed because it is too large Load diff

View file

@ -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;

View file

@ -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;

View file

@ -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 '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 16.5a6 6 0 0 1 0-9"/><path d="M15.5 7.5a6 6 0 0 1 0 9"/><circle cx="12" cy="12" r="1.7" fill="currentColor" stroke="none"/></svg>';
}
if (kind === 'link') {
return '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M10.5 13.5a4.5 4.5 0 0 0 6.4.1l2.1-2.1a4.5 4.5 0 0 0-6.4-6.4l-1.2 1.2"/><path d="M13.5 10.5a4.5 4.5 0 0 0-6.4-.1L5 12.5a4.5 4.5 0 0 0 6.4 6.4l1.2-1.2"/></svg>';
}
return '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/><path d="M8.7 8.7a4.7 4.7 0 0 0 0 6.6M15.3 15.3a4.7 4.7 0 0 0 0-6.6"/><path d="M5.3 5.3a9.5 9.5 0 0 0 0 13.4M18.7 18.7a9.5 9.5 0 0 0 0-13.4"/></svg>';
}
@ -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;
});

File diff suppressed because it is too large Load diff

View file

@ -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;

View file

@ -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 '<details class="relay-advanced-block relay-details">' +
'<summary>Message stamp protection</summary>' +
'<div class="settings-row propagation-settings-row">' +
@ -333,7 +333,7 @@ function renderStampSettings() {
'<span class="settings-row-label">Required work</span>' +
'<span class="settings-row-desc">Higher values make spam harder but slow down senders.</span>' +
'</div>' +
'<button class="selector-badge" id="stamp-cost-btn">' + escapeHtml(label) + '</button>' +
'<button class="selector-badge' + (label === 'OFF' ? ' settings-state-value' : '') + '" id="stamp-cost-btn">' + escapeHtml(label) + '</button>' +
'</div>' +
'</details>';
}
@ -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';

View file

@ -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. Youll 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);
});

View file

@ -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,