synpad 0.1.0

A full-featured Matrix chat client built with Dioxus
use anyhow::Result;
use matrix_sdk::Client;
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, OwnedUserId};

use crate::state::room_state::*;

/// Fetch recent messages from a room.
/// Returns (timeline_items, pagination_token_for_older_messages).
pub async fn load_room_messages(
    client: &Client,
    room_id: &OwnedRoomId,
    from_token: Option<&str>,
) -> Result<(Vec<TimelineItemKind>, Option<String>)> {
    let room = client
        .get_room(room_id)
        .ok_or_else(|| anyhow::anyhow!("Room not found: {room_id}"))?;

    let own_user_id = client.user_id().map(|u| u.to_owned());

    // Build backward pagination options
    let mut options = matrix_sdk::room::MessagesOptions::backward();
    if let Some(token) = from_token {
        options.from = Some(token.to_string());
    }

    let response = room.messages(options).await?;

    let mut items = Vec::new();
    for timeline_event in &response.chunk {
        // Get raw JSON from the event for flexible parsing
        if let Some(json) = event_to_json(timeline_event) {
            if let Some(item) = convert_raw_event(&json, own_user_id.as_ref()) {
                items.push(item);
            }
        }
    }

    // Fetched backward — reverse for chronological display order
    items.reverse();

    Ok((items, response.end))
}

/// Extract raw JSON from a TimelineEvent.
fn event_to_json(
    event: &matrix_sdk::deserialized_responses::TimelineEvent,
) -> Option<serde_json::Value> {
    let raw = event.raw();
    serde_json::from_str(raw.json().get()).ok()
}

/// Convert a raw event JSON object into a TimelineItemKind.
fn convert_raw_event(
    json: &serde_json::Value,
    own_user_id: Option<&OwnedUserId>,
) -> Option<TimelineItemKind> {
    let event_type = json.get("type")?.as_str()?;
    let sender_str = json.get("sender")?.as_str()?;
    let sender: OwnedUserId = sender_str.try_into().ok()?;
    let event_id: Option<OwnedEventId> = json
        .get("event_id")
        .and_then(|v| v.as_str())
        .and_then(|s| s.try_into().ok());
    let timestamp = json
        .get("origin_server_ts")
        .and_then(|v| v.as_u64())
        .unwrap_or(0);
    let is_own = own_user_id.map_or(false, |u| u.as_str() == sender_str);

    match event_type {
        "m.room.message" => {
            let content = json.get("content")?;
            let timeline_content = convert_message_content(content)?;

            Some(TimelineItemKind::Event(EventTimelineData {
                event_id,
                sender,
                sender_display_name: sender_str.to_string(),
                sender_avatar_url: None,
                timestamp,
                content: timeline_content,
                reactions: Vec::new(),
                is_edited: content
                    .get("m.relates_to")
                    .and_then(|r| r.get("rel_type"))
                    .and_then(|t| t.as_str())
                    .map_or(false, |t| t == "m.replace"),
                reply_to: extract_reply_preview(content),
                read_receipts: Vec::new(),
                send_state: SendState::None,
                is_own_message: is_own,
            }))
        }
        "m.room.encrypted" => Some(TimelineItemKind::Event(EventTimelineData {
            event_id,
            sender,
            sender_display_name: sender_str.to_string(),
            sender_avatar_url: None,
            timestamp,
            content: TimelineContent::EncryptionError {
                message: "Unable to decrypt message".to_string(),
            },
            reactions: Vec::new(),
            is_edited: false,
            reply_to: None,
            read_receipts: Vec::new(),
            send_state: SendState::None,
            is_own_message: is_own,
        })),
        "m.room.member" => {
            let content = json.get("content")?;
            let membership = content.get("membership")?.as_str()?;
            let description = format_membership_event(sender_str, membership, content);
            Some(make_state_event(
                event_id,
                sender,
                sender_str,
                timestamp,
                description,
                is_own,
            ))
        }
        "m.room.name" | "m.room.topic" | "m.room.avatar" | "m.room.create"
        | "m.room.power_levels" | "m.room.join_rules" | "m.room.history_visibility"
        | "m.room.canonical_alias" | "m.room.guest_access" => {
            let content = json.get("content")?;
            let description = format_state_event(event_type, sender_str, content);
            Some(make_state_event(
                event_id,
                sender,
                sender_str,
                timestamp,
                description,
                is_own,
            ))
        }
        "m.room.redaction" => {
            let reason = json
                .get("content")
                .and_then(|c| c.get("reason"))
                .and_then(|r| r.as_str())
                .map(|s| s.to_string());
            Some(TimelineItemKind::Event(EventTimelineData {
                event_id,
                sender,
                sender_display_name: sender_str.to_string(),
                sender_avatar_url: None,
                timestamp,
                content: TimelineContent::Redacted { reason },
                reactions: Vec::new(),
                is_edited: false,
                reply_to: None,
                read_receipts: Vec::new(),
                send_state: SendState::None,
                is_own_message: is_own,
            }))
        }
        "m.sticker" => {
            let content = json.get("content")?;
            let body = content.get("body")?.as_str()?.to_string();
            let url = str_field(content, "url");
            Some(TimelineItemKind::Event(EventTimelineData {
                event_id,
                sender,
                sender_display_name: sender_str.to_string(),
                sender_avatar_url: None,
                timestamp,
                content: TimelineContent::Sticker { body, url },
                reactions: Vec::new(),
                is_edited: false,
                reply_to: None,
                read_receipts: Vec::new(),
                send_state: SendState::None,
                is_own_message: is_own,
            }))
        }
        _ => None, // Skip unknown event types
    }
}

