Implement keyring #2
Generated
+139
@@ -256,6 +256,17 @@ version = "0.13.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c"
|
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]]
|
[[package]]
|
||||||
name = "arboard"
|
name = "arboard"
|
||||||
version = "3.6.1"
|
version = "3.6.1"
|
||||||
@@ -3395,6 +3406,27 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"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]]
|
[[package]]
|
||||||
name = "khronos_api"
|
name = "khronos_api"
|
||||||
version = "3.1.0"
|
version = "3.1.0"
|
||||||
@@ -4313,12 +4345,75 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
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]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -5962,6 +6057,25 @@ dependencies = [
|
|||||||
"syn 3.0.6",
|
"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]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -6829,6 +6943,7 @@ dependencies = [
|
|||||||
"eyeball-im",
|
"eyeball-im",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
|
"keyring",
|
||||||
"matrix-sdk",
|
"matrix-sdk",
|
||||||
"matrix-sdk-ui",
|
"matrix-sdk-ui",
|
||||||
"rand 0.10.3",
|
"rand 0.10.3",
|
||||||
@@ -7900,6 +8015,19 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
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]]
|
[[package]]
|
||||||
name = "windows-numerics"
|
name = "windows-numerics"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -8396,6 +8524,17 @@ dependencies = [
|
|||||||
"zvariant",
|
"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]]
|
[[package]]
|
||||||
name = "zbus_macros"
|
name = "zbus_macros"
|
||||||
version = "5.19.0"
|
version = "5.19.0"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ rand = "0.10.3"
|
|||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
|
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
|
keyring = "4.2.0"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
slint-build = "1.18.1"
|
slint-build = "1.18.1"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ use chrono::{DateTime, Datelike, Local, TimeZone};
|
|||||||
use matrix_sdk::ruma::MilliSecondsSinceUnixEpoch;
|
use matrix_sdk::ruma::MilliSecondsSinceUnixEpoch;
|
||||||
pub fn to_local(timestamp: MilliSecondsSinceUnixEpoch) -> DateTime<Local> {
|
pub fn to_local(timestamp: MilliSecondsSinceUnixEpoch) -> DateTime<Local> {
|
||||||
let ms: i64 = i64::try_from(u64::from(timestamp.0)).unwrap_or(0);
|
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"
|
/// "Today at 13:00" / "Yesterday at 13:00" / "2026-10-09 13:00"
|
||||||
|
|||||||
+140
-54
@@ -1,29 +1,37 @@
|
|||||||
mod format;
|
|
||||||
mod session;
|
|
||||||
mod room;
|
|
||||||
mod navigation;
|
|
||||||
mod credentials;
|
mod credentials;
|
||||||
|
mod format;
|
||||||
|
mod navigation;
|
||||||
|
mod room;
|
||||||
|
mod session;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use anyhow::{Context, Result, anyhow};
|
|
||||||
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use matrix_sdk::{Client, Room, RoomState, SessionChange, ruma::{
|
use matrix_sdk::{
|
||||||
EventId, OwnedRoomId, OwnedTransactionId, RoomId, RoomOrAliasId, OwnedUserId,
|
Client, OwnedServerName, Room, RoomState, SessionChange,
|
||||||
|
room::edit::EditedContent,
|
||||||
|
ruma::{
|
||||||
|
EventId, OwnedRoomId, OwnedTransactionId, OwnedUserId, RoomId, RoomOrAliasId,
|
||||||
events::room::message::{RoomMessageEventContent, RoomMessageEventContentWithoutRelation},
|
events::room::message::{RoomMessageEventContent, RoomMessageEventContentWithoutRelation},
|
||||||
}, room::edit::EditedContent, OwnedServerName};
|
},
|
||||||
|
};
|
||||||
|
|
||||||
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::{
|
use tokio::{
|
||||||
sync::{mpsc, watch},
|
sync::{mpsc, watch},
|
||||||
task::JoinHandle,
|
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.
|
/// How the backend delivers events. The UI layer provides this.
|
||||||
pub type Sink = Arc<dyn Fn(Event) + Send + Sync>;
|
pub type Sink = Arc<dyn Fn(Event) + Send + Sync>;
|
||||||
@@ -62,7 +70,7 @@ struct Backend {
|
|||||||
|
|
||||||
/// Tasks that live as long as the login does.
|
/// Tasks that live as long as the login does.
|
||||||
background: Vec<Task>,
|
background: Vec<Task>,
|
||||||
open: Option<OpenRoom>
|
open: Option<OpenRoom>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Backend {
|
impl Backend {
|
||||||
@@ -75,7 +83,7 @@ impl Backend {
|
|||||||
selected_tx: watch::channel(None).0,
|
selected_tx: watch::channel(None).0,
|
||||||
via: Arc::new(Mutex::new(HashMap::new())),
|
via: Arc::new(Mutex::new(HashMap::new())),
|
||||||
background: Vec::new(),
|
background: Vec::new(),
|
||||||
open: None
|
open: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +97,7 @@ impl Backend {
|
|||||||
while let Some(command) = rx.recv().await {
|
while let Some(command) = rx.recv().await {
|
||||||
if let Err(error) = self.handle(command).await {
|
if let Err(error) = self.handle(command).await {
|
||||||
tracing::error!("Command failed: {error:#}");
|
tracing::error!("Command failed: {error:#}");
|
||||||
self.emit(Event::Toast(format!("{error:#}")))
|
self.emit(Event::Toast(format!("{error:#}")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,10 +105,14 @@ impl Backend {
|
|||||||
// Session
|
// Session
|
||||||
|
|
||||||
async fn try_restore(&mut self) {
|
async fn try_restore(&mut self) {
|
||||||
let saved: SavedSession = match session::load() {
|
let saved: SavedSession = match session::load().await {
|
||||||
Ok(Some(session)) => session,
|
Ok(Some(session)) => session,
|
||||||
Ok(None) => return self.emit(Event::NeedLogin { error: None }),
|
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 {
|
match session::restore(&saved).await {
|
||||||
@@ -108,13 +120,17 @@ impl Backend {
|
|||||||
self.store_passphrase = saved.store_passphrase;
|
self.store_passphrase = saved.store_passphrase;
|
||||||
|
|
||||||
if let Err(error) = self.on_logged_in(client).await {
|
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) => {
|
Err(error) => {
|
||||||
tracing::warn!("Restoration of session failed: {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()),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,12 +142,15 @@ impl Backend {
|
|||||||
let (client, passphrase) = session::login(homeserver, username, password).await?;
|
let (client, passphrase) = session::login(homeserver, username, password).await?;
|
||||||
self.store_passphrase = passphrase;
|
self.store_passphrase = passphrase;
|
||||||
self.on_logged_in(client).await
|
self.on_logged_in(client).await
|
||||||
}.await;
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
self.emit(Event::LoginBusy(false));
|
self.emit(Event::LoginBusy(false));
|
||||||
|
|
||||||
if let Err(error) = result {
|
if let Err(error) = result {
|
||||||
self.emit(Event::NeedLogin { error: Some(format!("{error:#}")) })
|
self.emit(Event::NeedLogin {
|
||||||
|
error: Some(format!("{error:#}")),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,31 +167,54 @@ impl Backend {
|
|||||||
let _ = client.matrix_auth().logout().await;
|
let _ = client.matrix_auth().logout().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
session::clear();
|
// yet again, fuck you
|
||||||
|
let _ = session::clear().await;
|
||||||
|
|
||||||
self.selected_tx.send_replace(None);
|
self.selected_tx.send_replace(None);
|
||||||
self.emit(Event::NeedLogin { error: None });
|
self.emit(Event::NeedLogin { error: None });
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_logged_in(&mut self, client: Client) -> Result<()> {
|
async fn on_logged_in(&mut self, client: Client) -> Result<()> {
|
||||||
let id: String = client.user_id().map(ToString::to_string).unwrap_or_default();
|
let id: String = client.user_id().map_or_default(ToString::to_string);
|
||||||
let name: String = client.account()
|
let name: String = client
|
||||||
|
.account()
|
||||||
.get_display_name()
|
.get_display_name()
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.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 }));
|
self.emit(Event::LoggedIn(UserInfo { id, name }));
|
||||||
|
|
||||||
let sync: Arc<SyncService> = Arc::new(SyncService::builder(client.clone()).build().await.context("Couldn't start sync (does homeserver support sliding sync?)")?);
|
let sync: Arc<SyncService> = Arc::new(
|
||||||
|
SyncService::builder(client.clone())
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
.context("Couldn't start sync (does homeserver support sliding sync?)")?,
|
||||||
|
);
|
||||||
sync.start().await;
|
sync.start().await;
|
||||||
|
|
||||||
let spaces: Arc<SpaceService> = Arc::new(SpaceService::new(client.clone()).await);
|
let spaces: Arc<SpaceService> = Arc::new(SpaceService::new(client.clone()).await);
|
||||||
|
|
||||||
self.background = vec![
|
self.background = vec![
|
||||||
Task::spawn(navigation::run(client.clone(), spaces, self.selected_tx.subscribe(), self.emit.clone(), self.via.clone())),
|
Task::spawn(navigation::run(
|
||||||
Task::spawn(watch_session(client.clone(), self.store_passphrase.clone(), self.emit.clone())),
|
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())),
|
Task::spawn(watch_sync(sync.clone(), self.emit.clone())),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -187,12 +229,19 @@ impl Backend {
|
|||||||
|
|
||||||
async fn handle(&mut self, command: Command) -> Result<()> {
|
async fn handle(&mut self, command: Command) -> Result<()> {
|
||||||
match command {
|
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::Logout => self.logout().await,
|
||||||
|
|
||||||
Command::SelectServer(id) => {
|
Command::SelectServer(id) => {
|
||||||
let parsed: Option<OwnedRoomId> = id.filter(|string| !string.is_empty()).map(RoomId::parse).transpose()?;
|
let parsed: Option<OwnedRoomId> = id
|
||||||
|
.filter(|string| !string.is_empty())
|
||||||
|
.map(RoomId::parse)
|
||||||
|
.transpose()?;
|
||||||
self.selected_tx.send_replace(parsed);
|
self.selected_tx.send_replace(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,8 +251,11 @@ impl Backend {
|
|||||||
|
|
||||||
Command::Edit { id, text } => {
|
Command::Edit { id, text } => {
|
||||||
let item: TimelineEventItemId = parse_item_id(&id)?;
|
let item: TimelineEventItemId = parse_item_id(&id)?;
|
||||||
let content: RoomMessageEventContentWithoutRelation = RoomMessageEventContentWithoutRelation::text_markdown(text);
|
let content: RoomMessageEventContentWithoutRelation =
|
||||||
self.timeline()?.edit(&item, EditedContent::RoomMessage(content)).await?;
|
RoomMessageEventContentWithoutRelation::text_markdown(text);
|
||||||
|
self.timeline()?
|
||||||
|
.edit(&item, EditedContent::RoomMessage(content))
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Command::Delete { id } => {
|
Command::Delete { id } => {
|
||||||
@@ -211,12 +263,19 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Command::ToggleReaction { id, key } => {
|
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 => {
|
Command::LoadOlder => {
|
||||||
if let Some(open) = &self.open {
|
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(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,25 +285,36 @@ impl Backend {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn timeline(&self) -> Result<Arc<matrix_sdk_ui::Timeline>> {
|
fn timeline(&self) -> Result<Arc<Timeline>> {
|
||||||
self.open.as_ref().map(|open| open.timeline.clone()).ok_or_else(|| anyhow!("No room is open."))
|
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<()> {
|
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_id: OwnedRoomId = RoomId::parse(room_id)?;
|
||||||
|
|
||||||
let room: Room = match client.get_room(&room_id) {
|
let room: Room = match client.get_room(&room_id) {
|
||||||
Some(room) if room.state() == RoomState::Joined => room,
|
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 {
|
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;
|
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?);
|
self.open = Some(room::open(room, own, self.emit.clone()).await?);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -253,7 +323,12 @@ impl Backend {
|
|||||||
async fn join(&mut self, client: &Client, room_id: &OwnedRoomId) -> Result<Room> {
|
async fn join(&mut self, client: &Client, room_id: &OwnedRoomId) -> Result<Room> {
|
||||||
self.emit(Event::Toast("Joining...".into()));
|
self.emit(Event::Toast("Joining...".into()));
|
||||||
|
|
||||||
let via: Vec<OwnedServerName> = self.via.lock().ok().and_then(|m| m.get(room_id).cloned()).unwrap_or_default();
|
let via: Vec<OwnedServerName> = self
|
||||||
|
.via
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.get(room_id).cloned())
|
||||||
|
.unwrap_or_default();
|
||||||
let room_id: &RoomId = room_id;
|
let room_id: &RoomId = room_id;
|
||||||
let target: &RoomOrAliasId = <&RoomOrAliasId>::from(room_id);
|
let target: &RoomOrAliasId = <&RoomOrAliasId>::from(room_id);
|
||||||
|
|
||||||
@@ -271,11 +346,16 @@ impl Backend {
|
|||||||
|
|
||||||
match reply_to.filter(|str| !str.is_empty()) {
|
match reply_to.filter(|str| !str.is_empty()) {
|
||||||
Some(event_id) => {
|
Some(event_id) => {
|
||||||
let content: RoomMessageEventContentWithoutRelation = RoomMessageEventContentWithoutRelation::text_markdown(text);
|
let content: RoomMessageEventContentWithoutRelation =
|
||||||
timeline.send_reply(content, EventId::parse(event_id)?).await?;
|
RoomMessageEventContentWithoutRelation::text_markdown(text);
|
||||||
|
timeline
|
||||||
|
.send_reply(content, EventId::parse(event_id)?)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
timeline.send(RoomMessageEventContent::text_markdown(text).into()).await?;
|
timeline
|
||||||
|
.send(RoomMessageEventContent::text_markdown(text).into())
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,11 +373,12 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn typing(&mut self) {
|
fn typing(&mut self) {
|
||||||
let Some(open) = &mut self.open else {
|
let Some(open) = &mut self.open else { return };
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,14 +390,13 @@ impl Backend {
|
|||||||
let _ = room.typing_notice(true).await;
|
let _ = room.typing_notice(true).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UI IDs are event ids, or `txn:<id>` for local echoes that haven't been sent yet.
|
// UI IDs are event ids, or `txn:<id>` for local echoes that haven't been sent yet.
|
||||||
fn parse_item_id(id: &str) -> Result<TimelineEventItemId> {
|
fn parse_item_id(id: &str) -> Result<TimelineEventItemId> {
|
||||||
Ok(match id.strip_prefix("txn:") {
|
Ok(match id.strip_prefix("txn:") {
|
||||||
Some(txn) => TimelineEventItemId::TransactionId(OwnedTransactionId::from(txn)),
|
Some(txn) => TimelineEventItemId::TransactionId(OwnedTransactionId::from(txn)),
|
||||||
None => TimelineEventItemId::EventId(EventId::parse(id)?)
|
None => TimelineEventItemId::EventId(EventId::parse(id)?),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,14 +406,20 @@ async fn watch_session(client: Client, passphrase: String, emit: Sink) {
|
|||||||
loop {
|
loop {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
Ok(SessionChange::TokensRefreshed) => {
|
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:#}");
|
tracing::warn!("Couldn't persist refreshed tokens: {e:#}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(SessionChange::UnknownToken(_)) => {
|
Ok(SessionChange::UnknownToken(_)) => {
|
||||||
session::clear();
|
// fuck you
|
||||||
emit(Event::NeedLogin { error: Some("Your session expired or was signed out elsewhere. Please sign in again.".into()) });
|
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(_)) => {}
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
|
||||||
|
|||||||
+100
-34
@@ -1,18 +1,20 @@
|
|||||||
use std::{
|
|
||||||
collections::{HashMap, HashSet},
|
|
||||||
sync::{Arc, Mutex},
|
|
||||||
time::Duration
|
|
||||||
};
|
|
||||||
use eyeball_im::Vector;
|
use eyeball_im::Vector;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
|
Client, Room, RoomState,
|
||||||
ruma::{OwnedRoomId, OwnedServerName, room::RoomType},
|
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 super::Sink;
|
||||||
|
use tokio::sync::{broadcast::error::RecvError, watch};
|
||||||
|
|
||||||
use crate::model::{ChannelKind, ChannelRow, Event, ServerRow};
|
use crate::model::{ChannelKind, ChannelRow, Event, ServerRow};
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ fn is_voice(t: Option<&RoomType>) -> bool {
|
|||||||
#[derive(Default, Clone, Copy)]
|
#[derive(Default, Clone, Copy)]
|
||||||
struct Counts {
|
struct Counts {
|
||||||
unread: bool,
|
unread: bool,
|
||||||
mentions: u32
|
mentions: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Counts {
|
impl Counts {
|
||||||
@@ -51,7 +53,7 @@ impl Counts {
|
|||||||
struct ServerView {
|
struct ServerView {
|
||||||
id: OwnedRoomId,
|
id: OwnedRoomId,
|
||||||
list: SpaceRoomList,
|
list: SpaceRoomList,
|
||||||
categories: HashMap<OwnedRoomId, SpaceRoomList>
|
categories: HashMap<OwnedRoomId, SpaceRoomList>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn paginate_all(list: &SpaceRoomList) {
|
async fn paginate_all(list: &SpaceRoomList) {
|
||||||
@@ -74,34 +76,59 @@ impl ServerView {
|
|||||||
async fn open(spaces: &SpaceService, id: OwnedRoomId) -> Self {
|
async fn open(spaces: &SpaceService, id: OwnedRoomId) -> Self {
|
||||||
let list: SpaceRoomList = spaces.space_room_list(id.clone()).await;
|
let list: SpaceRoomList = spaces.space_room_list(id.clone()).await;
|
||||||
paginate_all(&list).await;
|
paginate_all(&list).await;
|
||||||
Self { id, list, categories: HashMap::new() }
|
Self {
|
||||||
|
id,
|
||||||
|
list,
|
||||||
|
categories: HashMap::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn name(&self) -> String {
|
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:<br>
|
/// Flatten the space into sidebar rows:<br>
|
||||||
/// Loose channels first, then one category per sub-space.
|
/// Loose channels first, then one category per sub-space.
|
||||||
async fn rows(&mut self, client: &Client, spaces: &SpaceService, via: &ViaMap) -> Vec<ChannelRow> {
|
async fn rows(
|
||||||
|
&mut self,
|
||||||
|
client: &Client,
|
||||||
|
spaces: &SpaceService,
|
||||||
|
via: &ViaMap,
|
||||||
|
) -> Vec<ChannelRow> {
|
||||||
let children: Vec<SpaceRoom> = self.list.rooms().await;
|
let children: Vec<SpaceRoom> = self.list.rooms().await;
|
||||||
let mut rows: Vec<ChannelRow> = Vec::new();
|
let mut rows: Vec<ChannelRow> = 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))
|
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) {
|
if !self.categories.contains_key(&space.room_id) {
|
||||||
let list: SpaceRoomList = spaces.space_room_list(self.id.clone()).await;
|
let list: SpaceRoomList = spaces.space_room_list(self.id.clone()).await;
|
||||||
paginate_all(&list).await;
|
paginate_all(&list).await;
|
||||||
self.categories.insert(space.room_id.clone(), list);
|
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) {
|
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))
|
rows.push(channel_row(client, room, via))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,7 +140,10 @@ impl ServerView {
|
|||||||
|
|
||||||
fn channel_row(client: &Client, room: &SpaceRoom, via: &ViaMap) -> ChannelRow {
|
fn channel_row(client: &Client, room: &SpaceRoom, via: &ViaMap) -> ChannelRow {
|
||||||
let joined: bool = room.state == Some(RoomState::Joined);
|
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() {
|
if !joined && let Ok(mut map) = via.lock() {
|
||||||
map.insert(room.room_id.clone(), room.via.clone());
|
map.insert(room.room_id.clone(), room.via.clone());
|
||||||
@@ -122,11 +152,15 @@ fn channel_row(client: &Client, room: &SpaceRoom, via: &ViaMap) -> ChannelRow {
|
|||||||
ChannelRow {
|
ChannelRow {
|
||||||
id: room.room_id.to_string(),
|
id: room.room_id.to_string(),
|
||||||
name: room.display_name.clone(),
|
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,
|
unread: joined && counts.unread,
|
||||||
mentions: if joined { counts.mentions } else { 0 },
|
mentions: if joined { counts.mentions } else { 0 },
|
||||||
joined,
|
joined,
|
||||||
is_dm: false
|
is_dm: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,20 +171,29 @@ async fn home_rows(client: &Client, spaces: &SpaceService) -> (Vec<ChannelRow>,
|
|||||||
let mut total: Counts = Counts::default();
|
let mut total: Counts = Counts::default();
|
||||||
|
|
||||||
for room in client.joined_rooms() {
|
for room in client.joined_rooms() {
|
||||||
if room.is_space() { continue; }
|
if room.is_space() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let counts: Counts = Counts::of(&room);
|
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 is_dm: bool = room.is_direct().await.unwrap_or(false);
|
||||||
|
|
||||||
let mut row = ChannelRow {
|
let mut row = ChannelRow {
|
||||||
id: room.room_id().to_string(),
|
id: room.room_id().to_string(),
|
||||||
name,
|
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,
|
unread: counts.unread,
|
||||||
mentions: counts.mentions,
|
mentions: counts.mentions,
|
||||||
joined: true,
|
joined: true,
|
||||||
is_dm
|
is_dm,
|
||||||
};
|
};
|
||||||
|
|
||||||
if is_dm {
|
if is_dm {
|
||||||
@@ -158,7 +201,11 @@ async fn home_rows(client: &Client, spaces: &SpaceService) -> (Vec<ChannelRow>,
|
|||||||
|
|
||||||
let recency: u64 = room.recency_stamp().map_or(0, u64::from);
|
let recency: u64 = room.recency_stamp().map_or(0, u64::from);
|
||||||
dms.push((recency, row));
|
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);
|
total.add(counts);
|
||||||
row.is_dm = false;
|
row.is_dm = false;
|
||||||
others.push(row)
|
others.push(row)
|
||||||
@@ -182,11 +229,15 @@ async fn server_counts(client: &Client, spaces: &SpaceService) -> HashMap<OwnedR
|
|||||||
let mut map: HashMap<OwnedRoomId, Counts> = HashMap::new();
|
let mut map: HashMap<OwnedRoomId, Counts> = HashMap::new();
|
||||||
|
|
||||||
for room in client.joined_rooms() {
|
for room in client.joined_rooms() {
|
||||||
if room.is_space() { continue; }
|
if room.is_space() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let counts: Counts = Counts::of(&room);
|
let counts: Counts = Counts::of(&room);
|
||||||
|
|
||||||
if !counts.unread && counts.mentions == 0 { continue }
|
if !counts.unread && counts.mentions == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let ancestors: HashSet<OwnedRoomId> = spaces.top_level_ancestors_of(room.room_id()).await;
|
let ancestors: HashSet<OwnedRoomId> = spaces.top_level_ancestors_of(room.room_id()).await;
|
||||||
|
|
||||||
@@ -198,7 +249,13 @@ async fn server_counts(client: &Client, spaces: &SpaceService) -> HashMap<OwnedR
|
|||||||
map
|
map
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(client: Client, spaces: Arc<SpaceService>, mut selected: watch::Receiver<Option<OwnedRoomId>>, emit: Sink, via: ViaMap) {
|
pub async fn run(
|
||||||
|
client: Client,
|
||||||
|
spaces: Arc<SpaceService>,
|
||||||
|
mut selected: watch::Receiver<Option<OwnedRoomId>>,
|
||||||
|
emit: Sink,
|
||||||
|
via: ViaMap,
|
||||||
|
) {
|
||||||
let (initial, stream) = spaces.subscribe_to_top_level_joined_spaces().await;
|
let (initial, stream) = spaces.subscribe_to_top_level_joined_spaces().await;
|
||||||
|
|
||||||
let mut top: Vector<SpaceRoom> = initial;
|
let mut top: Vector<SpaceRoom> = initial;
|
||||||
@@ -213,14 +270,15 @@ pub async fn run(client: Client, spaces: Arc<SpaceService>, mut selected: watch:
|
|||||||
if view.as_ref().map(|view| &view.id) != wanted.as_ref() {
|
if view.as_ref().map(|view| &view.id) != wanted.as_ref() {
|
||||||
view = match wanted {
|
view = match wanted {
|
||||||
Some(id) => Some(ServerView::open(&spaces, id).await),
|
Some(id) => Some(ServerView::open(&spaces, id).await),
|
||||||
None => None
|
None => None,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rail
|
// Rail
|
||||||
let counts: HashMap<OwnedRoomId, Counts> = server_counts(&client, &spaces).await;
|
let counts: HashMap<OwnedRoomId, Counts> = server_counts(&client, &spaces).await;
|
||||||
let (home, home_counts) = home_rows(&client, &spaces).await;
|
let (home, home_counts) = home_rows(&client, &spaces).await;
|
||||||
let servers: Vec<ServerRow> = top.iter()
|
let servers: Vec<ServerRow> = top
|
||||||
|
.iter()
|
||||||
.map(|room| {
|
.map(|room| {
|
||||||
let c = counts.get(&room.room_id).copied().unwrap_or_default();
|
let c = counts.get(&room.room_id).copied().unwrap_or_default();
|
||||||
|
|
||||||
@@ -228,11 +286,16 @@ pub async fn run(client: Client, spaces: Arc<SpaceService>, mut selected: watch:
|
|||||||
id: room.room_id.to_string(),
|
id: room.room_id.to_string(),
|
||||||
name: room.display_name.clone(),
|
name: room.display_name.clone(),
|
||||||
unread: c.unread,
|
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
|
// Sidebar
|
||||||
match view.as_mut() {
|
match view.as_mut() {
|
||||||
@@ -242,7 +305,10 @@ pub async fn run(client: Client, spaces: Arc<SpaceService>, mut selected: watch:
|
|||||||
emit(Event::Channels { title, channels });
|
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.
|
// Sleeping until something changes.
|
||||||
|
|||||||
+126
-44
@@ -1,15 +1,18 @@
|
|||||||
use std::{
|
use std::{
|
||||||
sync::{Arc, atomic::{AtomicBool, Ordering}},
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
|
Room, RoomDisplayName, RoomMemberships,
|
||||||
ruma::{
|
ruma::{
|
||||||
OwnedUserId, UserId,
|
OwnedUserId, UserId,
|
||||||
api::client::receipt::create_receipt::v3::ReceiptType,
|
api::client::receipt::create_receipt::v3::ReceiptType,
|
||||||
events::room::{message::MessageType, power_levels::UserPowerLevel},
|
events::room::{message::MessageType, power_levels::UserPowerLevel},
|
||||||
},
|
},
|
||||||
Room, RoomMemberships, RoomDisplayName
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use matrix_sdk_ui::timeline::{
|
use matrix_sdk_ui::timeline::{
|
||||||
@@ -17,14 +20,13 @@ use matrix_sdk_ui::timeline::{
|
|||||||
TimelineItemContent, TimelineItemKind, VirtualTimelineItem,
|
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 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 GROUP_WINDOW_MS: u64 = 7 * 60 * 1000;
|
||||||
const PAGE_SIZE: u16 = 30;
|
const PAGE_SIZE: u16 = 30;
|
||||||
@@ -37,18 +39,21 @@ pub struct OpenRoom {
|
|||||||
pub last_typing_sent: Option<Instant>,
|
pub last_typing_sent: Option<Instant>,
|
||||||
|
|
||||||
/// Dropping `OpenRoom` will abort these.
|
/// Dropping `OpenRoom` will abort these.
|
||||||
_tasks: Vec<Task>
|
_tasks: Vec<Task>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn open(room: Room, own: OwnedUserId, emit: Sink) -> Result<OpenRoom> {
|
pub async fn open(room: Room, own: OwnedUserId, emit: Sink) -> Result<OpenRoom> {
|
||||||
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 {
|
emit(Event::RoomOpened(RoomInfo {
|
||||||
id: room.room_id().to_string(),
|
id: room.room_id().to_string(),
|
||||||
name,
|
name,
|
||||||
topic: room.topic().unwrap_or_default(),
|
topic: room.topic().unwrap_or_default(),
|
||||||
is_dm: room.is_direct().await.unwrap_or(false),
|
is_dm: room.is_direct().await.unwrap_or(false),
|
||||||
encrypted: room.encryption_state().is_encrypted()
|
encrypted: room.encryption_state().is_encrypted(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let timeline: Arc<Timeline> = Arc::new(room.timeline().await?);
|
let timeline: Arc<Timeline> = Arc::new(room.timeline().await?);
|
||||||
@@ -56,16 +61,34 @@ pub async fn open(room: Room, own: OwnedUserId, emit: Sink) -> Result<OpenRoom>
|
|||||||
let reached_start: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
|
let reached_start: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
let tasks: Vec<Task> = vec![
|
let tasks: Vec<Task> = 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(members_task(room.clone(), emit.clone())),
|
||||||
Task::spawn(typing_task(room.clone(), emit)),
|
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.
|
/// Fetch one more page of history.
|
||||||
pub async fn load_older(timeline: Arc<Timeline>, loading: Arc<AtomicBool>, reached_start: Arc<AtomicBool>, emit: Sink) {
|
pub async fn load_older(
|
||||||
|
timeline: Arc<Timeline>,
|
||||||
|
loading: Arc<AtomicBool>,
|
||||||
|
reached_start: Arc<AtomicBool>,
|
||||||
|
emit: Sink,
|
||||||
|
) {
|
||||||
if reached_start.load(Ordering::Relaxed) || loading.swap(true, Ordering::AcqRel) {
|
if reached_start.load(Ordering::Relaxed) || loading.swap(true, Ordering::AcqRel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -74,21 +97,32 @@ pub async fn load_older(timeline: Arc<Timeline>, loading: Arc<AtomicBool>, reach
|
|||||||
match timeline.paginate_backwards(PAGE_SIZE).await {
|
match timeline.paginate_backwards(PAGE_SIZE).await {
|
||||||
Ok(true) => reached_start.store(true, Ordering::Relaxed),
|
Ok(true) => reached_start.store(true, Ordering::Relaxed),
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
Err(error) => tracing::warn!("Pagination failed: {error}")
|
Err(error) => tracing::warn!("Pagination failed: {error}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.store(false, Ordering::Release);
|
loading.store(false, Ordering::Release);
|
||||||
emit(Event::LoadingOlder(false));
|
emit(Event::LoadingOlder(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn messages_task(timeline: Arc<Timeline>, own: OwnedUserId, emit: Sink, loading: Arc<AtomicBool>, reached_start: Arc<AtomicBool>) {
|
async fn messages_task(
|
||||||
|
timeline: Arc<Timeline>,
|
||||||
|
own: OwnedUserId,
|
||||||
|
emit: Sink,
|
||||||
|
loading: Arc<AtomicBool>,
|
||||||
|
reached_start: Arc<AtomicBool>,
|
||||||
|
) {
|
||||||
let (mut items, stream) = timeline.subscribe().await;
|
let (mut items, stream) = timeline.subscribe().await;
|
||||||
let mut stream = Box::pin(stream);
|
let mut stream = Box::pin(stream);
|
||||||
|
|
||||||
emit(Event::Messages(render(&items, &own)));
|
emit(Event::Messages(render(&items, &own)));
|
||||||
|
|
||||||
// Backfilling so a freshly opened room isn't nearly empty.
|
// 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 {
|
while let Some(differences) = stream.next().await {
|
||||||
for difference in differences {
|
for difference in differences {
|
||||||
@@ -113,8 +147,11 @@ async fn messages_task(timeline: Arc<Timeline>, own: OwnedUserId, emit: Sink, lo
|
|||||||
|
|
||||||
fn name_of(profile: &TimelineDetails<Profile>, id: &UserId) -> String {
|
fn name_of(profile: &TimelineDetails<Profile>, id: &UserId) -> String {
|
||||||
match profile {
|
match profile {
|
||||||
TimelineDetails::Ready(profile) => profile.display_name.clone().unwrap_or_else(|| id.localpart().to_owned()),
|
TimelineDetails::Ready(profile) => profile
|
||||||
_ => id.localpart().to_owned()
|
.display_name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| id.localpart().to_owned()),
|
||||||
|
_ => id.localpart().to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,14 +164,14 @@ fn body_of(kind: &MsgLikeKind) -> Option<String> {
|
|||||||
MessageType::Audio(content) => format!("[audio] {}", content.body),
|
MessageType::Audio(content) => format!("[audio] {}", content.body),
|
||||||
MessageType::Location(content) => format!("[location] {}", content.body),
|
MessageType::Location(content) => format!("[location] {}", content.body),
|
||||||
MessageType::Emote(content) => format!("/me {}", content.body),
|
MessageType::Emote(content) => format!("/me {}", content.body),
|
||||||
_ => message.body().to_owned()
|
_ => message.body().to_owned(),
|
||||||
},
|
},
|
||||||
|
|
||||||
MsgLikeKind::Sticker(_) => "[sticker]".to_owned(),
|
MsgLikeKind::Sticker(_) => "[sticker]".to_owned(),
|
||||||
MsgLikeKind::Poll(_) => "[poll]".to_owned(),
|
MsgLikeKind::Poll(_) => "[poll]".to_owned(),
|
||||||
MsgLikeKind::Redacted => "This message was deleted".to_owned(),
|
MsgLikeKind::Redacted => "This message was deleted".to_owned(),
|
||||||
MsgLikeKind::UnableToDecrypt(_) => "Unable to decrypt this message (yet).".to_owned(),
|
MsgLikeKind::UnableToDecrypt(_) => "Unable to decrypt this message (yet).".to_owned(),
|
||||||
_ => return None
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +182,10 @@ fn render(items: &Vector<Arc<TimelineItem>>, own: &UserId) -> Vec<MessageRow> {
|
|||||||
for item in items {
|
for item in items {
|
||||||
match item.kind() {
|
match item.kind() {
|
||||||
TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(timestamp)) => {
|
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;
|
previous = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,29 +203,38 @@ fn render(items: &Vector<Arc<TimelineItem>>, own: &UserId) -> Vec<MessageRow> {
|
|||||||
|
|
||||||
match event.content() {
|
match event.content() {
|
||||||
TimelineItemContent::MsgLike(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 timestamp_ms: u64 = u64::from(timestamp.0);
|
||||||
let same_group: bool = previous.as_ref().is_some_and(|(id, t)| {
|
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 {
|
let reply = content
|
||||||
|
.in_reply_to
|
||||||
|
.as_ref()
|
||||||
|
.map(|reply| match &reply.event {
|
||||||
TimelineDetails::Ready(event) => {
|
TimelineDetails::Ready(event) => {
|
||||||
let who: String = name_of(&event.sender_profile, &event.sender);
|
let who: String = name_of(&event.sender_profile, &event.sender);
|
||||||
let what: String = match &event.content {
|
let what: String = match &event.content {
|
||||||
TimelineItemContent::MsgLike(message) => body_of(&message.kind).unwrap_or_default(),
|
TimelineItemContent::MsgLike(message) => {
|
||||||
_ => String::new()
|
body_of(&message.kind).unwrap_or_default()
|
||||||
|
}
|
||||||
|
_ => String::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: Make max characters configurable.
|
// TODO: Make max characters configurable.
|
||||||
(who, format::truncate(&what, 80))
|
(who, format::truncate(&what, 80))
|
||||||
}
|
}
|
||||||
|
|
||||||
_ => ("a message".to_owned(), String::new())
|
_ => ("a message".to_owned(), String::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
let reactions: Vec<ReactionRow> = content.reactions
|
let reactions: Vec<ReactionRow> = content
|
||||||
|
.reactions
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(key, senders)| ReactionRow {
|
.map(|(key, senders)| ReactionRow {
|
||||||
key: key.clone(),
|
key: key.clone(),
|
||||||
@@ -196,7 +245,10 @@ fn render(items: &Vector<Arc<TimelineItem>>, own: &UserId) -> Vec<MessageRow> {
|
|||||||
|
|
||||||
let id: String = match event.event_id() {
|
let id: String = match event.event_id() {
|
||||||
Some(event) => event.to_string(),
|
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 {
|
out.push(MessageRow {
|
||||||
@@ -220,16 +272,23 @@ fn render(items: &Vector<Arc<TimelineItem>>, own: &UserId) -> Vec<MessageRow> {
|
|||||||
|
|
||||||
TimelineItemContent::MembershipChange(change) => {
|
TimelineItemContent::MembershipChange(change) => {
|
||||||
let verb: &str = match change.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::Left) => "left",
|
||||||
Some(MembershipChange::Invited) => "was invited",
|
Some(MembershipChange::Invited) => "was invited",
|
||||||
Some(MembershipChange::Kicked) => "was removed",
|
Some(MembershipChange::Kicked) => "was removed",
|
||||||
Some(MembershipChange::Banned) => "was banned",
|
Some(MembershipChange::Banned) => "was banned",
|
||||||
_ => continue
|
_ => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
let who: String = change.display_name().unwrap_or_else(|| change.user_id().localpart().to_owned());
|
let who: String = change
|
||||||
out.push(MessageRow::plain(MessageKind::Notice, format!("{who} {verb}")));
|
.display_name()
|
||||||
|
.unwrap_or_else(|| change.user_id().localpart().to_owned());
|
||||||
|
out.push(MessageRow::plain(
|
||||||
|
MessageKind::Notice,
|
||||||
|
format!("{who} {verb}"),
|
||||||
|
));
|
||||||
previous = None;
|
previous = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,27 +324,50 @@ fn power_of(level: UserPowerLevel) -> i64 {
|
|||||||
match level {
|
match level {
|
||||||
UserPowerLevel::Infinite => i64::MAX,
|
UserPowerLevel::Infinite => i64::MAX,
|
||||||
UserPowerLevel::Int(i) => i64::from(i),
|
UserPowerLevel::Int(i) => i64::from(i),
|
||||||
_ => 0
|
_ => 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn group_members(members: Vec<matrix_sdk::room::RoomMember>) -> Vec<MemberRow> {
|
fn group_members(members: Vec<matrix_sdk::room::RoomMember>) -> Vec<MemberRow> {
|
||||||
let mut groups: [(&str, Vec<MemberRow>); 3] = [("ADMINS", Vec::new()), ("MODERATORS", Vec::new()), ("MEMBERS", Vec::new())];
|
let mut groups: [(&str, Vec<MemberRow>); 3] = [
|
||||||
|
("ADMINS", Vec::new()),
|
||||||
|
("MODERATORS", Vec::new()),
|
||||||
|
("MEMBERS", Vec::new()),
|
||||||
|
];
|
||||||
|
|
||||||
for member in members {
|
for member in members {
|
||||||
let level: i64 = power_of(member.power_level());
|
let level: i64 = power_of(member.power_level());
|
||||||
let idx: usize = if level >= 100 { 0 } else if level >= 50 { 1 } else { 2 };
|
let idx: usize = if level >= 100 {
|
||||||
let name: String = member.display_name().map_or_else(|| member.user_id().localpart().to_owned(), ToOwned::to_owned);
|
0
|
||||||
groups[idx].1.push(MemberRow { id: member.user_id().to_string(), name, is_header: false });
|
} 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<MemberRow> = Vec::new();
|
let mut out: Vec<MemberRow> = Vec::new();
|
||||||
|
|
||||||
for (title, mut rows) in groups {
|
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());
|
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);
|
out.extend(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +389,7 @@ async fn typing_task(room: Room, emit: Sink) {
|
|||||||
for id in ids {
|
for id in ids {
|
||||||
let name: Option<String> = match room.get_member_no_sync(&id).await {
|
let name: Option<String> = match room.get_member_no_sync(&id).await {
|
||||||
Ok(Some(member)) => member.display_name().map(ToOwned::to_owned),
|
Ok(Some(member)) => member.display_name().map(ToOwned::to_owned),
|
||||||
_ => None
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
names.push(name.unwrap_or_else(|| id.localpart().to_owned()));
|
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::Lagged(_)) => {}
|
||||||
Err(RecvError::Closed) => break
|
Err(RecvError::Closed) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+129
-32
@@ -1,10 +1,15 @@
|
|||||||
use matrix_sdk::{Client, authentication::matrix::MatrixSession, store::RoomLoadSettings};
|
use matrix_sdk::{Client, authentication::matrix::MatrixSession, store::RoomLoadSettings};
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
|
use keyring::{Entry, Error as KeyringError};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use std::{fs, path::PathBuf};
|
|
||||||
use crate::helpers;
|
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)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct SavedSession {
|
pub struct SavedSession {
|
||||||
@@ -32,58 +37,142 @@ fn random_passphrase() -> String {
|
|||||||
hex::encode(rand::random::<[u8; 32]>())
|
hex::encode(rand::random::<[u8; 32]>())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load() -> Result<Option<SavedSession>> {
|
fn credential_entry() -> Result<Entry> {
|
||||||
let path: PathBuf = session_file();
|
let directory = data_dir();
|
||||||
|
fs::create_dir_all(&directory).context("Creating the session data directory.")?;
|
||||||
|
|
||||||
if !path.exists() {
|
let directory = fs::canonicalize(directory).context("Resolving the session data directory.")?;
|
||||||
|
|
||||||
|
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.")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn storage_task<T, F>(operation: F) -> Result<T>
|
||||||
|
where
|
||||||
|
T: Send + 'static,
|
||||||
|
F: FnOnce() -> Result<T> + 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<Option<SavedSession>> {
|
||||||
|
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);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
Err(error) => {
|
||||||
|
return Err(error).context("Reading the legacy session file.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let raw: String = fs::read_to_string(&path).with_context(|| format!("Reading '{}'.", path.display()))?;
|
let saved: SavedSession =
|
||||||
Ok(Some(serde_json::from_str(&raw).context("Parsing 'session.json'.")?))
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(client: &Client, store_passphrase: &str) -> Result<()> {
|
//todo: use multiple credentials as to not overflow the windows keyring
|
||||||
let Some(session) = client.matrix_auth().session() else { bail!("Client has no Matrix session to save."); };
|
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 {
|
let saved = SavedSession {
|
||||||
homeserver: client.homeserver().to_string(),
|
homeserver: client.homeserver().to_string(),
|
||||||
store_passphrase: store_passphrase.to_owned(),
|
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();
|
remove_legacy_session()
|
||||||
|
})
|
||||||
fs::write(&path, serde_json::to_vec_pretty(&saved)?)?;
|
.await
|
||||||
|
|
||||||
#[cfg(unix)] {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
/// 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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forget stored account.<br>
|
remove_legacy_session()?;
|
||||||
/// Deletes tokens and the local store.
|
|
||||||
pub fn clear() {
|
match fs::remove_dir_all(store_dir()) {
|
||||||
let _ = fs::remove_file(session_file());
|
Ok(()) => Ok(()),
|
||||||
let _ = fs::remove_dir_all(store_dir());
|
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)> {
|
pub async fn login(server: &str, username: &str, password: &str) -> Result<(Client, String)> {
|
||||||
let server: &str = server.trim();
|
let server = server.trim();
|
||||||
let server: &str = if server.is_empty() { "matrix.org" } else { server };
|
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())?;
|
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)
|
.server_name_or_homeserver_url(server)
|
||||||
.sqlite_store(store_dir(), Some(&passphrase))
|
.sqlite_store(store_dir(), Some(&passphrase))
|
||||||
.handle_refresh_tokens()
|
.handle_refresh_tokens()
|
||||||
@@ -91,14 +180,21 @@ pub async fn login(server: &str, username: &str, password: &str) -> Result<(Clie
|
|||||||
.await
|
.await
|
||||||
.with_context(|| format!("Couldn't reach homeserver '{server}'."))?;
|
.with_context(|| format!("Couldn't reach homeserver '{server}'."))?;
|
||||||
|
|
||||||
client.matrix_auth()
|
client
|
||||||
|
.matrix_auth()
|
||||||
.login_username(username.trim(), password)
|
.login_username(username.trim(), password)
|
||||||
.initial_device_display_name("Tofu")
|
.initial_device_display_name("Tofu")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.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))
|
Ok((client, passphrase))
|
||||||
}
|
}
|
||||||
@@ -112,7 +208,8 @@ pub async fn restore(saved: &SavedSession) -> Result<Client> {
|
|||||||
.build()
|
.build()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
client.matrix_auth()
|
client
|
||||||
|
.matrix_auth()
|
||||||
.restore_session(saved.session.clone(), RoomLoadSettings::default())
|
.restore_session(saved.session.clone(), RoomLoadSettings::default())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
use std::env;
|
use std::{env, ffi::OsString, path::PathBuf};
|
||||||
use std::ffi::OsString;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
pub fn is_absolute_path(path: OsString) -> Option<PathBuf> {
|
pub fn is_absolute_path(path: OsString) -> Option<PathBuf> {
|
||||||
let path = PathBuf::from(path);
|
let path = PathBuf::from(path);
|
||||||
if path.is_absolute() {
|
path.is_absolute().then_some(path)
|
||||||
Some(path)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn home_dir() -> Option<PathBuf> {
|
pub fn home_dir() -> Option<PathBuf> {
|
||||||
|
|||||||
+5
-3
@@ -1,12 +1,12 @@
|
|||||||
#![recursion_limit = "256"]
|
#![recursion_limit = "256"]
|
||||||
|
|
||||||
mod backend;
|
mod backend;
|
||||||
mod model;
|
|
||||||
pub mod bridge;
|
pub mod bridge;
|
||||||
mod helpers;
|
mod helpers;
|
||||||
|
mod model;
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use slint::Weak;
|
use slint::Weak;
|
||||||
|
use std::sync::Arc;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
slint::include_modules!();
|
slint::include_modules!();
|
||||||
@@ -15,7 +15,9 @@ fn main() -> anyhow::Result<()> {
|
|||||||
let _ = dotenvy::dotenv();
|
let _ = dotenvy::dotenv();
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
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();
|
.init();
|
||||||
|
|
||||||
let runtime = tokio::runtime::Runtime::new()?;
|
let runtime = tokio::runtime::Runtime::new()?;
|
||||||
|
|||||||
+52
-23
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
/// Deterministic hue for a user/room/space id, so each gets a stable avatar colour even before avatars are loaded.
|
/// 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 {
|
pub fn hue_for(id: &str) -> f32 {
|
||||||
let mut hash: u32 = 0x811c_9dc5;
|
let mut hash: u32 = 0x811c_9dc5;
|
||||||
@@ -8,7 +7,8 @@ pub fn hue_for(id: &str) -> f32 {
|
|||||||
hash = hash.wrapping_mul(0x0100_0193);
|
hash = hash.wrapping_mul(0x0100_0193);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::cast_precision_loss)] {
|
#[allow(clippy::cast_precision_loss)]
|
||||||
|
{
|
||||||
(hash % 360) as f32
|
(hash % 360) as f32
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ pub fn initial_of(name: &str) -> String {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct UserInfo {
|
pub struct UserInfo {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// A "server" equals a top-level Matrix space.
|
// A "server" equals a top-level Matrix space.
|
||||||
@@ -33,12 +33,14 @@ pub struct ServerRow {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub unread: bool,
|
pub unread: bool,
|
||||||
pub mentions: u32
|
pub mentions: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ChannelKind {
|
pub enum ChannelKind {
|
||||||
Text, Voice, Category
|
Text,
|
||||||
|
Voice,
|
||||||
|
Category,
|
||||||
}
|
}
|
||||||
|
|
||||||
// A "channel" equals a Matrix room.
|
// A "channel" equals a Matrix room.
|
||||||
@@ -50,7 +52,7 @@ pub struct ChannelRow {
|
|||||||
pub unread: bool,
|
pub unread: bool,
|
||||||
pub mentions: u32,
|
pub mentions: u32,
|
||||||
pub joined: bool,
|
pub joined: bool,
|
||||||
pub is_dm: bool
|
pub is_dm: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChannelRow {
|
impl ChannelRow {
|
||||||
@@ -62,7 +64,7 @@ impl ChannelRow {
|
|||||||
unread: false,
|
unread: false,
|
||||||
mentions: 0,
|
mentions: 0,
|
||||||
joined: true,
|
joined: true,
|
||||||
is_dm: false
|
is_dm: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,19 +75,22 @@ pub struct RoomInfo {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub topic: String,
|
pub topic: String,
|
||||||
pub is_dm: bool,
|
pub is_dm: bool,
|
||||||
pub encrypted: bool
|
pub encrypted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ReactionRow {
|
pub struct ReactionRow {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
pub count: u32,
|
pub count: u32,
|
||||||
pub mine: bool
|
pub mine: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum MessageKind {
|
pub enum MessageKind {
|
||||||
Message, DateDivider, Notice, StartOfRoom
|
Message,
|
||||||
|
DateDivider,
|
||||||
|
Notice,
|
||||||
|
StartOfRoom,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -102,7 +107,7 @@ pub struct MessageRow {
|
|||||||
pub own: bool,
|
pub own: bool,
|
||||||
pub pending: bool,
|
pub pending: bool,
|
||||||
pub reply: Option<(String, String)>,
|
pub reply: Option<(String, String)>,
|
||||||
pub reactions: Vec<ReactionRow>
|
pub reactions: Vec<ReactionRow>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MessageRow {
|
impl MessageRow {
|
||||||
@@ -120,7 +125,7 @@ impl MessageRow {
|
|||||||
own: false,
|
own: false,
|
||||||
pending: false,
|
pending: false,
|
||||||
reply: None,
|
reply: None,
|
||||||
reactions: Vec::new()
|
reactions: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,18 +134,27 @@ impl MessageRow {
|
|||||||
pub struct MemberRow {
|
pub struct MemberRow {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub is_header: bool
|
pub is_header: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything the backend can tell the frontend.
|
/// Everything the backend can tell the frontend.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Event {
|
pub enum Event {
|
||||||
NeedLogin { error: Option<String> },
|
NeedLogin {
|
||||||
|
error: Option<String>,
|
||||||
|
},
|
||||||
LoginBusy(bool),
|
LoginBusy(bool),
|
||||||
LoggedIn(UserInfo),
|
LoggedIn(UserInfo),
|
||||||
Servers { servers: Vec<ServerRow>, home_unread: bool, home_mentions: u32 },
|
Servers {
|
||||||
|
servers: Vec<ServerRow>,
|
||||||
|
home_unread: bool,
|
||||||
|
home_mentions: u32,
|
||||||
|
},
|
||||||
/// Channel (or DM) list for the currently selected server / Home.
|
/// Channel (or DM) list for the currently selected server / Home.
|
||||||
Channels { title: String, channels: Vec<ChannelRow> },
|
Channels {
|
||||||
|
title: String,
|
||||||
|
channels: Vec<ChannelRow>,
|
||||||
|
},
|
||||||
RoomOpened(RoomInfo),
|
RoomOpened(RoomInfo),
|
||||||
Messages(Vec<MessageRow>),
|
Messages(Vec<MessageRow>),
|
||||||
Members(Vec<MemberRow>),
|
Members(Vec<MemberRow>),
|
||||||
@@ -148,24 +162,39 @@ pub enum Event {
|
|||||||
LoadingOlder(bool),
|
LoadingOlder(bool),
|
||||||
|
|
||||||
/// Short message for the toast banner.
|
/// Short message for the toast banner.
|
||||||
Toast(String)
|
Toast(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything the frontend can ask the backend.
|
/// Everything the frontend can ask the backend.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
Login { homeserver: String, username: String, password: String },
|
Login {
|
||||||
|
homeserver: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
},
|
||||||
Logout,
|
Logout,
|
||||||
|
|
||||||
/// `None` = Home, DMs or Rooms outside any space.
|
/// `None` = Home, DMs or Rooms outside any space.
|
||||||
SelectServer(Option<String>),
|
SelectServer(Option<String>),
|
||||||
SelectRoom(String),
|
SelectRoom(String),
|
||||||
Send { reply_to: Option<String>, text: String },
|
Send {
|
||||||
Edit { id: String, text: String },
|
reply_to: Option<String>,
|
||||||
Delete { id: String },
|
text: String,
|
||||||
ToggleReaction { id: String, key: String },
|
},
|
||||||
|
Edit {
|
||||||
|
id: String,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
Delete {
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
ToggleReaction {
|
||||||
|
id: String,
|
||||||
|
key: String,
|
||||||
|
},
|
||||||
LoadOlder,
|
LoadOlder,
|
||||||
|
|
||||||
/// The user is typing in the composer. (Throttled by the backend).
|
/// The user is typing in the composer. (Throttled by the backend).
|
||||||
Typing
|
Typing,
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user