6 Commits

Author SHA1 Message Date
89473cfd61 🚨 Fix some clippy warnings 2024-05-26 11:53:47 +02:00
62015f8d13 Use of Conversations layout 2024-05-26 11:39:18 +02:00
c8e8e2da67 Add a first Conversations layout 2024-05-26 11:17:37 +02:00
df32faa8e6 🐛 Dioxus Props shall implement PartialEq and Clone traits 2024-05-26 10:58:51 +02:00
5194899de0 Add a first Conversations component 2024-05-26 10:45:09 +02:00
ff0ac7f982 🚧 Add a ChatPanel placeholder component 2024-05-25 14:06:40 +02:00
19 changed files with 1321 additions and 152 deletions

View File

@@ -1,71 +0,0 @@
use std::rc::Rc;
use dioxus::prelude::*;
use futures_util::stream::StreamExt;
use tracing::{debug, error, warn};
use crate::domain::model::account::Account;
use crate::domain::model::messaging_interface::AccountMessagingProviderInterface;
use crate::domain::model::session::Session;
use crate::infrastructure::messaging::matrix::client::Client;
use crate::infrastructure::messaging::matrix::worker_tasks::LoginStyle;
use crate::ui::store::Store;
pub async fn login(mut rx: UnboundedReceiver<bool>, session: &GlobalSignal<Session>) {
while let Some(is_logged) = rx.next().await {
error!("is_logged={is_logged}");
if !is_logged {
let homeserver_url = session.read().homeserver_url.clone();
let username = session.read().username.clone();
let password = session.read().password.clone();
if homeserver_url.is_some() && username.is_some() && password.is_some() {
let (requester, account_events_receiver) =
Client::spawn(homeserver_url.unwrap()).await;
if let Err(err) = requester.init().await {
error!("Following error occureds during client init: {}", err);
}
error!("Before login");
match requester
.login(LoginStyle::Password(username.unwrap(), password.unwrap()))
.await
{
Ok(_) => {
debug!("successfully logged");
session.write().is_logged = true;
let requester = Rc::new(requester);
dioxus::prelude::spawn(async move {
ACCOUNT.write().set_messaging_provider(requester.clone());
let _ = requester
.run_forever(&*ACCOUNT.read(), account_events_receiver)
.await;
});
}
Err(err) => {
error!("Error during login: {err}");
// TODO: Handle invalid login
// invalid_login.modify(|_| true);
return;
}
}
} else {
warn!("At least one of the following values is/are invalid: homeserver, username or password");
}
} else {
warn!("already logged... skip login");
}
}
error!("=== LOGIN END ===");
}
pub static STORE: GlobalSignal<Store> = Signal::global(Store::new);
// TODO: Merge ACCOUNT and SESSION
pub static ACCOUNT: GlobalSignal<Account> = Signal::global(|| Account::new(&STORE));
pub static SESSION: GlobalSignal<Session> = Signal::global(Session::new);

View File

@@ -52,6 +52,7 @@ impl Account {
self.messaging_provider = Some(provider.clone()); self.messaging_provider = Some(provider.clone());
} }
#[allow(dead_code)]
pub fn get_room(&self, room_id: &RoomId) -> Option<Rc<Room>> { pub fn get_room(&self, room_id: &RoomId) -> Option<Rc<Room>> {
self.by_id_rooms.borrow().get(room_id).cloned() self.by_id_rooms.borrow().get(room_id).cloned()
} }

View File

@@ -118,6 +118,7 @@ impl Room {
&self.id &self.id
} }
#[allow(dead_code)]
pub fn name(&self) -> Option<String> { pub fn name(&self) -> Option<String> {
self.name.borrow().clone() self.name.borrow().clone()
} }

View File

@@ -65,6 +65,7 @@ impl Space {
&self.id &self.id
} }
#[allow(dead_code)]
pub fn name(&self) -> Option<String> { pub fn name(&self) -> Option<String> {
self.name.borrow().clone() self.name.borrow().clone()
} }

View File

@@ -28,7 +28,10 @@ pub trait RoomStoreConsumerInterface {
fn id(&self) -> &RoomId; fn id(&self) -> &RoomId;
fn is_direct(&self) -> Option<bool>; fn is_direct(&self) -> Option<bool>;
fn name(&self) -> Option<String>; fn name(&self) -> Option<String>;
#[allow(dead_code)]
async fn avatar(&self) -> Option<Avatar>; async fn avatar(&self) -> Option<Avatar>;
fn spaces(&self) -> &Vec<SpaceId>; fn spaces(&self) -> &Vec<SpaceId>;
} }

