This commit is contained in:
2026-09-11 10:22:13 +07:00
parent e63e58c125
commit 1e7cf0020c
5 changed files with 633 additions and 787 deletions
+33 -287
View File
@@ -1,20 +1,11 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use anyhow::Error;
use gpui::{AppContext, Context, Task};
use nostr_sdk::prelude::*;
use signed_core::{Deletions, InboxItem, InboxReadState, filters, inbox};
use crate::backend::{Backend, BackendEvent};
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;
use crate::backend::Backend;
/// The user's persisted inbox read state.
#[derive(Default)]
@@ -22,7 +13,7 @@ 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 [`InboxStore`] for the sidebar badge.
/// Unread notification groups, published by the inbox panel for the sidebar badge.
pub unread_count: usize,
}
@@ -37,7 +28,7 @@ impl Inbox {
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>) {
if self.unread_count == count {
return;
@@ -150,288 +141,43 @@ 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
/// panel is open. The persisted read state stays in the global [`Inbox`].
#[derive(Default)]
pub struct InboxStore {
/// Notifications grouped by thread root, newest activity first.
pub notifications: Arc<Vec<InboxItem>>,
/// The user's own recent git activity, newest first.
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,
}
/// Returns the notification groups, the user's own git activity and the number
/// of non-archived groups with an unread event.
pub async fn query_inbox(
client: &Client,
me: PublicKey,
state: &InboxReadState,
) -> Result<(Vec<InboxItem>, Vec<Event>, usize), Error> {
let deletion_events = client.database().query(filters::deletions()).await?;
let deletions = Deletions::from_events(deletion_events);
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}");
}
});
let (notification_events, by_id) = fetch_notifications(client, me, &deletions).await?;
let notifications = inbox::group(notification_events, me, state, &|id| {
by_id.get(&id).cloned()
});
let unread_count = notifications.iter().filter(|item| item.is_unread()).count();
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();
let mut activity = Vec::new();
for event in client
.database()
.query(filters::authored_activity(me))
.await?
{
if deletions.is_deleted(&event) || !filters::is_git_activity(&event) {
continue;
}
activity.push(event);
}
/// 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;
activity.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
});
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 deletions = Deletions::from_events(deletion_events);
let (notification_events, by_id) = fetch_notifications(&client, me, &deletions).await?;
let notifications = inbox::group(notification_events, me, &state, &|id| {
by_id.get(&id).cloned()
});
let unread_count = notifications.iter().filter(|item| item.is_unread()).count();
let mut activity = Vec::new();
for event in client
.database()
.query(filters::authored_activity(me))
.await?
{
if deletions.is_deleted(&event) || !filters::is_git_activity(&event) {
continue;
}
activity.push(event);
}
activity.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
});
activity.truncate(ACTIVITY_LIMIT);
Ok::<_, Error>((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()
}
Ok((notifications, activity, unread_count))
}
/// `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 git_store::GitStore;
use gpui::{App, AppContext};
pub use inbox::{Inbox, InboxStore};
pub use inbox::{Inbox, query_inbox};
pub use nostr_sdk::prelude::Timestamp;
pub use profile::{Profile, ProfileStore};
pub use refresh::{RefreshGate, RefreshRequest};
pub use repo::RepoStore;
pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore};
use signed_nostr::new_backend;
+470 -394
View File
@@ -1,116 +1,337 @@
use std::sync::Arc;
use std::time::Duration;
use anyhow::Error;
use assets::CustomIconName;
use dock::{BasePanel, DockArea, Panel, PanelEvent};
use dock::{BasePanel, Panel, PanelEvent};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
Subscription, WeakEntity, Window, div, px,
AnyElement, App, Context, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState,
Pixels, Render, SharedString, Subscription, Task, Window, div, list, px,
};
use gpui_component::input::{Input, InputState};
use gpui_component::scroll::ScrollableElement;
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
use nostr::prelude::{Event, Kind};
use signed_core::{Announcement, COVER_NOTE_KIND, InboxItem, RepoAddr, activity_subject};
use signed_state::{Backend, BackendEvent, InboxStore, ProfileStore, RepoListStore};
use nostr::prelude::{Event, EventId, Kind};
use signed_core::{
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 utils::relative_time;
use super::open_repo_panel;
use super::sidebar::create_repo_dialog;
/// Delay between a refresh request and the actual re-query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// Notification groups shown before the `Show all` toggle is used.
const NOTIFICATION_PREVIEW: usize = 5;
/// Activity rows shown in `Continue where you left off`.
const ACTIVITY_SHOWN: usize = 15;
/// Extra list rows measured above and below the visible area.
const LIST_OVERDRAW: Pixels = px(400.);
pub struct InboxView {
focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
/// Derives the notification and activity lists while the panel is open.
store: Entity<InboxStore>,
/// Search box filtering the `My repositories` column.
search: Entity<InputState>,
/// Whether the inbox list is expanded past [`NOTIFICATION_PREVIEW`].
show_all: bool,
/// Notifications grouped by thread root, newest activity first.
notifications: Arc<Vec<InboxItem>>,
/// The user's own recent git activity, newest first.
activity: Arc<Vec<Event>>,
/// Number of non-archived groups with an unread event.
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,
/// 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>,
}
impl InboxView {
pub fn new(
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
pub fn new(cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx);
let inbox = backend.read(cx).inbox();
let weak = cx.entity().downgrade();
let store = cx.new(InboxStore::new);
let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search..."));
let notifications_list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW);
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 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 _subscriptions = vec![
cx.observe(&inbox, |this, _inbox, cx| {
this.store.update(cx, |store, cx| store.sync_state(cx));
}),
cx.observe(&inbox, |this, _inbox, cx| this.sync_state(cx)),
cx.subscribe(&backend, |this, _backend, event, cx| {
if matches!(
event,
BackendEvent::SignerChanged | BackendEvent::SignerRequired
) {
this.show_all = false;
cx.notify();
}
this.store
.update(cx, |store, cx| store.handle_backend_event(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 {
focus_handle: cx.focus_handle(),
dock_area,
store,
search,
show_all: false,
notifications: Arc::new(Vec::new()),
activity: Arc::new(Vec::new()),
unread_count: 0,
state: InboxReadState::default(),
state_loaded: false,
refresh: RefreshGate::default(),
notifications_list,
activity_list,
_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.
fn mark_all_read(&mut self, cx: &mut Context<Self>) {
self.store.update(cx, |store, cx| store.mark_all_read(cx));
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));
}
/// Open a repository's detail panel.
fn open_repo(
&mut self,
announcement: &Announcement,
window: &mut Window,
cx: &mut Context<Self>,
) {
open_repo_panel(&self.dock_area, announcement, window, &mut *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();
}
}
/// Show the existing Create Repository dialog.
fn open_create_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
create_repo_dialog::open(self.dock_area.clone(), window, cx);
/// 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);
}
}
_ => {}
}
}
/// Display name of the repository at `addr`, from the announcement store.
fn repo_name(&self, addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> {
let addr = addr?;
RepoListStore::global(cx)
.read(cx)
.announcements
/// 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()
.find(|announcement| announcement.addr() == *addr)
.map(display_name)
.find(|item| item.root == root)
.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 {
v_flex()
.w_full()
.flex_1()
.min_h_0()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().border)
@@ -131,41 +352,10 @@ impl InboxView {
fn render_inbox_panel(
&self,
unread: usize,
visible: &[&InboxItem],
notifications: Arc<Vec<InboxItem>>,
visible: Vec<usize>,
cx: &mut Context<Self>,
) -> 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()
.w_full()
.gap_2()
@@ -183,91 +373,36 @@ impl InboxView {
.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)
}
fn render_notification_row(
fn render_activity_panel(
&self,
ix: usize,
item: &InboxItem,
activity: Arc<Vec<Event>>,
cx: &mut Context<Self>,
) -> AnyElement {
let Some(newest) = item.events.first() else {
return div().into_any_element();
};
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 = self.repo_name(item.address.as_ref(), cx);
let age = relative_time(item.latest_activity());
let unread = item.is_unread();
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(UserAvatar::new(profile.name()).picture(profile.picture()))
.child(div().flex_shrink_0().child(kind_icon(kind)))
.child(
v_flex()
.flex_1()
.min_w_0()
.gap_0p5()
.child(
div()
.text_sm()
.when(unread, |this| this.font_semibold())
.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)),
),
)
.when(unread, |this| {
this.child(
div()
.flex_shrink_0()
.size(px(8.))
.rounded(px(4.))
.bg(cx.theme().primary),
)
})
.into_any_element()
}
fn render_activity_panel(&self, activity: &[Event], cx: &mut Context<Self>) -> AnyElement {
let mut body = v_flex().w_full();
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()
@@ -279,200 +414,149 @@ impl InboxView {
.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)
}
}
fn render_activity_row(&self, ix: usize, event: &Event, cx: &mut Context<Self>) -> AnyElement {
let kind = event.kind;
let subject = SharedString::from(activity_subject(event));
let repo = self.repo_name(event.tags.coordinates().next().as_ref(), cx);
let age = relative_time(event.created_at);
/// 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)
}
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(div().flex_shrink_0().child(kind_icon(kind)))
.child(
v_flex()
.flex_1()
.min_w_0()
.gap_0p5()
.child(
div()
.text_sm()
.whitespace_nowrap()
.text_ellipsis()
.child(subject),
)
.child(
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)),
),
)
.into_any_element()
}
/// 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 {
return div().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())
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()
.id(("inbox-row", ix))
.w_full()
.gap_3()
.px_3()
.py_2()
.items_center()
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().list_hover))
.child(UserAvatar::new(profile.name()).picture(profile.picture()))
.child(div().flex_shrink_0().child(kind_icon(kind)))
.child(
v_flex()
.flex_1()
.min_w_0()
.gap_0p5()
.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()
.when(unread, |this| this.font_semibold())
.whitespace_nowrap()
.text_ellipsis()
.child(subject),
)
.child(
h_flex()
.gap_1()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(relative_time(activity))),
.child(SharedString::from(kind_label(kind)))
.when_some(repo, |this, repo| {
this.child(SharedString::from("on")).child(repo)
})
.child(SharedString::from("·"))
.child(SharedString::from(age)),
),
)
.when(unread, |this| {
this.child(
div()
.flex_shrink_0()
.size(px(8.))
.rounded(px(4.))
.bg(cx.theme().primary),
)
})
.into_any_element()
}
/// One row of the user's own recent git activity.
fn activity_row(ix: usize, event: &Event, cx: &App) -> AnyElement {
let kind = event.kind;
let subject = SharedString::from(activity_subject(event));
let repo = repo_name(event.tags.coordinates().next().as_ref(), cx);
let age = relative_time(event.created_at);
h_flex()
.id(("activity-row", ix))
.w_full()
.gap_3()
.px_3()
.py_2()
.items_center()
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().list_hover))
.child(div().flex_shrink_0().child(kind_icon(kind)))
.child(
v_flex()
.flex_1()
.min_w_0()
.gap_0p5()
.child(
div()
.text_sm()
.whitespace_nowrap()
.text_ellipsis()
.child(subject),
)
})
.on_click(cx.listener({
let announcement = announcement.clone();
move |this, _event, window, cx| this.open_repo(&announcement, window, cx)
}))
.into_any_element()
}
.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)),
),
)
.into_any_element()
}
/// 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 {
v_flex()
.w_full()
.flex_1()
.min_h_0()
.items_center()
.justify_center()
.gap_2()
@@ -570,41 +656,31 @@ impl Focusable for InboxView {
impl Render for InboxView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (unread, notifications, activity) = {
let store = self.store.read(cx);
(
store.unread_count,
store.notifications.clone(),
store.activity.clone(),
)
};
let unread = self.unread_count;
let notifications = self.notifications.clone();
let activity = self.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()
.size_full()
.image_cache(gpui::retain_all("inbox"))
.child(
v_flex().size_full().overflow_y_scrollbar().child(
h_flex()
.items_start()
.gap_4()
.p_4()
.child(
v_flex()
.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)),
),
),
)
.gap_4()
.p_4()
.child(self.render_inbox_panel(unread, notifications, visible, cx))
.child(self.render_activity_panel(activity, 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};
pub(crate) mod create_repo_dialog;
mod create_repo_dialog;
pub(crate) mod grasp_servers;
mod import_dialog;
mod onboarding_dialog;
@@ -224,7 +224,7 @@ impl SidebarPanel {
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());
let _ = self.dock_area.update(cx, |dock_area, cx| {