//! @mention tracking — maps to `message_mention` table. //! //! Parsed from message body on send. Used for notification and //! "mentions" feed. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; /// An @mention of a user in a message. /// /// Maps to the `message_mention` table. Parsed from message body on send /// and used for notifications and the mentions feed. #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct MessageMention { pub id: Uuid, pub message_id: Uuid, pub channel_id: Uuid, pub mentioned_user_id: Uuid, pub mentioned_by: Uuid, /// When the mentioned user read the notification. pub read_at: Option>, pub created_at: DateTime, } /// Parse @username mentions from a message body. /// Returns unique usernames (without the `@` prefix). /// /// Matches `@word` where word is `[a-zA-Z0-9_-]+`, min 2 chars. /// Ignores `@@` (escaped) and mentions inside code spans/blocks. pub fn parse_mentions(body: &str) -> Vec { let mut seen = std::collections::HashSet::new(); let mut mentions = Vec::new(); // Simple state machine: skip content inside backtick spans. let mut in_code = false; let bytes = body.as_bytes(); let mut i = 0; while i < bytes.len() { if bytes[i] == b'`' { in_code = !in_code; i += 1; continue; } if !in_code && bytes[i] == b'@' { // Skip escaped @@ if i + 1 < bytes.len() && bytes[i + 1] == b'@' { i += 2; continue; } let start = i + 1; let mut end = start; while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_' || bytes[end] == b'-') { end += 1; } let len = end - start; if len >= 2 { let name = body[start..end].to_lowercase(); if seen.insert(name.clone()) { mentions.push(name); } } i = end; } else { i += 1; } } mentions } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_mentions_basic() { let m = parse_mentions("hey @alice, cc @bob"); assert_eq!(m, vec!["alice".to_string(), "bob".to_string()]); } #[test] fn test_parse_mentions_dedup() { let m = parse_mentions("@alice said hi to @alice"); assert_eq!(m, vec!["alice".to_string()]); } #[test] fn test_parse_mentions_case_insensitive() { let m = parse_mentions("@Alice and @ALICE"); assert_eq!(m, vec!["alice".to_string()]); } #[test] fn test_parse_mentions_skip_code_span() { let m = parse_mentions("use `@here` to notify @alice"); assert_eq!(m, vec!["alice".to_string()]); } #[test] fn test_parse_mentions_skip_escaped() { let m = parse_mentions("@@alice is not a mention, but @bob is"); assert_eq!(m, vec!["bob".to_string()]); } #[test] fn test_parse_mentions_short_name_ignored() { let m = parse_mentions("@a is too short, @ab is ok"); assert_eq!(m, vec!["ab".to_string()]); } #[test] fn test_parse_mentions_empty() { assert!(parse_mentions("no mentions here").is_empty()); assert!(parse_mentions("").is_empty()); } }