View File

@@ -8,12 +8,22 @@ mod infrastructure;
mod ui; mod ui;
mod utils; mod utils;
use dioxus::prelude::*; use std::rc::Rc;
use tracing::{debug, error};
use dioxus::prelude::*;
use futures_util::stream::StreamExt;
use tracing::{debug, error, warn};
use tracing_forest::ForestLayer; use tracing_forest::ForestLayer;
use tracing_subscriber::prelude::*; use tracing_subscriber::{prelude::*, EnvFilter};
use tracing_subscriber::EnvFilter;
use crate::{
domain::model::{messaging_interface::AccountMessagingProviderInterface, session::Session},
infrastructure::messaging::matrix::{client::Client, worker_tasks::LoginStyle},
ui::{
layouts::{conversations::Conversations, login::Login},
ACCOUNT, SESSION,
},
};
cfg_if! { cfg_if! {
if #[cfg(target_family = "wasm")] { if #[cfg(target_family = "wasm")] {
@@ -26,80 +36,65 @@ cfg_if! {
} }
} }
use crate::base::{login, sync_rooms}; async fn login(mut rx: UnboundedReceiver<bool>, session: &GlobalSignal<Session>) {
use crate::base::{APP_SETTINGS, ROOMS, SESSION}; while let Some(is_logged) = rx.next().await {
use crate::ui::components::login::Login; if !is_logged {
use crate::ui::components::main_window::MainWindow; let homeserver_url = session.read().homeserver_url.clone();
use crate::ui::layouts::login::Login; let username = session.read().username.clone();
let password = session.read().password.clone();
mod base; if homeserver_url.is_some() && username.is_some() && password.is_some() {
let (requester, account_events_receiver) =
Client::spawn(homeserver_url.unwrap()).await;
if let Err(err) = requester.init().await {
warn!("Unable to login: {}", err);
}
match requester
.login(LoginStyle::Password(username.unwrap(), password.unwrap()))
.await
{
Ok(_) => {
debug!("successfully logged");
session.write().is_logged = true;
let requester = Rc::new(requester);
dioxus::prelude::spawn(async move {
ACCOUNT.write().set_messaging_provider(requester.clone());
let _ = requester
.run_forever(&*ACCOUNT.read(), account_events_receiver)
.await;
});
}
Err(err) => {
error!("Error during login: {err}");
// TODO: Handle invalid login
// invalid_login.modify(|_| true);
return;
}
}
} else {
warn!("At least one of the following values is/are invalid: homeserver, username or password");
}
} else {
warn!("already logged... skip login");
}
}
}
fn app() -> Element { fn app() -> Element {
debug!("*** App rendering ***"); let login_coro = use_coroutine(|rx| login(rx, &SESSION));
let login_coro = use_coroutine(|rx| login(rx, &APP_SETTINGS, &SESSION));
let mut sync_rooms_coro = None;
if let Some(requester) = &APP_SETTINGS.read().requester {
sync_rooms_coro = Some(use_coroutine(|rx| {
sync_rooms(rx, requester.borrow().receivers.clone(), &ROOMS)
}));
}
if !SESSION.read().is_logged { if !SESSION.read().is_logged {
login_coro.send(false); login_coro.send(false);
} else {
if let Some(coro) = sync_rooms_coro {
coro.send(true);
}
// if chats_win_state.read().is_none() {
// let chats_window = dioxus_desktop::use_window(cx);
// let receivers = app_settings
// .read()
// .requester
// .as_ref()
// .unwrap()
// .borrow()
// .receivers
// .clone();
// let chats_props = ChatsWindowProps {
// receivers,
// interface: chats_win_interface_ref.clone(),
// };
// let chats_dom = VirtualDom::new_with_props(ChatsWindow, chats_props);
// let window_cfg = Config::default().with_custom_head(
// r#"
// <style type="text/css">
// html, body {
// height: 100%;
// width: 100%;
// margin: 0;
// }
// #main, #bodywrap {
// height: 100%;
// width: 100%;
// }
// </style>
// "#
// .to_owned(),
// );
// let chats_window_desktop_service = chats_window.new_window(chats_dom, window_cfg);
// chats_win_state.set(Some(chats_window_desktop_service));
// }
} }
if SESSION.read().is_logged { if SESSION.read().is_logged {
debug!("Should render the MainWindow component");
rsx! { rsx! {
MainWindow {}, Conversations {}
} }
} else { } else {
rsx! { rsx! {

View File

@@ -174,6 +174,9 @@ $border-big-width: 4px;
$border-big: solid $border-big-width $border-default-color; $border-big: solid $border-big-width $border-default-color;
$border-normal-width: 2px; $border-normal-width: 2px;
$border-normal: solid $border-normal-width $border-default-color; $border-normal: solid $border-normal-width $border-default-color;
$border-thin-width: 1px;
$border-thin: solid $border-thin-width $border-default-color;
// TODO: Radius should be a percentage(eg: 1024/16px). // TODO: Radius should be a percentage(eg: 1024/16px).
$border-radius: 16px; $border-radius: 16px;

View File

@@ -0,0 +1,18 @@
use dioxus::prelude::*;
turf::style_sheet!("src/ui/components/chat_panel.scss");
#[component]
pub fn ChatPanel(name: String) -> Element {
rsx! {
style { {STYLE_SHEET} },
div {
class: ClassName::CHAT_PANEL,
div {
{name}
}
}
}
}

View File

@@ -0,0 +1,6 @@
@import "../_base.scss";
@import "./_panel.scss";
.chat-panel {
@include panel();
}

View File

@@ -0,0 +1,504 @@
use base64::{engine::general_purpose, Engine as _};
use dioxus::prelude::*;
use tracing::{debug, trace, 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},
ACCOUNT, STORE,
},
};
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})")
}
}
}
// TODO: Manage acount without avatar
None => None,
}
}
#[component]
fn PresenceState(state: Option<DomainPresenceState>, class_name: Option<String>) -> Element {
let class_name = class_name.unwrap_or("".to_string());
match state {
Some(state) => {
let state_class = match state {
DomainPresenceState::Online => ClassName::ONLINE,
DomainPresenceState::Offline => ClassName::OFFLINE,
DomainPresenceState::Unavailable => ClassName::UNAVAILABLE,
_ => ClassName::UNAVAILABLE,
};
let classes = [class_name.as_str(), state_class].join(" ");
rsx! {
div {
class: classes,
LogoIcon {},
}
}
}
None => None,
}
}
#[component]
fn DisplayName(display_name: Option<String>, class_name: Option<String>) -> Element {
match display_name {
Some(display_name) => {
rsx! {
div {
class: class_name,
p {
{display_name},
}
}
}
}
None => None,
}
}
#[component]
fn Status(status: Option<String>, class_name: Option<String>) -> Element {
let status = status.unwrap_or("Type your status".to_string());
rsx! {
div {
class: class_name,
TextInput {
placeholder: status,
}
}
}
}
pub fn Account() -> Element {
let avatar = use_resource(move || async move {
let account = ACCOUNT.read();
let avatar = account.get_avatar().await;
rsx! {
AccountAvatar {
class_name: ClassName::ACCOUNT_AVATAR,
content: avatar.borrow().clone(),
}
}
});
let presence_state = use_resource(move || async move {
// TODO: Fetch the state from the domain
rsx! {
PresenceState {
state: DomainPresenceState::Online,
class_name: ClassName::ACCOUNT_PRESENCE_STATE,
}
}
});
let display_name = use_resource(move || async move {
let account = ACCOUNT.read();
let display_name = account.get_display_name().await;
rsx! {
DisplayName {
class_name: ClassName::ACCOUNT_DISPLAY_NAME,
display_name: display_name.borrow().clone(),
}
}
});
let status = use_resource(move || async move {
// TODO: Fetch the status from the domain
rsx! {
Status {
class_name: ClassName::ACCOUNT_STATUS,
status: "Coucou, Je suis BG92".to_string(),
}
}
});
rsx! {
style { {STYLE_SHEET} },
div {
class: ClassName::ACCOUNT,
{avatar},
{presence_state},
{display_name},
{status},
div {
class: ClassName::ACCOUNT_SPACES,
Button {
SpacesIcon {},
}
},
div {
class: ClassName::ACCOUNT_CHAT,
Button {
ChatsIcon {},
}
},
div {
class: ClassName::ACCOUNT_ROOM,
Button {
RoomsIcon {},
}
},
}
}
}
#[component]
pub fn ConversationAvatar(room_id: RoomId, on_clicked: EventHandler<RoomId>) -> Element {
let rooms = STORE.read().rooms();
let toto = rooms.get(&room_id).unwrap();
let room = toto.signal();
let room_id = room.id();
let room_name = room.name();
let selected_room_id = use_context::<Signal<Option<RoomId>>>();
let invited_badge = if room.is_invited() {
rsx! {
div {
class: ClassName::CONVERSATION_AVATAR_INVITED_BADGE,
p {
"Invited",
}
}
}
} else {
None
};
let is_selected = match selected_room_id.read().as_ref() {
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! {
div {
class: ClassName::CONVERSATION_AVATAR_IMAGE,
background_image: format!("url(data:image/jpeg;base64,{encoded})"),
}
}
} else {
let placeholder = room_name
.unwrap_or("?".to_string())
.to_uppercase()
.chars()
.next()
.unwrap_or('?')
.to_string();
debug!("Use of {} placeholder for {}", placeholder, room_id);
rsx! {
div {
class: ClassName::CONVERSATION_AVATAR_IMAGE,
{placeholder},
}
}
};
let classes = [
ClassName::CONVERSATION_AVATAR,
if is_selected { ClassName::SELECTED } else { "" },
];
let classes_str = classes.join(" ");
rsx! {
div {
class: "{classes_str}",
onclick: move |evt| {
on_clicked.call(room_id.clone());
evt.stop_propagation();
},
{avatar}
{invited_badge}
}
}
}
#[component]
pub fn ConversationsCarousel(on_selected_conversation: EventHandler<RoomId>) -> Element {
let mut ordered_rooms = use_signal(Vec::<RoomId>::new);
use_effect(move || {
let rooms = use_context::<Signal<Vec<RoomId>>>();
let rooms = rooms.read();
for room in rooms.iter() {
if !ordered_rooms.peek().contains(room) {
ordered_rooms.push(room.clone());
}
}
ordered_rooms.retain(|room| rooms.contains(room));
});
let ordered_rooms = ordered_rooms.read();
let rendered_avatars = ordered_rooms.iter().map(|room| {
rsx! {
ConversationAvatar {
room_id: room.clone(),
on_clicked: on_selected_conversation,
}
}
});
rsx! {
div {
class: ClassName::SPACE_CONVERSATIONS_CAROUSEL,
// TODO: Needed?
onscroll: move |_| {
// Catch scrolling events.
},
{rendered_avatars},
}
}
}
#[component]
pub fn Space(id: SpaceId) -> Element {
let space = STORE.read().spaces().get(&id).unwrap().signal();
let name = space.name();
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 on_selected_conversation = move |room_id: RoomId| {
trace!("");
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},
}
}
}
}
}
#[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 {
let spaces = STORE.read().spaces();
let space_ids = spaces.keys().clone().last();
let rendered_spaces = space_ids.map(|id| {
rsx! {
Space { id: id.clone() }
}
});
rsx! {
style { {STYLE_SHEET} },
div {
class: ClassName::SPACES,
{rendered_spaces},
HomeSpace {},
}
}
}
pub fn Search() -> Element {
rsx! {
style { {STYLE_SHEET} },
div {
class: ClassName::SEARCH,
TextInput {}
div {
class: ClassName::SEARCH_BUTTON,
Button {
SearchIcon {}
}
}
}
}
}
pub fn Conversations() -> Element {
rsx! {
style { {STYLE_SHEET} },
div {
class: ClassName::CONVERSATIONS,
div {
class: ClassName::CONVERSATIONS_ACCOUNT,
Account {},
},
div {
class: ClassName::CONVERSATIONS_SPACES,
Spaces {},
},
div {
class: ClassName::CONVERSATIONS_SEARCH,
Search {},
},
}
}
}