fn make_state_event(
    event_id: Option<OwnedEventId>,
    sender: OwnedUserId,
    sender_str: &str,
    timestamp: u64,
    description: String,
    is_own: bool,
) -> TimelineItemKind {
    TimelineItemKind::Event(EventTimelineData {
        event_id,
        sender,
        sender_display_name: sender_str.to_string(),
        sender_avatar_url: None,
        timestamp,
        content: TimelineContent::StateEvent { description },
        reactions: Vec::new(),
        is_edited: false,
        reply_to: None,
        read_receipts: Vec::new(),
        send_state: SendState::None,
        is_own_message: is_own,
    })
}

/// Convert message content JSON to TimelineContent.
fn convert_message_content(content: &serde_json::Value) -> Option<TimelineContent> {
    let msgtype = content.get("msgtype")?.as_str()?;
    let body = content.get("body")?.as_str()?.to_string();

    match msgtype {
        "m.text" => Some(TimelineContent::Text {
            body,
            formatted_body: str_field(content, "formatted_body"),
        }),
        "m.image" => {
            let info = content.get("info");
            Some(TimelineContent::Image {
                body,
                url: str_field(content, "url"),
                thumbnail_url: info
                    .and_then(|i| i.get("thumbnail_url"))
                    .and_then(|v| v.as_str())
                    .map(|s| s.to_string()),
                blurhash: info
                    .and_then(|i| i.get("xyz.amorgan.blurhash"))
                    .and_then(|v| v.as_str())
                    .map(|s| s.to_string()),
                width: info.and_then(|i| u32_field(i, "w")),
                height: info.and_then(|i| u32_field(i, "h")),
            })
        }
        "m.file" => {
            let info = content.get("info");
            Some(TimelineContent::File {
                body,
                url: str_field(content, "url"),
                size: info.and_then(|i| i.get("size")).and_then(|v| v.as_u64()),
                mimetype: info.and_then(|i| str_field(i, "mimetype")),
            })
        }
        "m.audio" => {
            let info = content.get("info");
            let duration_ms = content
                .get("org.matrix.msc1767.audio")
                .and_then(|audio| audio.get("duration"))
                .and_then(|v| v.as_u64())
                .or_else(|| info.and_then(|i| i.get("duration")).and_then(|v| v.as_u64()));

            if content.get("org.matrix.msc3245.voice").is_some() {
                let waveform = content
                    .get("org.matrix.msc1767.audio")
                    .and_then(|audio| audio.get("waveform"))
                    .and_then(|value| value.as_array())
                    .map(|samples| {
                        samples
                            .iter()
                            .filter_map(|sample| sample.as_u64())
                            .map(|sample| sample.min(u16::MAX as u64) as u16)
                            .collect::<Vec<_>>()
                    })
                    .unwrap_or_default();

                Some(TimelineContent::VoiceMessage {
                    body,
                    url: str_field(content, "url"),
                    duration_ms,
                    waveform,
                })
            } else {
                Some(TimelineContent::Audio {
                    body,
                    url: str_field(content, "url"),
                    duration_ms,
                })
            }
        }
        "m.video" => {
            let info = content.get("info");
            Some(TimelineContent::Video {
                body,
                url: str_field(content, "url"),
                thumbnail_url: info
                    .and_then(|i| i.get("thumbnail_url"))
                    .and_then(|v| v.as_str())
                    .map(|s| s.to_string()),
                width: info.and_then(|i| u32_field(i, "w")),
                height: info.and_then(|i| u32_field(i, "h")),
                duration_ms: info
                    .and_then(|i| i.get("duration"))
                    .and_then(|v| v.as_u64()),
            })
        }
        "m.emote" => Some(TimelineContent::Emote {
            body,
            formatted_body: str_field(content, "formatted_body"),
        }),
        "m.notice" => Some(TimelineContent::Notice {
            body,
            formatted_body: str_field(content, "formatted_body"),
        }),
        _ => Some(TimelineContent::Text {
            body: format!("[{msgtype}] {body}"),
            formatted_body: None,
        }),
    }
}

