diff --git a/Cargo.toml b/Cargo.toml index 0050bd0..29e7bba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,33 +40,38 @@ tracing = "0.1.40" tracing-forest = "0.1.6" # SCSS -> CSS + usage in rust code -turf = "0.8.0" +turf = "0.9.3" # Dioxus -dioxus = { version = "0.5", default-features = false } -dioxus-free-icons = { version = "0.8", features = ["ionicons", "font-awesome-solid"] } +# dioxus-free-icons = { version = "0.8", features = ["ionicons", "font-awesome-solid"] } +dioxus-free-icons = { git = "https://github.com/ASR-ASU/dioxus-free-icons.git", branch = "asr/dioxus-0.6", features = ["ionicons", "font-awesome-solid"] } modx = "0.1.2" -[patch.crates-io] -dioxus = { git = "https://github.com/DioxusLabs/dioxus.git" } +# Matrix rich text editor +wysiwyg = { path = "../matrix.org/matrix-rich-text-editor/crates/wysiwyg/" } [target.'cfg(target_family = "wasm")'.dependencies] # Logging/tracing tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } tracing-web = "0.1.3" + # Dioxus -dioxus = { features = ["web"] } +dioxus = { git = "https://github.com/DioxusLabs/dioxus.git", branch = "main", features = ["web"] } web-sys = "0.3.69" + # Matrix matrix-sdk = { git = "https://github.com/matrix-org/matrix-rust-sdk.git", default-features = false, features = ["rustls-tls", "js"] } [target.'cfg(not(target_family = "wasm"))'.dependencies] # Utils time = "0.3.36" + # Logging/tracing tracing-subscriber = { version = "0.3.18", features = ["env-filter", "time"] } + # Dioxus -dioxus = { features = ["desktop"] } +dioxus = { git = "https://github.com/DioxusLabs/dioxus.git", branch = "main", features = ["desktop"] } + # Matrix matrix-sdk = { git = "https://github.com/matrix-org/matrix-rust-sdk.git", default-features = false, features = ["rustls-tls"] } diff --git a/build.rs b/build.rs index 58ada3f..80f3543 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,5 @@ use std::env; +use std::fmt::Display; use std::fs::File; use std::io::Write; use std::io::{self, BufRead}; @@ -10,14 +11,32 @@ use regex::Regex; fn main() { // Tell Cargo to rerun this build script if any SCSS file // in the 'src' directory or its subdirectories changes. - println!("cargo:rerun-if-changed=src/**/*.scss"); + println!("cargo:rerun-if-changed=src/ui/**/*.scss"); let out_dir = env::var("OUT_DIR").unwrap(); - let style_src_path = PathBuf::from("src/ui/_base.scss"); - let style_dst_path = Path::new(&out_dir).join("style_vars.rs"); + let mut tasks = Vec::new(); - export_color_variables(&style_src_path, &style_dst_path) + // Global tokens + tasks.push(Task::new( + PathBuf::from("src/ui/_base.scss"), + Path::new(&out_dir).join("style_tokens.rs"), + "style".to_string(), + )); + // variables defined by the Panel component + tasks.push(Task::new( + PathBuf::from("src/ui/components/_panel.scss"), + Path::new(&out_dir).join("style_component_panel.rs"), + "panel".to_string(), + )); + // Variables set by the Conversations layout + tasks.push(Task::new( + PathBuf::from("src/ui/layouts/conversations.scss"), + Path::new(&out_dir).join("style_layout_conversations.rs"), + "conversations".to_string(), + )); + + export_variables(tasks) } // From https://doc.rust-lang.org/rust-by-example/std_misc/file/read_lines.html @@ -32,14 +51,21 @@ where } #[derive(Debug)] -struct CssColorVariable<'a> { - name: &'a str, - value: &'a str, +struct ColorVariable { + name: String, + value: String, } -impl<'a> CssColorVariable<'a> { - pub fn to_rust(&self) -> String { - format!( +impl ColorVariable { + pub fn new(name: String, value: String) -> Self { + Self { name, value } + } +} + +impl Display for ColorVariable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, "const {name}: &str = \"{value}\";", name = self.name.replace('-', "_").to_uppercase(), value = self.value @@ -47,35 +73,83 @@ impl<'a> CssColorVariable<'a> { } } -fn export_color_variables(src_path: &PathBuf, dst_path: &PathBuf) { - let mut dst_file = File::create(dst_path).unwrap(); - if let Err(err) = dst_file.write(b"#[allow(dead_code)]\nmod style {") { - println!("{}", err); - return; - }; +#[derive(Debug)] +struct FloatVariable { + name: String, + value: f64, +} - let re = Regex::new(r"^\$([^:]+):[[:space:]]*#([^$]+);[[:space:]]*$").unwrap(); +impl FloatVariable { + pub fn new(name: String, value: f64) -> Self { + Self { name, value } + } +} - if let Ok(lines) = read_lines(src_path) { - for line in lines.map_while(Result::ok) { - let Some(groups) = re.captures(&line) else { - continue; - }; +impl Display for FloatVariable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "const {name}: f64 = {value};", + name = self.name.replace('-', "_").to_uppercase(), + value = self.value + ) + } +} - let var = CssColorVariable { - name: &groups[1], - value: &groups[2], - }; +struct Task { + src_path: PathBuf, + dst_path: PathBuf, + module_name: String, +} - let rust_export = var.to_rust(); - if let Err(err) = dst_file.write_fmt(format_args!(" pub {}\n", rust_export)) { +impl Task { + pub fn new(src_path: PathBuf, dst_path: PathBuf, module_name: String) -> Self { + Self { + src_path, + dst_path, + module_name, + } + } +} + +// fn export_variables(src_path: &PathBuf, dst_path: &PathBuf) { +fn export_variables(tasks: Vec) { + let color_re = Regex::new(r"^\$([^:]+):[[:space:]]*#([^$]+);[[:space:]]*$").unwrap(); + let variable_re = Regex::new(r"^\$([^:]+):[[:space:]]*([^;]+)[[:space:]]*;").unwrap(); + + for task in tasks { + let mut dst_file = File::create(task.dst_path).unwrap(); + if let Err(err) = dst_file.write_fmt(format_args!( + "#[allow(dead_code)]\nmod {} {{\n", + task.module_name + )) { + println!("{}", err); + return; + }; + + let mut variables = Vec::>::new(); + if let Ok(lines) = read_lines(task.src_path) { + for line in lines.map_while(Result::ok) { + if let Some(groups) = color_re.captures(&line) { + let var = ColorVariable::new(groups[1].to_string(), groups[2].to_string()); + variables.push(Box::new(var)); + } else if let Some(groups) = variable_re.captures(&line) { + if let Ok(value) = groups[2].parse::() { + variables.push(Box::new(FloatVariable::new(groups[1].to_string(), value))); + } + } + } + } + + for variable in variables { + if let Err(err) = dst_file.write_fmt(format_args!(" pub {}\n", variable)) { println!("{}", err); break; } } - } - if let Err(err) = dst_file.write(b"}\n") { - println!("{}", err); - }; + if let Err(err) = dst_file.write(b"}\n") { + println!("{}", err); + }; + } } diff --git a/public/index.html b/public/index.html index 5dcbf50..19d57c1 100644 --- a/public/index.html +++ b/public/index.html @@ -10,7 +10,6 @@ {style_include} - {script_include}
diff --git a/src/domain/model/messaging_interface.rs b/src/domain/model/messaging_interface.rs index 0d7e4ef..ea26778 100644 --- a/src/domain/model/messaging_interface.rs +++ b/src/domain/model/messaging_interface.rs @@ -44,6 +44,7 @@ pub trait RoomMessagingConsumerInterface { #[async_trait(?Send)] pub trait RoomMessagingProviderInterface { async fn get_avatar(&self, id: &RoomId) -> anyhow::Result>; + async fn join(&self, room_id: &RoomId) -> anyhow::Result; } #[async_trait(?Send)] diff --git a/src/domain/model/room.rs b/src/domain/model/room.rs index d9089a2..91ce56a 100644 --- a/src/domain/model/room.rs +++ b/src/domain/model/room.rs @@ -123,11 +123,6 @@ impl Room { self.name.borrow().clone() } - #[allow(dead_code)] - pub fn topic(&self) -> &Option { - &self.topic - } - #[allow(dead_code)] pub fn set_topic(&mut self, topic: Option) { self.topic = topic; @@ -308,6 +303,10 @@ impl RoomStoreConsumerInterface for Room { self.name.borrow().clone() } + fn topic(&self) -> Option { + self.topic.clone() + } + async fn avatar(&self) -> Option { self.get_avatar().await } @@ -315,4 +314,10 @@ impl RoomStoreConsumerInterface for Room { fn spaces(&self) -> &Vec { &self.spaces } + + async fn join(&self) { + if let Some(messaging_provider) = &self.messaging_provider { + let _ = messaging_provider.join(&self.id).await; + } + } } diff --git a/src/domain/model/store_interface.rs b/src/domain/model/store_interface.rs index 3cc5747..4a30354 100644 --- a/src/domain/model/store_interface.rs +++ b/src/domain/model/store_interface.rs @@ -28,16 +28,18 @@ pub trait RoomStoreConsumerInterface { fn id(&self) -> &RoomId; fn is_direct(&self) -> Option; fn name(&self) -> Option; + fn topic(&self) -> Option; + fn spaces(&self) -> &Vec; #[allow(dead_code)] async fn avatar(&self) -> Option; - - fn spaces(&self) -> &Vec; + async fn join(&self); } pub trait RoomStoreProviderInterface { fn on_new_name(&self, name: Option); fn on_new_avatar(&self, avatar: Option); + fn on_new_topic(&self, topic: Option); fn on_new_member(&self, member: RoomMember); fn on_invitation(&self, invitation: Invitation); } diff --git a/src/infrastructure/messaging/matrix/client.rs b/src/infrastructure/messaging/matrix/client.rs index 008ce76..a49a675 100644 --- a/src/infrastructure/messaging/matrix/client.rs +++ b/src/infrastructure/messaging/matrix/client.rs @@ -9,7 +9,7 @@ use dioxus::prelude::Task; use matrix_sdk::{ config::SyncSettings, event_handler::Ctx, - media::{MediaFormat, MediaRequest, MediaThumbnailSize}, + media::{MediaFormat, MediaRequest, MediaThumbnailSettings, MediaThumbnailSize}, room::{ParentSpace, Room}, ruma::{ api::client::media::get_content_thumbnail::v3::Method, @@ -448,10 +448,13 @@ impl Client { async fn on_room_avatar_event(room: &Room, senders: &Ctx) { let room_id = room.room_id(); let avatar = match room - .avatar(MediaFormat::Thumbnail(MediaThumbnailSize { - method: Method::Scale, - width: uint!(256), - height: uint!(256), + .avatar(MediaFormat::Thumbnail(MediaThumbnailSettings { + size: MediaThumbnailSize { + method: Method::Scale, + width: uint!(256), + height: uint!(256), + }, + animated: false, })) .await { @@ -668,10 +671,13 @@ impl Client { match client .account() - .get_avatar(MediaFormat::Thumbnail(MediaThumbnailSize { - method: Method::Scale, - width: uint!(256), - height: uint!(256), + .get_avatar(MediaFormat::Thumbnail(MediaThumbnailSettings { + size: MediaThumbnailSize { + method: Method::Scale, + width: uint!(256), + height: uint!(256), + }, + animated: false, })) .await { @@ -685,10 +691,13 @@ impl Client { if let Some(room) = client.get_room(room_id) { match room - .avatar(MediaFormat::Thumbnail(MediaThumbnailSize { - method: Method::Scale, - width: uint!(256), - height: uint!(256), + .avatar(MediaFormat::Thumbnail(MediaThumbnailSettings { + size: MediaThumbnailSize { + method: Method::Scale, + width: uint!(256), + height: uint!(256), + }, + animated: false, })) .await { @@ -709,10 +718,13 @@ impl Client { let request = MediaRequest { source: MediaSource::Plain(media_url), - format: MediaFormat::Thumbnail(MediaThumbnailSize { - method: Method::Scale, - width: uint!(256), - height: uint!(256), + format: MediaFormat::Thumbnail(MediaThumbnailSettings { + size: MediaThumbnailSize { + method: Method::Scale, + width: uint!(256), + height: uint!(256), + }, + animated: false, }), }; @@ -739,10 +751,13 @@ impl Client { Ok(room_member) => { if let Some(room_member) = room_member { let res = match room_member - .avatar(MediaFormat::Thumbnail(MediaThumbnailSize { - method: Method::Scale, - width: uint!(256), - height: uint!(256), + .avatar(MediaFormat::Thumbnail(MediaThumbnailSettings { + size: MediaThumbnailSize { + method: Method::Scale, + width: uint!(256), + height: uint!(256), + }, + animated: false, })) .await { @@ -761,6 +776,19 @@ impl Client { Ok(None) } + async fn join_room(&self, room_id: &RoomId) -> anyhow::Result { + let client = self.client.as_ref().unwrap(); + + if let Some(room) = client.get_room(room_id) { + return match room.join().await { + Ok(_) => Ok(true), + Err(err) => Err(err.into()), + }; + } + + Ok(false) + } + async fn work(&mut self, mut rx: UnboundedReceiver) { while let Some(task) = rx.recv().await { self.run(task).await; @@ -805,6 +833,9 @@ impl Client { ) .await; } + WorkerTask::JoinRoom(id, reply) => { + reply.send(self.join_room(&id).await).await; + } } } } diff --git a/src/infrastructure/messaging/matrix/requester.rs b/src/infrastructure/messaging/matrix/requester.rs index 85f7f29..232cb10 100644 --- a/src/infrastructure/messaging/matrix/requester.rs +++ b/src/infrastructure/messaging/matrix/requester.rs @@ -362,6 +362,10 @@ impl RoomMessagingProviderInterface for Requester { async fn get_avatar(&self, room_id: &RoomId) -> anyhow::Result> { request_to_worker!(self, WorkerTask::GetRoomAvatar, room_id.clone()) } + + async fn join(&self, room_id: &RoomId) -> anyhow::Result { + request_to_worker!(self, WorkerTask::JoinRoom, room_id.clone()) + } } #[async_trait(?Send)] diff --git a/src/infrastructure/messaging/matrix/worker_tasks.rs b/src/infrastructure/messaging/matrix/worker_tasks.rs index e8bb291..85be9fc 100644 --- a/src/infrastructure/messaging/matrix/worker_tasks.rs +++ b/src/infrastructure/messaging/matrix/worker_tasks.rs @@ -23,6 +23,7 @@ pub enum WorkerTask { OwnedUserId, Sender>>>, ), + JoinRoom(OwnedRoomId, Sender>), } impl Debug for WorkerTask { @@ -61,6 +62,10 @@ impl Debug for WorkerTask { .field(room_id) .field(user_id) .finish(), + WorkerTask::JoinRoom(room_id, _) => f + .debug_tuple("WorkerTask::JoinRoom") + .field(room_id) + .finish(), } } } diff --git a/src/infrastructure/services/mozaik_builder.rs b/src/infrastructure/services/mozaik_builder.rs index fb946b4..0d58b52 100644 --- a/src/infrastructure/services/mozaik_builder.rs +++ b/src/infrastructure/services/mozaik_builder.rs @@ -1,8 +1,7 @@ use std::io::Cursor; use image::imageops::FilterType; -use image::io::Reader; -use image::{DynamicImage, ImageFormat}; +use image::{DynamicImage, ImageFormat, ImageReader}; use image::{GenericImage, RgbImage}; use tracing::{error, warn}; @@ -13,7 +12,7 @@ cfg_if! { } fn from_raw_to_image(raw: &Vec) -> Option { - match Reader::new(Cursor::new(raw)).with_guessed_format() { + match ImageReader::new(Cursor::new(raw)).with_guessed_format() { Ok(reader) => match reader.decode() { Ok(image) => return Some(image), Err(err) => error!("Unable to decode the image: {}", err), diff --git a/src/main.rs b/src/main.rs index c29d3f3..bc39666 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,6 @@ use std::rc::Rc; use dioxus::prelude::*; use futures_util::stream::StreamExt; use tracing::{debug, error, warn}; -use tracing_forest::ForestLayer; use tracing_subscriber::{prelude::*, EnvFilter}; use crate::{ @@ -29,10 +28,12 @@ cfg_if! { if #[cfg(target_family = "wasm")] { use tracing_web::MakeWebConsoleWriter; } else { - use dioxus::desktop::Config; use std::fs::File; + + use dioxus::desktop::Config; use time::format_description::well_known::Iso8601; use tracing_subscriber::fmt::time::UtcTime; + use tracing_forest::ForestLayer; } } @@ -90,15 +91,12 @@ fn app() -> Element { if !SESSION.read().is_logged { login_coro.send(false); - } - - if SESSION.read().is_logged { rsx! { - Conversations {} + Login {} } } else { rsx! { - Login {}, + Conversations {} } } } diff --git a/src/ui/components/_panel.scss b/src/ui/components/_panel.scss index 9dc3efd..088acbf 100644 --- a/src/ui/components/_panel.scss +++ b/src/ui/components/_panel.scss @@ -1,6 +1,6 @@ @import "../base.scss"; -$panel-aspect-ratio: 1/1.618; +$aspect-ratio: 0.618; // 1/1.618; @mixin panel($padding-v: 2%, $padding-h: 2%) { padding: $padding-v $padding-h; diff --git a/src/ui/components/button.rs b/src/ui/components/button.rs index 225b29d..d8fd046 100644 --- a/src/ui/components/button.rs +++ b/src/ui/components/button.rs @@ -43,7 +43,7 @@ macro_rules! svg_text_button { ($name:ident,$style:ident,$icon:ident) => { pub fn $name(props: ButtonProps) -> Element { rsx! { - style { {STYLE_SHEET} }, + style { {STYLE_SHEET} } Button { id: props.id, @@ -79,7 +79,7 @@ pub struct ButtonProps { pub fn Button(props: ButtonProps) -> Element { rsx! { - style { {STYLE_SHEET} }, + style { {STYLE_SHEET} } button { id: props.id, @@ -96,8 +96,8 @@ pub fn Button(props: ButtonProps) -> Element { } }, - {props.children}, - }, + {props.children} + } } } @@ -107,6 +107,12 @@ svg_text_button!(RegisterButton, REGISTER_BUTTON, RegisterText); svg_text_icon!(LoginText, "LOGIN"); svg_text_button!(LoginButton, LOGIN_BUTTON, LoginText); +svg_text_icon!(JoinText, "JOIN"); +svg_text_button!(JoinButton, JOIN_BUTTON, JoinText); + +svg_text_icon!(RejectText, "REJECT"); +svg_text_button!(RejectButton, REJECT_BUTTON, RejectText); + svg_text_icon!(SuccessText, "OK"); svg_text_button!(SuccessButton, SUCCESS_BUTTON, SuccessText); diff --git a/src/ui/components/button.scss b/src/ui/components/button.scss index e16ecd6..7c02291 100644 --- a/src/ui/components/button.scss +++ b/src/ui/components/button.scss @@ -53,6 +53,14 @@ @include button(secondary, 90); } +.join-button { + @include button(secondary, 90); +} + +.reject-button { + @include button(critical, 90); +} + .success-button { @include button(success, 100); } diff --git a/src/ui/components/conversations.rs b/src/ui/components/conversations.rs index 597c2fe..ee71bdd 100644 --- a/src/ui/components/conversations.rs +++ b/src/ui/components/conversations.rs @@ -1,12 +1,19 @@ +use std::{rc::Rc, time::Duration}; + use base64::{engine::general_purpose, Engine as _}; use dioxus::prelude::*; -use tracing::{debug, trace, warn}; +use futures_util::StreamExt; +use tracing::{debug, warn}; use super::{button::Button, icons::SearchIcon, text_input::TextInput}; use crate::{ domain::model::{common::PresenceState as DomainPresenceState, room::RoomId, space::SpaceId}, ui::{ - components::icons::{ChatsIcon, LogoIcon, RoomsIcon, SpacesIcon}, + components::{ + button::{JoinButton, RejectButton}, + icons::{ChatsIcon, LogoIcon, RoomsIcon, SpacesIcon}, + }, + hooks::use_long_press, ACCOUNT, STORE, }, }; @@ -15,18 +22,13 @@ turf::style_sheet!("src/ui/components/conversations.scss"); #[component] fn AccountAvatar(content: Option>, class_name: Option) -> Element { - match content { - Some(content) => { - let encoded = general_purpose::STANDARD.encode(content); - rsx! { - div { - class: class_name, - background_image: format!("url(data:image/jpeg;base64,{encoded})") - } + rsx! { + if let Some(content) = content { + div { + class: class_name, + background_image: format!("url(data:image/jpeg;base64,{})", general_purpose::STANDARD.encode(content)) } } - // TODO: Manage acount without avatar - None => None, } } @@ -48,11 +50,11 @@ fn PresenceState(state: Option, class_name: Option) rsx! { div { class: classes, - LogoIcon {}, + LogoIcon {} } } } - None => None, + None => VNode::empty(), } } @@ -64,12 +66,12 @@ fn DisplayName(display_name: Option, class_name: Option) -> Elem div { class: class_name, p { - {display_name}, + {display_name} } } } } - None => None, + None => VNode::empty(), } } @@ -131,52 +133,53 @@ pub fn Account() -> Element { }); rsx! { - style { {STYLE_SHEET} }, - div { class: ClassName::ACCOUNT, - {avatar}, - {presence_state}, - {display_name}, + {avatar} + {presence_state} + {display_name} - {status}, + {status} div { class: ClassName::ACCOUNT_SPACES, Button { - SpacesIcon {}, + SpacesIcon {} } - }, + } div { class: ClassName::ACCOUNT_CHAT, Button { - ChatsIcon {}, + ChatsIcon {} } - }, + } div { class: ClassName::ACCOUNT_ROOM, Button { - RoomsIcon {}, + RoomsIcon {} } - }, + } } } } #[component] -pub fn ConversationAvatar(room_id: RoomId, on_clicked: EventHandler) -> Element { +pub fn ConversationAvatar( + room_id: RoomId, + on_selected: Option>, + on_pressed: Option>, +) -> Element { + let long_press_duration = Duration::from_millis(500); + let rooms = STORE.read().rooms(); - let toto = rooms.get(&room_id).unwrap(); - - let room = toto.signal(); - - let room_id = room.id(); + let room = rooms.get(&room_id).unwrap().signal(); + let room_id = Rc::new(room_id); let room_name = room.name(); - let selected_room_id = use_context::>>(); + let selected_room_id = use_signal(|| None::); let invited_badge = if room.is_invited() { rsx! { @@ -184,20 +187,19 @@ pub fn ConversationAvatar(room_id: RoomId, on_clicked: EventHandler) -> class: ClassName::CONVERSATION_AVATAR_INVITED_BADGE, p { - "Invited", + "Invited" } } } } else { - None + VNode::empty() }; let is_selected = match selected_room_id.read().as_ref() { - Some(selected_room_id) => *selected_room_id == room_id, + Some(selected_room_id) => *selected_room_id == *room_id, None => false, }; - // let avatar = if let Some(Some(content)) = &*avatar.read() { let avatar = if let Some(content) = room.avatar() { let encoded = general_purpose::STANDARD.encode(content); rsx! { @@ -221,7 +223,7 @@ pub fn ConversationAvatar(room_id: RoomId, on_clicked: EventHandler) -> div { class: ClassName::CONVERSATION_AVATAR_IMAGE, - {placeholder}, + {placeholder} } } }; @@ -232,14 +234,24 @@ pub fn ConversationAvatar(room_id: RoomId, on_clicked: EventHandler) -> ]; let classes_str = classes.join(" "); + let on_press = { + let room_id = room_id.clone(); + move || { + on_selected.map(|c| c.call(room_id.as_ref().clone())); + } + }; + + let on_long_press = move || { + on_pressed.map(|c| c.call(room_id.as_ref().clone())); + }; + + let long_press_hook = use_long_press(long_press_duration, on_press, on_long_press); + rsx! { div { class: "{classes_str}", - onclick: move |evt| { - on_clicked.call(room_id.clone()); - evt.stop_propagation(); - }, + ..long_press_hook.handlers, {avatar} {invited_badge} @@ -248,7 +260,10 @@ pub fn ConversationAvatar(room_id: RoomId, on_clicked: EventHandler) -> } #[component] -pub fn ConversationsCarousel(on_selected_conversation: EventHandler) -> Element { +pub fn ConversationsCarousel( + on_selected_conversation: EventHandler, + on_pressed_conversation: EventHandler, +) -> Element { let mut ordered_rooms = use_signal(Vec::::new); use_effect(move || { @@ -267,7 +282,8 @@ pub fn ConversationsCarousel(on_selected_conversation: EventHandler) -> rsx! { ConversationAvatar { room_id: room.clone(), - on_clicked: on_selected_conversation, + on_selected: on_selected_conversation, + on_pressed: on_pressed_conversation, } } }); @@ -279,33 +295,44 @@ pub fn ConversationsCarousel(on_selected_conversation: EventHandler) -> onscroll: move |_| { // Catch scrolling events. }, - {rendered_avatars}, + {rendered_avatars} } } } +// If id is None, the Space will handle all the Conversation which have no parent (Space). #[component] -pub fn Space(id: SpaceId) -> Element { - let space = STORE.read().spaces().get(&id).unwrap().signal(); - - let name = space.name(); - +pub fn Space(id: Option, on_pressed_conversation: EventHandler) -> Element { let mut selected_room_id = use_context_provider(|| Signal::new(None::)); let mut displayed_rooms = use_context_provider(|| Signal::new(Vec::::new())); - use_effect(move || { - // let rooms = STORE.read().rooms(); - let rooms = STORE.peek().rooms(); - let room_ids = space.room_ids(); - for room_id in room_ids { - if rooms.contains_key(&room_id) { - displayed_rooms.write().push(room_id); + let name = if let Some(id) = id { + let space = STORE.read().spaces().get(&id).unwrap().signal(); + use_effect(move || { + let rooms = STORE.peek().rooms(); + let room_ids = space.room_ids(); + for room_id in room_ids { + if rooms.contains_key(&room_id) { + displayed_rooms.write().push(room_id); + } } - } - }); + }); + space.name() + } else { + use_effect(move || { + let rooms = STORE.read().rooms(); + for room in rooms.values() { + if room.signal().spaces().is_empty() { + let room_id = room.signal().id(); + displayed_rooms.write().push(room_id); + } + } + }); + Some("Home".to_string()) + }; let on_selected_conversation = move |room_id: RoomId| { - trace!(""); + STORE.write().on_selected_room(room_id.clone()); selected_room_id.set(Some(room_id)); }; @@ -333,8 +360,6 @@ pub fn Space(id: SpaceId) -> Element { let classes_str = space_classes.join(" "); rsx! { - style { {STYLE_SHEET} }, - div { class: "{classes_str}", @@ -348,14 +373,15 @@ pub fn Space(id: SpaceId) -> Element { p { {name} } - }, + } ConversationsCarousel { on_selected_conversation, - }, + on_pressed_conversation, + } div { class: ClassName::SPACE_CONVERSATION_NAME, p { - {selected_room_name}, + {selected_room_name} } } } @@ -363,110 +389,38 @@ pub fn Space(id: SpaceId) -> Element { } #[component] -pub fn HomeSpace() -> Element { - let name = "Home"; - - let mut selected_room_id = use_context_provider(|| Signal::new(None::)); - let mut displayed_rooms = use_context_provider(|| Signal::new(Vec::::new())); - - use_effect(move || { - let rooms = STORE.read().rooms(); - for room in rooms.values() { - if room.signal().spaces().is_empty() { - let room_id = room.signal().id(); - displayed_rooms.write().push(room_id); - } - } - }); - - let on_selected_conversation = move |room_id: RoomId| { - selected_room_id.set(Some(room_id)); - }; - - let mut space_classes: [&str; 2] = [ClassName::SPACE, ""]; - - let mut selected_room_name = "".to_string(); - - if let Some(room_id) = selected_room_id.read().as_ref() { - space_classes[1] = ClassName::DISPLAY_CONVERSATION_NAME; - - if let Some(room) = STORE.read().rooms().get(room_id) { - let room = room.signal(); - - if let Some(name) = room.name() { - selected_room_name = name; - } else { - debug!("No name set for {} room", &room_id); - selected_room_name = room_id.to_string(); - } - } else { - warn!("No room found for the {} id", &room_id); - } - } - - let classes_str = space_classes.join(" "); - - rsx! { - style { {STYLE_SHEET} }, - - div { - class: "{classes_str}", - - // Deselect the conversation on clicks outside of the ConversationAvatar - onclick: move |_| { - selected_room_id.set(None); - }, - - div { - class: ClassName::SPACE_NAME, - p { - {name} - } - }, - ConversationsCarousel { - on_selected_conversation, - }, - div { - class: ClassName::SPACE_CONVERSATION_NAME, - p { - {selected_room_name}, - } - } - } - } -} - -pub fn Spaces() -> Element { +pub fn Spaces(on_pressed_conversation: EventHandler) -> Element { let spaces = STORE.read().spaces(); let space_ids = spaces.keys().clone().last(); let rendered_spaces = space_ids.map(|id| { rsx! { - Space { id: id.clone() } + Space { id: id.clone(), on_pressed_conversation } } }); rsx! { - style { {STYLE_SHEET} }, - div { class: ClassName::SPACES, - {rendered_spaces}, + {rendered_spaces} - HomeSpace {}, + Space { on_pressed_conversation } } } } +#[component] pub fn Search() -> Element { rsx! { - style { {STYLE_SHEET} }, - div { class: ClassName::SEARCH, - TextInput {} + div { + class: ClassName::SEARCH_TEXT, + + TextInput {} + } div { class: ClassName::SEARCH_BUTTON, @@ -478,27 +432,133 @@ pub fn Search() -> Element { } } -pub fn Conversations() -> Element { +#[derive(PartialEq)] +enum ConversationOptionsMenuActions { + Join(RoomId), + Close, +} + +#[component] +fn ConversationOptionsMenu( + room_id: RoomId, + callbacks: Coroutine, +) -> Element { + let room = STORE.read().rooms().get(&room_id).unwrap().signal(); + + let topic = room.topic().unwrap_or("".to_string()); + rsx! { - style { {STYLE_SHEET} }, + div { + class: ClassName::CONVERSATION_OPTIONS_MENU, + + div { + class: ClassName::CONVERSATION_OPTIONS_MENU_INNER, + + div { + class: ClassName::CONVERSATION_OPTIONS_MENU_INNER_AVATAR, + ConversationAvatar { room_id: room_id.clone() } + } + + div { + class: ClassName::CONVERSATION_OPTIONS_MENU_INNER_NAME, + p { + {room.name()} + } + } + + div { + class: ClassName::CONVERSATION_OPTIONS_MENU_INNER_TOPIC, + p { + {topic} + } + } + + div { + class: ClassName::CONVERSATION_OPTIONS_MENU_INNER_CONFIG, + p { + "Coming soon..." + } + } + + div { + class: ClassName::CONVERSATION_OPTIONS_MENU_INNER_CLOSE_BUTTON, + RejectButton { + onclick: move |_| { + callbacks.send(ConversationOptionsMenuActions::Close); + } + } + } + + div { + class: ClassName::CONVERSATION_OPTIONS_MENU_INNER_JOIN_BUTTON, + JoinButton { + onclick: move |_| { + callbacks.send(ConversationOptionsMenuActions::Join(room_id.clone())); + callbacks.send(ConversationOptionsMenuActions::Close); + } + } + } + } + } + } +} + +pub fn Conversations() -> Element { + let mut room_id = use_signal(|| None::); + + let on_pressed_conversation = move |id: RoomId| { + room_id.set(Some(id)); + }; + + let callbacks = use_coroutine( + move |mut rx: UnboundedReceiver| async move { + while let Some(action) = rx.next().await { + match action { + ConversationOptionsMenuActions::Join(room_id) => { + let rooms = STORE.read().rooms(); + if let Some(room) = rooms.get(&room_id) { + room.join().await; + } + } + ConversationOptionsMenuActions::Close => { + room_id.set(None); + } + } + } + }, + ); + + let menu = match room_id.read().as_ref() { + Some(room_id) => { + let room_id = room_id.clone(); + rsx! { + ConversationOptionsMenu { room_id, callbacks } + } + } + None => VNode::empty(), + }; + + rsx! { + style { {STYLE_SHEET} } div { class: ClassName::CONVERSATIONS, div { class: ClassName::CONVERSATIONS_ACCOUNT, - Account {}, - }, + Account {} + } div { class: ClassName::CONVERSATIONS_SPACES, - Spaces {}, - }, + Spaces { on_pressed_conversation } + } div { class: ClassName::CONVERSATIONS_SEARCH, - Search {}, - }, + Search {} + } } + {menu} } } diff --git a/src/ui/components/conversations.scss b/src/ui/components/conversations.scss index c565a92..847b0ff 100644 --- a/src/ui/components/conversations.scss +++ b/src/ui/components/conversations.scss @@ -10,6 +10,10 @@ } } +@mixin extra-marged-button() { + @include button-class(); +} + .account { $colum-spacing: 5%; $col-width: 8.75%; @@ -89,10 +93,6 @@ grid-area: status; } - @mixin extra-marged-button() { - @include button-class(); - } - &__spaces { grid-area: spaces; @@ -268,6 +268,11 @@ display: flex; gap: 5%; + &__text { + height: 100%; + width: 100%; + } + &__button { @include button-class(); @@ -284,22 +289,152 @@ $account-height: 15%; $search-height: 5%; - display: flex; - flex-direction: column; - justify-content: space-between; - gap: $gap; + display: grid; + grid-template-columns: auto; + grid-template-rows: min($account-height, 384px) $gap auto $gap min($search-height, 128px); + grid-template-areas: + "account" + "." + "spaces" + "." + "search" + ; &__account { - height: $account-height; - max-height: 384px; + grid-area: account; } &__spaces { - min-height: calc(100% - $account-height - $search-height - (2 * $gap)); + grid-area: spaces; } &__search { - height: $search-height; - max-height: 128px; + grid-area: search; + } + + &__menu { + grid-area: spaces; + z-index: 1; + } +} + +%base-helper-text { + margin: 0; + margin-top: 0.3vh; + + font-size: 1.2vh; + + // TODO: Set color used for text in _base.scss file + color: get-color(greyscale, 90); + + p { + margin: 0; + + &.invalid { + color: get-color(critical, 100); + } + } +} + +.conversation-options-menu { + width: 100%; + height: 100%; + + position: relative; + top: -100%; + margin-bottom: calc(-100% / $aspect-ratio); + + border-radius: $border-radius; + + display: flex; + align-items: center; + justify-content: center; + + background: rgba(0, 0, 0, 0.5); + + &__inner { + $padding: 5%; + // TODO: Thin border + @include panel($padding, $padding); + + width: 95%; + height: 60%; + + display: grid; + grid-template-columns: 10% 10% 5% 15% 20% 10% 20% 10%; + grid-template-rows: 7.5% 7.5% 5% 5% auto 5% 10%; + grid-template-areas: + "avatar avatar . name name name name name" + "avatar avatar . topic topic topic topic topic" + "avatar avatar . . . . . ." + ". . . . . . . ." + "config config config config config config config config" + ". . . . . . . ." + ". close close close . join join ." + ; + + &__avatar { + grid-area: avatar; + + width: 100%; + aspect-ratio: 1; + } + + &__name { + grid-area: name; + + // TODO: Merge with &__display-name + display: flex; + align-items: center; + justify-content: center; + + p { + font-size: 2.5vh; + margin: 0; + text-align: center; + } + } + + &__topic { + grid-area: topic; + + // TODO: Merge with &__display-name + display: flex; + align-items: center; + justify-content: center; + + @extend %base-helper-text; + p { + font-size: 2vh; + margin: 0; + text-align: center; + } + } + + &__config { + grid-area: config; + + // TODO: Merge with &__display-name + display: flex; + align-items: center; + justify-content: center; + + border: $border-thin; + border-color: get-color(ternary, 90); + border-radius: $border-radius; + } + + button { + height: 100%; + width: 100%; + } + + &__close-button { + grid-area: close; + } + + &__join-button { + grid-area: join; + } } } diff --git a/src/ui/components/icons.rs b/src/ui/components/icons.rs index 99fcb3d..7516049 100644 --- a/src/ui/components/icons.rs +++ b/src/ui/components/icons.rs @@ -7,7 +7,7 @@ use dioxus_free_icons::{Icon, IconShape}; turf::style_sheet!("src/ui/components/icons.scss"); -include!(concat!(env!("OUT_DIR"), "/style_vars.rs")); +include!(concat!(env!("OUT_DIR"), "/style_tokens.rs")); use style::{COLOR_PRIMARY_100, COLOR_TERNARY_100}; @@ -15,7 +15,8 @@ macro_rules! transparent_icon { ($name:ident, $icon:ident) => { pub fn $name() -> Element { rsx! { - style { {STYLE_SHEET} }, + style { {STYLE_SHEET} } + Icon { class: ClassName::TRANSPARENT_ICON, icon: $icon, @@ -52,7 +53,7 @@ impl IconShape for LogoShape { pub fn LogoIcon() -> Element { rsx! { - style { {STYLE_SHEET} }, + style { {STYLE_SHEET} } Icon { icon: LogoShape, @@ -133,14 +134,14 @@ impl IconShape for PyramidShape { L {_PYRAMID_EDGES_E1_X} {_PYRAMID_CENTRAL_EDGE_E2_Y} \ M {_PYRAMID_EDGES_E1_X} {_PYRAMID_EDGES_E1_Y} \ V {_PYRAMID_CENTRAL_EDGE_Y_LEN}", - }, + } path { d: "\ M {_PYRAMID_CENTRAL_EDGE_E2_X} {_PYRAMID_CENTRAL_EDGE_E2_Y} \ V {central_edge_ratio_e2_y} \ L {left_edge_ratio_e1_x} {no_central_edge_ratio_e1_y} \ L {_PYRAMID_LEFT_EDGE_E2_X} {_PYRAMID_LEFT_EDGE_E2_Y} Z", - }, + } path { d: "\ M {_PYRAMID_CENTRAL_EDGE_E2_X} {_PYRAMID_CENTRAL_EDGE_E2_Y} \ @@ -168,10 +169,11 @@ pub fn Pyramid(props: PyramidProps) -> Element { .unwrap_or(COLOR_TERNARY_100.to_string()); rsx! { - style { {STYLE_SHEET} }, + style { {STYLE_SHEET} } Icon { class: ClassName::PYRAMID_ICON, + icon: PyramidShape { ratio: props.ratio, color, progress_color }, } } diff --git a/src/ui/components/login.rs b/src/ui/components/login.rs index fce17f0..6545eeb 100644 --- a/src/ui/components/login.rs +++ b/src/ui/components/login.rs @@ -19,7 +19,7 @@ use super::{ text_input::{PasswordInputState, PasswordTextInput, TextInput, TextInputState}, }; -include!(concat!(env!("OUT_DIR"), "/style_vars.rs")); +include!(concat!(env!("OUT_DIR"), "/style_tokens.rs")); use style::{ COLOR_PRIMARY_100, COLOR_PRIMARY_110, COLOR_PRIMARY_120, COLOR_PRIMARY_140, COLOR_PRIMARY_150, @@ -97,27 +97,6 @@ impl Clone for Box { } } -#[derive(Clone)] -struct TextInputHandler { - state: Signal, -} - -impl TextInputHandler {} - -impl OnValidationError for TextInputHandler { - fn reset(&mut self) { - self.state.write().reset(); - } - - fn invalidate(&mut self, helper_text: String) { - self.state.write().invalidate(helper_text); - } - - fn box_clone(&self) -> Box { - Box::new(self.clone()) - } -} - #[derive(Clone)] struct UrlInputHandler { state: Signal, @@ -619,7 +598,6 @@ pub fn Login() -> Element { }); if *spinner_animated.read() && SESSION.read().is_logged { - debug!("Stop spinner"); spinner_animated.set(false); } @@ -745,7 +723,7 @@ pub fn Login() -> Element { let confirm_password_classes_str = confirm_password_classes.join(" "); rsx! { - style { {STYLE_SHEET} }, + style { {STYLE_SHEET} } div { class: "{classes_str}", @@ -757,73 +735,79 @@ pub fn Login() -> Element { random_avatar_future.restart() }, - {avatar}, - }, + {avatar} + } div { class: ClassName::LOGIN_HOMESERVER, + TextInput { placeholder: "Homeserver URL", value: "{homeserver_url}", state: homeserver_url_state, oninput: on_input![data, homeserver_url], - }, - }, + } + } div { class: ClassName::LOGIN_ID, + TextInput { placeholder: "{id_placeholder}", value: "{id}", state: id_state, oninput: on_input![data, id], - }, - }, + } + } div { class: "{password_classes_str}", + PasswordTextInput { placeholder: "Password", value: "{password}", state: password_state, oninput: on_input![data, password], - }, - - }, + } + } div { class: "{confirm_password_classes_str}", + PasswordTextInput { placeholder: "Confirm Password", value: "{confirm_password}", state: confirm_password_state, oninput: on_input![data, confirm_password], } - }, + } div { class: ClassName::LOGIN_SPINNER, + Spinner { animate: *spinner_animated.read(), - }, - }, + } + } div { class: ClassName::LOGIN_REGISTER_BUTTON, + RegisterButton { onclick: on_clicked_register, - }, - }, + } + } div { class: ClassName::LOGIN_LOGIN_BUTTON, + LoginButton { focus: true, onclick: on_clicked_login, - }, - }, - }, + } + } + } - {rendered_modal}, + {rendered_modal} } } diff --git a/src/ui/components/modal.rs b/src/ui/components/modal.rs index 483c8ea..f919c83 100644 --- a/src/ui/components/modal.rs +++ b/src/ui/components/modal.rs @@ -9,7 +9,7 @@ use crate::infrastructure::services::random_svg_generators::{ generate_random_svg_avatar, AvatarConfig, AvatarFeeling, }; -include!(concat!(env!("OUT_DIR"), "/style_vars.rs")); +include!(concat!(env!("OUT_DIR"), "/style_tokens.rs")); use style::{COLOR_CRITICAL_100, COLOR_SUCCESS_100, COLOR_WARNING_100}; @@ -76,10 +76,10 @@ pub fn Modal(props: ModalProps) -> Element { Severity::Critical => ErrorButton, }; - icon.as_ref()?; + let _ = icon.as_ref().ok_or(VNode::empty()); rsx! { - style { {STYLE_SHEET} }, + style { {STYLE_SHEET} } div { class: ClassName::MODAL, @@ -90,17 +90,17 @@ pub fn Modal(props: ModalProps) -> Element { div { class: ClassName::MODAL_CONTENT_ICON, {icon} - }, + } div { class: ClassName::MODAL_CONTENT_TITLE, - {props.title}, - }, + {props.title} + } div { class: ClassName::MODAL_CONTENT_MSG, - {props.children}, - }, + {props.children} + } div { class: ClassName::MODAL_CONTENT_BUTTONS, @@ -109,10 +109,10 @@ pub fn Modal(props: ModalProps) -> Element { if let Some(cb) = &props.on_confirm { cb.call(evt); } - }, - }, - }, - }, - }, + } + } + } + } + } } } diff --git a/src/ui/components/spinner.rs b/src/ui/components/spinner.rs index 68750b1..a45f2d9 100644 --- a/src/ui/components/spinner.rs +++ b/src/ui/components/spinner.rs @@ -13,13 +13,14 @@ pub struct SpinnerProps { pub fn Spinner(props: SpinnerProps) -> Element { rsx! { - style { {STYLE_SHEET} }, + style { {STYLE_SHEET} } div { class: ClassName::SPINNER, Icon { class: if props.animate { "" } else { ClassName::PAUSED }, + icon: LogoShape, } } diff --git a/src/ui/components/text_input.rs b/src/ui/components/text_input.rs index 07e4fcb..8f34dd8 100644 --- a/src/ui/components/text_input.rs +++ b/src/ui/components/text_input.rs @@ -67,7 +67,7 @@ pub fn TextInput(props: InputProps) -> Element { let input_classes_str = [ClassName::TEXT_INPUT_INPUT, criticity_class].join(" "); rsx! { - style { {STYLE_SHEET} }, + style { {STYLE_SHEET} } div { class: ClassName::TEXT_INPUT, @@ -83,7 +83,7 @@ pub fn TextInput(props: InputProps) -> Element { cb.call(evt); } }, - }, + } div { class: ClassName::TEXT_INPUT_HELPER_TEXT, @@ -159,7 +159,7 @@ pub fn PasswordTextInput(props: InputProps) -> Element { let input_classes = [ClassName::PASSWORD_TEXT_INPUT_INPUT, criticity_class].join(" "); rsx! { - style { {STYLE_SHEET} }, + style { {STYLE_SHEET} } div { class: "{text_input_classes}", @@ -175,7 +175,7 @@ pub fn PasswordTextInput(props: InputProps) -> Element { cb.call(evt); } }, - }, + } if let Some(score) = score { div { @@ -184,7 +184,7 @@ pub fn PasswordTextInput(props: InputProps) -> Element { ratio: score, } } - }, + } div { class: ClassName::PASSWORD_TEXT_INPUT_SHOW_TOGGLE, @@ -203,7 +203,7 @@ pub fn PasswordTextInput(props: InputProps) -> Element { icon: IoEye, } } - }, + } div { class: ClassName::PASSWORD_TEXT_INPUT_HELPER_TEXT, @@ -212,7 +212,7 @@ pub fn PasswordTextInput(props: InputProps) -> Element { class: criticity_class, {helper_text} } - }, + } } } } diff --git a/src/ui/components/text_input.scss b/src/ui/components/text_input.scss index ce09044..7c3dae8 100644 --- a/src/ui/components/text_input.scss +++ b/src/ui/components/text_input.scss @@ -1,4 +1,4 @@ -@import "../_base.scss" +@import "../_base.scss"; %base-text-input { $horizontal-padding: 1vw; diff --git a/src/ui/components/wallpaper.rs b/src/ui/components/wallpaper.rs index d880507..c2d16dc 100644 --- a/src/ui/components/wallpaper.rs +++ b/src/ui/components/wallpaper.rs @@ -7,20 +7,28 @@ turf::style_sheet!("src/ui/components/wallpaper.scss"); #[component] pub fn Wallpaper(display_version: Option) -> Element { + let background_image = format!( + "url({})", + manganis::mg!(file("public/images/wallpaper-pattern.svg")) + ); + let version = display_version.map(|flag| if flag { Some(GIT_VERSION) } else { None }); rsx! { - style { {STYLE_SHEET} }, + style { {STYLE_SHEET} } + div { class: ClassName::WALLPAPER, div { class: ClassName::WALLPAPER_CONTENT, + background_image: "{background_image}", } div { class: ClassName::WALLPAPER_VERSION, - {version}, + + {version} } } } diff --git a/src/ui/components/wallpaper.scss b/src/ui/components/wallpaper.scss index 1aea4d0..35c133b 100644 --- a/src/ui/components/wallpaper.scss +++ b/src/ui/components/wallpaper.scss @@ -12,7 +12,6 @@ overflow: hidden; &__content { - background-image: url("./images/wallpaper-pattern.svg"); background-position: center; width: 150%; diff --git a/src/ui/hooks/mod.rs b/src/ui/hooks/mod.rs new file mode 100644 index 0000000..4a7932f --- /dev/null +++ b/src/ui/hooks/mod.rs @@ -0,0 +1,3 @@ +pub use use_long_press::use_long_press; + +mod use_long_press; diff --git a/src/ui/hooks/use_long_press.rs b/src/ui/hooks/use_long_press.rs new file mode 100644 index 0000000..a507747 --- /dev/null +++ b/src/ui/hooks/use_long_press.rs @@ -0,0 +1,60 @@ +use std::{cell::RefCell, time::Duration}; + +use async_std::task; +use dioxus::prelude::*; + +pub struct UseLongPress { + _timer: UseFuture, + pub handlers: Vec, +} + +pub fn use_long_press( + duration: Duration, + on_press: impl FnMut() + 'static, + on_long_press: impl FnMut() + 'static, +) -> UseLongPress { + let on_press = std::rc::Rc::new(RefCell::new(on_press)); + let on_press_cb = use_callback(move |_| { + let mut on_press = on_press.as_ref().borrow_mut(); + on_press(); + }); + + let on_long_press = std::rc::Rc::new(RefCell::new(on_long_press)); + let on_long_press_cb = use_callback(move |_| { + let mut on_long_press = on_long_press.as_ref().borrow_mut(); + on_long_press(); + }); + + let mut timer = use_future(move || async move { + task::sleep(duration).await; + on_long_press_cb.call(()); + }); + + timer.cancel(); + + let selection_begin_cb = move |_: Event| { + timer.restart(); + }; + + let selection_end_cb = move |_: Event| { + if !timer.finished() { + timer.cancel(); + on_press_cb.call(()); + } + }; + + let mut handlers = Vec::new(); + for event_name in ["onmousedown", "ontouchstart"] { + let value = dioxus_core::AttributeValue::listener(selection_begin_cb); + handlers.push(Attribute::new(event_name, value, None, false)); + } + for event_name in ["onmouseup", "ontouchend"] { + let value = dioxus_core::AttributeValue::listener(selection_end_cb); + handlers.push(Attribute::new(event_name, value, None, false)); + } + + UseLongPress { + _timer: timer, + handlers, + } +} diff --git a/src/ui/layouts/conversations.rs b/src/ui/layouts/conversations.rs index 80cb846..58972a4 100644 --- a/src/ui/layouts/conversations.rs +++ b/src/ui/layouts/conversations.rs @@ -1,17 +1,28 @@ -use std::rc::Rc; +use std::{collections::HashSet, rc::Rc}; +use base64::{engine::general_purpose, Engine as _}; use dioxus::prelude::*; use futures::join; +use tracing::warn; + +use crate::{ + domain::model::room::RoomId, + ui::{ + components::{ + chat_panel::ChatPanel, conversations::Conversations as ConversationsComponent, + wallpaper::Wallpaper, + }, + STORE, + }, +}; turf::style_sheet!("src/ui/layouts/conversations.scss"); -use crate::ui::components::chat_panel::ChatPanel; -use crate::ui::components::conversations::Conversations as ConversationsComponent; -use crate::ui::components::wallpaper::Wallpaper; +include!(concat!(env!("OUT_DIR"), "/style_component_panel.rs")); +include!(concat!(env!("OUT_DIR"), "/style_layout_conversations.rs")); -// TODO: Get from SCSS -const WIDGET_HEIGHT_RATIO: f64 = 0.95; -const ASPECT_RATIO: f64 = 1.0 / 1.618; +use conversations::INNER_PANEL_HEIGHT_RATIO; +use panel::ASPECT_RATIO; async fn on_carousel_scroll( parent_div: &Rc, @@ -44,39 +55,63 @@ async fn on_carousel_scroll( } fn LayoutSmall() -> Element { - let mut carousel_div = use_signal(|| None::>); let mut first_div = use_signal(|| None::>); let mut last_div = use_signal(|| None::>); + let mut carousel_div = use_signal(|| None::>); - let conversation_panels_nb = 3; - let conversation_panels = (0..conversation_panels_nb + 1).map(|i| { - let inner = rsx! { - div { - class: ClassName::CONVERSATIONS_VIEW_SMALL_PANEL_INNER, - ChatPanel { name: format!("CHAT #{i}") }, - } - }; + let displayed_room_ids = STORE.read().displayed_room_ids(); - if i == conversation_panels_nb { - rsx! { - div { - class: ClassName::CONVERSATIONS_VIEW_SMALL_PANEL, - onmounted: move |cx: Event| last_div.set(Some(cx.data())), - {inner} + let mut conversation_panels = Vec::new(); + let mut displayed_room_ids_it = displayed_room_ids.iter().peekable(); + while let Some(room_id) = displayed_room_ids_it.next() { + if let Some(room) = STORE.read().rooms().get(room_id) { + let room = room.signal(); + let room_name_repr = room.name().unwrap_or(room.id().to_string()); + let inner = rsx! { + div { + class: ClassName::CONVERSATIONS_VIEW_SMALL_PANEL_INNER, + ChatPanel { name: format!("CHAT {room_name_repr}") } + } + }; + + // If this is the last iteration + let panel = if displayed_room_ids_it.peek().is_none() { + rsx! { + div { + class: ClassName::CONVERSATIONS_VIEW_SMALL_PANEL, + onmounted: move |cx: Event| last_div.set(Some(cx.data())), + {inner} + } } + } else { + rsx! { + div { + class: ClassName::CONVERSATIONS_VIEW_SMALL_PANEL, + {inner} + } + } + }; + + if let Ok(panel) = panel { + conversation_panels.push(panel); } } else { - rsx! { - div { - class: ClassName::CONVERSATIONS_VIEW_SMALL_PANEL, - {inner} - } + warn!("No {} room found", room_id); + } + } + + // Add tail div to dynamic rendered conversation_panels avoids side effects on layout changes + conversation_panels.push( + rsx! { + div { + class: ClassName::CONVERSATIONS_VIEW_TAIL, } } - }); + .unwrap(), + ); rsx! { - style { {STYLE_SHEET} }, + style { {STYLE_SHEET} } div { class: ClassName::CONVERSATIONS_VIEW_SMALL, @@ -112,58 +147,130 @@ fn LayoutSmall() -> Element { div { class: ClassName::CONVERSATIONS_VIEW_SMALL_CONVERSATIONS_PANEL_INNER, - ConversationsComponent {}, - }, - }, + ConversationsComponent {} + } + } - {conversation_panels} + {conversation_panels.iter()} + } + } +} + +#[component] +fn Tab(room_id: RoomId) -> Element { + let rooms = STORE.read().rooms(); + let room = rooms.get(&room_id).unwrap().signal(); + + let room_avatar = if let Some(content) = room.avatar() { + let encoded = general_purpose::STANDARD.encode(content); + rsx! { + div { + class: ClassName::TAB_AVATAR_IMAGE, + + div { + class: ClassName::TAB_AVATAR_IMAGE, + background_image: format!("url(data:image/jpeg;base64,{encoded})"), + } + } + } + } else { + VNode::empty() + }; + + rsx! { + div { + class: ClassName::TAB, + + {room_avatar} div { - class: ClassName::CONVERSATIONS_VIEW_TAIL, + class: ClassName::TAB_NAME, + + {room.name()} } } } } +#[component] +fn TabsBar(room_ids: HashSet) -> Element { + let tabs = room_ids + .iter() + .map(|room_id| rsx! { Tab { room_id: room_id.clone() }}); + + rsx! { + div { + class: ClassName::TABS_BAR, + + {tabs} + } + } +} + fn LayoutBig() -> Element { let mut carousel_div = use_signal(|| None::>); let mut first_div = use_signal(|| None::>); let mut last_div = use_signal(|| None::>); - let conversation_panels_nb = 3; - let conversation_panels = (0..conversation_panels_nb + 1).map(|i| { - if i == 0 { - rsx! { - div { - class: ClassName::CONVERSATIONS_VIEW_BIG_CONVERSATION_PANELS_PANEL, - onmounted: move |cx| async move { - let data = cx.data(); - let _ = data.as_ref().scroll_to(ScrollBehavior::Smooth).await; - first_div.set(Some(data)); - }, - ChatPanel { name: format!("CHAT #{i}") }, + let displayed_room_ids = STORE.read().displayed_room_ids(); + + let mut conversation_panels = Vec::new(); + let mut displayed_room_ids_it = displayed_room_ids.iter().peekable(); + let mut is_first = true; + while let Some(room_id) = displayed_room_ids_it.next() { + if let Some(room) = STORE.read().rooms().get(room_id) { + let room = room.signal(); + let room_name_repr = format!("CHAT {}", room.name().unwrap_or(room.id().to_string())); + let panel = if is_first { + is_first = false; + rsx! { + div { + class: ClassName::CONVERSATIONS_VIEW_BIG_CONVERSATIONS_PANELS_PANEL, + onmounted: move |cx| async move { + let data = cx.data(); + let _ = data.as_ref().scroll_to(ScrollBehavior::Smooth).await; + first_div.set(Some(data)); + }, + ChatPanel { name: room_name_repr } + } } - } - } else if i == conversation_panels_nb { - rsx! { - div { - class: ClassName::CONVERSATIONS_VIEW_BIG_CONVERSATION_PANELS_PANEL, - onmounted: move |cx: Event| last_div.set(Some(cx.data())), - ChatPanel { name: format!("CHAT #{i}") }, + } else if displayed_room_ids_it.peek().is_none() { + rsx! { + div { + class: ClassName::CONVERSATIONS_VIEW_BIG_CONVERSATIONS_PANELS_PANEL, + onmounted: move |cx: Event| last_div.set(Some(cx.data())), + ChatPanel { name: room_name_repr } + } } + } else { + rsx! { + div { + class: ClassName::CONVERSATIONS_VIEW_BIG_CONVERSATIONS_PANELS_PANEL, + ChatPanel { name: room_name_repr } + } + } + }; + + if let Ok(panel) = panel { + conversation_panels.push(panel); } } else { - rsx! { - div { - class: ClassName::CONVERSATIONS_VIEW_BIG_CONVERSATION_PANELS_PANEL, - ChatPanel { name: format!("CHAT #{i}") }, - } + warn!("No {} room found", room_id); + } + } + + // Add tail div to dynamic rendered conversation_panels avoids side effects on layout changes + conversation_panels.push( + rsx! { + div { + class: ClassName::CONVERSATIONS_VIEW_TAIL, } } - }); + .unwrap(), + ); rsx! { - style { {STYLE_SHEET} }, + style { {STYLE_SHEET} } div { class: ClassName::CONVERSATIONS_VIEW_BIG, @@ -171,82 +278,78 @@ fn LayoutBig() -> Element { div { class: ClassName::CONVERSATIONS_VIEW_BIG_CONVERSATIONS_PANEL, - ConversationsComponent {}, - }, + ConversationsComponent {} + } div { - class: ClassName::CONVERSATIONS_VIEW_BIG_CONVERSATION_PANELS, + class: ClassName::CONVERSATIONS_VIEW_BIG_CONVERSATIONS, - onmounted: move |cx| async move { - let data = cx.data(); - carousel_div.set(Some(data)); - }, + div { + class: ClassName::CONVERSATIONS_VIEW_BIG_CONVERSATIONS_TABS_BAR, - onscroll: move |_| { - async move { - if let (Some(carousel_div), Some(first_div), Some(last_div)) = ( - carousel_div.read().as_ref(), - first_div.read().as_ref(), - last_div.read().as_ref(), - ) { - on_carousel_scroll(carousel_div, first_div, last_div).await; + TabsBar { room_ids: displayed_room_ids} + } + + div { + class: ClassName::CONVERSATIONS_VIEW_BIG_CONVERSATIONS_PANELS, + + onmounted: move |cx| async move { + let data = cx.data(); + carousel_div.set(Some(data)); + }, + + onscroll: move |_| { + async move { + if let (Some(carousel_div), Some(first_div), Some(last_div)) = ( + carousel_div.read().as_ref(), + first_div.read().as_ref(), + last_div.read().as_ref(), + ) { + on_carousel_scroll(carousel_div, first_div, last_div).await; + } } + }, + + div { + class: ClassName::CONVERSATIONS_VIEW_HEAD, } - }, - div { - class: ClassName::CONVERSATIONS_VIEW_HEAD, + {conversation_panels.iter()} } - - {conversation_panels} - - div { - class: ClassName::CONVERSATIONS_VIEW_TAIL, - } - - }, + } } } } pub fn Conversations() -> Element { - let mut view_size = use_signal(|| None::<(f64, f64)>); - - // TODO: Make the layout reactive (on window resize) - let layout = { - move || { - if let Some((width, height)) = view_size.read().as_ref() { - let component_width = height * WIDGET_HEIGHT_RATIO * ASPECT_RATIO; - let breakpoint_width = component_width * 2_f64; - - if *width >= breakpoint_width { - return rsx! { LayoutBig {} }; - } - } - rsx! {LayoutSmall {}} - } - }(); + let mut use_big_layout = use_signal(|| false); rsx! { - style { {STYLE_SHEET} }, + style { {STYLE_SHEET} } Wallpaper { display_version: true - }, + } div { class: ClassName::CONVERSATIONS_VIEW, - onmounted: move |cx| { - async move { - let data = cx.data(); - if let Ok(client_rect) = data.get_client_rect().await { - view_size.set(Some((client_rect.size.width, client_rect.size.height))); - } + onresize: move |cx| { + let data = cx.data(); + + if let Ok(size) = data.get_border_box_size() { + // Use LayoutBig if the layout can contain 2 panels side by side + let component_width = size.height * INNER_PANEL_HEIGHT_RATIO * ASPECT_RATIO; + let breakpoint_width = component_width * 2_f64; + use_big_layout.set(size.width > breakpoint_width); } }, - {layout} + if use_big_layout() { + LayoutBig {} + } else { + LayoutSmall {} + } } } } diff --git a/src/ui/layouts/conversations.scss b/src/ui/layouts/conversations.scss index 8844a62..8038d9e 100644 --- a/src/ui/layouts/conversations.scss +++ b/src/ui/layouts/conversations.scss @@ -17,11 +17,13 @@ scroll-snap-align: start; } +$inner-panel-height-ratio: 0.95; + .conversations-view { $height: 100vh; $width: 100vw; - $conversations-panel-height: calc($height * 0.95); - $conversations-panel-width: calc($conversations-panel-height * $panel-aspect-ratio); + $conversations-panel-height: calc($height * $inner-panel-height-ratio); + $conversations-panel-width: calc($conversations-panel-height * $aspect-ratio); $gap: 1%; $content-height: 95%; $ratio: 2; @@ -63,11 +65,11 @@ height: 100%; // TODO: Is aspect-ratio the best criteria to defined that inner shall take all the available space ? - @media (max-aspect-ratio: $panel-aspect-ratio) { + @media (max-aspect-ratio: $aspect-ratio) { width: 100%; } - @media (min-aspect-ratio: $panel-aspect-ratio) { - aspect-ratio: $panel-aspect-ratio; + @media (min-aspect-ratio: $aspect-ratio) { + aspect-ratio: $aspect-ratio; } } } @@ -105,32 +107,109 @@ &__conversations-panel { height: $content-height; - aspect-ratio: $panel-aspect-ratio; + aspect-ratio: $aspect-ratio; } - &__conversation-panels { + &__conversations { height: $content-height; + min-width: 64px; flex-grow: 1; display: flex; - flex-direction: row; - overflow-x: scroll; - - justify-content: safe center; - align-items: safe center; - scroll-snap-type: x mandatory; - + flex-direction: column; gap: $gap; - &__panel { - flex-shrink: 0; - - height: 100%; + &__tabs-bar { + height: 5%; width: 100%; - scroll-snap-align: center; + flex-grow: 0; + } + + &__panels { + flex-grow: 1; + + display: flex; + flex-direction: row; + overflow-x: scroll; + + justify-content: safe center; + align-items: safe center; + scroll-snap-type: x mandatory; + + gap: $gap; + + &__panel { + flex-shrink: 0; + + height: 100%; + width: 100%; + + scroll-snap-align: center; + } } } } } + +.tabs-bar { + $gap: min(1vw, 8px); + + height: 100%; + width: 100%; + + display: flex; + gap: $gap; + overflow: scroll; + + scrollbar-width: none; + + white-space: nowrap; +} + +.tab { + $gap: min(1vw, 8px); + + height: 100%; + min-width: 0px; + max-width: 100%; + + flex-shrink: 0; + + display: inline-flex; + align-items: center; + gap: $gap; + + border: $border-normal; + border-color: get-color(primary, 90); + border-radius: $border-radius; + + padding: calc($gap / 2) $gap; + + font-size: 2vh; + + background-color: get-color(greyscale, 0); + + &__avatar-image { + height: 100%; + aspect-ratio: 1; + + border: $border-thin; + border-color: get-color(greyscale, 90); + border-radius: $border-radius; + + background-size: cover; + } + + &__name { + display: inline-block; + margin: 0px; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + cursor: default3; + } +} diff --git a/src/ui/layouts/login.rs b/src/ui/layouts/login.rs index 807661b..58001fe 100644 --- a/src/ui/layouts/login.rs +++ b/src/ui/layouts/login.rs @@ -7,7 +7,7 @@ turf::style_sheet!("src/ui/layouts/login.scss"); pub fn Login() -> Element { rsx! { - style { {STYLE_SHEET} }, + style { {STYLE_SHEET} } Wallpaper { display_version: true diff --git a/src/ui/layouts/login.scss b/src/ui/layouts/login.scss index 4b40530..5dde0b4 100644 --- a/src/ui/layouts/login.scss +++ b/src/ui/layouts/login.scss @@ -18,14 +18,14 @@ align-items: safe center; &__login-panel { - @media (max-aspect-ratio: $panel-aspect-ratio) { + @media (max-aspect-ratio: $aspect-ratio) { width: 95%; } - @media (min-aspect-ratio: $panel-aspect-ratio) { + @media (min-aspect-ratio: $aspect-ratio) { height: 100%; } - aspect-ratio: $panel-aspect-ratio; + aspect-ratio: $aspect-ratio; max-height: $panel-max-height; flex-shrink: 0; @@ -36,6 +36,6 @@ justify-content: center; // Variables inherited by children - --aspect-ratio: #{$panel-aspect-ratio}; + --aspect-ratio: #{$aspect-ratio}; } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d3dc2dd..134b666 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod components; +pub(crate) mod hooks; pub(crate) mod layouts; pub(crate) mod store; diff --git a/src/ui/store/mod.rs b/src/ui/store/mod.rs index 208ae74..c929257 100644 --- a/src/ui/store/mod.rs +++ b/src/ui/store/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod room; pub(crate) mod space; +use std::collections::HashSet; use std::{collections::HashMap, rc::Rc}; use async_trait::async_trait; @@ -21,6 +22,17 @@ use space::Space; pub struct Store { rooms: HashMap>, spaces: HashMap>, + + displayed_room_ids: HashSet, +} + +impl Store { + pub fn on_selected_room(&mut self, room_id: RoomId) { + // Toggle the room_id selection + if !self.displayed_room_ids.write().remove(&room_id) { + self.displayed_room_ids.write().insert(room_id); + } + } } #[async_trait(?Send)] diff --git a/src/ui/store/room.rs b/src/ui/store/room.rs index 413ec93..6d9145e 100644 --- a/src/ui/store/room.rs +++ b/src/ui/store/room.rs @@ -12,13 +12,15 @@ use crate::domain::model::{ store_interface::{RoomStoreConsumerInterface, RoomStoreProviderInterface}, }; -#[modx::props(id, is_direct, name, spaces)] +#[modx::props(id, is_direct, name, topic, spaces)] #[modx::store] pub struct Store { id: RoomId, is_direct: Option, name: Option, + topic: Option, + avatar: Option, members: Vec, invitations: Vec, @@ -31,7 +33,6 @@ pub struct Store { pub struct Room { store: RefCell, - #[allow(dead_code)] domain: Rc, } @@ -45,6 +46,7 @@ impl Room { room.id().clone(), room.is_direct(), room.name(), + room.topic(), room.spaces().clone(), ); @@ -54,6 +56,10 @@ impl Room { } } + pub async fn join(&self) { + self.domain.join().await; + } + #[allow(dead_code)] pub async fn get_avatar(&self) -> Option { self.domain.avatar().await @@ -71,8 +77,18 @@ impl RoomStoreProviderInterface for Room { store.avatar.set(avatar); } + fn on_new_topic(&self, topic: Option) { + let mut store = self.store.borrow_mut(); + store.topic.set(topic); + } + fn on_new_member(&self, member: RoomMember) { let mut store = self.store.borrow_mut(); + + if member.is_account_user() { + store.is_invited.set(false); + } + store.members.write().push(member); }