View File

@@ -0,0 +1,305 @@
@import "../base.scss";
@import "./_panel.scss";
@import "./button.scss";
@mixin button-class {
button {
@include button(secondary, 90);
width: 100%;
max-height: 128px;
}
}
.account {
$colum-spacing: 5%;
$col-width: 8.75%;
$button-width: 20%;
$button-height: calc(100% / 3);
$buttons-row-margin-top: 10%;
height: 100%;
width: 100%;
display: grid;
grid-template-columns: auto $colum-spacing repeat(2, calc($button-width/2)) $colum-spacing $button-width $colum-spacing $button-width;
grid-template-rows: 30% auto $button-height;
row-gap: 5%;
grid-template-areas:
"avatar . state name name name name name"
"avatar . status status status status status status"
"avatar . spaces spaces . chat . room"
;
&__avatar {
grid-area: avatar;
display: flex;
align-items: center;
justify-content: center;
border: $border-normal;
border-radius: $border-radius;
background-size: cover;
background-position: center;
}
&__presence-state {
grid-area: state;
svg {
height: 100%;
width: 100%;
stroke: get-color(greyscale, 90);
}
&.online {
svg {
fill: get-color(primary, 100);
}
}
&.offline {
svg {
fill: get-color(ternary, 100);
}
}
&.unavailable {
svg {
fill: get-color(greyscale, 80);
}
}
}
&__display-name {
grid-area: name;
display: flex;
align-items: center;
justify-content: center;
p {
font-size: 2.5vh;
margin: 0;
text-align: center;
}
}
&__status {
grid-area: status;
}
@mixin extra-marged-button() {
@include button-class();
}
&__spaces {
grid-area: spaces;
@include extra-marged-button();
}
&__chat {
grid-area: chat;
@include extra-marged-button();
}
&__room {
grid-area: room;
@include extra-marged-button();
}
}
.spaces {
$gap: 1%;
$spaces-to-display: 5;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: $gap;
overflow-y: scroll;
// TODO: Manage android, Safari, ...
scrollbar-width: none;
$space-height: calc((100% - (($spaces-to-display - 1) * (1%))) / $spaces-to-display);
--space-height: #{$space-height};
}
.space {
$gap: 5%;
$vertical-padding: 1%;
$horizontal-padding: 1%;
height: var(--space-height);
width: 100%;
flex-shrink: 0;
border: $border-normal;
border-color: get-color(primary, 100);
border-radius: $border-radius;
padding: $vertical-padding $horizontal-padding;
$name-height: 15%;
$conversation-name-height: 15%;
display: grid;
grid-template-columns: 100%;
grid-template-rows: $name-height $gap auto 0% 0%;
grid-template-areas:
"name"
"."
"conversations-carousel"
"."
"conversation-name"
;
transition: $transition-duration;
&.display-conversation-name {
grid-template-rows: $name-height $gap auto $gap $conversation-name-height;
}
cursor: default;
p {
margin: 0;
}
&__name {
grid-area: name;
display: flex;
align-items: center;
justify-content: left;
font-size: 2vh;
}
&__conversations-carousel {
grid-area: conversations-carousel;
display: flex;
flex-flow: row;
gap: 1%;
overflow-x: scroll;
// TODO: Manage android, Safari, ...
scrollbar-width: none;
}
&__conversation-name {
grid-area: conversation-name;
display: flex;
align-items: center;
justify-content: center;
font-size: 2vh;
}
}
.conversation-avatar {
height: 100%;
aspect-ratio: 1;
flex-shrink: 0;
border: $border-thin;
border-radius: $border-radius;
overflow: hidden;
filter: brightness(90%);
&.selected {
filter: brightness(120%);
}
&__image {
height: 100%;
width: 100%;
background-size: cover;
display: flex;
align-items: center;
justify-content: center;
font-size: 6vh;
color: get-color(primary, 80);
}
&__invited-badge {
$height: 20%;
height: $height;
width: 100%;
position: relative;
top: calc($height * -1);
color: get-color(greyscale, 0);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5vh;
background-color: get-color(ternary, 100);
}
}
.search {
height: 100%;
width: 100%;
display: flex;
gap: 5%;
&__button {
@include button-class();
width: 20%;
flex-shrink: 0;
}
}
.conversations {
@include panel();
$gap: 1%;
$account-height: 15%;
$search-height: 5%;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: $gap;
&__account {
height: $account-height;
max-height: 384px;
}
&__spaces {
min-height: calc(100% - $account-height - $search-height - (2 * $gap));
}
&__search {
height: $search-height;
max-height: 128px;
}
}

