feat: add inbox panel #18

Merged
reya merged 10 commits from feat/inbox into master 2026-09-12 03:34:54 +00:00
4 changed files with 578 additions and 429 deletions
Showing only changes of commit 81e421d121 - Show all commits
+5 -18
View File
@@ -12,9 +12,7 @@ use crate::backend::Backend;
pub struct Inbox { pub struct Inbox {
state: InboxReadState, state: InboxReadState,
/// Set once the stored state has been read for the current user. /// Set once the stored state has been read for the current user.
state_loaded: bool, loaded: bool,
/// Unread notification groups, published by the inbox panel for the sidebar badge.
pub unread_count: usize,
} }
impl Inbox { impl Inbox {
@@ -25,16 +23,7 @@ impl Inbox {
/// Whether the stored state has been read for the current user. /// Whether the stored state has been read for the current user.
pub fn is_loaded(&self) -> bool { pub fn is_loaded(&self) -> bool {
self.state_loaded self.loaded
}
/// Publish the unread count derived by the inbox panel.
pub fn set_unread_count(&mut self, count: usize, cx: &mut Context<Self>) {
if self.unread_count == count {
return;
}
self.unread_count = count;
cx.notify();
} }
/// Mark the events of one notification group read, then bound the id sets. /// 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. /// Load the stored state for current user.
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) { pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) {
self.state = InboxReadState::default(); self.state = InboxReadState::default();
self.state_loaded = false; self.loaded = false;
self.unread_count = 0;
cx.notify(); cx.notify();
let backend = Backend::global(cx); let backend = Backend::global(cx);
@@ -104,7 +92,7 @@ impl Inbox {
Err(error) => log::warn!("failed to load inbox state: {error}"), Err(error) => log::warn!("failed to load inbox state: {error}"),
} }
this.state_loaded = true; this.loaded = true;
cx.notify(); cx.notify();
})?; })?;
@@ -116,8 +104,7 @@ impl Inbox {
/// Clear the state of the signed-out user. /// Clear the state of the signed-out user.
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) { pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
self.state = InboxReadState::default(); self.state = InboxReadState::default();
self.state_loaded = false; self.loaded = false;
self.unread_count = 0;
cx.notify(); cx.notify();
} }
+376 -254
View File
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -9,16 +10,16 @@ use gpui::{
AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState, AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState,
Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div, list, px, 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 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::{ 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::{ 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 utils::relative_time;
use super::{RepoItem, open_repo_item, open_repo_panel}; 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. /// Extra list rows measured above and below the visible area.
const LIST_OVERDRAW: Pixels = px(400.); 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<RepoAddr>,
/// Number of notification groups with an unread event.
unread: usize,
/// Notification and activity rows, newest first.
entries: Vec<InboxEntry>,
/// 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 { pub struct InboxView {
focus_handle: FocusHandle, focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
@@ -36,6 +67,10 @@ pub struct InboxView {
notifications: Arc<Vec<InboxItem>>, notifications: Arc<Vec<InboxItem>>,
/// The user's own recent git activity, newest first. /// The user's own recent git activity, newest first.
activity: Arc<Vec<Event>>, activity: Arc<Vec<Event>>,
/// The notification and activity lists grouped by repository, newest first.
sections: Arc<Vec<InboxSection>>,
/// The flattened repository headers and rows of the list.
rows: Arc<Vec<InboxRow>>,
/// Number of non-archived groups with an unread event. /// Number of non-archived groups with an unread event.
unread_count: usize, unread_count: usize,
/// Copy of the global read state the current lists were derived with. /// 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. /// Set once the global state has been read for the current user.
state_loaded: bool, state_loaded: bool,
refresh: RefreshGate, refresh: RefreshGate,
/// Virtual-list state of the notification list. list: ListState,
notifications_list: ListState,
/// Virtual-list state of the activity list.
activity_list: ListState,
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@@ -55,22 +87,10 @@ impl InboxView {
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self { pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx); let backend = Backend::global(cx);
let inbox = backend.read(cx).inbox(); let inbox = backend.read(cx).inbox();
let repos = RepoListStore::global(cx);
let weak = cx.entity().downgrade(); let weak = cx.entity().downgrade();
let notifications_list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW); let list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW);
let activity_list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW);
for list_state in [&notifications_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 mut subscriptions = vec![]; let mut subscriptions = vec![];
subscriptions.push(cx.observe(&inbox, |this, _inbox, cx| { subscriptions.push(cx.observe(&inbox, |this, _inbox, cx| {
@@ -81,7 +101,14 @@ impl InboxView {
this.handle_backend_event(event, cx); 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| { cx.defer(move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) { if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) {
log::warn!("inbox dropped before bootstrap could run: {error}"); log::warn!("inbox dropped before bootstrap could run: {error}");
@@ -93,12 +120,13 @@ impl InboxView {
dock_area, dock_area,
notifications: Arc::new(Vec::new()), notifications: Arc::new(Vec::new()),
activity: Arc::new(Vec::new()), activity: Arc::new(Vec::new()),
sections: Arc::new(Vec::new()),
rows: Arc::new(Vec::new()),
unread_count: 0, unread_count: 0,
state: InboxReadState::default(), state: InboxReadState::default(),
state_loaded: false, state_loaded: false,
refresh: RefreshGate::default(), refresh: RefreshGate::default(),
notifications_list, list,
activity_list,
tasks: vec![], tasks: vec![],
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
@@ -123,8 +151,10 @@ impl InboxView {
}; };
if !loaded { if !loaded {
let was_present = let was_present = self.state_loaded
self.state_loaded || !self.notifications.is_empty() || !self.activity.is_empty(); || !self.notifications.is_empty()
|| !self.activity.is_empty()
|| !self.sections.is_empty();
self.clear(); self.clear();
if was_present { if was_present {
cx.notify(); cx.notify();
@@ -141,13 +171,12 @@ impl InboxView {
if self.state != state { if self.state != state {
self.state = state; self.state = state;
self.regroup(); self.regroup(cx);
self.publish_unread_count(cx);
cx.notify(); 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<Self>) { fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
match event { match event {
BackendEvent::NostrUpdate(updates) => { BackendEvent::NostrUpdate(updates) => {
@@ -228,7 +257,7 @@ impl InboxView {
this.notifications = Arc::new(notifications); this.notifications = Arc::new(notifications);
this.activity = Arc::new(activity); this.activity = Arc::new(activity);
this.unread_count = unread_count; this.unread_count = unread_count;
this.publish_unread_count(cx); this.rebuild(cx);
cx.notify(); cx.notify();
this.refresh.finish() this.refresh.finish()
@@ -243,7 +272,7 @@ impl InboxView {
} }
/// Recompute the unread and archived flags from the current state. /// Recompute the unread and archived flags from the current state.
fn regroup(&mut self) { fn regroup(&mut self, cx: &mut Context<Self>) {
let mut items = (*self.notifications).clone(); let mut items = (*self.notifications).clone();
for item in items.iter_mut() { for item in items.iter_mut() {
@@ -252,19 +281,46 @@ impl InboxView {
self.unread_count = items.iter().filter(|item| item.is_unread()).count(); self.unread_count = items.iter().filter(|item| item.is_unread()).count();
self.notifications = Arc::new(items); self.notifications = Arc::new(items);
self.rebuild(cx);
} }
/// Publish the derived unread count for the sidebar badge. /// Regroup the current lists by repository and flatten them into rows.
fn publish_unread_count(&self, cx: &mut Context<Self>) { fn rebuild(&mut self, cx: &mut Context<Self>) {
let count = self.unread_count; let backend = Backend::global(cx);
let inbox = Backend::global(cx).read(cx).inbox(); let repo_list = RepoListStore::global(cx);
inbox.update(cx, |inbox, cx| inbox.set_unread_count(count, 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(&sections);
self.sections = Arc::new(sections);
self.rows = Arc::new(rows);
} }
/// Forget everything derived for the current user. /// Forget everything derived for the current user.
fn clear(&mut self) { fn clear(&mut self) {
self.notifications = Arc::new(Vec::new()); self.notifications = Arc::new(Vec::new());
self.activity = 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.unread_count = 0;
self.state = InboxReadState::default(); self.state = InboxReadState::default();
self.state_loaded = false; self.state_loaded = false;
@@ -280,147 +336,205 @@ impl InboxView {
.collect() .collect()
} }
fn section(&self, header: impl IntoElement, body: impl IntoElement, cx: &App) -> AnyElement { fn render_entry(&self, ix: usize, cx: &App) -> AnyElement {
v_flex() let Some(row) = self.rows.get(ix) else {
.w_full() return div().into_any_element();
.flex_1() };
.min_h_0()
.rounded(cx.theme().radius) match *row {
.border_1() InboxRow::Empty => empty_section_row(cx),
.border_color(cx.theme().border) InboxRow::Repo(section_ix) => {
.overflow_hidden() let Some(section) = self.sections.get(section_ix) else {
.child( return div().into_any_element();
div() };
.px_3() repo_header(section, cx)
.py_2()
.bg(cx.theme().muted.opacity(0.5))
.border_b_1()
.border_color(cx.theme().border)
.child(header),
)
.child(body)
.into_any_element()
} }
InboxRow::Entry(section_ix, entry_ix) => {
let Some(section) = self.sections.get(section_ix) else {
return div().into_any_element();
};
fn render_inbox_panel( let Some(entry) = section.entries.get(entry_ix) else {
&self, return div().into_any_element();
unread: usize, };
notifications: Arc<Vec<InboxItem>>,
visible: Vec<usize>,
cx: &mut Context<Self>,
) -> 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() { match *entry {
empty_state(IconName::Inbox, "You're all caught up.", cx) InboxEntry::Notification(item_ix) => {
} else { let Some(item) = self.notifications.get(item_ix) 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 {
return div().into_any_element(); return div().into_any_element();
}; };
let root = item.root; let root = item.root;
let kind = item.root_kind; let kind = item.root_kind;
let address = item.address.clone(); let address = section.address.clone();
let dock_area = dock_area.clone(); let dock_area = self.dock_area.clone();
notification_row("inbox-row", ix, item, cx) notification_row("inbox-row", ix, item, cx)
.on_click(move |_, window, cx| { .on_click(move |_, window, cx| {
open_item(&dock_area, root, kind, address.clone(), window, cx); open_item(&dock_area, root, kind, address.clone(), window, cx);
}) })
.into_any_element() .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)
} }
InboxEntry::Activity(event_ix) => {
fn render_activity_panel( let Some(event) = self.activity.get(event_ix) else {
&self,
activity: Arc<Vec<Event>>,
cx: &mut Context<Self>,
) -> 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 {
return div().into_any_element(); return div().into_any_element();
}; };
activity_row(ix, event, cx) 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()
};
self.section(header, body, cx)
} }
} }
}
}
}
}
/// Group the notification groups and own activity into one section per repository.
fn group_sections(notifications: &[InboxItem], activity: &[Event]) -> Vec<InboxSection> {
let mut by_repo: HashMap<Option<RepoAddr>, 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<InboxSection> = 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<InboxRow> {
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<RepoAddr> {
event
.tags
.coordinates()
.find(|address| address.kind == Kind::GitRepoAnnouncement)
}
/// Display name of the repository at `addr`, from the announcement store. /// Display name of the repository at `addr`, from the announcement store.
fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> { fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> {
let repo_list = RepoListStore::global(cx);
let addr = addr?; let addr = addr?;
RepoListStore::global(cx) repo_list
.read(cx) .read(cx)
.announcements .announcements
.iter() .iter()
.find(|announcement| announcement.addr() == *addr) .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 /// Header of a repository section.
/// detail when the group's root is one. fn repo_header(section: &InboxSection, cx: &App) -> AnyElement {
/// let name =
/// The group carries only the repository coordinate, so the announcement is repo_name(section.address.as_ref(), cx).unwrap_or_else(|| SharedString::from("Untitled"));
/// looked up in the local list. A group whose repository is not known locally
/// opens nothing. 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( fn open_item(
dock_area: &WeakEntity<DockArea>, dock_area: &WeakEntity<DockArea>,
root: EventId, root: EventId,
@@ -458,125 +572,110 @@ fn open_item(
open_repo_item(dock_area, store, item, window, cx); 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<Div> { fn notification_row(prefix: &'static str, ix: usize, item: &InboxItem, cx: &App) -> Stateful<Div> {
let Some(newest) = item.events.first() else { let Some(newest) = item.events.first() else {
return div().id((prefix, ix)); 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 kind = item.root_kind.unwrap_or(newest.kind);
let subject = SharedString::from(activity_subject(newest)); let subject = SharedString::from(activity_subject(newest));
let repo = repo_name(item.address.as_ref(), cx);
let age = relative_time(item.latest_activity()); let age = relative_time(item.latest_activity());
let unread = item.is_unread(); let unread = item.is_unread();
h_flex()
.id((prefix, ix))
.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)))
.child(
v_flex() v_flex()
.flex_1() .id((prefix, ix))
.min_w_0() .h_16()
.gap_0p5() .w_full()
.px_3()
.justify_center()
.bg(cx.theme().secondary)
.hover(|this| this.bg(cx.theme().secondary_hover))
.child(
h_flex()
.gap_2()
.text_sm()
.child(
h_flex()
.size_6()
.flex_shrink_0()
.items_center()
.justify_center()
.child(kind_icon(kind)),
)
.child( .child(
div() div()
.text_sm() .min_w_0()
.when(unread, |this| this.font_semibold())
.whitespace_nowrap() .whitespace_nowrap()
.text_ellipsis() .text_ellipsis()
.child(subject), .child(subject),
) )
.child( .child(div().flex_1())
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)),
),
)
.when(unread, |this| { .when(unread, |this| {
this.child( this.child(
div() div()
.flex_shrink_0() .flex_shrink_0()
.size(px(8.)) .size_2()
.rounded(px(4.)) .rounded_full()
.bg(cx.theme().primary), .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()
.id(("activity-row", ix))
.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)))
.child(
v_flex()
.flex_1()
.min_w_0()
.gap_0p5()
.child(
div()
.text_sm()
.whitespace_nowrap()
.text_ellipsis()
.child(subject),
) )
.child( .child(
h_flex().gap_2().child(div().w_6().flex_shrink_0()).child(
h_flex() h_flex()
.gap_1() .gap_1()
.text_xs() .text_xs()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child(SharedString::from(kind_label(kind))) .child(SharedString::from(kind_label(kind)))
.when_some(repo, |this, repo| { .child("·")
this.child(SharedString::from("on")).child(repo) .child(SharedString::from(age)),
}) ),
.child(SharedString::from("·")) )
}
fn activity_row(ix: usize, event: &Event, cx: &App) -> AnyElement {
let kind = event.kind;
let subject = SharedString::from(activity_subject(event));
let age = relative_time(event.created_at);
v_flex()
.id(("activity-row", ix))
.h_16()
.w_full()
.px_3()
.justify_center()
.bg(cx.theme().secondary)
.hover(|this| this.bg(cx.theme().secondary_hover))
.child(
h_flex()
.gap_2()
.text_sm()
.whitespace_nowrap()
.text_ellipsis()
.child(
h_flex()
.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)), .child(SharedString::from(age)),
), ),
) )
.into_any_element() .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. /// Leading icon for a notification or activity kind.
fn kind_icon(kind: Kind) -> Icon { fn kind_icon(kind: Kind) -> Icon {
if kind == COVER_NOTE_KIND { if kind == COVER_NOTE_KIND {
@@ -589,7 +688,7 @@ fn kind_icon(kind: Kind) -> Icon {
Icon::new(CustomIconName::GitPullRequest) Icon::new(CustomIconName::GitPullRequest)
} }
Kind::GitPatch => Icon::new(CustomIconName::GitCommit), Kind::GitPatch => Icon::new(CustomIconName::GitCommit),
Kind::Comment => Icon::new(IconName::FileText), Kind::Comment => Icon::new(IconName::File),
Kind::GitStatusOpen Kind::GitStatusOpen
| Kind::GitStatusApplied | Kind::GitStatusApplied
| Kind::GitStatusClosed | Kind::GitStatusClosed
@@ -665,31 +764,54 @@ impl Focusable for InboxView {
impl Render for InboxView { impl Render for InboxView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let unread = self.unread_count; let rows = self.rows.clone();
let notifications = self.notifications.clone();
let activity = self.activity.clone();
let visible: Vec<usize> = notifications if self.list.item_count() != rows.len() {
.iter() self.list.reset(rows.len());
.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());
} }
v_flex() v_flex()
.size_full()
.image_cache(gpui::retain_all("inbox")) .image_cache(gpui::retain_all("inbox"))
.gap_4() .size_full()
.p_4() .gap_2()
.child(self.render_inbox_panel(unread, notifications, visible, cx)) .child(
.child(self.render_activity_panel(activity, cx)) 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(),
)
},
))
} }
} }
+4 -32
View File
@@ -21,7 +21,7 @@ use signed_core::{Announcement, RepoAddr, identifier_from_name};
use signed_state::{ use signed_state::{
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore, 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}; use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel};
@@ -39,8 +39,6 @@ pub struct SidebarPanel {
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
inbox: Option<WeakEntity<InboxView>>, inbox: Option<WeakEntity<InboxView>>,
explore: Option<WeakEntity<RepoListView>>, explore: Option<WeakEntity<RepoListView>>,
/// Unread notification groups, shown as the inbox nav item's badge.
unread: usize,
/// Artwork for the sign-in screen. /// Artwork for the sign-in screen.
banner: SharedString, banner: SharedString,
/// The signed-in user's announced repositories, newest first. /// The signed-in user's announced repositories, newest first.
@@ -74,6 +72,7 @@ impl SidebarPanel {
if signer_required { if signer_required {
this.banner = pick_banner(); this.banner = pick_banner();
cx.notify();
} }
if this.refresh(cx) || signer_required { if this.refresh(cx) || signer_required {
@@ -102,44 +101,20 @@ impl SidebarPanel {
} }
})); }));
// The inbox nav item shows the unread notification count as a badge. Self {
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 {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
dock_area, dock_area,
inbox: None, inbox: None,
explore: None, explore: None,
unread: inbox.read(cx).unread_count,
banner: pick_banner(), banner: pick_banner(),
announcements: Arc::new(Vec::new()), announcements: Arc::new(Vec::new()),
local_repos: Arc::new(Vec::new()), local_repos: Arc::new(Vec::new()),
scanning: false, scanning: false,
unpushed: HashMap::new(), unpushed: HashMap::new(),
_subscriptions: subscriptions, _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<Self>) -> bool { fn refresh(&mut self, cx: &mut Context<Self>) -> bool {
let backend = Backend::global(cx); let backend = Backend::global(cx);
let user = backend.read(cx).current_user(); let user = backend.read(cx).current_user();
@@ -644,9 +619,6 @@ impl Render for SidebarPanel {
.justify_start() .justify_start()
.child( .child(
NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small()) 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| { .on_click(cx.listener(|this, _ev, window, cx| {
this.open_inbox(window, cx) this.open_inbox(window, cx)
})), })),
+185 -117
View File
@@ -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 `<Dashboard />` when > page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `<Dashboard />` when
> an account is active, and that home screen is the inbox. > 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 signed_core` (68), `cargo test -p signed_state` (24),
> `cargo test -p workspace` (7), `cargo clippy -p workspace --all-targets` clean, > `cargo test -p workspace` (7), `cargo test -p dock` (1), `cargo clippy -p workspace --all-targets`
> `cargo check --workspace --all-targets` succeeds. > clean, `cargo check --workspace --all-targets` succeeds.
> Phase 5 is not started. This document reflects the implementation as it stands, including the > Phase 5 is not started. This document reflects the implementation as it stands: the Phase 1
> Phase 1 refactors, the §4.3 split of the inbox into a thin global `Inbox` and a panel-owned > refactors, the §4.3 split of the inbox into a thin global `Inbox` and a panel-owned derivation, the
> derivation, the Phase 3 bottom-dock sub-views, and the Phase 4 click-through. > 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 ## 1. What the GitWorkshop home screen is
@@ -55,39 +57,48 @@ Data hooks:
| Priority | Section | Notes | | Priority | Section | Notes |
|---|---|---| |---|---|---|
| **P0** | Inbox panel | Activity directed at you, grouped by thread root; unread badge; mark all read; all groups shown | | **P0** | Inbox panel | Activity directed at you and your own activity, **grouped by repository**; 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 |
| **P1** | Click-through | Open the repo panel at the relevant PR/issue | | **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 | | **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: Notes:
- There is **no greeting header**. The screen starts with the inbox panel. - 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. - 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 ## 3. The Signed screen
`InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item (currently a `InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item. It is one bordered
placeholder that opens Explore). It is two flexible bordered cards, each a virtual list: 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] | | Inbox (3 unread) [Mark all read] |
| [avatar] issue opened on you/repo 2m (scroll) | |-------------------------------------------------------------------------|
| [avatar] commented on "Fix parser" 1h | | [repo] you/repo-a (2) |
| [avatar] PR update on you/repo 3h | | [avatar] issue opened issue 2m |
+-------------------------------------------------------------------------+ | [avatar] commented on "..." comment 1h |
| Continue where you left off (scroll)| | [icon] "Add retry" patch 3d |
| [icon] "Fix parser bug" you/repo opened 3d | |-------------------------------------------------------------------------|
| [icon] "Add retry" you/repo PR 5d | | [repo] you/repo-b |
+-------------------------------------------------------------------------+ | No activity yet. |
| bottom dock: Unread or Archived list (opened by the header buttons) | |-------------------------------------------------------------------------|
| [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. Data layer
### 4.1 `signed_core`: pure logic ### 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. open.
**`Inbox`** is a child `Entity<Inbox>` owned by `Backend` (`inbox: Entity<Inbox>`) and is **`Inbox`** is a child `Entity<Inbox>` owned by `Backend` (`inbox: Entity<Inbox>`) and is
deliberately thin: it owns only the read/archive state that must outlive the panel, the NIP-78 deliberately thin: it owns only the read/archive state that must outlive the panel and the NIP-78
load/save, and the unread count the sidebar badge reads. load/save.
```rust ```rust
// backend.rs // backend.rs
@@ -280,14 +291,11 @@ pub struct Backend {
pub struct Inbox { pub struct Inbox {
state: InboxReadState, state: InboxReadState,
state_loaded: bool, state_loaded: bool,
/// Published by the inbox panel for the sidebar badge.
pub unread_count: usize,
} }
impl Inbox { impl Inbox {
pub fn state(&self) -> &InboxReadState; pub fn state(&self) -> &InboxReadState;
pub fn is_loaded(&self) -> bool; pub fn is_loaded(&self) -> bool;
pub fn set_unread_count(&mut self, count: usize, cx: &mut Context<Self>);
pub fn mark_read(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx); 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_archived(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx);
pub fn mark_all_read(&mut self, 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 **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 state they were computed with, and the refresh coalescing. There is no separate store entity: the
panel is the only consumer, so an `Entity<InboxStore>` would add an `update` indirection and a panel is the only consumer, so an `Entity<InboxStore>` 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 ```rust
pub struct InboxView { pub struct InboxView {
focus_handle: FocusHandle, focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
notifications: Arc<Vec<InboxItem>>, notifications: Arc<Vec<InboxItem>>,
activity: Arc<Vec<Event>>, activity: Arc<Vec<Event>>,
sections: Arc<Vec<InboxSection>>, // grouped by repository
rows: Arc<Vec<InboxRow>>, // flattened list
unread_count: usize, unread_count: usize,
state: InboxReadState, state: InboxReadState,
state_loaded: bool, state_loaded: bool,
refresh: RefreshGate, refresh: RefreshGate,
list: ListState,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
impl InboxView { impl InboxView {
pub fn new(cx: &mut Context<Self>) -> Self; // cx.defer(… sync_state) pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>); // cx.defer(… sync_state)
pub fn sync_state(&mut self, cx); // observes the global Inbox 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); pub fn mark_all_read(&mut self, cx);
fn handle_backend_event(&mut self, event: &BackendEvent, cx); fn handle_backend_event(&mut self, event: &BackendEvent, cx);
fn refresh(&mut self, cx); fn refresh(&mut self, cx);
fn run_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` The panel owns three subscriptions that carry logic: it observes the global `Inbox`
(`InboxView::sync_state`) and subscribes to `Backend` (`InboxView::handle_backend_event`). (`InboxView::sync_state`), subscribes to `Backend` (`InboxView::handle_backend_event`), and observes
Re-rendering needs no subscription: GPUI invalidates a window for every entity it read during `RepoListStore` to rebuild when the user's own repositories load. Re-rendering itself needs no
render, so the panel tracks `RepoListStore` and `ProfileStore` just by reading them in `render`. subscription: GPUI invalidates a window for every entity it read during render, so the panel tracks
The panel writes back to `Backend` only to publish the unread count for the badge. `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 **`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 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 derived lists live only as long as the panel. Nothing is wired from the `desktop` crate and
`signed_state::init` gains no parameters. `signed_state::init` gains no parameters.
**Badge trade-off.** The unread count is derived by the panel, so the sidebar badge is only current **No sidebar badge.** The sidebar's inbox nav item has no unread suffix (an earlier global count
after the inbox has been opened once in the session. Keeping it always live would require the derivation was removed with it). The unread count lives entirely in the panel, which shows it in
expensive derivation to run globally, which is exactly what this split avoids. 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 The dependency chain is `Backend``Inbox` and `InboxView``query_inbox`.
only to publish the unread count.
`Backend` does not funnel its events through the inbox: the panel subscribes to `Backend` directly. `Backend` owns the inbox lifecycle (`sync_inbox`); the panel subscribes to `Backend` directly for
`BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: `CheckoutsStore` its lists. `BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay:
and `SidebarPanel` consume them. They no longer drive the inbox. `CheckoutsStore` and `SidebarPanel` consume them. They no longer drive the inbox's activation
directly.
`InboxView::handle_backend_event` refreshes on: `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 ```rust
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) { pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) {
// 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 // 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. 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() == - 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 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 previous user's results never land. Then set `notifications`, `activity`, `unread_count`, rebuild the
the unread count to the global `Inbox`, `cx.notify()`, `refresh.finish()`. repository sections (`rebuild`), `cx.notify()`, `refresh.finish()`.
`InboxView::sync_state` reacts to the global `Inbox`: while the state is not loaded it clears the `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 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, **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 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 `Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the store refreshes. This was
reviewed and left as-is. reviewed and left as-is.
**Actions**: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()` live on the panel, which **Actions**: `mark_all_read()` lives on the panel, which passes every known notification event to the
passes the group and every known notification event to the global `Inbox`. The global marks the global `Inbox`. The global marks them, advances the cutoffs against *all* notification events to bound
group, advances the cutoffs against *all* notification events to bound the id sets, saves the state the id sets, saves the state to LMDB (signed with a fresh random key, see 4.2), and notifies. The
to LMDB (signed with a fresh random key, see 4.2), and notifies. The panel then re-derives and panel then re-derives and publishes the unread count.
publishes the unread count.
**Repository names need no new store**: `RepoListStore` already holds every announcement and **Repository names need no new store**: `RepoListStore` already holds every announcement and
`repo_name` resolves an address to a display name. `repo_name` resolves an address to a display name.
@@ -470,78 +485,77 @@ publishes the unread count.
### 5.1 `InboxView` center panel ### 5.1 `InboxView` center panel
New `crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`. `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 It owns the derived lists directly, so `cx.notify()` from an update re-renders it. The panel is one
column of two flexible bordered cards (`flex_1`, `min_h_0`), each with a header bar and a scrolling bordered card (`flex_1`, `min_h_0`) with a header bar and a scrolling body. The body is a single
body. Each body is a `gpui::list` virtual list (`ListState` + `ListAlignment::Top`, 400px `gpui::list` virtual list (`ListState` + `ListAlignment::Top`, 400px overdraw) with a
overdraw) with a `vertical_scrollbar`; the panel itself does not scroll, so both lists get a `vertical_scrollbar`; the panel itself does not scroll, so the list gets a definite viewport height.
definite viewport height. The list counts are reset from `render` whenever the rendered item count The list count is reset from `render` whenever the rendered row count changes.
changes. Row ids are prefixed (`("inbox-row", ix)` / `("activity-row", ix)`) so the two lists do not
collide.
- **Inbox card**: header with the unread count badge, the **Unread** and **Archived** sub-view buttons - **Header**: the unread count badge and **Mark all read**.
(Phase 3) and **Mark all read**; then every non-archived notification item. Rows show the actor - **Body**: the flattened repository-grouped rows. A repository header is a muted bar with a git icon,
avatar, a kind icon, the subject, the kind label, the repo name, a relative time, and an unread dot the repository name (or "Other repository" when the address is unknown) and its unread badge. Rows
(the subject is semibold while unread). Empty state: "You're all caught up." with under it show the actor avatar, a kind icon, the subject, the kind label, a relative time, and an
`IconName::Inbox`. unread dot (the subject is semibold while unread). A repository with nothing to show renders
- **Continue where you left off**: every event in the activity list, each row a kind icon, subject, "No activity yet."; the panel-level "You're all caught up." empty state appears only when there are
kind label, repo name, and relative time. 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 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 repositories.
dock.
### 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 ```rust
/// Add an already-wrapped panel handle to the bottom dock of `area`. struct InboxSection {
pub fn add_bottom_panel( address: Option<RepoAddr>, // repository, None for items without one
area: &mut DockArea, unread: usize, // unread notification groups
panel: Arc<dyn PanelView>, entries: Vec<InboxEntry>, // newest first
window: &mut Window, latest: Timestamp, // orders the sections
cx: &mut Context<DockArea>, }
) {
area.add_panel_view(panel, DockPlacement::Bottom, None, window, cx); 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<InboxView>` and a mode `rebuild` also seeds a section for every repository in `announcements_of(me)`. The panel observes
`InboxFilter::Unread | InboxFilter::Archived`. It renders the matching subset of the panel's `RepoListStore` so a repository that loads after the last refresh still appears (its own empty
notifications as a `gpui::list`, with the same rows as the inbox card. The mode filters on section, or with items if any arrived); this is the one logic subscription beyond the `Inbox` and
`InboxItem::is_unread()` / `InboxItem::archived` and picks the tab title and empty state. It reads `Backend` ones. Repository names are resolved per render through `repo_name` -> `RepoListStore`, so a
the inbox entity during render, so GPUI's render-time tracking re-renders it whenever the inbox late announcement still labels its section without re-deriving the grouping.
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<WeakEntity<InboxFilterView>>`. 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.
### 5.3 Sidebar ### 5.3 Sidebar
In `views/sidebar/mod.rs`: In `views/sidebar/mod.rs`:
- Add `inbox: Option<WeakEntity<InboxView>>` (mirrors `explore`) and `unread: usize`. - Add `inbox: Option<WeakEntity<InboxView>>` (mirrors `explore`).
- Add `fn open_inbox(&mut self, window, cx)` that returns when the panel is already open, else adds - 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). 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<DockArea>` so the panel can open its sub-view. `InboxView::new` takes the sidebar's `WeakEntity<DockArea>` so the panel can open a repo for a row.
- Point the existing nav item at it and add an unread suffix: - Point the existing nav item at it:
```rust ```rust
NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small()) 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))), .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 - No unread badge. The nav item carries no suffix, and the sidebar does not observe the global
publishes. `Inbox`. The unread count lives in the panel only.
### 5.4 Click-through (P1) ### 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<Inbox>` field, construction, `inbox()` accessor, `sync_inbox`, `RepoListStore` import | | `crates/signed_state/src/backend.rs` | `inbox: Entity<Inbox>` 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/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/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/dock/src/lib.rs` | `add_bottom_panel` helper (currently unused; left over from the removed sub-views) |
| `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/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/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()` | | `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` 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 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 exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the
implementation notes below. 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. **DONE.** See the implementation notes below.
4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived. 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 5. **Phase 4 - click-through**: `open_item` and announcement lookup. **DONE.** See the implementation
notes below. notes below.
6. **Phase 5 (optional)**: standalone notifications page, NIP-65 relays, pagination, patch detail 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 - `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. 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 - 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 nav item is repointed. The screen is still opened by the nav item, not on app startup, matching the
screen is still opened by the nav item, not on app startup, matching the "idle until signer" rule; "idle until signer" rule; auto-opening it as the post-login home is a possible follow-up.
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 - 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`, 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. `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 whether or not the home screen was open, and `Backend::emit` carried a deferred side effect just to
feed it. feed it.
- The global `Inbox` is now thin: `state: InboxReadState`, `state_loaded`, and the `unread_count` the - The global `Inbox` is now thin: `state: InboxReadState`, `state_loaded`, plus the NIP-78 load/save
sidebar badge reads, plus the NIP-78 load/save and the mark actions. and the mark actions.
- `Backend::emit` is gone. All `BackendEvent`s are emitted with `cx.emit` again, and `sync_inbox` - `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. 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 - 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` - `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. (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 Trade-off: the unread count is derived by the panel, so it is only current while the panel is open.
count is derived by the panel. (`publish_unread_count` fed a sidebar badge at the time; both were removed later - see "Sidebar
badge removed" below.)
### Phase 3 implementation notes ### 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 Files: `crates/dock/src/lib.rs` and `crates/workspace/src/views/{inbox.rs, sidebar/mod.rs}`. No
store changes. 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 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 `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`. 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 - Only the notification rows are clickable. Activity rows are display-only. The Phase 3 mark-read /
read, trailing button archives). Activity rows are unchanged. 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 - `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. `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, - `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). 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<InboxSection>` and a flattened `rows: Vec<InboxRow>` 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 ## 8. Validation
- `cargo test -p signed_core` (68 tests): root resolution, grouping, read-state cutoff, serde round-trip. - `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 - 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 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 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) ## 9. SDK APIs used (verified in the pinned `5c669a4` checkout)