diff options
36 files changed, 3051 insertions, 2779 deletions
diff --git a/src/chat.rs b/src/chat.rs new file mode 100644 index 0000000..6785b06 --- /dev/null +++ b/src/chat.rs @@ -0,0 +1,40 @@ +use std::ops::{Deref, DerefMut}; + +use filamento::{chat::Chat, user::User}; +use jid::BareJID; +use reactive_stores::ArcStore; +use leptos::prelude::*; + +use crate::{state_store::{StateListener, StateStore}, user::MacawUser}; + +#[derive(Clone)] +pub struct MacawChat { +    pub chat: StateListener<BareJID, ArcStore<Chat>>, +    pub user: MacawUser, +    // user: StateListener<BareJID, ArcStore<User>>, +} + +impl MacawChat { +    pub 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 chat = chat_state_store.store(chat.correspondent.clone(), ArcStore::new(chat)); +        let user = MacawUser::got_user(user); +        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 +    } +} + diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..b3ff6bb --- /dev/null +++ b/src/client.rs @@ -0,0 +1,30 @@ +use std::{ops::{Deref, DerefMut}, sync::Arc}; + +use jid::BareJID; +use leptos::prelude::*; + +use crate::files::Files; + +#[derive(Clone)] +pub struct Client { +    // TODO: not pub +    pub client: filamento::Client<Files>, +    pub resource: ArcRwSignal<Option<String>>, +    pub jid: Arc<BareJID>, +    pub file_store: Files, +} + +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 +    } +} + diff --git a/src/components/avatar.rs b/src/components/avatar.rs new file mode 100644 index 0000000..9265ef7 --- /dev/null +++ b/src/components/avatar.rs @@ -0,0 +1,36 @@ +use filamento::{presence::PresenceType, user::User}; +use leptos::prelude::*; +use reactive_stores::Store; + +use crate::{components::icon::{show_to_icon, IconComponent}, icon::Icon, user::get_avatar, user_presences::UserPresences}; + +#[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> +    } +} + diff --git a/src/components/chat_header.rs b/src/components/chat_header.rs new file mode 100644 index 0000000..208e7f6 --- /dev/null +++ b/src/components/chat_header.rs @@ -0,0 +1,23 @@ +use filamento::user::UserStoreFields; +use leptos::prelude::*; +use reactive_stores::ArcStore; + +use crate::{chat::MacawChat, components::avatar::AvatarWithPresence, user::get_name}; + +#[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> +    } +} + diff --git a/src/components/chats_list.rs b/src/components/chats_list.rs new file mode 100644 index 0000000..b8cf34c --- /dev/null +++ b/src/components/chats_list.rs @@ -0,0 +1,109 @@ +use chats_list_item::ChatsListItem; +use indexmap::IndexMap; +use jid::BareJID; +use leptos::prelude::*; +use tracing::debug; + +use crate::{chat::MacawChat, client::Client, components::{icon::IconComponent, new_chat::NewChatWidget, overlay::Overlay}, icon::Icon, message::MacawMessage, message_subscriptions::MessageSubscriptions}; + +mod chats_list_item; + +#[component] +pub 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.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> +    } +} + diff --git a/src/components/chats_list/chats_list_item.rs b/src/components/chats_list/chats_list_item.rs new file mode 100644 index 0000000..191f163 --- /dev/null +++ b/src/components/chats_list/chats_list_item.rs @@ -0,0 +1,66 @@ +use std::ops::Deref; + +use chrono::Local; +use filamento::{chat::{Chat, ChatStoreFields, Message, MessageStoreFields}, user::User}; +use leptos::prelude::*; +use reactive_stores::{ArcStore, Store}; +use tracing::debug; + +use crate::{chat::MacawChat, components::{avatar::AvatarWithPresence, sidebar::Open}, message::MacawMessage, open_chats::{OpenChatsPanel, OpenChatsPanelStoreFields}, user::get_name}; + +#[component] +pub 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> +    } +} diff --git a/src/components/icon.rs b/src/components/icon.rs new file mode 100644 index 0000000..7eaa52f --- /dev/null +++ b/src/components/icon.rs @@ -0,0 +1,57 @@ +use leptos::prelude::*; +use filamento::{chat::Delivery, presence::Show}; + +use crate::icon::Icon; + +// TODO: rename +#[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() /> +    } +} + +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 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() +        } +    } +} + + diff --git a/src/components/message.rs b/src/components/message.rs new file mode 100644 index 0000000..2ae2ef0 --- /dev/null +++ b/src/components/message.rs @@ -0,0 +1,52 @@ +use filamento::chat::MessageStoreFields; +use leptos::prelude::*; +use reactive_stores::{ArcStore, Store}; + +use crate::{message::MacawMessage, user::{get_avatar, get_name, NO_AVATAR}}; + +use super::icon::Delivery; + +#[component] +pub fn Message(message: MacawMessage, major: bool, r#final: bool) -> impl IntoView { +    let message_message: Store<filamento::chat::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() +    } +} + diff --git a/src/components/message_composer.rs b/src/components/message_composer.rs new file mode 100644 index 0000000..3876a5a --- /dev/null +++ b/src/components/message_composer.rs @@ -0,0 +1,97 @@ +use filamento::chat::Body; +use jid::BareJID; +use leptos::{html::Div, prelude::*, task::spawn_local}; + +use crate::client::Client; + +#[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> +    } +} + + diff --git a/src/components/message_history_buffer.rs b/src/components/message_history_buffer.rs new file mode 100644 index 0000000..36439a8 --- /dev/null +++ b/src/components/message_history_buffer.rs @@ -0,0 +1,176 @@ +use chrono::{NaiveDateTime, TimeDelta}; +use filamento::{chat::{Chat, ChatStoreFields, MessageStoreFields}, user::User}; +use indexmap::IndexMap; +use jid::BareJID; +use leptos::prelude::*; +use reactive_stores::{ArcStore, Store}; +use tracing::{debug, error}; +use uuid::Uuid; + +use crate::{chat::MacawChat, client::Client, components::message::Message, message::MacawMessage, message_subscriptions::MessageSubscriptions}; + +#[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.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> +    } +} + diff --git a/src/components/mod.rs b/src/components/mod.rs new file mode 100644 index 0000000..879f99e --- /dev/null +++ b/src/components/mod.rs @@ -0,0 +1,13 @@ +pub mod sidebar; +mod chats_list; +mod new_chat; +mod roster_list; +mod avatar; +mod message; +pub mod message_history_buffer; +pub mod message_composer; +pub mod chat_header; +mod overlay; +pub mod modal; +pub mod icon; +mod personal_status; diff --git a/src/components/modal.rs b/src/components/modal.rs new file mode 100644 index 0000000..62e1fac --- /dev/null +++ b/src/components/modal.rs @@ -0,0 +1,16 @@ +use leptos::prelude::*; +use leptos::ev::MouseEvent; + +#[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> +    } +} + diff --git a/src/components/new_chat.rs b/src/components/new_chat.rs new file mode 100644 index 0000000..8047afb --- /dev/null +++ b/src/components/new_chat.rs @@ -0,0 +1,123 @@ +use std::str::FromStr; + +use filamento::{chat::Chat, error::{CommandError, DatabaseError}, user::User}; +use jid::{BareJID, JID}; +use leptos::{html::Input, prelude::*}; +use reactive_stores::{ArcStore, Store}; +use thiserror::Error; + +use crate::{chat::MacawChat, client::Client, open_chats::OpenChatsPanel, state_store::StateStore, user::MacawUser}; + +#[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] +pub 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 = MacawUser::got_user(user); +                let user = user_state_store.store(user.jid.clone(), ArcStore::new(user)); +                let user = MacawUser { 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> +    } +} + diff --git a/src/components/overlay.rs b/src/components/overlay.rs new file mode 100644 index 0000000..d4ff1bf --- /dev/null +++ b/src/components/overlay.rs @@ -0,0 +1,16 @@ +use leptos::prelude::*; +use tracing::debug; + +#[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> +    } +} + diff --git a/src/components/personal_status.rs b/src/components/personal_status.rs new file mode 100644 index 0000000..f830a1b --- /dev/null +++ b/src/components/personal_status.rs @@ -0,0 +1,178 @@ +use filamento::{presence::{Offline, Online, PresenceType, Show}, user::{User, UserStoreFields}}; +use leptos::{html, prelude::*}; +use reactive_stores::{ArcStore, Store}; +use tracing::{debug, error}; + +use crate::{client::Client, components::{avatar::AvatarWithPresence, overlay::Overlay}, user::{get_name, MacawUser}, user_presences::UserPresences, views::{macaw::settings::SettingsPage, AppState}}; + +#[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> +    } +} + diff --git a/src/components/roster_list.rs b/src/components/roster_list.rs new file mode 100644 index 0000000..a398ffe --- /dev/null +++ b/src/components/roster_list.rs @@ -0,0 +1,58 @@ +use std::collections::HashSet; + +use contact_request_manager::AddContact; +use jid::BareJID; +use leptos::prelude::*; +use reactive_stores::Store; +use roster_list_item::RosterListItem; + +use crate::{components::icon::IconComponent, icon::Icon, roster::{Roster, RosterStoreFields}}; + +mod contact_request_manager; +mod roster_list_item; + +#[component] +pub 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> +    } +} + diff --git a/src/components/roster_list/contact_request_manager.rs b/src/components/roster_list/contact_request_manager.rs new file mode 100644 index 0000000..174e677 --- /dev/null +++ b/src/components/roster_list/contact_request_manager.rs @@ -0,0 +1,198 @@ +use std::{collections::HashSet, str::FromStr}; + +use filamento::{error::{CommandError, SubscribeError}, roster::ContactStoreFields}; +use jid::{BareJID, JID}; +use leptos::{html::Input, prelude::*}; +use reactive_stores::Store; +use thiserror::Error; + +use crate::{client::Client, roster::{Roster, RosterStoreFields}}; + +#[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] +// TODO: rename +pub 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> +    } +} + diff --git a/src/components/roster_list/roster_list_item.rs b/src/components/roster_list/roster_list_item.rs new file mode 100644 index 0000000..46ac1cc --- /dev/null +++ b/src/components/roster_list/roster_list_item.rs @@ -0,0 +1,62 @@ +use std::ops::Deref; + +use filamento::{chat::Chat, roster::{Contact, ContactStoreFields}, user::{User, UserStoreFields}}; +use leptos::prelude::*; +use reactive_stores::{ArcStore, Store}; +use tracing::debug; + +use crate::{chat::MacawChat, components::{avatar::AvatarWithPresence, sidebar::Open}, contact::MacawContact, open_chats::{OpenChatsPanel, OpenChatsPanelStoreFields}, user::get_name}; + +#[component] +pub 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> +    } +} + diff --git a/src/components/sidebar.rs b/src/components/sidebar.rs new file mode 100644 index 0000000..ca753ef --- /dev/null +++ b/src/components/sidebar.rs @@ -0,0 +1,176 @@ +use std::collections::HashSet; + +use jid::BareJID; +use leptos::prelude::*; + +use crate::components::{ +    personal_status::PersonalStatus, +    chats_list::ChatsList, +    roster_list::RosterList, +}; + +#[derive(PartialEq, Eq, Clone, Copy)] +pub enum SidebarOpen { +    Roster, +    Chats, +} + +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, +        } +    } +} + +/// 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> +    } +} + diff --git a/src/contact.rs b/src/contact.rs new file mode 100644 index 0000000..e9ab21f --- /dev/null +++ b/src/contact.rs @@ -0,0 +1,35 @@ +use std::ops::{Deref, DerefMut}; + +use filamento::{roster::Contact, user::User}; +use reactive_stores::Store; + +use crate::user::MacawUser; + +#[derive(Clone)] +pub struct MacawContact { +    pub contact: Store<Contact>, +    pub user: MacawUser, +} + +impl MacawContact { +    pub fn got_contact_and_user(contact: Contact, user: User) -> Self { +        let contact = Store::new(contact); +        let user = MacawUser::got_user(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 +    } +} + diff --git a/src/context.rs b/src/context.rs new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/context.rs diff --git a/src/files.rs b/src/files.rs new file mode 100644 index 0000000..b9e50df --- /dev/null +++ b/src/files.rs @@ -0,0 +1,50 @@ +use base64::{prelude::BASE64_STANDARD, Engine}; +use filamento::files::{opfs::OPFSError, FileStore, FilesMem, FilesOPFS}; + +#[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(), +        } +    } +} + diff --git a/src/icon.rs b/src/icon.rs new file mode 100644 index 0000000..b0ef60e --- /dev/null +++ b/src/icon.rs @@ -0,0 +1,111 @@ +#[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, +        } +    } +} + @@ -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> -    } -} diff --git a/src/message.rs b/src/message.rs new file mode 100644 index 0000000..e5caed1 --- /dev/null +++ b/src/message.rs @@ -0,0 +1,39 @@ +use std::ops::{Deref, DerefMut}; + +use filamento::{chat::Message, user::User}; +use reactive_stores::ArcStore; +use uuid::Uuid; +use leptos::prelude::*; + +use crate::{state_store::{StateListener, StateStore}, user::MacawUser}; + +#[derive(Clone)] +pub struct MacawMessage { +    pub message: StateListener<Uuid, ArcStore<Message>>, +    pub user: MacawUser, +} + +impl MacawMessage { +    pub 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 message = message_state_store.store(message.id, ArcStore::new(message)); +        let user = MacawUser::got_user(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 +    } +} + diff --git a/src/message_subscriptions.rs b/src/message_subscriptions.rs new file mode 100644 index 0000000..5b1d276 --- /dev/null +++ b/src/message_subscriptions.rs @@ -0,0 +1,86 @@ +use std::collections::HashMap; + +use jid::BareJID; +use tokio::sync::mpsc::{self, Receiver}; +use uuid::Uuid; + +use crate::message::MacawMessage; + +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); +        } +    } +} + diff --git a/src/open_chats.rs b/src/open_chats.rs new file mode 100644 index 0000000..ed89537 --- /dev/null +++ b/src/open_chats.rs @@ -0,0 +1,88 @@ +use filamento::chat::ChatStoreFields; +use indexmap::IndexMap; +use jid::BareJID; +use reactive_stores::{ArcStore, Store}; +use tracing::debug; +use leptos::prelude::*; + +use crate::chat::MacawChat; + +#[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) { + +    // } +} + + diff --git a/src/roster.rs b/src/roster.rs new file mode 100644 index 0000000..75f4c3b --- /dev/null +++ b/src/roster.rs @@ -0,0 +1,21 @@ +use std::collections::HashMap; + +use jid::BareJID; +use reactive_stores::Store; + +use crate::contact::MacawContact; + +#[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(), +        } +    } +} + diff --git a/src/state_store.rs b/src/state_store.rs new file mode 100644 index 0000000..2536cda --- /dev/null +++ b/src/state_store.rs @@ -0,0 +1,238 @@ +use std::{collections::HashMap, ops::{Deref, DerefMut}, sync::{Arc, RwLock}}; + +use leptos::prelude::*; + +// TODO: get rid of this +// V has to be an arc signal +#[derive(Debug)] +pub 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)] +pub 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)] +pub 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); +    } +} diff --git a/src/user.rs b/src/user.rs new file mode 100644 index 0000000..f55c0dd --- /dev/null +++ b/src/user.rs @@ -0,0 +1,78 @@ +use std::ops::{Deref, DerefMut}; + +use filamento::user::{User, UserStoreFields}; +use jid::BareJID; +use reactive_stores::{ArcStore, Store}; +use leptos::prelude::*; + +use crate::{client::Client, roster::{Roster, RosterStoreFields}, state_store::{StateListener, StateStore}}; + +#[derive(Clone)] +pub struct MacawUser { +    pub user: StateListener<BareJID, ArcStore<User>>, +} + +impl MacawUser { +    pub 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 +    } +} + +pub const NO_AVATAR: &str = "/assets/no-avatar.png"; + +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() +    } +} + diff --git a/src/user_presences.rs b/src/user_presences.rs new file mode 100644 index 0000000..1a719a2 --- /dev/null +++ b/src/user_presences.rs @@ -0,0 +1,133 @@ +use std::collections::HashMap; + +use chrono::Utc; +use filamento::presence::{Offline, Presence, PresenceType, Show}; +use indexmap::IndexMap; +use jid::BareJID; +use leptos::prelude::*; +use reactive_stores::Store; + +#[derive(Store)] +pub struct UserPresences { +    #[store(key: BareJID = |(jid, _)| jid.clone())] +    pub 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()), +            } +        } +    } +} + diff --git a/src/views/login_page.rs b/src/views/login_page.rs new file mode 100644 index 0000000..2edd4b5 --- /dev/null +++ b/src/views/login_page.rs @@ -0,0 +1,189 @@ +use std::{str::FromStr, sync::Arc}; + +use filamento::{db::Db, error::{CommandError, ConnectionError}, files::{opfs::OPFSError, FilesMem, FilesOPFS}, UpdateMessage}; +use jid::JID; +use thiserror::Error; +use leptos::prelude::*; +use tokio::sync::mpsc::Receiver; +use tracing::debug; + +use crate::{client::Client, files::Files}; + +use super::AppState; + +#[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] +pub 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(), +            ); +            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> +    } +} diff --git a/src/views/macaw.rs b/src/views/macaw.rs new file mode 100644 index 0000000..18e0ad3 --- /dev/null +++ b/src/views/macaw.rs @@ -0,0 +1,162 @@ +use std::collections::HashSet; + +use filamento::{chat::{Chat, Message, MessageStoreFields}, user::User, UpdateMessage}; +use jid::BareJID; +use leptos::{prelude::*, task::spawn_local}; +use open_chats_panel::OpenChatsPanelView; +use reactive_stores::{ArcStore, Store}; +use settings::{Settings, SettingsPage}; +use tokio::sync::mpsc::Receiver; +use tracing::debug; +use uuid::Uuid; + +use crate::{client::Client, components::sidebar::Sidebar, contact::MacawContact, message::MacawMessage, message_subscriptions::MessageSubscriptions, open_chats::OpenChatsPanel, roster::{Roster, RosterStoreFields}, state_store::StateStore, user::MacawUser, user_presences::{Presences, UserPresences}}; + +use super::AppState; + +pub mod settings; +mod open_chats_panel; + +#[component] +pub 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() +        }} +    } +} + diff --git a/src/views/macaw/open_chats_panel.rs b/src/views/macaw/open_chats_panel.rs new file mode 100644 index 0000000..062c786 --- /dev/null +++ b/src/views/macaw/open_chats_panel.rs @@ -0,0 +1,73 @@ +use leptos::prelude::*; +use open_chat::OpenChatView; +use reactive_stores::{ArcStore, Store}; + +use crate::open_chats::{OpenChatsPanel, OpenChatsPanelStoreFields}; + +// TODO: multiple panels +// pub struct OpenChats { +//     panels: +// } + +#[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> +    } +} + +mod open_chat { +    use filamento::chat::{Chat, ChatStoreFields}; +    use leptos::prelude::*; +    use reactive_stores::{ArcStore, Store}; + +    use crate::{chat::MacawChat, components::{chat_header::ChatViewHeader, message_composer::ChatViewMessageComposer, message_history_buffer::MessageHistoryBuffer}}; + +    #[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> +        } +    } +} + diff --git a/src/views/macaw/settings.rs b/src/views/macaw/settings.rs new file mode 100644 index 0000000..11a3fc3 --- /dev/null +++ b/src/views/macaw/settings.rs @@ -0,0 +1,170 @@ +use leptos::prelude::*; +use profile_settings::ProfileSettings; + +use crate::{components::{icon::IconComponent, modal::Modal}, icon::Icon}; + +mod profile_settings { +    use filamento::error::{AvatarPublishError, CommandError}; +    use thiserror::Error; +    use leptos::prelude::*; +    use web_sys::{js_sys::Uint8Array, wasm_bindgen::{prelude::Closure, JsCast, UnwrapThrowExt}, Event, FileReader, HtmlInputElement, ProgressEvent}; + +    use crate::{client::Client, files::Files}; + +    #[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> +        } +    } +} + +#[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> +    } +} + diff --git a/src/views/mod.rs b/src/views/mod.rs new file mode 100644 index 0000000..112f930 --- /dev/null +++ b/src/views/mod.rs @@ -0,0 +1,36 @@ +use filamento::UpdateMessage; +use leptos::prelude::*; +use login_page::LoginPage; +use macaw::Macaw; +use tokio::sync::mpsc::Receiver; + +use crate::client::Client; + +pub mod login_page; +pub mod macaw; + +pub enum AppState { +    LoggedOut, +    LoggedIn, +} + +#[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() +                } +            } +        }} +    } +} +  | 
