diff --git a/Cargo.lock b/Cargo.lock index 2362fa9..16d9e98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,14 +7,22 @@ name = "Tofu" version = "0.1.0" dependencies = [ "anyhow", + "chrono", + "dirs", "dotenvy", "eyeball-im", "futures-util", + "hex", "matrix-sdk", "matrix-sdk-ui", + "rand 0.10.3", + "serde", + "serde_json", "slint", "slint-build", "tokio", + "tracing", + "tracing-subscriber", ] [[package]] @@ -1478,6 +1486,27 @@ dependencies = [ "ctutils", ] +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -4744,6 +4773,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.55" @@ -5174,9 +5209,16 @@ checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ "bitflags 2.13.2", "memchr", + "pulldown-cmark-escape", "unicase", ] +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pxfm" version = "0.1.30" @@ -5444,6 +5486,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + [[package]] name = "regex" version = "1.13.1" @@ -5696,6 +5748,7 @@ dependencies = [ "indexmap", "js_int", "js_option", + "pulldown-cmark", "ruma-common", "ruma-html", "ruma-macros", diff --git a/Cargo.toml b/Cargo.toml index 528f313..c8f2f25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,15 +5,27 @@ version = "0.1.0" edition = "2024" [dependencies] -matrix-sdk = "0.19.1" +matrix-sdk = { version = "0.19.1", features = ["markdown"]} matrix-sdk-ui = "0.19.1" -slint = "1.18.1" -dotenvy = "0.15" eyeball-im = "0.9.1" + +slint = "1.18.1" + anyhow = "1.0.104" tokio = { version = "1.53.2", features = ["rt-multi-thread", "macros"] } futures-util = "0.3.34" +chrono = { version = "0.4.45", default-features = false, features = ["clock", "std"] } +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1.44" +rand = "0.10.3" +dirs = "7.0.0" +hex = "0.4.3" + +dotenvy = "0.15.7" + [build-dependencies] slint-build = "1.18.1" diff --git a/build.rs b/build.rs index 3e5cd6a..ef2013c 100644 --- a/build.rs +++ b/build.rs @@ -1,3 +1,6 @@ +use slint_build::CompilerConfiguration; + fn main() { - slint_build::compile("ui/main.slint").expect("Failed to compile Slint UI"); -} + let config = CompilerConfiguration::new().with_style("fluent-dark".to_owned()); + slint_build::compile_with_config("ui/main.slint", config).expect("Failed to compile Slint UI"); +} \ No newline at end of file diff --git a/src/backend/format.rs b/src/backend/format.rs new file mode 100644 index 0000000..ed6e712 --- /dev/null +++ b/src/backend/format.rs @@ -0,0 +1,43 @@ +use chrono::{DateTime, Datelike, Local, TimeZone}; +use matrix_sdk::ruma::MilliSecondsSinceUnixEpoch; +pub fn to_local(timestamp: MilliSecondsSinceUnixEpoch) -> DateTime { + let ms: i64 = i64::try_from(u64::from(timestamp.0)).unwrap_or(0); + Local.timestamp_millis_opt(ms).single().unwrap_or_else(Local::now) +} + +/// "Today at 13:00" / "Yesterday at 13:00" / "2026-10-09 13:00" +pub fn message_time(timestamp: MilliSecondsSinceUnixEpoch) -> String { + let time = to_local(timestamp); + let today = Local::now().date_naive(); + let day = time.date_naive(); + let clock = time.format("%H:%M"); + + if day == today { + format!("Today at {clock}") + } else if today.pred_opt() == Some(day) { + format!("Yesterday at {clock}") + } else { + format!("{} {clock}", time.format("%Y-%m-%d")) + } +} + +pub fn short_time(timestamp: MilliSecondsSinceUnixEpoch) -> String { + to_local(timestamp).format("%H:%M").to_string() +} + +/// "October 9, 2026" +pub fn date_label(timestamp: MilliSecondsSinceUnixEpoch) -> String { + let time = to_local(timestamp); + format!("{} {}, {}", time.format("%B"), time.day(), time.year()) +} + +pub fn truncate(string: &str, max_characters: usize) -> String { + let flat: String = string.split_whitespace().collect::>().join(" "); + + if flat.chars().count() > max_characters { + let cut: String = flat.chars().take(max_characters).collect(); + format!("{cut}...") + } else { + flat + } +} \ No newline at end of file diff --git a/src/backend/mod.rs b/src/backend/mod.rs new file mode 100644 index 0000000..aef04a2 --- /dev/null +++ b/src/backend/mod.rs @@ -0,0 +1,352 @@ +mod format; +mod session; +mod room; +mod navigation; + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use anyhow::{Context, Result, anyhow}; + +use std::time::{Duration, Instant}; + +use matrix_sdk::{Client, Room, RoomState, SessionChange, ruma::{ + EventId, OwnedRoomId, OwnedTransactionId, RoomId, RoomOrAliasId, OwnedUserId, + events::room::message::{RoomMessageEventContent, RoomMessageEventContentWithoutRelation}, +}, room::edit::EditedContent, OwnedServerName}; + +use matrix_sdk_ui::{sync_service::{State as SyncState, SyncService}, spaces::SpaceService, timeline::TimelineEventItemId, Timeline}; +use tokio::{ + sync::{mpsc, watch}, + task::JoinHandle, +}; +use crate::backend::room::OpenRoom; +use crate::backend::session::SavedSession; +use crate::model::{Command, Event, UserInfo}; + + +/// How the backend delivers events. The UI layer provides this. +pub type Sink = Arc; + +// A spawned task that is aborted when dropped, replacing it cancels the old one. +pub struct Task(JoinHandle<()>); + +impl Task { + pub fn spawn(future: impl Future + Send + 'static) -> Self { + Self(tokio::spawn(future)) + } +} + +impl Drop for Task { + fn drop(&mut self) { + self.0.abort(); + } +} + +pub fn spawn(rt: &tokio::runtime::Handle, emit: Sink) -> mpsc::UnboundedSender { + let (tx, rx) = mpsc::unbounded_channel(); + rt.spawn(Backend::new(emit).run(rx)); + tx +} + +struct Backend { + emit: Sink, + client: Option, + sync: Option>, + store_passphrase: String, + + /// Currently selected server (space); `None` = Home.
+ /// Watched by `navigation.rs`. + selected_tx: watch::Sender>, + via: navigation::ViaMap, + + /// Tasks that live as long as the login does. + background: Vec, + open: Option +} + +impl Backend { + fn new(emit: Sink) -> Self { + Self { + emit, + client: None, + sync: None, + store_passphrase: String::new(), + selected_tx: watch::channel(None).0, + via: Arc::new(Mutex::new(HashMap::new())), + background: Vec::new(), + open: None + } + } + + fn emit(&mut self, event: Event) { + (self.emit)(event); + } + + async fn run(mut self, mut rx: mpsc::UnboundedReceiver) { + self.try_restore().await; + + while let Some(command) = rx.recv().await { + if let Err(error) = self.handle(command).await { + tracing::error!("Command failed: {error:#}"); + self.emit(Event::Toast(format!("{error:#}"))) + } + } + } + + // Session + + async fn try_restore(&mut self) { + let saved: SavedSession = match session::load() { + Ok(Some(session)) => session, + Ok(None) => return self.emit(Event::NeedLogin { error: None }), + Err(error) => return self.emit(Event::NeedLogin { error: Some(format!("{error:#}")) }) + }; + + match session::restore(&saved).await { + Ok(client) => { + self.store_passphrase = saved.store_passphrase; + + if let Err(error) = self.on_logged_in(client).await { + self.emit(Event::NeedLogin { error: Some(format!("{error:#}")) }) + } + } + + Err(error) => { + tracing::warn!("Restoration of session failed: {error:#}"); + self.emit(Event::NeedLogin { error: Some("Couldn't restore your last session. Please sign in again.".into()) }) + } + } + } + + async fn login(&mut self, homeserver: &str, username: &str, password: &str) { + self.emit(Event::LoginBusy(true)); + + let result = async { + let (client, passphrase) = session::login(homeserver, username, password).await?; + self.store_passphrase = passphrase; + self.on_logged_in(client).await + }.await; + + self.emit(Event::LoginBusy(false)); + + if let Err(error) = result { + self.emit(Event::NeedLogin { error: Some(format!("{error:#}")) }) + } + } + + async fn logout(&mut self) { + self.open = None; + + self.background.clear(); + + if let Some(sync) = self.sync.take() { + sync.stop().await; + } + + if let Some(client) = self.client.take() { + let _ = client.matrix_auth().logout().await; + } + + session::clear(); + + self.selected_tx.send_replace(None); + self.emit(Event::NeedLogin { error: None }); + } + + async fn on_logged_in(&mut self, client: Client) -> Result<()> { + let id: String = client.user_id().map(ToString::to_string).unwrap_or_default(); + let name: String = client.account() + .get_display_name() + .await + .ok() + .flatten() + .unwrap_or_else(|| id.trim_start_matches('@').split(':').next().unwrap_or("").to_owned()); + + self.emit(Event::LoggedIn(UserInfo { id, name })); + + let sync: Arc = Arc::new(SyncService::builder(client.clone()).build().await.context("Couldn't start sync (does homeserver support sliding sync?)")?); + sync.start().await; + + let spaces: Arc = Arc::new(SpaceService::new(client.clone()).await); + + self.background = vec![ + Task::spawn(navigation::run(client.clone(), spaces, self.selected_tx.subscribe(), self.emit.clone(), self.via.clone())), + Task::spawn(watch_session(client.clone(), self.store_passphrase.clone(), self.emit.clone())), + Task::spawn(watch_sync(sync.clone(), self.emit.clone())), + ]; + + self.client = Some(client); + self.sync = Some(sync); + self.selected_tx.send_replace(None); + + Ok(()) + } + + // Commands + + async fn handle(&mut self, command: Command) -> Result<()> { + match command { + Command::Login { homeserver, username, password} => self.login(&homeserver, &username, &password).await, + + Command::Logout => self.logout().await, + + Command::SelectServer(id) => { + let parsed: Option = id.filter(|string| !string.is_empty()).map(RoomId::parse).transpose()?; + self.selected_tx.send_replace(parsed); + } + + Command::SelectRoom(id) => self.select_room(&id).await?, + + Command::Send { reply_to, text } => self.send(reply_to, &text).await?, + + Command::Edit { id, text } => { + let item: TimelineEventItemId = parse_item_id(&id)?; + let content: RoomMessageEventContentWithoutRelation = RoomMessageEventContentWithoutRelation::text_markdown(text); + self.timeline()?.edit(&item, EditedContent::RoomMessage(content)).await?; + } + + Command::Delete { id } => { + self.timeline()?.redact(&parse_item_id(&id)?, None).await?; + } + + Command::ToggleReaction { id, key } => { + self.timeline()?.toggle_reaction(&parse_item_id(&id)?, &key).await?; + } + + Command::LoadOlder => { + if let Some(open) = &self.open { + tokio::spawn(room::load_older(open.timeline.clone(), open.loading_older.clone(), open.reached_start.clone(), self.emit.clone())); + } + } + + Command::Typing => self.typing(), + } + + Ok(()) + } + + fn timeline(&self) -> Result> { + self.open.as_ref().map(|open| open.timeline.clone()).ok_or_else(|| anyhow!("No room is open.")) + } + + async fn select_room(&mut self, room_id: &str) -> Result<()> { + let client: Client = self.client.clone().ok_or_else(|| anyhow!("Not signed in."))?; + let room_id: OwnedRoomId = RoomId::parse(room_id)?; + + let room: Room = match client.get_room(&room_id) { + Some(room) if room.state() == RoomState::Joined => room, + _ => self.join(&client, &room_id).await? + }; + + if let Some(sync) = &self.sync { + sync.room_list_service().set_room_subscriptions(&[room.room_id()]).await; + } + + self.open = None; + let own: OwnedUserId = client.user_id().ok_or_else(|| anyhow!("No user id"))?.to_owned(); + self.open = Some(room::open(room, own, self.emit.clone()).await?); + + Ok(()) + } + + async fn join(&mut self, client: &Client, room_id: &OwnedRoomId) -> Result { + self.emit(Event::Toast("Joining...".into())); + + let via: Vec = self.via.lock().ok().and_then(|m| m.get(room_id).cloned()).unwrap_or_default(); + let room_id: &RoomId = room_id; + let target: &RoomOrAliasId = <&RoomOrAliasId>::from(room_id); + + Ok(client.join_room_by_id_or_alias(target, &via).await?) + } + + async fn send(&mut self, reply_to: Option, text: &str) -> Result<()> { + let text: &str = text.trim(); + + if text.is_empty() { + return Ok(()); + } + + let timeline: Arc = self.timeline()?; + + match reply_to.filter(|str| !str.is_empty()) { + Some(event_id) => { + let content: RoomMessageEventContentWithoutRelation = RoomMessageEventContentWithoutRelation::text_markdown(text); + timeline.send_reply(content, EventId::parse(event_id)?).await?; + } + None => { + timeline.send(RoomMessageEventContent::text_markdown(text).into()).await?; + } + } + + if let Some(open) = &mut self.open { + open.last_typing_sent = None; + + let room: Room = open.room.clone(); + + tokio::spawn(async move { + let _ = room.typing_notice(false).await; + }); + } + + Ok(()) + } + + fn typing(&mut self) { + let Some(open) = &mut self.open else { + return + }; + + if open.last_typing_sent.is_some_and(|time| time.elapsed() < Duration::from_secs(4)) { + return; + } + + open.last_typing_sent = Some(Instant::now()); + + let room: Room = open.room.clone(); + + tokio::spawn(async move { + let _ = room.typing_notice(true).await; + }); + } + +} + +// UI IDs are event ids, or `txn:` for local echoes that haven't been sent yet. +fn parse_item_id(id: &str) -> Result { + Ok(match id.strip_prefix("txn:") { + Some(txn) => TimelineEventItemId::TransactionId(OwnedTransactionId::from(txn)), + None => TimelineEventItemId::EventId(EventId::parse(id)?) + }) +} + +async fn watch_session(client: Client, passphrase: String, emit: Sink) { + let mut rx = client.subscribe_to_session_changes(); + + loop { + match rx.recv().await { + Ok(SessionChange::TokensRefreshed) => { + if let Err(e) = session::save(&client, &passphrase) { + tracing::warn!("Couldn't persist refreshed tokens: {e:#}"); + } + } + + Ok(SessionChange::UnknownToken(_)) => { + session::clear(); + emit(Event::NeedLogin { error: Some("Your session expired or was signed out elsewhere. Please sign in again.".into()) }); + } + + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } +} + +async fn watch_sync(sync: Arc, emit: Sink) { + let mut states = sync.state(); + while let Some(state) = states.next().await { + if let SyncState::Error(e) = state { + tracing::warn!("Sync error: {e}"); + emit(Event::Toast("Lost connection to the server.".into())); + } + } +} diff --git a/src/backend/navigation.rs b/src/backend/navigation.rs new file mode 100644 index 0000000..fcc72f8 --- /dev/null +++ b/src/backend/navigation.rs @@ -0,0 +1,274 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::{Arc, Mutex}, + time::Duration +}; +use eyeball_im::Vector; +use futures_util::StreamExt; +use matrix_sdk::{ + ruma::{OwnedRoomId, OwnedServerName, room::RoomType}, + Client, Room, RoomState +}; +use matrix_sdk_ui::spaces::{room_list::SpaceRoomListPaginationState, SpaceRoom, SpaceRoomList, SpaceService}; + +use tokio::sync::{broadcast::error::RecvError, watch}; +use super::Sink; + +use crate::model::{ChannelKind, ChannelRow, Event, ServerRow}; + +pub type ViaMap = Arc>>>; + +const VOICE_ROOM_TYPE: &str = "org.matrix.msc3417.call"; + +fn is_space(t: Option<&RoomType>) -> bool { + matches!(t, Some(RoomType::Space)) +} + +fn is_voice(t: Option<&RoomType>) -> bool { + t.is_some_and(|t| t.as_str() == VOICE_ROOM_TYPE) +} + +#[derive(Default, Clone, Copy)] +struct Counts { + unread: bool, + mentions: u32 +} + +impl Counts { + fn of(room: &Room) -> Self { + Self { + unread: room.num_unread_messages() > 0 || room.num_unread_notifications() > 0, + mentions: u32::try_from(room.num_unread_mentions()).unwrap_or(u32::MAX), + } + } + + fn add(&mut self, other: Self) { + self.unread |= other.unread; + self.mentions = self.mentions.saturating_add(other.mentions); + } +} + +struct ServerView { + id: OwnedRoomId, + list: SpaceRoomList, + categories: HashMap +} + +async fn paginate_all(list: &SpaceRoomList) { + for _ in 0..10 { + match list.pagination_state() { + SpaceRoomListPaginationState::Idle { end_reached: true } => return, + SpaceRoomListPaginationState::Loading => { + tokio::time::sleep(Duration::from_millis(150)).await; + } + SpaceRoomListPaginationState::Idle { .. } => { + if list.paginate().await.is_err() { + return; + } + } + } + } +} + +impl ServerView { + async fn open(spaces: &SpaceService, id: OwnedRoomId) -> Self { + let list: SpaceRoomList = spaces.space_room_list(id.clone()).await; + paginate_all(&list).await; + Self { id, list, categories: HashMap::new() } + } + + async fn name(&self) -> String { + self.list.space().map_or_else(|| self.id.to_string(), |space| space.display_name) + } + + /// Flatten the space into sidebar rows:
+ /// Loose channels first, then one category per sub-space. + async fn rows(&mut self, client: &Client, spaces: &SpaceService, via: &ViaMap) -> Vec { + let children: Vec = self.list.rooms().await; + let mut rows: Vec = Vec::new(); + + for room in children.iter().filter(|room| !is_space(room.room_type.as_ref())) { + rows.push(channel_row(client, room, via)) + } + + for space in children.iter().filter(|space| is_space(space.room_type.as_ref())) { + if !self.categories.contains_key(&space.room_id) { + let list: SpaceRoomList = spaces.space_room_list(self.id.clone()).await; + paginate_all(&list).await; + self.categories.insert(space.room_id.clone(), list); + } + + rows.push(ChannelRow::category(space.room_id.as_str(), &space.display_name)); + + 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())) { + rows.push(channel_row(client, room, via)) + } + } + } + + rows + } +} + +fn channel_row(client: &Client, room: &SpaceRoom, via: &ViaMap) -> ChannelRow { + let joined: bool = room.state == Some(RoomState::Joined); + let counts: Counts = client.get_room(&room.room_id).map(|room| Counts::of(&room)).unwrap_or_default(); + + if !joined && let Ok(mut map) = via.lock() { + map.insert(room.room_id.clone(), room.via.clone()); + } + + ChannelRow { + id: room.room_id.to_string(), + name: room.display_name.clone(), + kind: if is_voice(room.room_type.as_ref()) { ChannelKind::Voice } else { ChannelKind::Text }, + unread: joined && counts.unread, + mentions: if joined { counts.mentions } else { 0 }, + joined, + is_dm: false + } +} + +/// DMs (most recently active first) and "other rooms" (joined, but in no space). +async fn home_rows(client: &Client, spaces: &SpaceService) -> (Vec, Counts) { + let mut dms: Vec<(u64, ChannelRow)> = Vec::new(); + let mut others: Vec = Vec::new(); + let mut total: Counts = Counts::default(); + + for room in client.joined_rooms() { + if room.is_space() { continue; } + + let counts: Counts = Counts::of(&room); + let name: String = room.display_name().await.map_or_else(|_| room.room_id().to_string(), |name| name.to_string()); + let is_dm: bool = room.is_direct().await.unwrap_or(false); + + let mut row = ChannelRow { + id: room.room_id().to_string(), + name, + kind: if is_voice(room.room_type().as_ref()) { ChannelKind::Voice } else { ChannelKind::Text }, + unread: counts.unread, + mentions: counts.mentions, + joined: true, + is_dm + }; + + if is_dm { + total.add(counts); + + let recency: u64 = room.recency_stamp().map_or(0, u64::from); + dms.push((recency, row)); + } else if spaces.joined_parent_ids_of_child(room.room_id()).await.is_empty() { + total.add(counts); + row.is_dm = false; + others.push(row) + } + } + + dms.sort_by(|a, b| b.0.cmp(&a.0)); + others.sort_by_key(|row| row.name.to_lowercase()); + + let mut rows: Vec = dms.into_iter().map(|(_, row)| row).collect(); + + if !others.is_empty() { + rows.push(ChannelRow::category("other-rooms", "Other rooms")); + rows.extend(others) + } + + (rows, total) +} + +async fn server_counts(client: &Client, spaces: &SpaceService) -> HashMap { + let mut map: HashMap = HashMap::new(); + + for room in client.joined_rooms() { + if room.is_space() { continue; } + + let counts: Counts = Counts::of(&room); + + if !counts.unread && counts.mentions == 0 { continue } + + let ancestors: HashSet = spaces.top_level_ancestors_of(room.room_id()).await; + + for space_id in ancestors { + map.entry(space_id).or_default().add(counts); + } + } + + map +} + +pub async fn run(client: Client, spaces: Arc, mut selected: watch::Receiver>, emit: Sink, via: ViaMap) { + let (initial, stream) = spaces.subscribe_to_top_level_joined_spaces().await; + + let mut top: Vector = initial; + let mut space_stream = Box::pin(stream); + let mut room_updates = client.subscribe_to_all_room_updates(); + let mut view: Option = None; + + loop { + // (Re)open the selected space if the selection changed. + let wanted: Option = selected.borrow_and_update().clone(); + + if view.as_ref().map(|view| &view.id) != wanted.as_ref() { + view = match wanted { + Some(id) => Some(ServerView::open(&spaces, id).await), + None => None + }; + } + + // Rail + let counts: HashMap = server_counts(&client, &spaces).await; + let (home, home_counts) = home_rows(&client, &spaces).await; + let servers: Vec = top.iter() + .map(|room| { + let c = counts.get(&room.room_id).copied().unwrap_or_default(); + + ServerRow { + id: room.room_id.to_string(), + name: room.display_name.clone(), + unread: c.unread, + mentions: c.mentions + } + }).collect(); + + emit(Event::Servers { servers, home_unread: home_counts.unread, home_mentions: home_counts.mentions }); + + // Sidebar + match view.as_mut() { + Some(view) => { + let title: String = view.name().await; + let channels: Vec = view.rows(&client, &spaces, &via).await; + emit(Event::Channels { title, channels }); + } + + None => emit(Event::Channels { title: "Direct Messages".to_owned(), channels: home }) + } + + // Sleeping until something changes. + tokio::select! { + changed = selected.changed() => { + if changed.is_err() { break; } + } + + differences = space_stream.next() => { + match differences { + Some(differences) => for difference in differences { difference.apply(&mut top) }, + None => break + } + } + + update = room_updates.recv() => { + match update { + Err(RecvError::Closed) => break, + + Ok(_) | Err(RecvError::Lagged(_)) => { + // Debounce, sliding sync can fire many updates in a short burst. + tokio::time::sleep(Duration::from_millis(250)).await; + while room_updates.try_recv().is_ok() {} + } + } + } + } + } +} \ No newline at end of file diff --git a/src/backend/room.rs b/src/backend/room.rs new file mode 100644 index 0000000..526a587 --- /dev/null +++ b/src/backend/room.rs @@ -0,0 +1,323 @@ +use std::{ + sync::{Arc, atomic::{AtomicBool, Ordering}}, + time::{Duration, Instant}, +}; + +use matrix_sdk::{ + ruma::{ + OwnedUserId, UserId, + api::client::receipt::create_receipt::v3::ReceiptType, + events::room::{message::MessageType, power_levels::UserPowerLevel}, + }, + Room, RoomMemberships, RoomDisplayName +}; + +use matrix_sdk_ui::timeline::{ + MembershipChange, MsgLikeKind, Profile, RoomExt, Timeline, TimelineDetails, TimelineItem, + 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}; + + +const GROUP_WINDOW_MS: u64 = 7 * 60 * 1000; +const PAGE_SIZE: u16 = 30; + +pub struct OpenRoom { + pub room: Room, + pub timeline: Arc, + pub loading_older: Arc, + pub reached_start: Arc, + pub last_typing_sent: Option, + + /// Dropping `OpenRoom` will abort these. + _tasks: Vec +} + +pub async fn open(room: Room, own: OwnedUserId, emit: Sink) -> Result { + let name: String = room.display_name().await.map_or_else(|_| room.room_id().to_string(), |name: RoomDisplayName| name.to_string()); + + emit(Event::RoomOpened(RoomInfo { + id: room.room_id().to_string(), + name, + topic: room.topic().unwrap_or_default(), + is_dm: room.is_direct().await.unwrap_or(false), + encrypted: room.encryption_state().is_encrypted() + })); + + let timeline: Arc = Arc::new(room.timeline().await?); + let loading_older: Arc = Arc::new(AtomicBool::new(false)); + let reached_start: Arc = Arc::new(AtomicBool::new(false)); + + let tasks: Vec = vec![ + Task::spawn(messages_task(timeline.clone(), own, emit.clone(), loading_older.clone(), reached_start.clone())), + Task::spawn(members_task(room.clone(), emit.clone())), + Task::spawn(typing_task(room.clone(), emit)), + ]; + + Ok(OpenRoom { room, timeline, loading_older, reached_start, last_typing_sent: None, _tasks: tasks }) +} + +/// Fetch one more page of history. +pub async fn load_older(timeline: Arc, loading: Arc, reached_start: Arc, emit: Sink) { + if reached_start.load(Ordering::Relaxed) || loading.swap(true, Ordering::AcqRel) { + return; + } + + emit(Event::LoadingOlder(true)); + match timeline.paginate_backwards(PAGE_SIZE).await { + Ok(true) => reached_start.store(true, Ordering::Relaxed), + Ok(false) => {} + Err(error) => tracing::warn!("Pagination failed: {error}") + } + + loading.store(false, Ordering::Release); + emit(Event::LoadingOlder(false)); +} + +async fn messages_task(timeline: Arc, own: OwnedUserId, emit: Sink, loading: Arc, reached_start: Arc) { + let (mut items, stream) = timeline.subscribe().await; + let mut stream = Box::pin(stream); + + emit(Event::Messages(render(&items, &own))); + + // Backfilling so a freshly opened room isn't nearly empty. + tokio::spawn(load_older(timeline.clone(), loading, reached_start, emit.clone())); + + while let Some(differences) = stream.next().await { + for difference in differences { + difference.apply(&mut items); + } + + emit(Event::Messages(render(&items, &own))); + + // The room is on screen, so we should treat everything as read. + // TODO: Should happen ONLY when the window is focused AND scrolled to the bottom. + let t: Arc = timeline.clone(); + + tokio::spawn(async move { + let _ = t.mark_as_read(ReceiptType::Read).await; + }); + } +} + +// ************************************************* // +// Timeline // +// ************************************************* // + +fn name_of(profile: &TimelineDetails, id: &UserId) -> String { + match profile { + TimelineDetails::Ready(profile) => profile.display_name.clone().unwrap_or_else(|| id.localpart().to_owned()), + _ => id.localpart().to_owned() + } +} + +fn body_of(kind: &MsgLikeKind) -> Option { + Some(match kind { + MsgLikeKind::Message(message) => match message.msgtype() { + MessageType::Image(content) => format!("[image] {}", content.body), + MessageType::File(content) => format!("[file] {}", content.body), + MessageType::Video(content) => format!("[video] {}", content.body), + MessageType::Audio(content) => format!("[audio] {}", content.body), + MessageType::Location(content) => format!("[location] {}", content.body), + MessageType::Emote(content) => format!("/me {}", content.body), + _ => message.body().to_owned() + }, + + MsgLikeKind::Sticker(_) => "[sticker]".to_owned(), + MsgLikeKind::Poll(_) => "[poll]".to_owned(), + MsgLikeKind::Redacted => "This message was deleted".to_owned(), + MsgLikeKind::UnableToDecrypt(_) => "Unable to decrypt this message (yet).".to_owned(), + _ => return None + }) +} + +fn render(items: &Vector>, own: &UserId) -> Vec { + let mut out: Vec = Vec::with_capacity(items.len()); + let mut previous: Option<(String, u64)> = None; // (sender id, timestamp ms) of previous message row. + + for item in items { + match item.kind() { + TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(timestamp)) => { + out.push(MessageRow::plain(MessageKind::DateDivider, format::date_label(*timestamp))); + previous = None; + } + + TimelineItemKind::Virtual(VirtualTimelineItem::TimelineStart) => { + out.push(MessageRow::plain(MessageKind::StartOfRoom, String::new())); + previous = None; + } + + TimelineItemKind::Virtual(_) => {} + + TimelineItemKind::Event(event) => { + let sender_id: &UserId = event.sender(); + let sender: String = name_of(event.sender_profile(), sender_id); + let timestamp: MilliSecondsSinceUnixEpoch = event.timestamp(); + + match event.content() { + TimelineItemContent::MsgLike(content) => { + let Some(body) = body_of(&content.kind) else { continue }; + + let timestamp_ms: u64 = u64::from(timestamp.0); + let same_group: bool = previous.as_ref().is_some_and(|(id, t)| { + id == sender_id.as_str() && timestamp_ms.saturating_sub(*t) < GROUP_WINDOW_MS + }); + + let reply = content.in_reply_to.as_ref().map(|reply| match &reply.event { + TimelineDetails::Ready(event) => { + let who: String = name_of(&event.sender_profile, &event.sender); + let what: String = match &event.content { + TimelineItemContent::MsgLike(message) => body_of(&message.kind).unwrap_or_default(), + _ => String::new() + }; + + // TODO: Make max characters configurable. + (who, format::truncate(&what, 80)) + } + + _ => ("a message".to_owned(), String::new()) + }); + + let reactions: Vec = content.reactions + .iter() + .map(|(key, senders)| ReactionRow { + key: key.clone(), + count: u32::try_from(senders.len()).unwrap_or(u32::MAX), + mine: senders.contains_key(own), + }) + .collect(); + + let id: String = match event.event_id() { + Some(event) => event.to_string(), + None => event.transaction_id().map(|transaction| format!("txn:{transaction}")).unwrap_or_default() + }; + + out.push(MessageRow { + id, + kind: MessageKind::Message, + sender, + sender_id: sender_id.to_string(), + body, + time: format::message_time(timestamp), + short_time: format::short_time(timestamp), + show_header: !same_group || reply.is_some(), + edited: matches!(&content.kind, MsgLikeKind::Message(message) if message.is_edited()), + own: event.is_own(), + pending: event.is_local_echo(), + reply, + reactions + }); + + previous = Some((sender_id.to_string(), timestamp_ms)); + } + + TimelineItemContent::MembershipChange(change) => { + let verb: &str = match change.change() { + Some(MembershipChange::Joined | MembershipChange::InvitationAccepted) => "joined", + Some(MembershipChange::Left) => "left", + Some(MembershipChange::Invited) => "was invited", + Some(MembershipChange::Kicked) => "was removed", + Some(MembershipChange::Banned) => "was banned", + _ => continue + }; + + let who: String = change.display_name().unwrap_or_else(|| change.user_id().localpart().to_owned()); + out.push(MessageRow::plain(MessageKind::Notice, format!("{who} {verb}"))); + previous = None; + } + + // Room renames, topic changes, etc. + // TODO: Render as notices. + _ => {} + } + } + } + } + + out +} + +// ************************************************* // +// Members // +// ************************************************* // + +async fn members_task(room: Room, emit: Sink) { + let _ = room.sync_members().await; + + loop { + if let Ok(members) = room.members(RoomMemberships::JOIN).await { + emit(Event::Members(group_members(members))) + } + + // TODO: react to membership changes instead of polling. + tokio::time::sleep(Duration::from_secs(30)).await; + } +} + +fn power_of(level: UserPowerLevel) -> i64 { + match level { + UserPowerLevel::Infinite => i64::MAX, + UserPowerLevel::Int(i) => i64::from(i), + _ => 0 + } +} + +fn group_members(members: Vec) -> Vec { + let mut groups: [(&str, Vec); 3] = [("ADMINS", Vec::new()), ("MODERATORS", Vec::new()), ("MEMBERS", Vec::new())]; + + for member in members { + let level: i64 = power_of(member.power_level()); + let idx: usize = if level >= 100 { 0 } else if level >= 50 { 1 } else { 2 }; + let name: String = member.display_name().map_or_else(|| member.user_id().localpart().to_owned(), ToOwned::to_owned); + groups[idx].1.push(MemberRow { id: member.user_id().to_string(), name, is_header: false }); + } + + let mut out: Vec = Vec::new(); + + for (title, mut rows) in groups { + if rows.is_empty() { continue; } + + rows.sort_by_key(|row| row.name.to_lowercase()); + out.push(MemberRow { id: String::new(), name: format!("{title} - {}", rows.len()), is_header: true }); + out.extend(rows); + } + + out +} + +// ************************************************* // +// Typing // +// ************************************************* // + +async fn typing_task(room: Room, emit: Sink) { + let (_guard, mut rx) = room.subscribe_to_typing_notifications(); + + loop { + match rx.recv().await { + Ok(ids) => { + let mut names: Vec = Vec::new(); + + for id in ids { + let name: Option = match room.get_member_no_sync(&id).await { + Ok(Some(member)) => member.display_name().map(ToOwned::to_owned), + _ => None + }; + + names.push(name.unwrap_or_else(|| id.localpart().to_owned())); + } + + emit(Event::Typing(names)) + } + + Err(RecvError::Lagged(_)) => {} + Err(RecvError::Closed) => break + } + } +} \ No newline at end of file diff --git a/src/backend/session.rs b/src/backend/session.rs new file mode 100644 index 0000000..20b50a7 --- /dev/null +++ b/src/backend/session.rs @@ -0,0 +1,119 @@ +use matrix_sdk::{Client, authentication::matrix::MatrixSession, store::RoomLoadSettings}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +use std::{fs, path::PathBuf}; + +#[derive(Serialize, Deserialize)] +pub struct SavedSession { + pub homeserver: String, + pub store_passphrase: String, + pub session: MatrixSession, +} + +pub fn data_dir() -> PathBuf { + let base: PathBuf = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); + + // 'TOFU_DATA_DIR' so you can run several accounts side by side during development. + std::env::var_os("TOFU_DATA_DIR").map_or_else(|| base.join("tofu"), PathBuf::from) +} + +fn session_file() -> PathBuf { + data_dir().join("session.json") +} + +fn store_dir() -> PathBuf { + data_dir().join("store") +} + +fn random_passphrase() -> String { + hex::encode(rand::random::<[u8; 32]>()) +} + +pub fn load() -> Result> { + let path: PathBuf = session_file(); + + if !path.exists() { + return Ok(None); + } + + let raw: String = fs::read_to_string(&path).with_context(|| format!("Reading '{}'.", path.display()))?; + Ok(Some(serde_json::from_str(&raw).context("Parsing 'session.json'.")?)) +} + +pub fn save(client: &Client, store_passphrase: &str) -> Result<()> { + let Some(session) = client.matrix_auth().session() else { bail!("Client has no Matrix session to save."); }; + + let saved = SavedSession { + homeserver: client.homeserver().to_string(), + store_passphrase: store_passphrase.to_owned(), + session + }; + + fs::create_dir_all(data_dir())?; + + let path: PathBuf = session_file(); + + fs::write(&path, serde_json::to_vec_pretty(&saved)?)?; + + #[cfg(unix)] { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; + } + + Ok(()) +} + +/// Forget stored account.
+/// Deletes tokens and the local store. +pub fn clear() { + let _ = fs::remove_file(session_file()); + let _ = fs::remove_dir_all(store_dir()); +} + +pub async fn login(server: &str, username: &str, password: &str) -> Result<(Client, String)> { + let server: &str = server.trim(); + let server: &str = if server.is_empty() { "matrix.org" } else { server }; + + clear(); // A new login should replace any previous login's local data. + + fs::create_dir_all(data_dir())?; + + let passphrase: String = random_passphrase(); + + let client: Client = Client::builder() + .server_name_or_homeserver_url(server) + .sqlite_store(store_dir(), Some(&passphrase)) + .handle_refresh_tokens() + .build() + .await + .with_context(|| format!("Couldn't reach homeserver '{server}'."))?; + + client.matrix_auth() + .login_username(username.trim(), password) + .initial_device_display_name("Tofu") + .send() + .await + .context("Login failed (check username or password; some servers only allow SS0/0IDC login)")?; + + let _ = save(&client, &passphrase); + + Ok((client, passphrase)) +} + +/// Rebuild the client from a saved session. +pub async fn restore(saved: &SavedSession) -> Result { + let client = Client::builder() + .homeserver_url(&saved.homeserver) + .sqlite_store(store_dir(), Some(&saved.store_passphrase)) + .handle_refresh_tokens() + .build() + .await?; + + client.matrix_auth() + .restore_session(saved.session.clone(), RoomLoadSettings::default()) + .await?; + + Ok(client) +} \ No newline at end of file diff --git a/src/bridge.rs b/src/bridge.rs new file mode 100644 index 0000000..fb81c68 --- /dev/null +++ b/src/bridge.rs @@ -0,0 +1,278 @@ +use std::time::Duration; + +use slint::{Color, ComponentHandle, ModelRc, SharedString, Timer, VecModel}; +use tokio::sync::mpsc::UnboundedSender; + +use crate::{ + ChannelItem, MainWindow, MemberItem, MessageItem, ReactionItem, Screen, ServerItem, + model::{self, Command, Event, hue_for, initial_of}, +}; + +fn color_for(id: &str) -> Color { + Color::from_hsva(hue_for(id), 0.45, 0.55, 1.0) +} + +fn model_of(items: Vec) -> ModelRc { + ModelRc::new(VecModel::from(items)) +} + +pub fn toast(ui: &MainWindow, text: &str) { + ui.set_toast(text.into()); + + let weak = ui.as_weak(); + + Timer::single_shot(Duration::from_secs(4), move || { + if let Some(ui) = weak.upgrade() { + ui.set_toast(SharedString::new()); + } + }); +} + +fn typing_text(names: &[String]) -> String { + match names { + [] => String::new(), + [a] => format!("{a} is typing..."), + [a, b] => format!("{a} and {b} are typing..."), + _ => "Several people are typing...".to_owned() + } +} + +#[allow(clippy::too_many_lines)] +pub fn apply(ui: &MainWindow, event: Event) { + match event { + Event::NeedLogin { error } => { + ui.set_login_error(error.unwrap_or_default().into()); + ui.set_screen(Screen::Login); + } + + Event::LoginBusy(busy) => { + ui.set_login_busy(busy) + } + + Event::LoggedIn(user) => { + ui.set_me_color(color_for(&user.id)); + ui.set_me_initial(initial_of(&user.name).into()); + ui.set_me_name(user.name.into()); + ui.set_me_id(user.id.into()); + ui.set_login_error(SharedString::new()); + ui.set_screen(Screen::Main); + } + + Event::Servers { servers, home_unread, home_mentions } => { + let items: Vec = servers.into_iter() + .map(|server| ServerItem { + color: color_for(&server.id), + initial: initial_of(&server.name).into(), + id: server.id.into(), + name: server.name.into(), + unread: server.unread, + mentions: i32::try_from(server.mentions).unwrap_or(i32::MAX) + }).collect(); + + ui.set_servers(model_of(items)); + ui.set_home_unread(home_unread); + ui.set_home_mentions(i32::try_from(home_mentions).unwrap_or(i32::MAX)); + } + + Event::Channels { title, channels } => { + let selected: String = ui.get_selected_room_id().to_string(); + + let first_openable: Option = channels.iter() + .find(|channel| channel.kind != model::ChannelKind::Category && channel.kind != model::ChannelKind::Voice) + .map(|channel| channel.id.clone()); + + let still_here: bool = channels.iter().any(|channel| channel.id == selected); + + let items: Vec = channels.into_iter() + .map(|channel| ChannelItem { + color: color_for(&channel.id), + initial: initial_of(&channel.name).into(), + kind: match channel.kind { + model::ChannelKind::Text => crate::ChannelKind::Text, + model::ChannelKind::Voice => crate::ChannelKind::Voice, + model::ChannelKind::Category => crate::ChannelKind::Category, + }, + id: channel.id.into(), + name: channel.name.into(), + topic: SharedString::new(), + unread: channel.unread, + mentions: i32::try_from(channel.mentions).unwrap_or(i32::MAX), + joined: channel.joined, + is_dm: channel.is_dm + }).collect(); + + ui.set_sidebar_title(title.into()); + ui.set_channels(model_of(items)); + + if !still_here { + match first_openable { + Some(id) => { + ui.set_selected_room_id(id.clone().into()); + ui.invoke_select_room(id.into()); + } + + None => { + ui.set_selected_room_id(SharedString::new()); + ui.set_room_open(false); + ui.set_room_name(SharedString::new()); + ui.set_room_topic(SharedString::new()); + ui.set_messages(model_of(Vec::new())); + ui.set_members(model_of(Vec::new())); + } + } + } + } + + Event::RoomOpened(info) => { + ui.set_room_name(info.name.into()); + ui.set_room_topic(info.topic.into()); + ui.set_room_is_dm(info.is_dm); + ui.set_room_encrypted(info.encrypted); + ui.set_room_open(true); + ui.set_selected_room_id(info.id.into()); + ui.set_messages(model_of(Vec::new())); + ui.set_members(model_of(Vec::new())); + ui.set_typing_text(SharedString::new()); + ui.set_loading_older(false); + ui.set_reply_to_id(SharedString::new()); + ui.set_editing_id(SharedString::new()); + ui.set_room_nonce(ui.get_room_nonce().wrapping_add(1)); + } + + Event::Messages(rows) => { + let items: Vec = rows.into_iter().map(message_item).collect(); + ui.set_messages(model_of(items)); + } + + Event::Members(rows) => { + let items: Vec = rows.into_iter() + .map(|member| MemberItem { + color: color_for(&member.id), + initial: initial_of(&member.name).into(), + id: member.id.into(), + name: member.name.into(), + is_header: member.is_header, + }).collect(); + + ui.set_members(model_of(items)); + } + + Event::Typing(names) => ui.set_typing_text(typing_text(&names).into()), + Event::LoadingOlder(b) => ui.set_loading_older(b), + Event::Toast(text) => toast(ui, &text) + } +} + +fn message_item(message: model::MessageRow) -> MessageItem { + let (has_reply, reply_sender, reply_body) = match message.reply { + Some((who, what)) => (true, who, what), + None => (false, String::new(), String::new()), + }; + + MessageItem { + kind: match message.kind { + model::MessageKind::Message => crate::MessageKind::Message, + model::MessageKind::DateDivider => crate::MessageKind::DateDivider, + model::MessageKind::Notice => crate::MessageKind::Notice, + model::MessageKind::StartOfRoom => crate::MessageKind::StartOfRoom, + }, + color: color_for(&message.sender_id), + initial: initial_of(&message.sender).into(), + id: message.id.into(), + sender: message.sender.into(), + body: message.body.into(), + time: message.time.into(), + short_time: message.short_time.into(), + show_header: message.show_header, + edited: message.edited, + own: message.own, + pending: message.pending, + has_reply, + reply_sender: reply_sender.into(), + reply_body: reply_body.into(), + reactions: model_of( + message.reactions.into_iter() + .map(|reaction| ReactionItem { + key: reaction.key.into(), + count: i32::try_from(reaction.count).unwrap_or(i32::MAX), + mine: reaction.mine, + }).collect(), + ), + } +} + +pub fn wire(ui: &MainWindow, tx: &UnboundedSender) { + let send = |tx: &UnboundedSender, cmd: Command| { + let _ = tx.send(cmd); + }; + + ui.on_login({ + let tx = tx.clone(); + + move |homeserver, username, password| { + send(&tx, Command::Login { + homeserver: homeserver.into(), + username: username.into(), + password: password.into(), + }); + } + }); + + ui.on_logout({ + let tx = tx.clone(); + move || send(&tx, Command::Logout) + }); + + ui.on_select_server({ + let tx = tx.clone(); + move |id| send(&tx, Command::SelectServer(Some(id.to_string()).filter(|s| !s.is_empty()))) + }); + + ui.on_select_room({ + let tx = tx.clone(); + move |id| send(&tx, Command::SelectRoom(id.into())) + }); + + ui.on_send_message({ + let tx = tx.clone(); + move |reply_to, text| { + send(&tx, Command::Send { + reply_to: Some(reply_to.to_string()).filter(|s| !s.is_empty()), + text: text.into(), + }); + } + }); + + ui.on_edit_message({ + let tx = tx.clone(); + move |id, text| send(&tx, Command::Edit { id: id.into(), text: text.into() }) + }); + + ui.on_delete_message({ + let tx = tx.clone(); + move |id| send(&tx, Command::Delete { id: id.into() }) + }); + + ui.on_toggle_reaction({ + let tx = tx.clone(); + move |id, key| send(&tx, Command::ToggleReaction { id: id.into(), key: key.into() }) + }); + + ui.on_load_older({ + let tx = tx.clone(); + move || send(&tx, Command::LoadOlder) + }); + + ui.on_typing({ + let tx = tx.clone(); + move || send(&tx, Command::Typing) + }); + + let weak = ui.as_weak(); + + ui.on_not_implemented(move |what| { + if let Some(ui) = weak.upgrade() { + toast(&ui, &format!("{what} isn't built yet.")); + } + }); +} diff --git a/src/main.rs b/src/main.rs index cf96d86..e89c9c2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,41 @@ -use std::error::Error; +#![recursion_limit = "256"] + +mod backend; +mod model; +pub mod bridge; + +use std::sync::Arc; +use slint::Weak; +use tracing_subscriber::EnvFilter; slint::include_modules!(); -fn main() -> Result<(), Box> { +fn main() -> anyhow::Result<()> { + let _ = dotenvy::dotenv(); + + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn,Tofu=info"))) + .init(); + + let runtime = tokio::runtime::Runtime::new()?; let ui = MainWindow::new()?; - ui.run()?; + if let Ok(homeserver) = std::env::var("TOFU_HOMESERVER") { + ui.set_login_homeserver(homeserver.into()); + } + if let Ok(username) = std::env::var("TOFU_USERNAME") { + ui.set_login_username(username.into()); + } + + let weak: Weak = ui.as_weak(); + let sink: backend::Sink = Arc::new(move |event| { + let _ = weak.upgrade_in_event_loop(move |ui| bridge::apply(&ui, event)); + }); + + let commands = backend::spawn(runtime.handle(), sink); + bridge::wire(&ui, &commands); + + ui.run()?; Ok(()) } diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..ad70adc --- /dev/null +++ b/src/model.rs @@ -0,0 +1,171 @@ + +/// Deterministic hue for a user/room/space id, so each gets a stable avatar colour even before avatars are loaded. +pub fn hue_for(id: &str) -> f32 { + let mut hash: u32 = 0x811c_9dc5; + + for b in id.bytes() { + hash ^= u32::from(b); + hash = hash.wrapping_mul(0x0100_0193); + } + + #[allow(clippy::cast_precision_loss)] { + (hash % 360) as f32 + } +} + +pub fn initial_of(name: &str) -> String { + name.trim_start_matches(['@', '#', '!']) + .chars() + .find(|c| c.is_alphanumeric()) + .map(|c| c.to_uppercase().to_string()) + .unwrap_or_else(|| "?".to_owned()) +} + +#[derive(Debug, Clone)] +pub struct UserInfo { + pub id: String, + pub name: String +} + +// A "server" equals a top-level Matrix space. +#[derive(Debug, Clone)] +pub struct ServerRow { + pub id: String, + pub name: String, + pub unread: bool, + pub mentions: u32 +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChannelKind { + Text, Voice, Category +} + +// A "channel" equals a Matrix room. +#[derive(Debug, Clone)] +pub struct ChannelRow { + pub id: String, + pub name: String, + pub kind: ChannelKind, + pub unread: bool, + pub mentions: u32, + pub joined: bool, + pub is_dm: bool +} + +impl ChannelRow { + pub fn category(id: &str, name: &str) -> Self { + Self { + id: id.to_owned(), + name: name.to_uppercase(), + kind: ChannelKind::Category, + unread: false, + mentions: 0, + joined: true, + is_dm: false + } + } +} + +#[derive(Debug, Clone)] +pub struct RoomInfo { + pub id: String, + pub name: String, + pub topic: String, + pub is_dm: bool, + pub encrypted: bool +} + +#[derive(Debug, Clone)] +pub struct ReactionRow { + pub key: String, + pub count: u32, + pub mine: bool +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessageKind { + Message, DateDivider, Notice, StartOfRoom +} + +#[derive(Debug, Clone)] +pub struct MessageRow { + pub id: String, + pub kind: MessageKind, + pub sender: String, + pub sender_id: String, + pub body: String, + pub time: String, + pub short_time: String, + pub show_header: bool, + pub edited: bool, + pub own: bool, + pub pending: bool, + pub reply: Option<(String, String)>, + pub reactions: Vec +} + +impl MessageRow { + pub fn plain(kind: MessageKind, body: String) -> Self { + Self { + id: String::new(), + kind, + sender: String::new(), + sender_id: String::new(), + body, + time: String::new(), + short_time: String::new(), + show_header: false, + edited: false, + own: false, + pending: false, + reply: None, + reactions: Vec::new() + } + } +} + +#[derive(Debug, Clone)] +pub struct MemberRow { + pub id: String, + pub name: String, + pub is_header: bool +} + +/// Everything the backend can tell the frontend. +#[derive(Debug, Clone)] +pub enum Event { + NeedLogin { error: Option }, + LoginBusy(bool), + LoggedIn(UserInfo), + Servers { servers: Vec, home_unread: bool, home_mentions: u32 }, + /// Channel (or DM) list for the currently selected server / Home. + Channels { title: String, channels: Vec }, + RoomOpened(RoomInfo), + Messages(Vec), + Members(Vec), + Typing(Vec), + LoadingOlder(bool), + + /// Short message for the toast banner. + Toast(String) +} + +/// Everything the frontend can ask the backend. +#[derive(Debug, Clone)] +pub enum Command { + Login { homeserver: String, username: String, password: String }, + Logout, + + /// `None` = Home, DMs or Rooms outside any space. + SelectServer(Option), + SelectRoom(String), + Send { reply_to: Option, text: String }, + Edit { id: String, text: String }, + Delete { id: String }, + ToggleReaction { id: String, key: String }, + LoadOlder, + + /// The user is typing in the composer. (Throttled by the backend). + Typing +} \ No newline at end of file diff --git a/ui/components/avatar.slint b/ui/components/avatar.slint new file mode 100644 index 0000000..94e509a --- /dev/null +++ b/ui/components/avatar.slint @@ -0,0 +1,26 @@ +/// TEMPORARY AI GENERATED FRONTEND \\\ +/// NOT TO BE USED IN PRODUCTION \\\ + +import { Theme } from "../theme.slint"; + +// Coloured circle with an initial. Real avatar images (mxc:// media) are a +// planned follow-up; this is also the fallback when a user has no avatar. +export component Avatar inherits Rectangle { + in property initial; + in property tint: Theme.accent; + in property diameter: 40px; + + width: diameter; + height: diameter; + border-radius: diameter / 2; + background: tint; + + Text { + text: root.initial; + color: white; + font-size: root.diameter * 0.42; + font-weight: 600; + horizontal-alignment: center; + vertical-alignment: center; + } +} diff --git a/ui/components/badge.slint b/ui/components/badge.slint new file mode 100644 index 0000000..b733c0e --- /dev/null +++ b/ui/components/badge.slint @@ -0,0 +1,24 @@ +/// TEMPORARY AI GENERATED FRONTEND \\\ +/// NOT TO BE USED IN PRODUCTION \\\ + +import { Theme } from "../theme.slint"; + +// Red mention counter ("3", "99+"). +export component Badge inherits Rectangle { + in property count; + + visible: count > 0; + height: 16px; + width: max(16px, label.preferred-width + 10px); + border-radius: 8px; + background: Theme.danger; + + label := Text { + text: root.count > 99 ? "99+" : "\{root.count}"; + color: white; + font-size: 11px; + font-weight: 700; + horizontal-alignment: center; + vertical-alignment: center; + } +} diff --git a/ui/components/channel-list.slint b/ui/components/channel-list.slint new file mode 100644 index 0000000..321c6d4 --- /dev/null +++ b/ui/components/channel-list.slint @@ -0,0 +1,175 @@ +/// TEMPORARY AI GENERATED FRONTEND \\\ +/// NOT TO BE USED IN PRODUCTION \\\ + +import { ListView } from "std-widgets.slint"; +import { Theme } from "../theme.slint"; +import { Icons } from "../icons.slint"; +import { ChannelItem, ChannelKind } from "../types.slint"; +import { Icon } from "icon.slint"; +import { Avatar } from "avatar.slint"; +import { Badge } from "badge.slint"; + +component ChannelRow inherits Rectangle { + in property c; + in property selected; + callback clicked(); + + height: c.kind == ChannelKind.category ? 34px : (c.is-dm ? 44px : 34px); + opacity: c.joined || c.kind == ChannelKind.category ? 1.0 : 0.5; + + // --- category heading ("TEXT CHANNELS") --- + if c.kind == ChannelKind.category: Text { + x: 16px; + y: 0px; + width: parent.width - 32px; + height: parent.height - 4px; + text: c.name; + color: Theme.text-muted; + font-size: 12px; + font-weight: 700; + vertical-alignment: bottom; + overflow: elide; + } + + // --- text / voice channel --- + if c.kind != ChannelKind.category && !c.is-dm: Rectangle { + x: 8px; + y: 1px; + width: parent.width - 16px; + height: parent.height - 2px; + border-radius: 4px; + background: root.selected ? Theme.selected-bg : (touch.has-hover ? Theme.hover-bg : transparent); + + Icon { + source: c.kind == ChannelKind.voice ? Icons.speaker : Icons.hash; + tint: root.selected || c.unread ? Theme.text-header : Theme.text-muted; + icon-size: 18px; + x: 8px; + y: (parent.height - self.height) / 2; + } + + Text { + x: 34px; + width: parent.width - 34px - (c.mentions > 0 ? 40px : 8px); + height: parent.height; + text: c.name; + color: root.selected || c.unread || touch.has-hover ? Theme.text-header : Theme.text-muted; + font-weight: c.unread ? 700 : 500; + vertical-alignment: center; + overflow: elide; + } + + Badge { + count: c.mentions; + x: parent.width - self.width - 8px; + y: (parent.height - self.height) / 2; + } + } + + // --- direct message --- + if c.is-dm && c.kind != ChannelKind.category: Rectangle { + x: 8px; + y: 1px; + width: parent.width - 16px; + height: parent.height - 2px; + border-radius: 4px; + background: root.selected ? Theme.selected-bg : (touch.has-hover ? Theme.hover-bg : transparent); + + Avatar { + initial: c.initial; + tint: c.color; + diameter: 32px; + x: 8px; + y: (parent.height - self.height) / 2; + } + + Text { + x: 50px; + width: parent.width - 50px - (c.mentions > 0 ? 40px : 8px); + height: parent.height; + text: c.name; + color: root.selected || c.unread || touch.has-hover ? Theme.text-header : Theme.text-muted; + font-weight: c.unread ? 700 : 500; + vertical-alignment: center; + overflow: elide; + } + + Badge { + count: c.mentions; + x: parent.width - self.width - 8px; + y: (parent.height - self.height) / 2; + } + } + + touch := TouchArea { + enabled: c.kind != ChannelKind.category; + mouse-cursor: pointer; + clicked => { + root.clicked(); + } + } +} + +export component ChannelList inherits Rectangle { + in property title; + in property is-home; + in property <[ChannelItem]> channels; + in property selected-id; + callback select(string); + callback open-server-menu(); + + width: Theme.sidebar-width; + background: Theme.sidebar-bg; + + VerticalLayout { + // Header: server name (or "Direct Messages") + Rectangle { + height: Theme.header-height; + background: header-touch.has-hover && !root.is-home ? Theme.hover-bg : transparent; + + Text { + x: 16px; + width: parent.width - 52px; + height: parent.height; + text: root.title; + color: Theme.text-header; + font-size: 15px; + font-weight: 700; + vertical-alignment: center; + overflow: elide; + } + + if !root.is-home: Icon { + source: Icons.chevron-down; + tint: Theme.text-header; + icon-size: 16px; + x: parent.width - 16px - self.width; + y: (parent.height - self.height) / 2; + } + + // bottom hairline + Rectangle { + y: parent.height - 1px; + height: 1px; + background: Theme.rail-bg; + } + + header-touch := TouchArea { + enabled: !root.is-home; + clicked => { + root.open-server-menu(); + } + } + } + + ListView { + for c in root.channels: ChannelRow { + c: c; + selected: c.id == root.selected-id; + clicked => { + root.select(c.id); + } + } + } + } +} diff --git a/ui/components/chat-header.slint b/ui/components/chat-header.slint new file mode 100644 index 0000000..c375fef --- /dev/null +++ b/ui/components/chat-header.slint @@ -0,0 +1,141 @@ +/// TEMPORARY AI GENERATED FRONTEND \\\ +/// NOT TO BE USED IN PRODUCTION \\\ + +import { Theme } from "../theme.slint"; +import { Icons } from "../icons.slint"; +import { Icon } from "icon.slint"; +import { ToolButton } from "tool-button.slint"; + +// ToolButton has a fixed height, so wrap it to centre it in the 48px header. +component HeaderTool inherits Rectangle { + in property glyph; + in property active; + callback clicked(); + width: 32px; + ToolButton { + glyph: root.glyph; + active: root.active; + y: (parent.height - self.height) / 2; + clicked => { + root.clicked(); + } + } +} + +export component ChatHeader inherits Rectangle { + in property room-name; + in property topic; + in property is-dm; + in property encrypted; + in-out property show-members; + callback open-threads(); + callback open-pins(); + callback open-search(); + + height: Theme.header-height; + background: Theme.chat-bg; + + Rectangle { + y: parent.height - 1px; + height: 1px; + background: Theme.rail-bg; + } + + // Left: "# name | topic" + HorizontalLayout { + padding-left: 16px; + padding-right: 8px; + spacing: 8px; + + Rectangle { + width: 22px; + if !root.is-dm: Icon { + source: Icons.hash; + tint: Theme.text-muted; + icon-size: 22px; + y: (parent.height - self.height) / 2; + } + if root.is-dm: Text { + text: "@"; + color: Theme.text-muted; + font-size: 20px; + font-weight: 700; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + Text { + text: root.room-name; + color: Theme.text-header; + font-size: 16px; + font-weight: 700; + vertical-alignment: center; + overflow: elide; + width: min(self.preferred-width, 280px); + } + + if root.encrypted: Rectangle { + width: 16px; + Icon { + source: Icons.lock; + tint: Theme.text-muted; + icon-size: 14px; + y: (parent.height - self.height) / 2; + } + } + + if root.topic != "": Rectangle { + width: 1px; + Rectangle { + width: 1px; + height: 24px; + y: (parent.height - self.height) / 2; + background: Theme.divider; + } + } + + if root.topic != "": Text { + text: root.topic; + color: Theme.text-muted; + font-size: 14px; + vertical-alignment: center; + overflow: elide; + } + + Rectangle { + width: 0px; + horizontal-stretch: 1; + } + + // Right: toolbar. These open panels that aren't built yet (see docs/DISCORD-PARITY.md). + HeaderTool { + glyph: Icons.thread; + clicked => { + root.open-threads(); + } + } + + HeaderTool { + glyph: Icons.pin; + clicked => { + root.open-pins(); + } + } + + HeaderTool { + glyph: Icons.users; + active: root.show-members; + clicked => { + root.show-members = !root.show-members; + } + } + + HeaderTool { + glyph: Icons.search; + clicked => { + root.open-search(); + } + } + } +} diff --git a/ui/components/composer.slint b/ui/components/composer.slint new file mode 100644 index 0000000..7695d26 --- /dev/null +++ b/ui/components/composer.slint @@ -0,0 +1,155 @@ +/// TEMPORARY AI GENERATED FRONTEND \\\ +/// NOT TO BE USED IN PRODUCTION \\\ + +import { Theme } from "../theme.slint"; +import { Icons } from "../icons.slint"; +import { Icon } from "icon.slint"; +import { ToolButton } from "tool-button.slint"; + +// Message box. Enter sends, Shift+Enter inserts a newline (same as Discord). +// The component is deliberately stateless about *what* is being sent: the +// parent owns the reply/edit state and just tells us what banner to show. +export component Composer inherits Rectangle { + in property placeholder; + in property banner; // "Replying to Alice" / "Editing message"; "" hides it + in property editing; + in property typing-text; // "Bob is typing…" + callback submit(string); + callback cancel-banner(); + callback clear-text(); + callback typing(); + callback attach(); + // Parent -> composer requests, as properties so the composer can live inside an `if`. + in property text-to-set; + in property text-nonce; // bump to replace the input text with `text-to-set` (and focus) + in property focus-nonce; // bump to just focus the input + changed text-nonce => { + input.text = root.text-to-set; + input.focus(); + } + changed focus-nonce => { + input.focus(); + } + + // Grows with content up to ~8 lines, like Discord. + property input-height: min(max(input.preferred-height, 24px), 180px); + + height: (root.banner != "" ? 32px : 0px) + input-height + 24px + 24px; + background: Theme.chat-bg; + + // Reply / edit banner sits above the input and shares its rounded top. + if root.banner != "": Rectangle { + x: 16px; + y: 0px; + width: parent.width - 32px; + height: 32px + 8px; + background: Theme.sidebar-bg; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + + Text { + x: 12px; + width: parent.width - 60px; + height: 32px; + text: root.banner; + color: Theme.text-muted; + font-size: 13px; + vertical-alignment: center; + overflow: elide; + } + + ToolButton { + glyph: Icons.close; + x: parent.width - self.width - 6px; + y: 2px; + clicked => { + root.cancel-banner(); + } + } + } + + box := Rectangle { + x: 16px; + y: (root.banner != "" ? 32px : 0px); + width: parent.width - 32px; + height: root.input-height + 24px; + background: Theme.input-bg; + border-radius: root.banner != "" ? 0px : 8px; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + + ToolButton { + glyph: Icons.plus-circle; + x: 6px; + y: parent.height - self.height - 10px; + clicked => { + root.attach(); + } + } + + FocusScope { + x: 44px; + y: 12px; + width: parent.width - 44px - 52px; + height: root.input-height; + + // Intercept Enter before the TextInput turns it into a newline. + capture-key-pressed(event) => { + if (event.text == Key.Return && !event.modifiers.shift) { + if (input.text != "") { + root.submit(input.text); + input.text = ""; + } + return accept; + } + return reject; + } + + input := TextInput { + width: parent.width; + height: parent.height; + single-line: false; + wrap: word-wrap; + color: Theme.text-normal; + font-size: 15px; + selection-background-color: Theme.accent; + edited => { + root.typing(); + } + } + } + + if input.text == "": Text { + x: 44px; + y: 12px; + width: parent.width - 100px; + height: 24px; + text: root.placeholder; + color: Theme.text-faint; + font-size: 15px; + vertical-alignment: center; + overflow: elide; + } + + ToolButton { + glyph: Icons.smile; + x: parent.width - self.width - 8px; + y: parent.height - self.height - 10px; + // TODO: emoji picker + } + } + + // Typing indicator, under the box. + Text { + x: 20px; + y: parent.height - 22px; + width: parent.width - 40px; + height: 18px; + text: root.typing-text; + color: Theme.text-muted; + font-size: 12px; + font-weight: 600; + vertical-alignment: center; + overflow: elide; + } +} diff --git a/ui/components/icon.slint b/ui/components/icon.slint new file mode 100644 index 0000000..60686ec --- /dev/null +++ b/ui/components/icon.slint @@ -0,0 +1,14 @@ +/// TEMPORARY AI GENERATED FRONTEND \\\ +/// NOT TO BE USED IN PRODUCTION \\\ + +import { Theme } from "../theme.slint"; + +// A recolourable icon. Usage: `Icon { source: Icons.hash; tint: Theme.text-muted; }` +export component Icon inherits Image { + in property tint: Theme.text-muted; + in property icon-size: 20px; + width: icon-size; + height: icon-size; + colorize: tint; + image-fit: contain; +} diff --git a/ui/components/member-list.slint b/ui/components/member-list.slint new file mode 100644 index 0000000..15dce5d --- /dev/null +++ b/ui/components/member-list.slint @@ -0,0 +1,67 @@ +/// TEMPORARY AI GENERATED FRONTEND \\\ +/// NOT TO BE USED IN PRODUCTION \\\ + +import { ListView } from "std-widgets.slint"; +import { Theme } from "../theme.slint"; +import { MemberItem } from "../types.slint"; +import { Avatar } from "avatar.slint"; + +component MemberRow inherits Rectangle { + in property m; + height: m.is-header ? 36px : 42px; + + if m.is-header: Text { + x: 16px; + width: parent.width - 32px; + height: parent.height - 4px; + text: m.name; + color: Theme.text-muted; + font-size: 12px; + font-weight: 700; + vertical-alignment: bottom; + overflow: elide; + } + + if !m.is-header: Rectangle { + x: 8px; + y: 1px; + width: parent.width - 16px; + height: parent.height - 2px; + border-radius: 4px; + background: touch.has-hover ? Theme.hover-bg : transparent; + + Avatar { + initial: m.initial; + tint: m.color; + diameter: 32px; + x: 8px; + y: (parent.height - self.height) / 2; + } + + Text { + x: 50px; + width: parent.width - 58px; + height: parent.height; + text: m.name; + color: touch.has-hover ? Theme.text-header : Theme.text-muted; + font-weight: 500; + vertical-alignment: center; + overflow: elide; + } + + touch := TouchArea { } + } +} + +export component MemberList inherits Rectangle { + in property <[MemberItem]> members; + + width: Theme.member-width; + background: Theme.sidebar-bg; + + ListView { + for m in root.members: MemberRow { + m: m; + } + } +} diff --git a/ui/components/message-list.slint b/ui/components/message-list.slint new file mode 100644 index 0000000..706cbf6 --- /dev/null +++ b/ui/components/message-list.slint @@ -0,0 +1,399 @@ +/// TEMPORARY AI GENERATED FRONTEND \\\ +/// NOT TO BE USED IN PRODUCTION \\\ + +import { ListView } from "std-widgets.slint"; +import { Theme } from "../theme.slint"; +import { Icons } from "../icons.slint"; +import { MessageItem, MessageKind, ReactionItem } from "../types.slint"; +import { Icon } from "icon.slint"; +import { Avatar } from "avatar.slint"; +import { ToolButton } from "tool-button.slint"; + +component ReactionChip inherits Rectangle { + in property r; + callback clicked(); + + height: 24px; + width: label.preferred-width + 20px; + border-radius: 8px; + border-width: 1px; + border-color: r.mine ? Theme.accent : Theme.sidebar-bg; + background: r.mine ? Theme.accent.with-alpha(0.22) : Theme.sidebar-bg; + + label := Text { + text: "\{r.key} \{r.count}"; + font-size: 13px; + color: r.mine ? Theme.text-header : Theme.text-muted; + horizontal-alignment: center; + vertical-alignment: center; + } + + TouchArea { + mouse-cursor: pointer; + clicked => { + root.clicked(); + } + } +} + +// Floating toolbar shown on message hover (react / reply / edit / delete). +component HoverBar inherits Rectangle { + in property own; + callback react(); + callback reply(); + callback edit(); + callback delete(); + + height: 32px; + width: (own ? 4 : 2) * 32px + 8px; + background: Theme.chat-bg; + border-radius: 6px; + border-width: 1px; + border-color: Theme.divider; + + HorizontalLayout { + padding: 2px; + spacing: 0px; + ToolButton { + glyph: Icons.smile; + clicked => { + root.react(); + } + } + + ToolButton { + glyph: Icons.reply; + clicked => { + root.reply(); + } + } + + if root.own: ToolButton { + glyph: Icons.edit; + clicked => { + root.edit(); + } + } + + if root.own: ToolButton { + glyph: Icons.trash; + tint: Theme.danger; + clicked => { + root.delete(); + } + } + } +} + +component ChatMessage inherits Rectangle { + in property m; + callback reply(); + callback edit(); + callback delete(); + callback react(string); + + background: hover.has-hover ? Theme.message-hover-bg : transparent; + + // The TouchArea *wraps* the content so hover stays true while the pointer + // is over children (text, chips, the toolbar). + hover := TouchArea { + VerticalLayout { + padding-top: root.m.show-header ? 14px : 1px; + padding-bottom: 2px; + spacing: 2px; + + if root.m.has-reply: Rectangle { + height: 20px; + Text { + x: Theme.gutter; + width: parent.width - Theme.gutter - 16px; + height: parent.height; + text: "↳ \{root.m.reply-sender} \{root.m.reply-body}"; + color: Theme.text-muted; + font-size: 13px; + vertical-alignment: center; + overflow: elide; + } + } + + HorizontalLayout { + padding-left: 16px; + padding-right: 16px; + spacing: 16px; + + // Gutter: avatar on the first message of a group, hover-time otherwise. + Rectangle { + width: 40px; + if root.m.show-header: Avatar { + initial: root.m.initial; + tint: root.m.color; + diameter: 40px; + y: 2px; + } + if !root.m.show-header: Text { + visible: hover.has-hover; + text: root.m.short-time; + color: Theme.text-faint; + font-size: 10px; + horizontal-alignment: center; + y: 4px; + } + } + + VerticalLayout { + spacing: 2px; + + if root.m.show-header: HorizontalLayout { + height: 20px; + spacing: 8px; + alignment: start; + Text { + text: root.m.sender; + color: root.m.color.brighter(0.6); + font-size: 15px; + font-weight: 600; + vertical-alignment: center; + } + + Text { + text: root.m.time; + color: Theme.text-faint; + font-size: 12px; + vertical-alignment: center; + } + } + + // Read-only TextInput rather than Text: lets people select + copy. + TextInput { + text: root.m.body; + read-only: true; + single-line: false; + wrap: word-wrap; + color: root.m.pending ? Theme.text-muted : Theme.text-normal; + font-size: 15px; + selection-background-color: Theme.accent; + } + + if root.m.edited: Text { + text: "(edited)"; + color: Theme.text-faint; + font-size: 11px; + } + + if root.m.reactions.length > 0: HorizontalLayout { + height: 28px; + padding-top: 4px; + spacing: 4px; + alignment: start; + for r in root.m.reactions: ReactionChip { + r: r; + clicked => { + root.react(r.key); + } + } + } + } + } + } + + // Absolutely positioned => excluded from the row's height calculation. + if hover.has-hover && !root.m.pending: HoverBar { + own: root.m.own; + x: parent.width - self.width - 16px; + y: -14px; + react => { + root.react("👍"); + } + reply => { + root.reply(); + } + edit => { + root.edit(); + } + delete => { + root.delete(); + } + } + } +} + +component DateDivider inherits Rectangle { + in property text; + height: 40px; + + Rectangle { + x: 16px; + width: parent.width - 32px; + y: parent.height / 2; + height: 1px; + background: Theme.divider; + } + + Rectangle { + width: label.preferred-width + 16px; + height: 18px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + background: Theme.chat-bg; + label := Text { + text: root.text; + color: Theme.text-muted; + font-size: 12px; + font-weight: 600; + horizontal-alignment: center; + vertical-alignment: center; + } + } +} + +component NoticeRow inherits Rectangle { + in property text; + height: 28px; + Text { + x: Theme.gutter; + width: parent.width - Theme.gutter - 16px; + height: parent.height; + text: root.text; + color: Theme.text-faint; + font-size: 13px; + vertical-alignment: center; + overflow: elide; + } +} + +component RoomStart inherits Rectangle { + in property room-name; + + VerticalLayout { + padding: 16px; + padding-top: 32px; + spacing: 6px; + Text { + text: "Welcome to #\{root.room-name}!"; + color: Theme.text-header; + font-size: 28px; + font-weight: 700; + wrap: word-wrap; + } + + Text { + text: "This is the start of the #\{root.room-name} channel."; + color: Theme.text-muted; + font-size: 15px; + wrap: word-wrap; + } + } +} + +component RowSwitch inherits Rectangle { + in property m; + in property room-name; + callback reply(); + callback edit(); + callback delete(); + callback react(string); + + VerticalLayout { + if root.m.kind == MessageKind.message: ChatMessage { + m: root.m; + reply => { + root.reply(); + } + edit => { + root.edit(); + } + delete => { + root.delete(); + } + react(key) => { + root.react(key); + } + } + if root.m.kind == MessageKind.date-divider: DateDivider { + text: root.m.body; + } + if root.m.kind == MessageKind.notice: NoticeRow { + text: root.m.body; + } + if root.m.kind == MessageKind.start-of-room: RoomStart { + room-name: root.room-name; + } + } +} + +export component MessageList inherits Rectangle { + in property <[MessageItem]> messages; + in property room-name; + in property loading-older; + in property room-nonce; // bumped by Rust whenever a different room is opened + callback reply(string, string); // id, sender + callback edit(string, string); // id, body + callback delete(string); + callback react(string, string); // id, key + callback load-older(); + + // "Stick to bottom": follow new messages unless the user scrolled up. + property stick: true; + property last-height: 0px; + pure function bottom-y(h: length, v: length) -> length { + return min(0px, v - h); + } + + changed room-nonce => { + root.stick = true; + } + + background: Theme.chat-bg; + + list := ListView { + height: parent.height - 8px; // small gap above the composer + changed content-height => { + if (root.stick) { + self.content-y = bottom-y(self.content-height, self.visible-height); + } else if (self.content-y > -40px && root.last-height > 0px) { + // Older messages were prepended: keep what the user was reading in place. + self.content-y = self.content-y - (self.content-height - root.last-height); + } + root.last-height = self.content-height; + } + changed content-y => { + root.stick = self.content-y <= bottom-y(self.content-height, self.visible-height) + 24px; + if (self.content-y > -24px && self.content-height > self.visible-height && !root.loading-older) { + root.load-older(); + } + } + + for m in root.messages: RowSwitch { + m: m; + room-name: root.room-name; + reply => { + root.reply(m.id, m.sender); + } + edit => { + root.edit(m.id, m.body); + } + delete => { + root.delete(m.id); + } + react(key) => { + root.react(m.id, key); + } + } + } + + if root.loading-older: Rectangle { + y: 8px; + width: 140px; + height: 24px; + x: (parent.width - self.width) / 2; + border-radius: 12px; + background: Theme.popup-bg; + Text { + text: "Loading history…"; + color: Theme.text-muted; + font-size: 12px; + horizontal-alignment: center; + vertical-alignment: center; + } + } +} diff --git a/ui/components/server-rail.slint b/ui/components/server-rail.slint new file mode 100644 index 0000000..50940d4 --- /dev/null +++ b/ui/components/server-rail.slint @@ -0,0 +1,149 @@ +/// TEMPORARY AI GENERATED FRONTEND \\\ +/// NOT TO BE USED IN PRODUCTION \\\ + +import { ScrollView } from "std-widgets.slint"; +import { Theme } from "../theme.slint"; +import { Icons } from "../icons.slint"; +import { ServerItem } from "../types.slint"; +import { Icon } from "icon.slint"; +import { Badge } from "badge.slint"; + +// One round button on the rail. Morphs from circle to rounded square on hover +// or selection, with a white "pill" on the left edge. +component RailButton inherits Rectangle { + in property selected; + in property unread; + in property mentions; + in property idle-color: Theme.chat-bg; + in property active-color: Theme.accent; + callback clicked(); + + height: 56px; + + // Left-edge pill: tall = selected, medium = hovered, dot = unread. + Rectangle { + x: 0px; + width: 4px; + height: root.selected ? 40px : (touch.has-hover ? 20px : (root.unread ? 8px : 0px)); + y: (parent.height - self.height) / 2; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + background: Theme.text-header; + animate height { duration: 120ms; } + } + + body := Rectangle { + width: 48px; + height: 48px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + clip: true; + border-radius: root.selected || touch.has-hover ? 16px : 24px; + background: root.selected || touch.has-hover ? root.active-color : root.idle-color; + animate border-radius { duration: 120ms; } + @children + } + + Badge { + count: root.mentions; + x: body.x + body.width - self.width + 4px; + y: body.y + body.height - self.height + 4px; + } + + touch := TouchArea { + mouse-cursor: pointer; + clicked => { + root.clicked(); + } + } +} + +export component ServerRail inherits Rectangle { + in property <[ServerItem]> servers; + in property selected-id; // "" = Home (direct messages) + in property home-unread; + in property home-mentions; + callback select(string); + callback add-server(); + + width: Theme.rail-width; + background: Theme.rail-bg; + + ScrollView { + vertical-scrollbar-policy: always-off; + horizontal-scrollbar-policy: always-off; + + VerticalLayout { + padding-top: 8px; + padding-bottom: 8px; + alignment: start; + + RailButton { + selected: root.selected-id == ""; + unread: root.home-unread; + mentions: root.home-mentions; + clicked => { + root.select(""); + } + Icon { + source: Icons.chat; + tint: white; + icon-size: 26px; + // `parent` here is the rail layout (children are projected into RailButton), + // so centre against the button's fixed 48px body instead. + x: (48px - self.width) / 2; + y: (48px - self.height) / 2; + } + } + + // separator + Rectangle { + height: 10px; + Rectangle { + width: 32px; + height: 2px; + x: (parent.width - self.width) / 2; + y: 4px; + border-radius: 1px; + background: Theme.divider; + } + } + + for s in root.servers: RailButton { + selected: root.selected-id == s.id; + unread: s.unread; + mentions: s.mentions; + idle-color: s.color; + active-color: s.color; + clicked => { + root.select(s.id); + } + Text { + width: 48px; + height: 48px; + text: s.initial; + color: white; + font-size: 20px; + font-weight: 600; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + RailButton { + idle-color: Theme.chat-bg; + active-color: Theme.success; + clicked => { + root.add-server(); + } + Icon { + source: Icons.plus; + tint: Theme.success; + icon-size: 24px; + x: (48px - self.width) / 2; + y: (48px - self.height) / 2; + } + } + } + } +} diff --git a/ui/components/text-field.slint b/ui/components/text-field.slint new file mode 100644 index 0000000..c8a1662 --- /dev/null +++ b/ui/components/text-field.slint @@ -0,0 +1,48 @@ +/// TEMPORARY AI GENERATED FRONTEND \\\ +/// NOT TO BE USED IN PRODUCTION \\\ + +import { Theme } from "../theme.slint"; + +// Single-line text field with placeholder, built on the TextInput primitive so +// it matches the theme instead of the std-widgets style. +export component TextField inherits Rectangle { + in property placeholder; + in property password; + in-out property text <=> input.text; + callback accepted(); + public function focus-field() { + input.focus(); + } + + height: 40px; + border-radius: 4px; + background: Theme.rail-bg; + border-width: 1px; + border-color: input.has-focus ? Theme.accent : Theme.divider; + + input := TextInput { + x: 12px; + y: 0px; + width: parent.width - 24px; + height: parent.height; + vertical-alignment: center; + color: Theme.text-normal; + font-size: 15px; + single-line: true; + input-type: root.password ? InputType.password : InputType.text; + selection-background-color: Theme.accent; + accepted => { + root.accepted(); + } + } + + if input.text == "": Text { + x: 12px; + width: parent.width - 24px; + height: parent.height; + text: root.placeholder; + color: Theme.text-faint; + font-size: 15px; + vertical-alignment: center; + } +} diff --git a/ui/components/tool-button.slint b/ui/components/tool-button.slint new file mode 100644 index 0000000..a84a3f0 --- /dev/null +++ b/ui/components/tool-button.slint @@ -0,0 +1,33 @@ +/// TEMPORARY AI GENERATED FRONTEND \\\ +/// NOT TO BE USED IN PRODUCTION \\\ + +import { Theme } from "../theme.slint"; +import { Icon } from "icon.slint"; + +// Small square icon button used in headers and hover toolbars. +export component ToolButton inherits Rectangle { + in property glyph; + in property tint: Theme.text-muted; + in property active: false; + callback clicked(); + + width: 32px; + height: 28px; + border-radius: 4px; + background: touch.has-hover ? Theme.selected-bg : transparent; + + Icon { + source: root.glyph; + icon-size: 18px; + tint: root.active || touch.has-hover ? Theme.text-header : root.tint; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + } + + touch := TouchArea { + mouse-cursor: pointer; + clicked => { + root.clicked(); + } + } +} diff --git a/ui/components/user-panel.slint b/ui/components/user-panel.slint new file mode 100644 index 0000000..794c81b --- /dev/null +++ b/ui/components/user-panel.slint @@ -0,0 +1,95 @@ +/// TEMPORARY AI GENERATED FRONTEND \\\ +/// NOT TO BE USED IN PRODUCTION \\\ + +import { Theme } from "../theme.slint"; +import { Icons } from "../icons.slint"; +import { Icon } from "icon.slint"; +import { Avatar } from "avatar.slint"; +import { ToolButton } from "tool-button.slint"; + +// Bottom-left "you" bar: avatar, name, mute / deafen / settings. +export component UserPanel inherits Rectangle { + in property name; + in property user-id; + in property initial; + in property tint; + in-out property muted; + in-out property deafened; + callback open-settings(); + callback logout(); + + height: 52px; + background: #232428; + + Avatar { + initial: root.initial; + tint: root.tint; + diameter: 32px; + x: 8px; + y: (parent.height - self.height) / 2; + } + + // Presence dot + Rectangle { + width: 12px; + height: 12px; + border-radius: 6px; + background: Theme.success; + border-width: 2px; + border-color: #232428; + x: 8px + 32px - 10px; + y: (parent.height - 32px) / 2 + 32px - 10px; + } + + Text { + x: 48px; + y: 10px; + width: 100px; + height: 16px; + text: root.name; + color: Theme.text-header; + font-size: 13px; + font-weight: 600; + overflow: elide; + } + + Text { + x: 48px; + y: 26px; + width: 100px; + height: 14px; + text: root.user-id; + color: Theme.text-muted; + font-size: 11px; + overflow: elide; + } + + HorizontalLayout { + x: parent.width - self.preferred-width - 4px; + y: (parent.height - 28px) / 2; + spacing: 0px; + // Voice isn't wired yet: these only flip local state for now. + ToolButton { + glyph: Icons.mic; + tint: root.muted ? Theme.danger : Theme.text-muted; + clicked => { + root.muted = !root.muted; + } + } + + ToolButton { + glyph: Icons.headphones; + tint: root.deafened ? Theme.danger : Theme.text-muted; + clicked => { + root.deafened = !root.deafened; + } + } + + ToolButton { + glyph: Icons.logout; + clicked => { + root.logout(); + } + } + } +} diff --git a/ui/icons.slint b/ui/icons.slint new file mode 100644 index 0000000..4d16494 --- /dev/null +++ b/ui/icons.slint @@ -0,0 +1,28 @@ +/// TEMPORARY AI GENERATED FRONTEND \\\ +/// NOT TO BE USED IN PRODUCTION \\\ + +// Icon set: 24px stroke icons from the Feather set (MIT), drawn white so they +// can be recoloured with `colorize` (see components/icon.slint). +export global Icons { + out property hash: @image-url("icons/hash.svg"); + out property speaker: @image-url("icons/speaker.svg"); + out property plus: @image-url("icons/plus.svg"); + out property close: @image-url("icons/close.svg"); + out property chat: @image-url("icons/chat.svg"); + out property users: @image-url("icons/users.svg"); + out property mic: @image-url("icons/mic.svg"); + out property headphones: @image-url("icons/headphones.svg"); + out property search: @image-url("icons/search.svg"); + out property pin: @image-url("icons/pin.svg"); + out property thread: @image-url("icons/thread.svg"); + out property reply: @image-url("icons/reply.svg"); + out property edit: @image-url("icons/edit.svg"); + out property trash: @image-url("icons/trash.svg"); + out property smile: @image-url("icons/smile.svg"); + out property plus-circle: @image-url("icons/plus-circle.svg"); + out property chevron-down: @image-url("icons/chevron-down.svg"); + out property chevron-right: @image-url("icons/chevron-right.svg"); + out property logout: @image-url("icons/logout.svg"); + out property sliders: @image-url("icons/sliders.svg"); + out property lock: @image-url("icons/lock.svg"); +} diff --git a/ui/icons/chat.svg b/ui/icons/chat.svg new file mode 100644 index 0000000..732ac13 --- /dev/null +++ b/ui/icons/chat.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/icons/chevron-down.svg b/ui/icons/chevron-down.svg new file mode 100644 index 0000000..16a3256 --- /dev/null +++ b/ui/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/chevron-right.svg b/ui/icons/chevron-right.svg new file mode 100644 index 0000000..306c907 --- /dev/null +++ b/ui/icons/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/close.svg b/ui/icons/close.svg new file mode 100644 index 0000000..21d146b --- /dev/null +++ b/ui/icons/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/edit.svg b/ui/icons/edit.svg new file mode 100644 index 0000000..0c9035d --- /dev/null +++ b/ui/icons/edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/hash.svg b/ui/icons/hash.svg new file mode 100644 index 0000000..0250212 --- /dev/null +++ b/ui/icons/hash.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/headphones.svg b/ui/icons/headphones.svg new file mode 100644 index 0000000..7bb364f --- /dev/null +++ b/ui/icons/headphones.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/lock.svg b/ui/icons/lock.svg new file mode 100644 index 0000000..1a045dd --- /dev/null +++ b/ui/icons/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/logout.svg b/ui/icons/logout.svg new file mode 100644 index 0000000..bac923a --- /dev/null +++ b/ui/icons/logout.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/mic.svg b/ui/icons/mic.svg new file mode 100644 index 0000000..d461e9d --- /dev/null +++ b/ui/icons/mic.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/pin.svg b/ui/icons/pin.svg new file mode 100644 index 0000000..ad40f55 --- /dev/null +++ b/ui/icons/pin.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/plus-circle.svg b/ui/icons/plus-circle.svg new file mode 100644 index 0000000..93f4bbe --- /dev/null +++ b/ui/icons/plus-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/plus.svg b/ui/icons/plus.svg new file mode 100644 index 0000000..221279a --- /dev/null +++ b/ui/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/reply.svg b/ui/icons/reply.svg new file mode 100644 index 0000000..21a73ff --- /dev/null +++ b/ui/icons/reply.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/search.svg b/ui/icons/search.svg new file mode 100644 index 0000000..550ad43 --- /dev/null +++ b/ui/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/sliders.svg b/ui/icons/sliders.svg new file mode 100644 index 0000000..63cd96c --- /dev/null +++ b/ui/icons/sliders.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/smile.svg b/ui/icons/smile.svg new file mode 100644 index 0000000..00e37e9 --- /dev/null +++ b/ui/icons/smile.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/speaker.svg b/ui/icons/speaker.svg new file mode 100644 index 0000000..c2a434d --- /dev/null +++ b/ui/icons/speaker.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/thread.svg b/ui/icons/thread.svg new file mode 100644 index 0000000..f1f47fa --- /dev/null +++ b/ui/icons/thread.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/trash.svg b/ui/icons/trash.svg new file mode 100644 index 0000000..14a9772 --- /dev/null +++ b/ui/icons/trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/icons/users.svg b/ui/icons/users.svg new file mode 100644 index 0000000..3b4b7e8 --- /dev/null +++ b/ui/icons/users.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/login.slint b/ui/login.slint new file mode 100644 index 0000000..910d10f --- /dev/null +++ b/ui/login.slint @@ -0,0 +1,132 @@ +/// TEMPORARY AI GENERATED FRONTEND \\\ +/// NOT TO BE USED IN PRODUCTION \\\ + +import { Theme } from "theme.slint"; +import { TextField } from "components/text-field.slint"; + +export component LoginScreen inherits Rectangle { + in property error; + in property busy; + in-out property homeserver; + in-out property username; + callback submit(string, string, string); // homeserver, username, password + + background: Theme.rail-bg; + + function try-submit() { + if (!root.busy && root.username != "" && password.text != "") { + root.submit(root.homeserver, root.username, password.text); + } + } + + Rectangle { + width: min(480px, parent.width - 32px); + height: card.preferred-height + 64px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + background: Theme.chat-bg; + border-radius: 8px; + + card := VerticalLayout { + padding: 32px; + spacing: 6px; + + Text { + text: "Welcome back!"; + color: Theme.text-header; + font-size: 24px; + font-weight: 700; + horizontal-alignment: center; + } + + Text { + text: "Sign in with your Matrix account"; + color: Theme.text-muted; + font-size: 15px; + horizontal-alignment: center; + } + + Rectangle { + height: 14px; + } + + Text { + text: "HOMESERVER"; + color: Theme.text-muted; + font-size: 12px; + font-weight: 700; + } + + TextField { + placeholder: "matrix.org"; + text <=> root.homeserver; + } + + Rectangle { + height: 6px; + } + + Text { + text: "USERNAME"; + color: Theme.text-muted; + font-size: 12px; + font-weight: 700; + } + + TextField { + placeholder: "user (or @user:matrix.org)"; + text <=> root.username; + } + + Rectangle { + height: 6px; + } + + Text { + text: "PASSWORD"; + color: Theme.text-muted; + font-size: 12px; + font-weight: 700; + } + + password := TextField { + password: true; + accepted => { + root.try-submit(); + } + } + + if root.error != "": Text { + text: root.error; + color: Theme.danger; + font-size: 13px; + wrap: word-wrap; + } + + Rectangle { + height: 10px; + } + + Rectangle { + height: 44px; + border-radius: 4px; + background: root.busy ? Theme.accent-hover : (login-touch.has-hover ? Theme.accent-hover : Theme.accent); + Text { + text: root.busy ? "Signing in…" : "Log In"; + color: white; + font-size: 16px; + font-weight: 600; + horizontal-alignment: center; + vertical-alignment: center; + } + + login-touch := TouchArea { + mouse-cursor: pointer; + clicked => { + root.try-submit(); + } + } + } + } + } +} diff --git a/ui/main.slint b/ui/main.slint index ce509e8..80793bb 100644 --- a/ui/main.slint +++ b/ui/main.slint @@ -1,14 +1,256 @@ +/// TEMPORARY AI GENERATED FRONTEND \\\ +/// NOT TO BE USED IN PRODUCTION \\\ + +import { Theme } from "theme.slint"; +import { Screen, ServerItem, ChannelItem, MessageItem, MemberItem } from "types.slint"; +import { LoginScreen } from "login.slint"; +import { ServerRail } from "components/server-rail.slint"; +import { ChannelList } from "components/channel-list.slint"; +import { UserPanel } from "components/user-panel.slint"; +import { ChatHeader } from "components/chat-header.slint"; +import { MessageList } from "components/message-list.slint"; +import { Composer } from "components/composer.slint"; +import { MemberList } from "components/member-list.slint"; + +export { Screen, ServerItem, ChannelItem, MessageItem, MemberItem, ChannelKind, MessageKind, ReactionItem } from "types.slint"; + export component MainWindow inherits Window { - preferred-width: 854px; - preferred-height: 480px; + title: "Tofu"; + min-width: 900px; + min-height: 520px; + preferred-width: 1280px; + preferred-height: 800px; + background: Theme.chat-bg; + default-font-size: 15px; - min-width: 640px; - min-height: 360px; + // ---- State pushed from Rust ------------------------------------------- + in property screen: Screen.loading; + in property login-error; + in property login-busy; + in-out property login-homeserver; + in-out property login-username; - Text { - text: "Tofu Client"; - font-size: 48px; - horizontal-alignment: center; - vertical-alignment: center; + in property me-name; + in property me-id; + in property me-initial; + in property me-color: Theme.accent; + + in property <[ServerItem]> servers; + in property home-unread; + in property home-mentions; + in property <[ChannelItem]> channels; + in property <[MessageItem]> messages; + in property <[MemberItem]> members; + + in property sidebar-title: "Direct Messages"; + in property room-name; + in property room-topic; + in property room-is-dm; + in property room-encrypted; + in property room-open: false; + in property room-nonce; + in property loading-older; + in property typing-text; + in property toast; + + // ---- State owned by the UI -------------------------------------------- + in-out property selected-server-id; // "" = Home + in-out property selected-room-id; + in-out property show-members: true; + // Composer mode + in-out property reply-to-id; + in-out property reply-to-name; + in-out property editing-id; + in-out property composer-text; + in-out property composer-text-nonce; + in-out property composer-focus-nonce; + + // ---- Intents sent to Rust --------------------------------------------- + callback login(string, string, string); + callback logout(); + callback select-server(string); + callback select-room(string); + callback send-message(string, string); // reply-to-id (may be ""), text + callback edit-message(string, string); // event id, new text + callback delete-message(string); + callback toggle-reaction(string, string); + callback load-older(); + callback typing(); + callback not-implemented(string); // feature name, for stubs + + if root.screen == Screen.loading: Rectangle { + background: Theme.rail-bg; + Text { + text: "Tofu"; + color: Theme.text-muted; + font-size: 32px; + font-weight: 700; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + if root.screen == Screen.login: LoginScreen { + error: root.login-error; + busy: root.login-busy; + homeserver <=> root.login-homeserver; + username <=> root.login-username; + submit(hs, user, pass) => { + root.login(hs, user, pass); + } + } + + if root.screen == Screen.main: HorizontalLayout { + // Left column: rail + channel list on top, "you" panel along the bottom. + VerticalLayout { + width: Theme.rail-width + Theme.sidebar-width; + HorizontalLayout { + ServerRail { + servers: root.servers; + selected-id: root.selected-server-id; + home-unread: root.home-unread; + home-mentions: root.home-mentions; + select(id) => { + root.selected-server-id = id; + root.select-server(id); + } + add-server => { + root.not-implemented("Create / join a space"); + } + } + + ChannelList { + title: root.sidebar-title; + is-home: root.selected-server-id == ""; + channels: root.channels; + selected-id: root.selected-room-id; + select(id) => { + root.selected-room-id = id; + root.select-room(id); + } + open-server-menu => { + root.not-implemented("Space settings"); + } + } + } + + UserPanel { + name: root.me-name; + user-id: root.me-id; + initial: root.me-initial; + tint: root.me-color; + logout => { + root.logout(); + } + } + } + + // Right: chat + VerticalLayout { + ChatHeader { + room-name: root.room-name; + topic: root.room-topic; + is-dm: root.room-is-dm; + encrypted: root.room-encrypted; + show-members <=> root.show-members; + open-threads => { + root.not-implemented("Threads"); + } + open-pins => { + root.not-implemented("Pinned messages"); + } + open-search => { + root.not-implemented("Search"); + } + } + + HorizontalLayout { + VerticalLayout { + MessageList { + messages: root.messages; + room-name: root.room-name; + loading-older: root.loading-older; + room-nonce: root.room-nonce; + reply(id, sender) => { + root.editing-id = ""; + root.reply-to-id = id; + root.reply-to-name = sender; + root.composer-focus-nonce += 1; + } + edit(id, body) => { + root.reply-to-id = ""; + root.editing-id = id; + root.composer-text = body; + root.composer-text-nonce += 1; + } + delete(id) => { + root.delete-message(id); + } + react(id, key) => { + root.toggle-reaction(id, key); + } + load-older => { + root.load-older(); + } + } + + if root.room-open: Composer { + text-to-set: root.composer-text; + text-nonce: root.composer-text-nonce; + focus-nonce: root.composer-focus-nonce; + placeholder: root.room-is-dm ? "Message @\{root.room-name}" : "Message #\{root.room-name}"; + banner: root.editing-id != "" ? "Editing message" : (root.reply-to-id != "" ? "Replying to \{root.reply-to-name}" : ""); + editing: root.editing-id != ""; + typing-text: root.typing-text; + submit(text) => { + if (root.editing-id != "") { + root.edit-message(root.editing-id, text); + } else { + root.send-message(root.reply-to-id, text); + } + root.editing-id = ""; + root.reply-to-id = ""; + } + cancel-banner => { + root.editing-id = ""; + root.reply-to-id = ""; + root.composer-text = ""; + root.composer-text-nonce += 1; + } + typing => { + root.typing(); + } + attach => { + root.not-implemented("File uploads"); + } + } + } + + if root.show-members: MemberList { + members: root.members; + } + } + } + } + + // Transient banner for errors and "coming soon" notices. + if root.toast != "": Rectangle { + width: min(480px, parent.width - 32px); + height: 40px; + x: (parent.width - self.width) / 2; + y: parent.height - self.height - 96px; + border-radius: 6px; + background: Theme.popup-bg; + border-width: 1px; + border-color: Theme.divider; + Text { + x: 12px; + width: parent.width - 24px; + text: root.toast; + color: Theme.text-normal; + font-size: 14px; + vertical-alignment: center; + overflow: elide; + } } } diff --git a/ui/theme.slint b/ui/theme.slint new file mode 100644 index 0000000..ea13af4 --- /dev/null +++ b/ui/theme.slint @@ -0,0 +1,40 @@ +/// TEMPORARY AI GENERATED FRONTEND \\\ +/// NOT TO BE USED IN PRODUCTION \\\ + +// All colours and metrics live here so re-theming is a one-file change. +// The palette is a familiar dark-grey layered scheme (darkest = server rail, +// lightest = chat area) which is what Discord users expect to see. +export global Theme { + // --- Surfaces ----------------------------------------------------------- + out property rail-bg: #1e1f22; + out property sidebar-bg: #2b2d31; + out property chat-bg: #313338; + out property input-bg: #383a40; + out property popup-bg: #111214; + out property hover-bg: #35373c; + out property selected-bg: #404249; + out property message-hover-bg: #2e3035; + out property divider: #3f4147; + + // --- Text --------------------------------------------------------------- + out property text-normal: #dbdee1; + out property text-muted: #949ba4; + out property text-faint: #6d6f78; + out property text-header: #f2f3f5; + out property text-link: #00a8fc; + + // --- Accents ------------------------------------------------------------ + out property accent: #6f7bf7; + out property accent-hover: #5b67e0; + out property danger: #da373c; + out property success: #23a559; + out property mention-bg: #f0b13222; + out property mention-bar: #f0b132; + + // --- Metrics ------------------------------------------------------------ + out property rail-width: 72px; + out property sidebar-width: 240px; + out property member-width: 240px; + out property header-height: 48px; + out property gutter: 56px; // avatar column inside a message row +} diff --git a/ui/types.slint b/ui/types.slint new file mode 100644 index 0000000..2480f5e --- /dev/null +++ b/ui/types.slint @@ -0,0 +1,70 @@ +/// TEMPORARY AI GENERATED FRONTEND \\\ +/// NOT TO BE USED IN PRODUCTION \\\ + +// Data shapes shared between Rust and Slint. Rust fills models of these; the UI +// only renders them. Keep them flat and dumb. + +export enum Screen { loading, login, main } +export enum ChannelKind { text, voice, category } +export enum MessageKind { message, date-divider, notice, start-of-room } + +// A Discord "server" == a top-level Matrix Space. +export struct ServerItem { + id: string, + name: string, + initial: string, + color: color, + unread: bool, + mentions: int, +} + +// A Discord "channel" == a Matrix room. A "category" == a sub-space. +// In Home mode the same struct carries DMs (is-dm = true). +export struct ChannelItem { + id: string, + name: string, + topic: string, + kind: ChannelKind, + unread: bool, + mentions: int, + joined: bool, + is-dm: bool, + initial: string, + color: color, +} + +export struct ReactionItem { + key: string, + count: int, + mine: bool, +} + +// One row of the message list. Date dividers, notices and the "start of room" +// banner are rows too, so the list is a single flat model. +export struct MessageItem { + id: string, + kind: MessageKind, + sender: string, + initial: string, + color: color, + body: string, + time: string, + short-time: string, + show-header: bool, + edited: bool, + own: bool, + pending: bool, + has-reply: bool, + reply-sender: string, + reply-body: string, + reactions: [ReactionItem], +} + +// Member list rows. Header rows ("ADMINS — 2") are rows too. +export struct MemberItem { + id: string, + name: string, + initial: string, + color: color, + is-header: bool, +}