fn str_field(obj: &serde_json::Value, key: &str) -> Option<String> {
    obj.get(key)
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
}

fn u32_field(obj: &serde_json::Value, key: &str) -> Option<u32> {
    obj.get(key).and_then(|v| v.as_u64()).map(|v| v as u32)
}

fn extract_reply_preview(content: &serde_json::Value) -> Option<Box<ReplyPreview>> {
    let relates_to = content.get("m.relates_to")?;
    let in_reply_to = relates_to.get("m.in_reply_to")?;
    let event_id_str = in_reply_to.get("event_id")?.as_str()?;
    let event_id: OwnedEventId = event_id_str.try_into().ok()?;
    Some(Box::new(ReplyPreview {
        event_id,
        sender_name: String::new(),
        body: String::new(),
    }))
}

fn format_membership_event(
    sender: &str,
    membership: &str,
    content: &serde_json::Value,
) -> String {
    let display_name = content
        .get("displayname")
        .and_then(|v| v.as_str())
        .unwrap_or(sender);

    match membership {
        "join" => format!("{display_name} joined the room"),
        "leave" => format!("{display_name} left the room"),
        "invite" => format!("{sender} invited {display_name}"),
        "ban" => format!("{sender} banned {display_name}"),
        "knock" => format!("{display_name} requested to join"),
        _ => format!("{display_name} changed membership to {membership}"),
    }
}

fn format_state_event(
    event_type: &str,
    sender: &str,
    content: &serde_json::Value,
) -> String {
    match event_type {
        "m.room.name" => {
            let name = content
                .get("name")
                .and_then(|v| v.as_str())
                .unwrap_or("(empty)");
            format!("{sender} changed the room name to \"{name}\"")
        }
        "m.room.topic" => {
            let topic = content
                .get("topic")
                .and_then(|v| v.as_str())
                .unwrap_or("(empty)");
            format!("{sender} changed the topic to \"{topic}\"")
        }
        "m.room.avatar" => format!("{sender} changed the room avatar"),
        "m.room.create" => format!("Room created by {sender}"),
        "m.room.power_levels" => format!("{sender} changed the power levels"),
        "m.room.join_rules" => {
            let rule = content
                .get("join_rule")
                .and_then(|v| v.as_str())
                .unwrap_or("unknown");
            format!("{sender} set join rule to {rule}")
        }
        "m.room.history_visibility" => {
            let vis = content
                .get("history_visibility")
                .and_then(|v| v.as_str())
                .unwrap_or("unknown");
            format!("{sender} set history visibility to {vis}")
        }
        _ => format!("{sender} sent a {event_type} event"),
    }
}