View File

@@ -1,7 +1,4 @@
use std::borrow::Cow; use std::{borrow::Cow, cell::RefCell, collections::HashMap, rc::Rc};
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use const_format::formatcp; use const_format::formatcp;
use dioxus::prelude::*; use dioxus::prelude::*;
@@ -9,16 +6,18 @@ use tracing::{debug, error, warn};
use validator::{Validate, ValidateArgs, ValidateEmail, ValidationError, ValidationErrors}; use validator::{Validate, ValidateArgs, ValidateEmail, ValidationError, ValidationErrors};
use zxcvbn::zxcvbn; use zxcvbn::zxcvbn;
use crate::base::SESSION; use crate::{
use crate::domain::model::session::Session; domain::model::session::Session,
use crate::infrastructure::services::random_svg_generators::{ infrastructure::services::random_svg_generators::{generate_random_svg_shape, ShapeConfig},
generate_random_svg_shape, ShapeConfig, ui::SESSION,
}; };
use super::button::{LoginButton, RegisterButton}; use super::{
use super::modal::{Modal, Severity}; button::{LoginButton, RegisterButton},
use super::spinner::Spinner; modal::{Modal, Severity},
use super::text_input::{PasswordInputState, PasswordTextInput, TextInput, TextInputState}; spinner::Spinner,
text_input::{PasswordInputState, PasswordTextInput, TextInput, TextInputState},
};
include!(concat!(env!("OUT_DIR"), "/style_vars.rs")); include!(concat!(env!("OUT_DIR"), "/style_vars.rs"));

