diff --git a/src/ui/_base.scss b/src/ui/_base.scss index 9bf236c..a443cc3 100644 --- a/src/ui/_base.scss +++ b/src/ui/_base.scss @@ -174,6 +174,9 @@ $border-big-width: 4px; $border-big: solid $border-big-width $border-default-color; $border-normal-width: 2px; $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). $border-radius: 16px; diff --git a/src/ui/components/conversations.rs b/src/ui/components/conversations.rs new file mode 100644 index 0000000..1b7d65f --- /dev/null +++ b/src/ui/components/conversations.rs @@ -0,0 +1,502 @@ +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::{ + base::{ACCOUNT, STORE}, + domain::model::{common::PresenceState as DomainPresenceState, room::RoomId, space::SpaceId}, + ui::components::icons::{ChatsIcon, LogoIcon, RoomsIcon, SpacesIcon}, +}; + +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})") + } + } + } + // TODO: Manage acount without avatar + None => None, + } +} + +#[component] +fn PresenceState(state: Option, class_name: Option) -> 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, class_name: Option) -> Element { + match display_name { + Some(display_name) => { + rsx! { + div { + class: class_name, + p { + {display_name}, + } + } + } + } + None => None, + } +} + +#[component] +fn Status(status: Option, class_name: Option) -> 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) -> 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::>>(); + + 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) -> Element { + let mut ordered_rooms = use_signal(Vec::::new); + + use_effect(move || { + let rooms = use_context::>>(); + 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::)); + 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 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::)); + 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 { + 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 {}, + }, + } + } +} diff --git a/src/ui/components/conversations.scss b/src/ui/components/conversations.scss new file mode 100644 index 0000000..c565a92 --- /dev/null +++ b/src/ui/components/conversations.scss @@ -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; + } +}