diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml
new file mode 100644
index 0000000..644cdf0
--- /dev/null
+++ b/.idea/go.imports.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
index 16d9e98..6d7519c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,29 +2,6 @@
# It is not intended for manual editing.
version = 4
-[[package]]
-name = "Tofu"
-version = "0.1.0"
-dependencies = [
- "anyhow",
- "chrono",
- "dirs",
- "dotenvy",
- "eyeball-im",
- "futures-util",
- "hex",
- "matrix-sdk",
- "matrix-sdk-ui",
- "rand 0.10.3",
- "serde",
- "serde_json",
- "slint",
- "slint-build",
- "tokio",
- "tracing",
- "tracing-subscriber",
-]
-
[[package]]
name = "ab_glyph"
version = "0.2.32"
@@ -279,6 +256,17 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c"
+[[package]]
+name = "apple-native-keyring-store"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a"
+dependencies = [
+ "keyring-core",
+ "log",
+ "security-framework",
+]
+
[[package]]
name = "arboard"
version = "3.6.1"
@@ -1486,27 +1474,6 @@ dependencies = [
"ctutils",
]
-[[package]]
-name = "dirs"
-version = "7.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f"
-dependencies = [
- "dirs-sys",
-]
-
-[[package]]
-name = "dirs-sys"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
-dependencies = [
- "libc",
- "option-ext",
- "redox_users",
- "windows-sys 0.61.2",
-]
-
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -3439,6 +3406,27 @@ dependencies = [
"unicode-segmentation",
]
+[[package]]
+name = "keyring"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627"
+dependencies = [
+ "apple-native-keyring-store",
+ "keyring-core",
+ "windows-native-keyring-store",
+ "zbus-secret-service-keyring-store",
+]
+
+[[package]]
+name = "keyring-core"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d"
+dependencies = [
+ "log",
+]
+
[[package]]
name = "khronos_api"
version = "3.1.0"
@@ -4357,12 +4345,75 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "num"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
+dependencies = [
+ "num-bigint",
+ "num-complex",
+ "num-integer",
+ "num-iter",
+ "num-rational",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+[[package]]
+name = "num-integer"
+version = "0.1.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
+dependencies = [
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+]
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -4773,12 +4824,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
-[[package]]
-name = "option-ext"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
-
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -5486,16 +5531,6 @@ dependencies = [
"bitflags 2.13.2",
]
-[[package]]
-name = "redox_users"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
-dependencies = [
- "libredox",
- "thiserror 2.0.21",
-]
-
[[package]]
name = "regex"
version = "1.13.1"
@@ -6022,6 +6057,25 @@ dependencies = [
"syn 3.0.6",
]
+[[package]]
+name = "secret-service"
+version = "5.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8"
+dependencies = [
+ "aes",
+ "cbc",
+ "futures-util",
+ "getrandom 0.4.3",
+ "hkdf",
+ "hybrid-array",
+ "num",
+ "once_cell",
+ "serde",
+ "sha2 0.11.0",
+ "zbus",
+]
+
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -6879,6 +6933,29 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
+[[package]]
+name = "tofu"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "chrono",
+ "dotenvy",
+ "eyeball-im",
+ "futures-util",
+ "hex",
+ "keyring",
+ "matrix-sdk",
+ "matrix-sdk-ui",
+ "rand 0.10.3",
+ "serde",
+ "serde_json",
+ "slint",
+ "slint-build",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+]
+
[[package]]
name = "tokio"
version = "1.53.2"
@@ -7938,6 +8015,19 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+[[package]]
+name = "windows-native-keyring-store"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8"
+dependencies = [
+ "byteorder",
+ "keyring-core",
+ "regex",
+ "windows-sys 0.61.2",
+ "zeroize",
+]
+
[[package]]
name = "windows-numerics"
version = "0.3.1"
@@ -8434,6 +8524,17 @@ dependencies = [
"zvariant",
]
+[[package]]
+name = "zbus-secret-service-keyring-store"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a"
+dependencies = [
+ "keyring-core",
+ "secret-service",
+ "zbus",
+]
+
[[package]]
name = "zbus_macros"
version = "5.19.0"
diff --git a/Cargo.toml b/Cargo.toml
index c8f2f25..7b85b93 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
-name = "Tofu"
-authors = ["Inako"]
+name = "tofu"
+authors = ["Inako", "Thoq"]
version = "0.1.0"
edition = "2024"
@@ -21,10 +21,10 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1.44"
rand = "0.10.3"
-dirs = "7.0.0"
hex = "0.4.3"
dotenvy = "0.15.7"
+keyring = "4.2.0"
[build-dependencies]
slint-build = "1.18.1"
diff --git a/assets/translations/en_US/LC_MESSAGES/tofu.po b/assets/translations/en_US/LC_MESSAGES/tofu.po
new file mode 100644
index 0000000..0698e21
--- /dev/null
+++ b/assets/translations/en_US/LC_MESSAGES/tofu.po
@@ -0,0 +1,151 @@
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Tofu 0.1.0\n"
+"POT-Creation-Date: 2026-10-09 21:50+0000\n"
+"PO-Revision-Date: 2026-10-09 20:26+0000\n"
+"Last-Translator: Automatically generated\n"
+"Language-Team: none\n"
+"Language: en_US\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#. Reply preview: {0} is the sender, {1} the start of the replied-to text.
+#: ui/components/message-list.slint:112
+msgctxt "ChatMessage"
+msgid "↳ {0} {1}"
+msgstr "↳ {0} {1}"
+
+#: ui/components/message-list.slint:179
+msgctxt "ChatMessage"
+msgid "(edited)"
+msgstr "(edited)"
+
+#. {} is the channel name; the leading "#" is the channel prefix.
+#: ui/components/message-list.slint:274
+msgctxt "RoomStart"
+msgid "Welcome to #{}!"
+msgstr "Welcome to #{}!"
+
+#. {} is the channel name; the leading "#" is the channel prefix.
+#: ui/components/message-list.slint:283
+msgctxt "RoomStart"
+msgid "This is the start of the #{} channel."
+msgstr "This is the start of the #{} channel."
+
+#: ui/components/message-list.slint:395
+msgctxt "MessageList"
+msgid "Loading history…"
+msgstr "Loading history…"
+
+#: ui/login.slint:35
+msgctxt "LoginScreen"
+msgid "Welcome back!"
+msgstr "Welcome back!"
+
+#: ui/login.slint:43
+msgctxt "LoginScreen"
+msgid "Sign in with your Matrix account"
+msgstr "Sign in with your Matrix account"
+
+#: ui/login.slint:54
+msgctxt "LoginScreen"
+msgid "HOMESERVER"
+msgstr "HOMESERVER"
+
+#: ui/login.slint:61
+msgctxt "LoginScreen"
+msgid "matrix.org"
+msgstr "matrix.org"
+
+#: ui/login.slint:70
+msgctxt "LoginScreen"
+msgid "USERNAME"
+msgstr "USERNAME"
+
+#: ui/login.slint:77
+msgctxt "LoginScreen"
+msgid "user (or @user:matrix.org)"
+msgstr "user (or @user:matrix.org)"
+
+#: ui/login.slint:86
+msgctxt "LoginScreen"
+msgid "PASSWORD"
+msgstr "PASSWORD"
+
+#: ui/login.slint:115
+msgctxt "LoginScreen"
+msgid "Signing in…"
+msgstr "Signing in…"
+
+#: ui/login.slint:115
+msgctxt "LoginScreen"
+msgid "Log In"
+msgstr "Log In"
+
+#: ui/main.slint:18 ui/main.slint:84
+msgctxt "MainWindow"
+msgid "Tofu"
+msgstr "Tofu"
+
+#. Heading of the sidebar in the Home view, which lists direct messages.
+#: ui/main.slint:45 ui/main.slint:124
+msgctxt "MainWindow"
+msgid "Direct Messages"
+msgstr "Direct Messages"
+
+#: ui/main.slint:118
+msgctxt "MainWindow"
+msgid "Create / join a space"
+msgstr "Create / join a space"
+
+#: ui/main.slint:133
+msgctxt "MainWindow"
+msgid "Space settings"
+msgstr "Space settings"
+
+#: ui/main.slint:159
+msgctxt "MainWindow"
+msgid "Threads"
+msgstr "Threads"
+
+#: ui/main.slint:162
+msgctxt "MainWindow"
+msgid "Pinned messages"
+msgstr "Pinned messages"
+
+#: ui/main.slint:165
+msgctxt "MainWindow"
+msgid "Search"
+msgstr "Search"
+
+#. {} is the room name (or the person's name in a DM). Keep the "#" / "@".
+#: ui/main.slint:207
+msgctxt "MainWindow"
+msgid "Message @{}"
+msgstr "Message @{}"
+
+#. {} is the room name (or the person's name in a DM). Keep the "#" / "@".
+#: ui/main.slint:207
+msgctxt "MainWindow"
+msgid "Message #{}"
+msgstr "Message #{}"
+
+#. Banner above the message box. {} is the name of the person replied to.
+#: ui/main.slint:209
+msgctxt "MainWindow"
+msgid "Editing message"
+msgstr "Editing message"
+
+#. Banner above the message box. {} is the name of the person replied to.
+#: ui/main.slint:209
+msgctxt "MainWindow"
+msgid "Replying to {}"
+msgstr "Replying to {}"
+
+#: ui/main.slint:231
+msgctxt "MainWindow"
+msgid "File uploads"
+msgstr "File uploads"
diff --git a/assets/translations/tofu.pot b/assets/translations/tofu.pot
new file mode 100644
index 0000000..64e0dcf
--- /dev/null
+++ b/assets/translations/tofu.pot
@@ -0,0 +1,151 @@
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: tofu 0.1.0\n"
+"POT-Creation-Date: 2026-10-09 21:50+0000\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME \n"
+"Language-Team: LANGUAGE \n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+
+#. Reply preview: {0} is the sender, {1} the start of the replied-to text.
+#: ui/components/message-list.slint:112
+msgctxt "ChatMessage"
+msgid "↳ {0} {1}"
+msgstr ""
+
+#: ui/components/message-list.slint:179
+msgctxt "ChatMessage"
+msgid "(edited)"
+msgstr ""
+
+#. {} is the channel name; the leading "#" is the channel prefix.
+#: ui/components/message-list.slint:274
+msgctxt "RoomStart"
+msgid "Welcome to #{}!"
+msgstr ""
+
+#. {} is the channel name; the leading "#" is the channel prefix.
+#: ui/components/message-list.slint:283
+msgctxt "RoomStart"
+msgid "This is the start of the #{} channel."
+msgstr ""
+
+#: ui/components/message-list.slint:395
+msgctxt "MessageList"
+msgid "Loading history…"
+msgstr ""
+
+#: ui/login.slint:35
+msgctxt "LoginScreen"
+msgid "Welcome back!"
+msgstr ""
+
+#: ui/login.slint:43
+msgctxt "LoginScreen"
+msgid "Sign in with your Matrix account"
+msgstr ""
+
+#: ui/login.slint:54
+msgctxt "LoginScreen"
+msgid "HOMESERVER"
+msgstr ""
+
+#: ui/login.slint:61
+msgctxt "LoginScreen"
+msgid "matrix.org"
+msgstr ""
+
+#: ui/login.slint:70
+msgctxt "LoginScreen"
+msgid "USERNAME"
+msgstr ""
+
+#: ui/login.slint:77
+msgctxt "LoginScreen"
+msgid "user (or @user:matrix.org)"
+msgstr ""
+
+#: ui/login.slint:86
+msgctxt "LoginScreen"
+msgid "PASSWORD"
+msgstr ""
+
+#: ui/login.slint:115
+msgctxt "LoginScreen"
+msgid "Signing in…"
+msgstr ""
+
+#: ui/login.slint:115
+msgctxt "LoginScreen"
+msgid "Log In"
+msgstr ""
+
+#: ui/main.slint:18 ui/main.slint:84
+msgctxt "MainWindow"
+msgid "Tofu"
+msgstr ""
+
+#. Heading of the sidebar in the Home view, which lists direct messages.
+#: ui/main.slint:45 ui/main.slint:124
+msgctxt "MainWindow"
+msgid "Direct Messages"
+msgstr ""
+
+#: ui/main.slint:118
+msgctxt "MainWindow"
+msgid "Create / join a space"
+msgstr ""
+
+#: ui/main.slint:133
+msgctxt "MainWindow"
+msgid "Space settings"
+msgstr ""
+
+#: ui/main.slint:159
+msgctxt "MainWindow"
+msgid "Threads"
+msgstr ""
+
+#: ui/main.slint:162
+msgctxt "MainWindow"
+msgid "Pinned messages"
+msgstr ""
+
+#: ui/main.slint:165
+msgctxt "MainWindow"
+msgid "Search"
+msgstr ""
+
+#. {} is the room name (or the person's name in a DM). Keep the "#" / "@".
+#: ui/main.slint:207
+msgctxt "MainWindow"
+msgid "Message @{}"
+msgstr ""
+
+#. {} is the room name (or the person's name in a DM). Keep the "#" / "@".
+#: ui/main.slint:207
+msgctxt "MainWindow"
+msgid "Message #{}"
+msgstr ""
+
+#. Banner above the message box. {} is the name of the person replied to.
+#: ui/main.slint:209
+msgctxt "MainWindow"
+msgid "Editing message"
+msgstr ""
+
+#. Banner above the message box. {} is the name of the person replied to.
+#: ui/main.slint:209
+msgctxt "MainWindow"
+msgid "Replying to {}"
+msgstr ""
+
+#: ui/main.slint:231
+msgctxt "MainWindow"
+msgid "File uploads"
+msgstr ""
diff --git a/build.rs b/build.rs
index ef2013c..8032b86 100644
--- a/build.rs
+++ b/build.rs
@@ -1,6 +1,8 @@
use slint_build::CompilerConfiguration;
fn main() {
- let config = CompilerConfiguration::new().with_style("fluent-dark".to_owned());
+ let config = CompilerConfiguration::new()
+ .with_style("fluent-dark".to_owned())
+ .with_bundled_translations("assets/translations");
slint_build::compile_with_config("ui/main.slint", config).expect("Failed to compile Slint UI");
-}
\ No newline at end of file
+}
diff --git a/scripts/update-translations.sh b/scripts/update-translations.sh
new file mode 100755
index 0000000..332af8d
--- /dev/null
+++ b/scripts/update-translations.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+cd "$(dirname "${BASH_SOURCE[0]}")/.."
+
+# janky aaah
+domain=$(sed -n '/^\[package\]/,/^\[/s/^name = "\(.*\)"$/\1/p' Cargo.toml)
+version=$(sed -n '/^\[package\]/,/^\[/s/^version = "\(.*\)"$/\1/p' Cargo.toml)
+[ -n "$domain" ] || { echo "error: could not find the package name in Cargo.toml" >&2; exit 1; }
+dir=assets/translations
+pot=$dir/$domain.pot
+
+for tool in slint-tr-extractor msginit msgmerge msgcat; do
+ command -v "$tool" > /dev/null || { echo "error: '$tool' is not installed, see docs/TRANSLATIONS.md" >&2; exit 1; }
+done
+
+fresh=$(mktemp)
+trap 'rm -f "$fresh"' EXIT
+
+find ui -name '*.slint' | LC_ALL=C sort | xargs slint-tr-extractor --package-name "$domain" --package-version "$version" -o "$fresh"
+
+msgcat --no-wrap "$fresh" > /dev/null || { echo "error: the extracted template is not valid, is a translator comment longer than 76 characters?" >&2; exit 1; }
+
+if [ -f "$pot" ] && diff -q <(grep -v '^"POT-Creation-Date:' "$pot") <(grep -v '^"POT-Creation-Date:' "$fresh") > /dev/null; then
+ echo "$pot is up to date"
+else
+ cp "$fresh" "$pot"
+ echo "updated $pot"
+fi
+
+for lang in "$@"; do
+ po=$dir/$lang/LC_MESSAGES/$domain.po
+ if [ -e "$po" ]; then
+ echo "$po already exists" >&2
+ else
+ mkdir -p "$(dirname "$po")"
+ msginit --no-translator --locale="$lang" --input="$pot" --output-file="$po"
+ fi
+done
+
+for po in "$dir"/*/LC_MESSAGES/"$domain".po; do
+ [ -e "$po" ] || continue
+ msgmerge --update --backup=none --quiet "$po" "$pot"
+ echo "merged into $po"
+done
diff --git a/src/backend/credentials.rs b/src/backend/credentials.rs
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/backend/credentials.rs
@@ -0,0 +1 @@
+
diff --git a/src/backend/format.rs b/src/backend/format.rs
index ed6e712..d170479 100644
--- a/src/backend/format.rs
+++ b/src/backend/format.rs
@@ -2,7 +2,10 @@ use chrono::{DateTime, Datelike, Local, TimeZone};
use matrix_sdk::ruma::MilliSecondsSinceUnixEpoch;
pub fn to_local(timestamp: MilliSecondsSinceUnixEpoch) -> DateTime {
let ms: i64 = i64::try_from(u64::from(timestamp.0)).unwrap_or(0);
- Local.timestamp_millis_opt(ms).single().unwrap_or_else(Local::now)
+ Local
+ .timestamp_millis_opt(ms)
+ .single()
+ .unwrap_or_else(Local::now)
}
/// "Today at 13:00" / "Yesterday at 13:00" / "2026-10-09 13:00"
@@ -40,4 +43,4 @@ pub fn truncate(string: &str, max_characters: usize) -> String {
} else {
flat
}
-}
\ No newline at end of file
+}
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index aef04a2..a739f0f 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -1,28 +1,37 @@
+mod credentials;
mod format;
-mod session;
-mod room;
mod navigation;
+mod room;
+mod session;
+use anyhow::{Context, Result, anyhow};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
-use anyhow::{Context, Result, anyhow};
use std::time::{Duration, Instant};
-use matrix_sdk::{Client, Room, RoomState, SessionChange, ruma::{
- EventId, OwnedRoomId, OwnedTransactionId, RoomId, RoomOrAliasId, OwnedUserId,
- events::room::message::{RoomMessageEventContent, RoomMessageEventContentWithoutRelation},
-}, room::edit::EditedContent, OwnedServerName};
+use matrix_sdk::{
+ Client, OwnedServerName, Room, RoomState, SessionChange,
+ room::edit::EditedContent,
+ ruma::{
+ EventId, OwnedRoomId, OwnedTransactionId, OwnedUserId, RoomId, RoomOrAliasId,
+ events::room::message::{RoomMessageEventContent, RoomMessageEventContentWithoutRelation},
+ },
+};
-use matrix_sdk_ui::{sync_service::{State as SyncState, SyncService}, spaces::SpaceService, timeline::TimelineEventItemId, Timeline};
+use crate::backend::room::OpenRoom;
+use crate::backend::session::SavedSession;
+use crate::model::{Command, Event, UserInfo};
+use matrix_sdk_ui::{
+ Timeline,
+ spaces::SpaceService,
+ sync_service::{State as SyncState, SyncService},
+ timeline::TimelineEventItemId,
+};
use tokio::{
sync::{mpsc, watch},
task::JoinHandle,
};
-use crate::backend::room::OpenRoom;
-use crate::backend::session::SavedSession;
-use crate::model::{Command, Event, UserInfo};
-
/// How the backend delivers events. The UI layer provides this.
pub type Sink = Arc;
@@ -61,7 +70,7 @@ struct Backend {
/// Tasks that live as long as the login does.
background: Vec,
- open: Option
+ open: Option,
}
impl Backend {
@@ -74,7 +83,7 @@ impl Backend {
selected_tx: watch::channel(None).0,
via: Arc::new(Mutex::new(HashMap::new())),
background: Vec::new(),
- open: None
+ open: None,
}
}
@@ -88,7 +97,7 @@ impl Backend {
while let Some(command) = rx.recv().await {
if let Err(error) = self.handle(command).await {
tracing::error!("Command failed: {error:#}");
- self.emit(Event::Toast(format!("{error:#}")))
+ self.emit(Event::Toast(format!("{error:#}")));
}
}
}
@@ -96,10 +105,14 @@ impl Backend {
// Session
async fn try_restore(&mut self) {
- let saved: SavedSession = match session::load() {
+ let saved: SavedSession = match session::load().await {
Ok(Some(session)) => session,
Ok(None) => return self.emit(Event::NeedLogin { error: None }),
- Err(error) => return self.emit(Event::NeedLogin { error: Some(format!("{error:#}")) })
+ Err(error) => {
+ return self.emit(Event::NeedLogin {
+ error: Some(format!("{error:#}")),
+ });
+ }
};
match session::restore(&saved).await {
@@ -107,13 +120,17 @@ impl Backend {
self.store_passphrase = saved.store_passphrase;
if let Err(error) = self.on_logged_in(client).await {
- self.emit(Event::NeedLogin { error: Some(format!("{error:#}")) })
+ self.emit(Event::NeedLogin {
+ error: Some(format!("{error:#}")),
+ });
}
}
Err(error) => {
tracing::warn!("Restoration of session failed: {error:#}");
- self.emit(Event::NeedLogin { error: Some("Couldn't restore your last session. Please sign in again.".into()) })
+ self.emit(Event::NeedLogin {
+ error: Some("Couldn't restore your last session. Please sign in again.".into()),
+ });
}
}
}
@@ -125,12 +142,15 @@ impl Backend {
let (client, passphrase) = session::login(homeserver, username, password).await?;
self.store_passphrase = passphrase;
self.on_logged_in(client).await
- }.await;
+ }
+ .await;
self.emit(Event::LoginBusy(false));
if let Err(error) = result {
- self.emit(Event::NeedLogin { error: Some(format!("{error:#}")) })
+ self.emit(Event::NeedLogin {
+ error: Some(format!("{error:#}")),
+ });
}
}
@@ -147,31 +167,54 @@ impl Backend {
let _ = client.matrix_auth().logout().await;
}
- session::clear();
+ // yet again, fuck you
+ let _ = session::clear().await;
self.selected_tx.send_replace(None);
self.emit(Event::NeedLogin { error: None });
}
async fn on_logged_in(&mut self, client: Client) -> Result<()> {
- let id: String = client.user_id().map(ToString::to_string).unwrap_or_default();
- let name: String = client.account()
+ let id: String = client.user_id().map_or_default(ToString::to_string);
+ let name: String = client
+ .account()
.get_display_name()
.await
.ok()
.flatten()
- .unwrap_or_else(|| id.trim_start_matches('@').split(':').next().unwrap_or("").to_owned());
+ .unwrap_or_else(|| {
+ id.trim_start_matches('@')
+ .split(':')
+ .next()
+ .unwrap_or("")
+ .to_owned()
+ });
self.emit(Event::LoggedIn(UserInfo { id, name }));
- let sync: Arc = Arc::new(SyncService::builder(client.clone()).build().await.context("Couldn't start sync (does homeserver support sliding sync?)")?);
+ let sync: Arc = Arc::new(
+ SyncService::builder(client.clone())
+ .build()
+ .await
+ .context("Couldn't start sync (does homeserver support sliding sync?)")?,
+ );
sync.start().await;
let spaces: Arc = Arc::new(SpaceService::new(client.clone()).await);
self.background = vec![
- Task::spawn(navigation::run(client.clone(), spaces, self.selected_tx.subscribe(), self.emit.clone(), self.via.clone())),
- Task::spawn(watch_session(client.clone(), self.store_passphrase.clone(), self.emit.clone())),
+ Task::spawn(navigation::run(
+ client.clone(),
+ spaces,
+ self.selected_tx.subscribe(),
+ self.emit.clone(),
+ self.via.clone(),
+ )),
+ Task::spawn(watch_session(
+ client.clone(),
+ self.store_passphrase.clone(),
+ self.emit.clone(),
+ )),
Task::spawn(watch_sync(sync.clone(), self.emit.clone())),
];
@@ -186,12 +229,19 @@ impl Backend {
async fn handle(&mut self, command: Command) -> Result<()> {
match command {
- Command::Login { homeserver, username, password} => self.login(&homeserver, &username, &password).await,
+ Command::Login {
+ homeserver,
+ username,
+ password,
+ } => self.login(&homeserver, &username, &password).await,
Command::Logout => self.logout().await,
Command::SelectServer(id) => {
- let parsed: Option = id.filter(|string| !string.is_empty()).map(RoomId::parse).transpose()?;
+ let parsed: Option = id
+ .filter(|string| !string.is_empty())
+ .map(RoomId::parse)
+ .transpose()?;
self.selected_tx.send_replace(parsed);
}
@@ -201,8 +251,11 @@ impl Backend {
Command::Edit { id, text } => {
let item: TimelineEventItemId = parse_item_id(&id)?;
- let content: RoomMessageEventContentWithoutRelation = RoomMessageEventContentWithoutRelation::text_markdown(text);
- self.timeline()?.edit(&item, EditedContent::RoomMessage(content)).await?;
+ let content: RoomMessageEventContentWithoutRelation =
+ RoomMessageEventContentWithoutRelation::text_markdown(text);
+ self.timeline()?
+ .edit(&item, EditedContent::RoomMessage(content))
+ .await?;
}
Command::Delete { id } => {
@@ -210,12 +263,19 @@ impl Backend {
}
Command::ToggleReaction { id, key } => {
- self.timeline()?.toggle_reaction(&parse_item_id(&id)?, &key).await?;
+ self.timeline()?
+ .toggle_reaction(&parse_item_id(&id)?, &key)
+ .await?;
}
Command::LoadOlder => {
if let Some(open) = &self.open {
- tokio::spawn(room::load_older(open.timeline.clone(), open.loading_older.clone(), open.reached_start.clone(), self.emit.clone()));
+ tokio::spawn(room::load_older(
+ open.timeline.clone(),
+ open.loading_older.clone(),
+ open.reached_start.clone(),
+ self.emit.clone(),
+ ));
}
}
@@ -225,25 +285,36 @@ impl Backend {
Ok(())
}
- fn timeline(&self) -> Result> {
- self.open.as_ref().map(|open| open.timeline.clone()).ok_or_else(|| anyhow!("No room is open."))
+ fn timeline(&self) -> Result> {
+ self.open
+ .as_ref()
+ .map(|open| open.timeline.clone())
+ .ok_or_else(|| anyhow!("No room is open."))
}
async fn select_room(&mut self, room_id: &str) -> Result<()> {
- let client: Client = self.client.clone().ok_or_else(|| anyhow!("Not signed in."))?;
+ let client: Client = self
+ .client
+ .clone()
+ .ok_or_else(|| anyhow!("Not signed in."))?;
let room_id: OwnedRoomId = RoomId::parse(room_id)?;
let room: Room = match client.get_room(&room_id) {
Some(room) if room.state() == RoomState::Joined => room,
- _ => self.join(&client, &room_id).await?
+ _ => self.join(&client, &room_id).await?,
};
if let Some(sync) = &self.sync {
- sync.room_list_service().set_room_subscriptions(&[room.room_id()]).await;
+ sync.room_list_service()
+ .set_room_subscriptions(&[room.room_id()])
+ .await;
}
self.open = None;
- let own: OwnedUserId = client.user_id().ok_or_else(|| anyhow!("No user id"))?.to_owned();
+ let own: OwnedUserId = client
+ .user_id()
+ .ok_or_else(|| anyhow!("No user id"))?
+ .to_owned();
self.open = Some(room::open(room, own, self.emit.clone()).await?);
Ok(())
@@ -252,7 +323,12 @@ impl Backend {
async fn join(&mut self, client: &Client, room_id: &OwnedRoomId) -> Result {
self.emit(Event::Toast("Joining...".into()));
- let via: Vec = self.via.lock().ok().and_then(|m| m.get(room_id).cloned()).unwrap_or_default();
+ let via: Vec = self
+ .via
+ .lock()
+ .ok()
+ .and_then(|m| m.get(room_id).cloned())
+ .unwrap_or_default();
let room_id: &RoomId = room_id;
let target: &RoomOrAliasId = <&RoomOrAliasId>::from(room_id);
@@ -270,11 +346,16 @@ impl Backend {
match reply_to.filter(|str| !str.is_empty()) {
Some(event_id) => {
- let content: RoomMessageEventContentWithoutRelation = RoomMessageEventContentWithoutRelation::text_markdown(text);
- timeline.send_reply(content, EventId::parse(event_id)?).await?;
+ let content: RoomMessageEventContentWithoutRelation =
+ RoomMessageEventContentWithoutRelation::text_markdown(text);
+ timeline
+ .send_reply(content, EventId::parse(event_id)?)
+ .await?;
}
None => {
- timeline.send(RoomMessageEventContent::text_markdown(text).into()).await?;
+ timeline
+ .send(RoomMessageEventContent::text_markdown(text).into())
+ .await?;
}
}
@@ -292,11 +373,12 @@ impl Backend {
}
fn typing(&mut self) {
- let Some(open) = &mut self.open else {
- return
- };
+ let Some(open) = &mut self.open else { return };
- if open.last_typing_sent.is_some_and(|time| time.elapsed() < Duration::from_secs(4)) {
+ if open
+ .last_typing_sent
+ .is_some_and(|time| time.elapsed() < Duration::from_secs(4))
+ {
return;
}
@@ -308,14 +390,13 @@ impl Backend {
let _ = room.typing_notice(true).await;
});
}
-
}
// UI IDs are event ids, or `txn:` for local echoes that haven't been sent yet.
fn parse_item_id(id: &str) -> Result {
Ok(match id.strip_prefix("txn:") {
Some(txn) => TimelineEventItemId::TransactionId(OwnedTransactionId::from(txn)),
- None => TimelineEventItemId::EventId(EventId::parse(id)?)
+ None => TimelineEventItemId::EventId(EventId::parse(id)?),
})
}
@@ -325,14 +406,20 @@ async fn watch_session(client: Client, passphrase: String, emit: Sink) {
loop {
match rx.recv().await {
Ok(SessionChange::TokensRefreshed) => {
- if let Err(e) = session::save(&client, &passphrase) {
+ if let Err(e) = session::save(&client, &passphrase).await {
tracing::warn!("Couldn't persist refreshed tokens: {e:#}");
}
}
Ok(SessionChange::UnknownToken(_)) => {
- session::clear();
- emit(Event::NeedLogin { error: Some("Your session expired or was signed out elsewhere. Please sign in again.".into()) });
+ // fuck you
+ let _ = session::clear().await;
+ emit(Event::NeedLogin {
+ error: Some(
+ "Your session expired or was signed out elsewhere. Please sign in again."
+ .into(),
+ ),
+ });
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
diff --git a/src/backend/navigation.rs b/src/backend/navigation.rs
index fcc72f8..e0d7d6b 100644
--- a/src/backend/navigation.rs
+++ b/src/backend/navigation.rs
@@ -1,18 +1,20 @@
-use std::{
- collections::{HashMap, HashSet},
- sync::{Arc, Mutex},
- time::Duration
-};
use eyeball_im::Vector;
use futures_util::StreamExt;
use matrix_sdk::{
+ Client, Room, RoomState,
ruma::{OwnedRoomId, OwnedServerName, room::RoomType},
- Client, Room, RoomState
};
-use matrix_sdk_ui::spaces::{room_list::SpaceRoomListPaginationState, SpaceRoom, SpaceRoomList, SpaceService};
+use matrix_sdk_ui::spaces::{
+ SpaceRoom, SpaceRoomList, SpaceService, room_list::SpaceRoomListPaginationState,
+};
+use std::{
+ collections::{HashMap, HashSet},
+ sync::{Arc, Mutex},
+ time::Duration,
+};
-use tokio::sync::{broadcast::error::RecvError, watch};
use super::Sink;
+use tokio::sync::{broadcast::error::RecvError, watch};
use crate::model::{ChannelKind, ChannelRow, Event, ServerRow};
@@ -31,7 +33,7 @@ fn is_voice(t: Option<&RoomType>) -> bool {
#[derive(Default, Clone, Copy)]
struct Counts {
unread: bool,
- mentions: u32
+ mentions: u32,
}
impl Counts {
@@ -51,7 +53,7 @@ impl Counts {
struct ServerView {
id: OwnedRoomId,
list: SpaceRoomList,
- categories: HashMap
+ categories: HashMap,
}
async fn paginate_all(list: &SpaceRoomList) {
@@ -74,34 +76,59 @@ impl ServerView {
async fn open(spaces: &SpaceService, id: OwnedRoomId) -> Self {
let list: SpaceRoomList = spaces.space_room_list(id.clone()).await;
paginate_all(&list).await;
- Self { id, list, categories: HashMap::new() }
+ Self {
+ id,
+ list,
+ categories: HashMap::new(),
+ }
}
async fn name(&self) -> String {
- self.list.space().map_or_else(|| self.id.to_string(), |space| space.display_name)
+ self.list
+ .space()
+ .map_or_else(|| self.id.to_string(), |space| space.display_name)
}
/// Flatten the space into sidebar rows:
/// Loose channels first, then one category per sub-space.
- async fn rows(&mut self, client: &Client, spaces: &SpaceService, via: &ViaMap) -> Vec {
+ async fn rows(
+ &mut self,
+ client: &Client,
+ spaces: &SpaceService,
+ via: &ViaMap,
+ ) -> Vec {
let children: Vec = self.list.rooms().await;
let mut rows: Vec = Vec::new();
- for room in children.iter().filter(|room| !is_space(room.room_type.as_ref())) {
+ for room in children
+ .iter()
+ .filter(|room| !is_space(room.room_type.as_ref()))
+ {
rows.push(channel_row(client, room, via))
}
- for space in children.iter().filter(|space| is_space(space.room_type.as_ref())) {
+ for space in children
+ .iter()
+ .filter(|space| is_space(space.room_type.as_ref()))
+ {
if !self.categories.contains_key(&space.room_id) {
let list: SpaceRoomList = spaces.space_room_list(self.id.clone()).await;
paginate_all(&list).await;
self.categories.insert(space.room_id.clone(), list);
}
- rows.push(ChannelRow::category(space.room_id.as_str(), &space.display_name));
+ rows.push(ChannelRow::category(
+ space.room_id.as_str(),
+ &space.display_name,
+ ));
if let Some(list) = self.categories.get_mut(&space.room_id) {
- for room in list.rooms().await.iter().filter(|room| !is_space(room.room_type.as_ref())) {
+ for room in list
+ .rooms()
+ .await
+ .iter()
+ .filter(|room| !is_space(room.room_type.as_ref()))
+ {
rows.push(channel_row(client, room, via))
}
}
@@ -113,7 +140,10 @@ impl ServerView {
fn channel_row(client: &Client, room: &SpaceRoom, via: &ViaMap) -> ChannelRow {
let joined: bool = room.state == Some(RoomState::Joined);
- let counts: Counts = client.get_room(&room.room_id).map(|room| Counts::of(&room)).unwrap_or_default();
+ let counts: Counts = client
+ .get_room(&room.room_id)
+ .map(|room| Counts::of(&room))
+ .unwrap_or_default();
if !joined && let Ok(mut map) = via.lock() {
map.insert(room.room_id.clone(), room.via.clone());
@@ -122,11 +152,15 @@ fn channel_row(client: &Client, room: &SpaceRoom, via: &ViaMap) -> ChannelRow {
ChannelRow {
id: room.room_id.to_string(),
name: room.display_name.clone(),
- kind: if is_voice(room.room_type.as_ref()) { ChannelKind::Voice } else { ChannelKind::Text },
+ kind: if is_voice(room.room_type.as_ref()) {
+ ChannelKind::Voice
+ } else {
+ ChannelKind::Text
+ },
unread: joined && counts.unread,
mentions: if joined { counts.mentions } else { 0 },
joined,
- is_dm: false
+ is_dm: false,
}
}
@@ -137,20 +171,29 @@ async fn home_rows(client: &Client, spaces: &SpaceService) -> (Vec,
let mut total: Counts = Counts::default();
for room in client.joined_rooms() {
- if room.is_space() { continue; }
+ if room.is_space() {
+ continue;
+ }
let counts: Counts = Counts::of(&room);
- let name: String = room.display_name().await.map_or_else(|_| room.room_id().to_string(), |name| name.to_string());
+ let name: String = room
+ .display_name()
+ .await
+ .map_or_else(|_| room.room_id().to_string(), |name| name.to_string());
let is_dm: bool = room.is_direct().await.unwrap_or(false);
let mut row = ChannelRow {
id: room.room_id().to_string(),
name,
- kind: if is_voice(room.room_type().as_ref()) { ChannelKind::Voice } else { ChannelKind::Text },
+ kind: if is_voice(room.room_type().as_ref()) {
+ ChannelKind::Voice
+ } else {
+ ChannelKind::Text
+ },
unread: counts.unread,
mentions: counts.mentions,
joined: true,
- is_dm
+ is_dm,
};
if is_dm {
@@ -158,7 +201,11 @@ async fn home_rows(client: &Client, spaces: &SpaceService) -> (Vec,
let recency: u64 = room.recency_stamp().map_or(0, u64::from);
dms.push((recency, row));
- } else if spaces.joined_parent_ids_of_child(room.room_id()).await.is_empty() {
+ } else if spaces
+ .joined_parent_ids_of_child(room.room_id())
+ .await
+ .is_empty()
+ {
total.add(counts);
row.is_dm = false;
others.push(row)
@@ -182,11 +229,15 @@ async fn server_counts(client: &Client, spaces: &SpaceService) -> HashMap = HashMap::new();
for room in client.joined_rooms() {
- if room.is_space() { continue; }
+ if room.is_space() {
+ continue;
+ }
let counts: Counts = Counts::of(&room);
- if !counts.unread && counts.mentions == 0 { continue }
+ if !counts.unread && counts.mentions == 0 {
+ continue;
+ }
let ancestors: HashSet = spaces.top_level_ancestors_of(room.room_id()).await;
@@ -198,7 +249,13 @@ async fn server_counts(client: &Client, spaces: &SpaceService) -> HashMap, mut selected: watch::Receiver>, emit: Sink, via: ViaMap) {
+pub async fn run(
+ client: Client,
+ spaces: Arc,
+ mut selected: watch::Receiver>,
+ emit: Sink,
+ via: ViaMap,
+) {
let (initial, stream) = spaces.subscribe_to_top_level_joined_spaces().await;
let mut top: Vector = initial;
@@ -213,14 +270,15 @@ pub async fn run(client: Client, spaces: Arc, mut selected: watch:
if view.as_ref().map(|view| &view.id) != wanted.as_ref() {
view = match wanted {
Some(id) => Some(ServerView::open(&spaces, id).await),
- None => None
+ None => None,
};
}
// Rail
let counts: HashMap = server_counts(&client, &spaces).await;
let (home, home_counts) = home_rows(&client, &spaces).await;
- let servers: Vec = top.iter()
+ let servers: Vec = top
+ .iter()
.map(|room| {
let c = counts.get(&room.room_id).copied().unwrap_or_default();
@@ -228,11 +286,16 @@ pub async fn run(client: Client, spaces: Arc, mut selected: watch:
id: room.room_id.to_string(),
name: room.display_name.clone(),
unread: c.unread,
- mentions: c.mentions
+ mentions: c.mentions,
}
- }).collect();
+ })
+ .collect();
- emit(Event::Servers { servers, home_unread: home_counts.unread, home_mentions: home_counts.mentions });
+ emit(Event::Servers {
+ servers,
+ home_unread: home_counts.unread,
+ home_mentions: home_counts.mentions,
+ });
// Sidebar
match view.as_mut() {
@@ -242,7 +305,10 @@ pub async fn run(client: Client, spaces: Arc, mut selected: watch:
emit(Event::Channels { title, channels });
}
- None => emit(Event::Channels { title: "Direct Messages".to_owned(), channels: home })
+ None => emit(Event::Channels {
+ title: "Direct Messages".to_owned(),
+ channels: home,
+ }),
}
// Sleeping until something changes.
@@ -271,4 +337,4 @@ pub async fn run(client: Client, spaces: Arc, mut selected: watch:
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/backend/room.rs b/src/backend/room.rs
index 526a587..a99a801 100644
--- a/src/backend/room.rs
+++ b/src/backend/room.rs
@@ -1,15 +1,18 @@
use std::{
- sync::{Arc, atomic::{AtomicBool, Ordering}},
+ sync::{
+ Arc,
+ atomic::{AtomicBool, Ordering},
+ },
time::{Duration, Instant},
};
use matrix_sdk::{
+ Room, RoomDisplayName, RoomMemberships,
ruma::{
OwnedUserId, UserId,
api::client::receipt::create_receipt::v3::ReceiptType,
events::room::{message::MessageType, power_levels::UserPowerLevel},
},
- Room, RoomMemberships, RoomDisplayName
};
use matrix_sdk_ui::timeline::{
@@ -17,14 +20,13 @@ use matrix_sdk_ui::timeline::{
TimelineItemContent, TimelineItemKind, VirtualTimelineItem,
};
-use futures_util::StreamExt;
-use eyeball_im::Vector;
-use anyhow::Result;
-use matrix_sdk::ruma::{MilliSecondsSinceUnixEpoch};
-use tokio::sync::broadcast::error::RecvError;
-use crate::model::{Event, MemberRow, MessageKind, MessageRow, ReactionRow, RoomInfo};
use super::{Sink, Task, format};
-
+use crate::model::{Event, MemberRow, MessageKind, MessageRow, ReactionRow, RoomInfo};
+use anyhow::Result;
+use eyeball_im::Vector;
+use futures_util::StreamExt;
+use matrix_sdk::ruma::MilliSecondsSinceUnixEpoch;
+use tokio::sync::broadcast::error::RecvError;
const GROUP_WINDOW_MS: u64 = 7 * 60 * 1000;
const PAGE_SIZE: u16 = 30;
@@ -37,18 +39,21 @@ pub struct OpenRoom {
pub last_typing_sent: Option,
/// Dropping `OpenRoom` will abort these.
- _tasks: Vec
+ _tasks: Vec,
}
pub async fn open(room: Room, own: OwnedUserId, emit: Sink) -> Result {
- let name: String = room.display_name().await.map_or_else(|_| room.room_id().to_string(), |name: RoomDisplayName| name.to_string());
+ let name: String = room.display_name().await.map_or_else(
+ |_| room.room_id().to_string(),
+ |name: RoomDisplayName| name.to_string(),
+ );
emit(Event::RoomOpened(RoomInfo {
id: room.room_id().to_string(),
name,
topic: room.topic().unwrap_or_default(),
is_dm: room.is_direct().await.unwrap_or(false),
- encrypted: room.encryption_state().is_encrypted()
+ encrypted: room.encryption_state().is_encrypted(),
}));
let timeline: Arc = Arc::new(room.timeline().await?);
@@ -56,16 +61,34 @@ pub async fn open(room: Room, own: OwnedUserId, emit: Sink) -> Result
let reached_start: Arc = Arc::new(AtomicBool::new(false));
let tasks: Vec = vec![
- Task::spawn(messages_task(timeline.clone(), own, emit.clone(), loading_older.clone(), reached_start.clone())),
+ Task::spawn(messages_task(
+ timeline.clone(),
+ own,
+ emit.clone(),
+ loading_older.clone(),
+ reached_start.clone(),
+ )),
Task::spawn(members_task(room.clone(), emit.clone())),
Task::spawn(typing_task(room.clone(), emit)),
];
- Ok(OpenRoom { room, timeline, loading_older, reached_start, last_typing_sent: None, _tasks: tasks })
+ Ok(OpenRoom {
+ room,
+ timeline,
+ loading_older,
+ reached_start,
+ last_typing_sent: None,
+ _tasks: tasks,
+ })
}
/// Fetch one more page of history.
-pub async fn load_older(timeline: Arc, loading: Arc, reached_start: Arc, emit: Sink) {
+pub async fn load_older(
+ timeline: Arc,
+ loading: Arc,
+ reached_start: Arc,
+ emit: Sink,
+) {
if reached_start.load(Ordering::Relaxed) || loading.swap(true, Ordering::AcqRel) {
return;
}
@@ -74,21 +97,32 @@ pub async fn load_older(timeline: Arc, loading: Arc, reach
match timeline.paginate_backwards(PAGE_SIZE).await {
Ok(true) => reached_start.store(true, Ordering::Relaxed),
Ok(false) => {}
- Err(error) => tracing::warn!("Pagination failed: {error}")
+ Err(error) => tracing::warn!("Pagination failed: {error}"),
}
loading.store(false, Ordering::Release);
emit(Event::LoadingOlder(false));
}
-async fn messages_task(timeline: Arc, own: OwnedUserId, emit: Sink, loading: Arc, reached_start: Arc) {
+async fn messages_task(
+ timeline: Arc,
+ own: OwnedUserId,
+ emit: Sink,
+ loading: Arc,
+ reached_start: Arc,
+) {
let (mut items, stream) = timeline.subscribe().await;
let mut stream = Box::pin(stream);
emit(Event::Messages(render(&items, &own)));
// Backfilling so a freshly opened room isn't nearly empty.
- tokio::spawn(load_older(timeline.clone(), loading, reached_start, emit.clone()));
+ tokio::spawn(load_older(
+ timeline.clone(),
+ loading,
+ reached_start,
+ emit.clone(),
+ ));
while let Some(differences) = stream.next().await {
for difference in differences {
@@ -113,8 +147,11 @@ async fn messages_task(timeline: Arc, own: OwnedUserId, emit: Sink, lo
fn name_of(profile: &TimelineDetails, id: &UserId) -> String {
match profile {
- TimelineDetails::Ready(profile) => profile.display_name.clone().unwrap_or_else(|| id.localpart().to_owned()),
- _ => id.localpart().to_owned()
+ TimelineDetails::Ready(profile) => profile
+ .display_name
+ .clone()
+ .unwrap_or_else(|| id.localpart().to_owned()),
+ _ => id.localpart().to_owned(),
}
}
@@ -127,14 +164,14 @@ fn body_of(kind: &MsgLikeKind) -> Option {
MessageType::Audio(content) => format!("[audio] {}", content.body),
MessageType::Location(content) => format!("[location] {}", content.body),
MessageType::Emote(content) => format!("/me {}", content.body),
- _ => message.body().to_owned()
+ _ => message.body().to_owned(),
},
MsgLikeKind::Sticker(_) => "[sticker]".to_owned(),
MsgLikeKind::Poll(_) => "[poll]".to_owned(),
MsgLikeKind::Redacted => "This message was deleted".to_owned(),
MsgLikeKind::UnableToDecrypt(_) => "Unable to decrypt this message (yet).".to_owned(),
- _ => return None
+ _ => return None,
})
}
@@ -145,7 +182,10 @@ fn render(items: &Vector>, own: &UserId) -> Vec {
for item in items {
match item.kind() {
TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(timestamp)) => {
- out.push(MessageRow::plain(MessageKind::DateDivider, format::date_label(*timestamp)));
+ out.push(MessageRow::plain(
+ MessageKind::DateDivider,
+ format::date_label(*timestamp),
+ ));
previous = None;
}
@@ -163,29 +203,38 @@ fn render(items: &Vector>, own: &UserId) -> Vec {
match event.content() {
TimelineItemContent::MsgLike(content) => {
- let Some(body) = body_of(&content.kind) else { continue };
+ let Some(body) = body_of(&content.kind) else {
+ continue;
+ };
let timestamp_ms: u64 = u64::from(timestamp.0);
let same_group: bool = previous.as_ref().is_some_and(|(id, t)| {
- id == sender_id.as_str() && timestamp_ms.saturating_sub(*t) < GROUP_WINDOW_MS
+ id == sender_id.as_str()
+ && timestamp_ms.saturating_sub(*t) < GROUP_WINDOW_MS
});
- let reply = content.in_reply_to.as_ref().map(|reply| match &reply.event {
- TimelineDetails::Ready(event) => {
- let who: String = name_of(&event.sender_profile, &event.sender);
- let what: String = match &event.content {
- TimelineItemContent::MsgLike(message) => body_of(&message.kind).unwrap_or_default(),
- _ => String::new()
- };
+ let reply = content
+ .in_reply_to
+ .as_ref()
+ .map(|reply| match &reply.event {
+ TimelineDetails::Ready(event) => {
+ let who: String = name_of(&event.sender_profile, &event.sender);
+ let what: String = match &event.content {
+ TimelineItemContent::MsgLike(message) => {
+ body_of(&message.kind).unwrap_or_default()
+ }
+ _ => String::new(),
+ };
- // TODO: Make max characters configurable.
- (who, format::truncate(&what, 80))
- }
+ // TODO: Make max characters configurable.
+ (who, format::truncate(&what, 80))
+ }
- _ => ("a message".to_owned(), String::new())
- });
+ _ => ("a message".to_owned(), String::new()),
+ });
- let reactions: Vec = content.reactions
+ let reactions: Vec = content
+ .reactions
.iter()
.map(|(key, senders)| ReactionRow {
key: key.clone(),
@@ -196,7 +245,10 @@ fn render(items: &Vector>, own: &UserId) -> Vec {
let id: String = match event.event_id() {
Some(event) => event.to_string(),
- None => event.transaction_id().map(|transaction| format!("txn:{transaction}")).unwrap_or_default()
+ None => event
+ .transaction_id()
+ .map(|transaction| format!("txn:{transaction}"))
+ .unwrap_or_default(),
};
out.push(MessageRow {
@@ -220,16 +272,23 @@ fn render(items: &Vector>, own: &UserId) -> Vec {
TimelineItemContent::MembershipChange(change) => {
let verb: &str = match change.change() {
- Some(MembershipChange::Joined | MembershipChange::InvitationAccepted) => "joined",
+ Some(
+ MembershipChange::Joined | MembershipChange::InvitationAccepted,
+ ) => "joined",
Some(MembershipChange::Left) => "left",
Some(MembershipChange::Invited) => "was invited",
Some(MembershipChange::Kicked) => "was removed",
Some(MembershipChange::Banned) => "was banned",
- _ => continue
+ _ => continue,
};
- let who: String = change.display_name().unwrap_or_else(|| change.user_id().localpart().to_owned());
- out.push(MessageRow::plain(MessageKind::Notice, format!("{who} {verb}")));
+ let who: String = change
+ .display_name()
+ .unwrap_or_else(|| change.user_id().localpart().to_owned());
+ out.push(MessageRow::plain(
+ MessageKind::Notice,
+ format!("{who} {verb}"),
+ ));
previous = None;
}
@@ -265,27 +324,50 @@ fn power_of(level: UserPowerLevel) -> i64 {
match level {
UserPowerLevel::Infinite => i64::MAX,
UserPowerLevel::Int(i) => i64::from(i),
- _ => 0
+ _ => 0,
}
}
fn group_members(members: Vec) -> Vec {
- let mut groups: [(&str, Vec); 3] = [("ADMINS", Vec::new()), ("MODERATORS", Vec::new()), ("MEMBERS", Vec::new())];
+ let mut groups: [(&str, Vec); 3] = [
+ ("ADMINS", Vec::new()),
+ ("MODERATORS", Vec::new()),
+ ("MEMBERS", Vec::new()),
+ ];
for member in members {
let level: i64 = power_of(member.power_level());
- let idx: usize = if level >= 100 { 0 } else if level >= 50 { 1 } else { 2 };
- let name: String = member.display_name().map_or_else(|| member.user_id().localpart().to_owned(), ToOwned::to_owned);
- groups[idx].1.push(MemberRow { id: member.user_id().to_string(), name, is_header: false });
+ let idx: usize = if level >= 100 {
+ 0
+ } else if level >= 50 {
+ 1
+ } else {
+ 2
+ };
+ let name: String = member.display_name().map_or_else(
+ || member.user_id().localpart().to_owned(),
+ ToOwned::to_owned,
+ );
+ groups[idx].1.push(MemberRow {
+ id: member.user_id().to_string(),
+ name,
+ is_header: false,
+ });
}
let mut out: Vec = Vec::new();
for (title, mut rows) in groups {
- if rows.is_empty() { continue; }
+ if rows.is_empty() {
+ continue;
+ }
rows.sort_by_key(|row| row.name.to_lowercase());
- out.push(MemberRow { id: String::new(), name: format!("{title} - {}", rows.len()), is_header: true });
+ out.push(MemberRow {
+ id: String::new(),
+ name: format!("{title} - {}", rows.len()),
+ is_header: true,
+ });
out.extend(rows);
}
@@ -307,7 +389,7 @@ async fn typing_task(room: Room, emit: Sink) {
for id in ids {
let name: Option = match room.get_member_no_sync(&id).await {
Ok(Some(member)) => member.display_name().map(ToOwned::to_owned),
- _ => None
+ _ => None,
};
names.push(name.unwrap_or_else(|| id.localpart().to_owned()));
@@ -317,7 +399,7 @@ async fn typing_task(room: Room, emit: Sink) {
}
Err(RecvError::Lagged(_)) => {}
- Err(RecvError::Closed) => break
+ Err(RecvError::Closed) => break,
}
}
-}
\ No newline at end of file
+}
diff --git a/src/backend/session.rs b/src/backend/session.rs
index 20b50a7..74d7533 100644
--- a/src/backend/session.rs
+++ b/src/backend/session.rs
@@ -1,10 +1,16 @@
use matrix_sdk::{Client, authentication::matrix::MatrixSession, store::RoomLoadSettings};
use anyhow::{Context, Result, bail};
+use keyring::{Entry, Error as KeyringError};
use serde::{Deserialize, Serialize};
+use crate::helpers;
+use std::sync::Mutex;
use std::{fs, path::PathBuf};
+const KEYRING_SERVICE: &str = "tofu.matrix-session";
+static STORAGE_LOCK: Mutex<()> = Mutex::new(());
+
#[derive(Serialize, Deserialize)]
pub struct SavedSession {
pub homeserver: String,
@@ -13,7 +19,7 @@ pub struct SavedSession {
}
pub fn data_dir() -> PathBuf {
- let base: PathBuf = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
+ let base: PathBuf = helpers::system::data_dir().unwrap_or_else(|| PathBuf::from("."));
// 'TOFU_DATA_DIR' so you can run several accounts side by side during development.
std::env::var_os("TOFU_DATA_DIR").map_or_else(|| base.join("tofu"), PathBuf::from)
@@ -31,58 +37,142 @@ fn random_passphrase() -> String {
hex::encode(rand::random::<[u8; 32]>())
}
-pub fn load() -> Result> {
- let path: PathBuf = session_file();
+fn credential_entry() -> Result {
+ let directory = data_dir();
+ fs::create_dir_all(&directory).context("Creating the session data directory.")?;
- if !path.exists() {
- return Ok(None);
- }
+ let directory = fs::canonicalize(directory).context("Resolving the session data directory.")?;
- let raw: String = fs::read_to_string(&path).with_context(|| format!("Reading '{}'.", path.display()))?;
- Ok(Some(serde_json::from_str(&raw).context("Parsing 'session.json'.")?))
+ let profile = directory
+ .to_str()
+ .context("The session data directory must have a UTF-8 path.")?;
+
+ Entry::new(KEYRING_SERVICE, profile).context("Opening the session keyring entry.")
}
-pub fn save(client: &Client, store_passphrase: &str) -> Result<()> {
- let Some(session) = client.matrix_auth().session() else { bail!("Client has no Matrix session to save."); };
+async fn storage_task(operation: F) -> Result
+where
+ T: Send + 'static,
+ F: FnOnce() -> Result + Send + 'static,
+{
+ tokio::task::spawn_blocking(move || {
+ let _guard = STORAGE_LOCK
+ .lock()
+ .map_err(|_| anyhow::anyhow!("Session storage lock was poisoned."))?;
+
+ operation()
+ })
+ .await
+ .context("Session storage task failed.")?
+}
+
+fn remove_legacy_session() -> Result<()> {
+ match fs::remove_file(session_file()) {
+ Ok(()) => Ok(()),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
+ Err(error) => Err(error).context("Removing the plaintext session file."),
+ }
+}
+
+pub async fn load() -> Result> {
+ storage_task(|| {
+ let entry = credential_entry()?;
+
+ match entry.get_password() {
+ Ok(raw) => {
+ let saved = serde_json::from_str(&raw).context("Parsing the keyring session.")?;
+
+ // Also, clean up a legacy file left by interrupted migration.
+ remove_legacy_session()?;
+ Ok(Some(saved))
+ }
+ Err(KeyringError::NoEntry) => {
+ let raw = match fs::read_to_string(session_file()) {
+ Ok(raw) => raw,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+ return Ok(None);
+ }
+ Err(error) => {
+ return Err(error).context("Reading the legacy session file.");
+ }
+ };
+
+ let saved: SavedSession =
+ serde_json::from_str(&raw).context("Parsing the legacy session file.")?;
+
+ entry
+ .set_password(&serde_json::to_string(&saved)?)
+ .context("Migrating the session to the keyring.")?;
+
+ // Never remove the old file before successful keyring storage.
+ remove_legacy_session()?;
+ Ok(Some(saved))
+ }
+ Err(error) => Err(error).context("Reading the session from the keyring."),
+ }
+ })
+ .await
+}
+
+//todo: use multiple credentials as to not overflow the windows keyring
+pub async fn save(client: &Client, store_passphrase: &str) -> Result<()> {
+ let Some(session) = client.matrix_auth().session() else {
+ bail!("Client has no Matrix session to save.");
+ };
let saved = SavedSession {
homeserver: client.homeserver().to_string(),
store_passphrase: store_passphrase.to_owned(),
- session
+ session,
};
- fs::create_dir_all(data_dir())?;
+ storage_task(move || {
+ credential_entry()?
+ .set_password(&serde_json::to_string(&saved)?)
+ .context("Saving the session to the keyring.")?;
- let path: PathBuf = session_file();
-
- fs::write(&path, serde_json::to_vec_pretty(&saved)?)?;
-
- #[cfg(unix)] {
- use std::os::unix::fs::PermissionsExt;
- fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
- }
-
- Ok(())
+ remove_legacy_session()
+ })
+ .await
}
-/// Forget stored account.
-/// Deletes tokens and the local store.
-pub fn clear() {
- let _ = fs::remove_file(session_file());
- let _ = fs::remove_dir_all(store_dir());
+/// Forget the stored account, including credentials and the local store.
+pub async fn clear() -> Result<()> {
+ storage_task(|| {
+ match credential_entry()?.delete_credential() {
+ Ok(()) | Err(KeyringError::NoEntry) => {}
+ Err(error) => {
+ return Err(error).context("Deleting the session from the keyring.");
+ }
+ }
+
+ remove_legacy_session()?;
+
+ match fs::remove_dir_all(store_dir()) {
+ Ok(()) => Ok(()),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
+ Err(error) => Err(error).context("Deleting the local Matrix store."),
+ }
+ })
+ .await
}
pub async fn login(server: &str, username: &str, password: &str) -> Result<(Client, String)> {
- let server: &str = server.trim();
- let server: &str = if server.is_empty() { "matrix.org" } else { server };
+ let server = server.trim();
+ let server = if server.is_empty() {
+ "matrix.org"
+ } else {
+ server
+ };
- clear(); // A new login should replace any previous login's local data.
+ // Preserves the current single-store replacement behavior.
+ clear().await?;
fs::create_dir_all(data_dir())?;
- let passphrase: String = random_passphrase();
+ let passphrase = random_passphrase();
- let client: Client = Client::builder()
+ let client = Client::builder()
.server_name_or_homeserver_url(server)
.sqlite_store(store_dir(), Some(&passphrase))
.handle_refresh_tokens()
@@ -90,14 +180,21 @@ pub async fn login(server: &str, username: &str, password: &str) -> Result<(Clie
.await
.with_context(|| format!("Couldn't reach homeserver '{server}'."))?;
- client.matrix_auth()
+ client
+ .matrix_auth()
.login_username(username.trim(), password)
.initial_device_display_name("Tofu")
.send()
.await
- .context("Login failed (check username or password; some servers only allow SS0/0IDC login)")?;
+ .context(
+ "Login failed (check username or password; some servers only allow SSO/OIDC login).",
+ )?;
- let _ = save(&client, &passphrase);
+ if let Err(error) = save(&client, &passphrase).await {
+ // Avoid leaving a newly created server session after persistence fails.
+ let _ = client.matrix_auth().logout().await;
+ return Err(error);
+ }
Ok((client, passphrase))
}
@@ -111,9 +208,10 @@ pub async fn restore(saved: &SavedSession) -> Result {
.build()
.await?;
- client.matrix_auth()
+ client
+ .matrix_auth()
.restore_session(saved.session.clone(), RoomLoadSettings::default())
.await?;
Ok(client)
-}
\ No newline at end of file
+}
diff --git a/src/bridge.rs b/src/bridge.rs
index fb81c68..fb428b3 100644
--- a/src/bridge.rs
+++ b/src/bridge.rs
@@ -30,10 +30,11 @@ pub fn toast(ui: &MainWindow, text: &str) {
fn typing_text(names: &[String]) -> String {
match names {
+ //todo: translate
[] => String::new(),
[a] => format!("{a} is typing..."),
[a, b] => format!("{a} and {b} are typing..."),
- _ => "Several people are typing...".to_owned()
+ _ => "Several people are typing...".to_owned(),
}
}
@@ -46,7 +47,7 @@ pub fn apply(ui: &MainWindow, event: Event) {
}
Event::LoginBusy(busy) => {
- ui.set_login_busy(busy)
+ ui.set_login_busy(busy);
}
Event::LoggedIn(user) => {
@@ -58,16 +59,22 @@ pub fn apply(ui: &MainWindow, event: Event) {
ui.set_screen(Screen::Main);
}
- Event::Servers { servers, home_unread, home_mentions } => {
- let items: Vec = servers.into_iter()
+ Event::Servers {
+ servers,
+ home_unread,
+ home_mentions,
+ } => {
+ let items: Vec = servers
+ .into_iter()
.map(|server| ServerItem {
color: color_for(&server.id),
initial: initial_of(&server.name).into(),
id: server.id.into(),
name: server.name.into(),
unread: server.unread,
- mentions: i32::try_from(server.mentions).unwrap_or(i32::MAX)
- }).collect();
+ mentions: i32::try_from(server.mentions).unwrap_or(i32::MAX),
+ })
+ .collect();
ui.set_servers(model_of(items));
ui.set_home_unread(home_unread);
@@ -77,13 +84,18 @@ pub fn apply(ui: &MainWindow, event: Event) {
Event::Channels { title, channels } => {
let selected: String = ui.get_selected_room_id().to_string();
- let first_openable: Option = channels.iter()
- .find(|channel| channel.kind != model::ChannelKind::Category && channel.kind != model::ChannelKind::Voice)
+ let first_openable: Option = channels
+ .iter()
+ .find(|channel| {
+ channel.kind != model::ChannelKind::Category
+ && channel.kind != model::ChannelKind::Voice
+ })
.map(|channel| channel.id.clone());
let still_here: bool = channels.iter().any(|channel| channel.id == selected);
- let items: Vec = channels.into_iter()
+ let items: Vec = channels
+ .into_iter()
.map(|channel| ChannelItem {
color: color_for(&channel.id),
initial: initial_of(&channel.name).into(),
@@ -98,27 +110,24 @@ pub fn apply(ui: &MainWindow, event: Event) {
unread: channel.unread,
mentions: i32::try_from(channel.mentions).unwrap_or(i32::MAX),
joined: channel.joined,
- is_dm: channel.is_dm
- }).collect();
+ is_dm: channel.is_dm,
+ })
+ .collect();
ui.set_sidebar_title(title.into());
ui.set_channels(model_of(items));
if !still_here {
- match first_openable {
- Some(id) => {
- ui.set_selected_room_id(id.clone().into());
- ui.invoke_select_room(id.into());
- }
-
- None => {
- ui.set_selected_room_id(SharedString::new());
- ui.set_room_open(false);
- ui.set_room_name(SharedString::new());
- ui.set_room_topic(SharedString::new());
- ui.set_messages(model_of(Vec::new()));
- ui.set_members(model_of(Vec::new()));
- }
+ if let Some(id) = first_openable {
+ ui.set_selected_room_id(id.clone().into());
+ ui.invoke_select_room(id.into());
+ } else {
+ ui.set_selected_room_id(SharedString::new());
+ ui.set_room_open(false);
+ ui.set_room_name(SharedString::new());
+ ui.set_room_topic(SharedString::new());
+ ui.set_messages(model_of(Vec::new()));
+ ui.set_members(model_of(Vec::new()));
}
}
}
@@ -145,21 +154,23 @@ pub fn apply(ui: &MainWindow, event: Event) {
}
Event::Members(rows) => {
- let items: Vec = rows.into_iter()
+ let items: Vec = rows
+ .into_iter()
.map(|member| MemberItem {
color: color_for(&member.id),
initial: initial_of(&member.name).into(),
id: member.id.into(),
name: member.name.into(),
is_header: member.is_header,
- }).collect();
+ })
+ .collect();
ui.set_members(model_of(items));
}
Event::Typing(names) => ui.set_typing_text(typing_text(&names).into()),
Event::LoadingOlder(b) => ui.set_loading_older(b),
- Event::Toast(text) => toast(ui, &text)
+ Event::Toast(text) => toast(ui, &text),
}
}
@@ -191,12 +202,15 @@ fn message_item(message: model::MessageRow) -> MessageItem {
reply_sender: reply_sender.into(),
reply_body: reply_body.into(),
reactions: model_of(
- message.reactions.into_iter()
+ message
+ .reactions
+ .into_iter()
.map(|reaction| ReactionItem {
key: reaction.key.into(),
count: i32::try_from(reaction.count).unwrap_or(i32::MAX),
mine: reaction.mine,
- }).collect(),
+ })
+ .collect(),
),
}
}
@@ -210,11 +224,14 @@ pub fn wire(ui: &MainWindow, tx: &UnboundedSender) {
let tx = tx.clone();
move |homeserver, username, password| {
- send(&tx, Command::Login {
- homeserver: homeserver.into(),
- username: username.into(),
- password: password.into(),
- });
+ send(
+ &tx,
+ Command::Login {
+ homeserver: homeserver.into(),
+ username: username.into(),
+ password: password.into(),
+ },
+ );
}
});
@@ -225,7 +242,12 @@ pub fn wire(ui: &MainWindow, tx: &UnboundedSender) {
ui.on_select_server({
let tx = tx.clone();
- move |id| send(&tx, Command::SelectServer(Some(id.to_string()).filter(|s| !s.is_empty())))
+ move |id| {
+ send(
+ &tx,
+ Command::SelectServer(Some(id.to_string()).filter(|s| !s.is_empty())),
+ );
+ }
});
ui.on_select_room({
@@ -236,16 +258,27 @@ pub fn wire(ui: &MainWindow, tx: &UnboundedSender) {
ui.on_send_message({
let tx = tx.clone();
move |reply_to, text| {
- send(&tx, Command::Send {
- reply_to: Some(reply_to.to_string()).filter(|s| !s.is_empty()),
- text: text.into(),
- });
+ send(
+ &tx,
+ Command::Send {
+ reply_to: Some(reply_to.to_string()).filter(|s| !s.is_empty()),
+ text: text.into(),
+ },
+ );
}
});
ui.on_edit_message({
let tx = tx.clone();
- move |id, text| send(&tx, Command::Edit { id: id.into(), text: text.into() })
+ move |id, text| {
+ send(
+ &tx,
+ Command::Edit {
+ id: id.into(),
+ text: text.into(),
+ },
+ );
+ }
});
ui.on_delete_message({
@@ -255,7 +288,15 @@ pub fn wire(ui: &MainWindow, tx: &UnboundedSender) {
ui.on_toggle_reaction({
let tx = tx.clone();
- move |id, key| send(&tx, Command::ToggleReaction { id: id.into(), key: key.into() })
+ move |id, key| {
+ send(
+ &tx,
+ Command::ToggleReaction {
+ id: id.into(),
+ key: key.into(),
+ },
+ );
+ }
});
ui.on_load_older({
diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs
new file mode 100644
index 0000000..ac77f63
--- /dev/null
+++ b/src/helpers/mod.rs
@@ -0,0 +1 @@
+pub mod system;
diff --git a/src/helpers/system.rs b/src/helpers/system.rs
new file mode 100644
index 0000000..f4de65f
--- /dev/null
+++ b/src/helpers/system.rs
@@ -0,0 +1,16 @@
+use std::{env, ffi::OsString, path::PathBuf};
+
+pub fn is_absolute_path(path: OsString) -> Option {
+ let path = PathBuf::from(path);
+ path.is_absolute().then_some(path)
+}
+
+pub fn home_dir() -> Option {
+ env::var("HOME").ok().map(PathBuf::from)
+}
+
+pub fn data_dir() -> Option {
+ env::var_os("XDG_DATA_HOME")
+ .and_then(is_absolute_path)
+ .or_else(|| home_dir().map(|home| home.join(".local/share")))
+}
diff --git a/src/main.rs b/src/main.rs
index e89c9c2..7c1cac3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,11 +1,12 @@
#![recursion_limit = "256"]
mod backend;
-mod model;
pub mod bridge;
+mod helpers;
+mod model;
-use std::sync::Arc;
use slint::Weak;
+use std::sync::Arc;
use tracing_subscriber::EnvFilter;
slint::include_modules!();
@@ -14,7 +15,9 @@ fn main() -> anyhow::Result<()> {
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
- .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn,Tofu=info")))
+ .with_env_filter(
+ EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn,Tofu=info")),
+ )
.init();
let runtime = tokio::runtime::Runtime::new()?;
diff --git a/src/model.rs b/src/model.rs
index ad70adc..cf844dc 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -1,4 +1,3 @@
-
/// Deterministic hue for a user/room/space id, so each gets a stable avatar colour even before avatars are loaded.
pub fn hue_for(id: &str) -> f32 {
let mut hash: u32 = 0x811c_9dc5;
@@ -8,7 +7,8 @@ pub fn hue_for(id: &str) -> f32 {
hash = hash.wrapping_mul(0x0100_0193);
}
- #[allow(clippy::cast_precision_loss)] {
+ #[allow(clippy::cast_precision_loss)]
+ {
(hash % 360) as f32
}
}
@@ -24,7 +24,7 @@ pub fn initial_of(name: &str) -> String {
#[derive(Debug, Clone)]
pub struct UserInfo {
pub id: String,
- pub name: String
+ pub name: String,
}
// A "server" equals a top-level Matrix space.
@@ -33,12 +33,14 @@ pub struct ServerRow {
pub id: String,
pub name: String,
pub unread: bool,
- pub mentions: u32
+ pub mentions: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChannelKind {
- Text, Voice, Category
+ Text,
+ Voice,
+ Category,
}
// A "channel" equals a Matrix room.
@@ -50,7 +52,7 @@ pub struct ChannelRow {
pub unread: bool,
pub mentions: u32,
pub joined: bool,
- pub is_dm: bool
+ pub is_dm: bool,
}
impl ChannelRow {
@@ -62,7 +64,7 @@ impl ChannelRow {
unread: false,
mentions: 0,
joined: true,
- is_dm: false
+ is_dm: false,
}
}
}
@@ -73,19 +75,22 @@ pub struct RoomInfo {
pub name: String,
pub topic: String,
pub is_dm: bool,
- pub encrypted: bool
+ pub encrypted: bool,
}
#[derive(Debug, Clone)]
pub struct ReactionRow {
pub key: String,
pub count: u32,
- pub mine: bool
+ pub mine: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageKind {
- Message, DateDivider, Notice, StartOfRoom
+ Message,
+ DateDivider,
+ Notice,
+ StartOfRoom,
}
#[derive(Debug, Clone)]
@@ -102,7 +107,7 @@ pub struct MessageRow {
pub own: bool,
pub pending: bool,
pub reply: Option<(String, String)>,
- pub reactions: Vec
+ pub reactions: Vec,
}
impl MessageRow {
@@ -120,7 +125,7 @@ impl MessageRow {
own: false,
pending: false,
reply: None,
- reactions: Vec::new()
+ reactions: Vec::new(),
}
}
}
@@ -129,18 +134,27 @@ impl MessageRow {
pub struct MemberRow {
pub id: String,
pub name: String,
- pub is_header: bool
+ pub is_header: bool,
}
/// Everything the backend can tell the frontend.
#[derive(Debug, Clone)]
pub enum Event {
- NeedLogin { error: Option },
+ NeedLogin {
+ error: Option,
+ },
LoginBusy(bool),
LoggedIn(UserInfo),
- Servers { servers: Vec, home_unread: bool, home_mentions: u32 },
+ Servers {
+ servers: Vec,
+ home_unread: bool,
+ home_mentions: u32,
+ },
/// Channel (or DM) list for the currently selected server / Home.
- Channels { title: String, channels: Vec },
+ Channels {
+ title: String,
+ channels: Vec,
+ },
RoomOpened(RoomInfo),
Messages(Vec),
Members(Vec),
@@ -148,24 +162,39 @@ pub enum Event {
LoadingOlder(bool),
/// Short message for the toast banner.
- Toast(String)
+ Toast(String),
}
/// Everything the frontend can ask the backend.
#[derive(Debug, Clone)]
pub enum Command {
- Login { homeserver: String, username: String, password: String },
+ Login {
+ homeserver: String,
+ username: String,
+ password: String,
+ },
Logout,
/// `None` = Home, DMs or Rooms outside any space.
SelectServer(Option),
SelectRoom(String),
- Send { reply_to: Option, text: String },
- Edit { id: String, text: String },
- Delete { id: String },
- ToggleReaction { id: String, key: String },
+ Send {
+ reply_to: Option,
+ text: String,
+ },
+ Edit {
+ id: String,
+ text: String,
+ },
+ Delete {
+ id: String,
+ },
+ ToggleReaction {
+ id: String,
+ key: String,
+ },
LoadOlder,
/// The user is typing in the composer. (Throttled by the backend).
- Typing
-}
\ No newline at end of file
+ Typing,
+}
diff --git a/ui/components/message-list.slint b/ui/components/message-list.slint
index 706cbf6..2bfbce0 100644
--- a/ui/components/message-list.slint
+++ b/ui/components/message-list.slint
@@ -108,7 +108,8 @@ component ChatMessage inherits Rectangle {
x: Theme.gutter;
width: parent.width - Theme.gutter - 16px;
height: parent.height;
- text: "↳ \{root.m.reply-sender} \{root.m.reply-body}";
+ // Reply preview: {0} is the sender, {1} the start of the replied-to text.
+ text: @tr("↳ {0} {1}", root.m.reply-sender, root.m.reply-body);
color: Theme.text-muted;
font-size: 13px;
vertical-alignment: center;
@@ -175,7 +176,7 @@ component ChatMessage inherits Rectangle {
}
if root.m.edited: Text {
- text: "(edited)";
+ text: @tr("(edited)");
color: Theme.text-faint;
font-size: 11px;
}
@@ -269,7 +270,8 @@ component RoomStart inherits Rectangle {
padding-top: 32px;
spacing: 6px;
Text {
- text: "Welcome to #\{root.room-name}!";
+ // {} is the channel name; the leading "#" is the channel prefix.
+ text: @tr("Welcome to #{}!", root.room-name);
color: Theme.text-header;
font-size: 28px;
font-weight: 700;
@@ -277,7 +279,8 @@ component RoomStart inherits Rectangle {
}
Text {
- text: "This is the start of the #\{root.room-name} channel.";
+ // {} is the channel name; the leading "#" is the channel prefix.
+ text: @tr("This is the start of the #{} channel.", root.room-name);
color: Theme.text-muted;
font-size: 15px;
wrap: word-wrap;
@@ -389,7 +392,7 @@ export component MessageList inherits Rectangle {
border-radius: 12px;
background: Theme.popup-bg;
Text {
- text: "Loading history…";
+ text: @tr("Loading history…");
color: Theme.text-muted;
font-size: 12px;
horizontal-alignment: center;
diff --git a/ui/login.slint b/ui/login.slint
index 910d10f..3e0bdfb 100644
--- a/ui/login.slint
+++ b/ui/login.slint
@@ -32,7 +32,7 @@ export component LoginScreen inherits Rectangle {
spacing: 6px;
Text {
- text: "Welcome back!";
+ text: @tr("Welcome back!");
color: Theme.text-header;
font-size: 24px;
font-weight: 700;
@@ -40,7 +40,7 @@ export component LoginScreen inherits Rectangle {
}
Text {
- text: "Sign in with your Matrix account";
+ text: @tr("Sign in with your Matrix account");
color: Theme.text-muted;
font-size: 15px;
horizontal-alignment: center;
@@ -51,14 +51,14 @@ export component LoginScreen inherits Rectangle {
}
Text {
- text: "HOMESERVER";
+ text: @tr("HOMESERVER");
color: Theme.text-muted;
font-size: 12px;
font-weight: 700;
}
TextField {
- placeholder: "matrix.org";
+ placeholder: @tr("matrix.org");
text <=> root.homeserver;
}
@@ -67,14 +67,14 @@ export component LoginScreen inherits Rectangle {
}
Text {
- text: "USERNAME";
+ text: @tr("USERNAME");
color: Theme.text-muted;
font-size: 12px;
font-weight: 700;
}
TextField {
- placeholder: "user (or @user:matrix.org)";
+ placeholder: @tr("user (or @user:matrix.org)");
text <=> root.username;
}
@@ -83,7 +83,7 @@ export component LoginScreen inherits Rectangle {
}
Text {
- text: "PASSWORD";
+ text: @tr("PASSWORD");
color: Theme.text-muted;
font-size: 12px;
font-weight: 700;
@@ -112,7 +112,7 @@ export component LoginScreen inherits Rectangle {
border-radius: 4px;
background: root.busy ? Theme.accent-hover : (login-touch.has-hover ? Theme.accent-hover : Theme.accent);
Text {
- text: root.busy ? "Signing in…" : "Log In";
+ text: root.busy ? @tr("Signing in…") : @tr("Log In");
color: white;
font-size: 16px;
font-weight: 600;
diff --git a/ui/main.slint b/ui/main.slint
index 80793bb..ca14e83 100644
--- a/ui/main.slint
+++ b/ui/main.slint
@@ -15,7 +15,7 @@ import { MemberList } from "components/member-list.slint";
export { Screen, ServerItem, ChannelItem, MessageItem, MemberItem, ChannelKind, MessageKind, ReactionItem } from "types.slint";
export component MainWindow inherits Window {
- title: "Tofu";
+ title: @tr("Tofu");
min-width: 900px;
min-height: 520px;
preferred-width: 1280px;
@@ -42,7 +42,7 @@ export component MainWindow inherits Window {
in property <[MessageItem]> messages;
in property <[MemberItem]> members;
- in property sidebar-title: "Direct Messages";
+ in property sidebar-title: @tr("Direct Messages");
in property room-name;
in property room-topic;
in property room-is-dm;
@@ -81,7 +81,7 @@ export component MainWindow inherits Window {
if root.screen == Screen.loading: Rectangle {
background: Theme.rail-bg;
Text {
- text: "Tofu";
+ text: @tr("Tofu");
color: Theme.text-muted;
font-size: 32px;
font-weight: 700;
@@ -115,12 +115,13 @@ export component MainWindow inherits Window {
root.select-server(id);
}
add-server => {
- root.not-implemented("Create / join a space");
+ root.not-implemented(@tr("Create / join a space"));
}
}
ChannelList {
- title: root.sidebar-title;
+ // Heading of the sidebar in the Home view, which lists direct messages.
+ title: root.selected-server-id == "" ? @tr("Direct Messages") : root.sidebar-title;
is-home: root.selected-server-id == "";
channels: root.channels;
selected-id: root.selected-room-id;
@@ -129,7 +130,7 @@ export component MainWindow inherits Window {
root.select-room(id);
}
open-server-menu => {
- root.not-implemented("Space settings");
+ root.not-implemented(@tr("Space settings"));
}
}
}
@@ -147,6 +148,7 @@ export component MainWindow inherits Window {
// Right: chat
VerticalLayout {
+ width: root.width - Theme.rail-width - Theme.sidebar-width;
ChatHeader {
room-name: root.room-name;
topic: root.room-topic;
@@ -154,18 +156,21 @@ export component MainWindow inherits Window {
encrypted: root.room-encrypted;
show-members <=> root.show-members;
open-threads => {
- root.not-implemented("Threads");
+ root.not-implemented(@tr("Threads"));
}
open-pins => {
- root.not-implemented("Pinned messages");
+ root.not-implemented(@tr("Pinned messages"));
}
open-search => {
- root.not-implemented("Search");
+ root.not-implemented(@tr("Search"));
}
}
- HorizontalLayout {
+ Rectangle {
+ vertical-stretch: 1;
VerticalLayout {
+ width: parent.width - (root.show-members ? Theme.member-width : 0px);
+ height: parent.height;
MessageList {
messages: root.messages;
room-name: root.room-name;
@@ -198,8 +203,8 @@ export component MainWindow inherits Window {
text-to-set: root.composer-text;
text-nonce: root.composer-text-nonce;
focus-nonce: root.composer-focus-nonce;
- placeholder: root.room-is-dm ? "Message @\{root.room-name}" : "Message #\{root.room-name}";
- banner: root.editing-id != "" ? "Editing message" : (root.reply-to-id != "" ? "Replying to \{root.reply-to-name}" : "");
+ placeholder: root.room-is-dm ? @tr("Message @{}", root.room-name) : @tr("Message #{}", root.room-name);
+ banner: root.editing-id != "" ? @tr("Editing message") : (root.reply-to-id != "" ? @tr("Replying to {}", root.reply-to-name) : "");
editing: root.editing-id != "";
typing-text: root.typing-text;
submit(text) => {
@@ -221,19 +226,20 @@ export component MainWindow inherits Window {
root.typing();
}
attach => {
- root.not-implemented("File uploads");
+ root.not-implemented(@tr("File uploads"));
}
}
}
if root.show-members: MemberList {
+ x: parent.width - self.width;
+ height: parent.height;
members: root.members;
}
}
}
}
- // Transient banner for errors and "coming soon" notices.
if root.toast != "": Rectangle {
width: min(480px, parent.width - 32px);
height: 40px;