diff --git a/Cargo.lock b/Cargo.lock index aa15ccf..cd56dfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1471,9 +1471,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -1486,7 +1486,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -1559,12 +1558,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1572,9 +1572,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1585,9 +1585,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1599,15 +1599,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1619,15 +1619,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1784,9 +1784,9 @@ dependencies = [ [[package]] name = "iri-string" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -1882,9 +1882,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.92" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ "cfg-if", "futures-util", @@ -1941,9 +1941,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libsqlite3-sys" @@ -1972,9 +1972,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "litrs" @@ -2509,12 +2509,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkg-config" version = "0.3.32" @@ -2632,9 +2626,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -3695,9 +3689,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -3879,7 +3873,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttp-core" version = "0.1.0" -source = "git+https://github.com/Tensamin/TTP.git#23c9e68da6622a0cb3a773881f353ae4489c9743" +source = "git+https://github.com/Tensamin/TTP.git#e246d1af6a42c71514d0ccc06fef94d71e4ad167" dependencies = [ "base64", "byteorder", @@ -3891,7 +3885,7 @@ dependencies = [ [[package]] name = "ttp-native" version = "0.1.0" -source = "git+https://github.com/Tensamin/TTP.git#23c9e68da6622a0cb3a773881f353ae4489c9743" +source = "git+https://github.com/Tensamin/TTP.git#e246d1af6a42c71514d0ccc06fef94d71e4ad167" dependencies = [ "quinn", "rustls", @@ -4123,9 +4117,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -4136,9 +4130,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.65" +version = "0.4.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" dependencies = [ "js-sys", "wasm-bindgen", @@ -4146,9 +4140,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4156,9 +4150,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -4169,9 +4163,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -4212,9 +4206,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.92" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" dependencies = [ "js-sys", "wasm-bindgen", @@ -4872,9 +4866,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4883,9 +4877,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -4895,18 +4889,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -4915,18 +4909,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -4956,9 +4950,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -4967,9 +4961,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -4978,9 +4972,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/README.md b/README.md index 0ea942b..f7bc9c8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1 @@ -# Iota -Iota is the selfhost server for establishing a connection between clients. It uses its counterpart Omikron to allow a connection between clients. +# ⚠️ Moved to [git.methanium.net](https://git.methanium.net/tensamin/iota) ⚠️ diff --git a/src/gui/elements/console_card.rs b/src/gui/elements/console_card.rs index a9c1d72..b189bd4 100644 --- a/src/gui/elements/console_card.rs +++ b/src/gui/elements/console_card.rs @@ -17,20 +17,30 @@ use crate::{ ui::FPS, util::borders::draw_block_joins, }, - log, log_cv, + log, log_command, log_cv, omikron::omikron_connection::OMIKRON_CONNECTION, users::{user_manager, user_profile::UserProfile}, util::file_util, }; -use std::{any::Any, time::Duration}; +use std::{ + any::Any, + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::time::Instant; pub struct ConsoleCard { focused: bool, pub title: String, pub content: String, + pub cursor_position: usize, borders: Borders, joins: Borders, + + cursor: Arc>, + last_swap: Arc>, + tab_index: usize, } impl ConsoleCard { @@ -39,10 +49,164 @@ impl ConsoleCard { focused: false, title: title.to_string(), content: content.to_string(), + cursor_position: content.chars().count(), borders: Borders::ALL, joins: Borders::NONE, + cursor: Arc::new(Mutex::new(true)), + last_swap: Arc::new(Mutex::new(Instant::now())), + tab_index: 0, } } + + fn byte_index(&self) -> usize { + self.content + .char_indices() + .nth(self.cursor_position) + .map(|(i, _)| i) + .unwrap_or(self.content.len()) + } + + fn cursor_visible(&self) -> bool { + if !self.focused { + return false; + } + + let mut visible = self.cursor.lock().unwrap(); + let mut last = self.last_swap.lock().unwrap(); + let now = Instant::now(); + + if now.duration_since(*last) >= Duration::from_millis(500) { + *visible = !*visible; + *last = now; + } + + *visible + } + + fn current_prefix(&self) -> Option<&str> { + if self.content.starts_with('/') { + Some("/") + } else { + None + } + } + + fn cursor_spans(&self) -> Vec> { + let cursor_visible = self.cursor_visible(); + let cursor_style = Style::default().fg(Color::White).bg(Color::DarkGray); + let mut spans = Vec::new(); + + if self.content.is_empty() { + if self.focused { + if cursor_visible { + spans.push(Span::styled(" ", cursor_style)); + } else { + spans.push(Span::styled(" ", Style::default().fg(Color::White))); + } + spans.push(Span::styled( + "send command ( for info)", + Style::default().fg(Color::DarkGray), + )); + } else { + spans.push(Span::styled( + " send command ( for info)", + Style::default().fg(Color::DarkGray), + )); + } + return spans; + } + + let byte_index = self.byte_index(); + let before = self.content[..byte_index].to_string(); + let after = self.content[byte_index..].to_string(); + + let prefix_len = self.current_prefix().map(|s| s.len()).unwrap_or(0); + + if prefix_len > 0 && before.len() >= prefix_len { + let prefix = &before[..prefix_len]; + let rest = &before[prefix_len..]; + spans.push(Span::styled( + prefix.to_string(), + Self::style_for_part(true, false, false), + )); + if !rest.is_empty() { + spans.push(Span::styled( + rest.to_string(), + Style::default().fg(Color::White), + )); + } + } else if !before.is_empty() { + spans.push(Span::styled( + before.clone(), + Style::default().fg(Color::White), + )); + } + + if cursor_visible { + spans.push(Span::styled(" ", cursor_style)); + } + + if !after.is_empty() { + spans.push(Span::styled(after, Style::default().fg(Color::White))); + } + + spans + } + + fn style_for_part(is_prefix: bool, is_hint: bool, is_error: bool) -> Style { + if is_error { + return Style::default().fg(Color::Red); + } + + if is_hint { + return Style::default().fg(Color::DarkGray); + } + + if is_prefix { + return Style::default().fg(Color::DarkGray); + } + + Style::default().fg(Color::White) + } + + fn render_cursor_spans(&self) -> Vec> { + self.cursor_spans() + } + + fn move_cursor_left(&mut self) { + if self.cursor_position > 0 { + self.cursor_position -= 1; + } + } + + fn move_cursor_right(&mut self) { + let len = self.content.chars().count(); + if self.cursor_position < len { + self.cursor_position += 1; + } + } + + fn delete_at_cursor(&mut self) { + if self.content.is_empty() || self.cursor_position == 0 { + return; + } + + let start = self + .content + .char_indices() + .nth(self.cursor_position.saturating_sub(1)) + .map(|(i, _)| i) + .unwrap_or(0); + let end = self.byte_index(); + self.content.replace_range(start..end, ""); + self.cursor_position -= 1; + } + + fn insert_at_cursor(&mut self, c: char) { + let idx = self.byte_index(); + self.content.insert(idx, c); + self.cursor_position += 1; + } } impl Element for ConsoleCard { @@ -70,7 +234,8 @@ impl Element for ConsoleCard { Style::default() }); - let par = Paragraph::new(Line::from(Span::from(self.content.clone()))) + let spans = self.render_cursor_spans(); + let par = Paragraph::new(Line::from(spans)) .block(block) .scroll((0, 0)); f.render_widget(par, r); @@ -87,11 +252,11 @@ impl JoinableElement for ConsoleCard { self } - fn as_element(&self) -> &dyn Element { + fn as_element(&self) -> &(dyn Element + 'static) { self } - fn as_element_mut(&mut self) -> &mut dyn Element { + fn as_element_mut(&mut self) -> &mut (dyn Element + 'static) { self } @@ -113,11 +278,11 @@ impl InteractableElement for ConsoleCard { self } - fn as_element(&self) -> &dyn Element { + fn as_element(&self) -> &(dyn Element + 'static) { self } - fn as_element_mut(&mut self) -> &mut dyn Element { + fn as_element_mut(&mut self) -> &mut (dyn Element + 'static) { self } @@ -128,26 +293,71 @@ impl InteractableElement for ConsoleCard { log!(""); return InteractionResult::Handled; } + let command = self.content.clone(); let id = Uuid::new_v4(); let id = id.to_string(); let id = id.split_at(8).0; let task_id = format!("command_{}_{}", command, id); ACTIVE_TASKS.insert(task_id.clone()); + + log_command!("{}", command); + tokio::spawn(async move { run_command(&command).await; ACTIVE_TASKS.remove(&task_id); }); - self.content = "".to_string(); + + self.content.clear(); + self.cursor_position = 0; + self.tab_index = 0; InteractionResult::Handled } KeyCode::Backspace => { - self.content.pop(); + self.delete_at_cursor(); + InteractionResult::Handled + } + KeyCode::Delete => { + let len = self.content.chars().count(); + if self.cursor_position < len { + let start = self.byte_index(); + let end = self + .content + .char_indices() + .nth(self.cursor_position + 1) + .map(|(i, _)| i) + .unwrap_or(self.content.len()); + self.content.replace_range(start..end, ""); + } + InteractionResult::Handled + } + KeyCode::Left => { + self.move_cursor_left(); + InteractionResult::Handled + } + KeyCode::Right => { + self.move_cursor_right(); + InteractionResult::Handled + } + KeyCode::Home => { + self.cursor_position = 0; + InteractionResult::Handled + } + KeyCode::End => { + self.cursor_position = self.content.chars().count(); + InteractionResult::Handled + } + KeyCode::Tab => { + if let Some(prefix) = self.current_prefix() { + if prefix == "/" { + self.tab_index = self.tab_index.saturating_add(1); + } + } InteractionResult::Handled } _ => { if let Some(c) = key.code.as_char() { - self.content.push(c); + self.insert_at_cursor(c); InteractionResult::Handled } else { InteractionResult::Unhandled @@ -168,9 +378,8 @@ impl InteractableElement for ConsoleCard { self.focused = f; } } -pub async fn run_command(command: &str) { - log!(":{}", command); +pub async fn run_command(command: &str) { let parts = command.split(" ").collect::>(); match parts.as_slice() { @@ -267,8 +476,6 @@ pub async fn run_command(command: &str) { } pub async fn ping(time: u64) { - let time = time; - let conn = OMIKRON_CONNECTION.clone(); let response_cv = conn diff --git a/src/gui/elements/log_card.rs b/src/gui/elements/log_card.rs index fe125c8..e2d4ba1 100755 --- a/src/gui/elements/log_card.rs +++ b/src/gui/elements/log_card.rs @@ -1,27 +1,76 @@ -use crate::gui::elements::elements::{InteractableElement, JoinableElement}; +use crate::APP_STATE; +use crate::gui::elements::elements::{Element, InteractableElement, JoinableElement}; use crate::gui::interaction_result::InteractionResult; use crate::gui::util::borders::draw_block_joins; -use crate::{APP_STATE, gui::elements::elements::Element}; +use crate::util::logger::PrintType; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, layout::Rect, style::{Color, Style}, text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Wrap}, + widgets::{Block, Borders, Paragraph}, }; use std::any::Any; +use std::time::{SystemTime, UNIX_EPOCH}; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct UiLogEntry { - pub line: String, - pub color: Color, + pub timestamp_ms: u128, + pub sender: PrintType, + pub message: String, + pub is_error: bool, +} + +impl UiLogEntry { + pub fn format_timestamp(&self) -> String { + let secs = (self.timestamp_ms / 1000) as i64; + let hours = (secs / 3600) % 24; + let minutes = (secs / 60) % 60; + let seconds = secs % 60; + format!("{:02}:{:02}:{:02}", hours, minutes, seconds) + } +} + +impl From for UiLogEntry { + fn from(entry: LogEntry) -> Self { + Self { + timestamp_ms: entry.timestamp_ms, + sender: entry.sender, + message: entry.message, + is_error: entry.is_error, + } + } +} + +#[derive(Clone, Debug)] +pub struct LogEntry { + pub timestamp_ms: u128, + pub sender: PrintType, + pub message: String, + pub is_error: bool, +} + +impl LogEntry { + pub fn new(sender: PrintType, message: String, is_error: bool) -> Self { + Self { + timestamp_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(), + sender, + message, + is_error, + } + } } pub struct LogCard { focused: bool, - scroll: u16, - + selected: bool, + scroll_offset: usize, + last_total_lines: usize, + last_visible_height: usize, pub borders: Borders, pub joins: Borders, } @@ -30,12 +79,213 @@ impl LogCard { pub fn new() -> Self { Self { focused: false, - scroll: 1, + selected: false, + scroll_offset: 0, + last_total_lines: 0, + last_visible_height: 10, borders: Borders::ALL, joins: Borders::NONE, } } + + fn get_logs(&self) -> Vec { + let state = APP_STATE.lock().unwrap(); + state.get_logs().iter().cloned().collect() + } + + fn find_split_point(s: &str, max_width: usize) -> usize { + if max_width == 0 { + return s.len(); + } + + let mut current_width = 0usize; + let mut last_boundary = 0usize; + + for (idx, ch) in s.char_indices() { + let char_width = if ch.is_ascii() { 1 } else { 2 }; + if current_width + char_width > max_width { + if last_boundary == 0 { + return idx + ch.len_utf8(); + } + return last_boundary; + } + current_width += char_width; + last_boundary = idx + ch.len_utf8(); + } + + s.len() + } + + fn wrap_entry(entry: &UiLogEntry, available_width: usize) -> Vec<(String, Color, bool)> { + let mut result = Vec::new(); + + let timestamp = entry.format_timestamp(); + let first_prefix = "┌ "; + let default_prefix = "│ "; + let last_prefix = "└ "; + + let prefix_width = 2; + let first_line_width = available_width.saturating_sub(prefix_width + 1); + let continuation_width = available_width.saturating_sub(prefix_width); + + let segments: Vec<&str> = entry.message.split('\n').collect(); + + let mut raw_lines = Vec::new(); + + for segment in segments { + let mut remaining = segment; + if remaining.is_empty() { + raw_lines.push(String::new()); + continue; + } + + let mut is_first_part = true; + while !remaining.is_empty() { + let current_width = if is_first_part { + first_line_width + } else { + continuation_width + }; + + let split_point = Self::find_split_point(remaining, current_width); + let line_content = remaining[..split_point].to_string(); + raw_lines.push(line_content); + remaining = &remaining[split_point..]; + is_first_part = false; + } + } + + if raw_lines.is_empty() { + raw_lines.push(String::new()); + } + + for (idx, content) in raw_lines.iter().enumerate() { + let is_last = idx + 1 == raw_lines.len(); + let is_single = raw_lines.len() == 1; + let is_first = idx == 0; + let prefix = if is_first { + first_prefix + } else if is_last && !is_single { + last_prefix + } else { + default_prefix + }; + + let mut line = String::from(prefix); + line.push_str(content); + + if is_last && !timestamp.is_empty() { + line.push(' '); + line.push_str(×tamp); + } + + result.push((line, entry.sender.prefix_color(), entry.is_error)); + } + + result + } + + fn build_all_lines( + &self, + entries: Vec, + width: usize, + ) -> Vec<(String, Color, bool)> { + let mut lines = Vec::new(); + + for entry in entries { + let wrapped = Self::wrap_entry(&entry, width); + lines.extend(wrapped); + } + + lines + } + + fn calculate_view_window(&self, total_lines: usize, visible_height: usize) -> (usize, usize) { + if total_lines <= visible_height { + return (0, total_lines); + } + + let max_offset = total_lines - visible_height; + let clamped_offset = self.scroll_offset.min(max_offset); + + let end = total_lines - clamped_offset; + let start = end.saturating_sub(visible_height); + + (start, end) + } + + fn get_title_hints(&self) -> (bool, bool) { + if self.last_total_lines == 0 || self.last_total_lines <= self.last_visible_height { + return (false, false); + } + + let max_offset = self.last_total_lines - self.last_visible_height; + let can_scroll_up = self.scroll_offset < max_offset; + let can_scroll_down = self.scroll_offset > 0; + + (can_scroll_up, can_scroll_down) + } + + fn build_title(&self) -> String { + if !self.focused { + return "Logs".to_string(); + } + + let (can_up, can_down) = self.get_title_hints(); + + if !can_up && !can_down { + return "Logs".to_string(); + } + + let nav_symbol = if self.selected { "↑" } else { "j" }; + let down_symbol = if self.selected { "↓" } else { "k" }; + + match (can_up, can_down) { + (true, true) => format!("Logs ({} older {} newer)", nav_symbol, down_symbol), + (true, false) => format!("Logs ({} older)", nav_symbol), + (false, true) => format!("Logs ({} newer)", down_symbol), + (false, false) => "Logs".to_string(), + } + } + + fn scroll_up(&mut self) { + let max_offset = self + .last_total_lines + .saturating_sub(self.last_visible_height); + self.scroll_offset = (self.scroll_offset + 1).min(max_offset); + } + + fn scroll_down(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } + + fn split_line_prefix(line: &str) -> (&str, &str) { + if let Some(rest) = line.strip_prefix("└ ") { + ("└ ", rest) + } else if let Some(rest) = line.strip_prefix("│ ") { + ("│ ", rest) + } else if let Some(rest) = line.strip_prefix("┌ ") { + ("┌ ", rest) + } else { + ("", line) + } + } + + fn split_timestamp_suffix(line: &str) -> (&str, &str) { + if let Some(idx) = line.rfind(' ') { + let possible_timestamp = &line[idx + 1..]; + if possible_timestamp.len() == 8 + && possible_timestamp.as_bytes()[2] == b':' + && possible_timestamp.as_bytes()[5] == b':' + { + let (content, timestamp_with_space) = line.split_at(idx); + return (content.trim_end(), timestamp_with_space); + } + } + (line, "") + } } + impl Element for LogCard { fn as_any(&self) -> &dyn Any { self @@ -46,22 +296,10 @@ impl Element for LogCard { } fn render(&self, f: &mut Frame, area: Rect) { - let state = APP_STATE.lock().unwrap(); - let logs = state.get_logs(); - - let lines: Vec = logs - .iter() - .map(|log| { - Line::from(Span::raw(log.line.clone())).style(Style::default().fg(log.color)) - }) - .collect(); + let entries = self.get_logs(); let block = Block::default() - .title(if self.focused { - "Logs J/K to scroll" - } else { - "Logs" - }) + .title(self.build_title()) .borders(self.borders) .border_style(if self.focused { Style::default().fg(Color::Yellow) @@ -69,22 +307,70 @@ impl Element for LogCard { Style::default() }); - let inner_height = area.height.saturating_sub(2) as usize; + let inner_area = block.inner(area); + f.render_widget(block, area); - let total_lines = lines.len(); - let base_scroll = total_lines.saturating_sub(inner_height) as u16; + if inner_area.width == 0 || inner_area.height == 0 { + draw_block_joins(f, area, self.borders, self.joins); + return; + } - let scroll = base_scroll.saturating_sub(self.scroll); + let all_lines = self.build_all_lines(entries, inner_area.width as usize); + let total_lines = all_lines.len(); + let visible_height = inner_area.height as usize; - let paragraph = Paragraph::new(lines) - .block(block) - .wrap(Wrap { trim: false }) - .scroll((scroll, 0)); + let (start, end) = self.calculate_view_window(total_lines, visible_height); + let visible_lines = &all_lines[start..end]; + + let rendered_lines: Vec = visible_lines + .iter() + .map(|(line, prefix_color, is_error)| { + let mut spans = Vec::new(); + + let (prefix, rest) = Self::split_line_prefix(line); + + if !prefix.is_empty() { + spans.push(Span::styled( + prefix.to_string(), + Style::default().fg(*prefix_color), + )); + } + + let (content, timestamp) = Self::split_timestamp_suffix(rest); + let text_color = if *is_error { Color::Red } else { Color::White }; + + if !content.is_empty() { + spans.push(Span::styled( + content.to_string(), + Style::default().fg(text_color), + )); + } + + if !timestamp.is_empty() { + spans.push(Span::styled( + timestamp.to_string(), + Style::default().fg(Color::DarkGray), + )); + } + + Line::from(spans) + }) + .collect(); + + for (idx, line) in rendered_lines.iter().enumerate() { + let line_area = Rect { + x: inner_area.x, + y: inner_area.y + idx as u16, + width: inner_area.width, + height: 1, + }; + f.render_widget(Paragraph::new(line.clone()), line_area); + } - f.render_widget(paragraph, area); draw_block_joins(f, area, self.borders, self.joins); } } + impl JoinableElement for LogCard { fn as_any(&self) -> &dyn Any { self @@ -94,12 +380,12 @@ impl JoinableElement for LogCard { self } - fn as_element(&self) -> &dyn Element { - self + fn as_element(&self) -> &(dyn Element + 'static) { + &*self } - fn as_element_mut(&mut self) -> &mut dyn Element { - self + fn as_element_mut(&mut self) -> &mut (dyn Element + 'static) { + &mut *self } fn set_borders(&mut self, borders: Borders) { @@ -110,6 +396,7 @@ impl JoinableElement for LogCard { self.joins = joins; } } + impl InteractableElement for LogCard { fn as_any(&self) -> &dyn Any { self @@ -119,30 +406,65 @@ impl InteractableElement for LogCard { self } - fn as_element(&self) -> &dyn Element { - self + fn as_element(&self) -> &(dyn Element + 'static) { + &*self } - fn as_element_mut(&mut self) -> &mut dyn Element { - self + fn as_element_mut(&mut self) -> &mut (dyn Element + 'static) { + &mut *self } fn interact(&mut self, key: KeyEvent) -> InteractionResult { + let entries = self.get_logs(); + let estimated_width = 80usize; + let all_lines = self.build_all_lines(entries, estimated_width); + + self.last_total_lines = all_lines.len(); + let visible_height = self.last_visible_height.max(1); + match key.code { - KeyCode::Char('J') | KeyCode::Char('j') => { - let state = APP_STATE.lock().unwrap(); - let logs = state.get_logs(); - if self.scroll < logs.len() as u16 { - self.scroll += 1; + KeyCode::Enter | KeyCode::Char(' ') => { + self.selected = !self.selected; + InteractionResult::Handled + } + KeyCode::Char('j') | KeyCode::Char('J') => { + let (can_up, _) = self.get_title_hints(); + if can_up { + self.scroll_up(); } InteractionResult::Handled } - KeyCode::Char('K') | KeyCode::Char('k') => { - if self.scroll > 1 { - self.scroll -= 1; + KeyCode::Char('k') | KeyCode::Char('K') => { + let (_, can_down) = self.get_title_hints(); + if can_down { + self.scroll_down(); } InteractionResult::Handled } + KeyCode::Up if self.selected => { + let (can_up, _) = self.get_title_hints(); + if can_up { + self.scroll_up(); + } + InteractionResult::Handled + } + KeyCode::Down if self.selected => { + let (_, can_down) = self.get_title_hints(); + if can_down { + self.scroll_down(); + } + InteractionResult::Handled + } + KeyCode::Home => { + if self.last_total_lines > visible_height { + self.scroll_offset = self.last_total_lines - visible_height; + } + InteractionResult::Handled + } + KeyCode::End => { + self.scroll_offset = 0; + InteractionResult::Handled + } _ => InteractionResult::Unhandled, } } diff --git a/src/gui/input_handler.rs b/src/gui/input_handler.rs index d4e0d45..b2557c5 100644 --- a/src/gui/input_handler.rs +++ b/src/gui/input_handler.rs @@ -1,59 +1,51 @@ -use crate::ACTIVE_TASKS; use crate::gui::ui::{UI, UNIQUE}; use crate::{RELOAD, SHUTDOWN}; -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, poll, read}; +use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read}; use std::sync::Arc; use std::sync::atomic::Ordering; use std::time::Duration; pub fn setup_input_handler(ui: Arc) { tokio::spawn(async move { - ACTIVE_TASKS.insert("Input Handler".to_string()); - loop { - { - let should_shutdown = *SHUTDOWN.read().await; - if should_shutdown { - break; - } + if *SHUTDOWN.read().await { + break; } - let has_event = match poll(Duration::from_millis(100)) { - Ok(true) => true, - Ok(false) => false, - Err(_) => false, - }; + let event_result = tokio::task::spawn_blocking(|| { + if let Ok(true) = poll(Duration::from_millis(100)) { + read().ok().and_then(|ev| match ev { + Event::Key(key) if key.kind == KeyEventKind::Press => Some(key), + _ => None, + }) + } else { + None + } + }) + .await; - if has_event { - match read() { - Ok(event) => { - if let Event::Key(key_event) = event { - if key_event.kind == KeyEventKind::Press { - let uic = ui.clone(); - handle_input(key_event, uic).await; - UNIQUE.store(true, Ordering::Relaxed); - } - } - } - Err(_) => (), + match event_result { + Ok(Some(key_event)) => { + handle_input(key_event, ui.clone()).await; + UNIQUE.store(true, Ordering::Relaxed); + } + Ok(_) => {} + Err(e) => { + eprintln!("Input task error: {}", e); + tokio::time::sleep(Duration::from_millis(10)).await; } } } - { - ACTIVE_TASKS.remove("Input Handler"); - } }); } pub async fn handle_input(key: KeyEvent, ui: Arc) { match (key.code, key.modifiers) { - (KeyCode::Char('q'), KeyModifiers::CONTROL) => { + (crossterm::event::KeyCode::Char('q'), KeyModifiers::CONTROL) + | (crossterm::event::KeyCode::Char('c'), KeyModifiers::CONTROL) => { *SHUTDOWN.write().await = true; } - (KeyCode::Char('c'), KeyModifiers::CONTROL) => { - *SHUTDOWN.write().await = true; - } - (KeyCode::Char('r'), KeyModifiers::CONTROL) => { + (crossterm::event::KeyCode::Char('r'), KeyModifiers::CONTROL) => { *RELOAD.write().await = true; *SHUTDOWN.write().await = true; } diff --git a/src/omikron/omikron_connection.rs b/src/omikron/omikron_connection.rs index 3d60510..7e92269 100755 --- a/src/omikron/omikron_connection.rs +++ b/src/omikron/omikron_connection.rs @@ -1,7 +1,7 @@ use crate::users::contact::Contact; -use crate::users::user_community_util::UserCommunityUtil; use crate::util::chat_files::{MessageState, change_message_state}; use crate::util::chats_util::{get_user, mod_user}; +use crate::util::communities_util::CommunitiesUtil; use crate::util::crypto_util::{DataFormat, SecurePayload}; use crate::util::file_util::{get_children, load_file, save_file}; use crate::util::{chat_files, chats_util}; @@ -283,11 +283,16 @@ impl OmikronConnection { let key_pair = crypto_helper::generate_keypair(); let public_key_base64 = crypto_helper::public_key_to_base64(&key_pair.public); - let private_key_base64 = crypto_helper::secret_key_to_base64(&key_pair.secret); + let _private_key_base64 = crypto_helper::secret_key_to_base64(&key_pair.secret); let mut conf_write = CONFIG.write().await; - /*conf_write.change("public_key", DataValue::Str(public_key_base64.clone())); - conf_write.change("private_key", DataValue::Str(private_key_base64));*/ + // NOTE: + // Intentionally not storing the generated private/public keys directly into the + // config file here to avoid persisting sensitive material in plaintext. If you + // want to persist them, uncomment the two lines below and accept the security + // implications (they will be saved by `conf_write.update()`). + // conf_write.change("public_key", DataValue::Str(public_key_base64.clone())); + // conf_write.change("private_key", DataValue::Str(private_key_base64)); conf_write.update(); drop(conf_write); @@ -418,7 +423,7 @@ impl OmikronConnection { } if cv.is_type(CommunicationType::identification_response) { - if let Some(accepted) = cv.get_data(DataTypes::accepted).as_bool() { + if let Some(_accepted) = cv.get_data(DataTypes::accepted).as_bool() { let mut state = self.state.write().await; if let ConnectionState::Connected { identified: _ } = *state { *state = ConnectionState::Connected { identified: true }; @@ -430,6 +435,7 @@ impl OmikronConnection { // ************************************************ // // Direct messages // // ************************************************ // + if cv.is_type(CommunicationType::message_state) { let sender_id = &cv.get_sender(); let receiver_id = &cv.get_receiver(); @@ -458,6 +464,160 @@ impl OmikronConnection { ); } + // Incoming stored message: store for the recipient, attempt local delivery, notify sender. + if cv.is_type(CommunicationType::message_send) { + let sender_id: i64 = if let Some(n) = cv.get_data(DataTypes::sender_id).as_number() { + n as i64 + } else if let Some(s) = cv.get_data(DataTypes::sender_id).as_str() { + s.parse::().unwrap_or(0) + } else { + 0 + }; + + // parse receiver_id (the storage owner for this incoming message) + let receiver_id: i64 = if let Some(n) = cv.get_data(DataTypes::receiver_id).as_number() + { + n as i64 + } else if let Some(s) = cv.get_data(DataTypes::receiver_id).as_str() { + s.parse::().unwrap_or(0) + } else { + 0 + }; + + // parse send_time robustly (number or string), fallback to now + let send_time_val = cv.get_data(DataTypes::send_time); + let now_i64 = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + let timestamp_i64 = if let Some(n) = send_time_val.as_number() { + n as i64 + } else if let Some(s) = send_time_val.as_str() { + s.parse::().unwrap_or(now_i64) + } else { + now_i64 + }; + let timestamp_u128 = timestamp_i64 as u128; + + // content may be missing; default to empty string + let content = cv + .get_data(DataTypes::content) + .as_str() + .unwrap_or("") + .to_string(); + + let height = cv.get_data(DataTypes::height).as_number().unwrap_or(0) as i64; + + // persist message for the receiver (storage_owner = receiver_id) + chat_files::add_message( + timestamp_u128, + false, + receiver_id as i64, + sender_id as i64, + &content, + height, + ); + + // persist message for the sender (storage_owner = sender_id) + chat_files::add_message( + timestamp_u128, + true, + sender_id as i64, + receiver_id as i64, + &content, + height, + ); + + // send confirmation back to sender + let conf_msg = CommunicationValue::new(CommunicationType::message_send) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64); + self.send_message(&conf_msg).await; + + // Build a live-delivery message for the local client (recipient) + let user_forward = CommunicationValue::new(CommunicationType::message_live) + .with_id(cv.get_id()) + .with_receiver(receiver_id as u64) + .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) + .add_data(DataTypes::content, DataValue::Str(content.clone())) + .add_data(DataTypes::sender_id, DataValue::Number(sender_id as i64)) + .add_data(DataTypes::height, DataValue::Number(height)); + + // Attempt delivery and await a response from the local client + let user_resp = self + .clone() + .await_response(&user_forward, Some(Duration::from_secs(10))) + .await; + + if let Ok(user_resp) = user_resp { + let ms_raw = user_resp + .get_data(DataTypes::message_state) + .as_string() + .unwrap_or_else(|| "".to_string()); + let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); + + // update stored message state for receiver + let _ = chat_files::change_message_state( + timestamp_i64, + receiver_id as i64, + sender_id as i64, + ms.clone(), + ); + + // update stored message state for sender + let _ = chat_files::change_message_state( + timestamp_i64, + sender_id as i64, + receiver_id as i64, + ms.clone(), + ); + + // notify original sender about the delivered/read state + self.send_message( + &CommunicationValue::new(CommunicationType::message_state) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) + .add_data( + DataTypes::message_state, + DataValue::Str(ms.as_str().to_string()), + ), + ) + .await; + } else { + // Delivery failed or timed out; mark as Sent + let _ = chat_files::change_message_state( + timestamp_i64, + receiver_id as i64, + sender_id as i64, + MessageState::Sent, + ); + + let _ = chat_files::change_message_state( + timestamp_i64, + sender_id as i64, + receiver_id as i64, + MessageState::Sent, + ); + + // notify sender + self.send_message( + &CommunicationValue::new(CommunicationType::message_state) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) + .add_data( + DataTypes::message_state, + DataValue::Str(MessageState::Sent.as_str().to_string()), + ), + ) + .await; + } + return; + } + if cv.is_type(CommunicationType::message_other_iota) { let sender_id = &cv.get_sender(); let receiver_id = &cv.get_receiver(); @@ -483,26 +643,25 @@ impl OmikronConnection { .unwrap_or("") .to_string(); + let height = cv.get_data(DataTypes::height).as_number().unwrap_or(0) as i64; + chat_files::add_message( timestamp as u128, false, *receiver_id as i64, *sender_id as i64, &content, + height, ); + // Build user_forward using the parsed numeric timestamp and safe content string let user_forward = CommunicationValue::new(CommunicationType::message_live) .with_id(cv.get_id()) .with_receiver(*receiver_id) - .add_data( - DataTypes::send_time, - cv.get_data(DataTypes::send_time).clone(), - ) - .add_data(DataTypes::message, cv.get_data(DataTypes::content).clone()) - .add_data( - DataTypes::sender_id, - DataValue::Number(cv.get_sender() as i64), - ); + .add_data(DataTypes::send_time, DataValue::Number(timestamp)) + .add_data(DataTypes::content, DataValue::Str(content.clone())) + .add_data(DataTypes::sender_id, DataValue::Number(*sender_id as i64)) + .add_data(DataTypes::height, DataValue::Number(height)); let user_resp = self .clone() @@ -528,10 +687,7 @@ impl OmikronConnection { .with_id(cv.get_id()) .with_receiver(*sender_id) .with_sender(*receiver_id) - .add_data( - DataTypes::send_time, - cv.get_data(DataTypes::send_time).clone(), - ) + .add_data(DataTypes::send_time, DataValue::Number(timestamp)) .add_data( DataTypes::message_state, DataValue::Str(ms.as_str().to_string()), @@ -539,15 +695,20 @@ impl OmikronConnection { ) .await; } else { + // Delivery timed out/failed — update stored state and notify sender with numeric timestamp + let _ = chat_files::change_message_state( + timestamp, + *receiver_id as i64, + *sender_id as i64, + MessageState::Sent, + ); + self.send_message( &CommunicationValue::new(CommunicationType::message_state) .with_id(cv.get_id()) .with_receiver(*sender_id) .with_sender(*receiver_id) - .add_data( - DataTypes::send_time, - cv.get_data(DataTypes::send_time).clone(), - ) + .add_data(DataTypes::send_time, DataValue::Number(timestamp)) .add_data( DataTypes::message_state, DataValue::Str(MessageState::Sent.as_str().to_string()), @@ -558,90 +719,21 @@ impl OmikronConnection { return; } - if cv.is_type(CommunicationType::message_send) { - let my_id = cv.get_sender(); - - // parse other id robustly (number or string) - let other_id = if let Some(n) = cv.get_data(DataTypes::receiver_id).as_number() { - n as i64 - } else if let Some(s) = cv.get_data(DataTypes::receiver_id).as_str() { - s.parse::().unwrap_or(0) - } else { - 0 - }; - - let now_ms_u128 = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u128; - // derive an i64 timestamp for protocol fields; fall back to current time if out of range - let now_ms_i64: i64 = match i64::try_from(now_ms_u128) { - Ok(v) => v, - Err(_) => SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64, - }; - - // safe content extraction - let content = cv - .get_data(DataTypes::content) - .as_str() - .unwrap_or("") - .to_string(); - - chat_files::add_message(now_ms_u128, true, my_id as i64, other_id, &content); - - let ack = CommunicationValue::new(CommunicationType::success) - .with_id(cv.get_id()) - .with_receiver(my_id); - self.send_message(&ack).await; - - let forward = CommunicationValue::new(CommunicationType::message_other_iota) - .with_id(cv.get_id()) - .with_receiver(other_id as u64) - .add_data(DataTypes::receiver_id, DataValue::Number(other_id)) - .with_sender(my_id) - .add_data(DataTypes::send_time, DataValue::Number(now_ms_i64)) - .add_data(DataTypes::sender_id, DataValue::Number(my_id as i64)) - .add_data(DataTypes::content, DataValue::Str(content)); - if let Err(err) = self.send_message_result(&forward).await { - // sending failed - record via existing logging path - log_t!("send_message_failed", err); - } else { - // forwarding succeeded -> update stored message state to Sent - let _ = chat_files::change_message_state( - now_ms_i64, - my_id as i64, - other_id, - MessageState::Sent, - ); - } - return; - } - if cv.is_type(CommunicationType::messages_get) { let my_id = cv.get_sender(); let partner_id = cv.get_data(DataTypes::user_id).as_number().unwrap_or(0); let offset = cv.get_data(DataTypes::offset).as_number().unwrap_or(0); let amount = cv.get_data(DataTypes::amount).as_number().unwrap_or(0); - // retrieve raw JSON messages let messages = chat_files::get_messages(my_id as i64, partner_id, offset, amount); - // convert JSON array -> protocol Array of Containers (send_time, content, sender_id, message_state) let mut msg_array: Vec = Vec::new(); for m in messages.members() { - // extract fields defensively let message_time: i64 = m["message_time"].as_i64().unwrap_or(0); let content: String = m["content"].as_str().unwrap_or("").to_string(); let sent_by_self: bool = m["sent_by_self"].as_bool().unwrap_or(false); - // determine sender id: - // - if sent_by_self => sender is the requester (my_id) - // - otherwise prefer an explicit chat_partner_id if present on the request, - // fallback to the partner_id parameter + let height: i64 = m["height"].as_i64().unwrap_or(0); let sender_id: i64 = if sent_by_self { my_id as i64 } else { - // check for chat_partner_id in the incoming request (accept number or string) if let Some(n) = cv.get_data(DataTypes::chat_partner_id).as_number() { n as i64 } else if let Some(s) = cv.get_data(DataTypes::chat_partner_id).as_str() { @@ -654,9 +746,11 @@ impl OmikronConnection { let mut container = Vec::new(); container.push((DataTypes::send_time, DataValue::Number(message_time))); - container.push((DataTypes::message, DataValue::Str(content))); + container.push((DataTypes::content, DataValue::Str(content))); container.push((DataTypes::sender_id, DataValue::Number(sender_id))); container.push((DataTypes::message_state, DataValue::Str(message_state))); + container.push((DataTypes::height, DataValue::Number(height))); + container.push((DataTypes::sent_by_self, DataValue::Bool(sent_by_self))); msg_array.push(DataValue::Container(container)); } @@ -724,7 +818,7 @@ impl OmikronConnection { } if cv.is_type(CommunicationType::add_community) { - UserCommunityUtil::add_community( + CommunitiesUtil::add_community( cv.get_sender() as i64, cv.get_data(DataTypes::community_address) .as_str() @@ -747,19 +841,37 @@ impl OmikronConnection { } if cv.is_type(CommunicationType::get_communities) { + let mut comm_array = Vec::new(); + for c in CommunitiesUtil::get_communities(cv.get_sender() as i64) { + let mut container: Vec<(DataTypes, DataValue)> = Vec::new(); + if let Some(address) = c["address"].as_str() { + container.push(( + DataTypes::community_address, + DataValue::Str(address.to_string()), + )); + } + if let Some(title) = c["title"].as_str() { + container.push(( + DataTypes::community_title, + DataValue::Str(title.to_string()), + )); + } + if let Some(position) = c["position"].as_str() { + container.push((DataTypes::position, DataValue::Str(position.to_string()))); + } + comm_array.push(DataValue::Container(container)); + } + let resp = CommunicationValue::new(CommunicationType::get_communities) .with_id(cv.get_id()) .with_receiver(cv.get_sender()) - /*.add_data( - DataTypes::communities, - DataValue::Array(UserCommunityUtil::get_communities(cv.get_sender() as i64)), - ) */; + .add_data(DataTypes::communities, DataValue::Array(comm_array)); self.send_message(&resp).await; return; } if cv.is_type(CommunicationType::remove_community) { - UserCommunityUtil::remove_community( + CommunitiesUtil::remove_community( cv.get_sender() as i64, cv.get_data(DataTypes::community_address) .as_str() diff --git a/src/util/chat_files.rs b/src/util/chat_files.rs index bfdc184..e54fe29 100644 --- a/src/util/chat_files.rs +++ b/src/util/chat_files.rs @@ -1,9 +1,9 @@ use crate::log; -use crate::util::file_util::get_directory; +use crate::util::db; use json::{JsonValue, array, object}; -use rusqlite::{Connection, params}; +use rusqlite::params; use std::io; -use std::sync::{LazyLock, Mutex}; +use std::sync::{Arc, LazyLock, Mutex}; #[derive(PartialEq, Debug, Clone)] pub enum MessageState { @@ -45,30 +45,10 @@ impl MessageState { } } -static DB_CONN: LazyLock> = LazyLock::new(|| { - let conn = Connection::open(format!("{}/messages.sqlite3", get_directory())) - .expect("Failed to open messages sqlite DB"); - conn.execute_batch( - r#" - PRAGMA journal_mode = WAL; - PRAGMA synchronous = NORMAL; - - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - storage_owner INTEGER NOT NULL, - external_user INTEGER NOT NULL, - message_time INTEGER NOT NULL, - content TEXT NOT NULL, - sent_by_self INTEGER NOT NULL, - message_state TEXT NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_messages_lookup - ON messages (storage_owner, external_user, message_time DESC); - "#, - ) - .expect("Failed to initialize messages DB"); - Mutex::new(conn) +// Shared DB created via helper. +// The db helper constructs the messages sqlite file and ensures PRAGMAs and schema exist. +static MESSAGES_DB: LazyLock>> = LazyLock::new(|| { + db::create_general_messages_db().expect("Failed to create or initialize general messages DB") }); pub fn add_message( @@ -77,6 +57,7 @@ pub fn add_message( storage_owner: i64, external_user: i64, message: &str, + height: i64, ) { let message_time = match i64::try_from(send_time) { Ok(v) => v, @@ -86,40 +67,48 @@ pub fn add_message( } }; - let conn = match DB_CONN.lock() { - Ok(g) => g, - Err(e) => { - log!("Failed to lock messages DB mutex for add_message: {:?}", e); - return; - } - }; + // Insert the message into the DB + let insert_result = db::with_conn(&MESSAGES_DB, |conn| { + conn.execute( + r#" + INSERT INTO messages ( + storage_owner, + external_user, + message_time, + content, + sent_by_self, + message_state, + height + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + "#, + params![ + storage_owner, + external_user, + message_time, + message, + if storage_owner_is_sender { + 1_i64 + } else { + 0_i64 + }, + MessageState::Sending.as_str(), + height, + ], + )?; + Ok(()) + }); - if let Err(e) = conn.execute( - r#" - INSERT INTO messages ( - storage_owner, - external_user, - message_time, - content, - sent_by_self, - message_state - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) - "#, - params![ - storage_owner, - external_user, - message_time, - message, - if storage_owner_is_sender { - 1_i64 - } else { - 0_i64 - }, - MessageState::Sending.as_str(), - ], - ) { + if let Err(e) = insert_result { log!("Failed to insert message into sqlite: {}", e); + return; } + + // Update contacts table to reflect that this conversation exists and has a recent message. + // Use the Contact helper to set last_message_at to the message timestamp. + let mut contact = crate::users::contact::Contact::new(external_user); + contact.set_last_message_at(message_time); + // This will insert or update the contact for the storage owner. + crate::util::chats_util::mod_user(storage_owner, &contact); } pub fn change_message_state( @@ -128,56 +117,58 @@ pub fn change_message_state( external_user: i64, new_state: MessageState, ) -> io::Result<()> { - let conn = DB_CONN - .lock() - .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("Mutex lock error: {:?}", e)))?; - - let current: Option = match conn.query_row( - r#" - SELECT message_state - FROM messages - WHERE storage_owner = ?1 - AND external_user = ?2 - AND message_time = ?3 - ORDER BY id DESC - LIMIT 1 - "#, - params![storage_owner, external_user, timestamp], - |row| row.get(0), - ) { - Ok(state) => Some(state), - Err(rusqlite::Error::QueryReturnedNoRows) => None, - Err(e) => return Err(io::Error::new(io::ErrorKind::Other, e.to_string())), - }; - - let Some(current_state_raw) = current else { - return Ok(()); - }; - - let upgraded = MessageState::from_str(¤t_state_raw) - .upgrade(new_state) - .as_str() - .to_string(); - - conn.execute( - r#" - UPDATE messages - SET message_state = ?1 - WHERE id = ( - SELECT id + // Run the SELECT and UPDATE inside with_conn to centralize connection access. + let res: Result<(), String> = db::with_conn(&MESSAGES_DB, |conn| { + let current: Option = match conn.query_row( + r#" + SELECT message_state FROM messages - WHERE storage_owner = ?2 - AND external_user = ?3 - AND message_time = ?4 + WHERE storage_owner = ?1 + AND external_user = ?2 + AND message_time = ?3 ORDER BY id DESC LIMIT 1 - ) - "#, - params![upgraded, storage_owner, external_user, timestamp], - ) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + "#, + params![storage_owner, external_user, timestamp], + |row| row.get(0), + ) { + Ok(state) => Some(state), + Err(rusqlite::Error::QueryReturnedNoRows) => None, + Err(e) => return Err(e), + }; - Ok(()) + let Some(current_state_raw) = current else { + return Ok(()); + }; + + let upgraded = MessageState::from_str(¤t_state_raw) + .upgrade(new_state) + .as_str() + .to_string(); + + conn.execute( + r#" + UPDATE messages + SET message_state = ?1 + WHERE id = ( + SELECT id + FROM messages + WHERE storage_owner = ?2 + AND external_user = ?3 + AND message_time = ?4 + ORDER BY id DESC + LIMIT 1 + ) + "#, + params![upgraded, storage_owner, external_user, timestamp], + )?; + Ok(()) + }); + + match res { + Ok(_) => Ok(()), + Err(e) => Err(io::Error::new(io::ErrorKind::Other, e)), + } } pub fn get_messages( @@ -186,80 +177,73 @@ pub fn get_messages( loaded_messages: i64, amount: i64, ) -> JsonValue { - let mut messages = array![]; + let messages = array![]; if amount <= 0 || loaded_messages < 0 { return messages; } - let conn = match DB_CONN.lock() { - Ok(g) => g, - Err(e) => { - log!("Failed to lock messages DB mutex for get_messages: {:?}", e); - return messages; - } - }; + let res: Result = db::with_conn(&MESSAGES_DB, |conn| { + let mut stmt = conn.prepare( + r#" + SELECT + message_time, + content, + sent_by_self, + message_state, + height + FROM messages + WHERE storage_owner = ?1 + AND external_user = ?2 + ORDER BY message_time DESC, id DESC + LIMIT ?3 OFFSET ?4 + "#, + )?; - let mut stmt = match conn.prepare( - r#" - SELECT - message_time, - content, - sent_by_self, - message_state - FROM messages - WHERE storage_owner = ?1 - AND external_user = ?2 - ORDER BY message_time DESC, id DESC - LIMIT ?3 OFFSET ?4 - "#, - ) { - Ok(s) => s, - Err(e) => { - log!("Failed to prepare get_messages query: {}", e); - return messages; - } - }; + let rows = stmt.query_map( + params![storage_owner, external_user, amount, loaded_messages], + |row| { + let message_time: i64 = row.get(0)?; + let content: String = row.get(1)?; + let sent_by_self: i64 = row.get(2)?; + let message_state: String = row.get(3)?; + let height: i64 = row.get(4).unwrap_or(0); + Ok((message_time, content, sent_by_self, message_state, height)) + }, + )?; - let rows = stmt.query_map( - params![storage_owner, external_user, amount, loaded_messages], - |row| { - let message_time: i64 = row.get(0)?; - let content: String = row.get(1)?; - let sent_by_self: i64 = row.get(2)?; - let message_state: String = row.get(3)?; - Ok((message_time, content, sent_by_self, message_state)) - }, - ); - - let Ok(rows) = rows else { - if let Err(e) = rows { - log!("Failed to query messages: {}", e); - } - return messages; - }; - - for row in rows { - match row { - Ok((message_time, content, sent_by_self, message_state)) => { - let msg = object! { - "message_time" => message_time, - "content" => content, - "sent_by_self" => (sent_by_self != 0), - "message_state" => message_state - }; - - if let Err(e) = messages.push(msg) { - log!("Failed to append message to output array: {}", e); + let mut out = array![]; + for row in rows { + match row { + Ok((message_time, content, sent_by_self, message_state, height)) => { + let msg = object! { + "message_time" => message_time, + "content" => content, + "sent_by_self" => (sent_by_self != 0), + "message_state" => message_state, + "height" => height + }; + if let Err(e) = out.push(msg) { + // out.push returns a JsonError; log it instead of using `?` to avoid + // incompatible error conversions inside the DB closure. + log!("Failed to append message to output array: {:?}", e); + } + } + Err(e) => { + log!("Failed to read row from sqlite: {}", e); } } - Err(e) => { - log!("Failed to read row from sqlite: {}", e); - } + } + Ok(out) + }); + + match res { + Ok(v) => v, + Err(e) => { + log!("Failed to query messages: {}", e); + messages } } - - messages } #[cfg(test)] diff --git a/src/util/chats_util.rs b/src/util/chats_util.rs index 0677b19..b8e0ad2 100644 --- a/src/util/chats_util.rs +++ b/src/util/chats_util.rs @@ -1,69 +1,96 @@ use crate::users::contact::Contact; -use crate::util::file_util::get_directory; -use rusqlite::{Connection, params}; -use std::sync::{LazyLock, Mutex}; +use crate::util::db; +use rusqlite::params; +use std::sync::{Arc, LazyLock, Mutex}; -static DB_CONN: LazyLock> = LazyLock::new(|| { - let conn = Connection::open(format!("{}/messages.sqlite3", get_directory())) - .expect("Failed to open DB"); - conn.execute_batch( - r#" - PRAGMA journal_mode = WAL; - PRAGMA synchronous = NORMAL; - - CREATE TABLE IF NOT EXISTS contacts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - storage_owner INTEGER NOT NULL, - user_id INTEGER NOT NULL, - user_name TEXT, - last_message_at INTEGER, - UNIQUE(storage_owner, user_id) - ); - - CREATE INDEX IF NOT EXISTS idx_contacts_owner - ON contacts (storage_owner, last_message_at DESC, user_id ASC); - "#, - ) - .expect("Failed to initialize DB"); - Mutex::new(conn) +/// Shared DB connection for contacts/messages (created by db helper). +static MESSAGES_DB: LazyLock>> = LazyLock::new(|| { + db::create_general_messages_db().expect("Failed to create or initialize general messages DB") }); +/// Insert or update a contact for the given storage owner. pub fn mod_user(storage_owner: i64, contact: &Contact) { - let conn = DB_CONN.lock().unwrap(); - - let _ = conn.execute( - r#" - INSERT INTO contacts ( - storage_owner, - user_id, - user_name, - last_message_at - ) VALUES (?1, ?2, ?3, ?4) - ON CONFLICT(storage_owner, user_id) DO UPDATE SET - user_name = excluded.user_name, - last_message_at = excluded.last_message_at - "#, - params![ - storage_owner, - contact.user_id, - contact.user_name.clone(), - contact.last_message_at - ], - ); + if let Err(e) = db::with_conn(&MESSAGES_DB, |conn| { + conn.execute( + r#" + INSERT INTO contacts ( + storage_owner, + user_id, + user_name, + last_message_at + ) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(storage_owner, user_id) DO UPDATE SET + user_name = excluded.user_name, + last_message_at = excluded.last_message_at + "#, + params![ + storage_owner, + contact.user_id, + contact.user_name.clone(), + contact.last_message_at + ], + )?; + Ok(()) + }) { + eprintln!("Failed to mod_user: {}", e); + } } +/// Retrieve a single contact for storage_owner/user_id. pub fn get_user(storage_owner: i64, user_id: i64) -> Option { - let conn = DB_CONN.lock().unwrap(); + let res: Result, String> = db::with_conn(&MESSAGES_DB, |conn| { + match conn.query_row( + r#" + SELECT user_id, user_name, last_message_at + FROM contacts + WHERE storage_owner = ?1 AND user_id = ?2 + LIMIT 1 + "#, + params![storage_owner, user_id], + |r| { + let user_id: i64 = r.get(0)?; + let user_name: Option = r.get(1)?; + let last_message_at: Option = r.get(2)?; + Ok(Contact { + user_id, + user_name, + last_message_at, + }) + }, + ) { + Ok(c) => Ok(Some(c)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + }); - let row = conn.query_row( - r#" - SELECT user_id, user_name, last_message_at - FROM contacts - WHERE storage_owner = ?1 AND user_id = ?2 - LIMIT 1 - "#, - params![storage_owner, user_id], - |r| { + match res { + Ok(opt) => opt, + Err(e) => { + eprintln!("Error querying user in get_user: {}", e); + None + } + } +} + +/// Retrieve all contacts for a storage owner, ordered by last_message_at desc / user_id asc. +pub fn get_users(storage_owner: i64) -> Vec { + let contacts_out = Vec::new(); + + let res: Result, String> = db::with_conn(&MESSAGES_DB, |conn| { + let mut stmt = conn.prepare( + r#" + SELECT user_id, user_name, last_message_at + FROM contacts + WHERE storage_owner = ?1 + ORDER BY + CASE WHEN last_message_at IS NULL THEN 1 ELSE 0 END, + last_message_at DESC, + user_id ASC + "#, + )?; + + let rows = stmt.query_map(params![storage_owner], |r| { let user_id: i64 = r.get(0)?; let user_name: Option = r.get(1)?; let last_message_at: Option = r.get(2)?; @@ -72,64 +99,23 @@ pub fn get_user(storage_owner: i64, user_id: i64) -> Option { user_name, last_message_at, }) - }, - ); + })?; - match row { - Ok(contact) => Some(contact), - Err(rusqlite::Error::QueryReturnedNoRows) => None, + let mut out = Vec::new(); + for row in rows { + match row { + Ok(contact) => out.push(contact), + Err(e) => eprintln!("Failed to read contact row: {}", e), + } + } + Ok(out) + }); + + match res { + Ok(v) => v, Err(e) => { - eprintln!("Error querying user in get_user: {}", e); - None + eprintln!("Failed to query contacts in get_users: {}", e); + contacts_out } } } - -pub fn get_users(storage_owner: i64) -> Vec { - let mut contacts_out = Vec::new(); - - let conn = DB_CONN.lock().unwrap(); - - let mut stmt = match conn.prepare( - r#" - SELECT user_id, user_name, last_message_at - FROM contacts - WHERE storage_owner = ?1 - ORDER BY - CASE WHEN last_message_at IS NULL THEN 1 ELSE 0 END, - last_message_at DESC, - user_id ASC - "#, - ) { - Ok(s) => s, - Err(e) => { - eprintln!("Failed to prepare statement in get_users: {}", e); - return contacts_out; - } - }; - - let rows = match stmt.query_map(params![storage_owner], |r| { - let user_id: i64 = r.get(0)?; - let user_name: Option = r.get(1)?; - let last_message_at: Option = r.get(2)?; - Ok(Contact { - user_id, - user_name, - last_message_at, - }) - }) { - Ok(r) => r, - Err(e) => { - eprintln!("Failed to query map in get_users: {}", e); - return contacts_out; - } - }; - - for row in rows { - if let Ok(contact) = row { - contacts_out.push(contact); - } - } - - contacts_out -} diff --git a/src/util/communities_util.rs b/src/util/communities_util.rs new file mode 100644 index 0000000..ed8b7ae --- /dev/null +++ b/src/util/communities_util.rs @@ -0,0 +1,90 @@ +use crate::util::db; +use json::Array; +use rusqlite::params; +use std::sync::{Arc, LazyLock, Mutex}; + +static MESSAGES_DB: LazyLock>> = LazyLock::new(|| { + db::create_general_messages_db().expect("Failed to create or initialize general messages DB") +}); + +pub struct CommunitiesUtil; + +impl CommunitiesUtil { + pub fn add_community(storage_owner: i64, address: String, title: String, position: String) { + if let Err(e) = db::with_conn(&MESSAGES_DB, |conn| { + conn.execute( + r#" + INSERT INTO communities ( + storage_owner, + address, + title, + position + ) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(storage_owner, address) DO UPDATE SET + title = excluded.title, + position = excluded.position + "#, + params![storage_owner, address, title, position], + )?; + Ok(()) + }) { + eprintln!("Failed to add_community: {}", e); + } + } + + pub fn remove_community(storage_owner: i64, community_address: String) { + if let Err(e) = db::with_conn(&MESSAGES_DB, |conn| { + conn.execute( + "DELETE FROM communities WHERE storage_owner = ?1 AND address = ?2", + params![storage_owner, community_address], + )?; + Ok(()) + }) { + eprintln!("Failed to remove_community: {}", e); + } + } + + pub fn get_communities(storage_owner: i64) -> Array { + let communities_out = Array::new(); + + let res: Result = db::with_conn(&MESSAGES_DB, |conn| { + let mut stmt = conn.prepare( + r#" + SELECT address, title, position + FROM communities + WHERE storage_owner = ?1 + "#, + )?; + + let rows = stmt.query_map(params![storage_owner], |r| { + let address: String = r.get(0)?; + let title: String = r.get(1)?; + let position: String = r.get(2)?; + Ok((address, title, position)) + })?; + + let mut out = Array::new(); + for row in rows { + match row { + Ok((address, title, position)) => { + let mut community = json::JsonValue::new_object(); + community["title"] = json::JsonValue::String(title); + community["address"] = json::JsonValue::String(address); + community["position"] = json::JsonValue::String(position); + out.push(community); + } + Err(e) => eprintln!("Failed to read community row: {}", e), + } + } + Ok(out) + }); + + match res { + Ok(arr) => arr, + Err(e) => { + eprintln!("Failed to query communities in get_communities: {}", e); + communities_out + } + } + } +} diff --git a/src/util/db.rs b/src/util/db.rs new file mode 100644 index 0000000..9fb40a0 --- /dev/null +++ b/src/util/db.rs @@ -0,0 +1,185 @@ +//! Database helper utilities. +//! +//! This module provides small helpers to open/init sqlite databases and to +//! create a shared (Arc>) connection wrapper callers can +//! reuse. The goal is to centralize the "open and initialize" logic and +//! provide small convenience helpers used by other util modules. + +use crate::util::file_util::get_directory; +use rusqlite::{Connection, Error as RusqliteError}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// Returns the file path for a named DB inside the application's data directory. +/// +/// Arguments: +/// - `db_name` : name of the DB (without extension). Example: `"messages"`. +pub fn db_file_path(db_name: &str) -> String { + let mut p = PathBuf::from(get_directory()); + p.push(format!("{db_name}.sqlite3")); + p.to_string_lossy().to_string() +} + +/// Open a sqlite connection to the named DB file (no initialization). +/// +/// Arguments: +/// - `db_name`: name of the DB (without extension). +pub fn open_connection(db_name: &str) -> Result { + let path = db_file_path(db_name); + Connection::open(path) +} + +/// Open a connection and immediately run `init_sql` via `execute_batch`. +/// +/// Arguments: +/// - `db_name`: name of the DB (without extension). +/// - `init_sql`: SQL statements to initialize schema & PRAGMAs (can be multiple). +pub fn open_and_init(db_name: &str, init_sql: &str) -> Result { + let conn = open_connection(db_name)?; + conn.execute_batch(init_sql)?; + Ok(conn) +} + +/// Create a shared, Arc> initialized with the given SQL. +/// +/// This is a convenience wrapper that returns an owned Arc> +/// so caller modules can store it in a `static` or pass it around. +/// +/// Arguments: +/// - `db_name`: DB name (without extension). +/// - `init_sql`: init SQL (eg PRAGMA + CREATE TABLE statements). +pub fn create_shared_connection( + db_name: &str, + init_sql: &str, +) -> Result>, String> { + match open_and_init(db_name, init_sql) { + Ok(conn) => { + // Configure some sensible defaults for concurrency + // Attempt to set a busy timeout to reduce SQLITE_BUSY failures. + let _ = conn.busy_timeout(Duration::from_millis(250)); + Ok(Arc::new(Mutex::new(conn))) + } + Err(e) => Err(format!("Failed to open/init DB '{}': {}", db_name, e)), + } +} + +/// Acquire the Connection from an Arc> and run the provided +/// closure. Converts rusqlite::Error into a String on error. +/// +/// Arguments: +/// - `shared`: Arc> +/// - `f`: closure that receives &Connection and returns Result +/// +/// Returns Ok(T) or Err(String). +pub fn with_conn(shared: &Arc>, f: F) -> Result +where + F: FnOnce(&Connection) -> Result, +{ + // When invoked from within an async runtime (such as Tokio), taking a blocking + // std::sync::Mutex lock on the runtime thread can cause deadlocks or permanent + // awaits. Detect whether we're running inside a Tokio runtime and, if so, + // execute the blocking lock + database closure using Tokio's blocking helper. + // + // The blocking section returns Result so we can propagate errors + // in the same form as before. + if tokio::runtime::Handle::try_current().is_ok() { + tokio::task::block_in_place(|| { + let guard = shared + .lock() + .map_err(|e| format!("DB mutex poisoned: {:?}", e))?; + f(&*guard).map_err(|e| e.to_string()) + }) + } else { + let guard = shared + .lock() + .map_err(|e| format!("DB mutex poisoned: {:?}", e))?; + f(&*guard).map_err(|e| e.to_string()) + } +} + +/// Initialize a general-purpose messages+contacts DB and return a shared +/// connection. This helper creates a single DB file that can contain multiple +/// tables (messages, contacts, ...). The SQL here is conservative and intended +/// to be safe if called multiple times. +/// +/// Callers may prefer to call `create_shared_connection("messages", INIT_SQL)` +/// directly, but this convenience is useful for code that expects both tables. +pub fn create_general_messages_db() -> Result>, String> { + // Keep PRAGMA and schema in one multi-statement string so callers only + // need to call a single execute_batch. + const INIT_SQL: &str = r#" + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + storage_owner INTEGER NOT NULL, + external_user INTEGER NOT NULL, + message_time INTEGER NOT NULL, + content TEXT NOT NULL, + sent_by_self INTEGER NOT NULL, + message_state TEXT NOT NULL, + height INTEGER NOT NULL DEFAULT 0 + ); + + CREATE INDEX IF NOT EXISTS idx_messages_lookup + ON messages (storage_owner, external_user, message_time DESC); + + CREATE TABLE IF NOT EXISTS contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + storage_owner INTEGER NOT NULL, + user_id INTEGER NOT NULL, + user_name TEXT, + last_message_at INTEGER, + UNIQUE(storage_owner, user_id) + ); + + CREATE INDEX IF NOT EXISTS idx_contacts_owner + ON contacts (storage_owner, last_message_at DESC, user_id ASC); + + CREATE TABLE IF NOT EXISTS communities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + storage_owner INTEGER NOT NULL, + address TEXT NOT NULL, + title TEXT NOT NULL, + position TEXT NOT NULL, + UNIQUE(storage_owner, address) + ); + + CREATE INDEX IF NOT EXISTS idx_communities_owner + ON communities (storage_owner); + "#; + + match create_shared_connection("messages", INIT_SQL) { + Ok(shared_conn) => { + // Attempt to add the height column for backwards compatibility. + // This will fail if the column already exists, which is expected. + let _ = with_conn(&shared_conn, |conn| { + let _ = conn.execute( + "ALTER TABLE messages ADD COLUMN height INTEGER NOT NULL DEFAULT 0", + [], + ); + Ok(()) + }); + Ok(shared_conn) + } + Err(e) => Err(e), + } +} + +/* +Example usage: + +// In some util module (at init time, e.g. lazy_static or LazyLock) +static MESSAGES_DB: LazyLock>> = LazyLock::new(|| { + create_general_messages_db().expect("failed to create messages DB") +}); + +// Later, to run a query: +let res: Result, String> = with_conn(&MESSAGES_DB, |conn| { + let mut stmt = conn.prepare("SELECT ...")?; + let rows = stmt.query_map(...)?; + // collect and return Ok(...) +}); +*/ diff --git a/src/util/logger.rs b/src/util/logger.rs index 2c09c33..a5feb02 100755 --- a/src/util/logger.rs +++ b/src/util/logger.rs @@ -13,13 +13,13 @@ use ttp_core::{CommunicationValue, DataTypes, DataValue}; use crate::{ APP_STATE, - gui::{elements::log_card::UiLogEntry, ui::UNIQUE}, + gui::{elements::log_card::LogEntry, ui::UNIQUE}, langu::language_manager, }; static LOGGER: OnceLock> = OnceLock::new(); -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] #[allow(unused)] pub enum PrintType { Call, @@ -28,6 +28,20 @@ pub enum PrintType { Omikron, Omega, General, + Command, +} +impl PrintType { + pub fn prefix_color(self) -> Color { + match self { + PrintType::Call => Color::Magenta, + PrintType::Client => Color::Green, + PrintType::Iota => Color::Yellow, + PrintType::Omikron => Color::Blue, + PrintType::Omega => Color::Cyan, + PrintType::General => Color::LightCyan, + PrintType::Command => Color::LightGreen, + } + } } struct LogMessage { @@ -35,10 +49,8 @@ struct LogMessage { prefix: String, kind: PrintType, is_error: bool, - translation_key: Option, format_args: Vec, - message: Option, } @@ -70,27 +82,49 @@ pub fn startup() { msg.message.unwrap_or_default() }; - let ts = fixed_box(&msg.timestamp_ms.to_string(), 13); + let timestamp = format_timestamp_inline(msg.timestamp_ms); + let entry = LogEntry::new(msg.kind, resolved_message, msg.is_error); - let line = format!("{} {}", msg.prefix, resolved_message); - - let _ = writeln!(file, "{} {}", ts, line); - - let color = colorize(msg.kind, msg.is_error); - - let ui_entry = UiLogEntry { - line: line.clone(), - color, + let prefix = if msg.prefix.is_empty() { + String::new() + } else { + format!("{} ", msg.prefix) }; - { - let mut state = APP_STATE.lock().unwrap(); - state.push_log(ui_entry); - } + let _ = writeln!( + file, + "{} {}{}", + fixed_box(&msg.timestamp_ms.to_string(), 13), + prefix, + entry.message + ); + + let _ = writeln!(file, " {}", timestamp); + + let mut state = APP_STATE.lock().unwrap(); + state.push_log(entry.into()); } }); } +fn format_timestamp_inline(timestamp_ms: u128) -> String { + let secs = (timestamp_ms / 1000) as i64; + let hours = (secs / 3600) % 24; + let minutes = (secs / 60) % 60; + let seconds = secs % 60; + format!("[{:02}:{:02}:{:02}]", hours, minutes, seconds) +} + +fn fixed_box(content: &str, width: usize) -> String { + let s: String = content.chars().take(width).collect(); + let len = s.chars().count(); + if len < width { + format!("[{}{}]", " ".repeat(width - len), s) + } else { + s + } +} + fn colorize(kind: PrintType, is_error: bool) -> Color { if is_error { return Color::Red; @@ -102,17 +136,8 @@ fn colorize(kind: PrintType, is_error: bool) -> Color { PrintType::Iota => Color::Yellow, PrintType::Omikron => Color::Blue, PrintType::Omega => Color::Cyan, - PrintType::General => Color::White, - } -} - -fn fixed_box(content: &str, width: usize) -> String { - let s: String = content.chars().take(width).collect(); - let len = s.chars().count(); - if len < width { - format!("[{}{}]", " ".repeat(width - len), s) - } else { - s + PrintType::General => Color::LightCyan, + PrintType::Command => Color::LightGreen, } } @@ -180,6 +205,7 @@ macro_rules! log_t { ) }; } + #[macro_export] macro_rules! log_t_err { ($key:expr) => { @@ -203,13 +229,32 @@ macro_rules! log_t_err { }; } +/// Log a command message. +#[macro_export] +macro_rules! log_command { + ($($arg:tt)*) => { + $crate::util::logger::log_internal( + $crate::util::logger::PrintType::Command, + "".to_string(), + false, + format!($($arg)*) + ) + }; +} + /// Log a general informational message. #[macro_export] macro_rules! log { ($($arg:tt)*) => { - $crate::util::logger::log_internal($crate::util::logger::PrintType::General, "".to_string(), false, format!($($arg)*)) + $crate::util::logger::log_internal( + $crate::util::logger::PrintType::General, + "".to_string(), + false, + format!($($arg)*) + ) }; } + /// Log an inbound message (`>`). #[macro_export] macro_rules! log_in { @@ -222,6 +267,7 @@ macro_rules! log_in { ) }; } + /// Log an outbound message (`<`). #[macro_export] macro_rules! log_out { @@ -234,6 +280,7 @@ macro_rules! log_out { ) }; } + /// Log an error message (`>>`). #[macro_export] macro_rules! log_err { @@ -373,6 +420,7 @@ macro_rules! log_cv_in { $crate::util::logger::log_cv_internal("> ", &$cv, None) }; } + #[macro_export] macro_rules! log_cv_out { ($kind:expr, $cv:expr) => { diff --git a/src/util/mod.rs b/src/util/mod.rs index 8a1382b..47901f9 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,7 +1,9 @@ pub mod chat_files; pub mod chats_util; +pub mod communities_util; pub mod config_util; pub mod crypto_helper; pub mod crypto_util; +pub mod db; pub mod file_util; pub mod logger;