diff --git a/android/src/app.rs b/android/src/app.rs index af408eb..85304c4 100644 --- a/android/src/app.rs +++ b/android/src/app.rs @@ -1,5 +1,7 @@ //! Sleek eframe app — desktop + Android. +use std::time::{Duration, Instant}; + use eframe::egui::{self, Align, Layout, RichText, ScrollArea, Stroke, Vec2}; use freeq_sdk::event::Event; use vidya::{apply, body, button, dim_label, reserve_system_chrome, title, Mode, Theme}; @@ -16,8 +18,8 @@ use crate::state::{ LinkMeta, MediaFetch, Route, Tab, }; use crate::ui::{ - self, image_lightbox_overlay, ChatAction, ChatsAction, ConnectAction, DiscoverAction, - SettingsAction, + self, image_lightbox_overlay, open_verification_url, policy_gate_overlay, ChatAction, + ChatsAction, ConnectAction, DiscoverAction, PolicyGateAction, SettingsAction, }; /// Phone-shaped default for local desktop; on Codespaces / noVNC fill the @@ -712,6 +714,27 @@ impl SleekApp { .apply_results(request_id, query, actors); } } + NetEvent::PolicyFetched { + request_id, + check, + rules, + } => { + if self.state.policy_gate.fetch_id != request_id { + return; + } + self.state.policy_gate.loading = false; + self.state.policy_gate.accepting = false; + match check { + Ok(c) => self.state.policy_gate.check = Some(c), + Err(e) => { + self.state.policy_gate.error = Some(e); + self.state.policy_gate.check = None; + } + } + if let Ok(text) = rules { + self.state.policy_gate.rules = Some(text); + } + } NetEvent::Sdk(event) => self.handle_sdk_event(event), } } @@ -1082,6 +1105,9 @@ impl SleekApp { self.state.status_line = text.clone(); let buf = self.state.ensure_buffer("*status"); buf.append(ChatMessage::system(text.clone())); + if let Some(channel) = crate::policy::parse_policy_accepted(&text) { + self.on_policy_accepted(&channel); + } // freeq 477 (guest / policy gate) and other JOIN denials arrive // as ServerNotice: "#policytest This channel requires authentication — …" if let Some((channel, reason)) = parse_join_denial(&text) { @@ -1780,6 +1806,14 @@ impl SleekApp { #[cfg(not(target_os = "android"))] fn poll_oauth_deep_link(&mut self, _ctx: &egui::Context) {} + fn poll_policy_gate_refresh(&mut self) { + if let Some(deadline) = self.state.policy_gate.refresh_after { + if std::time::Instant::now() >= deadline { + self.state.policy_gate.refresh_after = None; + self.refresh_policy_gate(); + } + } + } fn do_reconnect_session(&mut self) { let Some(broker_token) = self.state.broker_token.clone() else { @@ -1848,6 +1882,10 @@ impl SleekApp { buf.join_error = Some(reason.clone()); buf.append(ChatMessage::system(reason.clone())); } + // Authenticated users blocked by ACCEPT rules — open the join-gate modal. + if self.state.did.is_some() && crate::policy::is_policy_acceptance_denial(&reason) { + self.open_policy_gate(&ch); + } self.state.error = Some(format!("{ch}: {reason}")); self.state.show_toast(format!("{ch}: {reason}")); // Keep the chat open so the error empty-state is visible. @@ -1857,6 +1895,115 @@ impl SleekApp { self.state.tab = Tab::Chats; } + fn open_policy_gate(&mut self, channel: &str) { + let did = match self.state.did.as_deref() { + Some(d) if !d.is_empty() => d.to_string(), + _ => return, + }; + let request_id = self.state.open_policy_gate(channel); + let channel = self + .state + .policy_gate + .channel + .clone() + .unwrap_or_else(|| AppState::normalize_channel(channel)); + let api_base = api_base_for_server(&self.state.server); + self.net.send(NetCmd::PolicyFetch { + request_id, + channel, + api_base, + did, + }); + } + + fn refresh_policy_gate(&mut self) { + let channel = match self.state.policy_gate.channel.clone() { + Some(c) => c, + None => return, + }; + let did = match self.state.did.as_deref() { + Some(d) if !d.is_empty() => d.to_string(), + _ => return, + }; + self.state.policy_gate.loading = true; + self.state.policy_gate.error = None; + self.state.policy_gate.fetch_id = self.state.policy_gate.fetch_id.wrapping_add(1); + let request_id = self.state.policy_gate.fetch_id; + let api_base = api_base_for_server(&self.state.server); + self.net.send(NetCmd::PolicyFetch { + request_id, + channel, + api_base, + did, + }); + } + + fn do_policy_accept(&mut self, channel: &str) { + let ch = AppState::normalize_channel(channel); + self.state.policy_gate.accepting = true; + self.net + .send(NetCmd::Raw(format!("POLICY {ch} ACCEPT\r\n"))); + } + + fn do_policy_join(&mut self, channel: &str) { + let ch = AppState::normalize_channel(channel); + self.state.policy_gate.joining = true; + self.net + .send(NetCmd::Raw(format!("POLICY {ch} ACCEPT\r\n"))); + // Brief delay so the server can issue the attestation before JOIN. + let ch_join = ch.clone(); + if let Some(buf) = self.state.channels.get_mut(&ch) { + buf.join_error = None; + buf.join_pending = true; + } + self.net.send(NetCmd::Join(ch_join)); + self.state.policy_gate.close(); + } + + fn on_policy_accepted(&mut self, channel: &str) { + let ch = AppState::normalize_channel(channel); + if self.state.policy_gate.channel.as_deref() == Some(ch.as_str()) { + self.state.policy_gate.accepting = false; + self.refresh_policy_gate(); + } + if let Some(buf) = self.state.channels.get_mut(&ch) { + if buf.join_error.is_some() { + buf.join_error = None; + } + } + self.state.show_toast(format!("{ch}: policy accepted — you may join")); + } + + fn handle_policy_gate_action(&mut self, ctx: &egui::Context, act: PolicyGateAction) { + match act { + PolicyGateAction::None => {} + PolicyGateAction::Close => { + self.state.close_policy_gate(); + } + PolicyGateAction::Refresh => { + self.refresh_policy_gate(); + } + PolicyGateAction::AcceptRules => { + if let Some(ch) = self.state.policy_gate.channel.clone() { + self.do_policy_accept(&ch); + self.state.policy_gate.refresh_after = + Some(Instant::now() + Duration::from_millis(900)); + } + } + PolicyGateAction::VerifyExternal { url } => { + let api_base = api_base_for_server(&self.state.server); + if let Some(did) = self.state.did.clone() { + open_verification_url(ctx, &api_base, &url, &did); + } + } + PolicyGateAction::Join => { + if let Some(ch) = self.state.policy_gate.channel.clone() { + self.do_policy_join(&ch); + } + } + } + } + fn do_send(&mut self, target: String, text: String) { // Media attached: upload then PRIVMSG the freeq media URL. // Slash commands don't apply when attaching media (caption is plain text). @@ -2272,6 +2419,7 @@ impl eframe::App for SleekApp { ctx.request_repaint_after(std::time::Duration::from_millis(200)); } self.poll_oauth_deep_link(ctx); + self.poll_policy_gate_refresh(); let th = self.theme(); let p = &th.palette; @@ -2570,6 +2718,9 @@ impl eframe::App for SleekApp { }); } } + ChatAction::OpenPolicyGate(channel) => { + self.open_policy_gate(&channel); + } ChatAction::AvStart(channel) => { self.do_av_start(channel); } @@ -2706,6 +2857,9 @@ impl eframe::App for SleekApp { // Full-screen image lightbox (above chat / tabs). image_lightbox_overlay(ctx, &th, &mut self.state); + + let gate_act = policy_gate_overlay(ctx, &th, &mut self.state); + self.handle_policy_gate_action(ctx, gate_act); } } diff --git a/android/src/lib.rs b/android/src/lib.rs index 9c8791f..8c5335d 100644 --- a/android/src/lib.rs +++ b/android/src/lib.rs @@ -7,6 +7,7 @@ mod av_media; mod bsky; mod clipboard; mod net; +mod policy; mod preview; mod reconnect; mod slash; diff --git a/android/src/net.rs b/android/src/net.rs index 4cdfc8b..47a878a 100644 --- a/android/src/net.rs +++ b/android/src/net.rs @@ -152,6 +152,13 @@ pub enum NetCmd { request_id: u64, query: String, }, + /// Fetch policy check + rules for the join-gate modal. + PolicyFetch { + request_id: u64, + channel: String, + api_base: String, + did: String, + }, Quit, } @@ -230,6 +237,12 @@ pub enum NetEvent { /// `None` on success; set when the AppView request failed. error: Option, }, + /// Policy check + rules for the join-gate modal. + PolicyFetched { + request_id: u64, + check: Result, + rules: Result, + }, } /// Payload after a successful image upload + PRIVMSG. @@ -929,6 +942,23 @@ async fn apply_cmd( } }); } + NetCmd::PolicyFetch { + request_id, + channel, + api_base, + did, + } => { + let tx = event_tx.clone(); + tokio::spawn(async move { + let check = crate::policy::fetch_check(&api_base, &channel, &did).await; + let rules = crate::policy::fetch_rules(&api_base, &channel).await; + let _ = tx.send(NetEvent::PolicyFetched { + request_id, + check, + rules, + }); + }); + } NetCmd::Quit => { media.stop().await; if let Some(h) = handle.take() { diff --git a/android/src/policy.rs b/android/src/policy.rs new file mode 100644 index 0000000..2536d9f --- /dev/null +++ b/android/src/policy.rs @@ -0,0 +1,265 @@ +//! Channel policy HTTP types and helpers (freeq `/api/v1/policy/…`). + +use serde::{Deserialize, Serialize}; + +/// Result of `POST /api/v1/policy/{channel}/check`. +#[derive(Debug, Clone, Deserialize)] +pub struct PolicyCheck { + pub channel: String, + pub can_join: bool, + pub status: String, + pub requirements: Vec, + #[serde(default)] + pub role: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RequirementStatus { + pub requirement_type: String, + pub description: String, + pub satisfied: bool, + #[serde(default)] + pub action: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RequirementAction { + pub action_type: String, + #[serde(default)] + pub url: Option, + pub label: String, + #[serde(default)] + pub accept_hash: Option, +} + +#[derive(Debug, Deserialize)] +struct RulesResponse { + #[allow(dead_code)] + channel: String, + #[allow(dead_code)] + hash: String, + text: String, +} + +#[derive(Debug, Serialize)] +struct CheckRequest<'a> { + did: &'a str, +} + +/// True when a join denial is the authenticated-user policy gate (not guest auth). +pub fn is_policy_acceptance_denial(reason: &str) -> bool { + let lower = reason.to_ascii_lowercase(); + lower.contains("policy acceptance") || lower.contains("policy accept") +} + +/// Parse `Policy accepted for #channel — role: …` server notices. +pub fn parse_policy_accepted(text: &str) -> Option { + let text = text.trim(); + let lower = text.to_ascii_lowercase(); + if !lower.contains("policy accepted") { + return None; + } + // `Policy accepted for #policytest — role: member. You may now JOIN.` + let rest = text + .split_once(" for ") + .map(|(_, r)| r) + .unwrap_or(text); + let channel = rest + .split_whitespace() + .next()? + .trim_matches(|c: char| c == '.' || c == ','); + if channel.starts_with('#') || channel.starts_with('&') { + Some(channel.to_string()) + } else { + None + } +} + +/// Human-friendly title/subtitle for a requirement row. +pub fn friendly_requirement(req: &RequirementStatus) -> (String, String) { + match req.requirement_type.as_str() { + "accept" => ( + "Accept channel rules".into(), + "Read and agree to the community guidelines".into(), + ), + "present" => { + let cred_type = req + .description + .split("Credential:") + .nth(1) + .map(|s| s.trim().split_whitespace().next().unwrap_or("")) + .unwrap_or(""); + let url = req.action.as_ref().and_then(|a| a.url.as_deref()); + match cred_type { + "github_repo" => { + let repo = url_param(url, "repo"); + ( + "Verify GitHub access".into(), + repo.map(|r| format!("Collaborator on {r}")) + .unwrap_or_else(|| { + "Prove you're a collaborator on the linked repository".into() + }), + ) + } + "github_membership" => { + let org = url_param(url, "org"); + ( + "Verify GitHub org membership".into(), + org.map(|o| format!("Member of {o}")) + .unwrap_or_else(|| { + "Prove you're a member of the linked organization".into() + }), + ) + } + "bluesky_follower" => { + let target = url_param(url, "target"); + ( + "Verify Bluesky follow".into(), + target + .map(|t| format!("Follow @{t}")) + .unwrap_or_else(|| "Follow the required account on Bluesky".into()), + ) + } + "channel_moderator" => ( + "Moderator credential required".into(), + "A channel operator must appoint you as a moderator".into(), + ), + other if !other.is_empty() => ( + format!("Present {} credential", other.replace('_', " ")), + "External verification required".into(), + ), + _ => ( + req.description.clone(), + "External verification required".into(), + ), + } + } + "prove" => ("Provide proof".into(), req.description.clone()), + _ => (req.description.clone(), String::new()), + } +} + +fn url_param(url: Option<&str>, param: &str) -> Option { + let url = url?; + let full = if url.starts_with('/') { + format!("https://x{url}") + } else { + url.to_string() + }; + url::Url::parse(&full) + .ok() + .and_then(|u| u.query_pairs().find(|(k, _)| k == param).map(|(_, v)| v.into_owned())) +} + +/// Build an absolute verification URL with callback + subject DID. +pub fn verification_url(raw: &str, api_base: &str, subject_did: &str) -> String { + let full = if raw.starts_with("http://") || raw.starts_with("https://") { + raw.to_string() + } else { + format!( + "{}{}", + api_base.trim_end_matches('/'), + if raw.starts_with('/') { raw } else { "/" } + ) + }; + let callback = format!("{}/api/v1/credentials/present", api_base.trim_end_matches('/')); + if let Ok(mut u) = url::Url::parse(&full) { + { + let mut pairs: Vec<(String, String)> = u + .query_pairs() + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect(); + pairs.retain(|(k, _)| k != "callback" && k != "subject_did"); + pairs.push(("callback".into(), callback)); + pairs.push(("subject_did".into(), subject_did.to_string())); + u.query_pairs_mut().clear(); + for (k, v) in pairs { + u.query_pairs_mut().append_pair(&k, &v); + } + } + return u.to_string(); + } + // Fallback: append query params manually. + let sep = if full.contains('?') { '&' } else { '?' }; + format!( + "{full}{sep}callback={}&subject_did={}", + urlencoding::encode(&callback), + urlencoding::encode(subject_did) + ) +} + +pub async fn fetch_check(api_base: &str, channel: &str, did: &str) -> Result { + let encoded = urlencoding::encode(channel); + let url = format!("{}/api/v1/policy/{encoded}/check", api_base.trim_end_matches('/')); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| e.to_string())?; + let res = client + .post(&url) + .json(&CheckRequest { did }) + .send() + .await + .map_err(|e| e.to_string())?; + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + return Err(if body.is_empty() { + format!("HTTP {status}") + } else { + body + }); + } + res.json::() + .await + .map_err(|e| e.to_string()) +} + +pub async fn fetch_rules(api_base: &str, channel: &str) -> Result { + let encoded = urlencoding::encode(channel); + let url = format!("{}/api/v1/policy/{encoded}/rules", api_base.trim_end_matches('/')); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| e.to_string())?; + let res = client.get(&url).send().await.map_err(|e| e.to_string())?; + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + return Err(if body.is_empty() { + format!("HTTP {status}") + } else { + body + }); + } + let rules = res + .json::() + .await + .map_err(|e| e.to_string())?; + Ok(rules.text) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn policy_acceptance_denial_detection() { + assert!(is_policy_acceptance_denial( + "This channel requires policy acceptance — use POLICY ACCEPT" + )); + assert!(!is_policy_acceptance_denial( + "This channel requires authentication — sign in to join" + )); + } + + #[test] + fn parse_policy_accepted_notice() { + assert_eq!( + parse_policy_accepted( + "Policy accepted for #policytest — role: member. You may now JOIN." + ), + Some("#policytest".into()) + ); + } +} diff --git a/android/src/state.rs b/android/src/state.rs index c654111..2e092b2 100644 --- a/android/src/state.rs +++ b/android/src/state.rs @@ -1036,6 +1036,45 @@ pub fn api_base_for_server(server: &str) -> String { } } +/// Join-gate modal state (policy acceptance before JOIN). +#[derive(Debug, Default)] +pub struct PolicyGate { + /// Channel the modal is open for (`None` = closed). + pub channel: Option, + pub loading: bool, + pub error: Option, + pub check: Option, + pub rules: Option, + pub accepting: bool, + pub joining: bool, + /// Monotonic id so stale HTTP responses are ignored. + pub fetch_id: u64, + /// When set, refresh the policy check after ACCEPT / verify. + pub refresh_after: Option, +} + +impl PolicyGate { + pub fn is_open(&self) -> bool { + self.channel.is_some() + } + + pub fn open(&mut self, channel: String) { + self.channel = Some(channel); + self.loading = true; + self.error = None; + self.check = None; + self.rules = None; + self.accepting = false; + self.joining = false; + self.refresh_after = None; + self.fetch_id = self.fetch_id.wrapping_add(1); + } + + pub fn close(&mut self) { + *self = Self::default(); + } +} + #[derive(Debug)] pub struct AppState { pub connection: ConnectionState, @@ -1188,6 +1227,9 @@ pub struct AppState { pub reconnect_attempts: u32, /// When set, fire auto-reconnect once `Instant::now() >=` this deadline. pub reconnect_at: Option, + + /// Channel policy join-gate modal (authenticated users blocked by ACCEPT rules). + pub policy_gate: PolicyGate, /// MoQ media-plane reconnect while `local_call` is still active. pub media_reconnect_attempts: u32, pub media_reconnect_at: Option, @@ -1309,6 +1351,7 @@ impl AppState { intentional_disconnect: false, reconnect_attempts: 0, reconnect_at: None, + policy_gate: PolicyGate::default(), media_reconnect_attempts: 0, media_reconnect_at: None, media_live_since: None, @@ -2041,6 +2084,17 @@ impl AppState { self.react_picker_search.clear(); } + /// Open the channel policy join-gate modal and request a fresh check. + pub fn open_policy_gate(&mut self, channel: &str) -> u64 { + let ch = Self::normalize_channel(channel); + self.policy_gate.open(ch); + self.policy_gate.fetch_id + } + + pub fn close_policy_gate(&mut self) { + self.policy_gate.close(); + } + /// Start editing an existing message in the compose bar. pub fn begin_edit(&mut self, msgid: String, text: String) { self.cancel_reply(); diff --git a/android/src/ui/chat.rs b/android/src/ui/chat.rs index 7d64ea8..97fa465 100644 --- a/android/src/ui/chat.rs +++ b/android/src/ui/chat.rs @@ -54,6 +54,8 @@ pub enum ChatAction { }, /// Soft-delete a message (`+draft/delete`). Delete { target: String, msgid: String }, + /// Open the channel policy join-gate modal. + OpenPolicyGate(String), } fn apply_message_bubble_action( @@ -349,8 +351,12 @@ pub fn chat_screen(ui: &mut egui::Ui, th: &Theme, state: &mut AppState, channel: && (err.to_ascii_lowercase().contains("authentication") || err.to_ascii_lowercase().contains("sign in") || err.to_ascii_lowercase().contains("policy")); + let policy_denial = + !guest_auth && crate::policy::is_policy_acceptance_denial(err); let title = if guest_auth { "Guests can't join" + } else if policy_denial { + "Policy acceptance required" } else { "Can't join this channel" }; @@ -364,6 +370,17 @@ pub fn chat_screen(ui: &mut egui::Ui, th: &Theme, state: &mut AppState, channel: "Sign in with Bluesky from Settings, then try again.", ); ui.add_space(sp.sm); + } else if policy_denial { + dim_label( + ui, + th, + "Review the channel rules and accept them to join.", + ); + ui.add_space(sp.sm); + if primary_button(ui, th, "View policy & accept").clicked() { + action = ChatAction::OpenPolicyGate(channel.to_string()); + } + ui.add_space(sp.sm); } if button(ui, th, "Dismiss").clicked() { action = ChatAction::Part(channel.to_string()); diff --git a/android/src/ui/mod.rs b/android/src/ui/mod.rs index 9441a08..2efd248 100644 --- a/android/src/ui/mod.rs +++ b/android/src/ui/mod.rs @@ -4,6 +4,7 @@ mod chat; mod chats; mod connect; mod discover; +mod policy_gate; mod search; mod settings; mod widgets; @@ -12,6 +13,7 @@ pub use chat::{active_call_panel, chat_screen, ChatAction}; pub use chats::{chats_tab, ChatsAction}; pub use connect::{connect_screen, ConnectAction}; pub use discover::{discover_tab, DiscoverAction}; +pub use policy_gate::{open_verification_url, policy_gate_overlay, PolicyGateAction}; pub use search::{message_search_panel, SearchAction}; pub use settings::{settings_tab, SettingsAction}; // Widget helpers are used from screens via `widgets::` or direct calls as needed. diff --git a/android/src/ui/policy_gate.rs b/android/src/ui/policy_gate.rs new file mode 100644 index 0000000..6ae8e48 --- /dev/null +++ b/android/src/ui/policy_gate.rs @@ -0,0 +1,333 @@ +//! Channel policy join-gate modal (freeq `JoinGateModal` parity). + +use eframe::egui::{self, Align, Color32, CursorIcon, Layout, RichText, ScrollArea, Stroke}; +use vidya::{button, dim_label, primary_button, Theme}; + +use crate::policy::{friendly_requirement, verification_url, PolicyCheck, RequirementStatus}; +use crate::state::AppState; + +pub enum PolicyGateAction { + None, + Close, + Refresh, + AcceptRules, + VerifyExternal { url: String }, + Join, +} + +/// Centered modal for accepting channel policy before JOIN. +pub fn policy_gate_overlay( + ctx: &egui::Context, + th: &Theme, + state: &mut AppState, +) -> PolicyGateAction { + let Some(channel) = state.policy_gate.channel.clone() else { + return PolicyGateAction::None; + }; + + let p = &th.palette; + let sp = &th.spacing; + let frame = egui::Frame::new() + .fill(p.card_bg) + .stroke(Stroke::new(1.0, p.border_soft)) + .corner_radius(sp.radius_md) + .inner_margin(egui::Margin::symmetric(16, 14)) + .shadow(egui::epaint::Shadow { + offset: [0, 4], + blur: 24, + spread: 0, + color: Color32::from_black_alpha(80), + }); + + let mut action = PolicyGateAction::None; + let mut close = false; + + let modal = egui::Modal::new(egui::Id::new("policy_gate_modal")) + .backdrop_color(Color32::from_black_alpha(140)) + .frame(frame) + .show(ctx, |ui| { + let panel_w = (ctx.screen_rect().width() - 32.0).clamp(280.0, 420.0); + ui.set_width(panel_w); + + // Header + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label( + RichText::new(format!( + "#{}", + channel.trim_start_matches('#') + )) + .size(th.type_scale.title) + .color(p.text) + .strong(), + ); + dim_label( + ui, + th, + "This channel requires policy acceptance to join", + ); + }); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let close_resp = ui + .add( + egui::Button::new( + RichText::new("✕") + .size(th.type_scale.body) + .color(p.text_secondary), + ) + .fill(Color32::TRANSPARENT) + .stroke(Stroke::NONE) + .frame(false), + ) + .on_hover_cursor(CursorIcon::PointingHand); + if close_resp.clicked() { + close = true; + } + }); + }); + + ui.add_space(sp.md); + ui.separator(); + ui.add_space(sp.sm); + + let body_h = (ctx.screen_rect().height() * 0.45).clamp(160.0, 360.0); + ScrollArea::vertical() + .id_salt("policy_gate_body") + .max_height(body_h) + .show(ui, |ui| { + if state.policy_gate.loading { + ui.vertical_centered(|ui| { + ui.add_space(sp.lg); + ui.spinner(); + ui.add_space(sp.sm); + dim_label(ui, th, "Checking requirements…"); + }); + return; + } + + if let Some(err) = state.policy_gate.error.as_ref() { + ui.label( + RichText::new(err) + .size(th.type_scale.body) + .color(p.destructive), + ); + ui.add_space(sp.sm); + } + + if let Some(rules) = state.policy_gate.rules.as_ref() { + if !rules.trim().is_empty() { + ui.label( + RichText::new("Channel rules") + .size(th.type_scale.body) + .color(p.text) + .strong(), + ); + ui.add_space(sp.xs); + ui.label( + RichText::new(rules.trim()) + .size(th.type_scale.caption) + .color(p.text_secondary), + ); + ui.add_space(sp.md); + } + } + + if let Some(check) = state.policy_gate.check.as_ref() { + render_check(ui, th, check, state.policy_gate.accepting, &mut action); + } + }); + + ui.add_space(sp.sm); + ui.separator(); + ui.add_space(sp.sm); + + // Footer + ui.horizontal(|ui| { + if button(ui, th, "Cancel").clicked() { + close = true; + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let can_join = state + .policy_gate + .check + .as_ref() + .is_some_and(|c| c.can_join); + let joining = state.policy_gate.joining; + let join_label = if joining { + "Joining…" + } else { + "Join channel" + }; + if primary_button(ui, th, join_label).clicked() && can_join && !joining { + action = PolicyGateAction::Join; + } + ui.add_space(sp.xs); + if button(ui, th, "Refresh").clicked() { + action = PolicyGateAction::Refresh; + } + }); + }); + }); + + if close || modal.should_close() { + action = PolicyGateAction::Close; + } + + action +} + +fn render_check( + ui: &mut egui::Ui, + th: &Theme, + check: &PolicyCheck, + accepting: bool, + action: &mut PolicyGateAction, +) { + let p = &th.palette; + let sp = &th.spacing; + + match check.status.as_str() { + "no_policy" => { + dim_label(ui, th, "This channel has no policy. You should be able to join directly."); + return; + } + "satisfied" if check.requirements.is_empty() => { + ui.vertical_centered(|ui| { + ui.label(RichText::new("✅").size(28.0)); + ui.label( + RichText::new("All requirements satisfied") + .size(th.type_scale.body) + .color(p.text) + .strong(), + ); + if let Some(role) = &check.role { + dim_label(ui, th, &format!("Role: {role}")); + } + }); + return; + } + _ => {} + } + + if !check.requirements.is_empty() { + for req in &check.requirements { + requirement_row(ui, th, req, accepting, action); + ui.add_space(sp.xs); + } + } + + if check.can_join { + if let Some(role) = &check.role { + ui.add_space(sp.sm); + ui.label( + RichText::new(format!("✓ All requirements met — role: {role}")) + .size(th.type_scale.caption) + .color(p.success), + ); + } + } +} + +fn requirement_row( + ui: &mut egui::Ui, + th: &Theme, + req: &RequirementStatus, + accepting: bool, + action: &mut PolicyGateAction, +) { + let p = &th.palette; + let sp = &th.spacing; + let (title, subtitle) = friendly_requirement(req); + let border = if req.satisfied { + p.success + } else { + p.border_soft + }; + let bg = if req.satisfied { + Color32::from_rgba_unmultiplied(p.success.r(), p.success.g(), p.success.b(), 20) + } else { + p.view_bg + }; + + egui::Frame::new() + .fill(bg) + .stroke(Stroke::new(1.0, border)) + .corner_radius(sp.radius_sm) + .inner_margin(egui::Margin::symmetric(10, 8)) + .show(ui, |ui| { + ui.horizontal_top(|ui| { + let icon = if req.satisfied { "✓" } else { "○" }; + let icon_color = if req.satisfied { + p.success + } else { + p.text_secondary + }; + ui.label( + RichText::new(icon) + .size(th.type_scale.body) + .color(icon_color) + .strong(), + ); + ui.add_space(sp.xs); + ui.vertical(|ui| { + let title_color = if req.satisfied { + p.text_secondary + } else { + p.text + }; + ui.label( + RichText::new(title) + .size(th.type_scale.body) + .color(title_color) + .strong(), + ); + if !subtitle.is_empty() { + dim_label(ui, th, &subtitle); + } + if !req.satisfied { + if let Some(act) = &req.action { + ui.add_space(sp.xs); + match act.action_type.as_str() { + "accept_rules" if act.accept_hash.is_some() => { + let label = if accepting { + "Accepting…" + } else { + act.label.as_str() + }; + if primary_button(ui, th, label).clicked() && !accepting { + *action = PolicyGateAction::AcceptRules; + } + } + "verify_external" if act.url.is_some() => { + let label = act.label.clone(); + if button(ui, th, &label).clicked() { + *action = PolicyGateAction::VerifyExternal { + url: act.url.clone().unwrap_or_default(), + }; + } + } + _ => {} + } + } + } + }); + }); + }); +} + +/// Resolve a verification URL and open it in the system browser. +pub fn open_verification_url(ctx: &egui::Context, api_base: &str, raw: &str, subject_did: &str) { + let url = verification_url(raw, api_base, subject_did); + #[cfg(target_os = "android")] + { + if crate::android_media::open_url(&url).is_err() { + ctx.open_url(egui::OpenUrl::new_tab(&url)); + } + } + #[cfg(not(target_os = "android"))] + { + if open::that(&url).is_err() { + ctx.open_url(egui::OpenUrl::new_tab(&url)); + } + } +}