update
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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 [¬ifications_list, &activity_list] {
|
||||
let weak = weak.clone();
|
||||
list_state.set_scroll_handler(move |_, _, cx| {
|
||||
let weak = weak.clone();
|
||||
cx.defer(move |cx| {
|
||||
let _ = weak.update(cx, |_, cx| cx.notify());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Drive the lists from the global inbox state and from backend events.
|
||||
let _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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
+126
-103
@@ -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 check --workspace --all-targets` succeeds.
|
||||
> 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
|
||||
> `InboxStore`.
|
||||
> Phase 1 refactors and the §4.3 split of the inbox into a thin global `Inbox` and a
|
||||
> panel-owned derivation.
|
||||
|
||||
## 1. What the GitWorkshop home screen is
|
||||
|
||||
@@ -55,47 +55,37 @@ Data hooks:
|
||||
|
||||
| Priority | Section | Notes |
|
||||
|---|---|---|
|
||||
| **P0** | Inbox panel | Activity directed at you, grouped by thread root; unread badge; mark all read; top N + show all |
|
||||
| **P0** | My repositories | Reuse `RepoListStore::announcements_of(me)`; search filter; existing New-repo dialog |
|
||||
| **P0** | Inbox panel | Activity directed at you, grouped by thread root; unread badge; mark all read; all groups shown |
|
||||
| **P0** | Continue where you left off | Your own recent git activity, newest first |
|
||||
| **P1** | Unread / Archived sub-views | Open as **bottom-dock panels**, not tabs inside the inbox panel |
|
||||
| **P1** | Click-through | Open the repo panel at the relevant PR/issue |
|
||||
| **P2 (defer)** | Standalone notifications page, NIP-65 relay discovery, pagination | Web-app concerns |
|
||||
| **Out of scope** | Greeting header, 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:
|
||||
|
||||
- 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.
|
||||
|
||||
## 3. The Signed screen
|
||||
|
||||
`InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item (currently a
|
||||
placeholder that opens Explore). It is 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] |
|
||||
| [avatar] issue opened on you/repo 2m |
|
||||
+-------------------------------------------------------------------------+
|
||||
| Inbox (3 unread) [Unread] [Archived] [Mark all read] |
|
||||
| [avatar] issue opened on you/repo 2m (scroll) |
|
||||
| [avatar] commented on "Fix parser" 1h |
|
||||
| [avatar] PR update on you/repo 3h |
|
||||
| [Show all] |
|
||||
| |
|
||||
| Continue where you left off |
|
||||
+-------------------------------------------------------------------------+
|
||||
| Continue where you left off (scroll)|
|
||||
| [icon] "Fix parser bug" you/repo opened 3d |
|
||||
| [icon] "Add retry" you/repo PR 5d |
|
||||
+--------------------------------------------------+-----------------------+
|
||||
+-------------------------------------------------------------------------+
|
||||
| bottom dock: Unread or Archived list (opened by the header buttons) |
|
||||
+--------------------------------------------------+-----------------------+
|
||||
```
|
||||
|
||||
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
|
||||
@@ -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.
|
||||
`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
|
||||
open.
|
||||
@@ -290,7 +280,7 @@ pub struct Backend {
|
||||
pub struct Inbox {
|
||||
state: InboxReadState,
|
||||
state_loaded: bool,
|
||||
/// Published by `InboxStore` for the sidebar badge.
|
||||
/// Published by the inbox panel for the sidebar badge.
|
||||
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
|
||||
derives the notification groups and the activity list, coalesces refreshes and applies the read
|
||||
state for rendering.
|
||||
**The panel owns the derivation.** `InboxView` itself holds the derived lists, the copy of the read
|
||||
state they were computed with, and the refresh coalescing. There is no separate store entity: the
|
||||
panel is the only consumer, so an `Entity<InboxStore>` would add an `update` indirection and a
|
||||
forwarding subscription without buying any sharing.
|
||||
|
||||
```rust
|
||||
pub struct InboxStore {
|
||||
pub notifications: Arc<Vec<InboxItem>>,
|
||||
pub activity: Arc<Vec<Event>>,
|
||||
pub unread_count: usize,
|
||||
pub struct InboxView {
|
||||
focus_handle: FocusHandle,
|
||||
notifications: Arc<Vec<InboxItem>>,
|
||||
activity: Arc<Vec<Event>>,
|
||||
unread_count: usize,
|
||||
state: InboxReadState,
|
||||
state_loaded: bool,
|
||||
refresh: RefreshGate,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl InboxStore {
|
||||
pub fn new(cx: &mut Context<Self>) -> Self;
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>);
|
||||
pub fn mark_read(&mut self, root: EventId, cx);
|
||||
pub fn mark_archived(&mut self, root: EventId, cx);
|
||||
impl InboxView {
|
||||
pub fn new(cx: &mut Context<Self>) -> Self; // cx.defer(… sync_state)
|
||||
pub fn sync_state(&mut self, cx); // observes the global Inbox
|
||||
pub fn mark_read(&mut self, root: EventId, cx); // Phase 3
|
||||
pub fn mark_archived(&mut self, root: EventId, cx); // Phase 3
|
||||
pub fn mark_all_read(&mut self, cx);
|
||||
fn handle_backend_event(&mut self, event: &BackendEvent, cx);
|
||||
fn refresh(&mut self, cx);
|
||||
fn run_refresh(&mut self, cx);
|
||||
}
|
||||
```
|
||||
|
||||
`InboxStore` has no subscriptions of its own. The panel owns the two subscriptions that carry
|
||||
logic: it observes the global `Inbox` (`InboxStore::sync_state`) and subscribes to `Backend`
|
||||
(`InboxStore::handle_backend_event`, plus resetting `show_all` on a signer change). Re-rendering
|
||||
needs no subscription: GPUI invalidates a window for every entity it read during render, so the
|
||||
panel tracks the store, the search `InputState` and `RepoListStore` just by reading them in
|
||||
`render`. The store never writes derived data back to `Backend` except the unread count.
|
||||
The panel owns the two subscriptions that carry logic: it observes the global `Inbox`
|
||||
(`InboxView::sync_state`) and subscribes to `Backend` (`InboxView::handle_backend_event`).
|
||||
Re-rendering needs no subscription: GPUI invalidates a window for every entity it read during
|
||||
render, so the panel tracks `RepoListStore` and `ProfileStore` just by reading them in `render`.
|
||||
The panel writes back to `Backend` only to publish the unread count for the badge.
|
||||
|
||||
**Lifespan.** `Inbox` is created with the backend but idles until the user has a signer. `InboxStore`
|
||||
is created and dropped with the panel. Neither is wired from the `desktop` crate and
|
||||
**`signed_state::query_inbox`.** The database work stays in `signed_state`, so the UI crate never
|
||||
queries LMDB directly. `query_inbox` returns the grouped notifications, the user's own git
|
||||
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.
|
||||
|
||||
**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
|
||||
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.
|
||||
|
||||
`Backend` no longer funnels its events through the inbox: `InboxStore` subscribes to `Backend`
|
||||
directly. `BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay:
|
||||
`CheckoutsStore` and `SidebarPanel` consume them. They no longer drive the inbox.
|
||||
`Backend` does not funnel its events through the inbox: the panel subscribes to `Backend` directly.
|
||||
`BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: `CheckoutsStore`
|
||||
and `SidebarPanel` consume them. They no longer drive the inbox.
|
||||
|
||||
`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
|
||||
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`
|
||||
/ `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:
|
||||
|
||||
```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.
|
||||
|
||||
**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.
|
||||
|
||||
**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
|
||||
`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
|
||||
`inbox::group`.
|
||||
- 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() ==
|
||||
Some(me)`; if the signer changed while the query ran, `refresh.abort()` instead of applying, so a
|
||||
previous user's results never land. Then set `notifications`, `activity`, `unread_count`, publish
|
||||
the unread count to the global `Inbox`, `cx.notify()`, `refresh.finish()`.
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
**My repositories needs no new store**: `RepoListStore` already holds every announcement and exposes
|
||||
`announcements_of(user)`.
|
||||
**Repository names need no new store**: `RepoListStore` already holds every announcement and
|
||||
`repo_name` resolves an address to a display name.
|
||||
|
||||
### 4.4 `Cargo.toml`
|
||||
|
||||
@@ -464,23 +471,23 @@ publishes the unread count.
|
||||
### 5.1 `InboxView` center panel
|
||||
|
||||
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
|
||||
reads it during render. One `overflow_y_scrollbar` container holding a two-column flex row, left
|
||||
column flexible and right column fixed at 300px. Both columns are bordered cards with a header bar.
|
||||
It owns the derived lists directly, so `cx.notify()` from an update re-renders it. The panel is a
|
||||
column of two flexible bordered cards (`flex_1`, `min_h_0`), each with a header bar and a scrolling
|
||||
body. Each body is a `gpui::list` virtual list (`ListState` + `ListAlignment::Top`, 400px
|
||||
overdraw) with a `vertical_scrollbar`; the panel itself does not scroll, so both lists get a
|
||||
definite viewport height. The list counts are reset from `render` whenever the rendered item count
|
||||
changes. Row ids are prefixed (`("inbox-row", ix)` / `("activity-row", ix)`) so the two lists do not
|
||||
collide.
|
||||
|
||||
- **Inbox column**: header with the unread count badge and **Mark all read**; then the non-archived
|
||||
notification items (top 5, with a **Show all** toggle expanding inline). Rows show the actor
|
||||
avatar, a kind icon, the subject, the kind label, the repo name, a relative time, and an unread
|
||||
dot (the subject is semibold while unread). Empty state: "You're all caught up." with
|
||||
`IconName::Inbox`.
|
||||
- **Continue where you left off**: `Inbox::activity`, top 15, each row a kind icon, subject, kind
|
||||
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.".
|
||||
- **Inbox card**: header with the unread count badge and **Mark all read**; then every non-archived
|
||||
notification item. Rows show the actor avatar, a kind icon, the subject, the kind label, the repo
|
||||
name, a relative time, and an unread dot (the subject is semibold while unread). Empty state:
|
||||
"You're all caught up." with `IconName::Inbox`.
|
||||
- **Continue where you left off**: every event in the activity list, each row a kind icon, subject,
|
||||
kind label, repo name, and relative time.
|
||||
|
||||
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).
|
||||
|
||||
### 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:
|
||||
|
||||
- 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
|
||||
`add_bottom_panel` with the requested mode. `InboxView` keeps
|
||||
`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))),
|
||||
```
|
||||
|
||||
- `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.
|
||||
|
||||
### 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/lib.rs` | `mod inbox;` and re-exports |
|
||||
| `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/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/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/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) |
|
||||
@@ -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
|
||||
exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the
|
||||
implementation notes below.
|
||||
3. **Phase 2 - screen**: `InboxView` (inbox + activity + my repositories), sidebar nav and badge.
|
||||
3. **Phase 2 - screen**: `InboxView` (inbox + activity), sidebar nav and badge.
|
||||
**DONE.** See the implementation notes below.
|
||||
4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived.
|
||||
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
|
||||
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.
|
||||
- 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
|
||||
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`.
|
||||
@@ -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.
|
||||
|
||||
- `InboxView` is a plain center panel like `RepoListView`. It owns an `Entity<InboxStore>` and
|
||||
drives it; the sidebar holds a `WeakEntity<InboxView>` so there is no cycle. The panel's `Backend`
|
||||
subscription also resets `show_all` on a signer change. Re-rendering relies on GPUI's render-time
|
||||
entity tracking rather than explicit observations.
|
||||
- The layout is one `overflow_y_scrollbar` container with a two-column `h_flex`. The left column
|
||||
(`flex_1`, `min_w_0`) stacks the inbox card over the activity card; the right column is fixed at
|
||||
300px. Each card is a bordered rounded `v_flex` with a header bar (`section`).
|
||||
- `InboxView` is a plain center panel like `RepoListView`; the sidebar holds a
|
||||
`WeakEntity<InboxView>` so there is no cycle. Re-rendering relies on GPUI's render-time entity
|
||||
tracking rather than explicit observations. (Phase 2 introduced an `Entity<InboxStore>` here; it
|
||||
was later folded into the panel - see the store-merge note below.)
|
||||
- The layout is a column of two flexible bordered cards (`gap_4`, `p_4`, each `flex_1`/`min_h_0`),
|
||||
inbox over activity. Each card is a rounded `v_flex` with a header bar (`section`) and a
|
||||
`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
|
||||
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.
|
||||
- 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
|
||||
inline **Show all** toggle, so the panel is fully usable on its own.
|
||||
`add_bottom_panel` / `InboxFilterView`, which are Phase 3. The header is only **Mark all read**,
|
||||
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
|
||||
cover note is compared with `==` rather than matched, since `Kind` cannot appear in a pattern arm.
|
||||
- Sidebar: `open_inbox` mirrors `open_explore` (return if open, else add a center panel); the inbox
|
||||
nav item is repointed and carries a `CountBadge` suffix driven by the observed unread count. The
|
||||
screen is still opened by the nav item, not on app startup, matching the "idle until signer" rule;
|
||||
auto-opening it as the post-login home is a possible follow-up.
|
||||
- `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`
|
||||
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
|
||||
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`
|
||||
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.
|
||||
- `signed_core` is unchanged. `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.
|
||||
- The panel became the client-side owner of the derivation, initially through a panel-scoped
|
||||
`Entity<InboxStore>`.
|
||||
- `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
|
||||
count is derived by the panel-scoped store.
|
||||
count is derived by the panel.
|
||||
|
||||
## 8. Validation
|
||||
|
||||
- `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).
|
||||
- `cargo test -p workspace` (7 tests): repository-detail helpers.
|
||||
- `cargo clippy -p signed_state --all-targets`, `cargo clippy -p workspace --all-targets` and
|
||||
`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
|
||||
populates from another identity's issue/comment, the activity list shows your own items, the
|
||||
repositories panel matches the sidebar, and that no kind-30078 event is broadcast (watch the
|
||||
relays / `Published` events). Restart to confirm the read state is read back from LMDB.
|
||||
populates from another identity's issue/comment, the activity list shows your own items, and that no
|
||||
kind-30078 event is broadcast (watch the relays / `Published` events). Restart to confirm the read
|
||||
state is read back from LMDB.
|
||||
|
||||
## 9. SDK APIs used (verified in the pinned `5c669a4` checkout)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user