Migrated left overs from github
This commit is contained in:
parent
b53203f8a7
commit
5dc5ffdad8
12 changed files with 1487 additions and 566 deletions
|
|
@ -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<Mutex<bool>>,
|
||||
last_swap: Arc<Mutex<Instant>>,
|
||||
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<Span<'static>> {
|
||||
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 (<help> for info)",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
} else {
|
||||
spans.push(Span::styled(
|
||||
" send command (<help> 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<Span<'static>> {
|
||||
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::<Vec<&str>>();
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<LogEntry> 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<UiLogEntry> {
|
||||
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<UiLogEntry>,
|
||||
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<Line> = 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<Line> = 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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<UI>) {
|
||||
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<UI>) {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<i64>().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::<i64>().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::<i64>().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::<i64>().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<DataValue> = 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()
|
||||
|
|
|
|||
|
|
@ -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<Mutex<Connection>> = 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<Arc<Mutex<rusqlite::Connection>>> = 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<String> = 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<String> = 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<JsonValue, String> = 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)]
|
||||
|
|
|
|||
|
|
@ -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<Mutex<Connection>> = 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<Arc<Mutex<rusqlite::Connection>>> = 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<Contact> {
|
||||
let conn = DB_CONN.lock().unwrap();
|
||||
let res: Result<Option<Contact>, 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<String> = r.get(1)?;
|
||||
let last_message_at: Option<i64> = 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<Contact> {
|
||||
let contacts_out = Vec::new();
|
||||
|
||||
let res: Result<Vec<Contact>, 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<String> = r.get(1)?;
|
||||
let last_message_at: Option<i64> = r.get(2)?;
|
||||
|
|
@ -72,64 +99,23 @@ pub fn get_user(storage_owner: i64, user_id: i64) -> Option<Contact> {
|
|||
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<Contact> {
|
||||
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<String> = r.get(1)?;
|
||||
let last_message_at: Option<i64> = 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
|
||||
}
|
||||
|
|
|
|||
90
src/util/communities_util.rs
Normal file
90
src/util/communities_util.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
use crate::util::db;
|
||||
use json::Array;
|
||||
use rusqlite::params;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
|
||||
static MESSAGES_DB: LazyLock<Arc<Mutex<rusqlite::Connection>>> = 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<Array, String> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
185
src/util/db.rs
Normal file
185
src/util/db.rs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
//! Database helper utilities.
|
||||
//!
|
||||
//! This module provides small helpers to open/init sqlite databases and to
|
||||
//! create a shared (Arc<Mutex<Connection>>) 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<Connection, RusqliteError> {
|
||||
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<Connection, RusqliteError> {
|
||||
let conn = open_connection(db_name)?;
|
||||
conn.execute_batch(init_sql)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Create a shared, Arc<Mutex<Connection>> initialized with the given SQL.
|
||||
///
|
||||
/// This is a convenience wrapper that returns an owned Arc<Mutex<Connection>>
|
||||
/// 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<Arc<Mutex<Connection>>, 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<Mutex<Connection>> and run the provided
|
||||
/// closure. Converts rusqlite::Error into a String on error.
|
||||
///
|
||||
/// Arguments:
|
||||
/// - `shared`: Arc<Mutex<Connection>>
|
||||
/// - `f`: closure that receives &Connection and returns Result<T, RusqliteError>
|
||||
///
|
||||
/// Returns Ok(T) or Err(String).
|
||||
pub fn with_conn<T, F>(shared: &Arc<Mutex<Connection>>, f: F) -> Result<T, String>
|
||||
where
|
||||
F: FnOnce(&Connection) -> Result<T, RusqliteError>,
|
||||
{
|
||||
// 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<T, String> 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<Arc<Mutex<Connection>>, 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<Arc<Mutex<Connection>>> = LazyLock::new(|| {
|
||||
create_general_messages_db().expect("failed to create messages DB")
|
||||
});
|
||||
|
||||
// Later, to run a query:
|
||||
let res: Result<Vec<MyRow>, String> = with_conn(&MESSAGES_DB, |conn| {
|
||||
let mut stmt = conn.prepare("SELECT ...")?;
|
||||
let rows = stmt.query_map(...)?;
|
||||
// collect and return Ok(...)
|
||||
});
|
||||
*/
|
||||
|
|
@ -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<mpsc::Sender<LogMessage>> = 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<String>,
|
||||
format_args: Vec<String>,
|
||||
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue