synpad 0.1.0

A full-featured Matrix chat client built with Dioxus
use dioxus::prelude::*;
use matrix_sdk::ruma::OwnedRoomId;

use crate::platform::file_dialog::save_file;
use crate::state::app_state::{AppState, RightPanelView};

/// Right panel showing files that have been shared in the current room.
#[component]
pub fn FilePanel() -> Element {
    let mut state = use_context::<Signal<AppState>>();
    let mut active_tab = use_signal(|| FileTab::Files);
    let mut files = use_signal(Vec::<SharedFileData>::new);
    let mut loaded_for = use_signal(|| Option::<String>::None);

    let active_room_id = state.read().active_room_id.clone();
    let room_id_str = active_room_id.as_ref().map(|id| id.to_string());

    // Load files from timeline when room changes
    if room_id_str != *loaded_for.read() {
        loaded_for.set(room_id_str.clone());
        if let Some(rid) = room_id_str {
            spawn(async move {
                let client = { state.read().client.clone() };
                if let Some(client) = client {
                    if let Ok(room_id) = OwnedRoomId::try_from(rid.as_str()) {
                        if let Some(room) = client.get_room(&room_id) {
                            // Fetch recent messages and extract media events
                            use matrix_sdk::room::MessagesOptions;

                            let options = MessagesOptions::backward().from(None);
                            let mut found_files = Vec::new();

                            match room.messages(options).await {
                                Ok(response) => {
                                    for event in response.chunk.iter() {
                                        let raw = event.raw();
                                        if let Ok(val) = raw.deserialize_as::<serde_json::Value>() {
                                            let msgtype = val.get("content")
                                                .and_then(|c| c.get("msgtype"))
                                                .and_then(|m| m.as_str())
                                                .unwrap_or("");
                                            let kind = match msgtype {
                                                "m.image" => Some(SharedFileKind::Image),
                                                "m.file" => Some(SharedFileKind::File),
                                                "m.video" => Some(SharedFileKind::Video),
                                                "m.audio" => Some(SharedFileKind::Audio),
                                                _ => None,
                                            };
                                            if let Some(kind) = kind {
                                                let event_id = val.get("event_id")
                                                    .and_then(|v| v.as_str())
                                                    .unwrap_or("")
                                                    .to_string();
                                                let sender = val.get("sender")
                                                    .and_then(|v| v.as_str())
                                                    .unwrap_or("")
                                                    .to_string();
                                                let content = val.get("content");
                                                let name = content
                                                    .and_then(|c| c.get("body"))
                                                    .and_then(|b| b.as_str())
                                                    .unwrap_or("Unnamed file")
                                                    .to_string();
                                                let size = content
                                                    .and_then(|c| c.get("info"))
                                                    .and_then(|i| i.get("size"))
                                                    .and_then(|s| s.as_u64());
                                                let mimetype = content
                                                    .and_then(|c| c.get("info"))
                                                    .and_then(|i| i.get("mimetype"))
                                                    .and_then(|m| m.as_str())
                                                    .map(String::from);

                                                found_files.push(SharedFileData {
                                                    event_id,
                                                    name,
                                                    sender_name: sender,
                                                    size,
                                                    mimetype,
                                                    kind,
                                                });
                                            }
                                        }
                                    }
                                }
                                Err(e) => {
                                    tracing::error!("Failed to fetch room messages for files: {e}");
                                }
                            }
                            files.set(found_files);
                        }
                    }
                }
            });
        }
    }

    let close_panel = move |_| {
        state.write().right_panel = RightPanelView::Closed;
    };

    let back_to_info = move |_| {
        state.write().right_panel = RightPanelView::RoomInfo;
    };

    let current_tab = *active_tab.read();
    let files_read = files.read();

    let filtered_files: Vec<&SharedFileData> = files_read
        .iter()
        .filter(|f| match current_tab {
            FileTab::Files => matches!(f.kind, SharedFileKind::File),
            FileTab::Images => matches!(f.kind, SharedFileKind::Image),
            FileTab::Videos => matches!(f.kind, SharedFileKind::Video),
            FileTab::Audio => matches!(f.kind, SharedFileKind::Audio),
        })
        .collect();

    rsx! {
        div {
            class: "file-panel",

            // Header
            header {
                class: "file-panel__header",
                button {
                    class: "file-panel__back-btn",
                    title: "Back to room info",
                    onclick: back_to_info,
                    ""
                }
                h3 {
                    class: "file-panel__title",
                    "Files"
                }
                button {
                    class: "file-panel__close-btn",
                    title: "Close panel",
                    onclick: close_panel,
                    ""
                }
            }

            // Tab bar
            nav {
                class: "file-panel__tabs",

                FileTabButton {
                    label: "Files",
                    is_active: matches!(current_tab, FileTab::Files),
                    on_click: move |_| active_tab.set(FileTab::Files),
                }
                FileTabButton {
                    label: "Images",
                    is_active: matches!(current_tab, FileTab::Images),
                    on_click: move |_| active_tab.set(FileTab::Images),
                }
                FileTabButton {
                    label: "Videos",
                    is_active: matches!(current_tab, FileTab::Videos),
                    on_click: move |_| active_tab.set(FileTab::Videos),
                }
                FileTabButton {
                    label: "Audio",
                    is_active: matches!(current_tab, FileTab::Audio),
                    on_click: move |_| active_tab.set(FileTab::Audio),
                }
            }

            // File list
            div {
                class: "file-panel__list",

                if filtered_files.is_empty() {
                    div {
                        class: "file-panel__empty",
                        p { "No files have been shared in this room yet." }
                    }
                }

                for file in filtered_files.iter() {
                    SharedFileItem {
                        key: "{file.event_id}",
                        event_id: file.event_id.clone(),
                        name: file.name.clone(),
                        sender_name: file.sender_name.clone(),
                        size: file.size,
                        mimetype: file.mimetype.clone(),
                        kind: file.kind,
                    }
                }
            }
        }
    }
}

