update ui

This commit is contained in:
2026-09-11 21:35:03 +07:00
parent 625587fd52
commit 81e421d121
4 changed files with 578 additions and 429 deletions
+5 -18
View File
@@ -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<Self>) {
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>) {
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>) {
self.state = InboxReadState::default();
self.state_loaded = false;
self.unread_count = 0;
self.loaded = false;
cx.notify();
}
+384 -262
View File
@@ -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<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 {
focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
@@ -36,6 +67,10 @@ pub struct InboxView {
notifications: Arc<Vec<InboxItem>>,
/// The user's own recent git activity, newest first.
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.
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<Task<Result<(), Error>>>,
_subscriptions: Vec<Subscription>,
}
@@ -55,22 +87,10 @@ impl InboxView {
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> 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 [&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 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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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(&sections);
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<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() {
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<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 {
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<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.
fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> {
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<DockArea>,
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<Div> {
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<Self>) -> 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<usize> = 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(),
)
},
))
}
}
+4 -32
View File
@@ -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<DockArea>,
inbox: Option<WeakEntity<InboxView>>,
explore: Option<WeakEntity<RepoListView>>,
/// 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<Self>) -> 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)
})),
+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
> 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<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
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<Self>);
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<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
pub struct InboxView {
focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
notifications: Arc<Vec<InboxItem>>,
activity: Arc<Vec<Event>>,
sections: Arc<Vec<InboxSection>>, // grouped by repository
rows: Arc<Vec<InboxRow>>, // flattened list
unread_count: usize,
state: InboxReadState,
state_loaded: bool,
refresh: RefreshGate,
list: ListState,
_subscriptions: Vec<Subscription>,
}
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 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<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
}
```
@@ -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<dyn PanelView>,
window: &mut Window,
cx: &mut Context<DockArea>,
) {
area.add_panel_view(panel, DockPlacement::Bottom, None, window, cx);
struct InboxSection {
address: Option<RepoAddr>, // repository, None for items without one
unread: usize, // unread notification groups
entries: Vec<InboxEntry>, // 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<InboxView>` 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<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.
`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<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
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.
- Point the existing nav item at it and add an unread suffix:
`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:
```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<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/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<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
- `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)