saorsa_core/messaging/
composer.rs

1// Message composer with rich text editing capabilities
2
3use super::SendMessageRequest;
4use super::types::*;
5use super::user_handle::UserHandle;
6use anyhow::Result;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Message composer for creating rich messages
11pub struct MessageComposer {
12    /// Draft messages by channel
13    drafts: HashMap<ChannelId, DraftMessage>,
14    /// Mention suggestions
15    mention_cache: Vec<UserHandle>,
16    /// Emoji shortcuts
17    emoji_shortcuts: HashMap<String, String>,
18}
19
20impl Default for MessageComposer {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26impl MessageComposer {
27    /// Create new message composer
28    pub fn new() -> Self {
29        let mut emoji_shortcuts = HashMap::new();
30        emoji_shortcuts.insert(":)".to_string(), "😊".to_string());
31        emoji_shortcuts.insert(":D".to_string(), "😃".to_string());
32        emoji_shortcuts.insert(":((".to_string(), "😢".to_string());
33        emoji_shortcuts.insert("<3".to_string(), "❤️".to_string());
34        emoji_shortcuts.insert(":fire:".to_string(), "🔥".to_string());
35        emoji_shortcuts.insert(":rocket:".to_string(), "🚀".to_string());
36        emoji_shortcuts.insert(":+1:".to_string(), "👍".to_string());
37        emoji_shortcuts.insert(":-1:".to_string(), "👎".to_string());
38
39        Self {
40            drafts: HashMap::new(),
41            mention_cache: Vec::new(),
42            emoji_shortcuts,
43        }
44    }
45
46    /// Start composing a message
47    pub fn start_draft(&mut self, channel_id: ChannelId) -> &mut DraftMessage {
48        self.drafts
49            .entry(channel_id)
50            .or_insert_with(|| DraftMessage::new(channel_id))
51    }
52
53    /// Get current draft
54    pub fn get_draft(&self, channel_id: ChannelId) -> Option<&DraftMessage> {
55        self.drafts.get(&channel_id)
56    }
57
58    /// Update draft text
59    pub fn update_draft(&mut self, channel_id: ChannelId, text: String) {
60        let draft = self.start_draft(channel_id);
61        draft.text = text;
62        draft.update_formatted();
63    }
64
65    /// Add mention to draft
66    pub fn add_mention(&mut self, channel_id: ChannelId, user: UserHandle) {
67        let draft = self.start_draft(channel_id);
68        draft.mentions.push(user.clone());
69
70        // Add to text
71        let mention_text = format!("@{} ", user);
72        draft.text.push_str(&mention_text);
73        draft.update_formatted();
74    }
75
76    /// Add attachment to draft
77    pub fn add_attachment(&mut self, channel_id: ChannelId, attachment: DraftAttachment) {
78        let draft = self.start_draft(channel_id);
79        draft.attachments.push(attachment);
80    }
81
82    /// Remove attachment from draft
83    pub fn remove_attachment(&mut self, channel_id: ChannelId, index: usize) {
84        if let Some(draft) = self.drafts.get_mut(&channel_id)
85            && index < draft.attachments.len()
86        {
87            draft.attachments.remove(index);
88        }
89    }
90
91    /// Set reply target
92    pub fn set_reply_to(&mut self, channel_id: ChannelId, message_id: MessageId) {
93        let draft = self.start_draft(channel_id);
94        draft.reply_to = Some(message_id);
95    }
96
97    /// Set thread target
98    pub fn set_thread(&mut self, channel_id: ChannelId, thread_id: ThreadId) {
99        let draft = self.start_draft(channel_id);
100        draft.thread_id = Some(thread_id);
101    }
102
103    /// Clear draft
104    pub fn clear_draft(&mut self, channel_id: ChannelId) {
105        self.drafts.remove(&channel_id);
106    }
107
108    /// Get mention suggestions
109    pub fn get_mention_suggestions(&self, partial: &str) -> Vec<UserHandle> {
110        self.mention_cache
111            .iter()
112            .filter(|user| {
113                user.as_str()
114                    .to_lowercase()
115                    .contains(&partial.to_lowercase())
116            })
117            .cloned()
118            .collect()
119    }
120
121    /// Update mention cache
122    pub fn update_mention_cache(&mut self, users: Vec<UserHandle>) {
123        self.mention_cache = users;
124    }
125
126    /// Apply text formatting
127    pub fn apply_formatting(&mut self, channel_id: ChannelId, format: TextFormat) {
128        let draft = self.start_draft(channel_id);
129
130        match format {
131            TextFormat::Bold => {
132                draft.text = format!("**{}**", draft.text);
133            }
134            TextFormat::Italic => {
135                draft.text = format!("*{}*", draft.text);
136            }
137            TextFormat::Code => {
138                draft.text = format!("`{}`", draft.text);
139            }
140            TextFormat::Strike => {
141                draft.text = format!("~~{}~~", draft.text);
142            }
143            TextFormat::Quote => {
144                draft.text = format!("> {}", draft.text);
145            }
146            TextFormat::CodeBlock(lang) => {
147                draft.text = format!("```{}\n{}\n```", lang, draft.text);
148            }
149        }
150
151        draft.update_formatted();
152    }
153
154    /// Insert emoji
155    pub fn insert_emoji(&mut self, channel_id: ChannelId, emoji: String) {
156        let draft = self.start_draft(channel_id);
157        draft.text.push_str(&emoji);
158        draft.update_formatted();
159    }
160
161    /// Convert emoji shortcuts
162    pub fn process_shortcuts(&mut self, channel_id: ChannelId) {
163        // Clone the shortcuts to avoid borrow conflicts
164        let shortcuts = self.emoji_shortcuts.clone();
165
166        let draft = self.start_draft(channel_id);
167
168        for (shortcut, emoji) in &shortcuts {
169            draft.text = draft.text.replace(shortcut, emoji);
170        }
171
172        draft.update_formatted();
173    }
174
175    /// Validate draft before sending
176    pub fn validate_draft(&self, channel_id: ChannelId) -> Result<()> {
177        let draft = self
178            .drafts
179            .get(&channel_id)
180            .ok_or_else(|| anyhow::anyhow!("No draft found"))?;
181
182        // Check if empty
183        if draft.text.trim().is_empty() && draft.attachments.is_empty() {
184            return Err(anyhow::anyhow!("Cannot send empty message"));
185        }
186
187        // Check message length
188        if draft.text.len() > 10000 {
189            return Err(anyhow::anyhow!("Message too long (max 10000 characters)"));
190        }
191
192        // Check attachment size
193        let total_size: usize = draft.attachments.iter().map(|a| a.size).sum();
194
195        if total_size > 100 * 1024 * 1024 {
196            return Err(anyhow::anyhow!("Total attachment size exceeds 100MB"));
197        }
198
199        Ok(())
200    }
201
202    /// Build message from draft
203    pub fn build_message(&self, channel_id: ChannelId) -> Result<SendMessageRequest> {
204        let draft = self
205            .drafts
206            .get(&channel_id)
207            .ok_or_else(|| anyhow::anyhow!("No draft found"))?;
208
209        // Create message content
210        let content = if draft.formatted_text.is_some() {
211            MessageContent::RichText(MarkdownContent {
212                raw: draft.text.clone(),
213                formatted: draft.formatted_text.clone().unwrap_or_default(),
214                mentions: draft.mentions.clone(),
215                links: draft.extract_links(),
216            })
217        } else {
218            MessageContent::Text(draft.text.clone())
219        };
220
221        // Convert attachments
222        let attachments = draft.attachments.iter().map(|a| a.data.clone()).collect();
223
224        Ok(SendMessageRequest {
225            channel_id,
226            content,
227            attachments,
228            thread_id: draft.thread_id,
229            reply_to: draft.reply_to,
230            mentions: draft.mentions.clone(),
231            ephemeral: draft.ephemeral,
232        })
233    }
234}
235
236/// Draft message being composed
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct DraftMessage {
239    pub channel_id: ChannelId,
240    pub text: String,
241    pub formatted_text: Option<String>,
242    pub mentions: Vec<UserHandle>,
243    pub attachments: Vec<DraftAttachment>,
244    pub reply_to: Option<MessageId>,
245    pub thread_id: Option<ThreadId>,
246    pub ephemeral: bool,
247    pub created_at: chrono::DateTime<chrono::Utc>,
248    pub updated_at: chrono::DateTime<chrono::Utc>,
249}
250
251impl DraftMessage {
252    /// Create new draft
253    fn new(channel_id: ChannelId) -> Self {
254        let now = chrono::Utc::now();
255        Self {
256            channel_id,
257            text: String::new(),
258            formatted_text: None,
259            mentions: Vec::new(),
260            attachments: Vec::new(),
261            reply_to: None,
262            thread_id: None,
263            ephemeral: false,
264            created_at: now,
265            updated_at: now,
266        }
267    }
268
269    /// Update formatted text from raw text
270    fn update_formatted(&mut self) {
271        // Simple markdown detection
272        if self.text.contains("**")
273            || self.text.contains("*")
274            || self.text.contains("`")
275            || self.text.contains("~~")
276        {
277            self.formatted_text = Some(self.text.clone());
278        }
279
280        self.updated_at = chrono::Utc::now();
281    }
282
283    /// Extract links from text
284    fn extract_links(&self) -> Vec<String> {
285        fn try_re(p: &str) -> Option<regex::Regex> {
286            regex::Regex::new(p).ok()
287        }
288        let url_regex = try_re(r"https?://[^\s<]+[^<.,:;'!\?\s]")
289            .or_else(|| try_re(r"https?://.+"))
290            .or_else(|| try_re(r"https?://.*"));
291
292        if let Some(re) = url_regex {
293            re.find_iter(&self.text)
294                .map(|m| m.as_str().to_string())
295                .collect()
296        } else {
297            Vec::new()
298        }
299    }
300}
301
302/// Draft attachment
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct DraftAttachment {
305    pub filename: String,
306    pub mime_type: String,
307    pub size: usize,
308    pub data: Vec<u8>,
309    pub thumbnail: Option<Vec<u8>>,
310}
311
312/// Text formatting options
313#[derive(Debug, Clone)]
314pub enum TextFormat {
315    Bold,
316    Italic,
317    Code,
318    Strike,
319    Quote,
320    CodeBlock(String),
321}
322
323/// Autocomplete suggestion
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct AutocompleteSuggestion {
326    pub text: String,
327    pub icon: String,
328    pub description: String,
329    pub action: AutocompleteAction,
330}
331
332/// Autocomplete action
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub enum AutocompleteAction {
335    InsertMention(UserHandle),
336    InsertEmoji(String),
337    InsertCommand(String),
338    InsertChannel(ChannelId),
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_draft_creation() {
347        let mut composer = MessageComposer::new();
348        let channel = ChannelId::new();
349
350        composer.update_draft(channel, "Hello world".to_string());
351
352        let draft = composer.get_draft(channel).unwrap();
353        assert_eq!(draft.text, "Hello world");
354    }
355
356    #[test]
357    fn test_mention_addition() {
358        let mut composer = MessageComposer::new();
359        let channel = ChannelId::new();
360        let user = UserHandle::from("alice");
361
362        composer.add_mention(channel, user.clone());
363
364        let draft = composer.get_draft(channel).unwrap();
365        assert!(draft.mentions.contains(&user));
366        assert!(draft.text.contains("@alice"));
367    }
368
369    #[test]
370    fn test_emoji_shortcuts() {
371        let mut composer = MessageComposer::new();
372        let channel = ChannelId::new();
373
374        composer.update_draft(channel, "Hello :) :fire:".to_string());
375        composer.process_shortcuts(channel);
376
377        let draft = composer.get_draft(channel).unwrap();
378        assert!(draft.text.contains("😊"));
379        assert!(draft.text.contains("🔥"));
380    }
381
382    #[test]
383    fn test_draft_validation() {
384        let mut composer = MessageComposer::new();
385        let channel = ChannelId::new();
386
387        // Empty draft should fail
388        let result = composer.validate_draft(channel);
389        assert!(result.is_err());
390
391        // Valid draft should pass
392        composer.update_draft(channel, "Valid message".to_string());
393        let result = composer.validate_draft(channel);
394        assert!(result.is_ok());
395    }
396}