View File

@@ -10,7 +10,7 @@ turf::style_sheet!("src/ui/components/text_input.scss");
pub trait InputPropsData {} pub trait InputPropsData {}
#[derive(Props, Clone, PartialEq)] #[derive(Props, Clone, PartialEq)]
pub struct InputProps<D: InputPropsData + 'static + std::cmp::PartialEq> { pub struct InputProps<D: InputPropsData + 'static + std::cmp::PartialEq + std::clone::Clone> {
value: Option<String>, value: Option<String>,
placeholder: Option<String>, placeholder: Option<String>,
oninput: Option<EventHandler<Event<FormData>>>, oninput: Option<EventHandler<Event<FormData>>>,

View File

@@ -0,0 +1,252 @@
use std::rc::Rc;
use dioxus::prelude::*;
use futures::join;
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;
// TODO: Get from SCSS
const WIDGET_HEIGHT_RATIO: f64 = 0.95;
const ASPECT_RATIO: f64 = 1.0 / 1.618;
async fn on_carousel_scroll(
parent_div: &Rc<MountedData>,
first_div: &Rc<MountedData>,
last_div: &Rc<MountedData>,
) {
let results = join!(
parent_div.get_scroll_offset(),
parent_div.get_scroll_size(),
last_div.get_client_rect()
);
if let (Ok(offset), Ok(size), Ok(last_div_rect)) = results {
let left = offset.x;
let width = size.width;
// The left border of the first div has been exceeded, scrool to the last div.
if left <= 0.0 {
let _ = last_div.scroll_to(ScrollBehavior::Smooth).await;
}
// The left border of the last div has been exceeded, scrool to the first div.
else {
let last_div_width = last_div_rect.size.width;
let distance_to_tail = width - left - last_div_width;
if distance_to_tail < 1.0 {
let first_div = first_div.as_ref(); //.unwrap();
let _ = first_div.scroll_to(ScrollBehavior::Smooth).await;
}
}
}
}
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 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}") },
}
};
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}
}
}
} else {
rsx! {
div {
class: ClassName::CONVERSATIONS_VIEW_SMALL_PANEL,
{inner}
}
}
}
});
rsx! {
style { {STYLE_SHEET} },
div {
class: ClassName::CONVERSATIONS_VIEW_SMALL,
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_SMALL_CONVERSATIONS_PANEL,
onmounted: move |cx| async move {
let data = cx.data();
let _ = data.as_ref().scroll_to(ScrollBehavior::Smooth).await;
first_div.set(Some(data));
},
div {
class: ClassName::CONVERSATIONS_VIEW_SMALL_CONVERSATIONS_PANEL_INNER,
ConversationsComponent {},
},
},
{conversation_panels}
div {
class: ClassName::CONVERSATIONS_VIEW_TAIL,
}
}
}
}
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}") },
}
}
} 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 {
rsx! {
div {
class: ClassName::CONVERSATIONS_VIEW_BIG_CONVERSATION_PANELS_PANEL,
ChatPanel { name: format!("CHAT #{i}") },
}
}
}
});
rsx! {
style { {STYLE_SHEET} },
div {
class: ClassName::CONVERSATIONS_VIEW_BIG,
div {
class: ClassName::CONVERSATIONS_VIEW_BIG_CONVERSATIONS_PANEL,
ConversationsComponent {},
},
div {
class: ClassName::CONVERSATIONS_VIEW_BIG_CONVERSATION_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,
}
{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 {}}
}
}();
rsx! {
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)));
}
}
},
{layout}
}
}
}

