iris-chat 0.1.0

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

#[derive(Debug, Clone, PartialEq, Eq)]
struct FileLinkMatch {
    start: usize,
    end: usize,
    attachment: MessageAttachmentSnapshot,
}

pub(super) fn extract_message_attachments(text: &str) -> (String, Vec<MessageAttachmentSnapshot>) {
    let matches = find_file_links(text);
    if matches.is_empty() {
        return (text.trim().to_string(), Vec::new());
    }

    let mut body = String::with_capacity(text.len());
    let mut last = 0usize;
    let mut attachments = Vec::with_capacity(matches.len());

    for found in matches {
        body.push_str(&text[last..found.start]);
        last = found.end;
        attachments.push(found.attachment);
    }
    body.push_str(&text[last..]);

    (body.trim().to_string(), attachments)
}

pub(super) fn message_preview(message: &ChatMessageSnapshot) -> String {
    if !message.body.is_empty() {
        return message.body.clone();
    }
    match message.attachments.as_slice() {
        [] => String::new(),
        [attachment] => attachment_preview_label(attachment).to_string(),
        attachments => {
            if attachments.iter().all(|a| a.is_image) {
                format!("{} photos", attachments.len())
            } else {
                format!("{} attachments", attachments.len())
            }
        }
    }
}

fn attachment_preview_label(attachment: &MessageAttachmentSnapshot) -> &'static str {
    if attachment.is_image {
        "Photo"
    } else if attachment.is_video {
        "Video"
    } else if attachment.is_audio {
        "Audio"
    } else {
        "Attachment"
    }
}

pub(super) fn format_attachment_links_message(
    caption: &str,
    attachments: &[(String, String)],
) -> String {
    let caption = caption.trim();
    let file_links = attachments
        .iter()
        .map(|(nhash, filename)| format_file_link(nhash, filename))
        .collect::<Vec<_>>();
    match (caption.is_empty(), file_links.is_empty()) {
        (true, _) => file_links.join("\n"),
        (_, true) => caption.to_string(),
        (false, false) => format!("{caption}\n{}", file_links.join("\n")),
    }
}

pub(super) fn format_file_link(nhash: &str, filename: &str) -> String {
    format!("{}/{}", nhash.trim(), percent_encode_filename(filename))
}

fn find_file_links(text: &str) -> Vec<FileLinkMatch> {
    let bytes = text.as_bytes();
    let mut out = Vec::new();
    let mut i = 0usize;

    while i < text.len() {
        let Some(relative) = text[i..].find("nhash1") else {
            break;
        };
        let nhash_start = i + relative;
        let mut match_start = nhash_start;

        if nhash_start >= "htree://".len()
            && &text[nhash_start - "htree://".len()..nhash_start] == "htree://"
        {
            match_start = nhash_start - "htree://".len();
        } else if nhash_start >= "nhash://".len()
            && &text[nhash_start - "nhash://".len()..nhash_start] == "nhash://"
        {
            match_start = nhash_start - "nhash://".len();
        }

        let mut nhash_end = nhash_start;
        while nhash_end < bytes.len() {
            let byte = bytes[nhash_end];
            if byte == b'/' {
                break;
            }
            if !byte.is_ascii_alphanumeric() {
                break;
            }
            nhash_end += 1;
        }

        if nhash_end >= bytes.len() || bytes[nhash_end] != b'/' {
            i = nhash_start + "nhash1".len();
            continue;
        }

        let file_start = nhash_end + 1;
        let mut file_end = file_start;
        while file_end < bytes.len() && !bytes[file_end].is_ascii_whitespace() {
            file_end += 1;
        }
        if file_end == file_start {
            i = file_start;
            continue;
        }

        if let Some(attachment) = parse_file_link(&text[match_start..file_end]) {
            out.push(FileLinkMatch {
                start: match_start,
                end: file_end,
                attachment,
            });
        }
        i = file_end;
    }

    out
}

