Merge pull request #3178 from matrix-org/ex-tui

labs: turn `rrrepl` into a timeline client
This commit is contained in:
Ivan Enderlin
2024-03-11 14:53:35 +01:00
committed by GitHub
9 changed files with 1110 additions and 236 deletions

View File

@@ -22,6 +22,7 @@ sing = "sign"
singed = "signed"
singing = "signing"
Nd = "Nd"
ratatui = "ratatui"
[files]
extend-exclude = [

244
Cargo.lock generated
View File

@@ -635,12 +635,27 @@ dependencies = [
"thiserror",
]
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "castaway"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc"
dependencies = [
"rustversion",
]
[[package]]
name = "cbc"
version = "0.1.2"
@@ -803,6 +818,33 @@ dependencies = [
"cc",
]
[[package]]
name = "color-eyre"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a667583cca8c4f8436db8de46ea8233c42a7d9ae424a82d338f2e4675229204"
dependencies = [
"backtrace",
"color-spantrace",
"eyre",
"indenter",
"once_cell",
"owo-colors",
"tracing-error",
]
[[package]]
name = "color-spantrace"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2"
dependencies = [
"once_cell",
"owo-colors",
"tracing-core",
"tracing-error",
]
[[package]]
name = "color_quant"
version = "1.1.0"
@@ -815,6 +857,19 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
[[package]]
name = "compact_str"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"ryu",
"static_assertions",
]
[[package]]
name = "concurrent-queue"
version = "2.4.0"
@@ -986,6 +1041,31 @@ version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345"
[[package]]
name = "crossterm"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
dependencies = [
"bitflags 2.4.2",
"crossterm_winapi",
"libc",
"mio",
"parking_lot",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "crunchy"
version = "0.2.2"
@@ -2693,6 +2773,15 @@ dependencies = [
"log",
]
[[package]]
name = "lru"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc"
dependencies = [
"hashbrown 0.14.3",
]
[[package]]
name = "mac"
version = "0.1.1"
@@ -3448,10 +3537,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.48.0",
]
[[package]]
name = "multiverse"
version = "0.1.0"
dependencies = [
"anyhow",
"color-eyre",
"crossterm",
"futures-util",
"imbl",
"matrix-sdk",
"matrix-sdk-ui",
"ratatui",
"rpassword",
"serde_json",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"url",
]
[[package]]
name = "native-tls"
version = "0.2.11"
@@ -3834,6 +3945,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "owo-colors"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f"
[[package]]
name = "p256"
version = "0.13.2"
@@ -4489,6 +4606,26 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "ratatui"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcb12f8fbf6c62614b0d56eb352af54f6a22410c3b079eb53ee93c7b97dd31d8"
dependencies = [
"bitflags 2.4.2",
"cassowary",
"compact_str",
"crossterm",
"indoc",
"itertools 0.12.1",
"lru",
"paste",
"stability",
"strum",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "rayon"
version = "1.8.1"
@@ -4693,19 +4830,14 @@ dependencies = [
]
[[package]]
name = "rrrepl"
version = "0.1.0"
name = "rpassword"
version = "7.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f"
dependencies = [
"anyhow",
"futures-util",
"matrix-sdk",
"matrix-sdk-ui",
"serde_json",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"url",
"libc",
"rtoolbox",
"windows-sys 0.48.0",
]
[[package]]
@@ -4728,6 +4860,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rtoolbox"
version = "0.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e"
dependencies = [
"libc",
"windows-sys 0.48.0",
]
[[package]]
name = "ruma"
version = "0.9.4"
@@ -5300,6 +5442,36 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "signal-hook"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1"
dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
@@ -5378,6 +5550,16 @@ dependencies = [
"der",
]
[[package]]
name = "stability"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebd1b177894da2a2d9120208c3386066af06a488255caabc5de8ddca22dbc3ce"
dependencies = [
"quote",
"syn 1.0.109",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
@@ -5460,6 +5642,28 @@ dependencies = [
"syn 2.0.48",
]
[[package]]
name = "strum"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "723b93e8addf9aa965ebe2d11da6d7540fa2283fcea14b3371ff055f7ba13f5f"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a3417fc93d76740d974a01654a09777cb500428cc874ca9f45edfe0c4d4cd18"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.48",
]
[[package]]
name = "subtle"
version = "2.5.0"
@@ -5927,6 +6131,16 @@ dependencies = [
"valuable",
]
[[package]]
name = "tracing-error"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d686ec1c0f384b1277f097b2f279a2ecc11afe8c133c1aabf036a27cb4cd206e"
dependencies = [
"tracing",
"tracing-subscriber",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
@@ -6054,6 +6268,12 @@ dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-segmentation"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
[[package]]
name = "unicode-width"
version = "0.1.11"

View File

@@ -35,6 +35,7 @@ futures-core = "0.3.28"
futures-executor = "0.3.21"
futures-util = { version = "0.3.26", default-features = false, features = ["alloc"] }
http = "0.2.6"
imbl = "2.0.0"
itertools = "0.12.0"
ruma = { git = "https://github.com/ruma/ruma", rev = "b2542df2bbbdf09af0612c9f28bcfa5620e1911c", features = ["client-api-c", "compat-upload-signatures", "compat-user-id", "compat-arbitrary-length-ids", "unstable-msc3401"] }
ruma-common = { git = "https://github.com/ruma/ruma", rev = "b2542df2bbbdf09af0612c9f28bcfa5620e1911c" }

View File

@@ -34,7 +34,7 @@ eyeball-im-util = { workspace = true }
futures-core = { workspace = true }
futures-util = { workspace = true }
fuzzy-matcher = "0.3.7"
imbl = { version = "2.0.0", features = ["serde"] }
imbl = { workspace = true, features = ["serde"] }
indexmap = "2.0.0"
itertools = { workspace = true }
matrix-sdk = { workspace = true, features = ["experimental-oidc", "experimental-sliding-sync"] }

View File

@@ -82,7 +82,7 @@ futures-core = { workspace = true }
futures-util = { workspace = true }
http = { workspace = true }
hyper = { version = "0.14.20", features = ["http1", "http2", "server"], optional = true }
imbl = { version = "2.0.0", features = ["serde"] }
imbl = { workspace = true, features = ["serde"] }
indexmap = "2.0.2"
js_int = "0.2.2"
language-tags = { version = "0.3.2", optional = true }

View File

@@ -13,8 +13,8 @@ Rust SDK can evolve, feel free to propose an experiment.
## Current experiments
- rrrepl: a *R*ead *R*eceipts REPL, to help with client-side computation of read-receipts. Useful
for debugging.
- multiverse: a TUI client mostly for quick development iteration of SDK features and debugging.
Run with `cargo run --bin multiverse matrix.org ~/.cache/multiverse-cache`.
## Archived experiments

View File

@@ -1,23 +1,26 @@
[package]
name = "rrrepl"
name = "multiverse"
version = "0.1.0"
edition = "2021"
publish = false
[[bin]]
name = "rrrepl"
name = "multiverse"
test = false
[dependencies]
anyhow = "1"
tokio = { version = "1.24.2", features = ["macros", "rt-multi-thread"] }
url = "2.2.2"
# when copy-pasting this, please use a git dependency or make sure that you
# have copied the example as it was at the time of the release you use.
color-eyre = "0.6.2"
crossterm = "0.27.0"
futures-util = { workspace = true }
imbl = { workspace = true }
matrix-sdk = { path = "../../crates/matrix-sdk", features = ["sso-login"] }
matrix-sdk-ui = { path = "../../crates/matrix-sdk-ui" }
ratatui = "0.26.1"
rpassword = "7.3.1"
serde_json = { workspace = true }
tokio = { version = "1.24.2", features = ["macros", "rt-multi-thread"] }
tracing = { workspace = true }
tracing-appender = { version = "0.2.2" }
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
futures-util = { workspace = true }
serde_json = { workspace = true }
url = "2.2.2"

861
labs/multiverse/src/main.rs Normal file
View File

@@ -0,0 +1,861 @@
use std::{
collections::HashMap,
env,
io::{self, stdout, Write},
path::PathBuf,
process::exit,
sync::{Arc, Mutex},
time::Duration,
};
use color_eyre::config::HookBuilder;
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use futures_util::{pin_mut, StreamExt as _};
use imbl::Vector;
use matrix_sdk::{
config::StoreConfig,
encryption::{BackupDownloadStrategy, EncryptionSettings},
matrix_auth::MatrixSession,
ruma::{
api::client::receipt::create_receipt::v3::ReceiptType, events::room::message::MessageType,
OwnedRoomId, RoomId,
},
AuthSession, Client, RoomListEntry, ServerName, SqliteCryptoStore, SqliteStateStore,
};
use matrix_sdk_ui::{
room_list_service,
sync_service::{self, SyncService},
timeline::{
PaginationOptions, TimelineItem, TimelineItemContent, TimelineItemKind, VirtualTimelineItem,
},
Timeline as SdkTimeline,
};
use ratatui::{prelude::*, style::palette::tailwind, widgets::*};
use tokio::{spawn, task::JoinHandle};
use tracing::error;
use tracing_subscriber::{layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter};
const HEADER_BG: Color = tailwind::BLUE.c950;
const NORMAL_ROW_COLOR: Color = tailwind::SLATE.c950;
const ALT_ROW_COLOR: Color = tailwind::SLATE.c900;
const SELECTED_STYLE_FG: Color = tailwind::BLUE.c300;
const TEXT_COLOR: Color = tailwind::SLATE.c200;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let file_layer = tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(tracing_appender::rolling::hourly("/tmp/", "logs-"));
tracing_subscriber::registry()
.with(EnvFilter::new(std::env::var("RUST_LOG").unwrap_or("".into())))
.with(file_layer)
.init();
// Read the server name from the command line.
let Some(server_name) = env::args().nth(1) else {
eprintln!("Usage: {} <server_name> <session_path?>", env::args().next().unwrap());
exit(1)
};
let config_path = env::args().nth(2).unwrap_or("/tmp/".to_owned());
let client = configure_client(server_name, config_path).await?;
init_error_hooks()?;
let terminal = init_terminal()?;
let mut app = App::new(client).await?;
app.run(terminal).await
}
fn init_error_hooks() -> anyhow::Result<()> {
let (panic, error) = HookBuilder::default().into_hooks();
let panic = panic.into_panic_hook();
let error = error.into_eyre_hook();
color_eyre::eyre::set_hook(Box::new(move |e| {
let _ = restore_terminal();
error(e)
}))?;
std::panic::set_hook(Box::new(move |info| {
let _ = restore_terminal();
panic(info)
}));
Ok(())
}
fn init_terminal() -> anyhow::Result<Terminal<impl Backend>> {
enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout());
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
fn restore_terminal() -> anyhow::Result<()> {
disable_raw_mode()?;
stdout().execute(LeaveAlternateScreen)?;
Ok(())
}
#[derive(Default)]
struct StatefulList<T> {
state: ListState,
items: Arc<Mutex<Vector<T>>>,
}
#[derive(Default, PartialEq)]
enum DetailsMode {
#[default]
ReadReceipts,
TimelineItems,
// Events // TODO: Soon™
}
struct Timeline {
timeline: Arc<SdkTimeline>,
items: Arc<Mutex<Vector<Arc<TimelineItem>>>>,
task: JoinHandle<()>,
}
struct App {
/// Reference to the main SDK client.
client: Client,
/// The sync service used for synchronizing events.
sync_service: Arc<SyncService>,
/// Room list service rooms known to the app.
ui_rooms: Arc<Mutex<HashMap<OwnedRoomId, room_list_service::Room>>>,
/// Timelines data structures for each room.
timelines: Arc<Mutex<HashMap<OwnedRoomId, Timeline>>>,
/// Ratatui's list of room list entries.
room_list_entries: StatefulList<RoomListEntry>,
/// Task listening to room list service changes, and spawning timelines.
listen_task: JoinHandle<()>,
/// Content of the latest status message, if set.
last_status_message: Arc<Mutex<Option<String>>>,
/// A task to automatically clear the status message in N seconds, if set.
clear_status_message: Option<JoinHandle<()>>,
/// What's shown in the details view, aka the right panel.
details_mode: DetailsMode,
/// The current room that's subscribed to in the room list's sliding sync.
current_room_subscription: Option<room_list_service::Room>,
current_pagination: Arc<Mutex<Option<JoinHandle<()>>>>,
}
impl App {
async fn new(client: Client) -> anyhow::Result<Self> {
let sync_service = Arc::new(SyncService::builder(client.clone()).build().await?);
let room_list_service = sync_service.room_list_service();
let all_rooms = room_list_service.all_rooms().await?;
let (rooms, stream) = all_rooms.entries();
let rooms = Arc::new(Mutex::new(rooms));
let ui_rooms: Arc<Mutex<HashMap<OwnedRoomId, room_list_service::Room>>> =
Default::default();
let timelines = Arc::new(Mutex::new(HashMap::new()));
let r = rooms.clone();
let ur = ui_rooms.clone();
let s = sync_service.clone();
let t = timelines.clone();
let listen_task = spawn(async move {
pin_mut!(stream);
let rooms = r;
let ui_rooms = ur;
let sync_service = s;
let timelines = t;
while let Some(diffs) = stream.next().await {
let all_rooms = {
// Apply the diffs to the list of room entries.
let mut rooms = rooms.lock().unwrap();
for diff in diffs {
diff.apply(&mut rooms);
}
// Collect rooms early to release the room entries list lock.
rooms
.iter()
.filter_map(|entry| entry.as_room_id().map(ToOwned::to_owned))
.collect::<Vec<_>>()
};
// Clone the previous set of ui rooms to avoid keeping the ui_rooms lock (which
// we couldn't do below, because it's a sync lock, and has to be
// sync b/o rendering; and we'd have to cross await points
// below).
let previous_ui_rooms = ui_rooms.lock().unwrap().clone();
let mut new_ui_rooms = HashMap::new();
let mut new_timelines = Vec::new();
// Initialize all the new rooms.
for room_id in
all_rooms.into_iter().filter(|room_id| !previous_ui_rooms.contains_key(room_id))
{
// Retrieve the room list service's Room.
let Ok(ui_room) = sync_service.room_list_service().room(&room_id).await else {
error!("error when retrieving room after an update");
continue;
};
// Initialize the timeline.
let builder = match ui_room.default_room_timeline_builder().await {
Ok(builder) => builder,
Err(err) => {
error!("error when getting default timeline builder: {err}");
continue;
}
};
if let Err(err) = ui_room.init_timeline_with_builder(builder).await {
error!("error when creating default timeline: {err}");
}
// Save the timeline in the cache.
let sdk_timeline = ui_room.timeline().unwrap();
let (items, stream) = sdk_timeline.subscribe().await;
let items = Arc::new(Mutex::new(items));
// Spawn a timeline task that will listen to all the timeline item changes.
let i = items.clone();
let timeline_task = spawn(async move {
pin_mut!(stream);
let items = i;
while let Some(diff) = stream.next().await {
let mut items = items.lock().unwrap();
diff.apply(&mut items);
}
});
new_timelines.push((
room_id.clone(),
Timeline { timeline: sdk_timeline, items, task: timeline_task },
));
// Save the room list service room in the cache.
new_ui_rooms.insert(room_id, ui_room);
}
ui_rooms.lock().unwrap().extend(new_ui_rooms);
timelines.lock().unwrap().extend(new_timelines);
}
});
// This will sync (with encryption) until an error happens or the program is
// stopped.
sync_service.start().await;
Ok(Self {
sync_service,
room_list_entries: StatefulList { state: Default::default(), items: rooms },
client,
listen_task,
last_status_message: Default::default(),
clear_status_message: None,
ui_rooms,
details_mode: Default::default(),
timelines,
current_room_subscription: None,
current_pagination: Default::default(),
})
}
}
impl App {
/// Set the current status message (displayed at the bottom), for a few
/// seconds.
fn set_status_message(&mut self, status: String) {
if let Some(handle) = self.clear_status_message.take() {
// Cancel the previous task to clear the status message.
handle.abort();
}
*self.last_status_message.lock().unwrap() = Some(status);
let message = self.last_status_message.clone();
self.clear_status_message = Some(spawn(async move {
// Clear the status message in 4 seconds.
tokio::time::sleep(Duration::from_secs(4)).await;
*message.lock().unwrap() = None;
}));
}
/// Mark the currently selected room as read.
async fn mark_as_read(&mut self) {
let Some(room) = self
.get_selected_room_id(None)
.and_then(|room_id| self.ui_rooms.lock().unwrap().get(&room_id).cloned())
else {
self.set_status_message("missing room or nothing to show".to_owned());
return;
};
// Mark as read!
match room.timeline().unwrap().mark_as_read(ReceiptType::Read).await {
Ok(did) => {
self.set_status_message(format!(
"did {}send a read receipt!",
if did { "" } else { "not " }
));
}
Err(err) => {
self.set_status_message(format!("error when marking a room as read: {err}",));
}
}
}
/// Run a small back-pagination (expect a batch of 20 events, continue until
/// we get 10 timeline items or hit the timeline start).
async fn back_paginate(&mut self) {
let Some(sdk_timeline) = self.get_selected_room_id(None).and_then(|room_id| {
self.timelines.lock().unwrap().get(&room_id).map(|timeline| timeline.timeline.clone())
}) else {
self.set_status_message("missing timeline for room".to_owned());
return;
};
let mut pagination = self.current_pagination.lock().unwrap();
// Cancel the previous back-pagination, if any.
if let Some(prev) = pagination.take() {
prev.abort();
}
// Start a new one, request batches of 20 events, stop after 10 timeline items
// have been added.
*pagination = Some(spawn(async move {
if let Err(err) =
sdk_timeline.paginate_backwards(PaginationOptions::until_num_items(20, 10)).await
{
// TODO: would be nice to be able to set the status
// message remotely?
//self.set_status_message(format!(
//"Error during backpagination: {err}"
//));
error!("Error during backpagination: {err}")
}
}));
}
/// Returns the currently selected room id, if any.
fn get_selected_room_id(&self, selected: Option<usize>) -> Option<OwnedRoomId> {
let selected = selected.or_else(|| self.room_list_entries.state.selected())?;
self.room_list_entries
.items
.lock()
.unwrap()
.get(selected)
.cloned()
.and_then(|entry| entry.as_room_id().map(ToOwned::to_owned))
}
fn subscribe_to_selected_room(&mut self, selected: usize) {
// Delete the subscription to the previous room, if any.
if let Some(room) = self.current_room_subscription.take() {
room.unsubscribe();
}
// Subscribe to the new room.
if let Some(room) = self
.get_selected_room_id(Some(selected))
.and_then(|room_id| self.ui_rooms.lock().unwrap().get(&room_id).cloned())
{
room.subscribe(None);
self.current_room_subscription = Some(room);
}
}
async fn render_loop(&mut self, mut terminal: Terminal<impl Backend>) -> anyhow::Result<()> {
loop {
terminal.draw(|f| f.render_widget(&mut *self, f.size()))?;
if crossterm::event::poll(Duration::from_millis(16))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
use KeyCode::*;
match key.code {
Char('q') | Esc => return Ok(()),
Char('j') | Down => {
if let Some(i) = self.room_list_entries.next() {
self.subscribe_to_selected_room(i);
}
}
Char('k') | Up => {
if let Some(i) = self.room_list_entries.previous() {
self.subscribe_to_selected_room(i);
}
}
Char('s') => self.sync_service.start().await,
Char('S') => self.sync_service.stop().await?,
Char('r') => self.details_mode = DetailsMode::ReadReceipts,
Char('t') => self.details_mode = DetailsMode::TimelineItems,
Char('b') if self.details_mode == DetailsMode::TimelineItems => {
self.back_paginate().await;
}
Char('m') if self.details_mode == DetailsMode::ReadReceipts => {
self.mark_as_read().await
}
_ => {}
}
}
}
}
}
}
async fn run(&mut self, terminal: Terminal<impl Backend>) -> anyhow::Result<()> {
self.render_loop(terminal).await?;
// At this point the user has exited the loop, so shut down the application.
restore_terminal()?;
println!("Closing sync service...");
let s = self.sync_service.clone();
let wait_for_termination = spawn(async move {
while let Some(state) = s.state().next().await {
if !matches!(state, sync_service::State::Running) {
break;
}
}
});
self.sync_service.stop().await?;
self.listen_task.abort();
for timeline in self.timelines.lock().unwrap().values() {
timeline.task.abort();
}
wait_for_termination.await.unwrap();
println!("okthxbye!");
Ok(())
}
}
impl Widget for &mut App {
/// Render the whole app.
fn render(self, area: Rect, buf: &mut Buffer) {
// Create a space for header, todo list and the footer.
let vertical =
Layout::vertical([Constraint::Length(2), Constraint::Min(0), Constraint::Length(2)]);
let [header_area, rest_area, footer_area] = vertical.areas(area);
// Create two chunks with equal horizontal screen space. One for the list and
// the other for the info block.
let horizontal =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]);
let [lhs, rhs] = horizontal.areas(rest_area);
self.render_title(header_area, buf);
self.render_left(lhs, buf);
self.render_right(rhs, buf);
self.render_footer(footer_area, buf);
}
}
impl App {
/// Render the top square (title of the program).
fn render_title(&self, area: Rect, buf: &mut Buffer) {
Paragraph::new("Multiverse").bold().centered().render(area, buf);
}
/// Renders the left part of the screen, that is, the list of rooms.
fn render_left(&mut self, area: Rect, buf: &mut Buffer) {
// We create two blocks, one is for the header (outer) and the other is for list
// (inner).
let outer_block = Block::default()
.borders(Borders::NONE)
.fg(TEXT_COLOR)
.bg(HEADER_BG)
.title("Room list")
.title_alignment(Alignment::Center);
let inner_block =
Block::default().borders(Borders::NONE).fg(TEXT_COLOR).bg(NORMAL_ROW_COLOR);
// We get the inner area from outer_block. We'll use this area later to render
// the table.
let outer_area = area;
let inner_area = outer_block.inner(outer_area);
// We can render the header in outer_area.
outer_block.render(outer_area, buf);
// Iterate through all elements in the `items` and stylize them.
let items: Vec<ListItem<'_>> = self
.room_list_entries
.items
.lock()
.unwrap()
.iter()
.enumerate()
.map(|(i, item)| {
let bg_color = match i % 2 {
0 => NORMAL_ROW_COLOR,
_ => ALT_ROW_COLOR,
};
let line = if let Some(room) =
item.as_room_id().and_then(|room_id| self.client.get_room(room_id))
{
format!("#{i} {}", room.room_id())
} else {
"non-filled room".to_owned()
};
let line = Line::styled(line, TEXT_COLOR);
ListItem::new(line).bg(bg_color)
})
.collect();
// Create a List from all list items and highlight the currently selected one.
let items = List::new(items)
.block(inner_block)
.highlight_style(
Style::default()
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::REVERSED)
.fg(SELECTED_STYLE_FG),
)
.highlight_symbol(">")
.highlight_spacing(HighlightSpacing::Always);
StatefulWidget::render(items, inner_area, buf, &mut self.room_list_entries.state);
}
/// Render the right part of the screen, showing the details of the current
/// view.
fn render_right(&mut self, area: Rect, buf: &mut Buffer) {
// Split the block into two parts:
// - outer_block with the title of the block.
// - inner_block that will contain the actual details.
let outer_block = Block::default()
.borders(Borders::NONE)
.fg(TEXT_COLOR)
.bg(HEADER_BG)
.title("Room view")
.title_alignment(Alignment::Center);
let inner_block = Block::default()
.borders(Borders::NONE)
.bg(NORMAL_ROW_COLOR)
.padding(Padding::horizontal(1));
// This is a similar process to what we did for list. outer_info_area will be
// used for header inner_info_area will be used for the list info.
let outer_area = area;
let inner_area = outer_block.inner(outer_area);
// We can render the header. Inner area will be rendered later.
outer_block.render(outer_area, buf);
// Helper to render some string as a paragraph.
let render_paragraph = |buf: &mut Buffer, content: String| {
Paragraph::new(content)
.block(inner_block.clone())
.fg(TEXT_COLOR)
.wrap(Wrap { trim: false })
.render(inner_area, buf);
};
if let Some(room_id) = self.get_selected_room_id(None) {
match self.details_mode {
DetailsMode::ReadReceipts => {
// In read receipts mode, show the read receipts object as computed
// by the client.
match self.ui_rooms.lock().unwrap().get(&room_id).cloned() {
Some(room) => {
let receipts = room.read_receipts();
render_paragraph(
buf,
format!(
r#"Read receipts:
- unread: {}
- notifications: {}
- mentions: {}
---
{:?}
"#,
receipts.num_unread,
receipts.num_notifications,
receipts.num_mentions,
receipts
),
)
}
None => render_paragraph(
buf,
"(room disappeared in the room list service)".to_owned(),
),
}
}
DetailsMode::TimelineItems => {
if !self.render_timeline(&room_id, inner_block.clone(), inner_area, buf) {
render_paragraph(buf, "(room's timeline disappeared)".to_owned())
}
}
}
} else {
render_paragraph(buf, "Nothing to see here...".to_owned())
};
}
/// Renders the list of timeline items for the given room.
fn render_timeline(
&mut self,
room_id: &RoomId,
inner_block: Block<'_>,
inner_area: Rect,
buf: &mut Buffer,
) -> bool {
let Some(items) =
self.timelines.lock().unwrap().get(room_id).map(|timeline| timeline.items.clone())
else {
return false;
};
let items = items.lock().unwrap();
let mut content = Vec::new();
for item in items.iter() {
match item.kind() {
TimelineItemKind::Event(ev) => {
let sender = ev.sender();
match ev.content() {
TimelineItemContent::Message(message) => {
if let MessageType::Text(text) = message.msgtype() {
content.push(format!("{}: {}", sender, text.body))
}
}
TimelineItemContent::RedactedMessage => {
content.push(format!("{}: -- redacted --", sender))
}
TimelineItemContent::UnableToDecrypt(_) => {
content.push(format!("{}: (UTD)", sender))
}
TimelineItemContent::Sticker(_)
| TimelineItemContent::MembershipChange(_)
| TimelineItemContent::ProfileChange(_)
| TimelineItemContent::OtherState(_)
| TimelineItemContent::FailedToParseMessageLike { .. }
| TimelineItemContent::FailedToParseState { .. }
| TimelineItemContent::Poll(_)
| TimelineItemContent::CallInvite => {
continue;
}
}
}
TimelineItemKind::Virtual(virt) => match virt {
VirtualTimelineItem::DayDivider(unix_ts) => {
content.push(format!("Date: {unix_ts:?}"));
}
VirtualTimelineItem::ReadMarker => {
content.push("Read marker".to_owned());
}
},
}
}
let list_items = content
.into_iter()
.enumerate()
.map(|(i, line)| {
let bg_color = match i % 2 {
0 => NORMAL_ROW_COLOR,
_ => ALT_ROW_COLOR,
};
let line = Line::styled(line, TEXT_COLOR);
ListItem::new(line).bg(bg_color)
})
.collect::<Vec<_>>();
let list = List::new(list_items)
.block(inner_block)
.highlight_style(
Style::default()
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::REVERSED)
.fg(SELECTED_STYLE_FG),
)
.highlight_symbol(">")
.highlight_spacing(HighlightSpacing::Always);
let mut dummy_list_state = ListState::default();
StatefulWidget::render(list, inner_area, buf, &mut dummy_list_state);
true
}
/// Render the bottom part of the screen, with a status message if one is
/// set, or a default help message otherwise.
fn render_footer(&self, area: Rect, buf: &mut Buffer) {
let content = if let Some(status_message) = self.last_status_message.lock().unwrap().clone()
{
status_message
} else {
match self.details_mode {
DetailsMode::ReadReceipts => {
"\nUse ↓↑ to move, s/S to start/stop the sync service, m to mark as read, t to show the timeline.".to_owned()
}
DetailsMode::TimelineItems => {
"\nUse ↓↑ to move, s/S to start/stop the sync service, r to show read receipts.".to_owned()
}
}
};
Paragraph::new(content).centered().render(area, buf);
}
}
impl<T> StatefulList<T> {
/// Focus the list on the next item, wraps around if needs be.
///
/// Returns the index only if there was a meaningful change.
fn next(&mut self) -> Option<usize> {
let num_items = self.items.lock().unwrap().len();
// If there's no item to select, leave early.
if num_items == 0 {
self.state.select(None);
return None;
}
// Otherwise, select the next one or wrap around.
let prev = self.state.selected();
let new = prev.map_or(0, |i| if i >= num_items - 1 { 0 } else { i + 1 });
if prev != Some(new) {
self.state.select(Some(new));
Some(new)
} else {
None
}
}
/// Focus the list on the previous item, wraps around if needs be.
///
/// Returns the index only if there was a meaningful change.
fn previous(&mut self) -> Option<usize> {
let num_items = self.items.lock().unwrap().len();
// If there's no item to select, leave early.
if num_items == 0 {
self.state.select(None);
return None;
}
// Otherwise, select the previous one or wrap around.
let prev = self.state.selected();
let new = prev.map_or(0, |i| if i == 0 { num_items - 1 } else { i - 1 });
if prev != Some(new) {
self.state.select(Some(new));
Some(new)
} else {
None
}
}
}
/// Configure the client so it's ready for sync'ing.
///
/// Will log in or reuse a previous session.
async fn configure_client(server_name: String, config_path: String) -> anyhow::Result<Client> {
let server_name = ServerName::parse(&server_name)?;
let config_path = PathBuf::from(config_path);
let client = Client::builder()
.store_config(
StoreConfig::default()
.crypto_store(
SqliteCryptoStore::open(config_path.join("crypto.sqlite"), None).await?,
)
.state_store(SqliteStateStore::open(config_path.join("state.sqlite"), None).await?),
)
.server_name(&server_name)
.with_encryption_settings(EncryptionSettings {
auto_enable_cross_signing: true,
backup_download_strategy: BackupDownloadStrategy::AfterDecryptionFailure,
auto_enable_backups: true,
})
.build()
.await?;
// Try reading a session, otherwise create a new one.
let session_path = config_path.join("session.json");
if let Ok(serialized) = std::fs::read_to_string(&session_path) {
let session: MatrixSession = serde_json::from_str(&serialized)?;
client.restore_session(session).await?;
println!("restored session");
} else {
login_with_password(&client).await?;
println!("new login");
// Immediately save the session to disk.
if let Some(session) = client.session() {
let AuthSession::Matrix(session) = session else { panic!("unexpected oidc session") };
let serialized = serde_json::to_string(&session)?;
std::fs::write(session_path, serialized)?;
println!("saved session");
}
}
Ok(client)
}
/// Asks the user of a username and password, and try to login using the matrix
/// auth with those.
async fn login_with_password(client: &Client) -> anyhow::Result<()> {
println!("Logging in with username and password…");
loop {
print!("\nUsername: ");
stdout().flush().expect("Unable to write to stdout");
let mut username = String::new();
io::stdin().read_line(&mut username).expect("Unable to read user input");
username = username.trim().to_owned();
let password = rpassword::prompt_password("Password.")?;
match client.matrix_auth().login_username(&username, password.trim()).await {
Ok(_) => {
println!("Logged in as {username}");
break;
}
Err(error) => {
println!("Error logging in: {error}");
println!("Please try again\n");
}
}
}
Ok(())
}

