feat: implement client backend

This includes temporary AI-generated frontend scaffolding, none of this will be used in production. It was just used during development for the backend.
This commit is contained in:
2026-10-09 19:46:02 +01:00
parent 88a64768a2
commit 5eb47e45ef
49 changed files with 3576 additions and 17 deletions
Generated
+53
View File
@@ -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",
+15 -3
View File
@@ -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"
+5 -2
View File
@@ -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");
}
+43
View File
@@ -0,0 +1,43 @@
use chrono::{DateTime, Datelike, Local, TimeZone};
use matrix_sdk::ruma::MilliSecondsSinceUnixEpoch;
pub fn to_local(timestamp: MilliSecondsSinceUnixEpoch) -> DateTime<Local> {
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::<Vec<_>>().join(" ");
if flat.chars().count() > max_characters {
let cut: String = flat.chars().take(max_characters).collect();
format!("{cut}...")
} else {
flat
}
}
+352
View File
@@ -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<dyn Fn(Event) + Send + Sync>;
// 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<Output = ()> + 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<Command> {
let (tx, rx) = mpsc::unbounded_channel();
rt.spawn(Backend::new(emit).run(rx));
tx
}
struct Backend {
emit: Sink,
client: Option<Client>,
sync: Option<Arc<SyncService>>,
store_passphrase: String,
/// Currently selected server (space); `None` = Home.<br>
/// Watched by `navigation.rs`.
selected_tx: watch::Sender<Option<OwnedRoomId>>,
via: navigation::ViaMap,
/// Tasks that live as long as the login does.
background: Vec<Task>,
open: Option<OpenRoom>
}
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<Command>) {
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<SyncService> = Arc::new(SyncService::builder(client.clone()).build().await.context("Couldn't start sync (does homeserver support sliding sync?)")?);
sync.start().await;
let spaces: Arc<SpaceService> = 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<OwnedRoomId> = 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<Arc<matrix_sdk_ui::Timeline>> {
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<Room> {
self.emit(Event::Toast("Joining...".into()));
let via: Vec<OwnedServerName> = self.via.lock().ok().and_then(|m| m.get(room_id).cloned()).unwrap_or_default();
let 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<String>, text: &str) -> Result<()> {
let text: &str = text.trim();
if text.is_empty() {
return Ok(());
}
let timeline: Arc<Timeline> = 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:<id>` for local echoes that haven't been sent yet.
fn parse_item_id(id: &str) -> Result<TimelineEventItemId> {
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<SyncService>, 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()));
}
}
}
+274
View File
@@ -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<Mutex<HashMap<OwnedRoomId, Vec<OwnedServerName>>>>;
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<OwnedRoomId, SpaceRoomList>
}
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:<br>
/// Loose channels first, then one category per sub-space.
async fn rows(&mut self, client: &Client, spaces: &SpaceService, via: &ViaMap) -> Vec<ChannelRow> {
let children: Vec<SpaceRoom> = self.list.rooms().await;
let mut rows: Vec<ChannelRow> = 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<ChannelRow>, Counts) {
let mut dms: Vec<(u64, ChannelRow)> = Vec::new();
let mut others: Vec<ChannelRow> = 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<ChannelRow> = 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<OwnedRoomId, Counts> {
let mut map: HashMap<OwnedRoomId, Counts> = 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<OwnedRoomId> = 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<SpaceService>, mut selected: watch::Receiver<Option<OwnedRoomId>>, emit: Sink, via: ViaMap) {
let (initial, stream) = spaces.subscribe_to_top_level_joined_spaces().await;
let mut top: Vector<SpaceRoom> = initial;
let mut space_stream = Box::pin(stream);
let mut room_updates = client.subscribe_to_all_room_updates();
let mut view: Option<ServerView> = None;
loop {
// (Re)open the selected space if the selection changed.
let wanted: Option<OwnedRoomId> = 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<OwnedRoomId, Counts> = server_counts(&client, &spaces).await;
let (home, home_counts) = home_rows(&client, &spaces).await;
let servers: Vec<ServerRow> = 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<ChannelRow> = 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() {}
}
}
}
}
}
}
+323
View File
@@ -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<Timeline>,
pub loading_older: Arc<AtomicBool>,
pub reached_start: Arc<AtomicBool>,
pub last_typing_sent: Option<Instant>,
/// Dropping `OpenRoom` will abort these.
_tasks: Vec<Task>
}
pub async fn open(room: Room, own: OwnedUserId, emit: Sink) -> Result<OpenRoom> {
let name: String = room.display_name().await.map_or_else(|_| room.room_id().to_string(), |name: RoomDisplayName| name.to_string());
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<Timeline> = Arc::new(room.timeline().await?);
let loading_older: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
let reached_start: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
let tasks: Vec<Task> = 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<Timeline>, loading: Arc<AtomicBool>, reached_start: Arc<AtomicBool>, 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<Timeline>, own: OwnedUserId, emit: Sink, loading: Arc<AtomicBool>, reached_start: Arc<AtomicBool>) {
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> = timeline.clone();
tokio::spawn(async move {
let _ = t.mark_as_read(ReceiptType::Read).await;
});
}
}
// ************************************************* //
// Timeline //
// ************************************************* //
fn name_of(profile: &TimelineDetails<Profile>, 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<String> {
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<Arc<TimelineItem>>, own: &UserId) -> Vec<MessageRow> {
let mut out: Vec<MessageRow> = 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<ReactionRow> = 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<matrix_sdk::room::RoomMember>) -> Vec<MemberRow> {
let mut groups: [(&str, Vec<MemberRow>); 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<MemberRow> = 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<String> = Vec::new();
for id in ids {
let name: Option<String> = 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
}
}
}
+119
View File
@@ -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<Option<SavedSession>> {
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.<br>
/// 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<Client> {
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)
}
+278
View File
@@ -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<T: Clone + 'static>(items: Vec<T>) -> ModelRc<T> {
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<ServerItem> = 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<String> = 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<ChannelItem> = 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<MessageItem> = rows.into_iter().map(message_item).collect();
ui.set_messages(model_of(items));
}
Event::Members(rows) => {
let items: Vec<MemberItem> = 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<Command>) {
let send = |tx: &UnboundedSender<Command>, 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."));
}
});
}
+33 -3
View File
@@ -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<dyn Error>> {
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<MainWindow> = 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(())
}
+171
View File
@@ -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<ReactionRow>
}
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<String> },
LoginBusy(bool),
LoggedIn(UserInfo),
Servers { servers: Vec<ServerRow>, home_unread: bool, home_mentions: u32 },
/// Channel (or DM) list for the currently selected server / Home.
Channels { title: String, channels: Vec<ChannelRow> },
RoomOpened(RoomInfo),
Messages(Vec<MessageRow>),
Members(Vec<MemberRow>),
Typing(Vec<String>),
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<String>),
SelectRoom(String),
Send { reply_to: Option<String>, 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
}
+26
View File
@@ -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 <string> initial;
in property <color> tint: Theme.accent;
in property <length> 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;
}
}
+24
View File
@@ -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 <int> 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;
}
}
+175
View File
@@ -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 <ChannelItem> c;
in property <bool> 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 <string> title;
in property <bool> is-home;
in property <[ChannelItem]> channels;
in property <string> 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);
}
}
}
}
}
+141
View File
@@ -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 <image> glyph;
in property <bool> 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 <string> room-name;
in property <string> topic;
in property <bool> is-dm;
in property <bool> encrypted;
in-out property <bool> 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();
}
}
}
}
+155
View File
@@ -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 <string> placeholder;
in property <string> banner; // "Replying to Alice" / "Editing message"; "" hides it
in property <bool> editing;
in property <string> 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 <string> text-to-set;
in property <int> text-nonce; // bump to replace the input text with `text-to-set` (and focus)
in property <int> 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 <length> 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;
}
}
+14
View File
@@ -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 <color> tint: Theme.text-muted;
in property <length> icon-size: 20px;
width: icon-size;
height: icon-size;
colorize: tint;
image-fit: contain;
}
+67
View File
@@ -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 <MemberItem> 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;
}
}
}
+399
View File
@@ -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 <ReactionItem> 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 <bool> 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 <MessageItem> 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 <string> 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 <string> 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 <string> 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 <MessageItem> m;
in property <string> 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 <string> room-name;
in property <bool> loading-older;
in property <int> 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 <bool> stick: true;
property <length> 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;
}
}
}
+149
View File
@@ -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 <bool> selected;
in property <bool> unread;
in property <int> mentions;
in property <color> idle-color: Theme.chat-bg;
in property <color> 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 <string> selected-id; // "" = Home (direct messages)
in property <bool> home-unread;
in property <int> 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;
}
}
}
}
}
+48
View File
@@ -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 <string> placeholder;
in property <bool> password;
in-out property <string> 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;
}
}
+33
View File
@@ -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 <image> glyph;
in property <color> tint: Theme.text-muted;
in property <bool> 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();
}
}
}
+95
View File
@@ -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 <string> name;
in property <string> user-id;
in property <string> initial;
in property <color> tint;
in-out property <bool> muted;
in-out property <bool> 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();
}
}
}
}
+28
View File
@@ -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 <image> hash: @image-url("icons/hash.svg");
out property <image> speaker: @image-url("icons/speaker.svg");
out property <image> plus: @image-url("icons/plus.svg");
out property <image> close: @image-url("icons/close.svg");
out property <image> chat: @image-url("icons/chat.svg");
out property <image> users: @image-url("icons/users.svg");
out property <image> mic: @image-url("icons/mic.svg");
out property <image> headphones: @image-url("icons/headphones.svg");
out property <image> search: @image-url("icons/search.svg");
out property <image> pin: @image-url("icons/pin.svg");
out property <image> thread: @image-url("icons/thread.svg");
out property <image> reply: @image-url("icons/reply.svg");
out property <image> edit: @image-url("icons/edit.svg");
out property <image> trash: @image-url("icons/trash.svg");
out property <image> smile: @image-url("icons/smile.svg");
out property <image> plus-circle: @image-url("icons/plus-circle.svg");
out property <image> chevron-down: @image-url("icons/chevron-down.svg");
out property <image> chevron-right: @image-url("icons/chevron-right.svg");
out property <image> logout: @image-url("icons/logout.svg");
out property <image> sliders: @image-url("icons/sliders.svg");
out property <image> lock: @image-url("icons/lock.svg");
}
+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>

After

Width:  |  Height:  |  Size: 337 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>

After

Width:  |  Height:  |  Size: 289 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>

After

Width:  |  Height:  |  Size: 290 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>

After

Width:  |  Height:  |  Size: 297 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg>

After

Width:  |  Height:  |  Size: 325 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18"/></svg>

After

Width:  |  Height:  |  Size: 313 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg>

After

Width:  |  Height:  |  Size: 426 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>

After

Width:  |  Height:  |  Size: 351 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>

After

Width:  |  Height:  |  Size: 338 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8"/></svg>

After

Width:  |  Height:  |  Size: 382 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 3h6l-1 7 3 3v2H7v-2l3-3z"/></svg>

After

Width:  |  Height:  |  Size: 324 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v8M8 12h8"/></svg>

After

Width:  |  Height:  |  Size: 323 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>

After

Width:  |  Height:  |  Size: 293 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/></svg>

After

Width:  |  Height:  |  Size: 327 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>

After

Width:  |  Height:  |  Size: 324 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/></svg>

After

Width:  |  Height:  |  Size: 346 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01"/></svg>

After

Width:  |  Height:  |  Size: 349 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a10 10 0 0 1 0 14"/></svg>

After

Width:  |  Height:  |  Size: 366 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8z"/></svg>

After

Width:  |  Height:  |  Size: 448 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/></svg>

After

Width:  |  Height:  |  Size: 378 B

+3
View File
@@ -0,0 +1,3 @@
<!-- TEMPORARY AI GENERATED FRONTEND -->
<!-- NOT TO BE USED IN PRODUCTION -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>

After

Width:  |  Height:  |  Size: 410 B

+132
View File
@@ -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 <string> error;
in property <bool> busy;
in-out property <string> homeserver;
in-out property <string> 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();
}
}
}
}
}
}
+251 -9
View File
@@ -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: Screen.loading;
in property <string> login-error;
in property <bool> login-busy;
in-out property <string> login-homeserver;
in-out property <string> login-username;
Text {
text: "Tofu Client";
font-size: 48px;
horizontal-alignment: center;
vertical-alignment: center;
in property <string> me-name;
in property <string> me-id;
in property <string> me-initial;
in property <color> me-color: Theme.accent;
in property <[ServerItem]> servers;
in property <bool> home-unread;
in property <int> home-mentions;
in property <[ChannelItem]> channels;
in property <[MessageItem]> messages;
in property <[MemberItem]> members;
in property <string> sidebar-title: "Direct Messages";
in property <string> room-name;
in property <string> room-topic;
in property <bool> room-is-dm;
in property <bool> room-encrypted;
in property <bool> room-open: false;
in property <int> room-nonce;
in property <bool> loading-older;
in property <string> typing-text;
in property <string> toast;
// ---- State owned by the UI --------------------------------------------
in-out property <string> selected-server-id; // "" = Home
in-out property <string> selected-room-id;
in-out property <bool> show-members: true;
// Composer mode
in-out property <string> reply-to-id;
in-out property <string> reply-to-name;
in-out property <string> editing-id;
in-out property <string> composer-text;
in-out property <int> composer-text-nonce;
in-out property <int> 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;
}
}
}
+40
View File
@@ -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 <color> rail-bg: #1e1f22;
out property <color> sidebar-bg: #2b2d31;
out property <color> chat-bg: #313338;
out property <color> input-bg: #383a40;
out property <color> popup-bg: #111214;
out property <color> hover-bg: #35373c;
out property <color> selected-bg: #404249;
out property <color> message-hover-bg: #2e3035;
out property <color> divider: #3f4147;
// --- Text ---------------------------------------------------------------
out property <color> text-normal: #dbdee1;
out property <color> text-muted: #949ba4;
out property <color> text-faint: #6d6f78;
out property <color> text-header: #f2f3f5;
out property <color> text-link: #00a8fc;
// --- Accents ------------------------------------------------------------
out property <color> accent: #6f7bf7;
out property <color> accent-hover: #5b67e0;
out property <color> danger: #da373c;
out property <color> success: #23a559;
out property <color> mention-bg: #f0b13222;
out property <color> mention-bar: #f0b132;
// --- Metrics ------------------------------------------------------------
out property <length> rail-width: 72px;
out property <length> sidebar-width: 240px;
out property <length> member-width: 240px;
out property <length> header-height: 48px;
out property <length> gutter: 56px; // avatar column inside a message row
}
+70
View File
@@ -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,
}