View File

@@ -0,0 +1,136 @@
@import "../_base.scss";
@import "../components/_panel.scss";
.conversations-view-head {
height: 100%;
width: 1px;
flex-shrink: 0;
scroll-snap-align: end;
}
.conversations-view-tail {
height: 100%;
width: 1px;
flex-shrink: 0;
scroll-snap-align: start;
}
.conversations-view {
$height: 100vh;
$width: 100vw;
$conversations-panel-height: calc($height * 0.95);
$conversations-panel-width: calc($conversations-panel-height * $panel-aspect-ratio);
$gap: 1%;
$content-height: 95%;
$ratio: 2;
height: 100%;
width: 100%;
position: relative;
top: -100vh;
margin-bottom: -100vh;
&__small {
scroll-snap-type: x mandatory;
height: 100%;
width: 100%;
display: flex;
flex-direction: row;
gap: $gap;
justify-content: safe center;
align-items: safe center;
overflow-x: scroll;
&__conversations-panel {
height: $content-height;
width: 100%;
flex-shrink: 0;
scroll-snap-align: center;
display: flex;
align-items: center;
justify-content: center;
&__inner {
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) {
width: 100%;
}
@media (min-aspect-ratio: $panel-aspect-ratio) {
aspect-ratio: $panel-aspect-ratio;
}
}
}
&__panel {
flex-shrink: 0;
height: $content-height;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
scroll-snap-align: center;
&__inner {
height: 100%;
width: calc(100% - (2 * $gap));
}
}
}
&__big {
height: 100%;
width: 100%;
width: calc(100% - (2 * $gap));
display: flex;
flex-direction: row;
align-items: safe center;
gap: $gap;
margin: 0 $gap;
&__conversations-panel {
height: $content-height;
aspect-ratio: $panel-aspect-ratio;
}
&__conversation-panels {
height: $content-height;
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;
}
}
}
}

