diff --git a/assets/brand/coop-dark.png b/assets/brand/coop-dark.png deleted file mode 100644 index cb69627..0000000 Binary files a/assets/brand/coop-dark.png and /dev/null differ diff --git a/assets/brand/coop-light.png b/assets/brand/coop-light.png deleted file mode 100644 index c7f31c2..0000000 Binary files a/assets/brand/coop-light.png and /dev/null differ diff --git a/assets/brand/coop.svg b/assets/brand/coop.svg new file mode 100644 index 0000000..c463b0c --- /dev/null +++ b/assets/brand/coop.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/app/src/states/chat/room.rs b/crates/app/src/states/chat/room.rs index 9925d81..b88991c 100644 --- a/crates/app/src/states/chat/room.rs +++ b/crates/app/src/states/chat/room.rs @@ -31,7 +31,7 @@ impl Member { IMAGE_SERVICE, picture ) } else { - "brands/avatar.png".into() + "brand/avatar.png".into() } } diff --git a/crates/app/src/utils.rs b/crates/app/src/utils.rs index 6adf7ef..39f55b1 100644 --- a/crates/app/src/utils.rs +++ b/crates/app/src/utils.rs @@ -1,12 +1,11 @@ -use chrono::{Duration, Local, TimeZone}; +use crate::{constants::NIP96_SERVER, get_client}; +use chrono::{Datelike, Local, TimeZone}; use nostr_sdk::prelude::*; use std::{ collections::HashSet, hash::{DefaultHasher, Hash, Hasher}, }; -use crate::{constants::NIP96_SERVER, get_client}; - pub async fn nip96_upload(file: Vec) -> anyhow::Result { let client = get_client(); let signer = client.signer().await?; @@ -42,50 +41,47 @@ pub fn shorted_public_key(public_key: PublicKey) -> String { format!("{}:{}", &pk[0..4], &pk[pk.len() - 4..]) } -pub fn show_npub(public_key: PublicKey, len: usize) -> String { - let bech32 = public_key.to_bech32().unwrap_or_default(); - let separator = " ... "; - - let sep_len = separator.len(); - let chars_to_show = len - sep_len; - let front_chars = (chars_to_show + 1) / 2; // ceil - let back_chars = chars_to_show / 2; // floor - - format!( - "{}{}{}", - &bech32[..front_chars], - separator, - &bech32[bech32.len() - back_chars..] - ) -} - -pub fn ago(time: Timestamp) -> String { +pub fn message_ago(time: Timestamp) -> String { let now = Local::now(); let input_time = Local.timestamp_opt(time.as_u64() as i64, 0).unwrap(); let diff = (now - input_time).num_hours(); if diff < 24 { let duration = now.signed_duration_since(input_time); - format_duration(duration) + + if duration.num_seconds() < 60 { + "now".to_string() + } else if duration.num_minutes() == 1 { + "1m".to_string() + } else if duration.num_minutes() < 60 { + format!("{}m", duration.num_minutes()) + } else if duration.num_hours() == 1 { + "1h".to_string() + } else if duration.num_hours() < 24 { + format!("{}h", duration.num_hours()) + } else if duration.num_days() == 1 { + "1d".to_string() + } else { + format!("{}d", duration.num_days()) + } } else { input_time.format("%b %d").to_string() } } -pub fn format_duration(duration: Duration) -> String { - if duration.num_seconds() < 60 { - "now".to_string() - } else if duration.num_minutes() == 1 { - "1m".to_string() - } else if duration.num_minutes() < 60 { - format!("{}m", duration.num_minutes()) - } else if duration.num_hours() == 1 { - "1h".to_string() - } else if duration.num_hours() < 24 { - format!("{}h", duration.num_hours()) - } else if duration.num_days() == 1 { - "1d".to_string() +pub fn message_time(time: Timestamp) -> String { + let now = Local::now(); + let input_time = Local.timestamp_opt(time.as_u64() as i64, 0).unwrap(); + + if input_time.day() == now.day() { + format!("Today at {}", input_time.format("%H:%M %p")) + } else if input_time.day() == now.day() - 1 { + format!("Yesterday at {}", input_time.format("%H:%M %p")) } else { - format!("{}d", duration.num_days()) + format!( + "{}, {}", + input_time.format("%d/%m/%y"), + input_time.format("%H:%M %p") + ) } } diff --git a/crates/app/src/views/chat/mod.rs b/crates/app/src/views/chat/mod.rs index d434157..e9837ca 100644 --- a/crates/app/src/views/chat/mod.rs +++ b/crates/app/src/views/chat/mod.rs @@ -2,7 +2,7 @@ use crate::{ constants::IMAGE_SERVICE, get_client, states::chat::room::Room, - utils::{ago, compare, nip96_upload}, + utils::{compare, message_time, nip96_upload}, }; use async_utility::task::spawn; use gpui::{ @@ -197,7 +197,7 @@ impl ChatPanel { Some(Message::new( member, ev.content.into(), - ago(ev.created_at).into(), + message_time(ev.created_at).into(), )) } else { None @@ -228,7 +228,7 @@ impl ChatPanel { Message::new( member, event.content.clone().into(), - ago(event.created_at).into(), + message_time(event.created_at).into(), ) }) }) @@ -298,7 +298,7 @@ impl ChatPanel { let message = Message::new( owner, content.to_string().into(), - ago(Timestamp::now()).into(), + message_time(Timestamp::now()).into(), ); model.items.extend(vec![message]); diff --git a/crates/app/src/views/sidebar/compose.rs b/crates/app/src/views/sidebar/compose.rs new file mode 100644 index 0000000..90f05ca --- /dev/null +++ b/crates/app/src/views/sidebar/compose.rs @@ -0,0 +1,222 @@ +use crate::{get_client, states::chat::room::Member}; +use gpui::{ + div, img, impl_internal_actions, px, uniform_list, Context, FocusHandle, InteractiveElement, + IntoElement, Model, ParentElement, Render, StatefulInteractiveElement, Styled, View, + ViewContext, VisualContext, WindowContext, +}; +use nostr_sdk::prelude::*; +use serde::Deserialize; +use std::collections::HashSet; +use ui::{ + indicator::Indicator, + input::TextInput, + prelude::FluentBuilder, + theme::{scale::ColorScaleStep, ActiveTheme}, + Icon, IconName, Sizable, StyledExt, +}; + +#[derive(Clone, PartialEq, Eq, Deserialize)] +struct SelectContact(PublicKey); + +impl_internal_actions!(contacts, [SelectContact]); + +pub struct Compose { + input: View, + contacts: Model>>, + selected: Model>, + focus_handle: FocusHandle, +} + +impl Compose { + pub fn new(cx: &mut ViewContext<'_, Self>) -> Self { + let contacts = cx.new_model(|_| None); + let selected = cx.new_model(|_| HashSet::new()); + let input = cx.new_view(|cx| { + TextInput::new(cx) + .appearance(false) + .text_size(ui::Size::Small) + .placeholder("npub1...") + .cleanable() + }); + + cx.spawn(|this, mut async_cx| { + let client = get_client(); + + async move { + let query: anyhow::Result, anyhow::Error> = async_cx + .background_executor() + .spawn(async move { + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + let profiles = client.database().contacts(public_key).await?; + let members: Vec = profiles + .into_iter() + .map(|profile| Member::new(profile.public_key(), profile.metadata())) + .collect(); + + Ok(members) + }) + .await; + + if let Ok(contacts) = query { + if let Some(view) = this.upgrade() { + _ = async_cx.update_view(&view, |this, cx| { + this.contacts.update(cx, |this, cx| { + *this = Some(contacts); + cx.notify(); + }); + + cx.notify(); + }); + } + } + } + }) + .detach(); + + Self { + input, + contacts, + selected, + focus_handle: cx.focus_handle(), + } + } + + pub fn selected<'a>(&self, cx: &'a WindowContext) -> Vec<&'a PublicKey> { + self.selected.read(cx).iter().collect() + } + + fn on_action_select(&mut self, action: &SelectContact, cx: &mut ViewContext) { + self.selected.update(cx, |this, cx| { + if this.contains(&action.0) { + this.remove(&action.0); + } else { + this.insert(action.0); + }; + cx.notify(); + }); + + // TODO + } +} + +impl Render for Compose { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let msg = + "Start a conversation with someone using their npub or NIP-05 (like foo@bar.com)."; + + div() + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::on_action_select)) + .flex() + .flex_col() + .gap_3() + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_xs() + .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) + .child(msg), + ) + .child( + div() + .bg(cx.theme().base.step(cx, ColorScaleStep::FOUR)) + .rounded(px(cx.theme().radius)) + .px_2() + .child(self.input.clone()), + ), + ) + .child( + div() + .flex() + .flex_col() + .gap_1() + .child(div().text_xs().font_semibold().child("Contacts")) + .child(div().map(|this| { + if let Some(contacts) = self.contacts.read(cx).clone() { + this.child( + uniform_list( + cx.view().clone(), + "contacts", + contacts.len(), + move |this, range, cx| { + let selected = this.selected.read(cx); + let mut items = Vec::new(); + + for ix in range { + let item = contacts.get(ix).unwrap().clone(); + let is_select = selected.contains(&item.public_key()); + + items.push( + div() + .id(ix) + .w_full() + .h_10() + .px_1p5() + .rounded(px(cx.theme().radius)) + .flex() + .items_center() + .justify_between() + .child( + div() + .flex() + .items_center() + .gap_2() + .text_sm() + .child( + div().flex_shrink_0().child( + img(item.avatar()).size_8(), + ), + ) + .child(item.name()), + ) + .when(is_select, |this| { + this.child( + Icon::new(IconName::CircleCheck) + .size_4() + .text_color(cx.theme().base.step( + cx, + ColorScaleStep::TWELVE, + )), + ) + }) + .hover(|this| { + this.bg(cx + .theme() + .base + .step(cx, ColorScaleStep::FOUR)) + .text_color( + cx.theme().base.step( + cx, + ColorScaleStep::ELEVEN, + ), + ) + }) + .on_click(move |_, cx| { + cx.dispatch_action(Box::new( + SelectContact(item.public_key()), + )); + }), + ); + } + + items + }, + ) + .h(px(320.)), + ) + } else { + this.flex() + .items_center() + .justify_center() + .h_16() + .child(Indicator::new().small()) + } + })), + ) + } +} diff --git a/crates/app/src/views/sidebar/contact_list.rs b/crates/app/src/views/sidebar/contact_list.rs deleted file mode 100644 index e70fa03..0000000 --- a/crates/app/src/views/sidebar/contact_list.rs +++ /dev/null @@ -1,248 +0,0 @@ -use crate::{constants::IMAGE_SERVICE, get_client, utils::show_npub}; -use gpui::{ - div, img, impl_internal_actions, list, px, Context, ElementId, FocusHandle, InteractiveElement, - IntoElement, ListAlignment, ListState, Model, ParentElement, Pixels, Render, RenderOnce, - SharedString, StatefulInteractiveElement, Styled, ViewContext, WindowContext, -}; -use nostr_sdk::prelude::*; -use serde::Deserialize; -use std::collections::{BTreeSet, HashSet}; -use ui::{ - prelude::FluentBuilder, - theme::{scale::ColorScaleStep, ActiveTheme}, - Icon, IconName, Selectable, StyledExt, -}; - -#[derive(Clone, PartialEq, Eq, Deserialize)] -struct SelectContact(PublicKey); - -impl_internal_actions!(contacts, [SelectContact]); - -#[derive(Clone, IntoElement)] -struct ContactListItem { - id: ElementId, - public_key: PublicKey, - metadata: Metadata, - selected: bool, -} - -impl ContactListItem { - pub fn new(public_key: PublicKey, metadata: Metadata) -> Self { - let id = SharedString::from(public_key.to_hex()).into(); - - Self { - id, - public_key, - metadata, - selected: false, - } - } -} - -impl Selectable for ContactListItem { - fn selected(mut self, selected: bool) -> Self { - self.selected = selected; - self - } - - fn element_id(&self) -> &gpui::ElementId { - &self.id - } -} - -impl RenderOnce for ContactListItem { - fn render(self, cx: &mut WindowContext) -> impl IntoElement { - let fallback = show_npub(self.public_key, 16); - - div() - .id(self.id) - .w_full() - .h_8() - .px_1() - .rounded_md() - .flex() - .items_center() - .justify_between() - .child( - div() - .flex() - .items_center() - .gap_2() - .text_sm() - .map(|this| { - if let Some(picture) = self.metadata.picture { - this.flex_shrink_0().child( - img(format!( - "{}/?url={}&w=72&h=72&fit=cover&mask=circle&n=-1", - IMAGE_SERVICE, picture - )) - .size_6(), - ) - } else { - this.flex_shrink_0() - .child(img("brand/avatar.png").size_6().rounded_full()) - } - }) - .map(|this| { - if let Some(display_name) = self.metadata.display_name { - this.flex_1().child(display_name) - } else { - this.flex_1().child(fallback) - } - }), - ) - .when(self.selected, |this| { - this.child( - Icon::new(IconName::CircleCheck) - .size_4() - .text_color(cx.theme().accent.step(cx, ColorScaleStep::NINE)), - ) - }) - .hover(|this| { - this.bg(cx.theme().base.step(cx, ColorScaleStep::FOUR)) - .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) - }) - .on_click(move |_, cx| { - cx.dispatch_action(Box::new(SelectContact(self.public_key))); - }) - } -} - -#[derive(Clone)] -struct Contacts { - #[allow(dead_code)] - count: usize, - items: Vec, -} - -pub struct ContactList { - list: ListState, - contacts: Model>, - selected: HashSet, - focus_handle: FocusHandle, -} - -impl ContactList { - pub fn new(cx: &mut ViewContext<'_, Self>) -> Self { - let list = ListState::new(0, ListAlignment::Top, Pixels(50.), move |_, _| { - div().into_any_element() - }); - - let contacts = cx.new_model(|_| BTreeSet::new()); - let async_contacts = contacts.clone(); - - let mut async_cx = cx.to_async(); - - cx.foreground_executor() - .spawn({ - let client = get_client(); - - async move { - let query: anyhow::Result, anyhow::Error> = async_cx - .background_executor() - .spawn(async move { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - let profiles = client.database().contacts(public_key).await?; - - Ok(profiles) - }) - .await; - - if let Ok(profiles) = query { - _ = async_cx.update_model(&async_contacts, |model, cx| { - *model = profiles; - cx.notify(); - }); - } - } - }) - .detach(); - - cx.observe(&contacts, |this, model, cx| { - let profiles = model.read(cx).clone(); - let contacts = Contacts { - count: profiles.len(), - items: profiles - .into_iter() - .map(|contact| ContactListItem::new(contact.public_key(), contact.metadata())) - .collect(), - }; - - this.list = ListState::new( - contacts.items.len(), - ListAlignment::Top, - Pixels(50.), - move |idx, _cx| { - let item = contacts.items.get(idx).unwrap().clone(); - div().child(item).into_any_element() - }, - ); - - cx.notify(); - }) - .detach(); - - Self { - list, - contacts, - selected: HashSet::new(), - focus_handle: cx.focus_handle(), - } - } - - pub fn selected(&self) -> Vec { - self.selected.clone().into_iter().collect() - } - - fn on_action_select(&mut self, action: &SelectContact, cx: &mut ViewContext) { - self.selected.insert(action.0); - - let profiles = self.contacts.read(cx).clone(); - let contacts = Contacts { - count: profiles.len(), - items: profiles - .into_iter() - .map(|contact| { - let public_key = contact.public_key(); - let metadata = contact.metadata(); - - ContactListItem::new(contact.public_key(), metadata) - .selected(self.selected.contains(&public_key)) - }) - .collect(), - }; - - self.list = ListState::new( - contacts.items.len(), - ListAlignment::Top, - Pixels(50.), - move |idx, _cx| { - let item = contacts.items.get(idx).unwrap().clone(); - div().child(item).into_any_element() - }, - ); - - cx.notify(); - } -} - -impl Render for ContactList { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - div() - .track_focus(&self.focus_handle) - .on_action(cx.listener(Self::on_action_select)) - .flex() - .flex_col() - .gap_1() - .child(div().font_semibold().text_sm().child("Contacts")) - .child( - div() - .p_1() - .bg(cx.theme().base.step(cx, ColorScaleStep::THREE)) - .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) - .rounded_lg() - .child(list(self.list.clone()).h(px(300.))), - ) - } -} diff --git a/crates/app/src/views/sidebar/inbox.rs b/crates/app/src/views/sidebar/inbox.rs index b7f416d..1ea0dd3 100644 --- a/crates/app/src/views/sidebar/inbox.rs +++ b/crates/app/src/views/sidebar/inbox.rs @@ -1,6 +1,6 @@ use crate::{ states::chat::ChatRegistry, - utils::ago, + utils::message_ago, views::app::{AddPanel, PanelKind}, }; use gpui::{ @@ -22,7 +22,7 @@ pub struct Inbox { impl Inbox { pub fn new(_cx: &mut ViewContext<'_, Self>) -> Self { Self { - label: "Inbox".into(), + label: "Direct Messages".into(), is_collapsed: false, } } @@ -54,7 +54,7 @@ impl Inbox { let room = model.read(cx); let id = room.id; let room_id: SharedString = id.to_string().into(); - let ago: SharedString = ago(room.last_seen).into(); + let ago: SharedString = message_ago(room.last_seen).into(); div() .id(room_id) diff --git a/crates/app/src/views/sidebar/mod.rs b/crates/app/src/views/sidebar/mod.rs index ca546a2..d664b7a 100644 --- a/crates/app/src/views/sidebar/mod.rs +++ b/crates/app/src/views/sidebar/mod.rs @@ -1,7 +1,7 @@ use crate::views::sidebar::inbox::Inbox; -use contact_list::ContactList; +use compose::Compose; use gpui::{ - div, AnyElement, AppContext, Entity, EntityId, EventEmitter, FocusHandle, FocusableView, + AnyElement, AppContext, Entity, EntityId, EventEmitter, FocusHandle, FocusableView, IntoElement, ParentElement, Render, SharedString, Styled, View, ViewContext, VisualContext, WindowContext, }; @@ -16,7 +16,7 @@ use ui::{ v_flex, ContextModal, Icon, IconName, Sizable, StyledExt, }; -mod contact_list; +mod compose; mod inbox; pub struct Sidebar { @@ -49,24 +49,27 @@ impl Sidebar { } fn show_compose(&mut self, cx: &mut ViewContext) { - let contact_list = cx.new_view(ContactList::new); + let compose = cx.new_view(Compose::new); - cx.open_modal(move |modal, _cx| { - modal.child(contact_list.clone()).footer( - div().flex().gap_2().child( + cx.open_modal(move |modal, cx| { + let selected = compose.model.read(cx).selected(cx); + let label = if selected.len() > 1 { + "Create Group DM" + } else { + "Create DM" + }; + + modal + .title("Direct Messages") + .child(compose.clone()) + .footer( Button::new("create") - .label("Create DM") + .label(label) .primary() + .bold() .rounded(ButtonRounded::Large) - .w_full() - .on_click({ - let contact_list = contact_list.clone(); - move |_, cx| { - let _selected = contact_list.model.read(cx).selected(); - } - }), - ), - ) + .w_full(), + ) }) } } @@ -116,26 +119,15 @@ impl Render for Sidebar { .py_3() .gap_3() .child( - v_flex() - .px_2() - .gap_0p5() - .child( - Button::new("compose") - .small() - .ghost() - .not_centered() - .icon(Icon::new(IconName::ComposeFill)) - .label("New Message") - .on_click(cx.listener(|this, _, cx| this.show_compose(cx))), - ) - .child( - Button::new("contacts") - .small() - .ghost() - .not_centered() - .icon(Icon::new(IconName::GroupFill)) - .label("Contacts"), - ), + v_flex().px_2().gap_0p5().child( + Button::new("compose") + .small() + .ghost() + .not_centered() + .icon(Icon::new(IconName::ComposeFill)) + .label("New Message") + .on_click(cx.listener(|this, _, cx| this.show_compose(cx))), + ), ) .child(self.inbox.clone()) } diff --git a/crates/app/src/views/welcome.rs b/crates/app/src/views/welcome.rs index 84768c5..5e1131e 100644 --- a/crates/app/src/views/welcome.rs +++ b/crates/app/src/views/welcome.rs @@ -1,5 +1,5 @@ use gpui::{ - div, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView, IntoElement, + div, svg, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView, IntoElement, ParentElement, Render, SharedString, Styled, View, ViewContext, VisualContext, WindowContext, }; use ui::{ @@ -80,9 +80,25 @@ impl Render for WelcomePanel { .flex() .items_center() .justify_center() - .child("coop on nostr.") - .text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)) - .font_black() - .text_sm() + .child( + div() + .flex() + .flex_col() + .items_center() + .gap_1() + .child( + svg() + .path("brand/coop.svg") + .size_12() + .text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)), + ) + .child( + div() + .child("coop on nostr.") + .text_color(cx.theme().base.step(cx, ColorScaleStep::FOUR)) + .font_black() + .text_sm(), + ), + ) } } diff --git a/crates/ui/src/button.rs b/crates/ui/src/button.rs index 132901f..17a3ba1 100644 --- a/crates/ui/src/button.rs +++ b/crates/ui/src/button.rs @@ -353,7 +353,7 @@ impl RenderOnce for Button { Size::Size(size) => this.px(size * 0.2), Size::XSmall => this.h_6().px_0p5(), Size::Small => this.h_8().px_2(), - _ => this.h_10().px_4(), + _ => this.h_9().px_3(), } } }) @@ -487,7 +487,7 @@ impl ButtonVariant { fn text_color(&self, cx: &WindowContext) -> Hsla { match self { - ButtonVariant::Primary => cx.theme().accent.step(cx, ColorScaleStep::ONE), + ButtonVariant::Primary => cx.theme().base.step(cx, ColorScaleStep::TWELVE), ButtonVariant::Link => cx.theme().accent.step(cx, ColorScaleStep::NINE), ButtonVariant::Custom(colors) => colors.foreground, _ => cx.theme().base.step(cx, ColorScaleStep::TWELVE), diff --git a/crates/ui/src/dropdown.rs b/crates/ui/src/dropdown.rs index d909356..a9c54c4 100644 --- a/crates/ui/src/dropdown.rs +++ b/crates/ui/src/dropdown.rs @@ -72,7 +72,7 @@ pub trait DropdownDelegate: Sized { Self::Item: DropdownItem, V: PartialEq, { - (0..self.len()).find(|&i| self.get(i).map_or(false, |item| item.value() == value)) + (0..self.len()).find(|&i| self.get(i).is_some_and(|item| item.value() == value)) } fn can_search(&self) -> bool { @@ -125,9 +125,7 @@ where } fn render_item(&self, ix: usize, cx: &mut gpui::ViewContext>) -> Option { - let selected = self - .selected_index - .map_or(false, |selected_index| selected_index == ix); + let selected = self.selected_index == Some(ix); let size = self .dropdown .upgrade() diff --git a/crates/ui/src/modal.rs b/crates/ui/src/modal.rs index 31ac969..ba45e15 100644 --- a/crates/ui/src/modal.rs +++ b/crates/ui/src/modal.rs @@ -2,7 +2,7 @@ use crate::{ animation::cubic_bezier, button::{Button, ButtonVariants as _}, theme::{scale::ColorScaleStep, ActiveTheme as _}, - v_flex, ContextModal, IconName, Sizable as _, + v_flex, ContextModal, IconName, Sizable as _, StyledExt, }; use gpui::{ actions, anchored, div, hsla, point, prelude::FluentBuilder, px, relative, Animation, @@ -219,7 +219,13 @@ impl RenderOnce for Modal { .w(self.width) .when_some(self.max_width, |this, w| this.max_w(w)) .when_some(self.title, |this, title| { - this.child(div().line_height(relative(1.)).child(title)) + this.child( + div() + .text_sm() + .font_semibold() + .line_height(relative(1.)) + .child(title), + ) }) .when(self.show_close, |this| { this.child(