summaryrefslogtreecommitdiffstats
path: root/src/lib.rs
diff options
context:
space:
mode:
authorLibravatar cel 🌸 <cel@bunny.garden>2025-06-01 16:10:26 +0100
committerLibravatar cel 🌸 <cel@bunny.garden>2025-06-01 17:27:40 +0100
commit6ee4190a26f32bfa953302ee363ad3bb6c384ebb (patch)
tree2c3182c29d5780a0ad9c9770b5e546312bea49b4 /src/lib.rs
parentf76c80c1d23177ab00c81240ee3a75d3bcda0e3b (diff)
downloadmacaw-web-6ee4190a26f32bfa953302ee363ad3bb6c384ebb.tar.gz
macaw-web-6ee4190a26f32bfa953302ee363ad3bb6c384ebb.tar.bz2
macaw-web-6ee4190a26f32bfa953302ee363ad3bb6c384ebb.zip
refactor: reorganise code
Diffstat (limited to 'src/lib.rs')
-rw-r--r--src/lib.rs2795
1 files changed, 16 insertions, 2779 deletions
diff --git a/src/lib.rs b/src/lib.rs
index 772ea60..4c66911 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,2780 +1,17 @@
-use std::{
- borrow::Borrow,
- cell::RefCell,
- collections::{HashMap, HashSet},
- marker::PhantomData,
- ops::{Deref, DerefMut},
- rc::Rc,
- str::FromStr,
- sync::{atomic::AtomicUsize, Arc, RwLock},
- thread::sleep,
- time::{self, Duration},
-};
+pub use views::App;
+
+mod state_store;
+mod icon;
+mod user;
+mod chat;
+mod open_chats;
+mod components;
+mod views;
+mod files;
+mod client;
+mod roster;
+mod contact;
+mod message;
+mod message_subscriptions;
+mod user_presences;
-use base64::{Engine, prelude::BASE64_STANDARD};
-use chrono::{Local, NaiveDateTime, TimeDelta, Utc};
-use filamento::{
- chat::{Body, Chat, ChatStoreFields, Delivery, Message, MessageStoreFields}, db::Db, error::{AvatarPublishError, CommandError, ConnectionError, DatabaseError, SubscribeError}, files::{opfs::OPFSError, FileStore, FilesMem, FilesOPFS}, presence::{Offline, Online, Presence, PresenceType, Show}, roster::{Contact, ContactStoreFields}, user::{User, UserStoreFields}, UpdateMessage
-};
-use futures::stream::StreamExt;
-use indexmap::IndexMap;
-use jid::{JID, BareJID};
-use leptos::{
- ev::{Event, KeyboardEvent, MouseEvent, SubmitEvent},
- html::{self, Div, Input, Pre, Textarea},
- prelude::*,
- tachys::{dom::document, reactive_graph::bind::GetValue, renderer::dom::Element},
- task::{spawn, spawn_local},
-};
-use reactive_stores::{ArcStore, Store, StoreField};
-use stylance::import_style;
-use thiserror::Error;
-use tokio::sync::{
- Mutex,
- mpsc::{self, Receiver},
-};
-use tracing::{debug, error};
-use uuid::Uuid;
-use web_sys::{js_sys::Uint8Array, wasm_bindgen::{prelude::Closure, JsCast, UnwrapThrowExt}, FileReader, HtmlInputElement, ProgressEvent};
-
-const NO_AVATAR: &str = "/assets/no-avatar.png";
-
-pub enum AppState {
- LoggedOut,
- LoggedIn,
-}
-
-#[derive(Clone)]
-pub struct Client {
- client: filamento::Client<Files>,
- resource: ArcRwSignal<Option<String>>,
- jid: Arc<BareJID>,
- file_store: Files,
-}
-
-#[derive(Clone, Debug)]
-pub enum Files {
- Mem(FilesMem),
- Opfs(FilesOPFS),
-}
-
-impl FileStore for Files {
- type Err = OPFSError;
-
- async fn is_stored(&self, name: &str) -> Result<bool, Self::Err> {
- match self {
- Files::Mem(files_mem) => Ok(files_mem.is_stored(name).await.unwrap()),
- Files::Opfs(files_opfs) => Ok(files_opfs.is_stored(name).await?),
- }
- }
-
- async fn store(&self, name: &str, data: &[u8]) -> Result<(), Self::Err> {
- match self {
- Files::Mem(files_mem) => Ok(files_mem.store(name, data).await.unwrap()),
- Files::Opfs(files_opfs) => Ok(files_opfs.store(name, data).await?),
- }
- }
-
- async fn delete(&self, name: &str) -> Result<(), Self::Err> {
- match self {
- Files::Mem(files_mem) => Ok(files_mem.delete(name).await.unwrap()),
- Files::Opfs(files_opfs) => Ok(files_opfs.delete(name).await?),
- }
- }
-}
-
-impl Files {
- pub async fn get_src(&self, file_name: &str) -> Option<String> {
- match self {
- Files::Mem(files_mem) => {
- if let Some(data) = files_mem.get_file(file_name).await {
- let data = BASE64_STANDARD.encode(data);
- Some(format!("data:image/jpg;base64, {}", data))
- } else {
- None
- }
- }
- Files::Opfs(files_opfs) => files_opfs.get_src(file_name).await.ok(),
- }
- }
-}
-
-impl Deref for Client {
- type Target = filamento::Client<Files>;
-
- fn deref(&self) -> &Self::Target {
- &self.client
- }
-}
-
-impl DerefMut for Client {
- fn deref_mut(&mut self) -> &mut Self::Target {
- &mut self.client
- }
-}
-
-#[component]
-pub fn App() -> impl IntoView {
- let (app, set_app) = signal(AppState::LoggedOut);
- let (client, set_client) = signal(None::<(Client, Receiver<UpdateMessage>)>);
-
- view! {
- {move || match &*app.read() {
- AppState::LoggedOut => view! { <LoginPage set_app set_client /> }.into_any(),
- AppState::LoggedIn => {
- if let Some((client, updates)) = set_client.write_untracked().take() {
- view! { <Macaw client updates set_app /> }.into_any()
- } else {
- set_app.set(AppState::LoggedOut);
- view! { <LoginPage set_app set_client /> }.into_any()
- }
- }
- }}
- }
-}
-
-#[derive(Clone, Debug, Error)]
-pub enum LoginError {
- #[error("Missing Password")]
- MissingPassword,
- #[error("Missing JID")]
- MissingJID,
- #[error("Invalid JID: {0}")]
- InvalidJID(#[from] jid::ParseError),
- #[error("Connection Error: {0}")]
- ConnectionError(#[from] CommandError<ConnectionError>),
- #[error("OPFS: {0}")]
- OPFS(#[from] OPFSError),
-}
-
-#[component]
-fn LoginPage(
- set_app: WriteSignal<AppState>,
- set_client: WriteSignal<Option<(Client, Receiver<UpdateMessage>)>>,
-) -> impl IntoView {
- let jid = RwSignal::new("".to_string());
- let password = RwSignal::new("".to_string());
- let remember_me = RwSignal::new(false);
- let connect_on_login = RwSignal::new(true);
-
- let (error, set_error) = signal(None::<LoginError>);
- let error_message = move || {
- error.with(|error| {
- if let Some(error) = error {
- view! { <div class="error">{error.to_string()}</div> }.into_any()
- } else {
- view! {}.into_any()
- }
- })
- };
-
- let (login_pending, set_login_pending) = signal(false);
-
- let login = Action::new_local(move |_| {
- async move {
- set_login_pending.set(true);
-
- if jid.read_untracked().is_empty() {
- set_error.set(Some(LoginError::MissingJID));
- set_login_pending.set(false);
- return;
- }
-
- if password.read_untracked().is_empty() {
- set_error.set(Some(LoginError::MissingPassword));
- set_login_pending.set(false);
- return;
- }
-
- let jid = match JID::from_str(&jid.read_untracked()) {
- Ok(j) => j,
- Err(e) => {
- set_error.set(Some(e.into()));
- set_login_pending.set(false);
- return;
- }
- };
-
- let remember_me = remember_me.get_untracked();
- // initialise the client
- let db = if remember_me {
- debug!("creating db in opfs");
- Db::create_connect_and_migrate(jid.as_bare().to_string())
- .await
- .unwrap()
- } else {
- debug!("creating db in memory");
- Db::create_connect_and_migrate_memory().await.unwrap()
- };
- let files = if remember_me {
- let opfs = FilesOPFS::new(jid.as_bare().to_string()).await;
- match opfs {
- Ok(f) => Files::Opfs(f),
- Err(e) => {
- set_error.set(Some(e.into()));
- set_login_pending.set(false);
- return;
- }
- }
- } else {
- Files::Mem(FilesMem::new())
- };
- let (client, updates) = filamento::Client::new(
- jid.clone(),
- password.read_untracked().clone(),
- db,
- files.clone(),
- );
- // TODO: remember_me
- let resource = ArcRwSignal::new(None::<String>);
- let client = Client {
- client,
- resource: resource.clone(),
- jid: Arc::new(jid.to_bare()),
- file_store: files,
- };
-
- if *connect_on_login.read_untracked() {
- match client.connect().await {
- Ok(r) => {
- resource.set(Some(r))
- }
- Err(e) => {
- set_error.set(Some(e.into()));
- set_login_pending.set(false);
- return;
- }
- }
- }
-
- // debug!("before setting app state");
- set_client.set(Some((client, updates)));
- set_app.set(AppState::LoggedIn);
- }
- });
-
- view! {
- <div class="center fill">
- <div id="login-form" class="panel">
- <div id="hero">
- <img src="/assets/macaw-icon.png" />
- <h1>Macaw Instant Messenger</h1>
- </div>
- {error_message}
- <form on:submit=move |ev| {
- ev.prevent_default();
- login.dispatch(());
- }>
- <label for="jid">JID</label>
- <input
- disabled=login_pending
- placeholder="caw@macaw.chat"
- type="text"
- bind:value=jid
- name="jid"
- id="jid"
- autofocus="true"
- />
- <label for="password">Password</label>
- <input
- disabled=login_pending
- placeholder="••••••••"
- type="password"
- bind:value=password
- name="password"
- id="password"
- />
- <div>
- <label for="remember_me">Remember me</label>
- <input
- disabled=login_pending
- type="checkbox"
- bind:checked=remember_me
- name="remember_me"
- id="remember_me"
- />
- </div>
- <div>
- <label for="connect_on_login">Connect on login</label>
- <input
- disabled=login_pending
- type="checkbox"
- bind:checked=connect_on_login
- name="connect_on_login"
- id="connect_on_login"
- />
- </div>
- <input disabled=login_pending class="button" type="submit" value="Log In" />
- </form>
- </div>
- </div>
- }
-}
-
-pub struct MessageSubscriptions {
- all: HashMap<Uuid, mpsc::Sender<(BareJID, MacawMessage)>>,
- subset: HashMap<BareJID, HashMap<Uuid, mpsc::Sender<MacawMessage>>>,
-}
-
-impl MessageSubscriptions {
- pub fn new() -> Self {
- Self {
- all: HashMap::new(),
- subset: HashMap::new(),
- }
- }
-
- pub async fn broadcast(&mut self, to: BareJID, message: MacawMessage) {
- // subscriptions to all
- let mut removals = Vec::new();
- for (id, sender) in &self.all {
- match sender.send((to.clone(), message.clone())).await {
- Ok(_) => {}
- Err(_) => {
- removals.push(*id);
- }
- }
- }
- for removal in removals {
- self.all.remove(&removal);
- }
-
- // subscriptions to specific chat
- if let Some(subscribers) = self.subset.get_mut(&to) {
- let mut removals = Vec::new();
- for (id, sender) in &*subscribers {
- match sender.send(message.clone()).await {
- Ok(_) => {}
- Err(_) => {
- removals.push(*id);
- }
- }
- }
- for removal in removals {
- subscribers.remove(&removal);
- }
- if subscribers.is_empty() {
- self.subset.remove(&to);
- }
- }
- }
-
- pub fn subscribe_all(&mut self) -> (Uuid, Receiver<(BareJID, MacawMessage)>) {
- let (send, recv) = mpsc::channel(10);
- let id = Uuid::new_v4();
- self.all.insert(id, send);
- (id, recv)
- }
-
- pub fn subscribe_chat(&mut self, chat: BareJID) -> (Uuid, Receiver<MacawMessage>) {
- let (send, recv) = mpsc::channel(10);
- let id = Uuid::new_v4();
- if let Some(chat_subscribers) = self.subset.get_mut(&chat) {
- chat_subscribers.insert(id, send);
- } else {
- let hash_map = HashMap::from([(id, send)]);
- self.subset.insert(chat, hash_map);
- }
- (id, recv)
- }
-
- pub fn unsubscribe_all(&mut self, sub_id: Uuid) {
- self.all.remove(&sub_id);
- }
-
- pub fn unsubscribe_chat(&mut self, sub_id: Uuid, chat: BareJID) {
- if let Some(chat_subs) = self.subset.get_mut(&chat) {
- chat_subs.remove(&sub_id);
- }
- }
-}
-
-#[derive(Store, Clone)]
-pub struct Roster {
- #[store(key: BareJID = |(jid, _)| jid.clone())]
- contacts: HashMap<BareJID, MacawContact>,
-}
-
-impl Roster {
- pub fn new() -> Self {
- Self {
- contacts: HashMap::new(),
- }
- }
-}
-
-// TODO: multiple panels
-// pub struct OpenChats {
-// panels:
-// }
-
-#[derive(Store, Default)]
-pub struct OpenChatsPanel {
- // jid must be a chat in the chats map
- chat_view: Option<BareJID>,
- #[store(key: BareJID = |(jid, _)| jid.clone())]
- chats: IndexMap<BareJID, MacawChat>,
-}
-
-pub fn open_chat(open_chats: Store<OpenChatsPanel>, chat: MacawChat) {
- if let Some(jid) = &*open_chats.chat_view().read() {
- if let Some((index, _jid, entry)) = open_chats.chats().write().shift_remove_full(jid) {
- let new_jid = <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat)
- .correspondent()
- .read()
- .clone();
- open_chats
- .chats()
- .write()
- .insert_before(index, new_jid.clone(), chat);
- *open_chats.chat_view().write() = Some(new_jid);
- } else {
- let new_jid = <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat)
- .correspondent()
- .read()
- .clone();
- open_chats.chats().write().insert(new_jid.clone(), chat);
- *open_chats.chat_view().write() = Some(new_jid);
- }
- } else {
- let new_jid = <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat)
- .correspondent()
- .read()
- .clone();
- open_chats.chats().write().insert(new_jid.clone(), chat);
- *open_chats.chat_view().write() = Some(new_jid);
- }
-}
-
-impl OpenChatsPanel {
- pub fn open(&mut self, chat: MacawChat) {
- if let Some(jid) = &mut self.chat_view {
- debug!("a chat was already open");
- if let Some((index, _jid, entry)) = self.chats.shift_remove_full(jid) {
- let new_jid = <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat)
- .correspondent()
- .read()
- .clone();
- self.chats.insert_before(index, new_jid.clone(), chat);
- *&mut self.chat_view = Some(new_jid);
- } else {
- let new_jid = <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat)
- .correspondent()
- .read()
- .clone();
- self.chats.insert(new_jid.clone(), chat);
- *&mut self.chat_view = Some(new_jid);
- }
- } else {
- let new_jid = <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat)
- .correspondent()
- .read()
- .clone();
- self.chats.insert(new_jid.clone(), chat);
- *&mut self.chat_view = Some(new_jid);
- }
- debug!("opened chat");
- }
-
- // TODO:
- // pub fn open_in_new_tab_unfocused(&mut self) {
-
- // }
-
- // pub fn open_in_new_tab_focus(&mut self) {
-
- // }
-}
-
-#[derive(Store)]
-pub struct UserPresences {
- #[store(key: BareJID = |(jid, _)| jid.clone())]
- user_presences: HashMap<BareJID, ArcRwSignal<Presences>>,
-}
-
-impl UserPresences {
- pub fn clear(&mut self) {
- for (_user, presences) in &mut self.user_presences {
- presences.set(Presences::new())
- }
- }
-
- // TODO: should be a bare jid
- pub fn get_user_presences(&mut self, user: &BareJID) -> ArcRwSignal<Presences> {
- if let Some(presences) = self.user_presences.get(user) {
- presences.clone()
- } else {
- let presences = Presences::new();
- let signal = ArcRwSignal::new(presences);
- self.user_presences.insert(user.clone(), signal.clone());
- signal
- }
- }
-}
-
-impl UserPresences {
- pub fn new() -> Self {
- Self {
- user_presences: HashMap::new(),
- }
- }
-}
-
-pub struct Presences {
- /// presences are sorted by time, first by type, then by last activity.
- presences: IndexMap<String, Presence>
-}
-
-impl Presences {
- pub fn new() -> Self {
- Self {
- presences: IndexMap::new(),
- }
- }
-
- /// gets the highest priority presence
- pub fn presence(&self) -> Option<(String, Presence)> {
- if let Some((resource, presence)) = self.presences.iter().filter(|(_resource, presence)| if let PresenceType::Online(online) = &presence.presence {
- online.show == Some(Show::DoNotDisturb)
- } else {
- false
- }).next() {
- return Some((resource.clone(), presence.clone()))
- }
- if let Some((resource, presence)) = self.presences.iter().filter(|(_resource, presence)| if let PresenceType::Online(online) = &presence.presence {
- online.show == Some(Show::Chat)
- } else {
- false
- }).next() {
- return Some((resource.clone(), presence.clone()))
- }
- if let Some((resource, presence)) = self.presences.iter().filter(|(_resource, presence)| if let PresenceType::Online(online) = &presence.presence {
- online.show == None
- } else {
- false
- }).next() {
- return Some((resource.clone(), presence.clone()))
- }
- if let Some((resource, presence)) = self.presences.iter().filter(|(_resource, presence)| if let PresenceType::Online(online) = &presence.presence {
- online.show == Some(Show::Away)
- } else {
- false
- }).next() {
- return Some((resource.clone(), presence.clone()))
- }
- if let Some((resource, presence)) = self.presences.iter().filter(|(_resource, presence)| if let PresenceType::Online(online) = &presence.presence {
- online.show == Some(Show::ExtendedAway)
- } else {
- false
- }).next() {
- return Some((resource.clone(), presence.clone()))
- }
- if let Some((resource, presence)) = self.presences.iter().filter(|(_resource, presence)| if let PresenceType::Offline(_offline) = &presence.presence {
- true
- } else {
- false
- }).next() {
- return Some((resource.clone(), presence.clone()))
- } else {
- None
- }
- }
-
- pub fn update_presence(&mut self, resource: String, presence: Presence) {
- let index = match self.presences.binary_search_by(|_, existing_presence| {
- presence.timestamp
- .cmp(
- &existing_presence.timestamp
- )
- }) {
- Ok(i) => i,
- Err(i) => i,
- };
- self.presences.insert_before(
- // TODO: check if this logic is correct
- index,
- resource,
- presence,
- );
- }
-
- pub fn resource_presence(&mut self, resource: String) -> Presence {
- if let Some(presence) = self.presences.get(&resource) {
- presence.clone()
- } else {
- Presence {
- timestamp: Utc::now(),
- presence: PresenceType::Offline(Offline::default()),
- }
- }
- }
-}
-
-#[component]
-fn Macaw(
- // TODO: logout
- // app_state: WriteSignal<Option<essage>)>, LocalStorage>,
- client: Client,
- mut updates: Receiver<UpdateMessage>,
- set_app: WriteSignal<AppState>,
-) -> impl IntoView {
- provide_context(set_app);
- provide_context(client);
-
- let roster = Store::new(Roster::new());
- provide_context(roster);
-
- let message_subscriptions = RwSignal::new(MessageSubscriptions::new());
- provide_context(message_subscriptions);
-
- let messages_store: StateStore<Uuid, ArcStore<Message>> = StateStore::new();
- provide_context(messages_store);
- let chats_store: StateStore<BareJID, ArcStore<Chat>> = StateStore::new();
- provide_context(chats_store);
- let users_store: StateStore<BareJID, ArcStore<User>> = StateStore::new();
- provide_context(users_store);
-
- let open_chats = Store::new(OpenChatsPanel::default());
- provide_context(open_chats);
- let show_settings = RwSignal::new(None::<SettingsPage>);
- provide_context(show_settings);
-
- let user_presences = Store::new(UserPresences::new());
- provide_context(user_presences);
-
- let client_user = LocalResource::new(move || {
- async move {
- let client = use_context::<Client>().expect("client not in context");
- let user = client.get_user((*client.jid).clone()).await.unwrap();
- MacawUser::got_user(user)
- }
- });
- provide_context(client_user);
-
- // TODO: timestamp incoming/outgoing subscription requests
- let (subscription_requests, set_subscription_requests)= signal(HashSet::<BareJID>::new());
- provide_context(subscription_requests);
- provide_context(set_subscription_requests);
-
- // TODO: get cached contacts on login before getting the updated contacts
-
- OnceResource::new(async move {
- while let Some(update) = updates.recv().await {
- match update {
- UpdateMessage::Online(online, items) => {
- let contacts = items
- .into_iter()
- .map(|(contact, user)| {
- (
- contact.user_jid.clone(),
- MacawContact::got_contact_and_user(contact, user),
- )
- })
- .collect();
- roster.contacts().set(contacts);
- }
- UpdateMessage::Offline(offline) => {
- // when offline, will no longer receive updated user presences, consider everybody offline.
- user_presences.write().clear();
- }
- UpdateMessage::RosterUpdate(contact, user) => {
- roster.contacts().update(|roster| {
- if let Some(macaw_contact) = roster.get_mut(&contact.user_jid) {
- macaw_contact.set(contact);
- } else {
- let jid = contact.user_jid.clone();
- let contact = MacawContact::got_contact_and_user(contact, user);
- roster.insert(jid, contact);
- }
- });
- }
- UpdateMessage::RosterDelete(jid) => {
- roster.contacts().update(|roster| {
- roster.remove(&jid);
- });
- }
- UpdateMessage::Presence { from, presence } => {
- let bare_jid = from.to_bare();
- if let Some(presences) = user_presences.read().user_presences.get(&bare_jid) {
- if let Some(resource) = from.resourcepart() {
- presences.write().update_presence(resource.clone(), presence);
- }
- } else {
- if let Some(resource) = from.resourcepart() {
- let mut presences = Presences::new();
- presences.update_presence(resource.clone(), presence);
- user_presences.write().user_presences.insert(bare_jid, ArcRwSignal::new(presences));
- }
- }
- }
- UpdateMessage::Message { to, from, message } => {
- debug!("before got message");
- let new_message = MacawMessage::got_message_and_user(message, from);
- debug!("after got message");
- spawn_local(async move {
- message_subscriptions
- .write()
- .broadcast(to, new_message)
- .await
- });
- debug!("after set message");
- }
- UpdateMessage::MessageDelivery { id, chat, delivery } => {
- messages_store.modify(&id, |message| {
- <ArcStore<filamento::chat::Message> as Clone>::clone(&message)
- .delivery()
- .set(Some(delivery))
- });
- }
- UpdateMessage::SubscriptionRequest(jid) => {
- set_subscription_requests.update(|req| { req.insert(jid); });
- }
- UpdateMessage::NickChanged { jid, nick } => {
- users_store.modify(&jid, |user| {
- user.update(|user| *&mut user.nick = nick.clone())
- });
- }
- UpdateMessage::AvatarChanged { jid, id } => {
- users_store.modify(&jid, |user| *&mut user.write().avatar = id.clone());
- }
- }
- }
- });
-
- view! {
- <Sidebar />
- // <ChatsList />
- <OpenChatsPanelView />
- {move || if let Some(_) = *show_settings.read() {
- view! { <Settings /> }.into_any()
- } else {
- view! {}.into_any()
- }}
- }
-}
-
-#[derive(PartialEq, Eq, Clone, Copy)]
-pub enum SidebarOpen {
- Roster,
- Chats,
-}
-
-/// returns whether the state was changed to open (true) or closed (false)
-pub fn toggle_open(state: &mut Option<SidebarOpen>, open: SidebarOpen) -> bool {
- match state {
- Some(opened) => {
- if *opened == open {
- *state = None;
- false
- } else {
- *state = Some(open);
- true
- }
- }
- None => {
- *state = Some(open);
- true
- },
- }
-}
-
-#[component]
-pub fn Sidebar() -> impl IntoView {
- let requests: ReadSignal<HashSet<BareJID>> = use_context().expect("no pending subscriptions in context");
-
- // for what has been clicked open (in the background)
- let (open, set_open) = signal(None::<SidebarOpen>);
- // for what is just in the hovered state (not clicked to be pinned open yet necessarily)
- let (hovered, set_hovered) = signal(None::<SidebarOpen>);
- let (just_closed, set_just_closed) = signal(false);
-
- view! {
- <div class="sidebar" on:mouseleave=move |_| {
- set_hovered.set(None);
- set_just_closed.set(false);
- }>
- <div class="dock panel">
- <div class="shortcuts">
- <div class="roster-tab dock-item" class:focused=move || *open.read() == Some(SidebarOpen::Roster) class:hovering=move || *hovered.read() == Some(SidebarOpen::Roster)
- on:mouseenter=move |_| {
- set_just_closed.set(false);
- set_hovered.set(Some(SidebarOpen::Roster))
- }
- on:click=move |_| {
- set_open.update(|state| {
- if !toggle_open(state, SidebarOpen::Roster) {
- set_just_closed.set(true);
- }
- })
- }>
- <div class="dock-pill"></div>
- <div class="dock-icon">
- <div class="icon-with-badge">
- <img src="/assets/caw.png" />
- {move || {
- let len = requests.read().len();
- if len > 0 {
- view! {
- <div class="badge">{len}</div>
- }.into_any()
- } else {
- view! {}.into_any()
- }
- }}
- </div>
- </div>
- </div>
- <div class="chats-tab dock-item" class:focused=move || *open.read() == Some(SidebarOpen::Chats) class:hovering=move || *hovered.read() == Some(SidebarOpen::Chats)
- on:mouseenter=move |_| {
- set_just_closed.set(false);
- set_hovered.set(Some(SidebarOpen::Chats))
- }
- on:click=move |_| {
- set_open.update(|state| {
- if !toggle_open(state, SidebarOpen::Chats) {
- set_just_closed.set(true);
- }
- })
- }>
- <div class="dock-pill"></div>
- <img src="/assets/bubble.png" />
- </div>
- </div>
- <div class="pins">
- </div>
- <div class="personal">
- <PersonalStatus />
- </div>
- </div>
- {move || if let Some(hovered) = *hovered.read() {
- if Some(hovered) != *open.read() {
- if !just_closed.get() {
- match hovered {
- SidebarOpen::Roster => view! {
- <div class="sidebar-drawer sidebar-hovering-drawer">
- <RosterList />
- </div>
- }.into_any(),
- SidebarOpen::Chats => view! {
- <div class="sidebar-drawer sidebar-hovering-drawer">
- <ChatsList />
- </div>
- }.into_any(),
- }
- } else {
-
- view! {}.into_any()
- }
- } else {
- view! {}.into_any()
- }
- } else {
- view! {}.into_any()
- }}
- {move || if let Some(opened) = *open.read() {
- match opened {
- SidebarOpen::Roster => view! {
- <div class="sidebar-drawer">
- <RosterList />
- </div>
- }.into_any(),
- SidebarOpen::Chats => view! {
- <div class="sidebar-drawer">
- <ChatsList />
- </div>
- }.into_any(),
- }
- } else {
- view! {}.into_any()
- }}
- </div>
- }
-}
-
-#[component]
-pub fn PersonalStatus() -> impl IntoView {
- let user: LocalResource<MacawUser> = use_context().expect("no local user in context");
-
- let (open, set_open) = signal(false);
- move || if let Some(user) = user.get() {
- let user: Store<User> = <ArcStore<filamento::user::User> as Clone>::clone(&(*user.user)).into();
- view! {
- <div class="dock-item" class:focused=move || *open.read() on:click=move |_| {
- debug!("set open to true");
- set_open.update(|state| *state = !*state)
- }>
- <AvatarWithPresence user=user />
- <div class="dock-pill"></div>
- </div>
- {move || {
- let open = open.get();
- debug!("open = {:?}", open);
- if open {
- view! {
- <Overlay set_open>
- <PersonalStatusMenu user set_open/>
- </Overlay>
- }.into_any()
- } else {
- view! {}.into_any()
- }}}
- }.into_any()
- } else {
- view! {}.into_any()
- }
-}
-
-#[component]
-pub fn PersonalStatusMenu(user: Store<User>, set_open: WriteSignal<bool>) -> impl IntoView {
- let set_app: WriteSignal<AppState> = use_context().unwrap();
- let show_settings: RwSignal<Option<SettingsPage>> = use_context().unwrap();
- let user_presences: Store<UserPresences> = use_context().expect("no user presence store");
-
- let client = use_context::<Client>().expect("client not in context");
- let client1 = client.clone();
- let (show_value, set_show_value) = signal({
- let show = match user_presences.write().get_user_presences(&user.jid().read()).write().resource_presence(client.resource.read().clone().unwrap_or_default()).presence {
- PresenceType::Online(online) => match online.show {
- Some(s) => match s {
- Show::Away => 3,
- Show::Chat => 0,
- Show::DoNotDisturb => 2,
- Show::ExtendedAway => 4,
- },
- None => 1,
- },
- PresenceType::Offline(_offline) => 5,
- };
- debug!("initial show = {show}");
- show
- });
-
- let show_select: NodeRef<html::Select> = NodeRef::new();
-
- let disconnect = Action::new_local(move |()| {
- let client = client.clone();
- async move {
- client.disconnect(Offline::default()).await;
- }
- });
- let set_status = Action::new_local(move |show_value: &i32| {
- let show_value = show_value.to_owned();
- let client = client1.clone();
- async move {
- if let Err(e) = match show_value {
- 0 => {
- if let Ok(r) = client.connect().await {
- client.resource.set(Some(r))
- };
- client.set_status(Online { show: Some(Show::Chat), ..Default::default() }).await
- },
- 1 => {
- if let Ok(r) = client.connect().await {
- client.resource.set(Some(r))
- };
- client.set_status(Online { show: None, ..Default::default() }).await
- },
- 2 => {
- if let Ok(r) = client.connect().await {
- client.resource.set(Some(r))
- };
- client.set_status(Online { show: Some(Show::DoNotDisturb), ..Default::default() }).await
- },
- 3 => {
- if let Ok(r) = client.connect().await {
- client.resource.set(Some(r))
- };
- client.set_status(Online { show: Some(Show::Away), ..Default::default() }).await
- },
- 4 => {
- if let Ok(r) = client.connect().await {
- client.resource.set(Some(r))
- };
- client.set_status(Online { show: Some(Show::ExtendedAway), ..Default::default() }).await
- },
- 5 => {
- if let Ok(_) = client.disconnect(Offline::default()).await {
- client.resource.set(None)
- }
- set_show_value.set(5);
- return
- }
- _ => {
- error!("invalid availability select");
- return
- }
- } {
- error!("show set error: {e}");
- return
- }
- set_show_value.set(show_value);
- }
- });
-
- view! {
- <div class="personal-status-menu menu">
- <div class="user">
- <AvatarWithPresence user=user />
- <div class="user-info">
- <div class="nick">{move || get_name(user, false)}</div>
- <div class="jid">{move || user.jid().with(|jid| jid.to_string())}</div>
- </div>
- </div>
- <div class="status-edit">
- <select
- node_ref=show_select
- on:change:target=move |ev| {
- let show_value = ev.target().value().parse().unwrap();
- set_status.dispatch(show_value);
- }
- prop:show_value=move || show_value.get().to_string()
- >
- <option value="0" selected=move || show_value.get_untracked() == 0>Available to Chat</option>
- <option value="1" selected=move || show_value.get_untracked() == 1>Online</option>
- <option value="2" selected=move || show_value.get_untracked() == 2>Do not disturb</option>
- <option value="3" selected=move || show_value.get_untracked() == 3>Away</option>
- <option value="4" selected=move || show_value.get_untracked() == 4>Extended Away</option>
- <option value="5" selected=move || show_value.get_untracked() == 5>Offline</option>
- </select>
- </div>
- <hr />
- <div class="menu-item" on:click=move |_| {
- show_settings.set(Some(SettingsPage::Profile));
- set_open.set(false);
- }>
- Profile
- </div>
- <div class="menu-item" on:click=move |_| {
- show_settings.set(Some(SettingsPage::Account));
- set_open.set(false);
- }>
- Settings
- </div>
- <hr />
- <div class="menu-item" on:click=move |_| {
- // TODO: check if client is actually dropped/shutdown eventually
- disconnect.dispatch(());
- set_app.set(AppState::LoggedOut)
- }>
- Log out
- </div>
- </div>
- }
-}
-
-#[component]
-pub fn Overlay(set_open: WriteSignal<bool>, children: Children) -> impl IntoView {
- view! {
- <div class="overlay">
- <div class="overlay-background" on:click=move |_| {
- debug!("set open to false");
- set_open.update(|state| *state = false)
- }></div>
- <div class="overlay-content">{children()}</div>
- </div>
- }
-}
-
-#[component]
-pub fn Modal(on_background_click: impl Fn(MouseEvent) + 'static, children: Children) -> impl IntoView {
- view! {
- <div class="modal" on:click=move |e| {
- if e.current_target() == e.target() {
- on_background_click(e)
- }
- }>
- {children()}
- </div>
- }
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum SettingsPage {
- Account,
- Chat,
- Profile,
- Privacy,
-}
-
-#[component]
-pub fn Settings() -> impl IntoView {
- let show_settings: RwSignal<Option<SettingsPage>> = use_context().unwrap();
-
- view! {
- <Modal on_background_click=move |_| { show_settings.set(None); }>
- <div class="settings panel">
- <div class="header">
- <h2>Settings</h2>
- <div class="header-icon close">
- <IconComponent icon=Icon::Close24 on:click=move |_| show_settings.set(None)/>
- </div>
- </div>
- <div class="settings-main">
- <div class="settings-sidebar">
- <div class:open=move || *show_settings.read() == Some(SettingsPage::Account) on:click=move |_| show_settings.set(Some(SettingsPage::Account))>Account</div>
- <div class:open=move || *show_settings.read() == Some(SettingsPage::Chat) on:click=move |_| show_settings.set(Some(SettingsPage::Chat))>Chat</div>
- <div class:open=move || *show_settings.read() == Some(SettingsPage::Privacy) on:click=move |_| show_settings.set(Some(SettingsPage::Privacy))>Privacy</div>
- <div class:open=move || *show_settings.read() == Some(SettingsPage::Profile) on:click=move |_| show_settings.set(Some(SettingsPage::Profile))>Profile</div>
- </div>
- <div class="settings-page">
- {move || if let Some(page) = show_settings.get() {
- match page {
- SettingsPage::Account => view! { <div>"account"</div> }.into_any(),
- SettingsPage::Chat => view! { <div>"chat"</div> }.into_any(),
- SettingsPage::Profile => view! { <ProfileSettings /> }.into_any(),
- SettingsPage::Privacy => view! { <div>"privacy"</div> }.into_any(),
- }
- } else {
- view! {}.into_any()
- }}
- </div>
- </div>
- </div>
- </Modal>
- }
-}
-
-#[derive(Debug, Clone, Error)]
-pub enum ProfileSaveError {
- #[error("avatar publish: {0}")]
- Avatar(#[from] CommandError<AvatarPublishError<Files>>),
-}
-
-#[component]
-pub fn ProfileSettings() -> impl IntoView {
- let client: Client = use_context().expect("no client in context");
-
- // TODO: compartmentalise into error component, form component...
- let (error, set_error) = signal(None::<ProfileSaveError>);
- let error_message = move || {
- error.with(|error| {
- if let Some(error) = error {
- view! { <div class="error">{error.to_string()}</div> }.into_any()
- } else {
- view! {}.into_any()
- }
- })
- };
- let (profile_save_pending, set_profile_save_pending) = signal(false);
-
- let profile_upload_data = RwSignal::new(None::<Vec<u8>>);
- let from_input = move |ev: Event| {
- let elem = ev.target().unwrap().unchecked_into::<HtmlInputElement>();
-
- // let UploadSignal(file_signal) = expect_context();
- // file_signal.update(Vec::clear); // Clear list from previous change
- let files = elem.files().unwrap_throw();
-
- if let Some(file) = files.get(0) {
- let reader = FileReader::new().unwrap_throw();
- // let name = file.name();
-
- // This closure only needs to be called a single time as we will just
- // remake it on each loop
- // * web_sys drops it for us when using this specific constructor
- let read_file = {
- // FileReader is cloned prior to moving into the closure
- let reader = reader.to_owned();
- Closure::once_into_js(move |_: ProgressEvent| {
- // `.result` valid after the `read_*` completes on FileReader
- // https://developer.mozilla.org/en-US/docs/Web/API/FileReader/result
- let result = reader.result().unwrap_throw();
- let data= Uint8Array::new(&result).to_vec();
- // Do whatever you want with the Vec<u8>
- profile_upload_data.set(Some(data));
- })
- };
- reader.set_onloadend(Some(read_file.as_ref().unchecked_ref()));
-
- // read_as_array_buffer takes a &Blob
- //
- // Per https://w3c.github.io/FileAPI/#file-section
- // > A File object is a Blob object with a name attribute..
- //
- // File is a subclass (inherits) from the Blob interface, so a File
- // can be used anywhere a Blob is required.
- reader.read_as_array_buffer(&file).unwrap_throw();
-
- // You can also use `.read_as_text(&file)` instead if you just want a string.
- // This example shows how to extract an array buffer as it is more flexible
- //
- // If you use `.read_as_text` change the closure from ...
- //
- // let result = reader.result().unwrap_throw();
- // let vec_of_u8_bytes = Uint8Array::new(&result).to_vec();
- // let content = String::from_utf8(vec_of_u8_bytes).unwrap_throw();
- //
- // to ...
- //
- // let result = reader.result().unwrap_throw();
- // let content = result.as_string().unwrap_throw();
- } else {
- profile_upload_data.set(None);
- }
- };
-
- let save_profile = Action::new_local(move |_| {
- let client = client.clone();
- async move {}
- });
-
- let new_nick= RwSignal::new("".to_string());
-
- view! {
- <div class="profile-settings">
- <form on:submit=move |ev| {
- ev.prevent_default();
- save_profile.dispatch(());
- }>
- {error_message}
- <div class="change-avatar">
- <input type="file" id="client-user-avatar" on:change=from_input />
- </div>
- <input disabled=profile_save_pending placeholder="Nickname" type="text" id="client-user-nick" bind:value=new_nick name="client-user-nick" />
- <input disabled=profile_save_pending class="button" type="submit" value="Save Changes" />
- </form>
- </div>
- <div class="profile-preview">
- <h2>Profile Preview</h2>
- <div class="preview">
- <img />
- <div>nick</div>
- </div>
- </div>
- }
-}
-
-#[component]
-pub fn OpenChatsPanelView() -> impl IntoView {
- let open_chats: Store<OpenChatsPanel> = use_context().expect("no open chats panel in context");
-
- // TODO: tabs
- // view! {
- // {move || {
- // if open_chats.chats().read().len() > 1 {
- // Some(
- // view! {
- // <For
- // each=move || open_chats.chats().get()
- // key=|(jid, _)| jid.clone()
- // let(chat)
- // ></For>
- // },
- // )
- // } else {
- // None
- // }
- // }}
- // }
- view! {
- <div class="open-chat-views">
- {move || {
- if let Some(open_chat) = open_chats.chat_view().get() {
- if let Some(open_chat) = open_chats.chats().read().get(&open_chat) {
- view! { <OpenChatView chat=open_chat.clone() /> }.into_any()
- } else {
- view! {}.into_any()
- }
- } else {
- view! {}.into_any()
- }
- }}
- </div>
- }
-}
-
-#[component]
-pub fn OpenChatView(chat: MacawChat) -> impl IntoView {
- let chat_chat: Store<Chat> =
- <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat).into();
- let chat_jid = move || chat_chat.correspondent().get();
-
- view! {
- <div class="open-chat-view">
- <ChatViewHeader chat=chat.clone() />
- <MessageHistoryBuffer chat=chat.clone() />
- <ChatViewMessageComposer chat=chat_jid() />
- </div>
- }
-}
-
-pub fn show_to_icon(show: Show) -> Icon {
- match show {
- Show::Away => Icon::Away16Color,
- Show::Chat => Icon::Chat16Color,
- Show::DoNotDisturb => Icon::Dnd16Color,
- Show::ExtendedAway => Icon::Xa16Color,
- }
-}
-
-#[component]
-pub fn AvatarWithPresence(user: Store<User>) -> impl IntoView {
- let avatar = LocalResource::new(move || get_avatar(user));
- let user_presences: Store<UserPresences> = use_context().expect("no user presences in context");
- let presence = move || user_presences.write().get_user_presences(&user.read().jid).read().presence();
- let show_icon = move || presence().map(|(_, presence)| {
- match presence.presence {
- PresenceType::Online(online) => if let Some(show) = online.show {
- Some(show_to_icon(show))
- } else {
- Some(Icon::Available16Color)
- },
- PresenceType::Offline(offline) => None,
- }
- }).unwrap_or_default();
-
- view! {
- <div class="avatar-with-presence">
- <img class="avatar" src=move || avatar.get() />
- {move || if let Some(icon) = show_icon() {
- view!{
- <IconComponent icon=icon class:presence-show-icon=true />
- }.into_any()
- } else {
- view! {}.into_any()
- }}
- </div>
- }
-}
-
-#[component]
-pub fn ChatViewHeader(chat: MacawChat) -> impl IntoView {
- let chat_user = <ArcStore<filamento::user::User> as Clone>::clone(&chat.user).into();
- let name = move || get_name(chat_user, true);
- let jid = move || chat_user.jid().read().to_string();
-
- view! {
- <div class="chat-view-header panel">
- <AvatarWithPresence user=chat_user />
- <div class="user-info">
- <h2 class="name">{name}</h2>
- <h3>{jid}</h3>
- </div>
- </div>
- }
-}
-
-#[component]
-pub fn MessageHistoryBuffer(chat: MacawChat) -> impl IntoView {
- let (messages, set_messages) = arc_signal(IndexMap::new());
- let chat_chat: Store<Chat> =
- <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat).into();
- let chat_user: Store<User> =
- <ArcStore<filamento::user::User> as Clone>::clone(&chat.user).into();
-
- let load_set_messages = set_messages.clone();
- let load_messages = LocalResource::new(move || {
- let load_set_messages = load_set_messages.clone();
- async move {
- let client = use_context::<Client>().expect("client not in context");
- let messages = client
- .get_messages_with_users(chat_chat.correspondent().get())
- .await
- .map_err(|e| e.to_string());
- match messages {
- Ok(m) => {
- let messages = m
- .into_iter()
- .map(|(message, message_user)| {
- (
- message.id,
- MacawMessage::got_message_and_user(message, message_user),
- )
- })
- .collect::<IndexMap<Uuid, _>>();
- load_set_messages.set(messages);
- }
- Err(err) => {
- error!("{err}")
- // TODO: show error message at top of chats list
- }
- }
- }
- });
-
- // TODO: filter new messages signal
- let new_messages_signal: RwSignal<MessageSubscriptions> = use_context().unwrap();
- let (sub_id, set_sub_id) = signal(None);
- let load_new_messages_set = set_messages.clone();
- let _load_new_messages = LocalResource::new(move || {
- let load_new_messages_set = load_new_messages_set.clone();
- async move {
- load_messages.await;
- let (sub_id, mut new_messages) = new_messages_signal
- .write()
- .subscribe_chat(chat_chat.correspondent().get());
- set_sub_id.set(Some(sub_id));
- while let Some(new_message) = new_messages.recv().await {
- debug!("got new message in let message buffer");
- let mut messages = load_new_messages_set.write();
- if let Some((_, last)) = messages.last() {
- if *<ArcStore<filamento::chat::Message> as Clone>::clone(&last.message)
- .timestamp()
- .read()
- < *<ArcStore<filamento::chat::Message> as Clone>::clone(&new_message)
- .timestamp()
- .read()
- {
- messages.insert(
- <ArcStore<filamento::chat::Message> as Clone>::clone(
- &new_message.message,
- )
- .id()
- .get(),
- new_message,
- );
- debug!("set the new message in message buffer");
- } else {
- let index = match messages.binary_search_by(|_, value| {
- <ArcStore<filamento::chat::Message> as Clone>::clone(&value.message)
- .timestamp()
- .read()
- .cmp(
- &<ArcStore<filamento::chat::Message> as Clone>::clone(
- &new_message.message,
- )
- .timestamp()
- .read(),
- )
- }) {
- Ok(i) => i,
- Err(i) => i,
- };
- messages.insert_before(
- // TODO: check if this logic is correct
- index,
- <ArcStore<filamento::chat::Message> as Clone>::clone(
- &new_message.message,
- )
- .id()
- .get(),
- new_message,
- );
- debug!("set the new message in message buffer");
- }
- } else {
- messages.insert(
- <ArcStore<filamento::chat::Message> as Clone>::clone(&new_message.message)
- .id()
- .get(),
- new_message,
- );
- debug!("set the new message in message buffer");
- }
- }
- }
- });
- on_cleanup(move || {
- if let Some(sub_id) = sub_id.get() {
- new_messages_signal
- .write()
- .unsubscribe_chat(sub_id, chat_chat.correspondent().get());
- }
- });
-
- let each = move || {
- let mut last_timestamp = NaiveDateTime::MIN;
- let mut last_user: Option<BareJID> = None;
- let mut messages = messages
- .get()
- .into_iter()
- .map(|(id, message)| {
- let message_timestamp =
- <ArcStore<filamento::chat::Message> as Clone>::clone(&message.message)
- .timestamp()
- .read()
- .naive_local();
- // TODO: mark new day
- // if message_timestamp.date() > last_timestamp.date() {
- // messages_view = messages_view.push(date(message_timestamp.date()));
- // }
- let major = if last_user.as_ref() != Some(&message.message.read().from)
- || message_timestamp - last_timestamp > TimeDelta::minutes(3)
- {
- true
- } else {
- false
- };
- last_user = Some(
- <ArcStore<filamento::chat::Message> as Clone>::clone(&message.message)
- .from()
- .get(),
- );
- last_timestamp = message_timestamp;
- (id, (message, major, false))
- })
- .collect::<Vec<_>>();
- if let Some((_id, (_, _, last))) = messages.last_mut() {
- *last = true
- }
- messages.into_iter().rev()
- };
-
- view! {
- <div class="messages-buffer">
- <For each=each key=|message| (message.0, message.1.1, message.1.2) let(message)>
- <Message message=message.1.0 major=message.1.1 r#final=message.1.2 />
- </For>
- </div>
- }
-}
-
-#[derive(Copy, Clone)]
-pub enum Icon {
- AddContact24,
- Attachment24,
- Away16,
- Away16Color,
- Bubble16,
- Bubble16Color,
- Bubble24,
- Close24,
- Contact24,
- Delivered16,
- Dnd16,
- Dnd16Color,
- Error16Color,
- Forward24,
- Heart24,
- NewBubble24,
- Reply24,
- Sending16,
- Sent16,
- Chat16Color,
- Xa16Color,
- Available16Color,
-}
-
-pub const ICONS_SRC: &str = "/assets/icons/";
-
-impl Icon {
- pub fn src(&self) -> String {
- match self {
- Icon::AddContact24 => format!("{}addcontact24.svg", ICONS_SRC),
- Icon::Attachment24 => format!("{}attachment24.svg", ICONS_SRC),
- Icon::Away16 => format!("{}away16.svg", ICONS_SRC),
- Icon::Away16Color => format!("{}away16color.svg", ICONS_SRC),
- Icon::Bubble16 => format!("{}bubble16.svg", ICONS_SRC),
- Icon::Bubble16Color => format!("{}bubble16color.svg", ICONS_SRC),
- Icon::Bubble24 => format!("{}bubble24.svg", ICONS_SRC),
- Icon::Close24 => format!("{}close24.svg", ICONS_SRC),
- Icon::Contact24 => format!("{}contact24.svg", ICONS_SRC),
- Icon::Delivered16 => format!("{}delivered16.svg", ICONS_SRC),
- Icon::Dnd16 => format!("{}dnd16.svg", ICONS_SRC),
- Icon::Dnd16Color => format!("{}dnd16color.svg", ICONS_SRC),
- Icon::Error16Color => format!("{}error16color.svg", ICONS_SRC),
- Icon::Forward24 => format!("{}forward24.svg", ICONS_SRC),
- Icon::Heart24 => format!("{}heart24.svg", ICONS_SRC),
- Icon::NewBubble24 => format!("{}newbubble24.svg", ICONS_SRC),
- Icon::Reply24 => format!("{}reply24.svg", ICONS_SRC),
- Icon::Sending16 => format!("{}sending16.svg", ICONS_SRC),
- Icon::Sent16 => format!("{}sent16.svg", ICONS_SRC),
- Icon::Chat16Color => format!("{}chat16color.svg", ICONS_SRC),
- Icon::Xa16Color => format!("{}xa16color.svg", ICONS_SRC),
- Icon::Available16Color => format!("{}available16color.svg", ICONS_SRC),
- }
- }
-
- pub fn size(&self) -> isize {
- match self {
- Icon::AddContact24 => 24,
- Icon::Attachment24 => 24,
- Icon::Away16 => 16,
- Icon::Away16Color => 16,
- Icon::Bubble16 => 16,
- Icon::Bubble16Color => 16,
- Icon::Bubble24 => 24,
- Icon::Close24 => 24,
- Icon::Contact24 => 24,
- Icon::Delivered16 => 16,
- Icon::Dnd16 => 16,
- Icon::Dnd16Color => 16,
- Icon::Error16Color => 16,
- Icon::Forward24 => 24,
- Icon::Heart24 => 24,
- Icon::NewBubble24 => 24,
- Icon::Reply24 => 24,
- Icon::Sending16 => 16,
- Icon::Sent16 => 16,
- Icon::Chat16Color => 16,
- Icon::Xa16Color => 16,
- Icon::Available16Color => 16,
- }
- }
-
- pub fn light(&self) -> bool {
- match self {
- Icon::AddContact24 => true,
- Icon::Attachment24 => true,
- Icon::Away16 => true,
- Icon::Away16Color => false,
- Icon::Bubble16 => true,
- Icon::Bubble16Color => false,
- Icon::Bubble24 => true,
- Icon::Close24 => true,
- Icon::Contact24 => true,
- Icon::Delivered16 => true,
- Icon::Dnd16 => true,
- Icon::Dnd16Color => false,
- Icon::Error16Color => false,
- Icon::Forward24 => true,
- Icon::Heart24 => true,
- Icon::NewBubble24 => true,
- Icon::Reply24 => true,
- Icon::Sending16 => true,
- Icon::Sent16 => true,
- Icon::Chat16Color => false,
- Icon::Xa16Color => false,
- Icon::Available16Color => false,
- }
- }
-}
-
-#[component]
-pub fn IconComponent(icon: Icon) -> impl IntoView {
- view! {
- <img class:light=icon.light() class:icon=true style=move || format!("height: {}px; width: {}px", icon.size(), icon.size()) src=move || icon.src() />
- }
-}
-
-#[component]
-pub fn Delivery(delivery: Delivery) -> impl IntoView {
- match delivery {
- // TODO: proper icon coloring/theming
- Delivery::Sending => {
- view! { <IconComponent class:visible=true class:light=true icon=Icon::Sending16 /> }
- .into_any()
- }
- Delivery::Written => {
- view! { <IconComponent class:light=true icon=Icon::Sent16 /> }.into_any()
- }
- // TODO: message receipts
- // Delivery::Written => view! {}.into_any(),
- Delivery::Sent => view! { <IconComponent class:light=true icon=Icon::Sent16 /> }.into_any(),
- Delivery::Delivered => {
- view! { <IconComponent class:light=true icon=Icon::Delivered16 /> }.into_any()
- }
- // TODO: check if there is also the icon class
- Delivery::Read => {
- view! { <IconComponent class:light=true class:read=true icon=Icon::Delivered16 /> }
- .into_any()
- }
- Delivery::Failed => {
- view! { <IconComponent class:visible=true class:light=true icon=Icon::Error16Color /> }
- .into_any()
- }
- // TODO: queued icon
- Delivery::Queued => {
- view! { <IconComponent class:visible=true class:light=true icon=Icon::Sending16 /> }
- .into_any()
- }
- }
-}
-
-#[component]
-pub fn Message(message: MacawMessage, major: bool, r#final: bool) -> impl IntoView {
- let message_message: Store<Message> =
- <ArcStore<filamento::chat::Message> as Clone>::clone(&message.message).into();
- let message_user = <ArcStore<filamento::user::User> as Clone>::clone(&message.user).into();
- let avatar = LocalResource::new(move || get_avatar(message_user));
- let name = move || get_name(message_user, false);
-
- // TODO: chrono-humanize?
- // TODO: if final, show delivery not only on hover.
- // {move || message_message.delivery().read().map(|delivery| delivery.to_string()).unwrap_or_default()}
- if major {
- view! {
- <div class:final=r#final class="chat-message major">
- <div class="left">
- <Transition fallback=|| view! { <img class="avatar" src=NO_AVATAR /> } >
- <img class="avatar" src=move || avatar.get() />
- </Transition>
- </div>
- <div class="middle">
- <div class="message-info">
- <div class="message-user-name">{name}</div>
- <div class="message-timestamp">{move || message_message.timestamp().read().format("%H:%M").to_string()}</div>
- </div>
- <div class="message-text">
- {move || message_message.body().read().body.clone()}
- </div>
- </div>
- <div class="right message-delivery">{move || message_message.delivery().get().map(|delivery| view! { <Delivery class:light=true delivery /> } ) }</div>
- </div>
- }.into_any()
- } else {
- view! {
- <div class:final=r#final class="chat-message minor">
- <div class="left message-timestamp">
- {move || message_message.timestamp().read().format("%H:%M").to_string()}
- </div>
- <div class="middle message-text">{move || message_message.body().read().body.clone()}</div>
- <div class="right message-delivery">{move || message_message.delivery().get().map(|delivery| view! { <Delivery delivery /> } ) }</div>
- </div>
- }.into_any()
- }
-}
-
-#[component]
-pub fn ChatViewMessageComposer(chat: BareJID) -> impl IntoView {
- let message_input: NodeRef<Div> = NodeRef::new();
-
- // TODO: load last message draft
- let new_message = RwSignal::new("".to_string());
- let client: Client = use_context().expect("no client in context");
- let client = RwSignal::new(client);
- let (shift_pressed, set_shift_pressed) = signal(false);
-
- let send_message = move || {
- let value = chat.clone();
- spawn_local(async move {
- match client
- .read()
- .send_message(
- value,
- Body {
- body: new_message.get(),
- },
- )
- .await
- {
- Ok(_) => {
- new_message.set("".to_string());
- message_input
- .write()
- .as_ref()
- .expect("message input div not mounted")
- .set_text_content(Some(""));
- }
- Err(e) => tracing::error!("message send error: {}", e),
- }
- })
- };
-
- let _focus = Effect::new(move |_| {
- if let Some(input) = message_input.get() {
- let _ = input.focus();
- // TODO: set the last draft
- input.set_text_content(Some(""));
- // input.style("height: 0");
- // let height = input.scroll_height();
- // input.style(format!("height: {}px", height));
- }
- });
-
- // let on_input = move |ev: Event| {
- // // let keyboard_event: KeyboardEvent = ev.try_into().unwrap();
- // debug!("got input event");
- // let key= event_target_value(&ev);
- // new_message.set(key);
- // debug!("set new message");
- // };
- //
-
- // TODO: placeholder
- view! {
- <form
- class="new-message-composer panel"
- >
- <div
- class="text-box"
- on:input:target=move |ev| new_message.set(ev.target().text_content().unwrap_or_default())
- node_ref=message_input
- contenteditable
- on:keydown=move |ev| {
- match ev.key_code() {
- 16 => set_shift_pressed.set(true),
- 13 => if !shift_pressed.get() {
- ev.prevent_default();
- send_message();
- }
- _ => {}
- // debug!("shift pressed down");
- }
- }
- on:keyup=move |ev| {
- match ev.key_code() {
- 16 => set_shift_pressed.set(false),
- _ => {}
- // debug!("shift released");
- }
- }
- ></div>
- // <input hidden type="submit" />
- </form>
- }
-}
-
-// V has to be an arc signal
-#[derive(Debug)]
-struct ArcStateStore<K, V> {
- store: Arc<RwLock<HashMap<K, (V, usize)>>>,
-}
-
-impl<K, V> PartialEq for ArcStateStore<K, V> {
- fn eq(&self, other: &Self) -> bool {
- Arc::ptr_eq(&self.store, &other.store)
- }
-}
-
-impl<K, V> Clone for ArcStateStore<K, V> {
- fn clone(&self) -> Self {
- Self {
- store: Arc::clone(&self.store),
- }
- }
-}
-
-impl<K, V> Eq for ArcStateStore<K, V> {}
-
-impl<K, V> ArcStateStore<K, V> {
- pub fn new() -> Self {
- Self {
- store: Arc::new(RwLock::new(HashMap::new())),
- }
- }
-}
-
-#[derive(Debug)]
-struct StateStore<K, V, S = SyncStorage> {
- inner: ArenaItem<ArcStateStore<K, V>, S>,
-}
-
-impl<K, V, S> Dispose for StateStore<K, V, S> {
- fn dispose(self) {
- self.inner.dispose()
- }
-}
-
-impl<K, V> StateStore<K, V>
-where
- K: Send + Sync + 'static,
- V: Send + Sync + 'static,
-{
- pub fn new() -> Self {
- Self::new_with_storage()
- }
-}
-
-impl<K, V, S> StateStore<K, V, S>
-where
- K: 'static,
- V: 'static,
- S: Storage<ArcStateStore<K, V>>,
-{
- pub fn new_with_storage() -> Self {
- Self {
- inner: ArenaItem::new_with_storage(ArcStateStore::new()),
- }
- }
-}
-
-impl<K, V> StateStore<K, V, LocalStorage>
-where
- K: 'static,
- V: 'static,
-{
- pub fn new_local() -> Self {
- Self::new_with_storage()
- }
-}
-
-impl<
- K: std::marker::Send + std::marker::Sync + 'static,
- V: std::marker::Send + std::marker::Sync + 'static,
-> From<ArcStateStore<K, V>> for StateStore<K, V>
-{
- fn from(value: ArcStateStore<K, V>) -> Self {
- Self {
- inner: ArenaItem::new_with_storage(value),
- }
- }
-}
-
-impl<K: 'static, V: 'static> FromLocal<ArcStateStore<K, V>> for StateStore<K, V, LocalStorage> {
- fn from_local(value: ArcStateStore<K, V>) -> Self {
- Self {
- inner: ArenaItem::new_with_storage(value),
- }
- }
-}
-
-impl<K, V, S> Copy for StateStore<K, V, S> {}
-
-impl<K, V, S> Clone for StateStore<K, V, S> {
- fn clone(&self) -> Self {
- *self
- }
-}
-
-impl<K: Eq + std::hash::Hash + Clone, V: Clone> StateStore<K, V>
-where
- K: Send + Sync + 'static,
- V: Send + Sync + 'static,
-{
- pub fn store(&self, key: K, value: V) -> StateListener<K, V> {
- {
- let store = self.inner.try_get_value().unwrap();
- let mut store = store.store.write().unwrap();
- if let Some((v, count)) = store.get_mut(&key) {
- *v = value.clone();
- *count += 1;
- } else {
- store.insert(key.clone(), (value.clone(), 1));
- }
- };
- StateListener {
- value,
- cleaner: StateCleaner {
- key,
- state_store: self.clone(),
- },
- }
- }
-}
-
-impl<K, V> StateStore<K, V>
-where
- K: Eq + std::hash::Hash + Send + Sync + 'static,
- V: Send + Sync + 'static,
-{
- pub fn update(&self, key: &K, value: V) {
- let store = self.inner.try_get_value().unwrap();
- let mut store = store.store.write().unwrap();
- if let Some((v, _)) = store.get_mut(key) {
- *v = value;
- }
- }
-
- pub fn modify(&self, key: &K, modify: impl Fn(&mut V)) {
- let store = self.inner.try_get_value().unwrap();
- let mut store = store.store.write().unwrap();
- if let Some((v, _)) = store.get_mut(key) {
- modify(v);
- }
- }
-
- fn remove(&self, key: &K) {
- // let store = self.inner.try_get_value().unwrap();
- // let mut store = store.store.write().unwrap();
- // if let Some((_v, count)) = store.get_mut(key) {
- // *count -= 1;
- // if *count == 0 {
- // store.remove(key);
- // debug!("dropped item from store");
- // }
- // }
- }
-}
-
-#[derive(Clone)]
-struct StateListener<K, V>
-where
- K: Eq + std::hash::Hash + 'static + std::marker::Send + std::marker::Sync,
- V: 'static + std::marker::Send + std::marker::Sync,
-{
- value: V,
- cleaner: StateCleaner<K, V>,
-}
-
-impl<
- K: std::cmp::Eq + std::hash::Hash + std::marker::Send + std::marker::Sync,
- V: std::marker::Send + std::marker::Sync,
-> Deref for StateListener<K, V>
-{
- type Target = V;
-
- fn deref(&self) -> &Self::Target {
- &self.value
- }
-}
-
-impl<K: std::cmp::Eq + std::hash::Hash + Send + Sync, V: Send + Sync> DerefMut
- for StateListener<K, V>
-{
- fn deref_mut(&mut self) -> &mut Self::Target {
- &mut self.value
- }
-}
-
-struct ArcStateCleaner<K, V> {
- key: K,
- state_store: ArcStateStore<K, V>,
-}
-
-struct StateCleaner<K, V>
-where
- K: Eq + std::hash::Hash + Send + Sync + 'static,
- V: Send + Sync + 'static,
-{
- key: K,
- state_store: StateStore<K, V>,
-}
-
-impl<K, V> Clone for StateCleaner<K, V>
-where
- K: Eq + std::hash::Hash + Clone + Send + Sync,
- V: Send + Sync,
-{
- fn clone(&self) -> Self {
- {
- let store = self.state_store.inner.try_get_value().unwrap();
- let mut store = store.store.write().unwrap();
- if let Some((_v, count)) = store.get_mut(&self.key) {
- *count += 1;
- }
- }
- Self {
- key: self.key.clone(),
- state_store: self.state_store.clone(),
- }
- }
-}
-
-impl<K: Eq + std::hash::Hash + Send + Sync + 'static, V: Send + Sync + 'static> Drop
- for StateCleaner<K, V>
-{
- fn drop(&mut self) {
- self.state_store.remove(&self.key);
- }
-}
-
-#[derive(Clone)]
-struct MacawChat {
- chat: StateListener<BareJID, ArcStore<Chat>>,
- user: StateListener<BareJID, ArcStore<User>>,
-}
-
-impl MacawChat {
- fn got_chat_and_user(chat: Chat, user: User) -> Self {
- let chat_state_store: StateStore<BareJID, ArcStore<Chat>> =
- use_context().expect("no chat state store");
- let user_state_store: StateStore<BareJID, ArcStore<User>> =
- use_context().expect("no user state store");
- let user = user_state_store.store(user.jid.clone(), ArcStore::new(user));
- let chat = chat_state_store.store(chat.correspondent.clone(), ArcStore::new(chat));
- Self { chat, user }
- }
-}
-
-impl Deref for MacawChat {
- type Target = StateListener<BareJID, ArcStore<Chat>>;
-
- fn deref(&self) -> &Self::Target {
- &self.chat
- }
-}
-
-impl DerefMut for MacawChat {
- fn deref_mut(&mut self) -> &mut Self::Target {
- &mut self.chat
- }
-}
-
-#[derive(Clone)]
-struct MacawMessage {
- message: StateListener<Uuid, ArcStore<Message>>,
- user: StateListener<BareJID, ArcStore<User>>,
-}
-
-impl MacawMessage {
- fn got_message_and_user(message: Message, user: User) -> Self {
- let message_state_store: StateStore<Uuid, ArcStore<Message>> =
- use_context().expect("no message state store");
- let user_state_store: StateStore<BareJID, ArcStore<User>> =
- use_context().expect("no user state store");
- let message = message_state_store.store(message.id, ArcStore::new(message));
- let user = user_state_store.store(user.jid.clone(), ArcStore::new(user));
- Self { message, user }
- }
-}
-
-impl Deref for MacawMessage {
- type Target = StateListener<Uuid, ArcStore<Message>>;
-
- fn deref(&self) -> &Self::Target {
- &self.message
- }
-}
-
-impl DerefMut for MacawMessage {
- fn deref_mut(&mut self) -> &mut Self::Target {
- &mut self.message
- }
-}
-
-#[derive(Clone)]
-struct MacawUser {
- user: StateListener<BareJID, ArcStore<User>>,
-}
-
-impl MacawUser {
- fn got_user(user: User) -> Self {
-
- let user_state_store: StateStore<BareJID, ArcStore<User>> =
- use_context().expect("no user state store");
- let user = user_state_store.store(user.jid.clone(), ArcStore::new(user));
- Self { user }
- }
-}
-
-impl Deref for MacawUser {
- type Target = StateListener<BareJID, ArcStore<User>>;
-
- fn deref(&self) -> &Self::Target {
- &self.user
- }
-}
-
-impl DerefMut for MacawUser {
- fn deref_mut(&mut self) -> &mut Self::Target {
- &mut self.user
- }
-}
-
-#[derive(Clone)]
-struct MacawContact {
- contact: Store<Contact>,
- user: StateListener<BareJID, ArcStore<User>>,
-}
-
-impl MacawContact {
- fn got_contact_and_user(contact: Contact, user: User) -> Self {
- let contact = Store::new(contact);
- let user_state_store: StateStore<BareJID, ArcStore<User>> =
- use_context().expect("no user state store");
- let user = user_state_store.store(user.jid.clone(), ArcStore::new(user));
- Self { contact, user }
- }
-}
-
-impl Deref for MacawContact {
- type Target = Store<Contact>;
-
- fn deref(&self) -> &Self::Target {
- &self.contact
- }
-}
-
-impl DerefMut for MacawContact {
- fn deref_mut(&mut self) -> &mut Self::Target {
- &mut self.contact
- }
-}
-
-#[component]
-fn ChatsList() -> impl IntoView {
- let (chats, set_chats) = signal(IndexMap::new());
-
- let load_chats = LocalResource::new(move || async move {
- let client = use_context::<Client>().expect("client not in context");
- let chats = client
- .get_chats_ordered_with_latest_messages_and_users()
- .await
- .map_err(|e| e.to_string());
- match chats {
- Ok(c) => {
- let chats = c
- .into_iter()
- .map(|((chat, chat_user), (message, message_user))| {
- (
- chat.correspondent.clone(),
- (
- MacawChat::got_chat_and_user(chat, chat_user),
- MacawMessage::got_message_and_user(message, message_user),
- ),
- )
- })
- .collect::<IndexMap<BareJID, _>>();
- set_chats.set(chats);
- }
- Err(_) => {
- // TODO: show error message at top of chats list
- }
- }
- });
-
- let (open_new_chat, set_open_new_chat) = signal(false);
-
- // TODO: filter new messages signal
- let new_messages_signal: RwSignal<MessageSubscriptions> = use_context().unwrap();
- let (sub_id, set_sub_id) = signal(None);
- let _load_new_messages = LocalResource::new(move || async move {
- load_chats.await;
- let (sub_id, mut new_messages) = new_messages_signal.write().subscribe_all();
- set_sub_id.set(Some(sub_id));
- while let Some((to, new_message)) = new_messages.recv().await {
- debug!("got new message in let");
- let mut chats = set_chats.write();
- if let Some((chat, _latest_message)) = chats.shift_remove(&to) {
- // TODO: check if new message is actually latest message
- debug!("chat existed");
- debug!("new message: {}", new_message.read().body.body);
- chats.insert_before(0, to, (chat.clone(), new_message));
- debug!("done setting");
- } else {
- debug!("the chat didn't exist");
- let client = use_context::<Client>().expect("client not in context");
- let chat = client.get_chat(to.clone()).await.unwrap();
- let user = client.get_user(to.clone()).await.unwrap();
- debug!("before got chat");
- let chat = MacawChat::got_chat_and_user(chat, user);
- debug!("after got chat");
- chats.insert_before(0, to, (chat, new_message));
- debug!("done setting");
- }
- }
- debug!("set the new message");
- });
- on_cleanup(move || {
- if let Some(sub_id) = sub_id.get() {
- new_messages_signal.write().unsubscribe_all(sub_id);
- }
- });
-
- view! {
- <div class="chats-list panel">
- // TODO: update icon, tooltip on hover.
- <div class="header">
- <h2>Chats</h2>
- <div class="new-chat header-icon" class:open=open_new_chat >
- <IconComponent icon=Icon::NewBubble24 on:click=move |_| set_open_new_chat.update(|state| *state = !*state)/>
- {move || {
- if *open_new_chat.read() {
- view! {
- <Overlay set_open=set_open_new_chat>
- <NewChatWidget set_open_new_chat />
- </Overlay>
- }.into_any()
- } else {
- view! {}.into_any()
- }
- }}
- </div>
- </div>
- <div class="chats-list-chats">
- <For each=move || chats.get() key=|chat| chat.1.1.message.read().id let(chat)>
- <ChatsListItem chat=chat.1.0 message=chat.1.1 />
- </For>
- </div>
- </div>
- }
-}
-
-#[derive(Clone, Debug, Error)]
-pub enum NewChatError {
- #[error("Missing JID")]
- MissingJID,
- #[error("Invalid JID: {0}")]
- InvalidJID(#[from] jid::ParseError),
- #[error("Database: {0}")]
- Db(#[from] CommandError<DatabaseError>),
-}
-
-#[component]
-fn NewChatWidget(set_open_new_chat: WriteSignal<bool>) -> impl IntoView {
- let jid = RwSignal::new("".to_string());
-
- // TODO: compartmentalise into error component, form component...
- let (error, set_error) = signal(None::<NewChatError>);
- let error_message = move || {
- error.with(|error| {
- if let Some(error) = error {
- view! { <div class="error">{error.to_string()}</div> }.into_any()
- } else {
- view! {}.into_any()
- }
- })
- };
- let (new_chat_pending, set_new_chat_pending) = signal(false);
-
- let open_chats: Store<OpenChatsPanel> =
- use_context().expect("no open chats panel store in context");
- let client = use_context::<Client>().expect("client not in context");
-
- let chat_state_store: StateStore<BareJID, ArcStore<Chat>> =
- use_context().expect("no chat state store");
- let user_state_store: StateStore<BareJID, ArcStore<User>> =
- use_context().expect("no user state store");
-
- let open_chat = Action::new_local(move |_| {
- let client = client.clone();
- async move {
- set_new_chat_pending.set(true);
-
- if jid.read_untracked().is_empty() {
- set_error.set(Some(NewChatError::MissingJID));
- set_new_chat_pending.set(false);
- return;
- }
-
- let jid = match JID::from_str(&jid.read_untracked()) {
- // TODO: ability to direct address a resource?
- Ok(j) => j.to_bare(),
- Err(e) => {
- set_error.set(Some(e.into()));
- set_new_chat_pending.set(false);
- return;
- }
- };
-
- let chat_jid = jid;
- let (chat, user) = match client.get_chat_and_user(chat_jid).await {
- Ok(c) => c,
- Err(e) => {
- set_error.set(Some(e.into()));
- set_new_chat_pending.set(false);
- return;
- },
- };
-
- let chat = {
- let user = user_state_store.store(user.jid.clone(), ArcStore::new(user));
- let chat = chat_state_store.store(chat.correspondent.clone(), ArcStore::new(chat));
- MacawChat { chat, user }
- };
- open_chats.update(|open_chats| open_chats.open(chat.clone()));
- set_open_new_chat.set(false);
- }
- });
-
- let jid_input = NodeRef::<Input>::new();
- let _focus = Effect::new(move |_| {
- if let Some(input) = jid_input.get() {
- let _ = input.focus();
- input.set_text_content(Some(""));
- // input.style("height: 0");
- // let height = input.scroll_height();
- // input.style(format!("height: {}px", height));
- }
- });
-
- view! {
- <div class="new-chat-widget">
- <form on:submit=move |ev| {
- ev.prevent_default();
- open_chat.dispatch(());
- }>
- {error_message}
- <input
- disabled=new_chat_pending
- placeholder="JID"
- type="text"
- node_ref=jid_input
- bind:value=jid
- name="jid"
- id="jid"
- autofocus="true"
- />
- <input disabled=new_chat_pending class="button" type="submit" value="Start Chat" />
- </form>
- </div>
- }
-}
-
-#[component]
-fn RosterList() -> impl IntoView {
- let requests: ReadSignal<HashSet<BareJID>> = use_context().expect("no pending subscriptions in context");
-
- let roster: Store<Roster> = use_context().expect("no roster in context");
- let (open_add_contact, set_open_add_contact) = signal(false);
-
- // TODO: filter new messages signal
- view! {
- <div class="roster-list panel">
- <div class="header">
- <h2>Roster</h2>
- <div class="add-contact header-icon" class:open=open_add_contact>
- <IconComponent icon=Icon::AddContact24 on:click=move |_| set_open_add_contact.update(|state| *state = !*state)/>
- {move || {
- if !requests.read().is_empty() {
- view! {
- <div class="badge"></div>
- }.into_any()
- } else {
- view! {}.into_any()
- }
- }}
- </div>
- </div>
- {move || {
- if *open_add_contact.read() {
- view! {
- <div class="roster-add-contact">
- <AddContact />
- </div>
- }.into_any()
- } else {
- view! {}.into_any()
- }
- }}
- <div class="roster-list-roster">
- <For each=move || roster.contacts().get() key=|contact| contact.0.clone() let(contact)>
- <RosterListItem contact=contact.1 />
- </For>
- </div>
- </div>
- }
-}
-
-#[derive(Clone, Debug, Error)]
-pub enum AddContactError {
- #[error("Missing JID")]
- MissingJID,
- #[error("Invalid JID: {0}")]
- InvalidJID(#[from] jid::ParseError),
- #[error("Subscription: {0}")]
- Db(#[from] CommandError<SubscribeError>),
-}
-
-#[component]
-fn AddContact() -> impl IntoView {
- let requests: ReadSignal<HashSet<BareJID>> = use_context().expect("no pending subscriptions in context");
- let set_requests: WriteSignal<HashSet<BareJID>> = use_context().expect("no pending subscriptions write signal in context");
- let roster: Store<Roster> = use_context().expect("no roster in context");
-
- let jid = RwSignal::new("".to_string());
- // TODO: compartmentalise into error component, form component...
- let (error, set_error) = signal(None::<AddContactError>);
- let error_message = move || {
- error.with(|error| {
- if let Some(error) = error {
- view! { <div class="error">{error.to_string()}</div> }.into_any()
- } else {
- view! {}.into_any()
- }
- })
- };
- let (add_contact_pending, set_add_contact_pending) = signal(false);
-
- let client = use_context::<Client>().expect("client not in context");
- let client2 = client.clone();
- let client3 = client.clone();
- let client4 = client.clone();
-
- let add_contact= Action::new_local(move |_| {
- let client = client.clone();
- async move {
- set_add_contact_pending.set(true);
-
- if jid.read_untracked().is_empty() {
- set_error.set(Some(AddContactError::MissingJID));
- set_add_contact_pending.set(false);
- return;
- }
-
- let jid = match JID::from_str(&jid.read_untracked()) {
- Ok(j) => j.to_bare(),
- Err(e) => {
- set_error.set(Some(e.into()));
- set_add_contact_pending.set(false);
- return;
- }
- };
-
- let chat_jid = jid;
- // TODO: more options?
- match client.buddy_request(chat_jid).await {
- Ok(c) => c,
- Err(e) => {
- set_error.set(Some(e.into()));
- set_add_contact_pending.set(false);
- return;
- },
- };
-
- set_add_contact_pending.set(false);
- }
- });
-
- let jid_input = NodeRef::<Input>::new();
- let _focus = Effect::new(move |_| {
- if let Some(input) = jid_input.get() {
- let _ = input.focus();
- input.set_text_content(Some(""));
- // input.style("height: 0");
- // let height = input.scroll_height();
- // input.style(format!("height: {}px", height));
- }
- });
-
- let outgoing = move || roster.contacts().get().into_iter().filter(|(jid, contact)| {
- match *contact.contact.subscription().read() {
- filamento::roster::Subscription::None => false,
- filamento::roster::Subscription::PendingOut => true,
- filamento::roster::Subscription::PendingIn => false,
- filamento::roster::Subscription::PendingInPendingOut => true,
- filamento::roster::Subscription::OnlyOut => false,
- filamento::roster::Subscription::OnlyIn => false,
- filamento::roster::Subscription::OutPendingIn => false,
- filamento::roster::Subscription::InPendingOut => true,
- filamento::roster::Subscription::Buddy => false,
- }
- }).collect::<Vec<_>>();
-
- let accept_friend_request = Action::new_local(move |jid: &BareJID| {
- let client = client2.clone();
- let jid = jid.clone();
- async move {
- // TODO: error
- client.accept_buddy_request(jid).await;
- }
- });
-
- let reject_friend_request = Action::new_local(move |jid: &BareJID| {
- let client = client3.clone();
- let jid = jid.clone();
- async move {
- // TODO: error
- client.unsubscribe_contact(jid.clone()).await;
- set_requests.write().remove(&jid);
- }
- });
-
- let cancel_subscription_request = Action::new_local(move |jid: &BareJID| {
- let client = client4.clone();
- let jid = jid.clone();
- async move {
- // TODO: error
- client.unsubscribe_from_contact(jid).await;
-
- }
- });
-
- view! {
- <div class="add-contact-menu">
- <div>
- {error_message}
- <form on:submit=move |ev| {
- ev.prevent_default();
- add_contact.dispatch(());
- }>
- <input
- disabled=add_contact_pending
- placeholder="JID"
- type="text"
- node_ref=jid_input
- bind:value=jid
- name="jid"
- id="jid"
- autofocus="true"
- />
- <input disabled=add_contact_pending class="button" type="submit" value="Send Friend Request" />
- </form>
- </div>
- {move || if !requests.read().is_empty() {
- view! {
- <div>
- <h3>Incoming Subscription Requests</h3>
- <For each=move || requests.get() key=|request| request.clone() let(request)>
- {
- let request2 = request.clone();
- let request3 = request.clone();
- let jid_string = move || request.to_string();
- view! {
- <div class="jid-with-button"><div class="jid">{jid_string}</div>
- <div><div class="button" on:click=move |_| { accept_friend_request.dispatch(request2.clone()); } >Accept</div><div class="button" on:click=move |_| { reject_friend_request.dispatch(request3.clone()); } >Reject</div></div></div>
- }
- }
- </For>
- </div>
- }.into_any()
- } else {
- view! {}.into_any()
- }}
- {move || if !outgoing().is_empty() {
- view! {
- <div>
- <h3>Pending Outgoing Subscription Requests</h3>
- <For each=move || outgoing() key=|(jid, _contact)| jid.clone() let((jid, contact))>
- {
- let jid2 = jid.clone();
- let jid_string = move || jid.to_string();
- view! {
- <div class="jid-with-button"><div class="jid">{jid_string}</div><div class="button" on:click=move |_| { cancel_subscription_request.dispatch(jid2.clone()); } >Cancel</div></div>
- }
- }
- </For>
- </div>
- }.into_any()
- } else {
- view! {}.into_any()
- }}
- </div>
- }
-}
-
-#[component]
-fn RosterListItem(contact: MacawContact) -> impl IntoView {
- let contact_contact: Store<Contact> = contact.contact;
- let contact_user: Store<User> =
- <ArcStore<filamento::user::User> as Clone>::clone(&contact.user).into();
- let name = move || get_name(contact_user, false);
-
- let open_chats: Store<OpenChatsPanel> =
- use_context().expect("no open chats panel store in context");
-
- // TODO: why can this not be in the closure?????
- // TODO: not good, as overwrites preexisting chat state with possibly incorrect one...
- let chat = Chat {
- correspondent: contact_user.jid().get(),
- have_chatted: false,
- };
- let chat = MacawChat::got_chat_and_user(chat, contact_user.get());
-
- let open_chat = move |_| {
- debug!("opening chat");
- open_chats.update(|open_chats| open_chats.open(chat.clone()));
- };
-
- let open = move || {
- if let Some(open_chat) = &*open_chats.chat_view().read() {
- debug!("got open chat: {:?}", open_chat);
- if *open_chat == *contact_user.jid().read() {
- return Open::Focused;
- }
- }
- if let Some(_backgrounded_chat) = open_chats
- .chats()
- .read()
- .get(contact_user.jid().read().deref())
- {
- return Open::Open;
- }
- Open::Closed
- };
- let focused = move || open().is_focused();
- let open = move || open().is_open();
-
- view! {
- <div class="roster-list-item" class:open=move || open() class:focused=move || focused() on:click=open_chat>
- <AvatarWithPresence user=contact_user />
- <div class="item-info">
- <div class="main-info"><p class="name">{name}<span class="jid"> - {move || contact_contact.user_jid().read().to_string()}</span></p></div>
- <div class="sub-info">{move || contact_contact.subscription().read().to_string()}</div>
- </div>
- </div>
- }
-}
-
-pub async fn get_avatar(user: Store<User>) -> String {
- if let Some(avatar) = &user.read().avatar {
- let client = use_context::<Client>().expect("client not in context");
- if let Some(data) = client.file_store.get_src(avatar).await {
- data
- } else {
- NO_AVATAR.to_string()
- }
- // TODO: enable avatar fetching
- // format!("/files/{}", avatar)
- } else {
- NO_AVATAR.to_string()
- }
-}
-
-pub fn get_name(user: Store<User>, note_to_self: bool) -> String {
- let roster: Store<Roster> = use_context().expect("no roster in context");
- if note_to_self {
- let client: Client = use_context().expect("no client in context");
- if *client.jid == *user.jid().read() {
- return "Note to self".to_string()
- }
- }
- if let Some(name) = roster
- .contacts()
- .read()
- .get(&user.read().jid)
- .map(|contact| contact.read().name.clone())
- .unwrap_or_default()
- {
- name.to_string()
- } else if let Some(nick) = &user.read().nick {
- nick.to_string()
- } else {
- user.read().jid.to_string()
- }
-}
-
-pub enum Open {
- /// Currently on screen
- Focused,
- /// Open in background somewhere (e.g. in another chat tab)
- Open,
- /// Closed
- Closed,
-}
-
-impl Open {
- pub fn is_focused(&self) -> bool {
- match self {
- Open::Focused => true,
- Open::Open => false,
- Open::Closed => false,
- }
- }
-
- pub fn is_open(&self) -> bool {
- match self {
- Open::Focused => true,
- Open::Open => true,
- Open::Closed => false,
- }
- }
-}
-
-#[component]
-fn ChatsListItem(chat: MacawChat, message: MacawMessage) -> impl IntoView {
- let chat_chat: Store<Chat> = <ArcStore<Chat> as Clone>::clone(&chat.chat).into();
- let chat_user: Store<User> =
- <ArcStore<filamento::user::User> as Clone>::clone(&chat.user).into();
- let message_message: Store<Message> = <ArcStore<Message> as Clone>::clone(&message.message).into();
- let name = move || get_name(chat_user, true);
-
- // TODO: store fine-grained reactivity
- let latest_message_body = move || message_message.body().get().body;
- let open_chats: Store<OpenChatsPanel> =
- use_context().expect("no open chats panel store in context");
-
- let open_chat = move |_| {
- debug!("opening chat");
- open_chats.update(|open_chats| open_chats.open(chat.clone()));
- };
-
- let open = move || {
- if let Some(open_chat) = &*open_chats.chat_view().read() {
- debug!("got open chat: {:?}", open_chat);
- if *open_chat == *chat_chat.correspondent().read() {
- return Open::Focused;
- }
- }
- if let Some(_backgrounded_chat) = open_chats
- .chats()
- .read()
- .get(chat_chat.correspondent().read().deref())
- {
- return Open::Open;
- }
- Open::Closed
- };
- let focused = move || open().is_focused();
- let open = move || open().is_open();
-
- let date = move || message_message.timestamp().read().naive_local();
- let now = move || Local::now().naive_local();
- let timeinfo = move || if date().date() == now().date() {
- // TODO: localisation/config
- date().time().format("%H:%M").to_string()
- } else {
- date().date().format("%d/%m").to_string()
- };
-
- view! {
- <div class="chats-list-item" class:open=move || open() class:focused=move || focused() on:click=open_chat>
- <AvatarWithPresence user=chat_user />
- <div class="item-info">
- <div class="main-info"><p class="name">{name}</p><p class="timestamp">{timeinfo}</p></div>
- <div class="sub-info"><p class="message-preview">{latest_message_body}</p><p><!-- "TODO: delivery or unread state" --></p></div>
- </div>
- </div>
- }
-}