View File

@@ -1 +1,2 @@
pub(crate) mod conversations;
pub(crate) mod login; pub(crate) mod login;

View File

@@ -1,3 +1,13 @@
pub(crate) mod components; pub(crate) mod components;
pub(crate) mod layouts; pub(crate) mod layouts;
pub(crate) mod store; pub(crate) mod store;
use dioxus::prelude::{GlobalSignal, Signal};
use super::domain::model::{account::Account, session::Session};
use store::Store;
pub static STORE: GlobalSignal<Store> = Signal::global(Store::new);
// TODO: Merge ACCOUNT and SESSION
pub static ACCOUNT: GlobalSignal<Account> = Signal::global(|| Account::new(&STORE));
pub static SESSION: GlobalSignal<Session> = Signal::global(Session::new);

View File

@@ -30,6 +30,8 @@ pub struct Store {
#[derive(Clone)] #[derive(Clone)]
pub struct Room { pub struct Room {
store: RefCell<Store>, store: RefCell<Store>,
#[allow(dead_code)]
domain: Rc<dyn RoomStoreConsumerInterface>, domain: Rc<dyn RoomStoreConsumerInterface>,
} }
@@ -52,6 +54,7 @@ impl Room {
} }
} }
#[allow(dead_code)]
pub async fn get_avatar(&self) -> Option<Avatar> { pub async fn get_avatar(&self) -> Option<Avatar> {
self.domain.avatar().await self.domain.avatar().await
} }

View File

@@ -20,6 +20,8 @@ pub struct Store {
#[derive(Clone)] #[derive(Clone)]
pub struct Space { pub struct Space {
store: RefCell<Store>, store: RefCell<Store>,
#[allow(dead_code)]
domain: Rc<dyn SpaceStoreConsumerInterface>, domain: Rc<dyn SpaceStoreConsumerInterface>,
} }