Rain il y a 10 mois
commit
04d38e3c4e
18 fichiers modifiés avec 1508 ajouts et 0 suppressions
  1. 2 0
      .gitignore
  2. 7 0
      Cargo.toml
  3. 1 0
      README.md
  4. 10 0
      client/Cargo.toml
  5. 111 0
      client/src/actions.rs
  6. 351 0
      client/src/main.rs
  7. 144 0
      client/src/message.rs
  8. 317 0
      client/src/ui.rs
  9. 9 0
      server/Cargo.toml
  10. 103 0
      server/src/main.rs
  11. 7 0
      utils/Cargo.toml
  12. 30 0
      utils/src/binary.rs
  13. 38 0
      utils/src/hash.rs
  14. 10 0
      utils/src/lib.rs
  15. 57 0
      utils/src/rng.rs
  16. 168 0
      utils/src/serialize.rs
  17. 104 0
      utils/src/strings.rs
  18. 39 0
      utils/src/time.rs

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+target/
+Cargo.lock

+ 7 - 0
Cargo.toml

@@ -0,0 +1,7 @@
+[workspace]
+resolver = "2"
+members = [
+    "client",
+    "server",
+    "utils",
+]

+ 1 - 0
README.md

@@ -0,0 +1 @@
+# channel

+ 10 - 0
client/Cargo.toml

@@ -0,0 +1,10 @@
+[package]
+name = "client"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+utils = { path = "../utils" }
+
+cursive = "0.21"
+reqwest = { version = "0.12.15", features = [ "blocking" ] }

+ 111 - 0
client/src/actions.rs

@@ -0,0 +1,111 @@
+use cursive::{views::TextArea, Cursive};
+use utils::{hash::Hashable as _, serialize::Serialize as _};
+
+use crate::{get_appdata, message::Message, ui, Appdata};
+
+// TODO: Create proper user objects
+pub fn on_user_click(siv: &mut Cursive, user: &str) {
+    let language = get_appdata(siv).language;
+    ui::alert(siv, ui::Labels::User.localize(language), user.into());
+}
+
+pub fn on_message_click(siv: &mut Cursive, message_id: &str) {
+    let appdata = get_appdata(siv);
+    let message = appdata.messages.get(message_id);
+
+    if let Some(message) = message {
+        ui::alert(
+            siv,
+            ui::Labels::Message.localize(appdata.language),
+            format!("{}\n{}\n{}", message.time, message.sender, message.content),
+        );
+    }
+}
+
+pub fn on_input_submit(siv: &mut Cursive, input_field_id: &str) {
+    let text = siv
+        .call_on_name(input_field_id, |view: &mut TextArea| {
+            view.get_content().to_string()
+        })
+        .expect("Failed to retrieve input field content.");
+
+    let appdata = get_appdata(siv);
+    let language = appdata.language;
+
+    let message = Message::new(&appdata.username, &text);
+    if message.is_valid() {
+        // NOTE:
+        // If an error was shown during send, it's likely that loading will fail too
+        // so we don't need to show it again
+        let mut error_dialogue_shown = false;
+
+        if send_message(siv, message).is_ok() {
+            clear_input_field(siv);
+        } else {
+            error_dialogue_shown = true;
+            ui::error(siv, ui::Labels::FailedToSendMessage.localize(language));
+        }
+
+        if !error_dialogue_shown && load_messages(siv).is_err() {
+            ui::error(siv, ui::Labels::FailedToLoadMessages.localize(language));
+        }
+    } else if !text.is_empty() {
+        // Invalid message
+        ui::error(siv, ui::Labels::InvalidMessage.localize(language));
+    }
+
+    ui::visual_update(siv);
+}
+
+pub fn clear_input_field(siv: &mut Cursive) {
+    siv.call_on_name(ui::INPUT_FIELD_ID, |view: &mut TextArea| {
+        view.set_content("");
+    })
+    .expect("Failed to clear input field.");
+}
+
+pub fn send_message(siv: &mut Cursive, message: Message) -> reqwest::Result<()> {
+    let api_endpoint = get_appdata(siv).api_endpoint;
+    let str = utils::binary::bin2hex(message.serialize_checked());
+
+    reqwest::blocking::Client::new()
+        .post(format!("{}/", api_endpoint))
+        .body(str)
+        .send()?;
+
+    Ok(())
+}
+
+pub fn load_messages(siv: &mut Cursive) -> reqwest::Result<()> {
+    let appdata = get_appdata(siv);
+
+    let bytes = reqwest::blocking::Client::new()
+        .get(format!("{}/", appdata.api_endpoint))
+        .send()?
+        .bytes()?;
+
+    let contents = String::from_utf8_lossy(&bytes);
+    let mut messages = vec![];
+
+    for message_ser_hex in contents.split(",") {
+        let Some(message_ser_bin) = utils::binary::hex2bin(message_ser_hex) else {
+            continue;
+        };
+
+        let Ok(message) = Message::deserialize_checked(message_ser_bin) else {
+            continue;
+        };
+
+        if message.is_valid() {
+            messages.push(message);
+        }
+    }
+
+    siv.with_user_data(|appdata: &mut Appdata| {
+        for message in messages {
+            appdata.messages.insert(message.hash().to_string(), message);
+        }
+    });
+
+    Ok(())
+}

+ 351 - 0
client/src/main.rs

