From 81e421d1219a28a311feb2322b17b15f9131f8a8 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 11 Sep 2026 21:35:03 +0700 Subject: [PATCH] update ui --- crates/signed_state/src/inbox.rs | 23 +- crates/workspace/src/views/inbox.rs | 646 +++++++++++++--------- crates/workspace/src/views/sidebar/mod.rs | 36 +- docs/inbox-plan.md | 302 ++++++---- 4 files changed, 578 insertions(+), 429 deletions(-) diff --git a/crates/signed_state/src/inbox.rs b/crates/signed_state/src/inbox.rs index 950ba05..51ccf62 100644 --- a/crates/signed_state/src/inbox.rs +++ b/crates/signed_state/src/inbox.rs @@ -12,9 +12,7 @@ use crate::backend::Backend; pub struct Inbox { state: InboxReadState, /// Set once the stored state has been read for the current user. - state_loaded: bool, - /// Unread notification groups, published by the inbox panel for the sidebar badge. - pub unread_count: usize, + loaded: bool, } impl Inbox { @@ -25,16 +23,7 @@ impl Inbox { /// Whether the stored state has been read for the current user. pub fn is_loaded(&self) -> bool { - self.state_loaded - } - - /// Publish the unread count derived by the inbox panel. - pub fn set_unread_count(&mut self, count: usize, cx: &mut Context) { - if self.unread_count == count { - return; - } - self.unread_count = count; - cx.notify(); + self.loaded } /// Mark the events of one notification group read, then bound the id sets. @@ -83,8 +72,7 @@ impl Inbox { /// Load the stored state for current user. pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context) { self.state = InboxReadState::default(); - self.state_loaded = false; - self.unread_count = 0; + self.loaded = false; cx.notify(); let backend = Backend::global(cx); @@ -104,7 +92,7 @@ impl Inbox { Err(error) => log::warn!("failed to load inbox state: {error}"), } - this.state_loaded = true; + this.loaded = true; cx.notify(); })?; @@ -116,8 +104,7 @@ impl Inbox { /// Clear the state of the signed-out user. pub(crate) fn reset(&mut self, cx: &mut Context) { self.state = InboxReadState::default(); - self.state_loaded = false; - self.unread_count = 0; + self.loaded = false; cx.notify(); } diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs index fb7ee3e..d610a86 100644 --- a/crates/workspace/src/views/inbox.rs +++ b/crates/workspace/src/views/inbox.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; @@ -9,16 +10,16 @@ use gpui::{ AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState, Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div, list, px, }; -use gpui_component::scroll::ScrollableElement; +use gpui_component::button::{Button, ButtonVariants}; use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex}; -use nostr::prelude::{Event, EventId, Kind}; +use nostr::prelude::{Event, EventId, Kind, Timestamp}; use signed_core::{ - Announcement, COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, activity_subject, filters, + COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, activity_subject, filters, }; use signed_state::{ - Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox, + Backend, BackendEvent, RefreshGate, RefreshRequest, RepoListStore, query_inbox, }; -use signed_ui::{CountBadge, SegmentButton, UserAvatar}; +use signed_ui::CountBadge; use utils::relative_time; use super::{RepoItem, open_repo_item, open_repo_panel}; @@ -29,6 +30,36 @@ const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); /// Extra list rows measured above and below the visible area. const LIST_OVERDRAW: Pixels = px(400.); +/// A repository's slice of the inbox: its notification groups and own activity. +struct InboxSection { + /// Repository the section groups, `None` for items without one. + address: Option, + /// Number of notification groups with an unread event. + unread: usize, + /// Notification and activity rows, newest first. + entries: Vec, + /// Timestamp of the newest entry, used to order the sections. + latest: Timestamp, +} + +/// One row inside a repository section, as an index into the inbox's own lists. +#[derive(Clone, Copy)] +enum InboxEntry { + /// Index into the notification groups. + Notification(usize), + /// Index into the user's own activity. + Activity(usize), +} + +/// One row of the flattened inbox: a repository header, one of its entries, or +/// the empty state of a repository without any. +#[derive(Clone, Copy)] +enum InboxRow { + Repo(usize), + Entry(usize, usize), + Empty, +} + pub struct InboxView { focus_handle: FocusHandle, dock_area: WeakEntity, @@ -36,6 +67,10 @@ pub struct InboxView { notifications: Arc>, /// The user's own recent git activity, newest first. activity: Arc>, + /// The notification and activity lists grouped by repository, newest first. + sections: Arc>, + /// The flattened repository headers and rows of the list. + rows: Arc>, /// Number of non-archived groups with an unread event. unread_count: usize, /// Copy of the global read state the current lists were derived with. @@ -43,10 +78,7 @@ pub struct InboxView { /// Set once the global state has been read for the current user. state_loaded: bool, refresh: RefreshGate, - /// Virtual-list state of the notification list. - notifications_list: ListState, - /// Virtual-list state of the activity list. - activity_list: ListState, + list: ListState, tasks: Vec>>, _subscriptions: Vec, } @@ -55,22 +87,10 @@ impl InboxView { pub fn new(dock_area: WeakEntity, cx: &mut Context) -> Self { let backend = Backend::global(cx); let inbox = backend.read(cx).inbox(); + let repos = RepoListStore::global(cx); let weak = cx.entity().downgrade(); - let notifications_list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW); - let activity_list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW); - - for list_state in [¬ifications_list, &activity_list] { - let weak = weak.clone(); - list_state.set_scroll_handler(move |_, _, cx| { - let weak = weak.clone(); - cx.defer(move |cx| { - let _ = weak.update(cx, |_, cx| cx.notify()); - }); - }); - } - - // Drive the lists from the global inbox state and from backend events. + let list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW); let mut subscriptions = vec![]; subscriptions.push(cx.observe(&inbox, |this, _inbox, cx| { @@ -81,7 +101,14 @@ impl InboxView { this.handle_backend_event(event, cx); })); - // Derive the lists once the panel exists. + // Rebuild when the user's own repositories load or change, + // so a repository without any activity still gets an empty section. + subscriptions.push(cx.observe(&repos, |this, _repos, cx| { + this.rebuild(cx); + cx.notify(); + })); + + // Derive the sections once the panel exists. cx.defer(move |cx| { if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) { log::warn!("inbox dropped before bootstrap could run: {error}"); @@ -93,12 +120,13 @@ impl InboxView { dock_area, notifications: Arc::new(Vec::new()), activity: Arc::new(Vec::new()), + sections: Arc::new(Vec::new()), + rows: Arc::new(Vec::new()), unread_count: 0, state: InboxReadState::default(), state_loaded: false, refresh: RefreshGate::default(), - notifications_list, - activity_list, + list, tasks: vec![], _subscriptions: subscriptions, } @@ -123,8 +151,10 @@ impl InboxView { }; if !loaded { - let was_present = - self.state_loaded || !self.notifications.is_empty() || !self.activity.is_empty(); + let was_present = self.state_loaded + || !self.notifications.is_empty() + || !self.activity.is_empty() + || !self.sections.is_empty(); self.clear(); if was_present { cx.notify(); @@ -141,13 +171,12 @@ impl InboxView { if self.state != state { self.state = state; - self.regroup(); - self.publish_unread_count(cx); + self.regroup(cx); cx.notify(); } } - /// Handle a backend event that can change the derived lists. + /// Handle a backend event that can change the derived sections. fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context) { match event { BackendEvent::NostrUpdate(updates) => { @@ -228,7 +257,7 @@ impl InboxView { this.notifications = Arc::new(notifications); this.activity = Arc::new(activity); this.unread_count = unread_count; - this.publish_unread_count(cx); + this.rebuild(cx); cx.notify(); this.refresh.finish() @@ -243,7 +272,7 @@ impl InboxView { } /// Recompute the unread and archived flags from the current state. - fn regroup(&mut self) { + fn regroup(&mut self, cx: &mut Context) { let mut items = (*self.notifications).clone(); for item in items.iter_mut() { @@ -252,19 +281,46 @@ impl InboxView { self.unread_count = items.iter().filter(|item| item.is_unread()).count(); self.notifications = Arc::new(items); + self.rebuild(cx); } - /// Publish the derived unread count for the sidebar badge. - fn publish_unread_count(&self, cx: &mut Context) { - let count = self.unread_count; - let inbox = Backend::global(cx).read(cx).inbox(); - inbox.update(cx, |inbox, cx| inbox.set_unread_count(count, cx)); + /// Regroup the current lists by repository and flatten them into rows. + fn rebuild(&mut self, cx: &mut Context) { + let backend = Backend::global(cx); + let repo_list = RepoListStore::global(cx); + let mut sections = group_sections(&self.notifications, &self.activity); + + if let Some(me) = backend.read(cx).current_user() { + for announcement in repo_list.read(cx).announcements_of(&me) { + let address = announcement.addr(); + let known = sections + .iter() + .any(|section| section.address.as_ref() == Some(&address)); + + if !known { + sections.push(InboxSection { + address: Some(address), + unread: 0, + entries: Vec::new(), + latest: Timestamp::default(), + }); + } + } + } + + sections.sort_by_key(|section| std::cmp::Reverse(section.latest)); + + let rows = flatten_rows(§ions); + self.sections = Arc::new(sections); + self.rows = Arc::new(rows); } /// Forget everything derived for the current user. fn clear(&mut self) { self.notifications = Arc::new(Vec::new()); self.activity = Arc::new(Vec::new()); + self.sections = Arc::new(Vec::new()); + self.rows = Arc::new(Vec::new()); self.unread_count = 0; self.state = InboxReadState::default(); self.state_loaded = false; @@ -280,147 +336,205 @@ impl InboxView { .collect() } - fn section(&self, header: impl IntoElement, body: impl IntoElement, cx: &App) -> AnyElement { - v_flex() - .w_full() - .flex_1() - .min_h_0() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .overflow_hidden() - .child( - div() - .px_3() - .py_2() - .bg(cx.theme().muted.opacity(0.5)) - .border_b_1() - .border_color(cx.theme().border) - .child(header), - ) - .child(body) - .into_any_element() - } + fn render_entry(&self, ix: usize, cx: &App) -> AnyElement { + let Some(row) = self.rows.get(ix) else { + return div().into_any_element(); + }; - fn render_inbox_panel( - &self, - unread: usize, - notifications: Arc>, - visible: Vec, - cx: &mut Context, - ) -> AnyElement { - let header = h_flex() - .w_full() - .gap_2() - .child(Icon::new(IconName::Inbox).small()) - .child( - div() - .text_sm() - .font_semibold() - .child(SharedString::from("Inbox")), - ) - .when(unread > 0, |this| this.child(CountBadge::new(unread))) - .child(div().flex_1()) - .child( - SegmentButton::new("mark-all-read", "Mark all read") - .on_click(cx.listener(|this, _event, _window, cx| this.mark_all_read(cx))), - ); - - let body = if visible.is_empty() { - empty_state(IconName::Inbox, "You're all caught up.", cx) - } else { - let list_state = self.notifications_list.clone(); - let dock_area = self.dock_area.clone(); - let rows = list(list_state.clone(), move |ix, _window, cx| { - let Some(item) = visible.get(ix).and_then(|&ix| notifications.get(ix)) else { + match *row { + InboxRow::Empty => empty_section_row(cx), + InboxRow::Repo(section_ix) => { + let Some(section) = self.sections.get(section_ix) else { + return div().into_any_element(); + }; + repo_header(section, cx) + } + InboxRow::Entry(section_ix, entry_ix) => { + let Some(section) = self.sections.get(section_ix) else { return div().into_any_element(); }; - let root = item.root; - let kind = item.root_kind; - let address = item.address.clone(); - let dock_area = dock_area.clone(); - - notification_row("inbox-row", ix, item, cx) - .on_click(move |_, window, cx| { - open_item(&dock_area, root, kind, address.clone(), window, cx); - }) - .into_any_element() - }) - .size_full() - .min_h_0(); - - div() - .relative() - .flex_1() - .min_h_0() - .child(rows) - .vertical_scrollbar(&list_state) - .into_any_element() - }; - - self.section(header, body, cx) - } - - fn render_activity_panel( - &self, - activity: Arc>, - cx: &mut Context, - ) -> AnyElement { - let header = h_flex() - .w_full() - .gap_2() - .child(Icon::new(CustomIconName::Recent).small()) - .child( - div() - .text_sm() - .font_semibold() - .child(SharedString::from("Continue where you left off")), - ); - - let body = if activity.is_empty() { - empty_state(CustomIconName::Recent, "No recent activity.", cx) - } else { - let list_state = self.activity_list.clone(); - let rows = list(list_state.clone(), move |ix, _window, cx| { - let Some(event) = activity.get(ix) else { + let Some(entry) = section.entries.get(entry_ix) else { return div().into_any_element(); }; - activity_row(ix, event, cx) - }) - .size_full() - .min_h_0(); - div() - .relative() - .flex_1() - .min_h_0() - .child(rows) - .vertical_scrollbar(&list_state) - .into_any_element() - }; + match *entry { + InboxEntry::Notification(item_ix) => { + let Some(item) = self.notifications.get(item_ix) else { + return div().into_any_element(); + }; - self.section(header, body, cx) + let root = item.root; + let kind = item.root_kind; + let address = section.address.clone(); + let dock_area = self.dock_area.clone(); + + notification_row("inbox-row", ix, item, cx) + .on_click(move |_, window, cx| { + open_item(&dock_area, root, kind, address.clone(), window, cx); + }) + .into_any_element() + } + InboxEntry::Activity(event_ix) => { + let Some(event) = self.activity.get(event_ix) else { + return div().into_any_element(); + }; + activity_row(ix, event, cx) + } + } + } + } } } +/// Group the notification groups and own activity into one section per repository. +fn group_sections(notifications: &[InboxItem], activity: &[Event]) -> Vec { + let mut by_repo: HashMap, InboxSection> = HashMap::new(); + + for (ix, item) in notifications.iter().enumerate() { + if item.archived { + continue; + } + + let address = item.address.clone(); + let section = by_repo + .entry(address.clone()) + .or_insert_with(move || InboxSection { + address, + unread: 0, + entries: Vec::new(), + latest: Timestamp::default(), + }); + + if item.is_unread() { + section.unread += 1; + } + + section.latest = section.latest.max(item.latest_activity()); + section.entries.push(InboxEntry::Notification(ix)); + } + + for (ix, event) in activity.iter().enumerate() { + let address = repo_address(event); + let section = by_repo + .entry(address.clone()) + .or_insert_with(move || InboxSection { + address, + unread: 0, + entries: Vec::new(), + latest: Timestamp::default(), + }); + + section.latest = section.latest.max(event.created_at); + section.entries.push(InboxEntry::Activity(ix)); + } + + let mut sections: Vec = by_repo.into_values().collect(); + + for section in &mut sections { + section.entries.sort_by(|a, b| { + entry_time(b, notifications, activity).cmp(&entry_time(a, notifications, activity)) + }); + } + + sections.sort_by_key(|section| std::cmp::Reverse(section.latest)); + sections +} + +/// Timestamp an entry is ordered by. +fn entry_time(entry: &InboxEntry, notifications: &[InboxItem], activity: &[Event]) -> Timestamp { + match entry { + InboxEntry::Notification(ix) => notifications + .get(*ix) + .map(InboxItem::latest_activity) + .unwrap_or_default(), + InboxEntry::Activity(ix) => activity + .get(*ix) + .map(|event| event.created_at) + .unwrap_or_default(), + } +} + +/// Flatten the sections into the list of repository headers and their rows. +fn flatten_rows(sections: &[InboxSection]) -> Vec { + let mut rows = Vec::new(); + + for (section_ix, section) in sections.iter().enumerate() { + rows.push(InboxRow::Repo(section_ix)); + + if section.entries.is_empty() { + rows.push(InboxRow::Empty); + continue; + } + + rows.extend( + (0..section.entries.len()).map(|entry_ix| InboxRow::Entry(section_ix, entry_ix)), + ); + } + + rows +} + +/// Repository an activity event belongs to, from its `a` tag. +fn repo_address(event: &Event) -> Option { + event + .tags + .coordinates() + .find(|address| address.kind == Kind::GitRepoAnnouncement) +} + /// Display name of the repository at `addr`, from the announcement store. fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option { + let repo_list = RepoListStore::global(cx); let addr = addr?; - RepoListStore::global(cx) + repo_list .read(cx) .announcements .iter() .find(|announcement| announcement.addr() == *addr) - .map(display_name) + .map(|announcement| announcement.name().map(SharedString::from)) } -/// Open the repository of a notification group, and the issue or pull request -/// detail when the group's root is one. -/// -/// The group carries only the repository coordinate, so the announcement is -/// looked up in the local list. A group whose repository is not known locally -/// opens nothing. +/// Header of a repository section. +fn repo_header(section: &InboxSection, cx: &App) -> AnyElement { + let name = + repo_name(section.address.as_ref(), cx).unwrap_or_else(|| SharedString::from("Untitled")); + + h_flex() + .h_12() + .w_full() + .gap_1() + .items_center() + .child( + div() + .min_w_0() + .text_sm() + .whitespace_nowrap() + .text_ellipsis() + .child(name), + ) + .when(section.unread > 0, |this| { + this.child(CountBadge::new(section.unread)) + }) + .into_any_element() +} + +/// Placeholder under a repository header that has nothing to show. +fn empty_section_row(cx: &App) -> AnyElement { + h_flex() + .h_12() + .w_full() + .px_3() + .text_xs() + .text_color(cx.theme().muted_foreground) + .bg(cx.theme().secondary) + .rounded(cx.theme().radius) + .child(SharedString::from("No activity yet.")) + .into_any_element() +} + +/// Open the repository of a notification group, +/// and the issue or pull request detail when the group's root is one. fn open_item( dock_area: &WeakEntity, root: EventId, @@ -458,125 +572,110 @@ fn open_item( open_repo_item(dock_area, store, item, window, cx); } -/// Leading row of a notification group, newest event first. -/// -/// `prefix` scopes the row's element id, so the inbox list and the Unread / -/// Archived list do not collide when both are on screen. fn notification_row(prefix: &'static str, ix: usize, item: &InboxItem, cx: &App) -> Stateful
{ let Some(newest) = item.events.first() else { return div().id((prefix, ix)); }; - let profiles = ProfileStore::global(cx); - let profile = profiles.read(cx).get(&newest.pubkey); let kind = item.root_kind.unwrap_or(newest.kind); let subject = SharedString::from(activity_subject(newest)); - let repo = repo_name(item.address.as_ref(), cx); let age = relative_time(item.latest_activity()); let unread = item.is_unread(); - h_flex() + v_flex() .id((prefix, ix)) + .h_16() .w_full() - .gap_3() .px_3() - .py_2() - .items_center() - .rounded(cx.theme().radius) - .hover(|this| this.bg(cx.theme().list_hover)) - .child(UserAvatar::new(profile.name()).picture(profile.picture())) - .child(div().flex_shrink_0().child(kind_icon(kind))) + .justify_center() + .bg(cx.theme().secondary) + .hover(|this| this.bg(cx.theme().secondary_hover)) .child( - v_flex() - .flex_1() - .min_w_0() - .gap_0p5() + h_flex() + .gap_2() + .text_sm() + .child( + h_flex() + .size_6() + .flex_shrink_0() + .items_center() + .justify_center() + .child(kind_icon(kind)), + ) .child( div() - .text_sm() - .when(unread, |this| this.font_semibold()) + .min_w_0() .whitespace_nowrap() .text_ellipsis() .child(subject), ) - .child( - h_flex() - .gap_1() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(kind_label(kind))) - .when_some(repo, |this, repo| { - this.child(SharedString::from("on")).child(repo) - }) - .child(SharedString::from("·")) - .child(SharedString::from(age)), - ), + .child(div().flex_1()) + .when(unread, |this| { + this.child( + div() + .flex_shrink_0() + .size_2() + .rounded_full() + .bg(cx.theme().primary), + ) + }), + ) + .child( + h_flex().gap_2().child(div().w_6().flex_shrink_0()).child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(kind_label(kind))) + .child("·") + .child(SharedString::from(age)), + ), ) - .when(unread, |this| { - this.child( - div() - .flex_shrink_0() - .size(px(8.)) - .rounded(px(4.)) - .bg(cx.theme().primary), - ) - }) } -/// One row of the user's own recent git activity. fn activity_row(ix: usize, event: &Event, cx: &App) -> AnyElement { let kind = event.kind; let subject = SharedString::from(activity_subject(event)); - let repo = repo_name(event.tags.coordinates().next().as_ref(), cx); let age = relative_time(event.created_at); - h_flex() + v_flex() .id(("activity-row", ix)) + .h_16() .w_full() - .gap_3() .px_3() - .py_2() - .items_center() - .rounded(cx.theme().radius) - .hover(|this| this.bg(cx.theme().list_hover)) - .child(div().flex_shrink_0().child(kind_icon(kind))) + .justify_center() + .bg(cx.theme().secondary) + .hover(|this| this.bg(cx.theme().secondary_hover)) .child( - v_flex() - .flex_1() - .min_w_0() - .gap_0p5() - .child( - div() - .text_sm() - .whitespace_nowrap() - .text_ellipsis() - .child(subject), - ) + h_flex() + .gap_2() + .text_sm() + .whitespace_nowrap() + .text_ellipsis() .child( h_flex() - .gap_1() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(kind_label(kind))) - .when_some(repo, |this, repo| { - this.child(SharedString::from("on")).child(repo) - }) - .child(SharedString::from("·")) - .child(SharedString::from(age)), - ), + .size_6() + .flex_shrink_0() + .items_center() + .justify_center() + .child(kind_icon(kind)), + ) + .child(subject), + ) + .child( + h_flex().gap_2().child(div().w_6().flex_shrink_0()).child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(kind_label(kind))) + .child("·") + .child(SharedString::from(age)), + ), ) .into_any_element() } -/// Name to show for a repository, its `name` tag or its id. -fn display_name(announcement: &Announcement) -> SharedString { - announcement - .name - .as_deref() - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from(announcement.id.clone())) -} - /// Leading icon for a notification or activity kind. fn kind_icon(kind: Kind) -> Icon { if kind == COVER_NOTE_KIND { @@ -589,7 +688,7 @@ fn kind_icon(kind: Kind) -> Icon { Icon::new(CustomIconName::GitPullRequest) } Kind::GitPatch => Icon::new(CustomIconName::GitCommit), - Kind::Comment => Icon::new(IconName::FileText), + Kind::Comment => Icon::new(IconName::File), Kind::GitStatusOpen | Kind::GitStatusApplied | Kind::GitStatusClosed @@ -665,31 +764,54 @@ impl Focusable for InboxView { impl Render for InboxView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let unread = self.unread_count; - let notifications = self.notifications.clone(); - let activity = self.activity.clone(); + let rows = self.rows.clone(); - let visible: Vec = notifications - .iter() - .enumerate() - .filter(|(_, item)| !item.archived) - .map(|(ix, _)| ix) - .collect(); - - if self.notifications_list.item_count() != visible.len() { - self.notifications_list.reset(visible.len()); - } - - if self.activity_list.item_count() != activity.len() { - self.activity_list.reset(activity.len()); + if self.list.item_count() != rows.len() { + self.list.reset(rows.len()); } v_flex() - .size_full() .image_cache(gpui::retain_all("inbox")) - .gap_4() - .p_4() - .child(self.render_inbox_panel(unread, notifications, visible, cx)) - .child(self.render_activity_panel(activity, cx)) + .size_full() + .gap_2() + .child( + h_flex() + .px_4() + .h_12() + .w_full() + .gap_1() + .items_center() + .child( + div() + .text_sm() + .font_semibold() + .child(SharedString::from("Inbox")), + ) + .child(div().flex_1()) + .child( + Button::new("mark-all") + .icon(IconName::CircleCheck) + .secondary() + .tooltip("Mark all as read") + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.mark_all_read(cx); + })), + ), + ) + .child(div().relative().flex_1().min_h_0().px_4().pb_4().when_else( + rows.is_empty(), + |this| this.child(empty_state(IconName::Inbox, "You're all caught up.", cx)), + |this| { + this.child( + list( + self.list.clone(), + cx.processor(|this, ix, _window, cx| this.render_entry(ix, cx)), + ) + .size_full() + .min_h_0() + .into_any_element(), + ) + }, + )) } } diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 502bf10..303471f 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -21,7 +21,7 @@ use signed_core::{Announcement, RepoAddr, identifier_from_name}; use signed_state::{ Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore, }; -use signed_ui::{CountBadge, NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers}; +use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers}; use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel}; @@ -39,8 +39,6 @@ pub struct SidebarPanel { dock_area: WeakEntity, inbox: Option>, explore: Option>, - /// Unread notification groups, shown as the inbox nav item's badge. - unread: usize, /// Artwork for the sign-in screen. banner: SharedString, /// The signed-in user's announced repositories, newest first. @@ -74,6 +72,7 @@ impl SidebarPanel { if signer_required { this.banner = pick_banner(); + cx.notify(); } if this.refresh(cx) || signer_required { @@ -102,44 +101,20 @@ impl SidebarPanel { } })); - // The inbox nav item shows the unread notification count as a badge. - let inbox = backend.read(cx).inbox(); - subscriptions.push(cx.observe(&inbox, |this, inbox, cx| { - let unread = inbox.read(cx).unread_count; - if this.unread != unread { - this.unread = unread; - cx.notify(); - } - })); - - let mut this = Self { + Self { focus_handle: cx.focus_handle(), dock_area, inbox: None, explore: None, - unread: inbox.read(cx).unread_count, banner: pick_banner(), announcements: Arc::new(Vec::new()), local_repos: Arc::new(Vec::new()), scanning: false, unpushed: HashMap::new(), _subscriptions: subscriptions, - }; - - // Seed the snapshot right away. - // The stores may already hold data from before the panel opened. - // The first render must not depend on a later store update. - this.refresh(cx); - - this + } } - /// The sidebar renders only its own derived fields, never the stores - /// directly. Because the panel is a cached view, a store update alone does - /// not re-render it: the observers notify this panel, which re-runs - /// `render` over the fresh snapshot. - /// - /// Returns `true` when a rendered field changed. fn refresh(&mut self, cx: &mut Context) -> bool { let backend = Backend::global(cx); let user = backend.read(cx).current_user(); @@ -644,9 +619,6 @@ impl Render for SidebarPanel { .justify_start() .child( NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small()) - .when(self.unread > 0, |this| { - this.suffix(CountBadge::new(self.unread)) - }) .on_click(cx.listener(|this, _ev, window, cx| { this.open_inbox(window, cx) })), diff --git a/docs/inbox-plan.md b/docs/inbox-plan.md index 4e32f7d..5a7fae8 100644 --- a/docs/inbox-plan.md +++ b/docs/inbox-plan.md @@ -6,13 +6,15 @@ Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for > page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `` when > an account is active, and that home screen is the inbox. -> **Status.** Phases 0-4 are implemented and green on `feat/inbox`: +> **Status.** Phases 0-4 are implemented and green on `feat/inbox`, then the screen was redesigned to +> group notifications and activity **by repository** (see the repository-grouping note in §7). > `cargo test -p signed_core` (68), `cargo test -p signed_state` (24), -> `cargo test -p workspace` (7), `cargo clippy -p workspace --all-targets` clean, -> `cargo check --workspace --all-targets` succeeds. -> Phase 5 is not started. This document reflects the implementation as it stands, including the -> Phase 1 refactors, the §4.3 split of the inbox into a thin global `Inbox` and a panel-owned -> derivation, the Phase 3 bottom-dock sub-views, and the Phase 4 click-through. +> `cargo test -p workspace` (7), `cargo test -p dock` (1), `cargo clippy -p workspace --all-targets` +> clean, `cargo check --workspace --all-targets` succeeds. +> Phase 5 is not started. This document reflects the implementation as it stands: the Phase 1 +> refactors, the §4.3 split of the inbox into a thin global `Inbox` and a panel-owned derivation, the +> Phase 4 click-through, and the repository-grouped list. The Phase 3 bottom-dock sub-views were +> removed before the redesign; their implementation notes in §7 are historical. ## 1. What the GitWorkshop home screen is @@ -55,39 +57,48 @@ Data hooks: | Priority | Section | Notes | |---|---|---| -| **P0** | Inbox panel | Activity directed at you, grouped by thread root; unread badge; mark all read; all groups shown | -| **P0** | Continue where you left off | Your own recent git activity, newest first | -| **P1** | Unread / Archived sub-views | Open as **bottom-dock panels**, not tabs inside the inbox panel | +| **P0** | Inbox panel | Activity directed at you and your own activity, **grouped by repository**; unread badge; mark all read; all groups shown | | **P1** | Click-through | Open the repo panel at the relevant PR/issue | | **P2 (defer)** | Standalone notifications page, NIP-65 relay discovery, pagination | Web-app concerns | -| **Out of scope** | Greeting header, my repositories, followed repositories, private repositories, pinned repositories | Not needed in Signed | +| **Out of scope** | Greeting header, my repositories, followed repositories, private repositories, pinned repositories, Unread/Archived sub-views | Not needed in Signed | Notes: - There is **no greeting header**. The screen starts with the inbox panel. - There is **no My repositories column**. The sidebar already lists the signed-in user's repositories, so the inbox is a single column. -- Unread and Archived are separate panels opened in the bottom dock, not tabs in the inbox panel. +- The Unread/Archived sub-view panels were removed: the panel is a single repository-grouped list instead. ## 3. The Signed screen -`InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item (currently a -placeholder that opens Explore). It is two flexible bordered cards, each a virtual list: +`InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item. It is one bordered +card holding a single virtual list. Every row is either a **repository header** or one of that +repository's **notifications / own activity**, newest first: +The sections are **all of the user's own repositories**, seeded from `RepoListStore`, plus any other +repository that has notifications or activity. Owned repositories with nothing to show render an +empty state ("No activity yet.") under their header, and sort after the ones with activity (newest +announcement first). Items with no repository address fall into a single "Other repository" section. ``` +-------------------------------------------------------------------------+ -| Inbox (3 unread) [Unread] [Archived] [Mark all read] | -| [avatar] issue opened on you/repo 2m (scroll) | -| [avatar] commented on "Fix parser" 1h | -| [avatar] PR update on you/repo 3h | -+-------------------------------------------------------------------------+ -| Continue where you left off (scroll)| -| [icon] "Fix parser bug" you/repo opened 3d | -| [icon] "Add retry" you/repo PR 5d | -+-------------------------------------------------------------------------+ -| bottom dock: Unread or Archived list (opened by the header buttons) | +| Inbox (3 unread) [Mark all read] | +|-------------------------------------------------------------------------| +| [repo] you/repo-a (2) | +| [avatar] issue opened issue 2m | +| [avatar] commented on "..." comment 1h | +| [icon] "Add retry" patch 3d | +|-------------------------------------------------------------------------| +| [repo] you/repo-b | +| No activity yet. | +|-------------------------------------------------------------------------| +| [repo] you/repo-c | +| No activity yet. | +-------------------------------------------------------------------------+ ``` +The sections are the repositories that actually have notifications or activity, ordered by their +newest row. A repository the user owns but that has no items is not shown. Items with no repository +address fall into a single "Other repository" section. + ## 4. Data layer ### 4.1 `signed_core`: pure logic @@ -265,8 +276,8 @@ The inbox is split in two, because the expensive derivation is only needed while open. **`Inbox`** is a child `Entity` owned by `Backend` (`inbox: Entity`) and is -deliberately thin: it owns only the read/archive state that must outlive the panel, the NIP-78 -load/save, and the unread count the sidebar badge reads. +deliberately thin: it owns only the read/archive state that must outlive the panel and the NIP-78 +load/save. ```rust // backend.rs @@ -280,14 +291,11 @@ pub struct Backend { pub struct Inbox { state: InboxReadState, state_loaded: bool, - /// Published by the inbox panel for the sidebar badge. - pub unread_count: usize, } impl Inbox { pub fn state(&self) -> &InboxReadState; pub fn is_loaded(&self) -> bool; - pub fn set_unread_count(&mut self, count: usize, cx: &mut Context); pub fn mark_read(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx); pub fn mark_archived(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx); pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx); @@ -299,37 +307,44 @@ impl Inbox { **The panel owns the derivation.** `InboxView` itself holds the derived lists, the copy of the read state they were computed with, and the refresh coalescing. There is no separate store entity: the panel is the only consumer, so an `Entity` would add an `update` indirection and a -forwarding subscription without buying any sharing. +forwarding subscription without buying any sharing. The panel's own `unread_count` feeds its header +badge only; there is no global count and no sidebar badge. ```rust pub struct InboxView { focus_handle: FocusHandle, + dock_area: WeakEntity, notifications: Arc>, activity: Arc>, + sections: Arc>, // grouped by repository + rows: Arc>, // flattened list unread_count: usize, state: InboxReadState, state_loaded: bool, refresh: RefreshGate, + list: ListState, _subscriptions: Vec, } impl InboxView { - pub fn new(cx: &mut Context) -> Self; // cx.defer(… sync_state) + pub fn new(dock_area: WeakEntity, cx: &mut Context); // cx.defer(… sync_state) pub fn sync_state(&mut self, cx); // observes the global Inbox - pub fn mark_read(&mut self, root: EventId, cx); // Phase 3 - pub fn mark_archived(&mut self, root: EventId, cx); // Phase 3 pub fn mark_all_read(&mut self, cx); fn handle_backend_event(&mut self, event: &BackendEvent, cx); fn refresh(&mut self, cx); fn run_refresh(&mut self, cx); + fn regroup(&mut self, cx); // re-apply read state + fn rebuild(&mut self, cx); // group by repository, seed owned, flatten + fn clear(&mut self); } ``` -The panel owns the two subscriptions that carry logic: it observes the global `Inbox` -(`InboxView::sync_state`) and subscribes to `Backend` (`InboxView::handle_backend_event`). -Re-rendering needs no subscription: GPUI invalidates a window for every entity it read during -render, so the panel tracks `RepoListStore` and `ProfileStore` just by reading them in `render`. -The panel writes back to `Backend` only to publish the unread count for the badge. +The panel owns three subscriptions that carry logic: it observes the global `Inbox` +(`InboxView::sync_state`), subscribes to `Backend` (`InboxView::handle_backend_event`), and observes +`RepoListStore` to rebuild when the user's own repositories load. Re-rendering itself needs no +subscription: GPUI invalidates a window for every entity it read during render, so the panel tracks +`RepoListStore` and `ProfileStore` just by reading them in `render`. The panel does not write back +to the global. **`signed_state::query_inbox`.** The database work stays in `signed_state`, so the UI crate never queries LMDB directly. `query_inbox` returns the grouped notifications, the user's own git @@ -348,16 +363,17 @@ pub async fn query_inbox( derived lists live only as long as the panel. Nothing is wired from the `desktop` crate and `signed_state::init` gains no parameters. -**Badge trade-off.** The unread count is derived by the panel, so the sidebar badge is only current -after the inbox has been opened once in the session. Keeping it always live would require the -expensive derivation to run globally, which is exactly what this split avoids. +**No sidebar badge.** The sidebar's inbox nav item has no unread suffix (an earlier global count +derivation was removed with it). The unread count lives entirely in the panel, which shows it in +its header and per repository section. The trade-off is that the count is only current while the +panel is open, which is acceptable now that nothing outside it displays one. -The dependency chain is `Backend` → `Inbox` and `InboxView` → `query_inbox`; the panel reaches back -only to publish the unread count. +The dependency chain is `Backend` → `Inbox` and `InboxView` → `query_inbox`. -`Backend` does not funnel its events through the inbox: the panel subscribes to `Backend` directly. -`BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: `CheckoutsStore` -and `SidebarPanel` consume them. They no longer drive the inbox. +`Backend` owns the inbox lifecycle (`sync_inbox`); the panel subscribes to `Backend` directly for +its lists. `BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: +`CheckoutsStore` and `SidebarPanel` consume them. They no longer drive the inbox's activation +directly. `InboxView::handle_backend_event` refreshes on: @@ -412,7 +428,7 @@ lists and in-flight refresh when it sees the unloaded state, then refreshes once ```rust pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context) { - // state = default; state_loaded = false; unread_count = 0; cx.notify(); + // state = default; state_loaded = false; cx.notify(); // spawn load_state(client, me), then set state and state_loaded = true } ``` @@ -437,12 +453,12 @@ read, not a wait on the network; see the note below. their `K` tag is a git kind; sort newest first. - Cross back to the main thread: guard on `Backend::global(cx).read(cx).current_user() == Some(me)`; if the signer changed while the query ran, `refresh.abort()` instead of applying, so a - previous user's results never land. Then set `notifications`, `activity`, `unread_count`, publish - the unread count to the global `Inbox`, `cx.notify()`, `refresh.finish()`. + previous user's results never land. Then set `notifications`, `activity`, `unread_count`, rebuild the + repository sections (`rebuild`), `cx.notify()`, `refresh.finish()`. `InboxView::sync_state` reacts to the global `Inbox`: while the state is not loaded it clears the lists, on the first load it runs the initial refresh, and on a state change (a mark action) it -re-derives the flags (`InboxItem::apply_state`) and publishes the new unread count. +re-derives the flags (`InboxItem::apply_state`). **Fetch vs. the immediate query.** `subscribe_bootstrap` / `connect_repo_relays` return immediately, so the query that follows them reads the local cache rather than waiting for the relays. That is @@ -452,11 +468,10 @@ timing: received events are written to LMDB and surfaced as `ClientNotification: `Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the store refreshes. This was reviewed and left as-is. -**Actions**: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()` live on the panel, which -passes the group and every known notification event to the global `Inbox`. The global marks the -group, advances the cutoffs against *all* notification events to bound the id sets, saves the state -to LMDB (signed with a fresh random key, see 4.2), and notifies. The panel then re-derives and -publishes the unread count. +**Actions**: `mark_all_read()` lives on the panel, which passes every known notification event to the +global `Inbox`. The global marks them, advances the cutoffs against *all* notification events to bound +the id sets, saves the state to LMDB (signed with a fresh random key, see 4.2), and notifies. The +panel then re-derives and publishes the unread count. **Repository names need no new store**: `RepoListStore` already holds every announcement and `repo_name` resolves an address to a display name. @@ -470,78 +485,77 @@ publishes the unread count. ### 5.1 `InboxView` center panel -New `crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`. -It owns the derived lists directly, so `cx.notify()` from an update re-renders it. The panel is a -column of two flexible bordered cards (`flex_1`, `min_h_0`), each with a header bar and a scrolling -body. Each body is a `gpui::list` virtual list (`ListState` + `ListAlignment::Top`, 400px -overdraw) with a `vertical_scrollbar`; the panel itself does not scroll, so both lists get a -definite viewport height. The list counts are reset from `render` whenever the rendered item count -changes. Row ids are prefixed (`("inbox-row", ix)` / `("activity-row", ix)`) so the two lists do not -collide. +`crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`. +It owns the derived lists directly, so `cx.notify()` from an update re-renders it. The panel is one +bordered card (`flex_1`, `min_h_0`) with a header bar and a scrolling body. The body is a single +`gpui::list` virtual list (`ListState` + `ListAlignment::Top`, 400px overdraw) with a +`vertical_scrollbar`; the panel itself does not scroll, so the list gets a definite viewport height. +The list count is reset from `render` whenever the rendered row count changes. -- **Inbox card**: header with the unread count badge, the **Unread** and **Archived** sub-view buttons - (Phase 3) and **Mark all read**; then every non-archived notification item. Rows show the actor - avatar, a kind icon, the subject, the kind label, the repo name, a relative time, and an unread dot - (the subject is semibold while unread). Empty state: "You're all caught up." with - `IconName::Inbox`. -- **Continue where you left off**: every event in the activity list, each row a kind icon, subject, - kind label, repo name, and relative time. +- **Header**: the unread count badge and **Mark all read**. +- **Body**: the flattened repository-grouped rows. A repository header is a muted bar with a git icon, + the repository name (or "Other repository" when the address is unknown) and its unread badge. Rows + under it show the actor avatar, a kind icon, the subject, the kind label, a relative time, and an + unread dot (the subject is semibold while unread). A repository with nothing to show renders + "No activity yet."; the panel-level "You're all caught up." empty state appears only when there are + no sections at all (no owned repositories and no items). No greeting header, and no **My repositories** column - the sidebar already lists the user's -repositories. The **Unread** and **Archived** header buttons open the sub-views of 5.2 in the bottom -dock. +repositories. -### 5.2 Unread / Archived as bottom-dock panels +### 5.2 Grouping by repository -Add a bottom-panel helper next to `add_center_panel` in `crates/dock/src/lib.rs`: +The grouping is panel-owned derivation, done once per data change in `InboxView::rebuild` (called +from `run_refresh` and `regroup`), never per frame: ```rust -/// Add an already-wrapped panel handle to the bottom dock of `area`. -pub fn add_bottom_panel( - area: &mut DockArea, - panel: Arc, - window: &mut Window, - cx: &mut Context, -) { - area.add_panel_view(panel, DockPlacement::Bottom, None, window, cx); +struct InboxSection { + address: Option, // repository, None for items without one + unread: usize, // unread notification groups + entries: Vec, // newest first + latest: Timestamp, // orders the sections +} + +enum InboxEntry { // indices into the panel's own lists + Notification(usize), + Activity(usize), +} + +enum InboxRow { // the flattened list + Repo(usize), + Entry(usize, usize), + Empty, // "No activity yet." under an empty section } ``` -The workspace already supports a bottom dock and prunes it when empty (`workspace.rs`). Then: +The section list and the flattened rows are stored as `Arc`s and cloned into the `gpui::list` +closure, which indexes the panel's `notifications` / `activity` lists - no per-frame deep copies. +Notification groups carry their repository in `InboxItem::address`; activity events carry it in a +`GitRepoAnnouncement` `a` tag (`repo_address`). Archived notification groups are left out. -- `InboxFilterView` is a bottom-dock panel holding an `Entity` and a mode - `InboxFilter::Unread | InboxFilter::Archived`. It renders the matching subset of the panel's - notifications as a `gpui::list`, with the same rows as the inbox card. The mode filters on - `InboxItem::is_unread()` / `InboxItem::archived` and picks the tab title and empty state. It reads - the inbox entity during render, so GPUI's render-time tracking re-renders it whenever the inbox - re-derives; it needs no subscription of its own. -- The **Unread** and **Archived** header buttons in `InboxView` call `InboxView::open_filter`, which - keeps `filter_view: Option>`. When the panel already exists it updates - its mode, focuses it, and reopens the bottom dock if the user collapsed it, instead of adding a - duplicate. Otherwise it creates the panel and adds it to the bottom dock. -- **Unread rows**: clicking a row marks that group read (`InboxView::mark_read`); a trailing ghost - icon button archives it (`InboxView::mark_archived`). Both call back into the `InboxView` entity, - which updates the global `Inbox`. **Archived rows** are display-only, since the read state has no - un-archive operation. +`rebuild` also seeds a section for every repository in `announcements_of(me)`. The panel observes +`RepoListStore` so a repository that loads after the last refresh still appears (its own empty +section, or with items if any arrived); this is the one logic subscription beyond the `Inbox` and +`Backend` ones. Repository names are resolved per render through `repo_name` -> `RepoListStore`, so a +late announcement still labels its section without re-deriving the grouping. ### 5.3 Sidebar In `views/sidebar/mod.rs`: -- Add `inbox: Option>` (mirrors `explore`) and `unread: usize`. +- Add `inbox: Option>` (mirrors `explore`). - Add `fn open_inbox(&mut self, window, cx)` that returns when the panel is already open, else adds a center panel (same shape as `open_explore`; there is no dock API to focus an existing tab). - `InboxView::new` takes the sidebar's `WeakEntity` so the panel can open its sub-view. -- Point the existing nav item at it and add an unread suffix: + `InboxView::new` takes the sidebar's `WeakEntity` so the panel can open a repo for a row. +- Point the existing nav item at it: ```rust NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small()) - .when(unread > 0, |this| this.suffix(...badge...)) .on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))), ``` -- `cx.observe` `Backend::global(cx).read(cx).inbox()` so the badge follows the count the panel - publishes. +- No unread badge. The nav item carries no suffix, and the sidebar does not observe the global + `Inbox`. The unread count lives in the panel only. ### 5.4 Click-through (P1) @@ -592,10 +606,10 @@ whose root is not an issue/PR/patch, or whose repository is not in `RepoListStor | `crates/signed_state/src/backend.rs` | `inbox: Entity` field, construction, `inbox()` accessor, `sync_inbox`, `RepoListStore` import | | `crates/signed_state/src/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users | | `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` and `query_inbox`; re-export `RefreshGate` (no global install) | -| `crates/dock/src/lib.rs` | `add_bottom_panel` helper | -| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel owning the derived lists directly, the `InboxFilterView` bottom-dock sub-view for Unread / Archived, and the notification click-through | +| `crates/dock/src/lib.rs` | `add_bottom_panel` helper (currently unused; left over from the removed sub-views) | +| `crates/workspace/src/views/inbox.rs` | `InboxView` home panel owning the derived lists, the repository grouping, and the notification click-through | | `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;`; re-export `RepoItem`, `open_repo_item`, `open_repo_panel` | -| `crates/workspace/src/views/sidebar/mod.rs` | `inbox`/`unread` fields, `open_inbox`, nav wiring and badge | +| `crates/workspace/src/views/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring | | `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `open_repo_item`, `RepoDetailView::store()` | No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; `Backend::sync_inbox` @@ -613,10 +627,11 @@ activates the `Inbox` child entity at each signer transition. 2. **Phase 1 - store**: `Inbox` child entity, activated by `Backend::sync_inbox` once a signer exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the implementation notes below. -3. **Phase 2 - screen**: `InboxView` (inbox + activity), sidebar nav and badge. +3. **Phase 2 - screen**: `InboxView` (inbox + activity) and the sidebar nav item. **DONE.** See the implementation notes below. 4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived. - **DONE.** See the implementation notes below. + **Done, then reverted.** The sub-views were removed before the repository-grouping redesign; the + notes below are historical. 5. **Phase 4 - click-through**: `open_item` and announcement lookup. **DONE.** See the implementation notes below. 6. **Phase 5 (optional)**: standalone notifications page, NIP-65 relays, pagination, patch detail @@ -692,9 +707,8 @@ Files: `crates/workspace/src/views/{inbox.rs, mod.rs, sidebar/mod.rs}`. No store - `kind_icon` / `kind_label` map a `Kind` to a `CustomIconName`/`IconName` and a short noun. The cover note is compared with `==` rather than matched, since `Kind` cannot appear in a pattern arm. - Sidebar: `open_inbox` mirrors `open_explore` (return if open, else add a center panel); the inbox - nav item is repointed and carries a `CountBadge` suffix driven by the observed unread count. The - screen is still opened by the nav item, not on app startup, matching the "idle until signer" rule; - auto-opening it as the post-login home is a possible follow-up. + nav item is repointed. The screen is still opened by the nav item, not on app startup, matching the + "idle until signer" rule; auto-opening it as the post-login home is a possible follow-up. - The **My repositories** column (search `InputState`, **New** button, `open_repo_panel` rows) was removed after Phase 2 as redundant with the sidebar, along with the panel's `dock_area`, `open_repo` / `open_create_repo` helpers and the `create_repo_dialog` / `open_repo_panel` imports. @@ -708,8 +722,8 @@ Phases 0-2 kept all derivation in the global `Inbox`, so every notification and whether or not the home screen was open, and `Backend::emit` carried a deferred side effect just to feed it. -- The global `Inbox` is now thin: `state: InboxReadState`, `state_loaded`, and the `unread_count` the - sidebar badge reads, plus the NIP-78 load/save and the mark actions. +- The global `Inbox` is now thin: `state: InboxReadState`, `state_loaded`, plus the NIP-78 load/save + and the mark actions. - `Backend::emit` is gone. All `BackendEvent`s are emitted with `cx.emit` again, and `sync_inbox` updates the inbox synchronously, passing the client in so nothing reads `Backend` mid-update. - The panel became the client-side owner of the derivation, initially through a panel-scoped @@ -731,11 +745,15 @@ The `InboxStore` entity was then folded into `InboxView`, since the panel was it - `cargo test -p signed_core` (68), `cargo test -p signed_state` (24) and `cargo test -p workspace` (7) pass; clippy and `cargo check --workspace --all-targets` are clean. -Trade-off: the sidebar badge is only current after the inbox is opened once, because the unread -count is derived by the panel. +Trade-off: the unread count is derived by the panel, so it is only current while the panel is open. +(`publish_unread_count` fed a sidebar badge at the time; both were removed later - see "Sidebar +badge removed" below.) ### Phase 3 implementation notes +> Historical: the Unread/Archived sub-views below were later removed; the panel is now a single +> repository-grouped list. Kept for the `add_bottom_panel` / sub-view rationale. + Files: `crates/dock/src/lib.rs` and `crates/workspace/src/views/{inbox.rs, sidebar/mod.rs}`. No store changes. @@ -784,13 +802,63 @@ Files: `crates/workspace/src/views/{inbox.rs, mod.rs, repo_detail/mod.rs}`. No s from the panel) because the `gpui::list` item closure only receives `&mut App`. It resolves `item.address` through `RepoListStore`, returns silently when the repository is unknown, opens the repo panel, then maps the root kind to a `RepoItem` and calls `open_repo_item`. -- Only the main notification list is clickable. Unread rows keep their Phase 3 behaviour (click marks - read, trailing button archives). Activity rows are unchanged. +- Only the notification rows are clickable. Activity rows are display-only. The Phase 3 mark-read / + archive row behaviour is gone with the sub-views. - `RepoItem::Patch` is a unit variant because the id would be unused: patches have no detail panel, so `open_repo_item` returns before doing anything and only the repository panel opens. - `cargo clippy -p workspace --all-targets` is clean, `cargo check --workspace --all-targets` succeeds, and `cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1). +### Repository grouping redesign (after Phase 4) + +Files: `crates/workspace/src/views/inbox.rs`. No store, no `signed_core` changes. + +The two-card layout (notifications over activity) was replaced by a single repository-grouped list. + +- The panel now derives `sections: Vec` and a flattened `rows: Vec` in + `rebuild`, called from `run_refresh` and `regroup`. Both are stored as `Arc`s and cloned into the + `gpui::list` closure, which indexes `notifications` / `activity` - no deep copies per frame and no + data duplicated between the section list and the source lists. +- `InboxSection` groups a repository's non-archived notification groups and the user's own activity, + newest first; sections are ordered by their newest entry. `InboxEntry` holds indices into the + panel's lists; `InboxRow::Repo` / `InboxRow::Entry` / `InboxRow::Empty` is the flattened shape the + list renders. +- All of the user's own repositories are seeded as sections from `RepoListStore::announcements_of`, + so an owned repository with nothing to show gets an empty section ("No activity yet.") and sorts + after the sections with activity. The panel observes `RepoListStore` to rebuild when the user's + repositories load or change. +- Activity is matched to a repository through a `GitRepoAnnouncement` `a` tag (`repo_address`). + Items without an address share the "Other repository" section. +- `notification_row` / `activity_row` no longer render the repository name - the section header does. + That also drops one `RepoListStore` scan per row. +- The single card has one `ListState`; the old `notifications_list` / `activity_list` and the + `render_inbox_panel` / `render_activity_panel` / `section` helpers are gone. `notification_row` still + takes an id prefix so rows stay unique within the list. +- `cargo clippy -p workspace --all-targets` is clean and `cargo test -p signed_core -p signed_state + -p workspace -p dock` passes (68 / 24 / 7 / 1). + +### Sidebar badge removed (after the repository grouping redesign) + +Files: `crates/signed_core/src/filters.rs`, `crates/signed_state/src/{inbox.rs,backend.rs}`, +`crates/workspace/src/views/{inbox.rs,sidebar/mod.rs}`. + +An intermediate change made the sidebar badge live by moving the unread count into the global +`Inbox` (a `refresh_unread_count` driven by `Backend`). That was then reverted along with the badge +itself, so the global is thin again. + +- The sidebar nav item no longer renders a `CountBadge`; `SidebarPanel` lost its `unread` field and + its observe of the global `Inbox`. +- The global `Inbox` no longer stores an `unread_count` and has no `set_unread_count` / + `refresh_unread_count`. `Backend` has no `refresh_inbox_unread` and no per-batch or per-sync count + refresh. `filters::affects_inbox` and the `query_inbox` helper split were reverted with it. +- `InboxView` keeps its local `unread_count` for its header badge and the per-section `unread` for + the repository headers; `publish_unread_count` stays deleted. +- Consequence: the unread count is only current while the panel is open, and there is no unread + indication anywhere else in the app. +- `cargo clippy -p signed_core -p signed_state -p workspace --all-targets` is clean, + `cargo check -p signed_core -p signed_state -p workspace --all-targets` succeeds, and + `cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1). + ## 8. Validation - `cargo test -p signed_core` (68 tests): root resolution, grouping, read-state cutoff, serde round-trip. @@ -802,7 +870,7 @@ Files: `crates/workspace/src/views/{inbox.rs, mod.rs, repo_detail/mod.rs}`. No s - Manual: log in with a repo-owning identity; open the inbox from the sidebar and confirm the panel populates from another identity's issue/comment, the activity list shows your own items, and that no kind-30078 event is broadcast (watch the relays / `Published` events). Restart to confirm the read - state is read back from LMDB. + state is read back from LMDB. Confirm the sidebar has no unread badge. ## 9. SDK APIs used (verified in the pinned `5c669a4` checkout)