/// Tabs for filtering the file list by media type.
#[derive(Clone, Copy, Debug, PartialEq)]
enum FileTab {
    Files,
    Images,
    Videos,
    Audio,
}

/// The kind of a shared file entry.
#[derive(Clone, Copy, Debug, PartialEq)]
enum SharedFileKind {
    File,
    Image,
    Video,
    Audio,
}

/// Data for a single shared file.
#[derive(Clone, Debug)]
struct SharedFileData {
    pub event_id: String,
    pub name: String,
    pub sender_name: String,
    pub size: Option<u64>,
    pub mimetype: Option<String>,
    pub kind: SharedFileKind,
}

/// A tab button inside the file panel.
#[component]
fn FileTabButton(
    label: &'static str,
    is_active: bool,
    on_click: EventHandler<MouseEvent>,
) -> Element {
    let class = if is_active {
        "file-panel__tab file-panel__tab--active"
    } else {
        "file-panel__tab"
    };

    rsx! {
        button {
            class: "{class}",
            onclick: move |evt| on_click.call(evt),
            "{label}"
        }
    }
}

/// Renders a single shared file entry with download support.
#[component]
fn SharedFileItem(
    event_id: String,
    name: String,
    sender_name: String,
    size: Option<u64>,
    mimetype: Option<String>,
    kind: SharedFileKind,
) -> Element {
    let state = use_context::<Signal<AppState>>();
    let mut is_downloading = use_signal(|| false);

    let icon = match kind {
        SharedFileKind::File => "📄",
        SharedFileKind::Image => "🖼",
        SharedFileKind::Video => "🎬",
        SharedFileKind::Audio => "🎵",
    };

    let size_text = match size {
        Some(bytes) if bytes >= 1_048_576 => format!("{:.1} MB", bytes as f64 / 1_048_576.0),
        Some(bytes) if bytes >= 1_024 => format!("{:.1} KB", bytes as f64 / 1_024.0),
        Some(bytes) => format!("{bytes} B"),
        None => String::new(),
    };

    let download_name = name.clone();
    let download_eid = event_id.clone();
    let on_download = move |_| {
        let fname = download_name.clone();
        let eid = download_eid.clone();
        is_downloading.set(true);
        spawn(async move {
            let (client, active_room_id) = {
                let s = state.read();
                (s.client.clone(), s.active_room_id.clone())
            };
            if let (Some(client), Some(room_id)) = (client, active_room_id) {
                if let Some(room) = client.get_room(&room_id) {
                    if let Ok(event_id) = <&str as TryInto<matrix_sdk::ruma::OwnedEventId>>::try_into(eid.as_str()) {
                        // Fetch the event to get media URL
                        match room.event(&event_id, None).await {
                            Ok(timeline_event) => {
                                let raw = timeline_event.raw();
                                if let Ok(val) = raw.deserialize_as::<serde_json::Value>() {
                                    let mxc_url = val.get("content")
                                        .and_then(|c| c.get("url"))
                                        .and_then(|u| u.as_str());
                                    if let Some(mxc_url) = mxc_url {
                                        use matrix_sdk::ruma::events::room::MediaSource;
                                        let mxc_uri: matrix_sdk::ruma::OwnedMxcUri = mxc_url.to_string().into();
                                        match client.media().get_media_content(
                                            &matrix_sdk::media::MediaRequestParameters {
                                                source: MediaSource::Plain(mxc_uri),
                                                format: matrix_sdk::media::MediaFormat::File,
                                            },
                                            true,
                                        ).await {
                                            Ok(data) => {
                                                match save_file("Save file as...", &fname, &data).await {
                                                    Ok(Some(_)) => tracing::info!("File saved: {fname}"),
                                                    Ok(None) => {}
                                                    Err(err) => tracing::error!("{err}"),
                                                }
                                            }
                                            Err(e) => {
                                                tracing::error!("Failed to download media: {e}");
                                            }
                                        }
                                    }
                                }
                            }
                            Err(e) => {
                                tracing::error!("Failed to fetch event for download: {e}");
                            }
                        }
                    }
                }
            }
            is_downloading.set(false);
        });
    };

    let is_dl = *is_downloading.read();

    rsx! {
        div {
            class: "shared-file-item",

            span {
                class: "shared-file-item__icon",
                "{icon}"
            }

            div {
                class: "shared-file-item__info",
                span {
                    class: "shared-file-item__name",
                    "{name}"
                }
                span {
                    class: "shared-file-item__meta",
                    "{sender_name}"
                    if !size_text.is_empty() {
                        " \u{00B7} {size_text}"
                    }
                }
            }

            button {
                class: "shared-file-item__download-btn",
                title: "Download",
                disabled: is_dl,
                onclick: on_download,
                if is_dl { "..." } else { "" }
            }
        }
    }
}