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.
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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."));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||