diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs index 7a39724..fb7ee3e 100644 --- a/crates/workspace/src/views/inbox.rs +++ b/crates/workspace/src/views/inbox.rs @@ -3,14 +3,12 @@ use std::time::Duration; use anyhow::Error; use assets::CustomIconName; -use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, add_bottom_panel, panel_handle}; +use dock::{BasePanel, DockArea, Panel, PanelEvent}; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment, - ListState, Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div, - list, px, + AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState, + Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div, list, px, }; -use gpui_component::button::{Button, ButtonVariants}; use gpui_component::scroll::ScrollableElement; use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex}; use nostr::prelude::{Event, EventId, Kind}; @@ -33,11 +31,7 @@ const LIST_OVERDRAW: Pixels = px(400.); pub struct InboxView { focus_handle: FocusHandle, - /// The dock area the Unread / Archived sub-view is added to. dock_area: WeakEntity, - /// The open Unread / Archived sub-view, if any. Reused instead of adding a - /// duplicate panel on every header click. - filter_view: Option>, /// Notifications grouped by thread root, newest activity first. notifications: Arc>, /// The user's own recent git activity, newest first. @@ -49,11 +43,11 @@ 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, kept in sync with the - /// rendered (non-archived) notifications. + /// Virtual-list state of the notification list. notifications_list: ListState, /// Virtual-list state of the activity list. activity_list: ListState, + tasks: Vec>>, _subscriptions: Vec, } @@ -77,27 +71,26 @@ impl InboxView { } // Drive the lists from the global inbox state and from backend events. - 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); - }), - ]; + let mut subscriptions = vec![]; + + subscriptions.push(cx.observe(&inbox, |this, _inbox, cx| { + this.sync_state(cx); + })); + + subscriptions.push(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}"); - } + 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}"); } }); Self { focus_handle: cx.focus_handle(), dock_area, - filter_view: None, notifications: Arc::new(Vec::new()), activity: Arc::new(Vec::new()), unread_count: 0, @@ -106,80 +99,21 @@ impl InboxView { refresh: RefreshGate::default(), notifications_list, activity_list, - _subscriptions, + tasks: vec![], + _subscriptions: subscriptions, } } - /// Mark every event in the group rooted at `root` read. - pub fn mark_read(&mut self, root: EventId, cx: &mut Context) { - 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) { - 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) { 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)); } - /// Show `mode` in the bottom dock, reusing the panel when it is already open. - fn open_filter(&mut self, mode: InboxFilter, window: &mut Window, cx: &mut Context) { - let Some(dock_area) = self.dock_area.upgrade() else { - return; - }; - - if let Some(filter) = self.filter_view.as_ref().and_then(WeakEntity::upgrade) { - filter.update(cx, |filter, cx| filter.set_mode(mode, cx)); - - let handle = filter.read(cx).focus_handle.clone(); - window.focus(&handle, cx); - - dock_area.update(cx, |dock_area, cx| { - if !dock_area.is_dock_open(DockPlacement::Bottom) { - dock_area.toggle_dock(DockPlacement::Bottom, window, cx); - } - }); - return; - } - - let inbox = cx.entity(); - let panel = cx.new(|cx| InboxFilterView::new(mode, inbox, cx)); - self.filter_view = Some(panel.downgrade()); - - dock_area.update(cx, |dock_area, cx| { - add_bottom_panel(dock_area, panel_handle(panel), window, cx); - }); - } - /// Re-derive from the global state when it is loaded or changes. pub fn sync_state(&mut self, cx: &mut Context) { let inbox = Backend::global(cx).read(cx).inbox(); @@ -216,7 +150,6 @@ impl InboxView { /// Handle a backend event that can change the derived lists. fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context) { 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); @@ -226,10 +159,12 @@ impl InboxView { is_notification || is_comment || is_event_deletion || is_request_to_vanish }); + if relevant { self.refresh(cx); } } + BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx), _ => {} } } @@ -241,7 +176,6 @@ impl InboxView { self.refresh.request(); return; } - self.run_refresh(cx); } @@ -255,11 +189,10 @@ impl InboxView { return; } - cx.spawn(async move |this, cx| { + self.tasks.push(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. @@ -277,10 +210,9 @@ impl InboxView { let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await }); - let task: Task> = cx.spawn(async move |this, cx| { + self.tasks.push(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()); @@ -288,9 +220,7 @@ impl InboxView { }; 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) { + if backend.read(cx).current_user() != Some(me) { this.refresh.abort(); return false; } @@ -309,9 +239,7 @@ impl InboxView { } Ok(()) - }); - - task.detach(); + })); } /// Recompute the unread and archived flags from the current state. @@ -344,14 +272,6 @@ impl InboxView { self.refresh = RefreshGate::default(); } - /// Events of the group rooted at `root`. - fn group_events(&self, root: EventId) -> Option> { - 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 { self.notifications @@ -360,10 +280,6 @@ impl InboxView { .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 { v_flex() .w_full() @@ -405,20 +321,6 @@ impl InboxView { ) .when(unread > 0, |this| this.child(CountBadge::new(unread))) .child(div().flex_1()) - .child( - SegmentButton::new("inbox-unread", "Unread") - .icon(Icon::new(IconName::Inbox).small()) - .on_click(cx.listener(|this, _event, window, cx| { - this.open_filter(InboxFilter::Unread, window, cx); - })), - ) - .child( - SegmentButton::new("inbox-archived", "Archived") - .icon(Icon::new(IconName::FolderClosed).small()) - .on_click(cx.listener(|this, _event, window, cx| { - this.open_filter(InboxFilter::Archived, window, cx); - })), - ) .child( SegmentButton::new("mark-all-read", "Mark all read") .on_click(cx.listener(|this, _event, _window, cx| this.mark_all_read(cx))), @@ -678,7 +580,7 @@ fn display_name(announcement: &Announcement) -> SharedString { /// Leading icon for a notification or activity kind. fn kind_icon(kind: Kind) -> Icon { if kind == COVER_NOTE_KIND { - return Icon::new(IconName::FileText).small(); + return Icon::new(IconName::File).small(); } match kind { @@ -741,166 +643,6 @@ fn empty_state(icon: impl IconNamed, message: &str, cx: &App) -> AnyElement { .into_any_element() } -/// Which subset of the inbox a bottom-dock sub-view shows. -#[derive(Clone, Copy, PartialEq, Eq)] -enum InboxFilter { - Unread, - Archived, -} - -impl InboxFilter { - /// Tab and empty-state label of the sub-view. - fn label(self) -> &'static str { - match self { - Self::Unread => "Unread", - Self::Archived => "Archived", - } - } - - /// Whether `item` belongs in this sub-view. - fn matches(self, item: &InboxItem) -> bool { - match self { - Self::Unread => item.is_unread(), - Self::Archived => item.archived, - } - } -} - -/// The Unread / Archived sub-view of the inbox, opened in the bottom dock. -struct InboxFilterView { - focus_handle: FocusHandle, - /// The inbox panel whose groups are filtered. A strong handle: the panel - /// keeps only a weak one back, so the two entities do not form a cycle. - inbox: Entity, - mode: InboxFilter, - list: ListState, -} - -impl InboxFilterView { - fn new(mode: InboxFilter, inbox: Entity, cx: &mut Context) -> Self { - let list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW); - let weak = cx.entity().downgrade(); - list.set_scroll_handler(move |_, _, cx| { - let weak = weak.clone(); - cx.defer(move |cx| { - let _ = weak.update(cx, |_, cx| cx.notify()); - }); - }); - - Self { - focus_handle: cx.focus_handle(), - inbox, - mode, - list, - } - } - - /// Switch which subset is shown, when the header asks for another mode. - fn set_mode(&mut self, mode: InboxFilter, cx: &mut Context) { - if self.mode == mode { - return; - } - self.mode = mode; - cx.notify(); - } -} - -impl BasePanel for InboxFilterView { - fn panel_name(&self) -> &'static str { - "inbox_filter" - } -} - -impl Panel for InboxFilterView { - fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div().text_sm().child(SharedString::from(self.mode.label())) - } -} - -impl EventEmitter for InboxFilterView {} - -impl Focusable for InboxFilterView { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for InboxFilterView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let mode = self.mode; - let notifications = self.inbox.read(cx).notifications.clone(); - - let visible: Vec = notifications - .iter() - .enumerate() - .filter(|(_, item)| mode.matches(item)) - .map(|(ix, _)| ix) - .collect(); - - if self.list.item_count() != visible.len() { - self.list.reset(visible.len()); - } - - let list_state = self.list.clone(); - let inbox = self.inbox.clone(); - - let body: AnyElement = if visible.is_empty() { - let (icon, message) = match mode { - InboxFilter::Unread => (IconName::Inbox, "Nothing unread."), - InboxFilter::Archived => (IconName::FolderClosed, "Nothing archived."), - }; - empty_state(icon, message, cx) - } else { - 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(); - }; - - match mode { - InboxFilter::Unread => { - let root = item.root; - let open = inbox.clone(); - let archive = inbox.clone(); - - notification_row("inbox-filter-row", ix, item, cx) - .on_click(move |_, _, cx| { - open.update(cx, |view, cx| view.mark_read(root, cx)); - }) - .child( - Button::new(("inbox-filter-archive", ix)) - .icon(IconName::FolderClosed) - .small() - .ghost() - .tab_stop(false) - .tooltip("Archive") - .on_click(move |_, _, cx| { - cx.stop_propagation(); - archive.update(cx, |view, cx| view.mark_archived(root, cx)); - }), - ) - .into_any_element() - } - InboxFilter::Archived => { - notification_row("inbox-filter-row", ix, item, 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() - }; - - v_flex().size_full().min_h_0().p_2().child(body) - } -} - impl BasePanel for InboxView { fn panel_name(&self) -> &'static str { "inbox"