21 Commits

Author SHA1 Message Date
7989d86af1 Merge branch 'conversations-panel' into develop
Some checks failed
ci/woodpecker/push/dockerize Pipeline failed
ci/woodpecker/push/deploy unknown status
2024-09-22 21:19:37 +02:00
d2108fa6fc ♻️ Remove duplicated if statement 2024-09-22 19:39:06 +02:00
44ba3d4d23 🐛 script_include is not used anymore (since Dioxus #2258) 2024-09-22 19:36:43 +02:00
5206fb13c8 ♻️ Use of manganis for wallpaper pattern 2024-09-22 18:51:48 +02:00
27934c7fc9 🚨 Fix some clippy warnings 2024-09-08 16:12:33 +02:00
9d95bd4481 Add the capability to join a conversation 2024-09-08 16:07:13 +02:00
648be8ba72 🐛 Add tail div to dynamic conversation_panels to fix some side effects 2024-09-07 13:01:26 +02:00
b7b98dff15 Add TabsBar to the LayoutBig (Conversations layout) 2024-09-07 12:58:39 +02:00
aaafa91cbe ⬆️ Bump turf version (0.8.0 -> 0.9.3) 2024-08-21 23:29:35 +02:00
9a5f7ae504 ⬆️ Use of Dioxus main branch instead of 0.5 release 2024-08-21 23:29:33 +02:00
d5d996eec3 Add topic to the UI store Room 2024-06-27 08:25:11 +02:00
73c5b70ba8 🚧 First design template for ConversationOptionsMenu 2024-06-22 20:57:44 +02:00
f0463213cf 🚧 Use of the use_long_press to open a future room configuration menu 2024-06-22 20:55:19 +02:00
e55992aed5 💄 Add Reject and Join buttons 2024-06-22 20:52:08 +02:00
cea60ce695 Add ui use_long_press hook 2024-06-15 16:19:20 +02:00
271e865d40 💄 Open/close ChatPanel on click (Conversations layout/big breakpoint) 2024-06-09 12:31:18 +02:00
ffe759e749 ♻️ Merge Space and HomeSpace components 2024-06-09 11:22:48 +02:00
9baa7f290a 💄 Open/close ChatPanel on click 2024-06-09 09:43:46 +02:00
d566a4927f ♻️ Use of the SCSS variables to compute the width of the inner panels 2024-06-08 13:04:17 +02:00
1ad4d444fb 💄 Add first breakpoint management by Conversations layout 2024-06-07 22:18:15 +02:00
204e11b8b1 🐛 tracing_forest crate isn't used for wasm 2024-05-26 18:09:05 +02:00
33 changed files with 1063 additions and 463 deletions

View File

@@ -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"] }

138
build.rs
View File

@@ -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<Task>) {
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::<Box<dyn Display>>::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::<f64>() {
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);
};
}
}

View File

@@ -10,7 +10,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="UTF-8" />
{style_include}
{script_include}
</head>
<body>
<div id="main">

View File

@@ -44,6 +44,7 @@ pub trait RoomMessagingConsumerInterface {
#[async_trait(?Send)]
pub trait RoomMessagingProviderInterface {
async fn get_avatar(&self, id: &RoomId) -> anyhow::Result<Option<Avatar>>;
async fn join(&self, room_id: &RoomId) -> anyhow::Result<bool>;
}
#[async_trait(?Send)]

View File

@@ -123,11 +123,6 @@ impl Room {
self.name.borrow().clone()
}
#[allow(dead_code)]
pub fn topic(&self) -> &Option<String> {
&self.topic
}
#[allow(dead_code)]
pub fn set_topic(&mut self, topic: Option<String>) {
self.topic = topic;
@@ -308,6 +303,10 @@ impl RoomStoreConsumerInterface for Room {
self.name.borrow().clone()
}
fn topic(&self) -> Option<String> {
self.topic.clone()
}
async fn avatar(&self) -> Option<Avatar> {
self.get_avatar().await
}
@@ -315,4 +314,10 @@ impl RoomStoreConsumerInterface for Room {
fn spaces(&self) -> &Vec<SpaceId> {
&self.spaces
}
async fn join(&self) {
if let Some(messaging_provider) = &self.messaging_provider {
let _ = messaging_provider.join(&self.id).await;
}
}
}

View File

@@ -28,16 +28,18 @@ pub trait RoomStoreConsumerInterface {
fn id(&self) -> &RoomId;
fn is_direct(&self) -> Option<bool>;
fn name(&self) -> Option<String>;
fn topic(&self) -> Option<String>;
fn spaces(&self) -> &Vec<SpaceId>;
#[allow(dead_code)]
async fn avatar(&self) -> Option<Avatar>;
fn spaces(&self) -> &Vec<SpaceId>;
async fn join(&self);
}
pub trait RoomStoreProviderInterface {
fn on_new_name(&self, name: Option<String>);
fn on_new_avatar(&self, avatar: Option<Avatar>);
fn on_new_topic(&self, topic: Option<String>);
fn on_new_member(&self, member: RoomMember);
fn on_invitation(&self, invitation: Invitation);
}

View File

@@ -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<Senders>) {
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<bool> {
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<WorkerTask>) {
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;
}
}
}
}

