feat: add inbox panel #18

Merged
reya merged 10 commits from feat/inbox into master 2026-09-12 03:34:54 +00:00
5 changed files with 633 additions and 787 deletions
Showing only changes of commit 1e7cf0020c - Show all commits
+14 -268
View File
@@ -1,20 +1,11 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use anyhow::Error; use anyhow::Error;
use gpui::{AppContext, Context, Task}; use gpui::{AppContext, Context, Task};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use signed_core::{Deletions, InboxItem, InboxReadState, filters, inbox}; use signed_core::{Deletions, InboxItem, InboxReadState, filters, inbox};
use crate::backend::{Backend, BackendEvent}; use crate::backend::Backend;
use crate::refresh::{RefreshGate, RefreshRequest};
/// Delay between a refresh request and the actual re-query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// Maximum number of "continue where you left off" activity events kept.
const ACTIVITY_LIMIT: usize = 50;
/// The user's persisted inbox read state. /// The user's persisted inbox read state.
#[derive(Default)] #[derive(Default)]
@@ -22,7 +13,7 @@ pub struct Inbox {
state: InboxReadState, state: InboxReadState,
/// Set once the stored state has been read for the current user. /// Set once the stored state has been read for the current user.
state_loaded: bool, state_loaded: bool,
/// Unread notification groups, published by [`InboxStore`] for the sidebar badge. /// Unread notification groups, published by the inbox panel for the sidebar badge.
pub unread_count: usize, pub unread_count: usize,
} }
@@ -37,7 +28,7 @@ impl Inbox {
self.state_loaded self.state_loaded
} }
/// Publish the unread count derived by [`InboxStore`]. /// Publish the unread count derived by the inbox panel.
pub fn set_unread_count(&mut self, count: usize, cx: &mut Context<Self>) { pub fn set_unread_count(&mut self, count: usize, cx: &mut Context<Self>) {
if self.unread_count == count { if self.unread_count == count {
return; return;
@@ -150,180 +141,20 @@ impl Inbox {
} }
} }
/// Derives the inbox home screen's notification and activity lists. /// Derive the inbox home screen's lists for `me` from the local database.
/// ///
/// Created by the inbox panel, so the database work only happens while the /// Returns the notification groups, the user's own git activity and the number
/// panel is open. The persisted read state stays in the global [`Inbox`]. /// of non-archived groups with an unread event.
#[derive(Default)] pub async fn query_inbox(
pub struct InboxStore { client: &Client,
/// Notifications grouped by thread root, newest activity first. me: PublicKey,
pub notifications: Arc<Vec<InboxItem>>, state: &InboxReadState,
/// The user's own recent git activity, newest first. ) -> Result<(Vec<InboxItem>, Vec<Event>, usize), Error> {
pub activity: Arc<Vec<Event>>,
/// Number of non-archived groups with an unread event.
pub unread_count: usize,
/// Copy of the global read state the current lists were derived with.
state: InboxReadState,
/// Set once the global state has been read for the current user.
state_loaded: bool,
refresh: RefreshGate,
}
impl InboxStore {
/// Create the store and derive the lists from the current global state.
pub fn new(cx: &mut Context<Self>) -> Self {
let weak = cx.entity().downgrade();
cx.defer(move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) {
log::warn!("inbox store dropped before bootstrap could run: {error}");
}
});
Self::default()
}
/// Mark every event in the group rooted at `root` read.
pub fn mark_read(&mut self, root: EventId, cx: &mut Context<Self>) {
let Some(me) = Backend::global(cx).read(cx).current_user() else {
return;
};
let Some(group) = self.group_events(root) else {
return;
};
let all = self.all_notification_events();
let inbox = Backend::global(cx).read(cx).inbox();
inbox.update(cx, |inbox, cx| inbox.mark_read(&group, &all, me, cx));
}
/// Archive the group rooted at `root`.
pub fn mark_archived(&mut self, root: EventId, cx: &mut Context<Self>) {
let Some(me) = Backend::global(cx).read(cx).current_user() else {
return;
};
let Some(group) = self.group_events(root) else {
return;
};
let all = self.all_notification_events();
let inbox = Backend::global(cx).read(cx).inbox();
inbox.update(cx, |inbox, cx| inbox.mark_archived(&group, &all, me, cx));
}
/// Mark every known notification read.
pub fn mark_all_read(&mut self, cx: &mut Context<Self>) {
let Some(me) = Backend::global(cx).read(cx).current_user() else {
return;
};
let all = self.all_notification_events();
let inbox = Backend::global(cx).read(cx).inbox();
inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx));
}
/// Re-derive from the global state when it is loaded or changes.
pub fn sync_state(&mut self, cx: &mut Context<Self>) {
let inbox = Backend::global(cx).read(cx).inbox();
let (loaded, state) = {
let inbox = inbox.read(cx);
(inbox.is_loaded(), inbox.state().clone())
};
if !loaded {
let was_present =
self.state_loaded || !self.notifications.is_empty() || !self.activity.is_empty();
self.clear();
if was_present {
cx.notify();
}
return;
}
if !self.state_loaded {
self.state_loaded = true;
self.state = state;
self.refresh_initial(cx);
return;
}
if self.state != state {
self.state = state;
self.regroup();
self.publish_unread_count(cx);
cx.notify();
}
}
/// Handle a backend event that can change the derived lists.
pub fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
match event {
BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx),
BackendEvent::NostrUpdate(updates) => {
let relevant = updates.iter().any(|update| {
let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind);
let is_comment = update.kind == Kind::Comment;
let is_event_deletion = update.kind == Kind::EventDeletion;
let is_request_to_vanish = update.kind == Kind::RequestToVanish;
is_notification || is_comment || is_event_deletion || is_request_to_vanish
});
if relevant {
self.refresh(cx);
}
}
_ => {}
}
}
/// One-shot initial load, no debounce.
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
debug_assert!(!self.refresh.debouncing());
if self.refresh.running() {
self.refresh.request();
return;
}
self.run_refresh(cx);
}
/// Re-query the local database.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if !self.state_loaded {
return;
}
if self.refresh.request() != RefreshRequest::Schedule {
return;
}
cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| this.run_refresh(cx))
})
.detach();
}
/// One query and apply cycle, the debounced entry point.
fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.refresh.begin();
let backend = Backend::global(cx);
let Some(me) = backend.read(cx).current_user() else {
self.refresh.abort();
return;
};
let client = backend.read(cx).client();
let state = self.state.clone();
let work = cx.background_spawn(async move {
let deletion_events = client.database().query(filters::deletions()).await?; let deletion_events = client.database().query(filters::deletions()).await?;
let deletions = Deletions::from_events(deletion_events); let deletions = Deletions::from_events(deletion_events);
let (notification_events, by_id) = fetch_notifications(&client, me, &deletions).await?; let (notification_events, by_id) = fetch_notifications(client, me, &deletions).await?;
let notifications = inbox::group(notification_events, me, &state, &|id| { let notifications = inbox::group(notification_events, me, state, &|id| {
by_id.get(&id).cloned() by_id.get(&id).cloned()
}); });
let unread_count = notifications.iter().filter(|item| item.is_unread()).count(); let unread_count = notifications.iter().filter(|item| item.is_unread()).count();
@@ -345,93 +176,8 @@ impl InboxStore {
.cmp(&a.created_at) .cmp(&a.created_at)
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex())) .then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
}); });
activity.truncate(ACTIVITY_LIMIT);
Ok::<_, Error>((notifications, activity, unread_count)) Ok((notifications, activity, unread_count))
});
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let (notifications, activity, unread_count) = match work.await {
Ok(results) => results,
// Database errors are transient, keep the last lists.
Err(error) => {
log::warn!("inbox refresh failed: {error}");
return this.update(cx, |this, _cx| this.refresh.abort());
}
};
let again = this.update(cx, |this, cx| {
// The signer may have changed while the query ran, making
// these results belong to the previous user.
if Backend::global(cx).read(cx).current_user() != Some(me) {
this.refresh.abort();
return false;
}
this.notifications = Arc::new(notifications);
this.activity = Arc::new(activity);
this.unread_count = unread_count;
this.publish_unread_count(cx);
cx.notify();
this.refresh.finish()
})?;
if again {
this.update(cx, |this, cx| this.refresh(cx))?;
}
Ok(())
});
task.detach();
}
/// Recompute the unread and archived flags from the current state.
fn regroup(&mut self) {
let mut items = (*self.notifications).clone();
for item in items.iter_mut() {
item.apply_state(&self.state);
}
self.unread_count = items.iter().filter(|item| item.is_unread()).count();
self.notifications = Arc::new(items);
}
/// 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));
}
/// Forget everything derived for the current user.
fn clear(&mut self) {
self.notifications = Arc::new(Vec::new());
self.activity = Arc::new(Vec::new());
self.unread_count = 0;
self.state = InboxReadState::default();
self.state_loaded = false;
// Drop any in-flight or pending run belonging to the previous user.
self.refresh = RefreshGate::default();
}
/// Events of the group rooted at `root`.
fn group_events(&self, root: EventId) -> Option<Vec<Event>> {
self.notifications
.iter()
.find(|item| item.root == root)
.map(|item| item.events.clone())
}
/// Every event in every group, archived groups included.
fn all_notification_events(&self) -> Vec<Event> {
self.notifications
.iter()
.flat_map(|item| item.events.iter().cloned())
.collect()
}
} }
/// `d` tag identifying the inbox state event of `me`. /// `d` tag identifying the inbox state event of `me`.
+2 -1
View File
@@ -13,9 +13,10 @@ pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout}; pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
pub use git_store::GitStore; pub use git_store::GitStore;
use gpui::{App, AppContext}; use gpui::{App, AppContext};
pub use inbox::{Inbox, InboxStore}; pub use inbox::{Inbox, query_inbox};
pub use nostr_sdk::prelude::Timestamp; pub use nostr_sdk::prelude::Timestamp;
pub use profile::{Profile, ProfileStore}; pub use profile::{Profile, ProfileStore};
pub use refresh::{RefreshGate, RefreshRequest};
pub use repo::RepoStore; pub use repo::RepoStore;
pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore}; pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore};
use signed_nostr::new_backend; use signed_nostr::new_backend;
+393 -317
View File
@@ -1,116 +1,337 @@
use std::sync::Arc;
use std::time::Duration;
use anyhow::Error;
use assets::CustomIconName; use assets::CustomIconName;
use dock::{BasePanel, DockArea, Panel, PanelEvent}; use dock::{BasePanel, Panel, PanelEvent};
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{ use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, AnyElement, App, Context, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState,
Subscription, WeakEntity, Window, div, px, Pixels, Render, SharedString, Subscription, Task, Window, div, list, px,
}; };
use gpui_component::input::{Input, InputState};
use gpui_component::scroll::ScrollableElement; use gpui_component::scroll::ScrollableElement;
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex}; use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
use nostr::prelude::{Event, Kind}; use nostr::prelude::{Event, EventId, Kind};
use signed_core::{Announcement, COVER_NOTE_KIND, InboxItem, RepoAddr, activity_subject}; use signed_core::{
use signed_state::{Backend, BackendEvent, InboxStore, ProfileStore, RepoListStore}; Announcement, COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, activity_subject, filters,
};
use signed_state::{
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
};
use signed_ui::{CountBadge, SegmentButton, UserAvatar}; use signed_ui::{CountBadge, SegmentButton, UserAvatar};
use utils::relative_time; use utils::relative_time;
use super::open_repo_panel; /// Delay between a refresh request and the actual re-query.
use super::sidebar::create_repo_dialog; const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// Notification groups shown before the `Show all` toggle is used. /// Extra list rows measured above and below the visible area.
const NOTIFICATION_PREVIEW: usize = 5; const LIST_OVERDRAW: Pixels = px(400.);
/// Activity rows shown in `Continue where you left off`.
const ACTIVITY_SHOWN: usize = 15;
pub struct InboxView { pub struct InboxView {
focus_handle: FocusHandle, focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>, /// Notifications grouped by thread root, newest activity first.
/// Derives the notification and activity lists while the panel is open. notifications: Arc<Vec<InboxItem>>,
store: Entity<InboxStore>, /// The user's own recent git activity, newest first.
/// Search box filtering the `My repositories` column. activity: Arc<Vec<Event>>,
search: Entity<InputState>, /// Number of non-archived groups with an unread event.
/// Whether the inbox list is expanded past [`NOTIFICATION_PREVIEW`]. unread_count: usize,
show_all: bool, /// Copy of the global read state the current lists were derived with.
state: InboxReadState,
/// Set once the global state has been read for the current user.
state_loaded: bool,
refresh: RefreshGate,
/// Virtual-list state of the notification list, kept in sync with the
/// rendered (non-archived) notifications.
notifications_list: ListState,
/// Virtual-list state of the activity list.
activity_list: ListState,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
impl InboxView { impl InboxView {
pub fn new( pub fn new(cx: &mut Context<Self>) -> Self {
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let backend = Backend::global(cx); let backend = Backend::global(cx);
let inbox = backend.read(cx).inbox(); let inbox = backend.read(cx).inbox();
let weak = cx.entity().downgrade();
let store = cx.new(InboxStore::new); let notifications_list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW);
let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search...")); let activity_list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW);
// Drive the store from the global inbox state and from backend events. for list_state in [&notifications_list, &activity_list] {
let _subscriptions = vec![ let weak = weak.clone();
cx.observe(&inbox, |this, _inbox, cx| { list_state.set_scroll_handler(move |_, _, cx| {
this.store.update(cx, |store, cx| store.sync_state(cx)); let weak = weak.clone();
}), cx.defer(move |cx| {
cx.subscribe(&backend, |this, _backend, event, cx| { let _ = weak.update(cx, |_, cx| cx.notify());
if matches!( });
event, });
BackendEvent::SignerChanged | BackendEvent::SignerRequired
) {
this.show_all = false;
cx.notify();
} }
this.store // Drive the lists from the global inbox state and from backend events.
.update(cx, |store, cx| store.handle_backend_event(event, cx)); let _subscriptions = vec![
cx.observe(&inbox, |this, _inbox, cx| this.sync_state(cx)),
cx.subscribe(&backend, |this, _backend, event, cx| {
this.handle_backend_event(event, cx);
}), }),
]; ];
// Derive the lists once the panel exists.
cx.defer({
let weak = weak.clone();
move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) {
log::warn!("inbox dropped before bootstrap could run: {error}");
}
}
});
Self { Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
dock_area, notifications: Arc::new(Vec::new()),
store, activity: Arc::new(Vec::new()),
search, unread_count: 0,
show_all: false, state: InboxReadState::default(),
state_loaded: false,
refresh: RefreshGate::default(),
notifications_list,
activity_list,
_subscriptions, _subscriptions,
} }
} }
/// Mark every event in the group rooted at `root` read.
#[allow(dead_code)] // Wired up by the Phase 3 Unread/Archived panels.
pub fn mark_read(&mut self, root: EventId, cx: &mut Context<Self>) {
let Some(me) = Backend::global(cx).read(cx).current_user() else {
return;
};
let Some(group) = self.group_events(root) else {
return;
};
let all = self.all_notification_events();
let inbox = Backend::global(cx).read(cx).inbox();
inbox.update(cx, |inbox, cx| inbox.mark_read(&group, &all, me, cx));
}
/// Archive the group rooted at `root`.
#[allow(dead_code)] // Wired up by the Phase 3 Unread/Archived panels.
pub fn mark_archived(&mut self, root: EventId, cx: &mut Context<Self>) {
let Some(me) = Backend::global(cx).read(cx).current_user() else {
return;
};
let Some(group) = self.group_events(root) else {
return;
};
let all = self.all_notification_events();
let inbox = Backend::global(cx).read(cx).inbox();
inbox.update(cx, |inbox, cx| inbox.mark_archived(&group, &all, me, cx));
}
/// Mark every known notification read. /// Mark every known notification read.
fn mark_all_read(&mut self, cx: &mut Context<Self>) { pub fn mark_all_read(&mut self, cx: &mut Context<Self>) {
self.store.update(cx, |store, cx| store.mark_all_read(cx)); let Some(me) = Backend::global(cx).read(cx).current_user() else {
return;
};
let all = self.all_notification_events();
let inbox = Backend::global(cx).read(cx).inbox();
inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx));
} }
/// Open a repository's detail panel. /// Re-derive from the global state when it is loaded or changes.
fn open_repo( pub fn sync_state(&mut self, cx: &mut Context<Self>) {
&mut self, let inbox = Backend::global(cx).read(cx).inbox();
announcement: &Announcement, let (loaded, state) = {
window: &mut Window, let inbox = inbox.read(cx);
cx: &mut Context<Self>, (inbox.is_loaded(), inbox.state().clone())
) { };
open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
if !loaded {
let was_present =
self.state_loaded || !self.notifications.is_empty() || !self.activity.is_empty();
self.clear();
if was_present {
cx.notify();
}
return;
} }
/// Show the existing Create Repository dialog. if !self.state_loaded {
fn open_create_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) { self.state_loaded = true;
create_repo_dialog::open(self.dock_area.clone(), window, cx); self.state = state;
self.refresh_initial(cx);
return;
} }
/// Display name of the repository at `addr`, from the announcement store. if self.state != state {
fn repo_name(&self, addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> { self.state = state;
let addr = addr?; self.regroup();
RepoListStore::global(cx) self.publish_unread_count(cx);
.read(cx) cx.notify();
.announcements }
}
/// Handle a backend event that can change the derived lists.
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
match event {
BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx),
BackendEvent::NostrUpdate(updates) => {
let relevant = updates.iter().any(|update| {
let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind);
let is_comment = update.kind == Kind::Comment;
let is_event_deletion = update.kind == Kind::EventDeletion;
let is_request_to_vanish = update.kind == Kind::RequestToVanish;
is_notification || is_comment || is_event_deletion || is_request_to_vanish
});
if relevant {
self.refresh(cx);
}
}
_ => {}
}
}
/// One-shot initial load, no debounce.
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
debug_assert!(!self.refresh.debouncing());
if self.refresh.running() {
self.refresh.request();
return;
}
self.run_refresh(cx);
}
/// Re-query the local database.
fn refresh(&mut self, cx: &mut Context<Self>) {
if !self.state_loaded {
return;
}
if self.refresh.request() != RefreshRequest::Schedule {
return;
}
cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| this.run_refresh(cx))
})
.detach();
}
/// One query and apply cycle, the debounced entry point.
fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.refresh.begin();
let backend = Backend::global(cx);
let Some(me) = backend.read(cx).current_user() else {
self.refresh.abort();
return;
};
let client = backend.read(cx).client();
let state = self.state.clone();
let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await });
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let (notifications, activity, unread_count) = match work.await {
Ok(results) => results,
// Database errors are transient, keep the last lists.
Err(error) => {
log::warn!("inbox refresh failed: {error}");
return this.update(cx, |this, _cx| this.refresh.abort());
}
};
let again = this.update(cx, |this, cx| {
// The signer may have changed while the query ran, making
// these results belong to the previous user.
if Backend::global(cx).read(cx).current_user() != Some(me) {
this.refresh.abort();
return false;
}
this.notifications = Arc::new(notifications);
this.activity = Arc::new(activity);
this.unread_count = unread_count;
this.publish_unread_count(cx);
cx.notify();
this.refresh.finish()
})?;
if again {
this.update(cx, |this, cx| this.refresh(cx))?;
}
Ok(())
});
task.detach();
}
/// Recompute the unread and archived flags from the current state.
fn regroup(&mut self) {
let mut items = (*self.notifications).clone();
for item in items.iter_mut() {
item.apply_state(&self.state);
}
self.unread_count = items.iter().filter(|item| item.is_unread()).count();
self.notifications = Arc::new(items);
}
/// 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));
}
/// Forget everything derived for the current user.
fn clear(&mut self) {
self.notifications = Arc::new(Vec::new());
self.activity = Arc::new(Vec::new());
self.unread_count = 0;
self.state = InboxReadState::default();
self.state_loaded = false;
// Drop any in-flight or pending run belonging to the previous user.
self.refresh = RefreshGate::default();
}
/// Events of the group rooted at `root`.
#[allow(dead_code)] // Only used by the Phase 3 mark actions.
fn group_events(&self, root: EventId) -> Option<Vec<Event>> {
self.notifications
.iter() .iter()
.find(|announcement| announcement.addr() == *addr) .find(|item| item.root == root)
.map(display_name) .map(|item| item.events.clone())
} }
/// Bordered card with a header bar and a body. /// Every event in every group, archived groups included.
fn all_notification_events(&self) -> Vec<Event> {
self.notifications
.iter()
.flat_map(|item| item.events.iter().cloned())
.collect()
}
/// Bordered card with a header bar and a scrolling body.
///
/// Flexible so its body gets a definite height, which the virtual list
/// needs to know which rows to render.
fn section(&self, header: impl IntoElement, body: impl IntoElement, cx: &App) -> AnyElement { fn section(&self, header: impl IntoElement, body: impl IntoElement, cx: &App) -> AnyElement {
v_flex() v_flex()
.w_full() .w_full()
.flex_1()
.min_h_0()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.border_1() .border_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
@@ -131,41 +352,10 @@ impl InboxView {
fn render_inbox_panel( fn render_inbox_panel(
&self, &self,
unread: usize, unread: usize,
visible: &[&InboxItem], notifications: Arc<Vec<InboxItem>>,
visible: Vec<usize>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> AnyElement { ) -> AnyElement {
let shown = if self.show_all {
visible.len()
} else {
visible.len().min(NOTIFICATION_PREVIEW)
};
let mut body = v_flex().w_full();
if visible.is_empty() {
body = body.child(empty_state(IconName::Inbox, "You're all caught up.", cx));
} else {
for (ix, item) in visible.iter().take(shown).enumerate() {
body = body.child(self.render_notification_row(ix, item, cx));
}
if visible.len() > NOTIFICATION_PREVIEW {
let label = if self.show_all {
"Show less"
} else {
"Show all"
};
body = body.child(div().px_3().py_2().child(
SegmentButton::new("show-all", label).on_click(cx.listener(
|this, _event, _window, cx| {
this.show_all = !this.show_all;
cx.notify();
},
)),
));
}
}
let header = h_flex() let header = h_flex()
.w_full() .w_full()
.gap_2() .gap_2()
@@ -183,15 +373,86 @@ impl InboxView {
.on_click(cx.listener(|this, _event, _window, cx| this.mark_all_read(cx))), .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 rows = list(list_state.clone(), move |ix, _window, cx| {
let Some(item) = visible.get(ix).and_then(|&ix| notifications.get(ix)) else {
return div().into_any_element();
};
notification_row(ix, item, cx)
})
.size_full()
.min_h_0();
div()
.relative()
.flex_1()
.min_h_0()
.child(rows)
.vertical_scrollbar(&list_state)
.into_any_element()
};
self.section(header, body, cx) self.section(header, body, cx)
} }
fn render_notification_row( fn render_activity_panel(
&self, &self,
ix: usize, activity: Arc<Vec<Event>>,
item: &InboxItem,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> AnyElement { ) -> AnyElement {
let header = h_flex()
.w_full()
.gap_2()
.child(Icon::new(CustomIconName::Recent).small())
.child(
div()
.text_sm()
.font_semibold()
.child(SharedString::from("Continue where you left off")),
);
let body = if activity.is_empty() {
empty_state(CustomIconName::Recent, "No recent activity.", cx)
} else {
let list_state = self.activity_list.clone();
let rows = list(list_state.clone(), move |ix, _window, cx| {
let Some(event) = activity.get(ix) else {
return div().into_any_element();
};
activity_row(ix, event, cx)
})
.size_full()
.min_h_0();
div()
.relative()
.flex_1()
.min_h_0()
.child(rows)
.vertical_scrollbar(&list_state)
.into_any_element()
};
self.section(header, body, cx)
}
}
/// Display name of the repository at `addr`, from the announcement store.
fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> {
let addr = addr?;
RepoListStore::global(cx)
.read(cx)
.announcements
.iter()
.find(|announcement| announcement.addr() == *addr)
.map(display_name)
}
/// Leading row of a notification group, newest event first.
fn notification_row(ix: usize, item: &InboxItem, cx: &App) -> AnyElement {
let Some(newest) = item.events.first() else { let Some(newest) = item.events.first() else {
return div().into_any_element(); return div().into_any_element();
}; };
@@ -200,12 +461,12 @@ impl InboxView {
let profile = profiles.read(cx).get(&newest.pubkey); let profile = profiles.read(cx).get(&newest.pubkey);
let kind = item.root_kind.unwrap_or(newest.kind); let kind = item.root_kind.unwrap_or(newest.kind);
let subject = SharedString::from(activity_subject(newest)); let subject = SharedString::from(activity_subject(newest));
let repo = self.repo_name(item.address.as_ref(), cx); let repo = repo_name(item.address.as_ref(), cx);
let age = relative_time(item.latest_activity()); let age = relative_time(item.latest_activity());
let unread = item.is_unread(); let unread = item.is_unread();
h_flex() h_flex()
.id(ix) .id(("inbox-row", ix))
.w_full() .w_full()
.gap_3() .gap_3()
.px_3() .px_3()
@@ -251,45 +512,17 @@ impl InboxView {
) )
}) })
.into_any_element() .into_any_element()
} }
fn render_activity_panel(&self, activity: &[Event], cx: &mut Context<Self>) -> AnyElement { /// One row of the user's own recent git activity.
let mut body = v_flex().w_full(); fn activity_row(ix: usize, event: &Event, cx: &App) -> AnyElement {
if activity.is_empty() {
body = body.child(empty_state(
CustomIconName::Recent,
"No recent activity.",
cx,
));
} else {
for (ix, event) in activity.iter().take(ACTIVITY_SHOWN).enumerate() {
body = body.child(self.render_activity_row(ix, event, cx));
}
}
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")),
);
self.section(header, body, cx)
}
fn render_activity_row(&self, ix: usize, event: &Event, cx: &mut Context<Self>) -> AnyElement {
let kind = event.kind; let kind = event.kind;
let subject = SharedString::from(activity_subject(event)); let subject = SharedString::from(activity_subject(event));
let repo = self.repo_name(event.tags.coordinates().next().as_ref(), cx); let repo = repo_name(event.tags.coordinates().next().as_ref(), cx);
let age = relative_time(event.created_at); let age = relative_time(event.created_at);
h_flex() h_flex()
.id(ix) .id(("activity-row", ix))
.w_full() .w_full()
.gap_3() .gap_3()
.px_3() .px_3()
@@ -324,155 +557,6 @@ impl InboxView {
), ),
) )
.into_any_element() .into_any_element()
}
fn render_repos_panel(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(me) = Backend::global(cx).read(cx).current_user() else {
let header = h_flex()
.w_full()
.gap_2()
.child(Icon::new(CustomIconName::GitBranch).small())
.child(
div()
.text_sm()
.font_semibold()
.child(SharedString::from("My repositories")),
);
let body = v_flex().w_full().child(empty_state(
CustomIconName::GitBranch,
"Sign in to see your repositories.",
cx,
));
return self.section(header, body, cx);
};
let query = self.search.read(cx).value().trim().to_lowercase();
let repos: Vec<Announcement> = RepoListStore::global(cx)
.read(cx)
.announcements_of(&me)
.into_iter()
.filter(|announcement| {
query.is_empty()
|| display_name(announcement).to_lowercase().contains(&query)
|| announcement.id.to_lowercase().contains(&query)
})
.collect();
let mut body = v_flex().w_full().child(
div().px_3().py_2().child(
Input::new(&self.search)
.cleanable(true)
.w_full()
.text_sm()
.border_color(cx.theme().muted)
.bg(cx.theme().muted)
.prefix(Icon::new(IconName::Search).small()),
),
);
if repos.is_empty() {
body = body.child(empty_state(
CustomIconName::GitBranch,
"No repositories yet.",
cx,
));
} else {
for (ix, announcement) in repos.iter().enumerate() {
body = body.child(self.render_repo_row(ix, announcement, cx));
}
}
let header = h_flex()
.w_full()
.gap_2()
.child(Icon::new(CustomIconName::GitBranch).small())
.child(
div()
.text_sm()
.font_semibold()
.child(SharedString::from("My repositories")),
)
.when(!repos.is_empty(), |this| {
this.child(CountBadge::new(repos.len()))
})
.child(div().flex_1())
.child(
SegmentButton::new("new-repo", "New")
.icon(Icon::new(CustomIconName::CirclePlus))
.primary()
.on_click(
cx.listener(|this, _event, window, cx| this.open_create_repo(window, cx)),
),
);
self.section(header, body, cx)
}
fn render_repo_row(
&self,
ix: usize,
announcement: &Announcement,
cx: &mut Context<Self>,
) -> AnyElement {
let name = display_name(announcement);
let description = announcement.description.clone().unwrap_or_default();
let activity = RepoListStore::global(cx)
.read(cx)
.last_activity
.get(&announcement.addr())
.copied();
h_flex()
.id(ix)
.w_full()
.gap_3()
.px_3()
.py_2()
.items_center()
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().list_hover))
.child(
Icon::new(CustomIconName::GitBranch)
.small()
.text_color(cx.theme().muted_foreground),
)
.child(
v_flex()
.flex_1()
.min_w_0()
.child(
div()
.text_sm()
.whitespace_nowrap()
.text_ellipsis()
.child(name),
)
.when(!description.is_empty(), |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.whitespace_nowrap()
.text_ellipsis()
.child(SharedString::from(description)),
)
}),
)
.when_some(activity, |this, activity| {
this.child(
div()
.flex_shrink_0()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(relative_time(activity))),
)
})
.on_click(cx.listener({
let announcement = announcement.clone();
move |this, _event, window, cx| this.open_repo(&announcement, window, cx)
}))
.into_any_element()
}
} }
/// Name to show for a repository, its `name` tag or its id. /// Name to show for a repository, its `name` tag or its id.
@@ -530,6 +614,8 @@ fn kind_label(kind: Kind) -> &'static str {
fn empty_state(icon: impl IconNamed, message: &str, cx: &App) -> AnyElement { fn empty_state(icon: impl IconNamed, message: &str, cx: &App) -> AnyElement {
v_flex() v_flex()
.w_full() .w_full()
.flex_1()
.min_h_0()
.items_center() .items_center()
.justify_center() .justify_center()
.gap_2() .gap_2()
@@ -570,41 +656,31 @@ impl Focusable for InboxView {
impl Render for InboxView { impl Render for InboxView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (unread, notifications, activity) = { let unread = self.unread_count;
let store = self.store.read(cx); let notifications = self.notifications.clone();
( let activity = self.activity.clone();
store.unread_count,
store.notifications.clone(),
store.activity.clone(),
)
};
let visible: Vec<&InboxItem> = notifications.iter().filter(|item| !item.archived).collect(); 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());
}
v_flex() v_flex()
.size_full() .size_full()
.image_cache(gpui::retain_all("inbox")) .image_cache(gpui::retain_all("inbox"))
.child(
v_flex().size_full().overflow_y_scrollbar().child(
h_flex()
.items_start()
.gap_4() .gap_4()
.p_4() .p_4()
.child( .child(self.render_inbox_panel(unread, notifications, visible, cx))
v_flex() .child(self.render_activity_panel(activity, cx))
.flex_1()
.min_w_0()
.gap_4()
.child(self.render_inbox_panel(unread, &visible, cx))
.child(self.render_activity_panel(&activity, cx)),
)
.child(
v_flex()
.w(px(300.))
.flex_shrink_0()
.child(self.render_repos_panel(cx)),
),
),
)
} }
} }
+2 -2
View File
@@ -25,7 +25,7 @@ use signed_ui::{CountBadge, NavItem, PixelAvatar, UserAvatar, title_bar_drag_han
use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel}; use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel};
pub(crate) mod create_repo_dialog; mod create_repo_dialog;
pub(crate) mod grasp_servers; pub(crate) mod grasp_servers;
mod import_dialog; mod import_dialog;
mod onboarding_dialog; mod onboarding_dialog;
@@ -224,7 +224,7 @@ impl SidebarPanel {
return; return;
} }
let panel = cx.new(|cx| InboxView::new(self.dock_area.clone(), window, cx)); let panel = cx.new(InboxView::new);
self.inbox = Some(panel.downgrade()); self.inbox = Some(panel.downgrade());
let _ = self.dock_area.update(cx, |dock_area, cx| { let _ = self.dock_area.update(cx, |dock_area, cx| {
+125 -102
View File
@@ -11,8 +11,8 @@ Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for
> `cargo test -p workspace` (7), `cargo clippy -p workspace --all-targets` clean, > `cargo test -p workspace` (7), `cargo clippy -p workspace --all-targets` clean,
> `cargo check --workspace --all-targets` succeeds. > `cargo check --workspace --all-targets` succeeds.
> Phases 3-5 are not started. This document reflects the implementation as it stands, including the > Phases 3-5 are not started. This document reflects the implementation as it stands, including the
> Phase 1 refactors and the §4.3 split of the inbox into a thin global `Inbox` and a panel-scoped > Phase 1 refactors and the §4.3 split of the inbox into a thin global `Inbox` and a
> `InboxStore`. > panel-owned derivation.
## 1. What the GitWorkshop home screen is ## 1. What the GitWorkshop home screen is
@@ -55,47 +55,37 @@ Data hooks:
| Priority | Section | Notes | | Priority | Section | Notes |
|---|---|---| |---|---|---|
| **P0** | Inbox panel | Activity directed at you, grouped by thread root; unread badge; mark all read; top N + show all | | **P0** | Inbox panel | Activity directed at you, grouped by thread root; unread badge; mark all read; all groups shown |
| **P0** | My repositories | Reuse `RepoListStore::announcements_of(me)`; search filter; existing New-repo dialog |
| **P0** | Continue where you left off | Your own recent git activity, newest first | | **P0** | Continue where you left off | Your own recent git activity, newest first |
| **P1** | Unread / Archived sub-views | Open as **bottom-dock panels**, not tabs inside the inbox panel | | **P1** | Unread / Archived sub-views | Open as **bottom-dock panels**, not tabs inside the inbox panel |
| **P1** | Click-through | Open the repo panel at the relevant PR/issue | | **P1** | Click-through | Open the repo panel at the relevant PR/issue |
| **P2 (defer)** | Standalone notifications page, NIP-65 relay discovery, pagination | Web-app concerns | | **P2 (defer)** | Standalone notifications page, NIP-65 relay discovery, pagination | Web-app concerns |
| **Out of scope** | Greeting header, followed repositories, private repositories, pinned repositories | Not needed in Signed | | **Out of scope** | Greeting header, my repositories, followed repositories, private repositories, pinned repositories | Not needed in Signed |
Notes: Notes:
- There is **no greeting header**. The screen starts with the inbox panel. - There is **no greeting header**. The screen starts with the inbox panel.
- There is **no My repositories column**. The sidebar already lists the signed-in user's repositories, so the inbox is a single column.
- Unread and Archived are separate panels opened in the bottom dock, not tabs in the inbox panel. - Unread and Archived are separate panels opened in the bottom dock, not tabs in the inbox panel.
## 3. The Signed screen ## 3. The Signed screen
`InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item (currently a `InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item (currently a
placeholder that opens Explore). It is one scrollable two-column flex row: placeholder that opens Explore). It is two flexible bordered cards, each a virtual list:
``` ```
+--------------------------------------------------+-----------------------+ +-------------------------------------------------------------------------+
| Inbox (3 unread) [Unread] [Archived] [Mark all read] | | Inbox (3 unread) [Unread] [Archived] [Mark all read] |
| [avatar] issue opened on you/repo 2m | | [avatar] issue opened on you/repo 2m (scroll) |
| [avatar] commented on "Fix parser" 1h | | [avatar] commented on "Fix parser" 1h |
| [avatar] PR update on you/repo 3h | | [avatar] PR update on you/repo 3h |
| [Show all] | +-------------------------------------------------------------------------+
| | | Continue where you left off (scroll)|
| Continue where you left off |
| [icon] "Fix parser bug" you/repo opened 3d | | [icon] "Fix parser bug" you/repo opened 3d |
| [icon] "Add retry" you/repo PR 5d | | [icon] "Add retry" you/repo PR 5d |
+--------------------------------------------------+-----------------------+ +-------------------------------------------------------------------------+
| bottom dock: Unread or Archived list (opened by the header buttons) | | bottom dock: Unread or Archived list (opened by the header buttons) |
+--------------------------------------------------+-----------------------+ +-------------------------------------------------------------------------+
```
The right column is **My repositories**, mirroring the sidebar's signed-in repo list:
```
| My repositories |
| [search] [New] |
| repo row |
| repo row |
``` ```
## 4. Data layer ## 4. Data layer
@@ -269,7 +259,7 @@ key in the store (see §4.3). An earlier implementation deleted the previous eve
id across saves; that was removed as more derived state than it was worth. id across saves; that was removed as more derived state than it was worth.
`NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs. `NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs.
### 4.3 `signed_state`: a thin global `Inbox` and a panel-scoped `InboxStore` ### 4.3 Data layer: a thin global `Inbox`, a panel-owned derivation
The inbox is split in two, because the expensive derivation is only needed while the home screen is The inbox is split in two, because the expensive derivation is only needed while the home screen is
open. open.
@@ -290,7 +280,7 @@ pub struct Backend {
pub struct Inbox { pub struct Inbox {
state: InboxReadState, state: InboxReadState,
state_loaded: bool, state_loaded: bool,
/// Published by `InboxStore` for the sidebar badge. /// Published by the inbox panel for the sidebar badge.
pub unread_count: usize, pub unread_count: usize,
} }
@@ -306,53 +296,70 @@ impl Inbox {
} }
``` ```
**`InboxStore`** is created by `InboxView` and therefore only exists while the panel is open. It **The panel owns the derivation.** `InboxView` itself holds the derived lists, the copy of the read
derives the notification groups and the activity list, coalesces refreshes and applies the read state they were computed with, and the refresh coalescing. There is no separate store entity: the
state for rendering. panel is the only consumer, so an `Entity<InboxStore>` would add an `update` indirection and a
forwarding subscription without buying any sharing.
```rust ```rust
pub struct InboxStore { pub struct InboxView {
pub notifications: Arc<Vec<InboxItem>>, focus_handle: FocusHandle,
pub activity: Arc<Vec<Event>>, notifications: Arc<Vec<InboxItem>>,
pub unread_count: usize, activity: Arc<Vec<Event>>,
unread_count: usize,
state: InboxReadState, state: InboxReadState,
state_loaded: bool, state_loaded: bool,
refresh: RefreshGate, refresh: RefreshGate,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
impl InboxStore { impl InboxView {
pub fn new(cx: &mut Context<Self>) -> Self; pub fn new(cx: &mut Context<Self>) -> Self; // cx.defer(… sync_state)
pub fn refresh(&mut self, cx: &mut Context<Self>); pub fn sync_state(&mut self, cx); // observes the global Inbox
pub fn mark_read(&mut self, root: EventId, cx); pub fn mark_read(&mut self, root: EventId, cx); // Phase 3
pub fn mark_archived(&mut self, root: EventId, cx); pub fn mark_archived(&mut self, root: EventId, cx); // Phase 3
pub fn mark_all_read(&mut self, cx); pub fn mark_all_read(&mut self, cx);
fn handle_backend_event(&mut self, event: &BackendEvent, cx);
fn refresh(&mut self, cx);
fn run_refresh(&mut self, cx);
} }
``` ```
`InboxStore` has no subscriptions of its own. The panel owns the two subscriptions that carry The panel owns the two subscriptions that carry logic: it observes the global `Inbox`
logic: it observes the global `Inbox` (`InboxStore::sync_state`) and subscribes to `Backend` (`InboxView::sync_state`) and subscribes to `Backend` (`InboxView::handle_backend_event`).
(`InboxStore::handle_backend_event`, plus resetting `show_all` on a signer change). Re-rendering Re-rendering needs no subscription: GPUI invalidates a window for every entity it read during
needs no subscription: GPUI invalidates a window for every entity it read during render, so the render, so the panel tracks `RepoListStore` and `ProfileStore` just by reading them in `render`.
panel tracks the store, the search `InputState` and `RepoListStore` just by reading them in The panel writes back to `Backend` only to publish the unread count for the badge.
`render`. The store never writes derived data back to `Backend` except the unread count.
**Lifespan.** `Inbox` is created with the backend but idles until the user has a signer. `InboxStore` **`signed_state::query_inbox`.** The database work stays in `signed_state`, so the UI crate never
is created and dropped with the panel. Neither is wired from the `desktop` crate and queries LMDB directly. `query_inbox` returns the grouped notifications, the user's own git
activity and the unread count; the panel applies the results on the main thread. `RefreshGate` is
re-exported for the panel's debounce.
```rust
pub async fn query_inbox(
client: &Client,
me: PublicKey,
state: &InboxReadState,
) -> Result<(Vec<InboxItem>, Vec<Event>, usize), Error>;
```
**Lifespan.** `Inbox` is created with the backend but idles until the user has a signer. The
derived lists live only as long as the panel. Nothing is wired from the `desktop` crate and
`signed_state::init` gains no parameters. `signed_state::init` gains no parameters.
**Badge trade-off.** The unread count is derived by the store, so the sidebar badge is only current **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 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. expensive derivation to run globally, which is exactly what this split avoids.
The dependency chain is `Backend``Inbox` and `InboxView``InboxStore`; the store reaches back The dependency chain is `Backend``Inbox` and `InboxView``query_inbox`; the panel reaches back
only to publish the unread count. only to publish the unread count.
`Backend` no longer funnels its events through the inbox: `InboxStore` subscribes to `Backend` `Backend` does not funnel its events through the inbox: the panel subscribes to `Backend` directly.
directly. `BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: `BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: `CheckoutsStore`
`CheckoutsStore` and `SidebarPanel` consume them. They no longer drive the inbox. and `SidebarPanel` consume them. They no longer drive the inbox.
`InboxStore::handle_backend_event` refreshes on: `InboxView::handle_backend_event` refreshes on:
- `NostrUpdate(updates)`: when any update kind is in `NOTIFICATION_KINDS`, is `Kind::Comment`, or is - `NostrUpdate(updates)`: when any update kind is in `NOTIFICATION_KINDS`, is `Kind::Comment`, or is
a deletion (`EventDeletion` / `RequestToVanish`). a deletion (`EventDeletion` / `RequestToVanish`).
@@ -400,7 +407,7 @@ time and never cached. (NIP-65 outbox relay discovery is deferred; Signed does n
10002 yet.) `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` has no `subscribe_remote` 10002 yet.) `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` has no `subscribe_remote`
/ `connect_own_repo_relays`. / `connect_own_repo_relays`.
**Activation** clears the state and loads the NIP-78 state from LMDB. `InboxStore` clears its own **Activation** clears the state and loads the NIP-78 state from LMDB. The panel clears its own
lists and in-flight refresh when it sees the unloaded state, then refreshes once it is loaded: lists and in-flight refresh when it sees the unloaded state, then refreshes once it is loaded:
```rust ```rust
@@ -416,10 +423,10 @@ Reading the state event needs no signer at all (the `d` tag carries the identity
still gated on the signer because the fetch filters need the user's pubkey. still gated on the signer because the fetch filters need the user's pubkey.
**Fetch** reuses `Backend::subscribe_bootstrap` and `Backend::connect_repo_relays` through **Fetch** reuses `Backend::subscribe_bootstrap` and `Backend::connect_repo_relays` through
`Backend::sync_inbox` (see above). The query the store runs is intentionally the offline-first cache `Backend::sync_inbox` (see above). The query the panel runs is intentionally the offline-first cache
read, not a wait on the network; see the note below. read, not a wait on the network; see the note below.
**Refresh** (`InboxStore::run_refresh`, mirrors `RepoListStore::run_refresh`): **Refresh** (`InboxView::run_refresh`, mirrors `RepoListStore::run_refresh`):
- `cx.background_spawn`: query the notification filters and the activity filter from - `cx.background_spawn`: query the notification filters and the activity filter from
`client.database()`. `client.database()`.
@@ -427,13 +434,13 @@ read, not a wait on the network; see the note below.
- Build `HashMap<EventId, Event>` for root walking; group the notification events with - Build `HashMap<EventId, Event>` for root walking; group the notification events with
`inbox::group`. `inbox::group`.
- Filter the activity events: keep issues/PRs/patches/statuses/cover notes, and comments only when - Filter the activity events: keep issues/PRs/patches/statuses/cover notes, and comments only when
their `K` tag is a git kind; sort newest first; take the top N. their `K` tag is a git kind; sort newest first.
- Cross back to the main thread: guard on `Backend::global(cx).read(cx).current_user() == - Cross back to the main thread: guard on `Backend::global(cx).read(cx).current_user() ==
Some(me)`; if the signer changed while the query ran, `refresh.abort()` instead of applying, so a Some(me)`; if the signer changed while the query ran, `refresh.abort()` instead of applying, so a
previous user's results never land. Then set `notifications`, `activity`, `unread_count`, publish previous user's results never land. Then set `notifications`, `activity`, `unread_count`, publish
the unread count to the global `Inbox`, `cx.notify()`, `refresh.finish()`. the unread count to the global `Inbox`, `cx.notify()`, `refresh.finish()`.
The store's `sync_state` reacts to the global `Inbox`: while the state is not loaded it clears the `InboxView::sync_state` reacts to the global `Inbox`: while the state is not loaded it clears the
lists, on the first load it runs the initial refresh, and on a state change (a mark action) it lists, on the first load it runs the initial refresh, and on a state change (a mark action) it
re-derives the flags (`InboxItem::apply_state`) and publishes the new unread count. re-derives the flags (`InboxItem::apply_state`) and publishes the new unread count.
@@ -445,14 +452,14 @@ timing: received events are written to LMDB and surfaced as `ClientNotification:
`Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the store refreshes. This was `Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the store refreshes. This was
reviewed and left as-is. reviewed and left as-is.
**Actions**: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()` live on `InboxStore`, which **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 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 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 store then re-derives and to LMDB (signed with a fresh random key, see 4.2), and notifies. The panel then re-derives and
publishes the unread count. publishes the unread count.
**My repositories needs no new store**: `RepoListStore` already holds every announcement and exposes **Repository names need no new store**: `RepoListStore` already holds every announcement and
`announcements_of(user)`. `repo_name` resolves an address to a display name.
### 4.4 `Cargo.toml` ### 4.4 `Cargo.toml`
@@ -464,23 +471,23 @@ publishes the unread count.
### 5.1 `InboxView` center panel ### 5.1 `InboxView` center panel
New `crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`. New `crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`.
It owns an `Entity<InboxStore>`; GPUI re-renders the panel when the store changes because the panel It owns the derived lists directly, so `cx.notify()` from an update re-renders it. The panel is a
reads it during render. One `overflow_y_scrollbar` container holding a two-column flex row, left column of two flexible bordered cards (`flex_1`, `min_h_0`), each with a header bar and a scrolling
column flexible and right column fixed at 300px. Both columns are bordered cards with a header bar. 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.
- **Inbox column**: header with the unread count badge and **Mark all read**; then the non-archived - **Inbox card**: header with the unread count badge and **Mark all read**; then every non-archived
notification items (top 5, with a **Show all** toggle expanding inline). Rows show the actor notification item. Rows show the actor avatar, a kind icon, the subject, the kind label, the repo
avatar, a kind icon, the subject, the kind label, the repo name, a relative time, and an unread name, a relative time, and an unread dot (the subject is semibold while unread). Empty state:
dot (the subject is semibold while unread). Empty state: "You're all caught up." with "You're all caught up." with `IconName::Inbox`.
`IconName::Inbox`. - **Continue where you left off**: every event in the activity list, each row a kind icon, subject,
- **Continue where you left off**: `Inbox::activity`, top 15, each row a kind icon, subject, kind kind label, repo name, and relative time.
label, repo name, and relative time.
- **My repositories**: `RepoListStore::announcements_of(me)` with a search `InputState` (same
pattern as `RepoListView`) and a **New** button opening the existing `create_repo_dialog`. Rows
open `open_repo_panel`. Empty state "No repositories yet."; without a signer it says
"Sign in to see your repositories.".
No greeting header. The **Unread** and **Archived** header buttons belong to Phase 3 and are not No greeting header, and no **My repositories** column - the sidebar already lists the user's
repositories. The **Unread** and **Archived** header buttons belong to Phase 3 and are not
rendered until `add_bottom_panel` / `InboxFilterView` exist (see 5.2). rendered until `add_bottom_panel` / `InboxFilterView` exist (see 5.2).
### 5.2 Unread / Archived as bottom-dock panels ### 5.2 Unread / Archived as bottom-dock panels
@@ -502,7 +509,7 @@ pub fn add_bottom_panel(
The workspace already supports a bottom dock and prunes it when empty (`workspace.rs`). Then: The workspace already supports a bottom dock and prunes it when empty (`workspace.rs`). Then:
- New `InboxFilterView` panel taking a mode `InboxFilter::Unread | InboxFilter::Archived` and the - New `InboxFilterView` panel taking a mode `InboxFilter::Unread | InboxFilter::Archived` and the
`Entity<InboxStore>`. It renders the matching subset of `InboxStore::notifications` as a list. `Entity<InboxView>`. It renders the matching subset of the panel's notifications as a list.
- The **Unread** and **Archived** header buttons in `InboxView` (added in Phase 3) call - The **Unread** and **Archived** header buttons in `InboxView` (added in Phase 3) call
`add_bottom_panel` with the requested mode. `InboxView` keeps `add_bottom_panel` with the requested mode. `InboxView` keeps
`filter_view: Option<WeakEntity<InboxFilterView>>`; when it already exists, update its mode and `filter_view: Option<WeakEntity<InboxFilterView>>`; when it already exists, update its mode and
@@ -523,7 +530,7 @@ In `views/sidebar/mod.rs`:
.on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))), .on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))),
``` ```
- `cx.observe` `Backend::global(cx).read(cx).inbox()` so the badge follows the count the store - `cx.observe` `Backend::global(cx).read(cx).inbox()` so the badge follows the count the panel
publishes. publishes.
### 5.4 Click-through (P1) ### 5.4 Click-through (P1)
@@ -566,12 +573,12 @@ patch-root click opens the repo panel. Note as a known limitation.
| `crates/signed_core/src/inbox.rs` | **new**: `InboxItem`, `notification_root`, `group`, `InboxReadState`, tests | | `crates/signed_core/src/inbox.rs` | **new**: `InboxItem`, `notification_root`, `group`, `InboxReadState`, tests |
| `crates/signed_core/src/lib.rs` | `mod inbox;` and re-exports | | `crates/signed_core/src/lib.rs` | `mod inbox;` and re-exports |
| `crates/signed_state/Cargo.toml` | add `serde_json` | | `crates/signed_state/Cargo.toml` | add `serde_json` |
| `crates/signed_state/src/inbox.rs` | thin global `Inbox` (NIP-78 read state) and panel-scoped `InboxStore` (query, grouping, activity, actions) | | `crates/signed_state/src/inbox.rs` | thin global `Inbox` (NIP-78 read state, mark actions) and `query_inbox` (query, grouping, activity) |
| `crates/signed_state/src/backend.rs` | `inbox: Entity<Inbox>` field, construction, `inbox()` accessor, `sync_inbox`, `RepoListStore` import | | `crates/signed_state/src/backend.rs` | `inbox: Entity<Inbox>` field, construction, `inbox()` accessor, `sync_inbox`, `RepoListStore` import |
| `crates/signed_state/src/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users | | `crates/signed_state/src/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users |
| `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` (no global install) | | `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` and `query_inbox`; re-export `RefreshGate` (no global install) |
| `crates/dock/src/lib.rs` | `add_bottom_panel` helper | | `crates/dock/src/lib.rs` | `add_bottom_panel` helper |
| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel owning `Entity<InboxStore>` (`InboxFilterView` is Phase 3) | | `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel owning the derived lists directly (`InboxFilterView` is Phase 3) |
| `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;` | | `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;` |
| `crates/workspace/src/views/sidebar/mod.rs` | `inbox`/`unread` fields, `open_inbox`, nav wiring and badge, `create_repo_dialog` visibility | | `crates/workspace/src/views/sidebar/mod.rs` | `inbox`/`unread` fields, `open_inbox`, nav wiring and badge, `create_repo_dialog` visibility |
| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `RepoDetailView::open_item` (P1) | | `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `RepoDetailView::open_item` (P1) |
@@ -594,7 +601,7 @@ activates the `Inbox` child entity at each signer transition.
2. **Phase 1 - store**: `Inbox` child entity, activated by `Backend::sync_inbox` once a signer 2. **Phase 1 - store**: `Inbox` child entity, activated by `Backend::sync_inbox` once a signer
exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the
implementation notes below. implementation notes below.
3. **Phase 2 - screen**: `InboxView` (inbox + activity + my repositories), sidebar nav and badge. 3. **Phase 2 - screen**: `InboxView` (inbox + activity), sidebar nav and badge.
**DONE.** See the implementation notes below. **DONE.** See the implementation notes below.
4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived. 4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived.
5. **Phase 4 - click-through**: `open_item` and announcement lookup. 5. **Phase 4 - click-through**: `open_item` and announcement lookup.
@@ -641,7 +648,7 @@ and two additions to `crates/signed_core/src/inbox.rs`.
- Actions: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each marks the group, advances - Actions: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each marks the group, advances
the relevant cutoffs against **all** notification events (matching GitWorkshop's use of `allEvents`), the relevant cutoffs against **all** notification events (matching GitWorkshop's use of `allEvents`),
re-derives the groups locally so the UI updates immediately, then persists in the background. re-derives the groups locally so the UI updates immediately, then persists in the background.
- The store keeps no derived state. The signing key is generated per save, the current user is read - The global `Inbox` keeps no derived state. The signing key is generated per save, the current user is read
from `Backend::current_user()` where needed, and the relays of the user's own repositories are from `Backend::current_user()` where needed, and the relays of the user's own repositories are
queried from `RepoListStore` in `Backend::sync_inbox` rather than cached. There is no prune logic queried from `RepoListStore` in `Backend::sync_inbox` rather than cached. There is no prune logic
either: the newest state event is selected by `created_at`. either: the newest state event is selected by `created_at`.
@@ -654,26 +661,30 @@ and two additions to `crates/signed_core/src/inbox.rs`.
Files: `crates/workspace/src/views/{inbox.rs, mod.rs, sidebar/mod.rs}`. No store changes. Files: `crates/workspace/src/views/{inbox.rs, mod.rs, sidebar/mod.rs}`. No store changes.
- `InboxView` is a plain center panel like `RepoListView`. It owns an `Entity<InboxStore>` and - `InboxView` is a plain center panel like `RepoListView`; the sidebar holds a
drives it; the sidebar holds a `WeakEntity<InboxView>` so there is no cycle. The panel's `Backend` `WeakEntity<InboxView>` so there is no cycle. Re-rendering relies on GPUI's render-time entity
subscription also resets `show_all` on a signer change. Re-rendering relies on GPUI's render-time tracking rather than explicit observations. (Phase 2 introduced an `Entity<InboxStore>` here; it
entity tracking rather than explicit observations. was later folded into the panel - see the store-merge note below.)
- The layout is one `overflow_y_scrollbar` container with a two-column `h_flex`. The left column - The layout is a column of two flexible bordered cards (`gap_4`, `p_4`, each `flex_1`/`min_h_0`),
(`flex_1`, `min_w_0`) stacks the inbox card over the activity card; the right column is fixed at inbox over activity. Each card is a rounded `v_flex` with a header bar (`section`) and a
300px. Each card is a bordered rounded `v_flex` with a header bar (`section`). `gpui::list` body. There is no **My repositories** column: the sidebar already lists the user's
repositories, so the panel is a single column.
- Notification rows read the newest event of each group for the actor, subject and time, and the - Notification rows read the newest event of each group for the actor, subject and time, and the
root's kind for the icon. The repo name is resolved from `item.address` through a linear scan of root's kind for the icon. The repo name is resolved from `item.address` through a linear scan of
`RepoListStore::announcements` (`repo_name`); the list is small and this keeps the store unchanged. `RepoListStore::announcements` (`repo_name`); the list is small and this keeps the store unchanged.
- The **Unread** / **Archived** header buttons are intentionally absent: they need - The **Unread** / **Archived** header buttons are intentionally absent: they need
`add_bottom_panel` / `InboxFilterView`, which are Phase 3. The header is **Mark all read** plus the `add_bottom_panel` / `InboxFilterView`, which are Phase 3. The header is only **Mark all read**,
inline **Show all** toggle, so the panel is fully usable on its own. so the panel is fully usable on its own.
- `kind_icon` / `kind_label` map a `Kind` to a `CustomIconName`/`IconName` and a short noun. The - `kind_icon` / `kind_label` map a `Kind` to a `CustomIconName`/`IconName` and a short noun. The
cover note is compared with `==` rather than matched, since `Kind` cannot appear in a pattern arm. cover note is compared with `==` rather than matched, since `Kind` cannot appear in a pattern arm.
- Sidebar: `open_inbox` mirrors `open_explore` (return if open, else add a center panel); the inbox - Sidebar: `open_inbox` mirrors `open_explore` (return if open, else add a center panel); the inbox
nav item is repointed and carries a `CountBadge` suffix driven by the observed unread count. The nav item is repointed 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; 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. auto-opening it as the post-login home is a possible follow-up.
- `create_repo_dialog` became `pub(crate) mod` in `sidebar` so the inbox New button reuses it. - 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.
`InboxView::new` now takes only `cx`. `create_repo_dialog` is private again.
- `cargo clippy -p workspace --all-targets` is clean and `cargo check --workspace --all-targets` - `cargo clippy -p workspace --all-targets` is clean and `cargo check --workspace --all-targets`
succeeds. `cargo test -p signed_core` (68) and `cargo test -p signed_state` (24) still pass. succeeds. `cargo test -p signed_core` (68) and `cargo test -p signed_state` (24) still pass.
@@ -685,30 +696,42 @@ feed it.
- The global `Inbox` is now thin: `state: InboxReadState`, `state_loaded`, and the `unread_count` the - The global `Inbox` is now thin: `state: InboxReadState`, `state_loaded`, and the `unread_count` the
sidebar badge reads, plus the NIP-78 load/save and the mark actions. sidebar badge reads, plus the NIP-78 load/save and the mark actions.
- `InboxStore` is created by `InboxView` and owns the query, grouping, activity list, refresh gate
and actions. It holds no subscriptions: the panel observes the global `Inbox` and subscribes to
`Backend`, driving the store. Re-renders rely on GPUI's render-time entity tracking.
- `Backend::emit` is gone. All `BackendEvent`s are emitted with `cx.emit` again, and `sync_inbox` - `Backend::emit` is gone. All `BackendEvent`s are emitted with `cx.emit` again, and `sync_inbox`
updates the inbox synchronously, passing the client in so nothing reads `Backend` mid-update. updates the inbox synchronously, passing the client in so nothing reads `Backend` mid-update.
- `InboxView` observes only its store; the two observations it used to hold moved into the store. - The panel became the client-side owner of the derivation, initially through a panel-scoped
- `signed_core` is unchanged. `cargo test -p signed_core` (68), `cargo test -p signed_state` (24) and `Entity<InboxStore>`.
`cargo test -p workspace` (7) pass; clippy and `cargo check --workspace --all-targets` are clean. - `signed_core` is unchanged.
### Store merged into the panel (after Phase 2)
The `InboxStore` entity was then folded into `InboxView`, since the panel was its only consumer.
- `InboxView` holds `notifications`, `activity`, `unread_count`, `state`, `state_loaded` and
`RefreshGate` as fields, and the store's methods (`sync_state`, `handle_backend_event`,
`refresh`/`run_refresh`, `regroup`, `publish_unread_count`, `clear`, the mark actions) became panel
methods. The two subscriptions call them directly, with no `update` indirection.
- The database work stayed in `signed_state` as `pub async fn query_inbox(...)`; `RefreshGate` and
`RefreshRequest` are re-exported. The UI crate never queries LMDB directly.
- `mark_read`, `mark_archived` and their `group_events` helper carry a scoped `#[allow(dead_code)]`
until the Phase 3 sub-views wire them up.
- `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 Trade-off: the sidebar badge is only current after the inbox is opened once, because the unread
count is derived by the panel-scoped store. count is derived by the panel.
## 8. Validation ## 8. Validation
- `cargo test -p signed_core` (68 tests): root resolution, grouping, read-state cutoff, serde round-trip. - `cargo test -p signed_core` (68 tests): root resolution, grouping, read-state cutoff, serde round-trip.
- `cargo test -p signed_state` (24 tests): the `Inbox` / `InboxStore` paths that do not need GPUI - `cargo test -p signed_state` (24 tests): the `Inbox` / `query_inbox` paths that do not need GPUI
(state round-trip, grouping helpers). (state round-trip, grouping helpers).
- `cargo test -p workspace` (7 tests): repository-detail helpers. - `cargo test -p workspace` (7 tests): repository-detail helpers.
- `cargo clippy -p signed_state --all-targets`, `cargo clippy -p workspace --all-targets` and - `cargo clippy -p signed_state --all-targets`, `cargo clippy -p workspace --all-targets` and
`cargo check --workspace --all-targets` after each phase. `cargo check --workspace --all-targets` after each phase.
- Manual: log in with a repo-owning identity; open the inbox from the sidebar and confirm the panel - Manual: log in with a repo-owning identity; open the inbox from the sidebar and confirm the panel
populates from another identity's issue/comment, the activity list shows your own items, the populates from another identity's issue/comment, the activity list shows your own items, and that no
repositories panel matches the sidebar, and that no kind-30078 event is broadcast (watch the kind-30078 event is broadcast (watch the relays / `Published` events). Restart to confirm the read
relays / `Published` events). Restart to confirm the read state is read back from LMDB. state is read back from LMDB.
## 9. SDK APIs used (verified in the pinned `5c669a4` checkout) ## 9. SDK APIs used (verified in the pinned `5c669a4` checkout)