update ui
This commit is contained in:
@@ -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
@@ -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 [¬ifications_list, &activity_list] {
|
||||
let weak = weak.clone();
|
||||
list_state.set_scroll_handler(move |_, _, cx| {
|
||||
let weak = weak.clone();
|
||||
cx.defer(move |cx| {
|
||||
let _ = weak.update(cx, |_, cx| cx.notify());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Drive the lists from the global inbox state and from backend events.
|
||||
let list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW);
|
||||
let mut subscriptions = vec![];
|
||||
|
||||
subscriptions.push(cx.observe(&inbox, |this, _inbox, cx| {
|
||||
@@ -81,7 +101,14 @@ impl InboxView {
|
||||
this.handle_backend_event(event, cx);
|
||||
}));
|
||||
|
||||
// Derive the lists once the panel exists.
|
||||
// Rebuild when the user's own repositories load or change,
|
||||
// so a repository without any activity still gets an empty section.
|
||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||
this.rebuild(cx);
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
// Derive the sections once the panel exists.
|
||||
cx.defer(move |cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) {
|
||||
log::warn!("inbox dropped before bootstrap could run: {error}");
|
||||
@@ -93,12 +120,13 @@ impl InboxView {
|
||||
dock_area,
|
||||
notifications: Arc::new(Vec::new()),
|
||||
activity: Arc::new(Vec::new()),
|
||||
sections: Arc::new(Vec::new()),
|
||||
rows: Arc::new(Vec::new()),
|
||||
unread_count: 0,
|
||||
state: InboxReadState::default(),
|
||||
state_loaded: false,
|
||||
refresh: RefreshGate::default(),
|
||||
notifications_list,
|
||||
activity_list,
|
||||
list,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
@@ -123,8 +151,10 @@ impl InboxView {
|
||||
};
|
||||
|
||||
if !loaded {
|
||||
let was_present =
|
||||
self.state_loaded || !self.notifications.is_empty() || !self.activity.is_empty();
|
||||
let was_present = self.state_loaded
|
||||
|| !self.notifications.is_empty()
|
||||
|| !self.activity.is_empty()
|
||||
|| !self.sections.is_empty();
|
||||
self.clear();
|
||||
if was_present {
|
||||
cx.notify();
|
||||
@@ -141,13 +171,12 @@ impl InboxView {
|
||||
|
||||
if self.state != state {
|
||||
self.state = state;
|
||||
self.regroup();
|
||||
self.publish_unread_count(cx);
|
||||
self.regroup(cx);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a backend event that can change the derived lists.
|
||||
/// Handle a backend event that can change the derived sections.
|
||||
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<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(§ions);
|
||||
self.sections = Arc::new(sections);
|
||||
self.rows = Arc::new(rows);
|
||||
}
|
||||
|
||||
/// Forget everything derived for the current user.
|
||||
fn clear(&mut self) {
|
||||
self.notifications = Arc::new(Vec::new());
|
||||
self.activity = Arc::new(Vec::new());
|
||||
self.sections = Arc::new(Vec::new());
|
||||
self.rows = Arc::new(Vec::new());
|
||||
self.unread_count = 0;
|
||||
self.state = InboxReadState::default();
|
||||
self.state_loaded = false;
|
||||
@@ -280,147 +336,205 @@ impl InboxView {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn section(&self, header: impl IntoElement, body: impl IntoElement, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.w_full()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.rounded(cx.theme().radius)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.bg(cx.theme().muted.opacity(0.5))
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(header),
|
||||
)
|
||||
.child(body)
|
||||
.into_any_element()
|
||||
}
|
||||
fn render_entry(&self, ix: usize, cx: &App) -> AnyElement {
|
||||
let Some(row) = self.rows.get(ix) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
fn render_inbox_panel(
|
||||
&self,
|
||||
unread: usize,
|
||||
notifications: Arc<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(),
|
||||
)
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user