View File

@@ -362,6 +362,10 @@ impl RoomMessagingProviderInterface for Requester {
async fn get_avatar(&self, room_id: &RoomId) -> anyhow::Result<Option<Avatar>> {
request_to_worker!(self, WorkerTask::GetRoomAvatar, room_id.clone())
}
async fn join(&self, room_id: &RoomId) -> anyhow::Result<bool> {
request_to_worker!(self, WorkerTask::JoinRoom, room_id.clone())
}
}
#[async_trait(?Send)]

View File

@@ -23,6 +23,7 @@ pub enum WorkerTask {
OwnedUserId,
Sender<anyhow::Result<Option<Vec<u8>>>>,
),
JoinRoom(OwnedRoomId, Sender<anyhow::Result<bool>>),
}
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(),
}
}
}

View File

@@ -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<u8>) -> Option<DynamicImage> {
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),

View File

@@ -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 {}
}
}
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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<Vec<u8>>, class_name: Option<String>) -> 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<DomainPresenceState>, class_name: Option<String>)
rsx! {
div {
class: classes,
LogoIcon {},
LogoIcon {}
}
}
}
None => None,
None => VNode::empty(),
}
}
@@ -64,12 +66,12 @@ fn DisplayName(display_name: Option<String>, class_name: Option<String>) -> 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<RoomId>) -> Element {
pub fn ConversationAvatar(
room_id: RoomId,
on_selected: Option<EventHandler<RoomId>>,
on_pressed: Option<EventHandler<RoomId>>,
) -> 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::<Signal<Option<RoomId>>>();
let selected_room_id = use_signal(|| None::<RoomId>);
let invited_badge = if room.is_invited() {
rsx! {
@@ -184,20 +187,19 @@ pub fn ConversationAvatar(room_id: RoomId, on_clicked: EventHandler<RoomId>) ->
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<RoomId>) ->
div {
class: ClassName::CONVERSATION_AVATAR_IMAGE,
{placeholder},
{placeholder}
}
}
};
@@ -232,14 +234,24 @@ pub fn ConversationAvatar(room_id: RoomId, on_clicked: EventHandler<RoomId>) ->
];
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<RoomId>) ->
}
#[component]
pub fn ConversationsCarousel(on_selected_conversation: EventHandler<RoomId>) -> Element {
pub fn ConversationsCarousel(
on_selected_conversation: EventHandler<RoomId>,
on_pressed_conversation: EventHandler<RoomId>,
) -> Element {
let mut ordered_rooms = use_signal(Vec::<RoomId>::new);
use_effect(move || {
@@ -267,7 +282,8 @@ pub fn ConversationsCarousel(on_selected_conversation: EventHandler<RoomId>) ->
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<RoomId>) ->
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<SpaceId>, on_pressed_conversation: EventHandler<RoomId>) -> Element {
let mut selected_room_id = use_context_provider(|| Signal::new(None::<RoomId>));
let mut displayed_rooms = use_context_provider(|| Signal::new(Vec::<RoomId>::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::<RoomId>));
let mut displayed_rooms = use_context_provider(|| Signal::new(Vec::<RoomId>::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<RoomId>) -> 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<ConversationOptionsMenuActions>,
) -> Element {
let room = STORE.read().rooms().get(&room_id).unwrap().signal();
let topic = room.topic().unwrap_or("<No topic set>".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::<RoomId>);
let on_pressed_conversation = move |id: RoomId| {
room_id.set(Some(id));
};
let callbacks = use_coroutine(
move |mut rx: UnboundedReceiver<ConversationOptionsMenuActions>| 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}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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 },
}
}

View File

@@ -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<dyn OnValidationError> {
}
}
#[derive(Clone)]
struct TextInputHandler {
state: Signal<TextInputState>,
}
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<dyn OnValidationError> {
Box::new(self.clone())
}
}
#[derive(Clone)]
struct UrlInputHandler {
state: Signal<TextInputState>,
@@ -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}
}
}

