diff options
| -rw-r--r-- | assets/style.scss | 56 | ||||
| -rw-r--r-- | src/lib.rs | 171 | 
2 files changed, 222 insertions, 5 deletions
diff --git a/assets/style.scss b/assets/style.scss index 4ad2dd7..0efe7e9 100644 --- a/assets/style.scss +++ b/assets/style.scss @@ -159,9 +159,15 @@ p {    padding: 8px 16px;  } -.chats-list h2, .roster-list h2 { +.chats-list .header, .roster-list .header {    margin-top: 0.4rem;    border-bottom: 2px solid black; +  display: flex; +  justify-content: space-between; +  align-items: center; +} + +.chats-list h2, .roster-list h2 {    flex: 0 0 auto;  } @@ -564,7 +570,7 @@ p {    z-index: 100;  } -.overlay-background { +.overlay-background, .modal-background {    position: fixed;    top: 0;    left: 0; @@ -577,6 +583,31 @@ p {    position: absolute;  } +.modal { +  position: relative; +  z-index: 150; +} + +.modal-background { +  background-color: rgba(0, 0, 0, 0.1); +} + +.modal-content { +  position: fixed; +  top: 0; +  left: 0; +  width: 100vw; +  height: 100vh; +} + +.modal .overlay { +  x-index: 200; +} + +.modal .overlay-content { +  z-index: 201; +} +  .personal .overlay-content {    bottom: 0;    left: 100%; @@ -628,6 +659,27 @@ hr {    width: 100%;  } +.header .header-icon { +  position: relative; +  top: -0.2rem; +  border-radius: 1em; +  padding: 4px; +} + +.new-chat:hover, .new-chat.open { +  background: #00000060; +} + +.new-chat-widget form { +  border: 2px solid black; +  padding: 4px; +  gap: 4px; +  background-color: #dcdcdc; +  display: flex; +  flex-direction: column; +  align-items: end; +} +  /* font-families */  /* thai */ @@ -1024,6 +1024,16 @@ pub fn Overlay(set_open: WriteSignal<bool>, children: Children) -> impl IntoView  }  #[component] +pub fn Modal(set_open: WriteSignal<bool>, children: Children) -> impl IntoView { +    view! { +        <div class="modal"> +            <div class="modal-background" on:click=move |_| set_open.update(|state| *state = false)></div> +            <div class="modal-content">{children()}</div> +        </div> +    } +} + +#[component]  pub fn OpenChatsPanelView() -> impl IntoView {      let open_chats: Store<OpenChatsPanel> = use_context().expect("no open chats panel in context"); @@ -1377,12 +1387,38 @@ impl Icon {              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::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:icon=true style=move || format!("height: {}px; width: {}px", icon.size(), icon.size()) src=move || icon.src() /> +        <img class:light=icon.light() class:icon=true style=move || format!("height: {}px; width: {}px", icon.size(), icon.size()) src=move || icon.src() />      }  } @@ -1945,6 +1981,8 @@ fn ChatsList() -> impl IntoView {          }      }); +    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); @@ -1983,7 +2021,24 @@ fn ChatsList() -> impl IntoView {      view! {          <div class="chats-list panel"> -            <h2>Chats</h2> +            // 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 /> @@ -1993,6 +2048,116 @@ fn ChatsList() -> impl IntoView {      }  } +#[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<JID, ArcStore<Chat>> = +        use_context().expect("no chat state store"); +    let user_state_store: StateStore<JID, 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()) { +                Ok(j) => j, +                Err(e) => { +                    set_error.set(Some(e.into())); +                    set_new_chat_pending.set(false); +                    return; +                } +            }; + +            let chat_jid = jid.as_bare(); +            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 roster: Store<Roster> = use_context().expect("no roster in context"); @@ -2000,7 +2165,7 @@ fn RosterList() -> impl IntoView {      // TODO: filter new messages signal      view! {          <div class="roster-list panel"> -            <h2>Roster</h2> +            <div class="header"><h2>Roster</h2><div class="header-icon"><IconComponent icon=Icon::AddContact24 /></div></div>              <div class="roster-list-roster">                  <For each=move || roster.contacts().get() key=|contact| contact.0.clone() let(contact)>                      <RosterListItem contact=contact.1 />  | 
