iris-chat 0.1.0

Iris Chat command line client and shared encrypted chat core
Documentation
use super::*;

pub(super) const FALLBACK_DEFAULT_RELAYS: &[&str] = &[
    "wss://relay.damus.io",
    "wss://nos.lol",
    "wss://relay.primal.net",
    "wss://relay.snort.social",
    "wss://temp.iris.to",
];
pub(super) const APP_VERSION: &str = env!("IRIS_APP_VERSION");
pub(super) const BUILD_CHANNEL: &str = env!("IRIS_BUILD_CHANNEL");
pub(super) const BUILD_GIT_SHA: &str = env!("IRIS_BUILD_GIT_SHA");
pub(super) const BUILD_TIMESTAMP_UTC: &str = env!("IRIS_BUILD_TIMESTAMP_UTC");
pub(super) const COMPILED_DEFAULT_RELAYS_CSV: &str = env!("IRIS_DEFAULT_RELAYS");
pub(super) const RELAY_SET_ID: &str = env!("IRIS_RELAY_SET_ID");
pub(super) const TRUSTED_TEST_BUILD: &str = env!("IRIS_TRUSTED_TEST_BUILD");
pub(super) const MAX_SEEN_EVENT_IDS: usize = 2048;
pub(super) const CATCH_UP_LOOKBACK_SECS: u64 = 30;
pub(super) const DEVICE_INVITE_DISCOVERY_LOOKBACK_SECS: u64 = 30 * 24 * 60 * 60;
pub(super) const DEVICE_INVITE_DISCOVERY_LIMIT: usize = 256;
pub(super) const DEVICE_INVITE_DISCOVERY_POLL_SECS: u64 = 5;
pub(super) const RELAY_CONNECT_TIMEOUT_SECS: u64 = 5;
pub(super) const RELAY_SYNC_TIMEOUT_SECS: u64 = 5;
pub(super) const RESUBSCRIBE_CATCH_UP_DELAY_SECS: u64 = 5;
pub(super) const GROUP_CHAT_PREFIX: &str = "group:";
pub(super) const CHAT_INVITE_ROOT_URL: &str = "https://chat.iris.to/";
pub(super) const DEBUG_SNAPSHOT_FILENAME: &str = "iris_chat_runtime_debug.json";
pub(super) const MAX_DEBUG_LOG_ENTRIES: usize = 128;
pub(super) const PERSISTED_STATE_VERSION: u32 = 12;

pub(crate) fn configured_relays() -> Vec<String> {
    let compiled_defaults = compiled_default_relays();
    let raw_relays = match std::env::var("IRIS_DEMO_RELAYS") {
        Ok(value) => value
            .split(',')
            .map(str::trim)
            .filter(|entry| !entry.is_empty())
            .map(ToOwned::to_owned)
            .collect(),
        Err(_) => compiled_defaults,
    };
    normalize_nostr_relay_urls(&raw_relays)
}

pub(super) fn relay_urls_from_strings(relays: &[String]) -> Vec<RelayUrl> {
    relays
        .iter()
        .filter_map(|relay| RelayUrl::parse(relay).ok())
        .collect()
}

pub(super) fn normalize_nostr_relay_url(raw_url: &str) -> Result<String, String> {
    let candidate = raw_url.trim();
    if candidate.is_empty() {
        return Err("Relay URL is required.".to_string());
    }

    let mut url = url::Url::parse(candidate)
        .map_err(|_| "Relay URL must be an absolute ws:// or wss:// URL.".to_string())?;
    let scheme = url.scheme().to_ascii_lowercase();
    if scheme != "ws" && scheme != "wss" {
        return Err("Relay URL must use ws:// or wss://.".to_string());
    }
    if url.host_str().is_none() {
        return Err("Relay URL must include a host.".to_string());
    }

    let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
    url.set_scheme(&scheme)
        .map_err(|_| "Relay URL must use ws:// or wss://.".to_string())?;
    url.set_host(Some(&host))
        .map_err(|_| "Relay URL must include a host.".to_string())?;

    let mut normalized = url.to_string();
    if normalized.ends_with('/')
        && url.path() == "/"
        && url.query().is_none()
        && url.fragment().is_none()
    {
        normalized.pop();
    }
    Ok(normalized)
}

pub(super) fn normalize_nostr_relay_urls(relays: &[String]) -> Vec<String> {
    let mut normalized = Vec::new();
    let mut seen = HashSet::new();
    for relay in relays {
        if let Ok(url) = normalize_nostr_relay_url(relay) {
            if seen.insert(url.clone()) {
                normalized.push(url);
            }
        }
    }
    normalized
}

pub(super) fn compiled_default_relays() -> Vec<String> {
    let compiled = COMPILED_DEFAULT_RELAYS_CSV
        .split(',')
        .map(str::trim)
        .filter(|entry| !entry.is_empty())
        .map(ToOwned::to_owned)
        .collect::<Vec<_>>();
    if compiled.is_empty() {
        FALLBACK_DEFAULT_RELAYS
            .iter()
            .map(|relay| (*relay).to_string())
            .collect()
    } else {
        compiled
    }
}

pub(super) fn trusted_test_build() -> bool {
    matches!(TRUSTED_TEST_BUILD, "1" | "true" | "TRUE" | "True")
}

pub(crate) fn build_summary() -> String {
    format!("{APP_VERSION} ({BUILD_GIT_SHA})")
}

pub(crate) fn relay_set_id() -> &'static str {
    RELAY_SET_ID
}

pub(crate) fn trusted_test_build_flag() -> bool {
    trusted_test_build()
}

pub(super) async fn ensure_session_relays_configured(client: &Client, relay_urls: &[RelayUrl]) {
    for relay in relay_urls {
        let _ = tokio::time::timeout(
            Duration::from_secs(RELAY_SYNC_TIMEOUT_SECS),
            client.add_relay(relay.clone()),
        )
        .await;
    }
}

pub(super) async fn sync_session_relays(
    client: &Client,
    previous_relay_urls: &[RelayUrl],
    next_relay_urls: &[RelayUrl],
) {
    for relay in previous_relay_urls {
        if !next_relay_urls.iter().any(|next| next == relay) {
            let _ = tokio::time::timeout(
                Duration::from_secs(RELAY_SYNC_TIMEOUT_SECS),
                client.remove_relay(relay),
            )
            .await;
        }
    }
    ensure_session_relays_configured(client, next_relay_urls).await;
}

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

    #[test]
    fn fallback_relays_include_ndr_cli_defaults() {
        assert_eq!(
            compiled_default_relays(),
            vec![
                "wss://relay.damus.io",
                "wss://nos.lol",
                "wss://relay.primal.net",
                "wss://relay.snort.social",
                "wss://temp.iris.to",
            ]
        );
    }

    #[test]
    fn empty_relay_list_stays_disabled() {
        assert!(normalize_nostr_relay_urls(&[]).is_empty());
        assert!(relay_urls_from_strings(&[]).is_empty());
    }

    #[test]
    fn relay_url_normalization_is_stable_for_comparisons() {
        assert_eq!(
            normalize_nostr_relay_urls(&[
                " WSS://Relay.Example/ ".to_string(),
                "wss://relay.example".to_string(),
                "wss://relay.example/path/".to_string(),
            ]),
            vec![
                "wss://relay.example".to_string(),
                "wss://relay.example/path/".to_string(),
            ]
        );
    }
}