fn parse_file_link(link: &str) -> Option<MessageAttachmentSnapshot> {
    let cleaned = link
        .trim()
        .strip_prefix("htree://")
        .or_else(|| link.trim().strip_prefix("nhash://"))
        .unwrap_or_else(|| link.trim());
    let (nhash, filename_encoded) = cleaned.split_once('/')?;
    let nhash = nhash.trim();
    if !is_valid_nhash(nhash) {
        return None;
    }
    let filename_encoded = filename_encoded.trim();
    if filename_encoded.is_empty() {
        return None;
    }

    let filename = percent_decode(filename_encoded);
    Some(MessageAttachmentSnapshot {
        nhash: nhash.to_string(),
        filename,
        filename_encoded: filename_encoded.to_string(),
        htree_url: format!("htree://{nhash}/{filename_encoded}"),
        is_image: has_extension(
            filename_encoded,
            &["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "avif"],
        ),
        is_video: has_extension(filename_encoded, &["mp4", "webm", "mov", "avi", "mkv"]),
        is_audio: has_extension(
            filename_encoded,
            &["mp3", "wav", "ogg", "flac", "m4a", "aac"],
        ),
    })
}

fn is_valid_nhash(nhash: &str) -> bool {
    nhash.to_ascii_lowercase().starts_with("nhash1")
        && nhash.chars().all(|ch| ch.is_ascii_alphanumeric())
}

fn has_extension(filename: &str, extensions: &[&str]) -> bool {
    let decoded = percent_decode(filename);
    let Some((_, extension)) = decoded.rsplit_once('.') else {
        return false;
    };
    extensions
        .iter()
        .any(|candidate| extension.eq_ignore_ascii_case(candidate))
}

fn percent_decode(value: &str) -> String {
    let bytes = value.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0usize;

    while i < bytes.len() {
        if bytes[i] == b'%' && i + 2 < bytes.len() {
            if let (Some(high), Some(low)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2])) {
                out.push((high << 4) | low);
                i += 3;
                continue;
            }
        }
        out.push(bytes[i]);
        i += 1;
    }

    String::from_utf8(out).unwrap_or_else(|_| value.to_string())
}

fn percent_encode_filename(value: &str) -> String {
    let mut out = String::with_capacity(value.len());
    for byte in value.as_bytes() {
        match *byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                out.push(*byte as char)
            }
            byte => out.push_str(&format!("%{byte:02X}")),
        }
    }
    out
}

fn hex_value(byte: u8) -> Option<u8> {
    match byte {
        b'0'..=b'9' => Some(byte - b'0'),
        b'a'..=b'f' => Some(byte - b'a' + 10),
        b'A'..=b'F' => Some(byte - b'A' + 10),
        _ => None,
    }
}

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

    #[test]
    fn extracts_plain_nhash_attachment_and_strips_visible_body() {
        let (body, attachments) = extract_message_attachments("here\nnhash1abc123/photo%201.png\n");

        assert_eq!(body, "here");
        assert_eq!(attachments.len(), 1);
        assert_eq!(attachments[0].nhash, "nhash1abc123");
        assert_eq!(attachments[0].filename, "photo 1.png");
        assert!(attachments[0].is_image);
    }

    #[test]
    fn accepts_htree_and_nhash_wrappers() {
        let (_, attachments) = extract_message_attachments(
            "htree://nhash1abc123/clip.mp4 nhash://nhash1def456/song.m4a",
        );

        assert_eq!(attachments.len(), 2);
        assert!(attachments[0].is_video);
        assert!(attachments[1].is_audio);
        assert_eq!(attachments[0].htree_url, "htree://nhash1abc123/clip.mp4");
    }

    #[test]
    fn ignores_invalid_links() {
        let (body, attachments) = extract_message_attachments("npub1abc/file.png nhash1bad");

        assert_eq!(body, "npub1abc/file.png nhash1bad");
        assert!(attachments.is_empty());
    }

    #[test]
    fn formats_attachment_messages_with_encoded_filename() {
        assert_eq!(
            format_attachment_links_message(
                "hello",
                &[("nhash1abc123".to_string(), "photo 1.png".to_string())],
            ),
            "hello\nnhash1abc123/photo%201.png"
        );
        assert_eq!(
            format_attachment_links_message(
                "",
                &[("nhash1abc123".to_string(), "m\u{00F6}te.txt".to_string())],
            ),
            "nhash1abc123/m%C3%B6te.txt"
        );
        assert_eq!(
            format_attachment_links_message(
                "album",
                &[
                    ("nhash1abc123".to_string(), "one.png".to_string()),
                    ("nhash1def456".to_string(), "two final.png".to_string()),
                ],
            ),
            "album\nnhash1abc123/one.png\nnhash1def456/two%20final.png"
        );
    }
}