{
- let updater = AutoUpdater::try_global(cx);
- 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();
-
- h_flex()
- .id("sidebar-user")
- .w_full()
- .h(TABBAR_HEIGHT)
- .flex_shrink_0()
- .items_center()
- .gap_2()
- .px_2()
- .when(cfg!(target_os = "macos"), |this| {
- this.pl(px(TRAFFIC_LIGHT_PADDING))
- })
- .child(
- Button::new("current-user")
- .child(
- Avatar::new(avatar.clone())
- .seed(avatar_seed.clone())
- .small(),
- )
- .small()
- .caret()
- .compact()
- .transparent()
- .dropdown_menu(move |this, _window, _cx| {
- let avatar = avatar.clone();
- let avatar_seed = avatar_seed.clone();
- let name = name.clone();
-
- this.min_w(px(256.))
- .item(PopupMenuItem::element(move |_window, cx| {
- h_flex()
- .gap_1p5()
- .text_xs()
- .text_color(cx.theme().text_muted)
- .child(
- Avatar::new(avatar.clone())
- .seed(avatar_seed.clone())
- .xsmall(),
- )
- .child(name.clone())
- }))
- .separator()
- .menu_with_icon("Inbox", IconName::Inbox, Box::new(Command::ShowInbox))
- .menu_with_icon(
- "Search",
- IconName::Search,
- Box::new(Command::ShowSearch),
- )
- .menu_with_icon(
- "Profile",
- IconName::Profile,
- Box::new(Command::ShowProfile),
- )
- .menu_with_icon(
- "Contact List",
- IconName::Book,
- Box::new(Command::ShowContactList),
- )
- .menu_with_icon(
- "Backup",
- IconName::UserKey,
- Box::new(Command::ShowBackup),
- )
- .menu_with_icon("Themes", IconName::Sun, Box::new(Command::ToggleTheme))
- .separator()
- .menu_with_icon(
- "Settings",
- IconName::Settings,
- Box::new(Command::ShowSettings),
- )
- }),
- )
- .when_some(updater, |this, updater| {
- this.child(self.render_updater(updater, cx))
- })
- .child(
- Button::new("sidebar-new")
- .icon(IconName::PlusCircle)
- .tooltip("New")
- .ghost()
- .small()
- .dropdown_menu(|menu, _window, _cx| {
- menu.menu_with_icon(
- "New Chat",
- IconName::Message,
- Box::new(Command::NewChat),
- )
- .menu_with_icon("New Group", IconName::Group, Box::new(Command::NewGroup))
- .separator()
- .menu_with_icon(
- "Join Group",
- IconName::Door,
- Box::new(Command::JoinGroup),
- )
- }),
- )
- .when(self.group_open, |this| {
- this.child(
- Button::new("sidebar-back")
- .icon(IconName::ArrowLeft)
- .tooltip("Back")
- .ghost()
- .small()
- .on_click(cx.listener(|this, _event, _window, cx| {
- this.hide_group(cx);
- })),
- )
- })
- }
-
- 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();
-
- 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()
- }
-
- fn render_list(&mut self, cx: &mut Context) -> AnyElement {
- let requests_open = self.requests_open;
- let contacts_open = self.contacts_open && !requests_open;
- let rows = Rc::new(self.rows_for(cx));
-
- // Whether to show the create button for the contact picker
- let create_button = contacts_open && !self.selected_contacts.is_empty();
-
- let (list_id, scroll_handle, list_title) = if contacts_open {
- ("sidebar-contacts", &self.contacts_scroll, Some("Contacts"))
- } else if requests_open {
- ("sidebar-requests", &self.requests_scroll, Some("Requests"))
- } else {
- ("sidebar-list", &self.list_scroll, None)
- };
-
- let request_count = ChatRegistry::global(cx)
- .read(cx)
- .count(&RoomKind::Request, cx);
-
- v_flex()
- .size_full()
- .flex_1()
- .min_h_0()
- .relative()
- .gap_2()
- .child(
- v_flex()
- .px_2()
- .gap_1()
- .child(
- NavItem::new("browse", "Browse", Icon::new(IconName::Compass).small())
- .on_click(|_, window, cx| {
- window.dispatch_action(Box::new(Command::ShowBrowse), cx)
- }),
- )
- .child(
- NavItem::new("reqs", "Requests", Icon::new(IconName::Invite).small())
- .when(request_count > 0, |this| {
- this.suffix(
- h_flex()
- .gap_1()
- .items_center()
- .child(
- h_flex()
- .py_0p5()
- .px_1()
- .min_w_6()
- .justify_center()
- .text_size(px(10.))
- .text_color(cx.theme().text_muted)
- .text_center()
- .rounded(cx.theme().radius)
- .bg(cx.theme().elevated_surface_background)
- .child(SharedString::from(
- request_count.to_string(),
- )),
- )
- .when(self.new_requests && !requests_open, |this| {
- this.child(
- div().size_1().rounded_full().bg(cx.theme().cursor),
- )
- }),
- )
- })
- .when(requests_open, |this| {
- this.bg(cx.theme().ghost_element_active)
- })
- .on_click(cx.listener(|this, _event, _window, cx| {
- this.toggle_requests(cx);
- })),
- )
- .child(
- NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small())
- .when(contacts_open, |this| {
- this.bg(cx.theme().ghost_element_active)
- })
- .on_click(cx.listener(|this, _event, window, cx| {
- this.toggle_contacts(window, cx);
- })),
- ),
- )
- .when_some(list_title, |this, list_title| {
- this.child(
- div()
- .px_4()
- .text_xs()
- .font_semibold()
- .text_color(cx.theme().text_placeholder)
- .child(list_title),
- )
- })
- .child(
- div()
- .min_h_0()
- .flex_1()
- .child(
- uniform_list(
- 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)),
- )
- .when(create_button, |this| {
- this.child(
- v_flex()
- .absolute()
- .bottom_0()
- .left_0()
- .justify_center()
- .h_20()
- .w_full()
- .px_4()
- .border_t_1()
- .border_color(cx.theme().border)
- .bg(cx.theme().background)
- .child(
- Button::new("create-chat")
- .label(if self.selected_contacts.len() > 1 {
- "Create Group Chat"
- } else {
- "Create Chat"
- })
- .primary()
- .on_click(cx.listener(|this, _ev, window, cx| {
- this.create_chat(window, cx);
- })),
- )
- .child(
- h_flex()
- .gap_1()
- .text_xs()
- .text_color(cx.theme().text_muted)
- .child("Selected")
- .child(div().font_semibold().child(SharedString::from(
- self.selected_contacts.len().to_string(),
- ))),
- ),
- )
- })
- .into_any_element()
- }
-
- fn rows_for(&self, cx: &App) -> Vec {
- if self.contacts_open {
- return self
- .contacts
- .clone()
- .unwrap_or_default()
- .into_iter()
- .map(|public_key| SidebarRow::Contact { public_key })
- .collect();
- }
-
- if self.requests_open {
- let chat = ChatRegistry::global(cx);
- return chat
- .read(cx)
- .rooms(&RoomKind::Request, cx)
- .into_iter()
- .map(|room| SidebarRow::Room { room })
- .collect();
- }
-
- let mut rows = Vec::new();
-
- let chat = ChatRegistry::global(cx);
- let pinned_rooms = AppSettings::get_pinned_rooms(cx);
-
- let (pinned_rooms, rooms): (Vec<_>, Vec<_>) = chat
- .read(cx)
- .rooms(&RoomKind::Ongoing, cx)
- .into_iter()
- .partition(|room| pinned_rooms.contains(&room.read(cx).id));
-
- let registry = GroupsRegistry::global(cx);
- let pinned_group_ids = AppSettings::get_pinned_groups(cx);
-
- let (pinned_groups, groups): (Vec<_>, Vec<_>) = registry
- .read(cx)
- .groups()
- .to_vec()
- .into_iter()
- .partition(|group| {
- let tag = group.read(cx).key().cache_tag();
- pinned_group_ids.iter().any(|pinned| pinned == &tag)
- });
-
- rows.push(SidebarRow::Section(ListSection::Pins));
- if self.pins_open {
- if pinned_rooms.is_empty() && pinned_groups.is_empty() {
- rows.push(SidebarRow::Empty("Nothing pinned yet"));
- } else {
- rows.extend(
- pinned_groups
- .into_iter()
- .map(|group| SidebarRow::Group { group }),
- );
- rows.extend(
- pinned_rooms
- .into_iter()
- .map(|room| SidebarRow::Room { room }),
- );
- }
- }
-
- rows.push(SidebarRow::Section(ListSection::Groups));
- if self.is_groups_open(cx) {
- if groups.is_empty() {
- rows.push(SidebarRow::Empty("No groups yet"));
- } else {
- rows.extend(groups.into_iter().map(|group| SidebarRow::Group { group }));
- }
- }
-
- rows.push(SidebarRow::Section(ListSection::Chats));
- if self.is_chats_open(cx) {
- if rooms.is_empty() {
- rows.push(SidebarRow::Empty("No chats yet"));
- } else {
- rows.extend(rooms.into_iter().map(|room| SidebarRow::Room { room }));
- }
- }
-
- rows
- }
-
- /// Whether anything is pinned; drives the sections' default expansion.
- fn pinned_any(cx: &App) -> bool {
- !AppSettings::get_pinned_rooms(cx).is_empty()
- || !AppSettings::get_pinned_groups(cx).is_empty()
- }
-
- fn is_chats_open(&self, cx: &App) -> bool {
- self.chats_open.unwrap_or_else(|| !Self::pinned_any(cx))
- }
-
- fn is_groups_open(&self, cx: &App) -> bool {
- self.groups_open.unwrap_or_else(|| !Self::pinned_any(cx))
- }
-
- fn render_rows(
- &self,
- range: Range,
- rows: &[SidebarRow],
- cx: &Context,
- ) -> Vec {
- let hide_avatar = AppSettings::get_hide_avatar(cx);
-
- rows.get(range.clone())
- .into_iter()
- .flatten()
- .enumerate()
- .map(|(offset, row)| {
- let index = range.start + offset;
-
- match row {
- SidebarRow::Section(section) => {
- let section = *section;
- let open = match section {
- ListSection::Pins => self.pins_open,
- ListSection::Groups => self.is_groups_open(cx),
- ListSection::Chats => self.is_chats_open(cx),
- };
- let icon = if open {
- IconName::CaretDown
- } else {
- IconName::CaretRight
- };
- let label = section.label();
-
- Nav::new(label)
- .label(label)
- .suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted))
- .text_xs()
- .font_semibold()
- .text_color(cx.theme().text_placeholder)
- .on_click(cx.listener(move |this, _event, _window, cx| {
- match section {
- ListSection::Pins => this.pins_open = !this.pins_open,
- ListSection::Groups => {
- this.groups_open = Some(!this.is_groups_open(cx))
- }
- ListSection::Chats => {
- this.chats_open = Some(!this.is_chats_open(cx))
- }
- }
- cx.notify();
- }))
- .into_any_element()
- }
- SidebarRow::Empty(text) => h_flex()
- .h_10()
- .w_full()
- .px_1p5()
- .py_1()
- .justify_center()
- .border_1()
- .border_dashed()
- .border_color(cx.theme().border)
- .rounded(cx.theme().radius)
- .justify_center()
- .text_xs()
- .text_color(cx.theme().text_placeholder)
- .child(*text)
- .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 kind = room.read(cx).kind;
- let peer = room.read(cx).display_member(cx).public_key();
- let id = room.read(cx).id;
- let member = room.read(cx).members().first().copied();
- let pinned = AppSettings::global(cx).read(cx).pinned_room(id, cx);
- let dock = self.dock.clone();
- let room = room.clone();
-
- let nav = Nav::new(index)
- .label(name)
- .text_sm()
- .font_medium()
- .when(!hide_avatar, |this| {
- this.prefix(
- Avatar::from_source(picture)
- .seed(seed)
- .small()
- .flex_shrink_0(),
- )
- })
- .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,
- );
- if kind != RoomKind::Ongoing && AppSettings::get_screening(cx) {
- let screening = screening::init(peer, window, cx);
-
- window.open_dialog(cx, move |this, _window, _cx| {
- this.confirm()
- .child(screening.clone())
- .button_props(
- DialogButtonProps::default()
- .cancel_text("Ignore")
- .ok_text("Response"),
- )
- .on_cancel(move |_event, window, cx| {
- window.dispatch_action(Box::new(ClosePanel), cx);
- true
- })
- });
- }
- });
-
- ContextMenu::new(
- format!("room-menu-{index}"),
- nav,
- move |menu, _window, _cx| {
- let menu = menu.item(
- PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" })
- .on_click(move |_, _window, cx| {
- AppSettings::global(cx).update(cx, |settings, cx| {
- settings.toggle_pinned_room(id, cx);
- });
- }),
- );
-
- if let Some(member) = member {
- menu.item(PopupMenuItem::new("View profile").on_click(
- move |_, window, cx| {
- profile::open(member, window, cx);
- },
- ))
- } else {
- menu
- }
- },
- )
- .into_any_element()
- }
- SidebarRow::Contact { public_key } => {
- let persons = PersonRegistry::global(cx);
- let person = persons.read(cx).get(public_key, cx);
- let selected = self.selected_contacts.contains(public_key);
- let sidebar = cx.entity().downgrade();
- let public_key = public_key.to_owned();
-
- Nav::new(index)
- .label(person.name())
- .text_sm()
- .font_medium()
- .when(!hide_avatar, |this| {
- this.prefix(
- Avatar::from_source(person.avatar())
- .seed(person.avatar_seed())
- .small()
- .flex_shrink_0(),
- )
- })
- .selected(selected)
- .on_click(move |_event, _window, cx| {
- if let Err(error) = sidebar.update(cx, |this, cx| {
- this.toggle_contact(&public_key, cx);
- }) {
- log::error!("Failed to select contact: {error}");
- }
- })
- .into_any_element()
- }
- SidebarRow::Group { group } => {
- let dock = self.dock.clone();
- let sidebar = cx.entity().downgrade();
- let group = group.clone();
-
- let name = group.read(cx).display_name();
- let picture = group.read(cx).display_image();
- let seed = group.read(cx).key().cache_tag();
- let tag = seed.clone();
- let pinned = AppSettings::global(cx).read(cx).pinned_group(&tag, cx);
- let weak_group = group.downgrade();
-
- let suffix = group
- .read(cx)
- .membership()
- .status()
- .or_else(|| group.read(cx).elsewhere().map(|_| "Moved"))
- .map(SharedString::from);
-
- let nav = Nav::new(index)
- .label(name)
- .text_sm()
- .font_medium()
- .when(!hide_avatar, |this| {
- this.prefix(
- Avatar::from_source(picture)
- .seed(seed)
- .small()
- .flex_shrink_0(),
- )
- })
- .when_some(suffix, |this, suffix| {
- this.suffix(
- div()
- .font_normal()
- .text_xs()
- .text_color(cx.theme().text_placeholder)
- .child(suffix),
- )
- })
- .on_click(move |_event, window, cx| {
- ui::dock::add_panel_to(
- &dock,
- PanelHandle::new(nip29_ui::init(group.clone(), window, cx)),
- DockPlacement::Center,
- window,
- cx,
- );
-
- if let Err(error) = sidebar.update(cx, |this, cx| {
- this.show_group(&group, cx);
- }) {
- log::error!("Failed to show the group in the sidebar: {error}");
- }
- });
-
- ContextMenu::new(
- format!("group-menu-{index}"),
- nav,
- move |menu, _window, _cx| {
- menu.item(
- PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" })
- .on_click({
- let tag = tag.clone();
- move |_, _window, cx| {
- AppSettings::global(cx).update(
- cx,
- |settings, cx| {
- settings.toggle_pinned_group(&tag, cx);
- },
- );
- }
- }),
- )
- .item(
- PopupMenuItem::new("Leave").on_click({
- let group = weak_group.clone();
- move |_, window, cx| {
- nip29_ui::confirm_leave(group.clone(), window, cx);
- }
- }),
- )
- },
- )
- .into_any_element()
- }
- }
- })
- .collect()
- }
-
- fn render_group(&mut self, group: Entity, cx: &mut Context) -> AnyElement {
- let admin = NostrRegistry::global(cx)
- .read(cx)
- .current_user()
- .is_some_and(|me| group.read(cx).admins().contains(&me));
-
- let (name, seed, picture, total_members, rows) = {
- let group = group.read(cx);
- let admins = group.admins();
-
- let mut admin_rows = Vec::new();
- let mut member_rows = Vec::new();
-
- for (public_key, roles) in admins.entries() {
- admin_rows.push(GroupRow::Member {
- public_key: *public_key,
- roles: roles.clone(),
- });
- }
-
- for public_key in group.members().iter() {
- if admins.contains(public_key) {
- continue;
- }
-
- member_rows.push(GroupRow::Member {
- public_key: *public_key,
- roles: Vec::new(),
- });
- }
-
- let mut rows = Vec::new();
-
- if !admin_rows.is_empty() {
- rows.push(GroupRow::Section(GroupSection::Admins));
- if self.admins_open {
- rows.append(&mut admin_rows);
- }
- }
-
- if !member_rows.is_empty() {
- rows.push(GroupRow::Section(GroupSection::Members));
- if self.members_open {
- rows.append(&mut member_rows);
- }
- }
-
- (
- group.display_name(),
- group.key().cache_tag(),
- group.display_image(),
- SharedString::from(format!("{} members", group.total_members())),
- rows,
- )
- };
-
- let rows = Rc::new(rows);
- let member_group = group.downgrade();
-
- v_flex()
- .flex_1()
- .min_h_0()
- .w_full()
- .gap_2()
- .child(
- h_flex()
- .px_2()
- .flex_shrink_0()
- .justify_between()
- .child(
- h_flex()
- .pl_0p5()
- .gap_1()
- .flex_shrink_1()
- .child(Avatar::from_source(picture).seed(seed).small())
- .child(
- v_flex()
- .flex_1()
- .min_w_0()
- .child(
- div()
- .text_xs()
- .truncate()
- .line_height(relative(1.2))
- .font_semibold()
- .child(name),
- )
- .child(
- div()
- .text_size(px(10.))
- .text_color(cx.theme().text_muted)
- .child(total_members),
- ),
- ),
- )
- .child(
- h_flex()
- .gap_1()
- .child(nip29_ui::actions(group.downgrade(), cx)),
- ),
- )
- .child(
- div()
- .min_h_0()
- .flex_1()
- .child(
- uniform_list(
- "group-rows",
- rows.len(),
- cx.processor(move |this, range, _window, cx| {
- this.render_group_rows(
- range,
- rows.as_slice(),
- &member_group,
- admin,
- cx,
- )
- }),
- )
- .track_scroll(&self.group_scroll)
- .h_full()
- .px_2(),
- )
- .child(Scrollbar::vertical(&self.group_scroll)),
- )
- .into_any_element()
- }
-
- fn render_group_rows(
- &self,
- range: Range,
- rows: &[GroupRow],
- group: &WeakEntity,
- admin: bool,
- cx: &mut Context,
- ) -> Vec {
- rows.get(range)
- .into_iter()
- .flatten()
- .map(|row| match row {
- GroupRow::Section(section) => self.section_row(*section, cx),
- GroupRow::Member { public_key, roles } => {
- self.member_row(public_key, roles, group, admin, cx)
- }
- })
- .collect()
- }
-
- fn section_row(&self, section: GroupSection, cx: &mut Context) -> AnyElement {
- let open = match section {
- GroupSection::Admins => self.admins_open,
- GroupSection::Members => self.members_open,
- };
- let icon = if open {
- IconName::CaretDown
- } else {
- IconName::CaretRight
- };
- let label = section.label();
-
- Nav::new(label)
- .label(label)
- .suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted))
- .text_xs()
- .font_semibold()
- .text_color(cx.theme().text_placeholder)
- .on_click(cx.listener(move |this, _event, _window, cx| {
- match section {
- GroupSection::Admins => this.admins_open = !this.admins_open,
- GroupSection::Members => this.members_open = !this.members_open,
- }
- cx.notify();
- }))
- .into_any_element()
- }
-
- fn member_row(
- &self,
- public_key: &PublicKey,
- roles: &[String],
- group: &WeakEntity,
- admin: bool,
- cx: &mut Context,
- ) -> AnyElement {
- let hide_avatar = AppSettings::get_hide_avatar(cx);
- let persons = PersonRegistry::global(cx);
- let person = persons.read(cx).get(public_key, cx);
- let public_key = *public_key;
-
- let group = group.clone();
-
- Nav::new(public_key.to_hex())
- .label(person.name())
- .text_sm()
- .font_medium()
- .clickable(true)
- .when(!hide_avatar, |this| {
- this.prefix(
- Avatar::new(person.avatar())
- .seed(person.avatar_seed())
- .small()
- .flex_shrink_0(),
- )
- })
- .when(!roles.is_empty(), |this| {
- this.suffix(
- div()
- .truncate()
- .text_xs()
- .text_color(cx.theme().text_placeholder)
- .child(SharedString::from(roles.join(", "))),
- )
- })
- .dropdown_menu(move |menu, _window, _cx| {
- let menu = menu
- .item(
- PopupMenuItem::new("View profile").on_click(move |_, window, cx| {
- profile::open(public_key, window, cx);
- }),
- )
- .item(PopupMenuItem::new("Copy").on_click(move |_, _, cx| {
- let npub = public_key.to_bech32().unwrap_or_default();
- cx.write_to_clipboard(ClipboardItem::new_string(npub));
- }));
-
- if !admin {
- return menu;
- }
-
- menu.separator()
- .item(PopupMenuItem::new("Remove").on_click({
- let group = group.clone();
- move |_event, window, cx| {
- nip29_ui::remove_member(group.clone(), public_key, window, cx);
- }
- }))
- })
- .into_any_element()
- }
}
impl Panel for Sidebar {
@@ -1334,6 +270,7 @@ impl Render for Sidebar {
let loading = chat.read(cx).loading();
let group = self.group.clone().and_then(|group| group.upgrade());
+ let searching = self.search_open && !self.find_input.read(cx).value().is_empty();
v_flex()
.image_cache(retain_all("sidebar"))
@@ -1350,6 +287,7 @@ impl Render for Sidebar {
cx,
))
})
+ .when(self.search_open, |this| this.child(self.render_find(cx)))
.when(!logged_in, |this| {
this.relative()
.child(title_bar_drag_handlers(
@@ -1370,33 +308,13 @@ impl Render for Sidebar {
.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);
- }),
- ),
- )
+ .child(onboarding::render_signed_out(window, cx))
})
.map(|this| match group {
- Some(group) if self.group_open => this.child(self.render_group(group, cx)),
+ Some(group) if self.group_open && !searching => {
+ this.child(self.render_group(group, cx))
+ }
+ _ if searching => this.child(self.render_results(cx)),
_ => this.child(self.render_list(cx)),
})
.when(loading && logged_in, |this| {
diff --git a/crates/workspace/src/sidebar/onboarding.rs b/crates/workspace/src/sidebar/onboarding.rs
new file mode 100644
index 00000000..2a168c5f
--- /dev/null
+++ b/crates/workspace/src/sidebar/onboarding.rs
@@ -0,0 +1,47 @@
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, img};
+use ui::button::{Button, ButtonCustomVariant, ButtonVariants};
+use ui::{Sizable, StyledExt, v_flex};
+
+use crate::dialogs::import;
+
+/// 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(super) 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()
+}
+
+/// The headline and identity import shown while signed out.
+pub(super) fn render_signed_out(window: &mut Window, cx: &mut App) -> AnyElement {
+ 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);
+ }),
+ )
+ .into_any_element()
+}
diff --git a/crates/workspace/src/sidebar/rows.rs b/crates/workspace/src/sidebar/rows.rs
new file mode 100644
index 00000000..7fc64d65
--- /dev/null
+++ b/crates/workspace/src/sidebar/rows.rs
@@ -0,0 +1,392 @@
+use std::ops::Range;
+
+use chat::{Room, RoomKind};
+use common::TimestampExt;
+use gpui::prelude::FluentBuilder;
+use gpui::{
+ AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, WeakEntity,
+ Window, div,
+};
+use nip29::Group;
+use nostr_sdk::prelude::*;
+use person::PersonRegistry;
+use settings::AppSettings;
+use theme::ActiveTheme;
+use ui::avatar::Avatar;
+use ui::dialog::DialogButtonProps;
+use ui::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle};
+use ui::menu::{ContextMenu, PopupMenuItem};
+use ui::nav::Nav;
+use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
+
+use super::Sidebar;
+use super::list::ListSection;
+use super::search::MessageHit;
+use crate::dialogs::{profile, screening};
+
+/// Open a room in the dock, screening new requests when enabled.
+fn open_room(room: &Entity, dock: &WeakEntity, window: &mut Window, cx: &mut App) {
+ ui::dock::add_panel_to(
+ dock,
+ PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
+ DockPlacement::Center,
+ window,
+ cx,
+ );
+
+ if room.read(cx).kind != RoomKind::Ongoing && AppSettings::get_screening(cx) {
+ let peer = room.read(cx).display_member(cx).public_key();
+ let view = screening::init(peer, window, cx);
+
+ window.open_dialog(cx, move |this, _window, _cx| {
+ this.confirm()
+ .child(view.clone())
+ .button_props(
+ DialogButtonProps::default()
+ .cancel_text("Ignore")
+ .ok_text("Response"),
+ )
+ .on_cancel(move |_event, window, cx| {
+ window.dispatch_action(Box::new(ClosePanel), cx);
+ true
+ })
+ });
+ }
+}
+
+impl Sidebar {
+ pub(super) fn render_rows(
+ &self,
+ range: Range,
+ rows: &[SidebarRow],
+ cx: &Context,
+ ) -> Vec {
+ let hide_avatar = AppSettings::get_hide_avatar(cx);
+
+ rows.get(range.clone())
+ .into_iter()
+ .flatten()
+ .enumerate()
+ .map(|(offset, row)| {
+ let index = range.start + offset;
+
+ match row {
+ SidebarRow::Section(section) => {
+ let section = *section;
+ let label = section.label();
+
+ let open = match section {
+ ListSection::Pins => self.pins_open,
+ ListSection::Groups => self.is_groups_open(cx),
+ ListSection::Chats => self.is_chats_open(cx),
+ };
+
+ let icon = if open {
+ IconName::CaretDown
+ } else {
+ IconName::CaretRight
+ };
+
+ Nav::new(label)
+ .label(label)
+ .suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted))
+ .text_xs()
+ .font_semibold()
+ .text_color(cx.theme().text_placeholder)
+ .on_click(cx.listener(move |this, _event, _window, cx| {
+ match section {
+ ListSection::Pins => this.pins_open = !this.pins_open,
+ ListSection::Groups => {
+ this.groups_open = Some(!this.is_groups_open(cx))
+ }
+ ListSection::Chats => {
+ this.chats_open = Some(!this.is_chats_open(cx))
+ }
+ }
+ cx.notify();
+ }))
+ .into_any_element()
+ }
+ SidebarRow::Heading(label) => {
+ let label = *label;
+
+ Nav::new(label)
+ .label(label)
+ .text_xs()
+ .font_semibold()
+ .text_color(cx.theme().text_placeholder)
+ .into_any_element()
+ }
+ SidebarRow::Empty(text) => v_flex()
+ .h_10()
+ .w_full()
+ .px_1p5()
+ .items_center()
+ .child(
+ h_flex()
+ .h_9()
+ .w_full()
+ .justify_center()
+ .border_1()
+ .border_dashed()
+ .border_color(cx.theme().border)
+ .rounded(cx.theme().radius)
+ .text_xs()
+ .text_color(cx.theme().text_placeholder)
+ .child(*text),
+ )
+ .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 id = room.read(cx).id;
+ let member = room.read(cx).members().first().copied();
+ let pinned = AppSettings::global(cx).read(cx).pinned_room(id, cx);
+ let dock = self.dock.clone();
+ let room = room.clone();
+
+ let nav = Nav::new(index)
+ .label(name)
+ .text_sm()
+ .when(!hide_avatar, |this| {
+ this.prefix(
+ Avatar::from_source(picture)
+ .seed(seed)
+ .small()
+ .flex_shrink_0(),
+ )
+ })
+ .suffix(
+ div()
+ .font_normal()
+ .text_xs()
+ .text_color(cx.theme().text_placeholder)
+ .child(created_at),
+ )
+ .on_click(move |_event, window, cx| {
+ open_room(&room, &dock, window, cx);
+ });
+
+ ContextMenu::new(
+ format!("room-menu-{index}"),
+ nav,
+ move |menu, _window, _cx| {
+ let menu = menu.item(
+ PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" })
+ .on_click(move |_, _window, cx| {
+ AppSettings::global(cx).update(cx, |settings, cx| {
+ settings.toggle_pinned_room(id, cx);
+ });
+ }),
+ );
+
+ if let Some(member) = member {
+ menu.item(PopupMenuItem::new("View profile").on_click(
+ move |_, window, cx| {
+ profile::open(member, window, cx);
+ },
+ ))
+ } else {
+ menu
+ }
+ },
+ )
+ .into_any_element()
+ }
+ SidebarRow::Contact { public_key } => {
+ let persons = PersonRegistry::global(cx);
+ let person = persons.read(cx).get(public_key, cx);
+ let selected = self.selected_contacts.contains(public_key);
+ let sidebar = cx.entity().downgrade();
+ let public_key = public_key.to_owned();
+
+ Nav::new(index)
+ .label(person.name())
+ .text_sm()
+ .when(!hide_avatar, |this| {
+ this.prefix(
+ Avatar::from_source(person.avatar())
+ .seed(person.avatar_seed())
+ .small()
+ .flex_shrink_0(),
+ )
+ })
+ .selected(selected)
+ .on_click(move |_event, _window, cx| {
+ if let Err(error) = sidebar.update(cx, |this, cx| {
+ this.toggle_contact(&public_key, cx);
+ }) {
+ log::error!("Failed to select contact: {error}");
+ }
+ })
+ .into_any_element()
+ }
+ SidebarRow::Profile { public_key } => {
+ let persons = PersonRegistry::global(cx);
+ let person = persons.read(cx).get(public_key, cx);
+ let public_key = public_key.to_owned();
+
+ Nav::new(index)
+ .label(person.name())
+ .text_sm()
+ .when(!hide_avatar, |this| {
+ this.prefix(
+ Avatar::from_source(person.avatar())
+ .seed(person.avatar_seed())
+ .small()
+ .flex_shrink_0(),
+ )
+ })
+ .on_click(move |_event, window, cx| {
+ profile::open(public_key, window, cx);
+ })
+ .into_any_element()
+ }
+ SidebarRow::Group { group } => {
+ let dock = self.dock.clone();
+ let sidebar = cx.entity().downgrade();
+ let group = group.clone();
+
+ let name = group.read(cx).display_name();
+ let picture = group.read(cx).display_image();
+ let seed = group.read(cx).key().cache_tag();
+ let tag = seed.clone();
+ let pinned = AppSettings::global(cx).read(cx).pinned_group(&tag, cx);
+ let weak_group = group.downgrade();
+
+ let suffix = group
+ .read(cx)
+ .membership()
+ .status()
+ .or_else(|| group.read(cx).elsewhere().map(|_| "Moved"))
+ .map(SharedString::from);
+
+ let nav = Nav::new(index)
+ .label(name)
+ .text_sm()
+ .when(!hide_avatar, |this| {
+ this.prefix(
+ Avatar::from_source(picture)
+ .seed(seed)
+ .small()
+ .flex_shrink_0(),
+ )
+ })
+ .when_some(suffix, |this, suffix| {
+ this.suffix(
+ div()
+ .font_normal()
+ .text_xs()
+ .text_color(cx.theme().text_placeholder)
+ .child(suffix),
+ )
+ })
+ .on_click(move |_event, window, cx| {
+ ui::dock::add_panel_to(
+ &dock,
+ PanelHandle::new(nip29_ui::init(group.clone(), window, cx)),
+ DockPlacement::Center,
+ window,
+ cx,
+ );
+
+ if let Err(error) = sidebar.update(cx, |this, cx| {
+ this.show_group(&group, cx);
+ }) {
+ log::error!("Failed to show the group in the sidebar: {error}");
+ }
+ });
+
+ ContextMenu::new(
+ format!("group-menu-{index}"),
+ nav,
+ move |menu, _window, _cx| {
+ menu.item(
+ PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" })
+ .on_click({
+ let tag = tag.clone();
+ move |_, _window, cx| {
+ AppSettings::global(cx).update(
+ cx,
+ |settings, cx| {
+ settings.toggle_pinned_group(&tag, cx);
+ },
+ );
+ }
+ }),
+ )
+ .item(
+ PopupMenuItem::new("Leave").on_click({
+ let group = weak_group.clone();
+ move |_, window, cx| {
+ nip29_ui::confirm_leave(group.clone(), window, cx);
+ }
+ }),
+ )
+ },
+ )
+ .into_any_element()
+ }
+ SidebarRow::Message { hit } => {
+ let person = PersonRegistry::global(cx).read(cx).get(&hit.author, cx);
+ let name = hit.room.read(cx).display_name(cx);
+ let detail = format!("{} ยท {}", name, hit.created_at.to_ago());
+ let dock = self.dock.clone();
+ let room = hit.room.clone();
+ let content = hit.content.clone();
+
+ Nav::new(index)
+ .label(content)
+ .text_sm()
+ .font_medium()
+ .when(!hide_avatar, |this| {
+ this.prefix(
+ Avatar::from_source(person.avatar())
+ .seed(person.avatar_seed())
+ .small()
+ .flex_shrink_0(),
+ )
+ })
+ .suffix(
+ div()
+ .font_normal()
+ .text_xs()
+ .text_color(cx.theme().text_placeholder)
+ .child(detail),
+ )
+ .on_click(move |_event, window, cx| {
+ open_room(&room, &dock, window, cx);
+ })
+ .into_any_element()
+ }
+ }
+ })
+ .collect()
+ }
+}
+
+/// A row shown in the sidebar's lists.
+pub(super) enum SidebarRow {
+ Section(ListSection),
+ Room {
+ room: Entity,
+ },
+ Group {
+ group: Entity,
+ },
+ Contact {
+ public_key: PublicKey,
+ },
+ /// A profile found by global search.
+ Profile {
+ public_key: PublicKey,
+ },
+ Message {
+ hit: MessageHit,
+ },
+ /// A non-interactive label, such as a search results section.
+ Heading(&'static str),
+ Empty(&'static str),
+}
diff --git a/crates/workspace/src/sidebar/search.rs b/crates/workspace/src/sidebar/search.rs
new file mode 100644
index 00000000..5724ce1d
--- /dev/null
+++ b/crates/workspace/src/sidebar/search.rs
@@ -0,0 +1,315 @@
+use std::collections::HashSet;
+use std::rc::Rc;
+
+use chat::{ChatRegistry, Room};
+use gpui::{
+ AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, Task,
+ Window, div, uniform_list,
+};
+use nip29::{Group, GroupsRegistry};
+use nostr_sdk::prelude::*;
+use state::{FIND_LIMIT, NostrRegistry};
+use theme::ActiveTheme;
+use ui::button::{Button, ButtonVariants};
+use ui::input::Input;
+use ui::scroll::Scrollbar;
+use ui::{IconName, Selectable, Sizable, h_flex, v_flex};
+
+use super::Sidebar;
+use super::rows::SidebarRow;
+
+/// An entry in the sidebar's search results.
+pub(super) enum SearchResult {
+ Room(Entity),
+ Group(Entity),
+ Message(MessageHit),
+}
+
+/// A message found by searching the indexed chat history.
+#[derive(Clone)]
+pub(super) struct MessageHit {
+ pub(super) room: Entity,
+ pub(super) author: PublicKey,
+ pub(super) content: SharedString,
+ pub(super) created_at: Timestamp,
+}
+
+/// Find groups whose display name contains all of the terms.
+fn find_groups(terms: &[String], cx: &App) -> Vec> {
+ GroupsRegistry::global(cx)
+ .read(cx)
+ .groups()
+ .iter()
+ .filter(|group| {
+ let name = group.read(cx).display_name().to_lowercase();
+ terms.iter().all(|term| name.contains(term.as_str()))
+ })
+ .cloned()
+ .collect()
+}
+
+impl Sidebar {
+ /// Show or hide the search bar below the header.
+ pub(super) fn toggle_search(&mut self, window: &mut Window, cx: &mut Context) {
+ self.search_open = !self.search_open;
+
+ if self.search_open {
+ self.find_input
+ .update(cx, |input, cx| input.focus(window, cx));
+ } else {
+ self.find_input.update(cx, |input, cx| {
+ input.set_value("", window, cx);
+ });
+ self.global_open = false;
+ self.clear_results(cx);
+ }
+
+ cx.notify();
+ }
+
+ /// Search rooms and groups by name, then indexed messages by content.
+ pub(super) fn search(&mut self, cx: &mut Context) {
+ let query = self.find_input.read(cx).value().to_string();
+ let terms: Vec = query
+ .to_lowercase()
+ .split_whitespace()
+ .map(str::to_owned)
+ .collect();
+
+ if terms.is_empty() {
+ return;
+ }
+
+ let chat = ChatRegistry::global(cx);
+ let rooms = chat.read(cx).find(&query, cx);
+ let groups = find_groups(&terms, cx);
+
+ let messages = chat
+ .read(cx)
+ .search_messages(&terms, FIND_LIMIT)
+ .into_iter()
+ .filter_map(|matched| {
+ let room = chat.read(cx).room(&matched.room, cx)?.upgrade()?;
+
+ Some(MessageHit {
+ room,
+ author: matched.author,
+ content: matched.content.into(),
+ created_at: matched.created_at,
+ })
+ })
+ .collect::>();
+
+ self.results = rooms
+ .into_iter()
+ .map(SearchResult::Room)
+ .chain(groups.into_iter().map(SearchResult::Group))
+ .chain(messages.into_iter().map(SearchResult::Message))
+ .collect();
+ cx.notify();
+ }
+
+ /// Search profiles on the indexer relays for the current query.
+ pub(super) fn global_search(&mut self, cx: &mut Context) {
+ if !self.global_open {
+ return;
+ }
+
+ let query = self.find_input.read(cx).value().trim().to_string();
+
+ if query.is_empty() {
+ return;
+ }
+
+ let nostr = NostrRegistry::global(cx);
+ let find_profiles = nostr.read(cx).search(&query, cx);
+
+ self.global_finding = true;
+ self.global_results = None;
+ cx.notify();
+
+ self.global_task = Some(cx.spawn(async move |this, cx| {
+ let result = find_profiles.await;
+
+ this.update(cx, |this, cx| {
+ this.global_finding = false;
+ this.global_results = Some(match result {
+ Ok(profiles) => {
+ // Relays may return the same profile more than once
+ let mut seen = HashSet::new();
+ profiles
+ .into_iter()
+ .filter(|key| seen.insert(*key))
+ .collect()
+ }
+ Err(error) => {
+ log::debug!("Global search failed: {error}");
+ Vec::new()
+ }
+ });
+ cx.notify();
+ })
+ .ok();
+ }));
+ }
+
+ /// Trigger a global search once the input debounce delay has passed.
+ pub(super) fn debounced_global(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Task<()> {
+ cx.spawn_in(window, async move |this, cx| {
+ this.update_in(cx, |this, _window, cx| {
+ this.global_search(cx);
+ })
+ .ok();
+ })
+ }
+
+ /// Toggle whether searches also cover global (relay) profiles.
+ pub(super) fn toggle_global(&mut self, cx: &mut Context) {
+ self.global_open = !self.global_open;
+
+ if self.global_open {
+ self.global_search(cx);
+ } else {
+ self.global_finding = false;
+ self.global_results = None;
+ self.global_task = None;
+ }
+
+ cx.notify();
+ }
+
+ /// Discard the search results.
+ pub(super) fn clear_results(&mut self, cx: &mut Context) {
+ self.results.clear();
+ self.global_finding = false;
+ self.global_results = None;
+ self.global_task = None;
+ cx.notify();
+ }
+
+ pub(super) fn render_find(&self, cx: &mut Context) -> AnyElement {
+ h_flex()
+ .px_2()
+ .flex_shrink_0()
+ .w_full()
+ .child(
+ h_flex()
+ .px_1p5()
+ .gap_1()
+ .w_full()
+ .rounded(cx.theme().radius)
+ .bg(cx.theme().elevated_surface_background)
+ .child(
+ Input::new(&self.find_input)
+ .appearance(false)
+ .small()
+ .text_xs(),
+ )
+ .child(
+ Button::new("sidebar-global")
+ .icon(IconName::Compass)
+ .tooltip(if self.global_open {
+ "Disable global search"
+ } else {
+ "Enable global search"
+ })
+ .small()
+ .ghost()
+ .selected(self.global_open)
+ .loading(self.global_finding)
+ .on_click(cx.listener(|this, _event, _window, cx| {
+ this.toggle_global(cx);
+ })),
+ ),
+ )
+ .into_any_element()
+ }
+
+ pub(super) fn render_results(&self, cx: &mut Context) -> AnyElement {
+ let rows = Rc::new(self.results_for());
+
+ v_flex()
+ .flex_1()
+ .min_h_0()
+ .relative()
+ .child(
+ div()
+ .min_h_0()
+ .flex_1()
+ .child(
+ uniform_list(
+ "sidebar-search",
+ rows.len(),
+ cx.processor(move |this, range, _window, cx| {
+ this.render_rows(range, rows.as_slice(), cx)
+ }),
+ )
+ .track_scroll(&self.search_scroll)
+ .h_full()
+ .px_2(),
+ )
+ .child(Scrollbar::vertical(&self.search_scroll)),
+ )
+ .into_any_element()
+ }
+
+ fn results_for(&self) -> Vec {
+ let mut rooms = Vec::new();
+ let mut groups = Vec::new();
+ let mut messages = Vec::new();
+
+ for result in &self.results {
+ match result {
+ SearchResult::Room(room) => rooms.push(room.clone()),
+ SearchResult::Group(group) => groups.push(group.clone()),
+ SearchResult::Message(hit) => messages.push(hit.clone()),
+ }
+ }
+
+ let mut rows = Vec::new();
+
+ rows.push(SidebarRow::Heading("Groups"));
+ if groups.is_empty() {
+ rows.push(SidebarRow::Empty("No groups found"));
+ } else {
+ rows.extend(groups.into_iter().map(|group| SidebarRow::Group { group }));
+ }
+
+ rows.push(SidebarRow::Heading("Rooms"));
+ if rooms.is_empty() {
+ rows.push(SidebarRow::Empty("No rooms found"));
+ } else {
+ rows.extend(rooms.into_iter().map(|room| SidebarRow::Room { room }));
+ }
+
+ rows.push(SidebarRow::Heading("Messages"));
+ if messages.is_empty() {
+ rows.push(SidebarRow::Empty("No messages found"));
+ } else {
+ rows.extend(messages.into_iter().map(|hit| SidebarRow::Message { hit }));
+ }
+
+ // Global profiles only appear once a global search was triggered
+ if self.global_finding || self.global_results.is_some() {
+ rows.push(SidebarRow::Heading("Global"));
+
+ match self.global_results.as_ref() {
+ Some(profiles) if profiles.is_empty() => {
+ rows.push(SidebarRow::Empty("No profiles found"));
+ }
+ Some(profiles) => {
+ rows.extend(profiles.iter().map(|public_key| SidebarRow::Profile {
+ public_key: *public_key,
+ }));
+ }
+ None => rows.push(SidebarRow::Empty("Searching...")),
+ }
+ }
+
+ rows
+ }
+}
diff --git a/crates/workspace/src/sidebar/utils.rs b/crates/workspace/src/sidebar/utils.rs
deleted file mode 100644
index 9781ba3f..00000000
--- a/crates/workspace/src/sidebar/utils.rs
+++ /dev/null
@@ -1,16 +0,0 @@
-use std::time::{SystemTime, UNIX_EPOCH};
-
-use gpui::SharedString;
-
-/// 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()
-}
diff --git a/docs/TODO.md b/docs/TODO.md
new file mode 100644
index 00000000..435b3697
--- /dev/null
+++ b/docs/TODO.md
@@ -0,0 +1,19 @@
+# TODO
+
+Open items from the search and lazy-loading work. Each entry notes the known
+tradeoff and where the fix would land.
+
+## nip29
+
+- [ ] **Incremental event handling for open groups.** Live events for an open
+ (active) group still trigger a full envelope reload and re-hydrate of the
+ whole group on every event (`GroupsRegistry::reload` in
+ `crates/nip29/src/lib.rs`). Closed groups already absorb events in O(1);
+ open groups could use the same incremental path, keeping the full reload
+ only as an occasional self-healing rebuild.
+- [ ] **Fetch admins' group lists from NIP-29 relays.** The fork-detection
+ query (`GroupsRegistry::sync_forks`) is unscoped, so group relays refuse it
+ by policy (`must have 'h', 'e' or 'a' tag`). It still works via generic and
+ gossip relays, but the deeper fix is a per-relay scoped subscription using
+ an `a` tag with `10009:` coordinates. The exact coordinate format
+ each relay accepts needs to be tested first.