@@ -0,0 +1,351 @@
+// TODO: Key/auth system
+// TODO: Args or config for username, language, server, and refresh rate.
+
+mod actions;
+mod message;
+mod ui;
+
+use cursive::views::{
+    Button, Dialog, DummyView, EditView, LinearLayout, NamedView, Panel, ScrollView, SelectView,
+    TextArea, TextView,
+};
+use cursive::{event, Cursive};
+use cursive::{traits::*, CursiveExt as _};
+use message::Message;
+use std::collections::HashMap;
+use std::thread;
+
+use ui::{
+    DIALOGUE_MIN_SIZE, INPUT_BUTTON_ID, INPUT_CLEAR_BUTTON_ID, INPUT_FIELD_ID, INPUT_PANEL_ID,
+    LANGUAGE_BUTTON_ID, MESSAGE_PANEL_ID, REFRESH_BUTTON_ID, USERNAME_BUTTON_ID, USERNAME_FIELD_ID,
+    USERS_PANEL_ID, USER_PANEL_SIZE,
+};
+
+const MAX_MESSAGE_LENGTH: usize = 512;
+const MAX_USERNAME_LENGTH: usize = 16;
+const DEFAULT_USERNAME_PREFIX: &str = "Myst";
+
+const REMOTE_REFRESH_RATE: u64 = 10;
+
+#[derive(Clone)]
+pub struct Appdata {
+    pub messages: HashMap<String, Message>,
+    pub username: String,
+    pub language: ui::Language,
+    pub quick_close_window_count: usize,
+    pub api_endpoint: String,
+    pub blocked_phrases: Vec<String>,
+    pub blocked_phrases_censor_char: char,
+}
+
+impl Default for Appdata {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl Appdata {
+    pub fn new() -> Self {
+        Self {
+            messages: HashMap::new(),
+            username: format!(
+                "{}#{}",
+                DEFAULT_USERNAME_PREFIX,
+                utils::rng::random_numeric_string(4)
+            ),
+            language: ui::Language::English,
+            quick_close_window_count: 0,
+            api_endpoint: "http://127.0.0.1:8080".to_string(),
+            blocked_phrases: Vec::new(),
+            blocked_phrases_censor_char: '*',
+        }
+    }
+}
+
+pub fn get_appdata(siv: &mut Cursive) -> Appdata {
+    siv.with_user_data(|appdata: &mut Appdata| appdata.clone())
+        .expect("Failed to retrieve appdata.")
+}
+
+fn main() {
+    utils::rng::shuffle_rng();
+
+    let mut siv = Cursive::default();
+    siv.set_user_data(Appdata::new());
+
+    // Global hotkeys
+    siv.add_global_callback(event::Key::Backspace, |siv| {
+        let _ = siv.focus_name(INPUT_FIELD_ID);
+    });
+
+    // Background thread
+    {
+        let cb_sink = siv.cb_sink().clone();
+        let mut timer = 0;
+        thread::spawn(move || loop {
+            cb_sink
+                .send(Box::new(move |siv| {
+                    ui::visual_update(siv);
+
+                    if timer % REMOTE_REFRESH_RATE == 0 {
+                        actions::load_messages(siv);
+                    }
+                }))
+                .expect("Failed to send callback from background thread.");
+
+            timer += 1;
+            thread::sleep(std::time::Duration::from_secs(1));
+        });
+    }
+
+    // Users
+    let users_panel = {
+        let mut users_view: SelectView<String> = SelectView::new();
+        users_view.set_on_submit(move |siv, user: &String| actions::on_user_click(siv, user));
+        Panel::new(ScrollView::new(users_view)).with_name(USERS_PANEL_ID)
+    };
+
+    // Messages
+    let messages_panel = {
+        let mut view: SelectView<String> = SelectView::new();
+        view.set_on_submit(move |siv, message_id: &str| actions::on_message_click(siv, message_id));
+        Panel::new(ScrollView::new(view)).with_name(MESSAGE_PANEL_ID)
+    };
+
+    // Input
+    let input_panel = {
+        let input_field = TextArea::new()
+            .with_name(INPUT_FIELD_ID)
+            .full_width()
+            .max_height(10); // NOTE: 10 seems to be the size of 2 vertical buttons
+        let input_button =
+            Button::new("", move |siv| actions::on_input_submit(siv, INPUT_FIELD_ID))
+                .with_name(INPUT_BUTTON_ID);
+
+        let input_clear_button =
+            Button::new("", actions::clear_input_field).with_name(INPUT_CLEAR_BUTTON_ID);
+
+        Panel::new(
+            LinearLayout::horizontal().child(input_field).child(
+                LinearLayout::vertical()
+                    .child(input_button)
+                    .child(input_clear_button),
+            ),
+        )
+        .with_name(INPUT_PANEL_ID)
+    };
+
+    // Language selector
+    let language_button = Button::new("", move |siv| {
+        // NOTE: This must match the order in the SelectView
+        let current_language_index = match get_appdata(siv).language {
+            ui::Language::English => 0,
+            ui::Language::Dutch => 1,
+            ui::Language::Japanese => 2,
+        };
+
+        let language = get_appdata(siv).language;
+
+        ui::keybind_close_once(siv);
+
+        siv.add_layer(
+            cursive::views::Dialog::new()
+                .content(
+                    LinearLayout::vertical().child(
+                        SelectView::<ui::Language>::new()
+                            .item("English", ui::Language::English)
+                            .item("Nederlands", ui::Language::Dutch)
+                            .item("日本語", ui::Language::Japanese)
+                            .selected(current_language_index)
+                            .on_submit(move |siv, language| {
+                                ui::change_language(siv, *language);
+                                ui::keybind_close_manual_end(siv, false);
+                            }),
+                    ),
+                )
+                .title(ui::Labels::SetLanguage.localize(language))
+                .button(ui::Labels::Cancel.localize(language), |siv| {
+                    ui::keybind_close_manual_end(siv, false);
+                })
+                .min_size(DIALOGUE_MIN_SIZE),
+        );
+    })
+    .with_name(LANGUAGE_BUTTON_ID);
+
+    // Current username panel
+    let current_username_panel = {
+        let current_username = get_appdata(&mut siv).username.clone();
+        let view = TextView::new(current_username);
+        Panel::new(view).with_name(ui::CURRENT_USERNAME_PANEL_ID)
+    };
+
+    // Set username
+    let username_button = Button::new("", move |siv| {
+        let current_name = get_appdata(siv).username.clone();
+        let language = get_appdata(siv).language;
+
+        ui::keybind_close_once(siv);
+
+        siv.add_layer(
+            cursive::views::Dialog::new()
+                .title(ui::Labels::Username.localize(language))
+                .content(
+                    LinearLayout::vertical().child(
+                        EditView::new()
+                            .content(current_name)
+                            .with_name(USERNAME_FIELD_ID)
+                            .fixed_width(DIALOGUE_MIN_SIZE.0.into()),
+                    ),
+                )
+                .button(ui::Labels::Ok.localize(language), |siv| {
+                    let input = siv
+                        .call_on_name(USERNAME_FIELD_ID, |view: &mut EditView| view.get_content())
+                        .expect("Failed to retrieve username input.");
+
+                    if message::is_valid_username(&input) {
+                        // Valid
+                        siv.with_user_data(|appdata: &mut Appdata| {
+                            appdata.username = input.to_string();
+                        });
+
+                        ui::visual_update(siv);
+                        ui::keybind_close_manual_end(siv, false);
+                    } else {
+                        // Invalid
+                        let language = get_appdata(siv).language;
+                        ui::keybind_close_manual_end(siv, false); // NOTE: Error dialogue overwrites this one, so end it here beforehand.
+                        ui::error(
+                            siv,
+                            ui::Labels::InvalidUsernameExplination.localize(language),
+                        );
+                    }
+                })
+                .button(ui::Labels::Cancel.localize(language), |siv| {
+                    ui::keybind_close_manual_end(siv, false);
+                })
+                .min_size(DIALOGUE_MIN_SIZE),
+        );
+    })
+    .with_name(USERNAME_BUTTON_ID);
+
+    // Refresh button
+    let refresh_button = Button::new("", move |siv| {
+        if actions::load_messages(siv).is_err() {
+            let language = get_appdata(siv).language;
+            ui::error(siv, ui::Labels::FailedToLoadMessages.localize(language));
+        }
+        ui::visual_update(siv);
+    })
+    .with_name(REFRESH_BUTTON_ID);
+
+    // Blocked words list
+    let blocked_words_list_button = {
+        Button::new("", move |siv| {
+            let appdata = get_appdata(siv);
+            let language = appdata.language;
+
+            let wordslist_id = utils::new_id();
+            let wordslist_id_clone = wordslist_id.clone();
+            let wordslist_id_typingarea_clone = wordslist_id.clone();
+            let mut wordslist_view =
+                SelectView::<String>::new().on_submit(move |siv, word: &str| {
+                    siv.with_user_data(|appdata: &mut Appdata| {
+                        appdata.blocked_phrases.retain(|x| x != word);
+                    });
+
+                    siv.call_on_name(&wordslist_id_clone, |view: &mut NamedView<SelectView>| {
+                        let idx = view
+                            .get_mut()
+                            .selected_id()
+                            .expect("Selection should exist when fired from submit.");
+                        view.get_mut().remove_item(idx);
+                    });
+
+                    ui::visual_update(siv);
+                });
+            wordslist_view.add_all_str(&appdata.blocked_phrases);
+
+            let typingarea_id = utils::new_id();
+            let appdata_typingarea_clone = appdata.clone();
+            let typing_area = EditView::new().with_name(&typingarea_id);
+            let typing_button = Button::new(ui::Labels::Submit.localize(language), move |siv| {
+                let text = siv
+                    .call_on_name(&typingarea_id, |view: &mut EditView| {
+                        let s = view.get_content();
+                        view.set_content("");
+                        s
+                    })
+                    .map(|x| x.to_string())
+                    .unwrap_or("".to_string());
+
+                if appdata_typingarea_clone.blocked_phrases.contains(&text) || text.is_empty() {
+                    return;
+                }
+
+                siv.with_user_data(|appdata: &mut Appdata| {
+                    appdata.blocked_phrases.push(text.clone());
+                });
+
+                siv.call_on_name(
+                    &wordslist_id_typingarea_clone,
+                    |view: &mut NamedView<SelectView>| {
+                        view.get_mut().add_item_str(text);
+                    },
+                );
+
+                ui::visual_update(siv);
+            });
+
+            let d = Dialog::new()
+                .title(ui::Labels::BlockedWords.localize(language))
+                .button(ui::Labels::Close.localize(language), |siv| {
+                    ui::keybind_close_manual_end(siv, false);
+                })
+                .content(
+                    LinearLayout::vertical()
+                        .child(ScrollView::new(wordslist_view.with_name(wordslist_id)))
+                        .child(DummyView.fixed_height(1))
+                        .child(
+                            LinearLayout::horizontal()
+                                .child(typing_area.min_width(DIALOGUE_MIN_SIZE.0 as usize))
+                                .child(typing_button),
+                        ),
+                );
+
+            ui::keybind_close_once(siv);
+            siv.add_layer(d);
+        })
+        .with_name(ui::BLOCKED_WORDS_BUTTON_ID)
+    };
+
+    // Main layout
+    let main_layout = LinearLayout::vertical()
+        .child(
+            LinearLayout::horizontal()
+                .child(
+                    LinearLayout::vertical()
+                        .child(users_panel.resized(
+                            cursive::view::SizeConstraint::Fixed(USER_PANEL_SIZE),
+                            cursive::view::SizeConstraint::Full,
+                        ))
+                        .child(current_username_panel),
+                )
+                .child(messages_panel.full_width()),
+        )
+        .child(input_panel)
+        .child(
+            LinearLayout::horizontal()
+                .child(language_button)
+                .child(DummyView.full_width())
+                .child(username_button)
+                .child(DummyView.full_width())
+                .child(blocked_words_list_button)
+                .child(DummyView.full_width())
+                .child(refresh_button)
+                .full_width(),
+        );
+
+    siv.add_fullscreen_layer(main_layout);
+    ui::change_language(&mut siv, ui::Language::English);
+    siv.run();
+}

+ 144 - 0
client/src/message.rs

@@ -0,0 +1,144 @@
+use utils::{
+    hash::{Hash, Hashable},
+    serialize::{self, Serialize, SetOfSerializedObjects},
+    time::time_millis,
+};
+
+use crate::{get_appdata, MAX_MESSAGE_LENGTH, MAX_USERNAME_LENGTH};
+
+#[derive(Debug, Clone)]
+pub struct Message {
+    pub sender: String,
+    pub content: String,
+    pub time: u128,
+}
+
+impl Message {
+    pub fn new(sender: &str, content: &str) -> Self {
+        Message {
+            sender: sender.to_string(),
+            content: content.to_string(),
+            time: time_millis(),
+        }
+    }
+
+    pub fn is_valid(&self) -> bool {
+        is_valid_username(&self.sender) && is_valid_message(&self.content)
+    }
+}
+
+impl Hashable for Message {
+    fn hash(&self) -> Hash {
+        let mut bytes = Vec::new();
+        bytes.extend_from_slice(self.content.as_bytes());
+        bytes.extend_from_slice(self.sender.as_bytes());
+        bytes.extend_from_slice(&self.time.to_be_bytes()[..]);
+
+        Hash::from(&bytes)
+    }
+}
+
+impl Serialize for Message {
+    const SIGNATURE: &'static str = "Message";
+
+    fn serialize_unchecked(&self) -> Vec<u8> {
+        let separate_bytes: Vec<Vec<u8>> = vec![
+            self.sender.as_bytes().to_vec(),
+            self.content.as_bytes().to_vec(),
+            self.time.to_be_bytes().to_vec(),
+        ];
+
+        separate_bytes.serialize_unchecked()
+    }
+
+    fn deserialize_unchecked<B>(data: B) -> serialize::Result<Self>
+    where
+        B: AsRef<[u8]>,
+        Self: Sized,
+    {
+        let separate_bytes = SetOfSerializedObjects::deserialize_unchecked(data)?;
+        if separate_bytes.len() != 3 {
+            return Err(serialize::DeserializationError::InvalidData(
+                "Invalid number of fields",
+            ));
+        }
+
+        let sender = String::from_utf8(separate_bytes[0].clone())
+            .map_err(|_| serialize::DeserializationError::InvalidData("Sender isn't UTF-8"))?;
+        let content = String::from_utf8(separate_bytes[1].clone())
+            .map_err(|_| serialize::DeserializationError::InvalidData("Content isn't UTF-8"))?;
+        let timestamp_bytes = &separate_bytes[2];
+        if timestamp_bytes.len() != 16 {
+            return Err(serialize::DeserializationError::InvalidData(
+                "Invalid timestamp length",
+            ));
+        }
+        let mut timestamp = [0u8; 16];
+        timestamp.copy_from_slice(timestamp_bytes);
+        let timestamp = u128::from_be_bytes(timestamp);
+
+        Ok(Message {
+            sender,
+            content,
+            time: timestamp,
+        })
+    }
+}
+
+pub fn is_valid_username(username: &str) -> bool {
+    username.len() >= 2
+        && username.len() <= MAX_USERNAME_LENGTH
+        && username
+            .chars()
+            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '#')
+}
+
+pub fn is_valid_message(message: &str) -> bool {
+    message.len() <= MAX_MESSAGE_LENGTH && !message.is_empty()
+}
+
+#[derive(Debug, Clone)]
+pub struct MessageSanitized {
+    pub sender: String,
+    pub content: String,
+    pub time: u128,
+    pub hash: Hash,
+}
+
+impl MessageSanitized {
+    pub fn remove_blocked_phrases(&mut self, siv: &mut cursive::Cursive) {
+        let appdata = get_appdata(siv);
+        let wordlist = appdata.blocked_phrases;
+        let censor_char = appdata.blocked_phrases_censor_char;
+        self.sender = utils::strings::remove_bad_words(&self.sender, &wordlist, censor_char);
+        self.content = utils::strings::remove_bad_words(&self.content, &wordlist, censor_char);
+    }
+}
+
+// TODO: Handle invalid messages better
+impl From<Message> for MessageSanitized {
+    fn from(value: Message) -> Self {
+        let mut sender = value.sender.clone();
+        if !is_valid_username(&sender) {
+            sender = format!("Invl@{}", &value.sender.hash().to_string()[..8]);
+        }
+
+        let mut content = value.content.clone();
+        if !is_valid_message(&value.content) {
+            content = format!("Invalid@{}", &value.content.hash().to_string());
+        }
+
+        MessageSanitized {
+            sender: sender.replace("\n", ""),
+            content: content.replace("\n", ""),
+            time: value.time,
+            hash: value.hash(),
+        }
+    }
+}
+
+impl Hashable for MessageSanitized {
+    fn hash(&self) -> Hash {
+        self.hash.clone()
+    }
+}