View File

@@ -1,212 +0,0 @@
use std::{
env,
io::{self, Write},
process::exit,
sync::{Arc, Mutex},
};
use futures_util::{pin_mut, StreamExt as _};
use matrix_sdk::{
config::StoreConfig, matrix_auth::MatrixSession,
ruma::api::client::receipt::create_receipt::v3::ReceiptType, AuthSession, Client, ServerName,
SqliteCryptoStore, SqliteStateStore,
};
use matrix_sdk_ui::sync_service::{self, SyncService};
use tokio::spawn;
use tracing_subscriber::{layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let file_layer = tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(tracing_appender::rolling::hourly("/tmp/", "logs-"));
tracing_subscriber::registry()
.with(EnvFilter::new(std::env::var("RUST_LOG").unwrap_or("".into())))
.with(file_layer)
.init();
let Some(server_name) = env::args().nth(1) else {
eprintln!("Usage: {} <server_name>", env::args().next().unwrap());
exit(1)
};
login_and_sync(server_name).await?;
Ok(())
}
/// Log in to the given homeserver and sync.
async fn login_and_sync(server_name: String) -> anyhow::Result<()> {
let server_name = ServerName::parse(&server_name)?;
let client = Client::builder()
.store_config(
StoreConfig::default()
.crypto_store(SqliteCryptoStore::open("/tmp/crypto.sqlite", None).await?)
.state_store(SqliteStateStore::open("/tmp/state.sqlite", None).await?),
)
.server_name(&server_name)
.build()
.await?;
// Try reading from /tmp/session.json
if let Ok(serialized) = std::fs::read_to_string("/tmp/session.json") {
let session: MatrixSession = serde_json::from_str(&serialized)?;
client.restore_session(session).await?;
println!("restored session");
} else {
login_with_password(&client).await?;
println!("new login");
}
let sync_service = SyncService::builder(client.clone()).build().await?;
let room_list_service = sync_service.room_list_service();
let all_rooms = room_list_service.all_rooms().await?;
let (rooms, stream) = all_rooms.entries();
let rooms = Arc::new(Mutex::new(rooms.clone()));
// This will sync (with encryption) until an error happens or the program is
// killed.
sync_service.start().await;
let c = client.clone();
let r = rooms.clone();
let handle = spawn(async move {
pin_mut!(stream);
let rooms = r;
let client = c;
while let Some(diffs) = stream.next().await {
let mut rooms = rooms.lock().unwrap();
for diff in diffs {
diff.apply(&mut rooms);
}
println!("New update!");
for (id, room) in rooms.iter().enumerate() {
if let Some(room) = room.as_room_id().and_then(|room_id| client.get_room(room_id)) {
println!("> #{id} {}: {:?}", room.room_id(), room.read_receipts());
}
}
}
});
loop {
let mut command = String::new();
print!("$ ");
let _ = io::stdout().flush();
io::stdin().read_line(&mut command).expect("Unable to read user input");
match command.trim() {
"rooms" => {
let rooms = rooms.lock().unwrap();
for (id, room) in rooms.iter().enumerate() {
if let Some(room) =
room.as_room_id().and_then(|room_id| client.get_room(room_id))
{
println!("> #{id} {}: {:?}", room.room_id(), room.read_receipts());
}
}
}
"start" => {
sync_service.start().await;
println!("> sync service started!");
}
"stop" => {
sync_service.stop().await?;
println!("> sync service stopped!");
}
"" | "exit" => {
break;
}
_ => {
if let Some((_, id)) = command.split_once("send ") {
let id = id.trim().parse::<usize>()?;
let room_id = { rooms.lock().unwrap()[id].as_room_id().map(ToOwned::to_owned) };
if let Some(room_id) = &room_id {
let room = room_list_service.room(room_id).await?;
if !room.is_timeline_initialized() {
room.init_timeline_with_builder(
room.default_room_timeline_builder().await?,
)
.await?;
}
let timeline = room.timeline().unwrap();
let did = timeline.mark_as_read(ReceiptType::Read).await?;
println!("> did {}send a read receipt!", if did { "" } else { "not " });
}
} else {
println!("unknown command");
}
}
}
}
println!("Closing sync service...");
let sync_service = Arc::new(sync_service);
let s = sync_service.clone();
let wait_for_termination = spawn(async move {
while let Some(state) = s.state().next().await {
if !matches!(state, sync_service::State::Running) {
break;
}
}
});
sync_service.stop().await?;
handle.abort();
wait_for_termination.await.unwrap();
if let Some(session) = client.session() {
let AuthSession::Matrix(session) = session else { panic!("unexpected oidc session") };
let serialized = serde_json::to_string(&session)?;
std::fs::write("/tmp/session.json", serialized)?;
println!("saved session");
}
println!("okthxbye!");
Ok(())
}
async fn login_with_password(client: &Client) -> anyhow::Result<()> {
println!("Logging in with username and password…");
loop {
print!("\nUsername: ");
io::stdout().flush().expect("Unable to write to stdout");
let mut username = String::new();
io::stdin().read_line(&mut username).expect("Unable to read user input");
username = username.trim().to_owned();
print!("Password: ");
io::stdout().flush().expect("Unable to write to stdout");
let mut password = String::new();
io::stdin().read_line(&mut password).expect("Unable to read user input");
password = password.trim().to_owned();
match client.matrix_auth().login_username(&username, &password).await {
Ok(_) => {
println!("Logged in as {username}");
break;
}
Err(error) => {
println!("Error logging in: {error}");
println!("Please try again\n");
}
}
}
Ok(())
}