View File

@@ -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);
}
},
},
},
},
},
}
}
}
}
}
}
}

View File

@@ -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,
}
}

View File

@@ -67,7 +67,7 @@ pub fn TextInput(props: InputProps<TextInputState>) -> 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<TextInputState>) -> Element {
cb.call(evt);
}
},
},
}
div {
class: ClassName::TEXT_INPUT_HELPER_TEXT,
@@ -159,7 +159,7 @@ pub fn PasswordTextInput(props: InputProps<PasswordInputState>) -> 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<PasswordInputState>) -> Element {
cb.call(evt);
}
},
},
}
if let Some(score) = score {
div {
@@ -184,7 +184,7 @@ pub fn PasswordTextInput(props: InputProps<PasswordInputState>) -> Element {
ratio: score,
}
}
},
}
div {
class: ClassName::PASSWORD_TEXT_INPUT_SHOW_TOGGLE,
@@ -203,7 +203,7 @@ pub fn PasswordTextInput(props: InputProps<PasswordInputState>) -> Element {
icon: IoEye,
}
}
},
}
div {
class: ClassName::PASSWORD_TEXT_INPUT_HELPER_TEXT,
@@ -212,7 +212,7 @@ pub fn PasswordTextInput(props: InputProps<PasswordInputState>) -> Element {
class: criticity_class,
{helper_text}
}
},
}
}
}
}

View File

@@ -1,4 +1,4 @@
@import "../_base.scss"
@import "../_base.scss";
%base-text-input {
$horizontal-padding: 1vw;

View File

@@ -7,20 +7,28 @@ turf::style_sheet!("src/ui/components/wallpaper.scss");
#[component]
pub fn Wallpaper(display_version: Option<bool>) -> 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}
}
}
}

View File

@@ -12,7 +12,6 @@
overflow: hidden;
&__content {
background-image: url("./images/wallpaper-pattern.svg");
background-position: center;
width: 150%;

3
src/ui/hooks/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub use use_long_press::use_long_press;
mod use_long_press;

View File

@@ -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<Attribute>,
}
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<PlatformEventData>| {
timer.restart();
};
let selection_end_cb = move |_: Event<PlatformEventData>| {
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,
}
}

View File

@@ -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<MountedData>,
@@ -44,39 +55,63 @@ async fn on_carousel_scroll(
}
fn LayoutSmall() -> Element {
let mut carousel_div = use_signal(|| None::<Rc<MountedData>>);
let mut first_div = use_signal(|| None::<Rc<MountedData>>);
let mut last_div = use_signal(|| None::<Rc<MountedData>>);
let mut carousel_div = use_signal(|| None::<Rc<MountedData>>);
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<MountedData>| 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<MountedData>| 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<RoomId>) -> 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::<Rc<MountedData>>);
let mut first_div = use_signal(|| None::<Rc<MountedData>>);
let mut last_div = use_signal(|| None::<Rc<MountedData>>);
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<MountedData>| 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<MountedData>| 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 {}
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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};
}
}

View File

@@ -1,4 +1,5 @@
pub(crate) mod components;
pub(crate) mod hooks;
pub(crate) mod layouts;
pub(crate) mod store;

View File

@@ -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<RoomId, Rc<Room>>,
spaces: HashMap<SpaceId, Rc<Space>>,
displayed_room_ids: HashSet<RoomId>,
}
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)]

View File

@@ -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<bool>,
name: Option<String>,
topic: Option<String>,
avatar: Option<Avatar>,
members: Vec<RoomMember>,
invitations: Vec<Invitation>,
@@ -31,7 +33,6 @@ pub struct Store {
pub struct Room {
store: RefCell<Store>,
#[allow(dead_code)]
domain: Rc<dyn RoomStoreConsumerInterface>,
}
@@ -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<Avatar> {
self.domain.avatar().await
@@ -71,8 +77,18 @@ impl RoomStoreProviderInterface for Room {
store.avatar.set(avatar);
}
fn on_new_topic(&self, topic: Option<String>) {
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);
}