+ 317 - 0
client/src/ui.rs

@@ -0,0 +1,317 @@
+use cursive::view::Resizable as _;
+use cursive::views::{
+    Button, Dialog, LinearLayout, NamedView, Panel, ScrollView, SelectView, TextView,
+};
+use cursive::{event, Cursive};
+use utils::hash::Hashable as _;
+
+use crate::message::MessageSanitized;
+use crate::{get_appdata, message, Appdata, MAX_MESSAGE_LENGTH, MAX_USERNAME_LENGTH};
+
+pub const USERS_PANEL_ID: &str = "users_view_id";
+pub const MESSAGE_PANEL_ID: &str = "message_view_id";
+pub const INPUT_FIELD_ID: &str = "input_field_id";
+pub const INPUT_PANEL_ID: &str = "input_panel_id";
+pub const INPUT_BUTTON_ID: &str = "input_button_id";
+pub const INPUT_CLEAR_BUTTON_ID: &str = "input_clear_button_id";
+pub const LANGUAGE_BUTTON_ID: &str = "language_button_id";
+pub const CURRENT_USERNAME_PANEL_ID: &str = "current_username_view_id";
+pub const USERNAME_FIELD_ID: &str = "username_field_id";
+pub const USERNAME_BUTTON_ID: &str = "username_button_id";
+pub const REFRESH_BUTTON_ID: &str = "refresh_button_id";
+pub const BLOCKED_WORDS_BUTTON_ID: &str = "blocked_words_view_id";
+
+pub const DIALOGUE_MIN_SIZE: (u16, u16) = (20, 5);
+pub const USER_PANEL_SIZE: usize = crate::MAX_USERNAME_LENGTH + 2;
+
+#[derive(Clone, Copy)]
+pub enum Labels {
+    Ok,
+    Cancel,
+    Close,
+    Submit,
+    Send,
+    Clear,
+    Error,
+    User,
+    Users,
+    Message,
+    Messages,
+    TypeAMessage,
+    SetLanguage,
+    SetUsername,
+    InvalidUsernameExplination,
+    Username,
+    InvalidMessage,
+    FailedToSendMessage,
+    FailedToLoadMessages,
+    RefreshButton,
+    BlockedWords,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub enum Language {
+    English,
+    Dutch,
+    Japanese,
+}
+
+impl Labels {
+    // TODO: Double check the translations
+    pub fn localize(&self, language: Language) -> String {
+        let set = match self {
+            Labels::Ok => ["Ok", "OK", "OK"],
+            Labels::Cancel => ["Cancel", "Annuleren", "キャンセル"],
+            Labels::Close => ["Close", "Sluiten", "閉じる"],
+            Labels::Error => ["Error", "Fout", "エラー"],
+            Labels::User => ["User", "Gebruiker", "ユーザー"],
+            Labels::Messages => ["Messages", "Berichten", "メッセージ"],
+            Labels::Message => ["Message", "Bericht", "メッセージ"],
+            Labels::Users => ["Users", "Gebruikers", "ユーザー"],
+            Labels::TypeAMessage => [
+                "Type a message",
+                "Typ een bericht",
+                "メッセージを入力してください",
+            ],
+            Labels::SetUsername => [
+                "Set username",
+                "Stel gebruikersnaam in",
+                "ユーザー名を設定する",
+            ],
+            Labels::Send => ["Send", "Verzenden", "送信する"],
+            Labels::Clear => ["Clear", "Wissen", "クリア"],
+            Labels::Username => ["Username", "Gebruikersnaam", "ユーザー名"],
+            Labels::FailedToSendMessage => [
+                "Failed to send message",
+                "Bericht verzenden mislukt",
+                "メッセージの送信に失敗しました",
+            ],
+            Labels::SetLanguage => ["Set language", "Stel taal in", "言語を設定する"],
+            Labels::FailedToLoadMessages => [
+                "Failed to load messages",
+                "Berichten laden mislukt",
+                "メッセージの読み込みに失敗しました",
+            ],
+            Labels::RefreshButton => ["Refresh", "Vernieuwen", "更新する"],
+            Labels::InvalidUsernameExplination => [
+                r"Invalid username. Must match ^[a-zA-Z0-9#_\-\.]{2,${MAX_USERNAME_LENGTH}}$",
+                r"Ongeldige gebruikersnaam. Moet overeenkomen met ^[a-zA-Z0-9#_\-\.]{2,${MAX_USERNAME_LENGTH}}$",
+                r"無効なユーザー名。 ^[a-zA-Z0-9#_\-\.]{2,${MAX_USERNAME_LENGTH}}$ に一致する必要があります",
+            ],
+            Labels::InvalidMessage => [
+                "Invalid message. Must contain fewer than ${MAX_MESSAGE_LENGTH} characters",
+                "Ongeldig bericht. Moet minder dan ${MAX_MESSAGE_LENGTH} tekens bevatten",
+                "無効なメッセージ。 ${MAX_MESSAGE_LENGTH}文字未満である必要があります",
+            ],
+            Labels::BlockedWords => [
+                "Blocked words",
+                "Geblokkeerde woorden",
+                "ブロックされた単語",
+            ],
+            Labels::Submit => ["Submit", "Indienen", "提出する"],
+        };
+
+        let idx = match language {
+            Language::English => 0,
+            Language::Dutch => 1,
+            Language::Japanese => 2,
+        };
+
+        set[idx]
+            .replace("${MAX_MESSAGE_LENGTH}", &MAX_MESSAGE_LENGTH.to_string())
+            .replace("${MAX_USERNAME_LENGTH}", &MAX_USERNAME_LENGTH.to_string())
+    }
+}
+
+pub fn alert<S>(siv: &mut Cursive, title: S, text: S)
+where
+    S: Into<String>,
+{
+    let language = get_appdata(siv).language;
+
+    keybind_close_once(siv);
+
+    siv.add_layer(
+        Dialog::text(text)
+            .title(title)
+            .button(Labels::Close.localize(language), |siv| {
+                keybind_close_manual_end(siv, false);
+            })
+            .min_size((20, 5)),
+    );
+}
+
+pub fn error<S>(siv: &mut Cursive, text: S)
+where
+    S: Into<String>,
+{
+    let language = get_appdata(siv).language;
+    alert(siv, Labels::Error.localize(language), text.into());
+}
+
+/// Sets up hotkey to close the most recent view
+pub fn keybind_close_once(siv: &mut Cursive) {
+    siv.with_user_data(|appdata: &mut Appdata| {
+        appdata.quick_close_window_count += 1;
+    });
+
+    siv.add_global_callback(event::Key::Esc, |siv| {
+        keybind_close_manual_end(siv, false);
+    });
+}
+
+// `close_all` can be removed, it isn't used.
+/// Manually close the most recent view and removes the hotkey
+pub fn keybind_close_manual_end(siv: &mut Cursive, close_all: bool) {
+    siv.with_user_data(|appdata: &mut Appdata| {
+        if close_all {
+            appdata.quick_close_window_count = 0;
+        } else {
+            appdata.quick_close_window_count = appdata.quick_close_window_count.saturating_sub(1);
+        }
+    });
+
+    if get_appdata(siv).quick_close_window_count == 0 {
+        siv.clear_global_callbacks(event::Key::Esc);
+    }
+
+    siv.pop_layer();
+}
+
+pub fn visual_update(siv: &mut Cursive) {
+    let appdata = get_appdata(siv);
+
+    let mut messages: Vec<message::MessageSanitized> = appdata
+        .messages
+        .clone()
+        .values()
+        .cloned()
+        .map(MessageSanitized::from)
+        .collect::<Vec<_>>();
+    messages.sort_by(|a, b| a.time.cmp(&b.time));
+
+    // Remove blocked phrases
+    for message in messages.iter_mut() {
+        message.remove_blocked_phrases(siv);
+    }
+
+    // Messages
+    siv.call_on_name(
+        MESSAGE_PANEL_ID,
+        |panel: &mut Panel<ScrollView<SelectView>>| {
+            let view = panel.get_inner_mut().get_inner_mut();
+
+            let selected = view.selected_id();
+
+            view.clear();
+            for message in messages.iter() {
+                view.add_item(
+                    // TODO: Localized timestamps
+                    format!(
+                        "{:>6} | {}: {}\n",
+                        utils::time::timestamp_relative(message.time),
+                        message.sender,
+                        message.content,
+                    ),
+                    message.hash().to_string(),
+                );
+            }
+
+            if let Some(selected) = selected {
+                view.set_selection(selected);
+            }
+        },
+    );
+
+    // Members list
+    siv.call_on_name(
+        USERS_PANEL_ID,
+        |panel: &mut Panel<ScrollView<SelectView>>| {
+            let view = panel.get_inner_mut().get_inner_mut();
+
+            let mut senders: Vec<String> = vec![];
+            for message in messages.iter() {
+                if senders.contains(&message.sender) {
+                    continue;
+                }
+                senders.push(message.sender.clone());
+            }
+
+            let selected = view.selected_id();
+            view.clear();
+            for sender in senders {
+                view.add_item(sender.clone(), sender.clone());
+            }
+
+            if let Some(selected) = selected {
+                view.set_selection(selected);
+            }
+        },
+    );
+
+    // Current username
+    siv.call_on_name(
+        CURRENT_USERNAME_PANEL_ID,
+        |view: &mut NamedView<Panel<TextView>>| {
+            view.get_mut().get_inner_mut().set_content(appdata.username);
+        },
+    );
+}
+
+pub fn change_language(siv: &mut Cursive, language: Language) {
+    siv.with_user_data(|appdata: &mut Appdata| {
+        appdata.language = language;
+    })
+    .expect("Failed to set language.");
+
+    for (name, label) in [
+        (USERS_PANEL_ID, Labels::Users.localize(language)),
+        (MESSAGE_PANEL_ID, Labels::Messages.localize(language)),
+    ] {
+        siv.call_on_name(name, |panel: &mut Panel<ScrollView<SelectView>>| {
+            panel.set_title(label);
+        });
+    }
+
+    siv.call_on_name(INPUT_PANEL_ID, |panel: &mut Panel<LinearLayout>| {
+        panel.set_title(Labels::TypeAMessage.localize(language));
+    });
+
+    siv.call_on_name(INPUT_BUTTON_ID, |view: &mut NamedView<Button>| {
+        view.get_mut().set_label(Labels::Send.localize(language));
+    });
+
+    siv.call_on_name(INPUT_CLEAR_BUTTON_ID, |view: &mut NamedView<Button>| {
+        view.get_mut().set_label(Labels::Clear.localize(language));
+    });
+
+    siv.call_on_name(LANGUAGE_BUTTON_ID, |view: &mut NamedView<Button>| {
+        view.get_mut()
+            .set_label(Labels::SetLanguage.localize(language));
+    });
+
+    siv.call_on_name(USERNAME_BUTTON_ID, |view: &mut NamedView<Button>| {
+        view.get_mut()
+            .set_label(Labels::SetUsername.localize(language));
+    });
+
+    siv.call_on_name(REFRESH_BUTTON_ID, |view: &mut NamedView<Button>| {
+        view.get_mut()
+            .set_label(Labels::RefreshButton.localize(language));
+    });
+
+    siv.call_on_name(BLOCKED_WORDS_BUTTON_ID, |view: &mut NamedView<Button>| {
+        view.get_mut()
+            .set_label(Labels::BlockedWords.localize(language));
+    });
+
+    siv.call_on_name(
+        CURRENT_USERNAME_PANEL_ID,
+        |view: &mut NamedView<Panel<TextView>>| {
+            view.get_mut()
+                .set_title(Labels::Username.localize(language));
+        },
+    );
+
+    visual_update(siv);
+}

+ 9 - 0
server/Cargo.toml

@@ -0,0 +1,9 @@
+[package]
+name = "server"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+utils = { path = "../utils" }
+
+actix-web = "4"

+ 103 - 0
server/src/main.rs

@@ -0,0 +1,103 @@
+use std::sync::Mutex;
+
+use actix_web::{dev::Service as _, get, post, web, HttpResponse, HttpServer, Responder};
+use utils::strings::StaticString;
+
+const MAX_MESSAGE_LENGTH: usize = 2048;
+const MESSAGE_HISTORY_LENGTH: usize = 64;
+
+#[derive(Debug)]
+struct AppdataInner {
+    messages: [StaticString<MAX_MESSAGE_LENGTH>; MESSAGE_HISTORY_LENGTH],
+    index: usize,
+}
+
+impl AppdataInner {
+    pub fn new() -> Self {
+        Self {
+            messages: core::array::from_fn(|_| StaticString::new()),
+            index: 0,
+        }
+    }
+}
+
+#[derive(Debug)]
+struct Appdata {
+    inner: Mutex<AppdataInner>,
+}
+
+impl Appdata {
+    pub fn new() -> Self {
+        Self {
+            inner: Mutex::new(AppdataInner::new()),
+        }
+    }
+
+    pub fn insert_message(&self, message: String) {
+        if message.len() > MAX_MESSAGE_LENGTH {
+            return;
+        }
+
+        let mut guard = self.inner.lock().unwrap();
+        let index = guard.index;
+        guard.messages[index] = message.into();
+        guard.index = (index + 1) % guard.messages.len();
+    }
+
+    pub fn get_messages(&self) -> Vec<String> {
+        let guard = self.inner.lock().unwrap();
+        let mut messages = vec![];
+
+        for message in guard.messages.iter() {
+            if message.is_empty() {
+                continue;
+            }
+            messages.push(String::from(message));
+        }
+
+        messages
+    }
+}
+
+#[get("/")]
+async fn index_get(data: web::Data<Appdata>) -> impl Responder {
+    let string = data.get_messages().join(",");
+    println!("Sending `{}` characters", string.len());
+    HttpResponse::Ok().body(string)
+}
+
+#[post("/")]
+async fn index_post(body: web::Payload, data: web::Data<Appdata>) -> impl Responder {
+    let bytes = body.to_bytes().await.unwrap();
+    let str = String::from_utf8_lossy(&bytes).into_owned();
+
+    println!("Received ({}): {:#?}", str.len(), str);
+
+    data.insert_message(str.clone());
+    HttpResponse::Ok().body(str)
+}
+
+#[actix_web::main]
+async fn main() -> std::io::Result<()> {
+    println!("Starting...");
+
+    let appdata = web::Data::new(Appdata::new());
+
+    HttpServer::new(move || {
+        actix_web::App::new()
+            .app_data(appdata.clone())
+            .wrap_fn(|req, srv| {
+                // println!("{:#?}", LoggedRequest::new(&req));
+                let fut = srv.call(req);
+                async {
+                    let res = fut.await?;
+                    Ok(res)
+                }
+            })
+            .service(index_get)
+            .service(index_post)
+    })
+    .bind(("127.0.0.1", 8080))?
+    .run()
+    .await
+}

+ 7 - 0
utils/Cargo.toml

@@ -0,0 +1,7 @@
+[package]
+name = "utils"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+sha2 = "0.10.8"

+ 30 - 0
utils/src/binary.rs

@@ -0,0 +1,30 @@
+pub fn bin2hex<B>(bin: B) -> String
+where
+    B: AsRef<[u8]>,
+{
+    bin.as_ref()
+        .iter()
+        .map(|b| format!("{:02x}", b))
+        .collect::<Vec<String>>()
+        .join("")
+}
+
+pub fn hex2bin<S>(hex: S) -> Option<Vec<u8>>
+where
+    S: AsRef<str>,
+{
+    let hex = hex.as_ref();
+    if hex.len() % 2 != 0 {
+        return None;
+    }
+
+    let mut bytes = Vec::with_capacity(hex.len() / 2);
+    for i in (0..hex.len()).step_by(2) {
+        let byte_str = &hex[i..i + 2];
+        match u8::from_str_radix(byte_str, 16) {
+            Ok(byte) => bytes.push(byte),
+            Err(_) => return None,
+        }
+    }
+    Some(bytes)
+}

+ 38 - 0
utils/src/hash.rs

@@ -0,0 +1,38 @@
+use sha2::Digest as _;
+
+#[derive(Debug, Clone)]
+pub struct Hash {
+    hash: [u8; 32],
+}
+
+impl<B> From<B> for Hash
+where
+    B: AsRef<[u8]>,
+{
+    fn from(bytes: B) -> Self {
+        let mut hasher = sha2::Sha256::new();
+        hasher.update(bytes);
+        let result = hasher.finalize();
+
+        let mut hash = [0u8; 32];
+        hash.copy_from_slice(&result);
+
+        Hash { hash }
+    }
+}
+
+impl ToString for Hash {
+    fn to_string(&self) -> String {
+        self.hash.iter().map(|b| format!("{:02x}", b)).collect()
+    }
+}
+
+pub trait Hashable {
+    fn hash(&self) -> Hash;
+}
+
+impl Hashable for String {
+    fn hash(&self) -> Hash {
+        Hash::from(self.as_bytes())
+    }
+}

+ 10 - 0
utils/src/lib.rs

@@ -0,0 +1,10 @@
+pub mod binary;
+pub mod hash;
+pub mod rng;
+pub mod serialize;
+pub mod strings;
+pub mod time;
+
+pub fn new_id() -> String {
+    rng::random_numeric_string(12)
+}

+ 57 - 0
utils/src/rng.rs

@@ -0,0 +1,57 @@
+use std::sync::Mutex;
+
+use crate::time;
+
+static RNG: Mutex<XorShift64> = Mutex::new(XorShift64::default());
+
+pub fn shuffle_rng() {
+    let mut rng = RNG.lock().expect("RNG mutex is poisoned.");
+    rng.inject_time();
+}
+
+pub fn random_numeric_string(length: usize) -> String {
+    let mut out = String::new();
+
+    while out.len() < length {
+        let num = random_number();
+        out.push_str(&num.to_string());
+    }
+
+    out[..length].to_string()
+}
+
+pub fn random_number() -> u64 {
+    let mut rng = RNG.lock().expect("RNG mutex is poisoned.");
+    rng.next()
+}
+
+struct XorShift64 {
+    state: u64,
+}
+
+impl XorShift64 {
+    const fn new(seed: u64) -> Self {
+        Self { state: seed }
+    }
+
+    const fn default() -> Self {
+        Self::new(49)
+    }
+
+    fn inject_time(&mut self) {
+        self.state ^= time::time_millis() as u64 % 1000;
+    }
+
+    fn next(&mut self) -> u64 {
+        self.state ^= self.state << 13;
+        self.state ^= self.state >> 7;
+        self.state ^= self.state << 17;
+        self.state
+    }
+}
+
+impl Default for XorShift64 {
+    fn default() -> Self {
+        Self::default()
+    }
+}

+ 168 - 0
utils/src/serialize.rs

@@ -0,0 +1,168 @@
+use std::error::Error;
+
+pub type Result<T> = std::result::Result<T, DeserializationError>;
+
+#[derive(Debug)]
+pub enum DeserializationError {
+    InvalidSignature,
+    InvalidLength,
+    #[allow(dead_code)]
+    InvalidData(&'static str),
+    InvalidDataLengthMarker,
+}
+
+impl Error for DeserializationError {}
+
+impl std::fmt::Display for DeserializationError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{:?}", self)
+    }
+}
+
+pub trait Serialize {
+    const SIGNATURE: &'static str = "";
+
+    fn serialize_unchecked(&self) -> Vec<u8>;
+    fn deserialize_unchecked<B>(data: B) -> Result<Self>
+    where
+        B: AsRef<[u8]>,
+        Self: Sized;
+
+    fn serialize_checked(&self) -> Vec<u8> {
+        let mut serialized = Self::SIGNATURE.as_bytes().to_vec();
+        serialized.extend_from_slice(&self.serialize_unchecked());
+        serialized
+    }
+
+    fn deserialize_checked<B>(data: B) -> Result<Self>
+    where
+        B: AsRef<[u8]>,
+        Self: Sized,
+    {
+        let data = data.as_ref();
+
+        if data.len() < Self::SIGNATURE.len() {
+            return Err(DeserializationError::InvalidLength);
+        }
+
+        let signature = &data[..Self::SIGNATURE.len()];
+        if signature != Self::SIGNATURE.as_bytes() {
+            return Err(DeserializationError::InvalidSignature);
+        }
+
+        Self::deserialize_unchecked(&data[Self::SIGNATURE.len()..])
+    }
+}
+
+pub type SetOfSerializedObjects = Vec<Vec<u8>>;
+type SetOfSerializedObjectsLengthMarker = u16; // NOTE: 65534 bytes is the max length of a serialized object
+const SET_OF_SERIALIZED_OBJECTS_LENGTH_MARKER_BYTES: usize =
+    (SetOfSerializedObjectsLengthMarker::BITS / 8) as usize;
+
+impl Serialize for SetOfSerializedObjects {
+    const SIGNATURE: &'static str = "SetOfSerializedObjects";
+
+    fn serialize_unchecked(&self) -> Vec<u8> {
+        let mut lengths: Vec<SetOfSerializedObjectsLengthMarker> = Vec::new();
+        for item in self {
+            // TODO: Account for wrapping
+            lengths.push(item.len() as SetOfSerializedObjectsLengthMarker);
+        }
+
+        let mut serialized = Vec::new();
+        for length in &lengths {
+            serialized.extend_from_slice(&length.to_be_bytes());
+        }
+
+        serialized.extend_from_slice(&SetOfSerializedObjectsLengthMarker::MAX.to_be_bytes()[..]);
+
+        for item in self {
+            serialized.extend_from_slice(item);
+        }
+
+        serialized
+    }
+
+    fn deserialize_unchecked<B>(data: B) -> Result<Self>
+    where
+        B: AsRef<[u8]>,
+    {
+        let data = data.as_ref();
+
+        let mut lengths = Vec::new();
+
+        for len_bytes in data.chunks_exact(SET_OF_SERIALIZED_OBJECTS_LENGTH_MARKER_BYTES) {
+            let length = SetOfSerializedObjectsLengthMarker::from_be_bytes(
+                len_bytes
+                    .try_into()
+                    .map_err(|_| DeserializationError::InvalidDataLengthMarker)?,
+            );
+            if length == SetOfSerializedObjectsLengthMarker::MAX {
+                break;
+            }
+            lengths.push(length);
+        }
+
+        let mut after_lengths_data = data
+            .iter()
+            .skip((lengths.len() + 1) * SET_OF_SERIALIZED_OBJECTS_LENGTH_MARKER_BYTES)
+            .cloned();
+
+        let mut items = Vec::new();
+
+        for length in lengths {
+            let mut item_data = Vec::new();
+            for _ in 0..length {
+                let Some(item_byte) = after_lengths_data.next() else {
+                    return Err(DeserializationError::InvalidLength);
+                };
+                item_data.push(item_byte);
+            }
+
+            items.push(item_data);
+        }
+
+        Ok(items)
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    fn sample_data() -> SetOfSerializedObjects {
+        vec![
+            vec![1, 2, 3],
+            vec![4, 5, 6, 7, 8],
+            vec![9, 10, 11, 12, 13, 14],
+        ]
+    }
+
+    #[test]
+    fn test_serialize_deserialize() {
+        let data = sample_data();
+
+        let serialized = data.serialize_unchecked();
+        let deserialized = SetOfSerializedObjects::deserialize_unchecked(&serialized).unwrap();
+
+        println!("Original: {:?}", data);
+        println!("Serialized: {:?}", serialized);
+        println!("Deserialized: {:?}", deserialized);
+
+        assert_eq!(data, deserialized);
+    }
+
+    #[test]
+    fn test_serialize_checked() {
+        let data = sample_data();
+
+        let serialized = data.serialize_checked();
+        let deserialized = SetOfSerializedObjects::deserialize_checked(&serialized).unwrap();
+
+        println!("Original: {:?}", data);
+        println!("Serialized: {:?}", serialized);
+        println!("Deserialized: {:?}", deserialized);
+
+        assert_eq!(data, deserialized);
+    }
+}

+ 104 - 0
utils/src/strings.rs

@@ -0,0 +1,104 @@
+// /// String that gets cropped if it exceeds a certain size.
+// #[derive(PartialEq, Eq, Hash, Debug)]
+// struct SizeCappedString<const N: usize> {
+//     inner: String,
+//     length: usize,
+//     is_cropped: bool,
+// }
+
+// impl<const N: usize> SizeCappedString<N> {
+//     const MAX_SIZE: usize = N;
+
+//     pub fn new(data: String) -> Self {
+//         let length = data.len();
+
+//         if length > Self::MAX_SIZE {
+//             Self {
+//                 inner: data[..Self::MAX_SIZE].to_string(),
+//                 length,
+//                 is_cropped: true,
+//             }
+//         } else {
+//             Self {
+//                 inner: data,
+//                 length,
+//                 is_cropped: false,
+//             }
+//         }
+//     }
+
+//     pub fn to_string_lossy(&self) -> String {
+//         self.inner.clone()
+//     }
+
+//     pub fn to_string_marked(&self, mark: &str) -> String {
+//         if self.is_cropped {
+//             format!("{}{}", self.inner, mark)
+//         } else {
+//             self.inner.clone()
+//         }
+//     }
+
+//     pub fn to_string(&self) -> Option<String> {
+//         if self.is_cropped {
+//             None
+//         } else {
+//             Some(self.inner.clone())
+//         }
+//     }
+// }
+
+/// String with a consistent size.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct StaticString<const N: usize>([char; N]);
+
+impl<const N: usize> From<&StaticString<N>> for String {
+    fn from(value: &StaticString<N>) -> Self {
+        let mut result = String::new();
+        for &c in value.0.iter() {
+            if c == 0 as char {
+                break;
+            }
+            result.push(c);
+        }
+        result
+    }
+}
+
+impl<const N: usize> From<String> for StaticString<N> {
+    fn from(value: String) -> Self {
+        let mut s = Self::new();
+        for (i, c) in value.chars().take(N).enumerate() {
+            s.0[i] = c;
+        }
+        s
+    }
+}
+
+impl<const N: usize> Default for StaticString<N> {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl<const N: usize> StaticString<N> {
+    pub fn new() -> Self {
+        StaticString([0 as char; N])
+    }
+
+    pub fn is_empty(&self) -> bool {
+        let first = self.0.first();
+        first.is_none() || first == Some(&(0 as char))
+    }
+}
+
+pub fn remove_bad_words(s: &str, wordlist: &[String], censor_char: char) -> String {
+    let mut s = s.to_string();
+
+    for word in wordlist {
+        let replacement = censor_char.to_string().repeat(word.len());
+        s = s.replace(word, &replacement);
+    }
+
+    s
+}

+ 39 - 0
utils/src/time.rs

@@ -0,0 +1,39 @@
+use std::time::{SystemTime, UNIX_EPOCH};
+
+pub type Timestamp = u128;
+
+pub fn time_millis() -> Timestamp {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .expect("System time is before UNIX EPOCH.")
+        .as_millis()
+}
+
+pub fn timestamp_relative(time_reference: Timestamp) -> String {
+    let mut ts = (time_millis() - time_reference) as f32 / 1000.;
+    let mut unit = "s";
+    let mut rounding = 0;
+
+    if ts > 60. {
+        ts /= 60.;
+        unit = "m";
+        rounding = 1;
+
+        if ts > 60. {
+            ts /= 60.;
+            unit = "h";
+            rounding = 1;
+
+            if ts > 24. {
+                ts /= 24.;
+                unit = "d";
+                rounding = 2;
+            }
+        }
+    }
+
+    let mul = 10f32.powi(rounding);
+    ts = (ts * mul).floor() / mul;
+
+    format!("{}{}", ts, unit)
+}