mirror of
https://github.com/ratspeak/Ratspeak
synced 2026-08-12 18:07:35 -04:00
channels: refine hub activity and hosting UX
This commit is contained in:
parent
460aa92903
commit
68c9ae1187
25 changed files with 2757 additions and 865 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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() {},
|
||||
|
|
|
|||
|
|
@ -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() {},
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ function openSettings() {
|
|||
initSettingsSectionNav();
|
||||
showSettingsMobileSectionIndex({ restoreFocus: false });
|
||||
initHapticsToggle();
|
||||
initChannelHostingToggle();
|
||||
initDeveloperModeToggle();
|
||||
initWindowDecorationsToggle();
|
||||
syncSettingsIdentityActions();
|
||||
|
|
@ -19,9 +20,121 @@ var _settingsUpdateCheckInFlight = false;
|
|||
var _settingsDeveloperModeBound = false;
|
||||
var _settingsDeveloperModeStorageKey = 'ratspeak-developer-mode-enabled';
|
||||
var _settingsDeveloperModeEnabled = readDeveloperModePreference();
|
||||
var _settingsChannelHostingBound = false;
|
||||
var _settingsChannelHostingBusy = false;
|
||||
var _settingsChannelHostingEnabled = false;
|
||||
var _settingsChannelHostingRequested = null;
|
||||
var _settingsChannelHostingSupported = null;
|
||||
var RATSPEAK_RELEASE_LATEST_URL = 'https://api.github.com/repos/ratspeak/Ratspeak/releases/latest';
|
||||
var RATSPEAK_RELEASES_URL = 'https://github.com/ratspeak/Ratspeak/releases';
|
||||
|
||||
window.ratspeakChannelHostingEnabled = function() {
|
||||
return !!_settingsChannelHostingEnabled;
|
||||
};
|
||||
|
||||
function syncChannelHostingRadioState() {
|
||||
var off = document.getElementById('settings-channel-hosting-off');
|
||||
var on = document.getElementById('settings-channel-hosting-on');
|
||||
var desc = document.getElementById('settings-channel-hosting-desc');
|
||||
var group = on && on.closest('.settings-radio-group');
|
||||
var displayedEnabled = _settingsChannelHostingRequested === null
|
||||
? _settingsChannelHostingEnabled
|
||||
: _settingsChannelHostingRequested;
|
||||
if (document.documentElement) {
|
||||
document.documentElement.dataset.channelHosting = displayedEnabled ? 'on' : 'off';
|
||||
}
|
||||
if (off) {
|
||||
off.checked = !displayedEnabled;
|
||||
off.disabled = _settingsChannelHostingBusy;
|
||||
}
|
||||
if (on) {
|
||||
on.checked = displayedEnabled;
|
||||
on.disabled = _settingsChannelHostingBusy || _settingsChannelHostingSupported === false;
|
||||
}
|
||||
if (group) group.setAttribute('aria-busy', _settingsChannelHostingBusy ? 'true' : 'false');
|
||||
if (desc) {
|
||||
if (_settingsChannelHostingBusy) {
|
||||
desc.textContent = _settingsChannelHostingRequested
|
||||
? 'Enabling hosting controls…'
|
||||
: 'Stopping your hub and hiding hosting controls…';
|
||||
} else {
|
||||
desc.textContent = _settingsChannelHostingSupported === false
|
||||
? 'Channel hosting is available in the desktop app.'
|
||||
: 'Show hub controls in Channels and allow this device to host.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function adoptChannelHostingFromBackend(enabled, supported) {
|
||||
_settingsChannelHostingEnabled = !!enabled;
|
||||
if (supported !== undefined) _settingsChannelHostingSupported = !!supported;
|
||||
if (typeof channelHubOverview !== 'undefined' && channelHubOverview) {
|
||||
channelHubOverview.hosting_enabled = _settingsChannelHostingEnabled;
|
||||
}
|
||||
syncChannelHostingRadioState();
|
||||
if (typeof channelHubRenderHome === 'function') channelHubRenderHome(channelHubOverview);
|
||||
}
|
||||
|
||||
function setChannelHostingEnabled(enabled) {
|
||||
if (_settingsChannelHostingBusy) return;
|
||||
if (enabled && _settingsChannelHostingSupported === false) return;
|
||||
_settingsChannelHostingRequested = !!enabled;
|
||||
_settingsChannelHostingBusy = true;
|
||||
syncChannelHostingRadioState();
|
||||
|
||||
RS.invoke('set_channel_hosting_enabled', { enabled: !!enabled }).then(function(overview) {
|
||||
if (overview && typeof _channelHubApplyOverview === 'function') {
|
||||
_channelHubApplyOverview(overview);
|
||||
}
|
||||
adoptChannelHostingFromBackend(
|
||||
overview && overview.hosting_enabled !== undefined
|
||||
? overview.hosting_enabled
|
||||
: enabled,
|
||||
overview ? overview.supported : undefined
|
||||
);
|
||||
}).catch(function(error) {
|
||||
if (typeof showToast === 'function') {
|
||||
showToast((error && error.message) || 'Could not update channel hosting', 'toast-red', 3200);
|
||||
}
|
||||
return Promise.all([
|
||||
RS.invoke('api_app_settings').then(applyAppSettingsPayload).catch(function() {}),
|
||||
RS.invoke('api_channel_hub').then(function(overview) {
|
||||
if (!overview) return;
|
||||
if (typeof _channelHubApplyOverview === 'function') {
|
||||
_channelHubApplyOverview(overview);
|
||||
}
|
||||
adoptChannelHostingFromBackend(overview.hosting_enabled, overview.supported);
|
||||
}).catch(function() {})
|
||||
]);
|
||||
}).then(function() {
|
||||
_settingsChannelHostingRequested = null;
|
||||
_settingsChannelHostingBusy = false;
|
||||
syncChannelHostingRadioState();
|
||||
});
|
||||
}
|
||||
|
||||
function initChannelHostingToggle() {
|
||||
var off = document.getElementById('settings-channel-hosting-off');
|
||||
var on = document.getElementById('settings-channel-hosting-on');
|
||||
if (!off || !on) return;
|
||||
syncChannelHostingRadioState();
|
||||
if (!_settingsChannelHostingBound) {
|
||||
_settingsChannelHostingBound = true;
|
||||
off.addEventListener('change', function() {
|
||||
if (off.checked) setChannelHostingEnabled(false);
|
||||
});
|
||||
on.addEventListener('change', function() {
|
||||
if (on.checked) setChannelHostingEnabled(true);
|
||||
});
|
||||
}
|
||||
if (typeof channelHubLoad === 'function') {
|
||||
channelHubLoad(false).then(function(overview) {
|
||||
if (!overview) return;
|
||||
adoptChannelHostingFromBackend(overview.hosting_enabled, overview.supported);
|
||||
}).catch(function() {});
|
||||
}
|
||||
}
|
||||
|
||||
function readDeveloperModePreference() {
|
||||
try {
|
||||
return window.localStorage.getItem(_settingsDeveloperModeStorageKey) === 'true';
|
||||
|
|
@ -1302,6 +1415,7 @@ function applyAppSettingsPayload(data) {
|
|||
var t = parseInt(data.hardware_session_timeout, 10);
|
||||
hwBadge.textContent = _hwLockLabel(t);
|
||||
hwBadge.setAttribute('data-value', t);
|
||||
hwBadge.classList.toggle('settings-state-value', !t || t <= 0);
|
||||
}
|
||||
if (data.developer_mode !== undefined) {
|
||||
adoptDeveloperModeFromBackend(data.developer_mode);
|
||||
|
|
@ -1309,10 +1423,13 @@ function applyAppSettingsPayload(data) {
|
|||
if (data.window_decorations !== undefined) {
|
||||
adoptWindowDecorationsFromBackend(data.window_decorations);
|
||||
}
|
||||
if (data.channel_hosting_enabled !== undefined) {
|
||||
adoptChannelHostingFromBackend(data.channel_hosting_enabled);
|
||||
}
|
||||
}
|
||||
|
||||
function _hwLockLabel(secs) {
|
||||
if (!secs || secs <= 0) return 'Off';
|
||||
if (!secs || secs <= 0) return 'OFF';
|
||||
if (secs % 3600 === 0) { var h = secs / 3600; return h + (h === 1 ? ' hour' : ' hours'); }
|
||||
if (secs % 60 === 0) return (secs / 60) + ' min';
|
||||
return secs + 's';
|
||||
|
|
@ -1336,7 +1453,7 @@ function _initHwLockSetting() {
|
|||
title: 'Hardware Key Auto-Lock',
|
||||
message: 'Lock your hardware identity after this much idle time. You’ll re-enter the PIN to resume.',
|
||||
choices: [
|
||||
{ label: 'Off', value: '0', hint: 'Only locks when you quit Ratspeak.' },
|
||||
{ label: 'OFF', value: '0', hint: 'Only locks when you quit Ratspeak.' },
|
||||
{ label: '5 minutes', value: '300', hint: 'Tightest; frequent PIN prompts.' },
|
||||
{ label: '15 minutes', value: '900' },
|
||||
{ label: '30 minutes', value: '1800' },
|
||||
|
|
@ -1347,6 +1464,7 @@ function _initHwLockSetting() {
|
|||
var secs = parseInt(val, 10);
|
||||
badge.textContent = _hwLockLabel(secs);
|
||||
badge.setAttribute('data-value', secs);
|
||||
badge.classList.toggle('settings-state-value', !secs || secs <= 0);
|
||||
RS.invoke('set_hardware_lock_timeout', { seconds: secs }).catch(function(err) {
|
||||
showToast((err && err.message) || 'Failed to update auto-lock', 'toast-red', 8000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue