{
+ let persons = PersonRegistry::global(cx);
+ let profile = persons.read(cx).get(current_user, cx);
+ let avatar = profile.avatar();
+ let avatar_seed = profile.avatar_seed();
+ let name = profile.name();
- let mut row = h_flex()
+ h_flex()
.id("sidebar-user")
.w_full()
.h(TABBAR_HEIGHT)
@@ -166,27 +177,19 @@ impl Sidebar {
.px_2()
.when(cfg!(target_os = "macos"), |this| {
this.pl(px(TRAFFIC_LIGHT_PADDING))
- });
-
- if let Some(public_key) = current_user.as_ref() {
- let persons = PersonRegistry::global(cx);
- let profile = persons.read(cx).get(public_key, cx);
- let avatar = profile.avatar();
- let avatar_seed = profile.avatar_seed();
- let name = profile.name();
-
- row = row.child(
+ })
+ .child(
Button::new("current-user")
.child(
Avatar::new(avatar.clone())
.seed(avatar_seed.clone())
- .xsmall(),
+ .small(),
)
.small()
.caret()
.compact()
.transparent()
- .dropdown_menu(move |this, _window, cx| {
+ .dropdown_menu(move |this, _window, _cx| {
let avatar = avatar.clone();
let avatar_seed = avatar_seed.clone();
let name = name.clone();
@@ -227,244 +230,497 @@ impl Sidebar {
Box::new(Command::ShowBackup),
)
.menu_with_icon("Themes", IconName::Sun, Box::new(Command::ToggleTheme))
- .when(AutoUpdater::is_available(cx), |this| {
- this.separator().menu_with_icon(
- "Check for Updates",
- IconName::Device,
- Box::new(Command::Update),
- )
- })
+ .separator()
.menu_with_icon(
"Settings",
IconName::Settings,
Box::new(Command::ShowSettings),
)
}),
- );
- }
-
- if self.community.is_some() {
- row = row.child(div().flex_1()).child(
- Button::new("sidebar-back")
- .icon(IconName::ArrowLeft)
- .tooltip("Back")
- .ghost()
- .small()
- .on_click(cx.listener(|this, _event, window, cx| {
- this.close_community(window, cx);
- })),
- );
- }
-
- title_bar_drag_handlers(row, window, cx).into_any_element()
- }
-}
-
-fn recent_communities(cx: &App) -> Vec
> {
- const LIMIT: usize = 3;
-
- let registry = CommunityRegistry::global(cx);
- let communities = registry.read(cx).communities();
- let recent = AppSettings::get_recent_communities(cx);
-
- let mut rows: Vec> = recent
- .iter()
- .filter_map(|id| {
- communities
- .iter()
- .find(|community| community.read(cx).id().to_hex() == *id)
- })
- .take(LIMIT)
- .cloned()
- .collect();
-
- if rows.is_empty() {
- rows = communities.iter().take(LIMIT).cloned().collect();
+ )
+ .child(div().flex_1())
+ .when_some(AutoUpdater::try_global(cx), |this, updater| {
+ this.child(self.render_updater(updater, cx))
+ })
+ .when(self.community.is_some(), |this| {
+ this.child(
+ Button::new("sidebar-back")
+ .icon(IconName::ArrowLeft)
+ .tooltip("Back")
+ .ghost()
+ .small()
+ .on_click(cx.listener(|this, _event, _window, cx| {
+ this.reset_community(cx);
+ })),
+ )
+ })
}
- rows
-}
+ fn render_updater(&self, updater: Entity, cx: &mut App) -> AnyElement {
+ let status = updater.read(cx).status();
+ let up_to_date = updater.read(cx).up_to_date();
+ let staged = updater.read(cx).staged();
-fn rows_for(tab: SidebarTab, cx: &App) -> Vec {
- match tab {
- SidebarTab::Recents => {
- let chat = ChatRegistry::global(cx);
- let rooms = chat.read(cx).rooms(&RoomKind::Ongoing, cx);
- let registry = CommunityRegistry::global(cx);
- let community_count = registry.read(cx).communities().len();
- let communities = recent_communities(cx);
+ h_flex()
+ .gap_2()
+ .when(!up_to_date, |this| {
+ this.child(
+ Button::new("update-status")
+ .icon(IconName::ArrowDownCircle)
+ .tooltip(status)
+ .small()
+ .warning()
+ .disabled(true),
+ )
+ })
+ .when(staged, |this| {
+ this.child(
+ Button::new("restart-to-update")
+ .icon(IconName::ArrowDownCircle)
+ .tooltip("Quit and relaunch into the installed update")
+ .small()
+ .ghost()
+ .on_click(move |_, _window, cx| {
+ updater.update(cx, |this, cx| {
+ this.restart(cx);
+ });
+ }),
+ )
+ })
+ .into_any_element()
+ }
- if communities.is_empty() && rooms.is_empty() {
- return vec![SidebarRow::Hint {
- text: "Nothing recent yet".into(),
- }];
+ fn render_tabs(&mut self, cx: &mut Context) -> AnyElement {
+ let sidebar = cx.entity().downgrade();
+ let active_tab = self.active_tab;
+ let rows = Rc::new(self.rows_for(active_tab, cx));
+ let scroll_handle = &self.scroll_handles[active_tab.index()];
+
+ v_flex()
+ .size_full()
+ .flex_1()
+ .min_h_0()
+ .gap_2()
+ .child(
+ div().px_2().child(
+ TabBar::new("sidebar-tabs")
+ .segmented(true)
+ .selected_index(active_tab.index())
+ .child(Tab::new().label(SidebarTab::Inbox.label()))
+ .child(Tab::new().label(SidebarTab::Communities.label()))
+ .on_click({
+ let sidebar = sidebar.clone();
+ move |index, _window, cx| {
+ let Some(tab) = SidebarTab::ALL.get(*index).copied() else {
+ return;
+ };
+ if let Err(error) =
+ sidebar.update(cx, |this, cx| this.select_tab(tab, cx))
+ {
+ log::error!("Failed to switch sidebar tab: {error}");
+ }
+ }
+ }),
+ ),
+ )
+ .map(|this| match active_tab {
+ SidebarTab::Inbox => this.child(
+ v_flex()
+ .px_2()
+ .gap_1()
+ .child(
+ NavItem::new(
+ "new-chat",
+ "New Chat",
+ Icon::new(IconName::Message).small(),
+ )
+ .on_click(|_event, window, cx| {
+ window.dispatch_action(Box::new(Command::NewChat), cx)
+ }),
+ )
+ .child(
+ NavItem::new("reqs", "Requests", Icon::new(IconName::Invite).small())
+ .when(self.new_requests, |this| {
+ this.suffix(div().size_1().rounded_full().bg(cx.theme().cursor))
+ })
+ .on_click({
+ let sidebar = sidebar.clone();
+ move |_event, window, cx| {
+ if let Err(error) = sidebar.update(cx, |this, cx| {
+ this.new_requests = false;
+ cx.notify();
+ }) {
+ log::error!("Failed to clear new requests: {error}");
+ }
+ window.dispatch_action(Box::new(Command::ShowRequests), cx);
+ }
+ }),
+ )
+ .child(
+ NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small())
+ .on_click(|_event, window, cx| {
+ window.dispatch_action(Box::new(Command::ShowContactList), cx)
+ }),
+ ),
+ ),
+ SidebarTab::Communities => this.child(
+ v_flex()
+ .px_2()
+ .gap_1()
+ .child(
+ NavItem::new(
+ "new-community",
+ "New Community",
+ Icon::new(IconName::Group).small(),
+ )
+ .on_click(|_, window, cx| {
+ window.dispatch_action(Box::new(Command::NewCommunity), cx)
+ }),
+ )
+ .child(
+ NavItem::new("browse", "Browse", Icon::new(IconName::Compass).small())
+ .on_click(|_, window, cx| {
+ window.dispatch_action(Box::new(Command::ShowBrowse), cx)
+ }),
+ ),
+ ),
+ })
+ .child(
+ div()
+ .px_4()
+ .text_xs()
+ .font_semibold()
+ .text_color(cx.theme().text_placeholder)
+ .child(active_tab.list_title()),
+ )
+ .child(
+ div()
+ .min_h_0()
+ .flex_1()
+ .child(
+ uniform_list(
+ active_tab.list_id(),
+ rows.len(),
+ cx.processor(move |this, range, _window, cx| {
+ this.render_rows(range, rows.as_slice(), cx)
+ }),
+ )
+ .track_scroll(scroll_handle)
+ .h_full()
+ .px_2(),
+ )
+ .child(Scrollbar::vertical(scroll_handle)),
+ )
+ .into_any_element()
+ }
+
+ fn render_community(
+ &mut self,
+ community: Entity,
+ cx: &mut Context,
+ ) -> AnyElement {
+ let (banner, rows) = {
+ let community = community.read(cx);
+ let owner = community.state().owner;
+
+ let mut admins = Vec::new();
+ let mut members = Vec::new();
+
+ for public_key in community.members() {
+ if community.control().roles.is_staff(public_key, &owner) {
+ admins.push(*public_key);
+ } else {
+ members.push(*public_key);
+ }
}
- let mut rows = Vec::new();
+ let active = community.active_channel();
+ let banner = community.banner();
- if !communities.is_empty() {
- rows.push(SidebarRow::Section {
- label: "Communities".into(),
- count: community_count,
- });
+ let mut rows = vec![CommunityRow::Section(CommunitySection::Channels)];
+ if self.channels_open {
rows.extend(
- communities
- .into_iter()
- .map(|community| SidebarRow::Community { community }),
- );
- rows.push(SidebarRow::Action {
- label: "Show all communities".into(),
- tab: SidebarTab::Communities,
- });
- }
-
- if !rooms.is_empty() {
- rows.push(SidebarRow::Section {
- label: "Chats".into(),
- count: rooms.len(),
- });
- rows.extend(
- rooms
- .into_iter()
- .take(5)
- .map(|room| SidebarRow::Room { room }),
- );
- rows.push(SidebarRow::Action {
- label: "Show all chats".into(),
- tab: SidebarTab::Chats,
- });
- }
-
- rows
- }
- SidebarTab::Chats => {
- let chat = ChatRegistry::global(cx);
- let chat = chat.read(cx);
- let messages = chat.rooms(&RoomKind::Ongoing, cx);
-
- let mut rows = vec![SidebarRow::Section {
- label: "Chats".into(),
- count: messages.len(),
- }];
-
- if messages.is_empty() {
- rows.push(SidebarRow::Hint {
- text: "No conversations yet".into(),
- });
- } else {
- rows.extend(messages.into_iter().map(|room| SidebarRow::Room { room }));
- }
-
- rows
- }
- SidebarTab::Communities => {
- let registry = CommunityRegistry::global(cx);
- let communities = registry.read(cx).communities();
-
- let mut rows = vec![SidebarRow::Section {
- label: "Communities".into(),
- count: communities.len(),
- }];
-
- if communities.is_empty() {
- rows.push(SidebarRow::Hint {
- text: "No communities yet".into(),
- });
- } else {
- rows.extend(
- communities
+ community
+ .channels()
.iter()
- .cloned()
- .map(|community| SidebarRow::Community { community }),
+ .map(|channel| CommunityRow::Channel {
+ id: channel.id,
+ name: channel.name.clone().into(),
+ private: channel.private,
+ selected: active == Some(channel.id),
+ }),
);
}
- rows
+ rows.push(CommunityRow::Section(CommunitySection::Admins));
+ if self.admins_open {
+ rows.extend(
+ admins
+ .into_iter()
+ .map(|public_key| CommunityRow::Member { public_key }),
+ );
+ }
+
+ rows.push(CommunityRow::Section(CommunitySection::Members));
+ if self.members_open {
+ rows.extend(
+ members
+ .into_iter()
+ .map(|public_key| CommunityRow::Member { public_key }),
+ );
+ }
+
+ (banner, rows)
+ };
+
+ let rows = Rc::new(rows);
+
+ v_flex()
+ .flex_1()
+ .min_h_0()
+ .w_full()
+ .gap_2()
+ .when_some(banner, |this, banner| {
+ this.child(
+ div().px_2().flex_shrink_0().child(
+ img(banner)
+ .w_full()
+ .h_20()
+ .rounded(cx.theme().radius_lg)
+ .object_fit(ObjectFit::Cover),
+ ),
+ )
+ })
+ .child(
+ div()
+ .min_h_0()
+ .flex_1()
+ .child(
+ uniform_list(
+ "community-rows",
+ rows.len(),
+ cx.processor(move |this, range, _window, cx| {
+ this.render_community_rows(range, rows.as_slice(), &community, cx)
+ }),
+ )
+ .track_scroll(&self.community_scroll)
+ .h_full()
+ .px_2(),
+ )
+ .child(Scrollbar::vertical(&self.community_scroll)),
+ )
+ .into_any_element()
+ }
+
+ fn render_community_rows(
+ &self,
+ range: Range,
+ rows: &[CommunityRow],
+ community: &Entity,
+ cx: &mut Context,
+ ) -> Vec {
+ rows.get(range)
+ .into_iter()
+ .flatten()
+ .map(|row| match row {
+ CommunityRow::Section(section) => self.section_row(section, cx),
+ CommunityRow::Member { public_key } => self.member_row(public_key, cx),
+ CommunityRow::Channel {
+ id,
+ name,
+ private,
+ selected,
+ } => self.channel_row(*id, name.clone(), *private, *selected, community, cx),
+ })
+ .collect()
+ }
+
+ fn rows_for(&self, tab: SidebarTab, cx: &App) -> Vec {
+ match tab {
+ SidebarTab::Inbox => {
+ let chat = ChatRegistry::global(cx);
+ chat.read(cx)
+ .rooms(&RoomKind::Ongoing, cx)
+ .into_iter()
+ .map(|room| SidebarRow::Room { room })
+ .collect()
+ }
+ SidebarTab::Communities => {
+ let registry = CommunityRegistry::global(cx);
+ registry
+ .read(cx)
+ .communities()
+ .iter()
+ .cloned()
+ .map(|community| SidebarRow::Community { community })
+ .collect()
+ }
}
}
-}
-fn render_rows(range: Range, rows: &[SidebarRow], cx: &Context) -> Vec {
- rows.get(range.clone())
- .into_iter()
- .flatten()
- .enumerate()
- .map(|(offset, row)| {
- let index = range.start + offset;
+ fn render_rows(
+ &self,
+ range: Range,
+ rows: &[SidebarRow],
+ cx: &Context,
+ ) -> Vec {
+ rows.get(range.clone())
+ .into_iter()
+ .flatten()
+ .enumerate()
+ .map(|(offset, row)| {
+ let index = range.start + offset;
- match row {
- SidebarRow::Section { label, count } => TreeRow::new(
- ElementId::NamedInteger("tree-row".into(), index as u64),
- TreeRowKind::Section,
- label.clone(),
- )
- .count(*count)
- .into_any_element(),
- SidebarRow::Room { room } => {
- let name = room.read(cx).display_name(cx);
- let picture = room.read(cx).display_image(cx);
- let seed = room.read(cx).display_image_seed(cx);
- let created_at = room.read(cx).created_at.to_ago();
- let room_clone = room.clone();
+ match row {
+ SidebarRow::Room { room } => {
+ let name = room.read(cx).display_name(cx);
+ let picture = room.read(cx).display_image(cx);
+ let seed = room.read(cx).display_image_seed(cx);
+ let created_at = room.read(cx).created_at.to_ago();
+ let dock = self.dock.clone();
+ let room = room.clone();
- let handler = cx.listener(move |_this, _event, window, cx| {
- ChatRegistry::global(cx).update(cx, |chat, cx| {
- chat.emit_room(&room_clone, window, cx);
- });
- });
+ Nav::new(SharedString::from(format!("room-{index}")))
+ .label(name)
+ .text_sm()
+ .font_medium()
+ .when_some(nav_avatar(Some(seed), picture, cx), |this, avatar| {
+ this.prefix(avatar)
+ })
+ .suffix(
+ div()
+ .font_normal()
+ .text_xs()
+ .text_color(cx.theme().text_placeholder)
+ .child(created_at),
+ )
+ .on_click(move |_event, window, cx| {
+ ui::dock::add_panel_to(
+ &dock,
+ PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
+ DockPlacement::Center,
+ window,
+ cx,
+ );
+ })
+ .into_any_element()
+ }
+ SidebarRow::Community { community } => {
+ let name = community.read(cx).name();
+ let seed = community.read(cx).id().to_hex();
+ let picture = community.read(cx).icon();
+ let dock = self.dock.clone();
+ let sidebar = cx.entity().downgrade();
+ let community = community.clone();
- TreeRow::new(
- ElementId::NamedInteger("tree-row".into(), index as u64),
- TreeRowKind::Room,
- name,
- )
- .avatar(seed)
- .picture(picture)
- .created_at(created_at)
- .on_click(handler)
- .into_any_element()
+ Nav::new(SharedString::from(format!("com-{index}")))
+ .label(name)
+ .text_sm()
+ .when_some(nav_avatar(Some(seed), picture, cx), |this, avatar| {
+ this.prefix(avatar)
+ })
+ .on_click(move |_event, window, cx| {
+ ui::dock::add_panel_to(
+ &dock,
+ PanelHandle::new(community_ui::init(
+ community.clone(),
+ window,
+ cx,
+ )),
+ DockPlacement::Center,
+ window,
+ cx,
+ );
+
+ if let Err(error) = sidebar.update(cx, |this, cx| {
+ this.community = Some(community.downgrade());
+ cx.notify();
+ }) {
+ log::error!("Failed to show community in sidebar: {error}");
+ }
+ })
+ .into_any_element()
+ }
}
- SidebarRow::Community { community } => {
- let name = community.read(cx).name();
- let seed = community.read(cx).id().to_hex();
- let picture = community.read(cx).icon();
- let community = community.clone();
+ })
+ .collect()
+ }
- TreeRow::new(
- ElementId::NamedInteger("tree-row".into(), index as u64),
- TreeRowKind::Community,
- name,
- )
- .avatar(seed)
- .picture(picture)
- .on_click(cx.listener(move |this, _event, window, cx| {
- this.open_community(community.clone(), window, cx);
- }))
- .into_any_element()
- }
- SidebarRow::Action { label, tab } => {
- let tab = *tab;
+ fn section_row(&self, section: &CommunitySection, cx: &mut Context) -> AnyElement {
+ let section = *section;
+ let (label, open) = match section {
+ CommunitySection::Channels => ("Channels", self.channels_open),
+ CommunitySection::Admins => ("Admins", self.admins_open),
+ CommunitySection::Members => ("Members", self.members_open),
+ };
+ let icon = if open {
+ IconName::CaretDown
+ } else {
+ IconName::CaretRight
+ };
- TreeRow::new(
- ElementId::NamedInteger("tree-row".into(), index as u64),
- TreeRowKind::Action,
- label.clone(),
- )
- .icon(IconName::ArrowRight)
- .on_click(cx.listener(move |this, _event, _window, cx| {
- this.select_tab(tab, cx);
- }))
- .into_any_element()
+ Nav::new(label)
+ .label(label)
+ .suffix(nav_icon(icon, cx))
+ .text_xs()
+ .font_semibold()
+ .text_color(cx.theme().text_placeholder)
+ .on_click(cx.listener(move |this, _ev, _window, cx| {
+ match section {
+ CommunitySection::Channels => this.channels_open = !this.channels_open,
+ CommunitySection::Admins => this.admins_open = !this.admins_open,
+ CommunitySection::Members => this.members_open = !this.members_open,
}
- SidebarRow::Hint { text } => TreeRow::new(
- ElementId::NamedInteger("tree-row".into(), index as u64),
- TreeRowKind::Hint,
- text.clone(),
- )
- .into_any_element(),
- }
- })
- .collect()
+ cx.notify();
+ }))
+ .into_any_element()
+ }
+
+ fn channel_row(
+ &self,
+ id: ChannelId,
+ name: SharedString,
+ private: bool,
+ selected: bool,
+ community: &Entity,
+ cx: &mut Context,
+ ) -> AnyElement {
+ let community = community.clone();
+ let icon = if private {
+ IconName::Lock
+ } else {
+ IconName::Hashtag
+ };
+
+ Nav::new(id.to_hex())
+ .label(name)
+ .prefix(nav_icon(icon, cx))
+ .text_sm()
+ .font_medium()
+ .selected(selected)
+ .on_click(cx.listener(move |_this, _event, _window, cx| {
+ community.update(cx, |community, cx| {
+ community.set_active_channel(id, cx);
+ });
+ cx.notify();
+ }))
+ .into_any_element()
+ }
+
+ fn member_row(&self, public_key: &PublicKey, cx: &App) -> AnyElement {
+ let persons = PersonRegistry::global(cx);
+ let person = persons.read(cx).get(public_key, cx);
+
+ Nav::new(public_key.to_hex())
+ .label(person.name())
+ .text_sm()
+ .font_medium()
+ .when_some(
+ nav_avatar(Some(person.avatar_seed()), person.avatar(), cx),
+ |this, avatar| this.prefix(avatar),
+ )
+ .into_any_element()
+ }
}
impl Panel for Sidebar {
@@ -495,35 +751,18 @@ impl Focusable for Sidebar {
impl Render for Sidebar {
fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
- let (logged_in, ready) = {
- let nostr = NostrRegistry::global(cx);
- (
- nostr.read(cx).current_user().is_some(),
- nostr.read(cx).ready(),
- )
- };
+ let nostr = NostrRegistry::global(cx);
+ let current_user = nostr.read(cx).current_user();
+ let logged_in = current_user.is_some();
- if !logged_in {
- if !ready {
- return v_flex()
- .size_full()
- .bg(cx.theme().surface_background)
- .border_r_1()
- .border_color(cx.theme().border_variant)
- .into_any_element();
- }
- return onboarding::render(window, cx).into_any_element();
- }
+ let chat = ChatRegistry::global(cx);
+ let loading = chat.read(cx).loading();
let community = self
.community
.clone()
.and_then(|community| community.upgrade());
- if community.is_none() {
- self.community = None;
- }
-
v_flex()
.image_cache(retain_all("sidebar"))
.size_full()
@@ -532,141 +771,67 @@ impl Render for Sidebar {
.bg(cx.theme().surface_background)
.border_r_1()
.border_color(cx.theme().border_variant)
- .child(self.render_user(window, cx))
+ .when_some(current_user.as_ref(), |this, current_user| {
+ this.child(title_bar_drag_handlers(
+ self.render_user(current_user, cx),
+ window,
+ cx,
+ ))
+ })
+ .when(!logged_in, |this| {
+ this.relative()
+ .child(title_bar_drag_handlers(
+ div()
+ .id("onboarding-drag")
+ .absolute()
+ .top_0()
+ .left_0()
+ .h(TABBAR_HEIGHT)
+ .w_full(),
+ window,
+ cx,
+ ))
+ .child(
+ div().absolute().inset_0().child(
+ img(self.banner.clone())
+ .size_full()
+ .object_fit(ObjectFit::Cover),
+ ),
+ )
+ .child(
+ v_flex()
+ .size_full()
+ .justify_end()
+ .gap_4()
+ .p_4()
+ .child(img("brand/headline.png").max_w_48())
+ .child(
+ Button::new("import")
+ .label("Import Identity")
+ .custom(
+ ButtonCustomVariant::new(window, cx)
+ .color(gpui::white())
+ .foreground(gpui::black())
+ .hover(gpui::white().opacity(0.9))
+ .active(gpui::white().opacity(0.8)),
+ )
+ .large()
+ .font_semibold()
+ .on_click(|_, window, cx| {
+ import::open(window, cx);
+ }),
+ ),
+ )
+ })
.map(|this| match community {
Some(community) => this.child(self.render_community(community, cx)),
None => this.child(self.render_tabs(cx)),
})
- .into_any_element()
- }
-}
-
-impl Sidebar {
- fn render_tabs(&mut self, cx: &mut Context) -> AnyElement {
- let chat = ChatRegistry::global(cx);
- let loading = chat.read(cx).loading();
-
- let sidebar = cx.entity().downgrade();
- let active_tab = self.active_tab;
- let rows = Rc::new(rows_for(active_tab, cx));
- let scroll_handle = &self.scroll_handles[active_tab.index()];
-
- v_flex()
- .size_full()
- .flex_1()
- .min_h_0()
- .gap_1()
- .when(active_tab.chat(), |this| {
- this.child(
- v_flex()
- .px_2()
- .gap_1()
- .child(
- NavItem::new(
- "nav-contacts",
- "Contacts",
- Icon::new(IconName::Book).small(),
- )
- .on_click(|_event, window, cx| {
- window.dispatch_action(Box::new(Command::ShowContactList), cx)
- }),
- )
- .child(
- NavItem::new(
- "nav-requests",
- "Requests",
- Icon::new(IconName::Invite).small(),
- )
- .when(self.new_requests, |this| {
- this.suffix(div().size_1().rounded_full().bg(cx.theme().cursor))
- })
- .on_click({
- let sidebar = sidebar.clone();
- move |_event, window, cx| {
- if let Err(error) = sidebar.update(cx, |this, cx| {
- this.new_requests = false;
- cx.notify();
- }) {
- log::error!("Failed to clear new requests: {error}");
- }
- window.dispatch_action(Box::new(Command::ShowRequests), cx);
- }
- }),
- )
- .child(
- NavItem::new(
- "nav-new-chat",
- "New chat",
- Icon::new(IconName::Plus).small(),
- )
- .on_click(|_event, window, cx| {
- window.dispatch_action(Box::new(Command::NewChat), cx)
- }),
- ),
- )
- })
- .when(active_tab.community(), |this| {
- this.child(
- v_flex()
- .px_2()
- .gap_1()
- .child(
- NavItem::new(
- "nav-browse",
- "Browse",
- Icon::new(IconName::Compass).small(),
- )
- .on_click(|_event, window, cx| {
- window.dispatch_action(Box::new(Command::ShowBrowse), cx)
- }),
- )
- .child(
- NavItem::new(
- "nav-new-community",
- "New community",
- Icon::new(IconName::Plus).small(),
- )
- .on_click(|_event, window, cx| {
- window.dispatch_action(Box::new(Command::NewCommunity), cx)
- }),
- ),
- )
- })
- .child(
- v_flex()
- .size_full()
- .flex_1()
- .min_h_0()
- .gap_1()
- .pb_12()
- .child(
- uniform_list(
- active_tab.list_id(),
- rows.len(),
- cx.processor(move |_this, range, _window, cx| {
- render_rows(range, rows.as_slice(), cx)
- }),
- )
- .track_scroll(scroll_handle)
- .flex_1()
- .h_full()
- .px_2(),
- )
- .child(Scrollbar::vertical(scroll_handle)),
- )
- .child(TabBar::new(active_tab).on_select({
- let sidebar = sidebar.clone();
- move |tab, _window, cx| {
- if let Err(error) = sidebar.update(cx, |this, cx| this.select_tab(tab, cx)) {
- log::error!("Failed to switch sidebar tab: {error}");
- }
- }
- }))
- .when(loading, |this| {
+ .when(loading && logged_in, |this| {
this.child(
div()
.absolute()
- .bottom_16()
+ .bottom_4()
.left_0()
.h_9()
.w_full()
@@ -690,193 +855,4 @@ impl Sidebar {
})
.into_any_element()
}
-
- fn render_community(
- &mut self,
- community: Entity,
- cx: &mut Context,
- ) -> AnyElement {
- let (channels, active, admins, members, banner) = {
- let community = community.read(cx);
- let owner = community.state().owner;
- let mut admins = Vec::new();
- let mut members = Vec::new();
-
- for public_key in community.members() {
- if community.control().roles.is_staff(public_key, &owner) {
- admins.push(*public_key);
- } else {
- members.push(*public_key);
- }
- }
-
- (
- community
- .channels()
- .iter()
- .map(|channel| (channel.id, channel.name.clone(), channel.private))
- .collect::>(),
- community.active_channel(),
- admins,
- members,
- community.banner(),
- )
- };
-
- let sections = v_flex()
- .id("community-sections")
- .flex_1()
- .min_h_0()
- .w_full()
- .px_2()
- .pb_2()
- .track_scroll(&self.community_scroll)
- .overflow_y_scroll()
- .child(section_row(
- "channels",
- "Channels",
- channels.len(),
- self.channels_open,
- |sidebar| sidebar.channels_open = !sidebar.channels_open,
- cx,
- ))
- .when(self.channels_open, |this| {
- this.children(channels.into_iter().map(|(id, name, private)| {
- channel_row(id, name, private, active == Some(id), &community, cx)
- }))
- })
- .child(section_row(
- "admins",
- "Admins",
- admins.len(),
- self.admins_open,
- |sidebar| sidebar.admins_open = !sidebar.admins_open,
- cx,
- ))
- .when(self.admins_open, |this| {
- this.children(
- admins
- .iter()
- .map(|public_key| member_row("community-admin", *public_key, cx)),
- )
- })
- .child(section_row(
- "members",
- "Members",
- members.len(),
- self.members_open,
- |sidebar| sidebar.members_open = !sidebar.members_open,
- cx,
- ))
- .when(self.members_open, |this| {
- this.children(
- members
- .iter()
- .map(|public_key| member_row("member", *public_key, cx)),
- )
- });
-
- v_flex()
- .flex_1()
- .min_h_0()
- .w_full()
- .gap_2()
- .when_some(banner, |this, banner| {
- this.child(
- div().px_2().child(
- img(banner)
- .w_full()
- .h(px(80.))
- .rounded(cx.theme().radius)
- .object_fit(ObjectFit::Cover),
- ),
- )
- })
- .child(sections)
- .child(Scrollbar::vertical(&self.community_scroll))
- .into_any_element()
- }
-}
-
-fn section_row(
- id: &'static str,
- label: &'static str,
- count: usize,
- open: bool,
- toggle: impl Fn(&mut Sidebar) + 'static,
- cx: &mut Context,
-) -> AnyElement {
- div()
- .flex_shrink_0()
- .child(
- TreeRow::new(ElementId::Name(id.into()), TreeRowKind::Section, label)
- .icon(if open {
- IconName::CaretDown
- } else {
- IconName::CaretRight
- })
- .count(count)
- .on_click(cx.listener(move |this, _event, _window, cx| {
- toggle(this);
- cx.notify();
- })),
- )
- .into_any_element()
-}
-
-fn channel_row(
- id: ChannelId,
- name: String,
- private: bool,
- selected: bool,
- community: &Entity,
- cx: &mut Context,
-) -> AnyElement {
- let community = community.clone();
-
- div()
- .flex_shrink_0()
- .rounded(cx.theme().radius)
- .child(
- TreeRow::new(
- ElementId::Name(SharedString::from(format!(
- "community-channel-{}",
- id.to_hex()
- ))),
- TreeRowKind::Room,
- name,
- )
- .icon(if private {
- IconName::Lock
- } else {
- IconName::Message
- })
- .on_click(cx.listener(move |_this, _event, _window, cx| {
- community.update(cx, |community, cx| community.set_active_channel(id, cx));
- cx.notify();
- })),
- )
- .when(selected, |this| this.bg(cx.theme().ghost_element_active))
- .into_any_element()
-}
-
-fn member_row(prefix: &str, public_key: PublicKey, cx: &App) -> AnyElement {
- let persons = PersonRegistry::global(cx);
- let person = persons.read(cx).get(&public_key, cx);
-
- div()
- .flex_shrink_0()
- .child(
- TreeRow::new(
- ElementId::Name(SharedString::from(format!(
- "{prefix}-{}",
- public_key.to_hex()
- ))),
- TreeRowKind::Room,
- person.name(),
- )
- .avatar(person.avatar_seed())
- .picture(person.avatar()),
- )
- .into_any_element()
}
diff --git a/crates/workspace/src/sidebar/onboarding.rs b/crates/workspace/src/sidebar/onboarding.rs
deleted file mode 100644
index abf2d9d4..00000000
--- a/crates/workspace/src/sidebar/onboarding.rs
+++ /dev/null
@@ -1,74 +0,0 @@
-use gpui::{App, InteractiveElement, IntoElement, ParentElement, Styled, Window, div, svg};
-use theme::{ActiveTheme, TABBAR_HEIGHT};
-use ui::button::{Button, ButtonVariants};
-use ui::{StyledExt, h_flex, title_bar_drag_handlers, v_flex};
-
-use crate::dialogs::import;
-
-const TITLE: &str = "Welcome to Coop!";
-const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
-
-pub(super) fn render(window: &mut Window, cx: &mut App) -> impl IntoElement {
- v_flex()
- .size_full()
- .relative()
- .bg(cx.theme().surface_background)
- .child(title_bar_drag_handlers(
- div()
- .id("onboarding-drag")
- .absolute()
- .top_0()
- .left_0()
- .h(TABBAR_HEIGHT)
- .w_full(),
- window,
- cx,
- ))
- .child(
- v_flex()
- .size_full()
- .justify_end()
- .gap_4()
- .p_4()
- .child(
- h_flex()
- .gap_2()
- .child(
- svg()
- .path("brand/coop.svg")
- .size_8()
- .text_color(cx.theme().icon_muted),
- )
- .child(
- v_flex().child(div().font_semibold().child(TITLE)).child(
- div()
- .text_xs()
- .text_color(cx.theme().text_muted)
- .child(DESCRIPTION),
- ),
- ),
- )
- .child(
- v_flex()
- .gap_2()
- .w_full()
- .child(
- Button::new("join-now")
- .label("Join now")
- .primary()
- .font_semibold()
- .h_8()
- .w_full(),
- )
- .child(
- Button::new("import-identity")
- .label("Import identity")
- .secondary()
- .font_semibold()
- .h_8()
- .w_full()
- .on_click(|_event, window, cx| import::open(window, cx)),
- ),
- ),
- )
-}
diff --git a/crates/workspace/src/sidebar/tab.rs b/crates/workspace/src/sidebar/tab.rs
index eda0d77b..5698be6a 100644
--- a/crates/workspace/src/sidebar/tab.rs
+++ b/crates/workspace/src/sidebar/tab.rs
@@ -1,121 +1,38 @@
-use std::rc::Rc;
-
-use gpui::prelude::FluentBuilder;
-use gpui::{App, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Window, div};
-use theme::ActiveTheme;
-use ui::button::{Button, ButtonVariants};
-use ui::{IconName, Selectable, h_flex};
-
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SidebarTab {
- Recents,
- Chats,
+ Inbox,
Communities,
}
impl SidebarTab {
- pub const ALL: [SidebarTab; 3] = [Self::Recents, Self::Chats, Self::Communities];
+ pub const ALL: [SidebarTab; 2] = [Self::Inbox, Self::Communities];
pub fn label(self) -> &'static str {
match self {
- Self::Recents => "Recents",
- Self::Chats => "Chats",
+ Self::Inbox => "Inbox",
Self::Communities => "Communities",
}
}
- pub fn icon(self) -> IconName {
+ /// Heading for the tab's list of items.
+ pub fn list_title(self) -> &'static str {
match self {
- Self::Recents => IconName::History,
- Self::Chats => IconName::Message,
- Self::Communities => IconName::Group,
+ Self::Inbox => "Direct Messages",
+ Self::Communities => "Communities",
}
}
pub fn list_id(self) -> &'static str {
match self {
- Self::Recents => "sidebar-recents",
- Self::Chats => "sidebar-chats",
+ Self::Inbox => "sidebar-inbox",
Self::Communities => "sidebar-communities",
}
}
pub fn index(self) -> usize {
match self {
- Self::Recents => 0,
- Self::Chats => 1,
- Self::Communities => 2,
+ Self::Inbox => 0,
+ Self::Communities => 1,
}
}
-
- pub fn chat(self) -> bool {
- matches!(self, Self::Chats)
- }
-
- pub fn community(self) -> bool {
- matches!(self, Self::Communities)
- }
-}
-
-#[derive(IntoElement)]
-#[allow(clippy::type_complexity)]
-pub struct TabBar {
- active: SidebarTab,
- on_select: Option>,
-}
-
-impl TabBar {
- pub fn new(active: SidebarTab) -> Self {
- Self {
- active,
- on_select: None,
- }
- }
-
- pub fn on_select(
- mut self,
- handler: impl Fn(SidebarTab, &mut Window, &mut App) + 'static,
- ) -> Self {
- self.on_select = Some(Rc::new(handler));
- self
- }
-}
-
-impl RenderOnce for TabBar {
- fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
- let Self { active, on_select } = self;
-
- div()
- .id("sidebar-tabs")
- .absolute()
- .bottom_3()
- .left_0()
- .w_full()
- .px_4()
- .child(
- h_flex()
- .w_full()
- .p_1()
- .gap_1()
- .rounded_full()
- .bg(cx.theme().background)
- .when(cx.theme().shadow, |this| this.shadow_md())
- .children(SidebarTab::ALL.into_iter().map(|tab| {
- let on_select = on_select.clone();
-
- Button::new(format!("tab-{}", tab.list_id()))
- .icon(tab.icon())
- .ghost()
- .flex_1()
- .rounded()
- .selected(tab == active)
- .tooltip(tab.label())
- .on_click(move |_event, window, cx| {
- if let Some(on_select) = on_select.as_ref() {
- on_select(tab, window, cx);
- }
- })
- })),
- )
- }
}
diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs
deleted file mode 100644
index 20323851..00000000
--- a/crates/workspace/src/sidebar/tree.rs
+++ /dev/null
@@ -1,235 +0,0 @@
-use std::rc::Rc;
-
-use chat::Room;
-use community::Community;
-use gpui::prelude::FluentBuilder;
-use gpui::{
- App, ClickEvent, ElementId, Entity, ImageSource, InteractiveElement, IntoElement,
- ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px,
-};
-use settings::AppSettings;
-use theme::ActiveTheme;
-use ui::avatar::{Avatar, PixelAvatar};
-use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex};
-
-use super::tab::SidebarTab;
-
-pub enum SidebarRow {
- Section {
- label: SharedString,
- count: usize,
- },
- Room {
- room: Entity,
- },
- Community {
- community: Entity,
- },
- Action {
- label: SharedString,
- tab: SidebarTab,
- },
- Hint {
- text: SharedString,
- },
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum TreeRowKind {
- Section,
- Room,
- Community,
- Action,
- Hint,
-}
-
-#[derive(IntoElement)]
-pub struct TreeRow {
- id: ElementId,
- kind: TreeRowKind,
- label: SharedString,
- avatar: Option,
- picture: Option,
- icon: Option,
- count: Option,
- created_at: Option,
- selected: bool,
- #[allow(clippy::type_complexity)]
- on_click: Option>,
-}
-
-impl TreeRow {
- pub fn new(
- id: impl Into,
- kind: TreeRowKind,
- label: impl Into,
- ) -> Self {
- Self {
- id: id.into(),
- kind,
- label: label.into(),
- avatar: None,
- picture: None,
- icon: None,
- count: None,
- created_at: None,
- selected: false,
- on_click: None,
- }
- }
-
- /// Sets the seed for the row's generated avatar.
- pub fn avatar(mut self, seed: impl Into) -> Self {
- self.avatar = Some(seed.into());
- self
- }
-
- /// Shows `picture` instead of the generated avatar.
- pub fn picture(mut self, picture: Option>) -> Self {
- self.picture = picture.map(Into::into);
- self
- }
-
- /// Shows `icon` in the avatar slot when the row has no avatar or picture.
- pub fn icon(mut self, icon: IconName) -> Self {
- self.icon = Some(icon);
- self
- }
-
- pub fn count(mut self, count: usize) -> Self {
- self.count = Some(count);
- self
- }
-
- pub fn created_at(mut self, created_at: impl Into) -> Self {
- self.created_at = Some(created_at.into());
- self
- }
-
- pub fn on_click(
- mut self,
- handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- ) -> Self {
- self.on_click = Some(Rc::new(handler));
- self
- }
-}
-
-impl Selectable for TreeRow {
- fn selected(mut self, selected: bool) -> Self {
- self.selected = selected;
- self
- }
-
- fn is_selected(&self) -> bool {
- self.selected
- }
-}
-
-impl RenderOnce for TreeRow {
- fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
- let hide_avatar = AppSettings::get_hide_avatar(cx);
-
- let is_section = self.kind == TreeRowKind::Section;
- let is_room = self.kind == TreeRowKind::Room;
- let is_community = self.kind == TreeRowKind::Community;
- let is_action = self.kind == TreeRowKind::Action;
- let is_hint = self.kind == TreeRowKind::Hint;
- let is_selected = self.selected;
-
- let avatar = if hide_avatar {
- None
- } else {
- match (self.avatar, self.picture) {
- (None, None) => None,
- (seed, Some(picture)) => Some(
- Avatar::from_source(picture)
- .when_some(seed, |avatar, seed| avatar.seed(seed))
- .xsmall()
- .flex_shrink_0()
- .into_any_element(),
- ),
- (Some(seed), None) => Some(
- PixelAvatar::new(seed)
- .xsmall()
- .flex_shrink_0()
- .into_any_element(),
- ),
- }
- };
-
- let avatar = avatar.or_else(|| {
- self.icon.map(|icon| {
- h_flex()
- .flex_shrink_0()
- .w(px(20.))
- .justify_center()
- .text_color(cx.theme().icon_muted)
- .child(Icon::new(icon).small())
- .into_any_element()
- })
- });
-
- h_flex()
- .id(self.id)
- .h_8()
- .w_full()
- .px_2()
- .gap_2()
- .rounded(cx.theme().radius)
- .when(is_section, |this| {
- this.text_xs()
- .text_color(cx.theme().text_placeholder)
- .font_semibold()
- })
- .when(is_room || is_community, |this| this.text_sm())
- .when(is_action, |this| {
- this.text_sm().text_color(cx.theme().text_muted)
- })
- .when(is_hint, |this| {
- this.text_xs()
- .font_normal()
- .text_color(cx.theme().text_placeholder)
- })
- .when_some(avatar, |this, avatar| this.child(avatar))
- .child(
- h_flex()
- .gap_1()
- .flex_1()
- .child(
- div()
- .truncate()
- .min_w_0()
- .when(is_room, |this| this.font_medium())
- .child(self.label),
- )
- .when(is_selected, |this| {
- this.child(
- Icon::new(IconName::CheckCircle)
- .small()
- .flex_shrink_0()
- .text_color(cx.theme().icon_accent),
- )
- })
- .when_some(self.count, |this, count| {
- this.child(div().flex_shrink_0().font_normal().child(count.to_string()))
- })
- .when_some(self.created_at, |this, created_at| {
- this.child(div().flex_1()).child(
- div()
- .flex_shrink_0()
- .text_color(cx.theme().text_placeholder)
- .text_xs()
- .child(created_at),
- )
- }),
- )
- .when_some(self.on_click, |this, handler| {
- this.cursor_pointer()
- .when(!is_section, |this| {
- this.hover(|this| this.bg(cx.theme().ghost_element_hover))
- })
- .on_click(move |event, window, cx| handler(event, window, cx))
- })
- }
-}
diff --git a/crates/workspace/src/sidebar/utils.rs b/crates/workspace/src/sidebar/utils.rs
new file mode 100644
index 00000000..b01f3520
--- /dev/null
+++ b/crates/workspace/src/sidebar/utils.rs
@@ -0,0 +1,58 @@
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use gpui::prelude::FluentBuilder as _;
+use gpui::{AnyElement, App, ImageSource, IntoElement, ParentElement, SharedString, Styled, px};
+use settings::AppSettings;
+use theme::ActiveTheme;
+use ui::avatar::{Avatar, PixelAvatar};
+use ui::{Icon, IconName, Sizable, h_flex};
+
+pub(crate) fn nav_avatar(
+ seed: Option>,
+ picture: Option>,
+ cx: &App,
+) -> Option {
+ if AppSettings::get_hide_avatar(cx) {
+ return None;
+ }
+
+ match (seed.map(Into::into), picture.map(Into::into)) {
+ (None, None) => None,
+ (seed, Some(picture)) => Some(
+ Avatar::from_source(picture)
+ .when_some(seed, |avatar, seed| avatar.seed(seed))
+ .small()
+ .flex_shrink_0()
+ .into_any_element(),
+ ),
+ (Some(seed), None) => Some(
+ PixelAvatar::new(seed)
+ .small()
+ .flex_shrink_0()
+ .into_any_element(),
+ ),
+ }
+}
+
+pub(crate) fn nav_icon(icon: IconName, cx: &App) -> AnyElement {
+ h_flex()
+ .flex_shrink_0()
+ .w(px(20.))
+ .justify_center()
+ .text_color(cx.theme().icon_muted)
+ .child(Icon::new(icon).small())
+ .into_any_element()
+}
+
+/// Brand backgrounds shown behind the signed-out screen; one is picked at random.
+const SIGNED_OUT_BANNERS: [&str; 2] = ["brand/bg1.jpg", "brand/bg2.jpg"];
+
+/// Pick one of the signed-out backgrounds at random.
+pub(crate) fn pick_banner() -> SharedString {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|elapsed| elapsed.subsec_nanos())
+ .unwrap_or_default();
+ let index = nanos as usize % SIGNED_OUT_BANNERS.len();
+ SIGNED_OUT_BANNERS[index].into()
+}