update ui
This commit is contained in:
+384
-262
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -9,16 +10,16 @@ use gpui::{
|
||||
AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState,
|
||||
Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div, list, px,
|
||||
};
|
||||
use gpui_component::scroll::ScrollableElement;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
|
||||
use nostr::prelude::{Event, EventId, Kind};
|
||||
use nostr::prelude::{Event, EventId, Kind, Timestamp};
|
||||
use signed_core::{
|
||||
Announcement, COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, activity_subject, filters,
|
||||
COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, activity_subject, filters,
|
||||
};
|
||||
use signed_state::{
|
||||
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
|
||||
Backend, BackendEvent, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
|
||||
};
|
||||
use signed_ui::{CountBadge, SegmentButton, UserAvatar};
|
||||
use signed_ui::CountBadge;
|
||||
use utils::relative_time;
|
||||
|
||||
use super::{RepoItem, open_repo_item, open_repo_panel};
|
||||
@@ -29,6 +30,36 @@ const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
/// Extra list rows measured above and below the visible area.
|
||||
const LIST_OVERDRAW: Pixels = px(400.);
|
||||
|
||||
/// A repository's slice of the inbox: its notification groups and own activity.
|
||||
struct InboxSection {
|
||||
/// Repository the section groups, `None` for items without one.
|
||||
address: Option<RepoAddr>,
|
||||
/// Number of notification groups with an unread event.
|
||||
unread: usize,
|
||||
/// Notification and activity rows, newest first.
|
||||
entries: Vec<InboxEntry>,
|
||||
/// Timestamp of the newest entry, used to order the sections.
|
||||
latest: Timestamp,
|
||||
}
|
||||
|
||||
/// One row inside a repository section, as an index into the inbox's own lists.
|
||||
#[derive(Clone, Copy)]
|
||||
enum InboxEntry {
|
||||
/// Index into the notification groups.
|
||||
Notification(usize),
|
||||
/// Index into the user's own activity.
|
||||
Activity(usize),
|
||||
}
|
||||
|
||||
/// One row of the flattened inbox: a repository header, one of its entries, or
|
||||
/// the empty state of a repository without any.
|
||||
#[derive(Clone, Copy)]
|
||||
enum InboxRow {
|
||||
Repo(usize),
|
||||
Entry(usize, usize),
|
||||
Empty,
|
||||
}
|
||||
|
||||
pub struct InboxView {
|
||||
focus_handle: FocusHandle,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
@@ -36,6 +67,10 @@ pub struct InboxView {
|
||||
notifications: Arc<Vec<InboxItem>>,
|
||||
/// The user's own recent git activity, newest first.
|
||||
activity: Arc<Vec<Event>>,
|
||||
/// The notification and activity lists grouped by repository, newest first.
|
||||
sections: Arc<Vec<InboxSection>>,
|
||||
/// The flattened repository headers and rows of the list.
|
||||
rows: Arc<Vec<InboxRow>>,
|
||||
/// Number of non-archived groups with an unread event.
|
||||
unread_count: usize,
|
||||
/// Copy of the global read state the current lists were derived with.
|
||||
@@ -43,10 +78,7 @@ pub struct InboxView {
|
||||
/// Set once the global state has been read for the current user.
|
||||
state_loaded: bool,
|
||||
refresh: RefreshGate,
|
||||
/// Virtual-list state of the notification list.
|
||||
notifications_list: ListState,
|
||||
/// Virtual-list state of the activity list.
|
||||
activity_list: ListState,
|
||||
list: ListState,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
@@ -55,22 +87,10 @@ impl InboxView {
|
||||
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
let inbox = backend.read(cx).inbox();
|
||||
let repos = RepoListStore::global(cx);
|
||||
let weak = cx.entity().downgrade();
|
||||
|
||||
let notifications_list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW);
|
||||
let activity_list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW);
|
||||
|
||||
for list_state in [¬ifications_list, &activity_list] {
|
||||
let weak = weak.clone();
|
||||
list_state.set_scroll_handler(move |_, _, cx| {
|
||||
let weak = weak.clone();
|
||||
cx.defer(move |cx| {
|
||||
let _ = weak.update(cx, |_, cx| cx.notify());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Drive the lists from the global inbox state and from backend events.
|
||||
let list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW);
|
||||
let mut subscriptions = vec![];
|
||||
|
||||
subscriptions.push(cx.observe(&inbox, |this, _inbox, cx| {
|
||||
@@ -81,7 +101,14 @@ impl InboxView {
|
||||
this.handle_backend_event(event, cx);
|
||||
}));
|
||||
|
||||
// Derive the lists once the panel exists.
|
||||
// Rebuild when the user's own repositories load or change,
|
||||
// so a repository without any activity still gets an empty section.
|
||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||
this.rebuild(cx);
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
// Derive the sections once the panel exists.
|
||||
cx.defer(move |cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) {
|
||||
log::warn!("inbox dropped before bootstrap could run: {error}");
|
||||
@@ -93,12 +120,13 @@ impl InboxView {
|
||||
dock_area,
|
||||
notifications: Arc::new(Vec::new()),
|
||||
activity: Arc::new(Vec::new()),
|
||||
sections: Arc::new(Vec::new()),
|
||||
rows: Arc::new(Vec::new()),
|
||||
unread_count: 0,
|
||||
state: InboxReadState::default(),
|
||||
state_loaded: false,
|
||||
refresh: RefreshGate::default(),
|
||||
notifications_list,
|
||||
activity_list,
|
||||
list,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
@@ -123,8 +151,10 @@ impl InboxView {
|
||||
};
|
||||
|
||||
if !loaded {
|
||||
let was_present =
|
||||
self.state_loaded || !self.notifications.is_empty() || !self.activity.is_empty();
|
||||
let was_present = self.state_loaded
|
||||
|| !self.notifications.is_empty()
|
||||
|| !self.activity.is_empty()
|
||||
|| !self.sections.is_empty();
|
||||
self.clear();
|
||||
if was_present {
|
||||
cx.notify();
|
||||
@@ -141,13 +171,12 @@ impl InboxView {
|
||||
|
||||
if self.state != state {
|
||||
self.state = state;
|
||||
self.regroup();
|
||||
self.publish_unread_count(cx);
|
||||
self.regroup(cx);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a backend event that can change the derived lists.
|
||||
/// Handle a backend event that can change the derived sections.
|
||||
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
BackendEvent::NostrUpdate(updates) => {
|
||||
@@ -228,7 +257,7 @@ impl InboxView {
|
||||
this.notifications = Arc::new(notifications);
|
||||
this.activity = Arc::new(activity);
|
||||
this.unread_count = unread_count;
|
||||
this.publish_unread_count(cx);
|
||||
this.rebuild(cx);
|
||||
cx.notify();
|
||||
|
||||
this.refresh.finish()
|
||||
@@ -243,7 +272,7 @@ impl InboxView {
|
||||
}
|
||||
|
||||
/// Recompute the unread and archived flags from the current state.
|
||||
fn regroup(&mut self) {
|
||||
fn regroup(&mut self, cx: &mut Context<Self>) {
|
||||
let mut items = (*self.notifications).clone();
|
||||
|
||||
for item in items.iter_mut() {
|
||||
@@ -252,19 +281,46 @@ impl InboxView {
|
||||
|
||||
self.unread_count = items.iter().filter(|item| item.is_unread()).count();
|
||||
self.notifications = Arc::new(items);
|
||||
self.rebuild(cx);
|
||||
}
|
||||
|
||||
/// Publish the derived unread count for the sidebar badge.
|
||||
fn publish_unread_count(&self, cx: &mut Context<Self>) {
|
||||
let count = self.unread_count;
|
||||
let inbox = Backend::global(cx).read(cx).inbox();
|
||||
inbox.update(cx, |inbox, cx| inbox.set_unread_count(count, cx));
|
||||
/// Regroup the current lists by repository and flatten them into rows.
|
||||
fn rebuild(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let repo_list = RepoListStore::global(cx);
|
||||
let mut sections = group_sections(&self.notifications, &self.activity);
|
||||
|
||||
if let Some(me) = backend.read(cx).current_user() {
|
||||
for announcement in repo_list.read(cx).announcements_of(&me) {
|
||||
let address = announcement.addr();
|
||||
let known = sections
|
||||
.iter()
|
||||
.any(|section| section.address.as_ref() == Some(&address));
|
||||
|
||||
if !known {
|
||||
sections.push(InboxSection {
|
||||
address: Some(address),
|
||||
unread: 0,
|
||||
entries: Vec::new(),
|
||||
latest: Timestamp::default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sections.sort_by_key(|section| std::cmp::Reverse(section.latest));
|
||||
|
||||
let rows = flatten_rows(§ions);
|
||||
self.sections = Arc::new(sections);
|
||||
self.rows = Arc::new(rows);
|
||||
}
|
||||
|
||||
/// Forget everything derived for the current user.
|
||||
fn clear(&mut self) {
|
||||
self.notifications = Arc::new(Vec::new());
|
||||
self.activity = Arc::new(Vec::new());
|
||||
self.sections = Arc::new(Vec::new());
|
||||
self.rows = Arc::new(Vec::new());
|
||||
self.unread_count = 0;
|
||||
self.state = InboxReadState::default();
|
||||
self.state_loaded = false;
|
||||
@@ -280,147 +336,205 @@ impl InboxView {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn section(&self, header: impl IntoElement, body: impl IntoElement, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.w_full()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.rounded(cx.theme().radius)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.bg(cx.theme().muted.opacity(0.5))
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(header),
|
||||
)
|
||||
.child(body)
|
||||
.into_any_element()
|
||||
}
|
||||
fn render_entry(&self, ix: usize, cx: &App) -> AnyElement {
|
||||
let Some(row) = self.rows.get(ix) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
fn render_inbox_panel(
|
||||
&self,
|
||||
unread: usize,
|
||||
notifications: Arc<Vec<InboxItem>>,
|
||||
visible: Vec<usize>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let header = h_flex()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(Icon::new(IconName::Inbox).small())
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Inbox")),
|
||||
)
|
||||
.when(unread > 0, |this| this.child(CountBadge::new(unread)))
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
SegmentButton::new("mark-all-read", "Mark all read")
|
||||
.on_click(cx.listener(|this, _event, _window, cx| this.mark_all_read(cx))),
|
||||
);
|
||||
|
||||
let body = if visible.is_empty() {
|
||||
empty_state(IconName::Inbox, "You're all caught up.", cx)
|
||||
} else {
|
||||
let list_state = self.notifications_list.clone();
|
||||
let dock_area = self.dock_area.clone();
|
||||
let rows = list(list_state.clone(), move |ix, _window, cx| {
|
||||
let Some(item) = visible.get(ix).and_then(|&ix| notifications.get(ix)) else {
|
||||
match *row {
|
||||
InboxRow::Empty => empty_section_row(cx),
|
||||
InboxRow::Repo(section_ix) => {
|
||||
let Some(section) = self.sections.get(section_ix) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
repo_header(section, cx)
|
||||
}
|
||||
InboxRow::Entry(section_ix, entry_ix) => {
|
||||
let Some(section) = self.sections.get(section_ix) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let root = item.root;
|
||||
let kind = item.root_kind;
|
||||
let address = item.address.clone();
|
||||
let dock_area = dock_area.clone();
|
||||
|
||||
notification_row("inbox-row", ix, item, cx)
|
||||
.on_click(move |_, window, cx| {
|
||||
open_item(&dock_area, root, kind, address.clone(), window, cx);
|
||||
})
|
||||
.into_any_element()
|
||||
})
|
||||
.size_full()
|
||||
.min_h_0();
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.child(rows)
|
||||
.vertical_scrollbar(&list_state)
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
self.section(header, body, cx)
|
||||
}
|
||||
|
||||
fn render_activity_panel(
|
||||
&self,
|
||||
activity: Arc<Vec<Event>>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let header = h_flex()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(Icon::new(CustomIconName::Recent).small())
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Continue where you left off")),
|
||||
);
|
||||
|
||||
let body = if activity.is_empty() {
|
||||
empty_state(CustomIconName::Recent, "No recent activity.", cx)
|
||||
} else {
|
||||
let list_state = self.activity_list.clone();
|
||||
let rows = list(list_state.clone(), move |ix, _window, cx| {
|
||||
let Some(event) = activity.get(ix) else {
|
||||
let Some(entry) = section.entries.get(entry_ix) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
activity_row(ix, event, cx)
|
||||
})
|
||||
.size_full()
|
||||
.min_h_0();
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.child(rows)
|
||||
.vertical_scrollbar(&list_state)
|
||||
.into_any_element()
|
||||
};
|
||||
match *entry {
|
||||
InboxEntry::Notification(item_ix) => {
|
||||
let Some(item) = self.notifications.get(item_ix) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
self.section(header, body, cx)
|
||||
let root = item.root;
|
||||
let kind = item.root_kind;
|
||||
let address = section.address.clone();
|
||||
let dock_area = self.dock_area.clone();
|
||||
|
||||
notification_row("inbox-row", ix, item, cx)
|
||||
.on_click(move |_, window, cx| {
|
||||
open_item(&dock_area, root, kind, address.clone(), window, cx);
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
InboxEntry::Activity(event_ix) => {
|
||||
let Some(event) = self.activity.get(event_ix) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
activity_row(ix, event, cx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Group the notification groups and own activity into one section per repository.
|
||||
fn group_sections(notifications: &[InboxItem], activity: &[Event]) -> Vec<InboxSection> {
|
||||
let mut by_repo: HashMap<Option<RepoAddr>, InboxSection> = HashMap::new();
|
||||
|
||||
for (ix, item) in notifications.iter().enumerate() {
|
||||
if item.archived {
|
||||
continue;
|
||||
}
|
||||
|
||||
let address = item.address.clone();
|
||||
let section = by_repo
|
||||
.entry(address.clone())
|
||||
.or_insert_with(move || InboxSection {
|
||||
address,
|
||||
unread: 0,
|
||||
entries: Vec::new(),
|
||||
latest: Timestamp::default(),
|
||||
});
|
||||
|
||||
if item.is_unread() {
|
||||
section.unread += 1;
|
||||
}
|
||||
|
||||
section.latest = section.latest.max(item.latest_activity());
|
||||
section.entries.push(InboxEntry::Notification(ix));
|
||||
}
|
||||
|
||||
for (ix, event) in activity.iter().enumerate() {
|
||||
let address = repo_address(event);
|
||||
let section = by_repo
|
||||
.entry(address.clone())
|
||||
.or_insert_with(move || InboxSection {
|
||||
address,
|
||||
unread: 0,
|
||||
entries: Vec::new(),
|
||||
latest: Timestamp::default(),
|
||||
});
|
||||
|
||||
section.latest = section.latest.max(event.created_at);
|
||||
section.entries.push(InboxEntry::Activity(ix));
|
||||
}
|
||||
|
||||
let mut sections: Vec<InboxSection> = by_repo.into_values().collect();
|
||||
|
||||
for section in &mut sections {
|
||||
section.entries.sort_by(|a, b| {
|
||||
entry_time(b, notifications, activity).cmp(&entry_time(a, notifications, activity))
|
||||
});
|
||||
}
|
||||
|
||||
sections.sort_by_key(|section| std::cmp::Reverse(section.latest));
|
||||
sections
|
||||
}
|
||||
|
||||
/// Timestamp an entry is ordered by.
|
||||
fn entry_time(entry: &InboxEntry, notifications: &[InboxItem], activity: &[Event]) -> Timestamp {
|
||||
match entry {
|
||||
InboxEntry::Notification(ix) => notifications
|
||||
.get(*ix)
|
||||
.map(InboxItem::latest_activity)
|
||||
.unwrap_or_default(),
|
||||
InboxEntry::Activity(ix) => activity
|
||||
.get(*ix)
|
||||
.map(|event| event.created_at)
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten the sections into the list of repository headers and their rows.
|
||||
fn flatten_rows(sections: &[InboxSection]) -> Vec<InboxRow> {
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for (section_ix, section) in sections.iter().enumerate() {
|
||||
rows.push(InboxRow::Repo(section_ix));
|
||||
|
||||
if section.entries.is_empty() {
|
||||
rows.push(InboxRow::Empty);
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.extend(
|
||||
(0..section.entries.len()).map(|entry_ix| InboxRow::Entry(section_ix, entry_ix)),
|
||||
);
|
||||
}
|
||||
|
||||
rows
|
||||
}
|
||||
|
||||
/// Repository an activity event belongs to, from its `a` tag.
|
||||
fn repo_address(event: &Event) -> Option<RepoAddr> {
|
||||
event
|
||||
.tags
|
||||
.coordinates()
|
||||
.find(|address| address.kind == Kind::GitRepoAnnouncement)
|
||||
}
|
||||
|
||||
/// Display name of the repository at `addr`, from the announcement store.
|
||||
fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> {
|
||||
let repo_list = RepoListStore::global(cx);
|
||||
let addr = addr?;
|
||||
RepoListStore::global(cx)
|
||||
repo_list
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.find(|announcement| announcement.addr() == *addr)
|
||||
.map(display_name)
|
||||
.map(|announcement| announcement.name().map(SharedString::from))
|
||||
}
|
||||
|
||||
/// Open the repository of a notification group, and the issue or pull request
|
||||
/// detail when the group's root is one.
|
||||
///
|
||||
/// The group carries only the repository coordinate, so the announcement is
|
||||
/// looked up in the local list. A group whose repository is not known locally
|
||||
/// opens nothing.
|
||||
/// Header of a repository section.
|
||||
fn repo_header(section: &InboxSection, cx: &App) -> AnyElement {
|
||||
let name =
|
||||
repo_name(section.address.as_ref(), cx).unwrap_or_else(|| SharedString::from("Untitled"));
|
||||
|
||||
h_flex()
|
||||
.h_12()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(name),
|
||||
)
|
||||
.when(section.unread > 0, |this| {
|
||||
this.child(CountBadge::new(section.unread))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Placeholder under a repository header that has nothing to show.
|
||||
fn empty_section_row(cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.h_12()
|
||||
.w_full()
|
||||
.px_3()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.bg(cx.theme().secondary)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(SharedString::from("No activity yet."))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Open the repository of a notification group,
|
||||
/// and the issue or pull request detail when the group's root is one.
|
||||
fn open_item(
|
||||
dock_area: &WeakEntity<DockArea>,
|
||||
root: EventId,
|
||||
@@ -458,125 +572,110 @@ fn open_item(
|
||||
open_repo_item(dock_area, store, item, window, cx);
|
||||
}
|
||||
|
||||
/// Leading row of a notification group, newest event first.
|
||||
///
|
||||
/// `prefix` scopes the row's element id, so the inbox list and the Unread /
|
||||
/// Archived list do not collide when both are on screen.
|
||||
fn notification_row(prefix: &'static str, ix: usize, item: &InboxItem, cx: &App) -> Stateful<Div> {
|
||||
let Some(newest) = item.events.first() else {
|
||||
return div().id((prefix, ix));
|
||||
};
|
||||
|
||||
let profiles = ProfileStore::global(cx);
|
||||
let profile = profiles.read(cx).get(&newest.pubkey);
|
||||
let kind = item.root_kind.unwrap_or(newest.kind);
|
||||
let subject = SharedString::from(activity_subject(newest));
|
||||
let repo = repo_name(item.address.as_ref(), cx);
|
||||
let age = relative_time(item.latest_activity());
|
||||
let unread = item.is_unread();
|
||||
|
||||
h_flex()
|
||||
v_flex()
|
||||
.id((prefix, ix))
|
||||
.h_16()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.items_center()
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.child(UserAvatar::new(profile.name()).picture(profile.picture()))
|
||||
.child(div().flex_shrink_0().child(kind_icon(kind)))
|
||||
.justify_center()
|
||||
.bg(cx.theme().secondary)
|
||||
.hover(|this| this.bg(cx.theme().secondary_hover))
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.size_6()
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(kind_icon(kind)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.when(unread, |this| this.font_semibold())
|
||||
.min_w_0()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(subject),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(kind_label(kind)))
|
||||
.when_some(repo, |this, repo| {
|
||||
this.child(SharedString::from("on")).child(repo)
|
||||
})
|
||||
.child(SharedString::from("·"))
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
.child(div().flex_1())
|
||||
.when(unread, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.size_2()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().primary),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
h_flex().gap_2().child(div().w_6().flex_shrink_0()).child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(kind_label(kind)))
|
||||
.child("·")
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.when(unread, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.size(px(8.))
|
||||
.rounded(px(4.))
|
||||
.bg(cx.theme().primary),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// One row of the user's own recent git activity.
|
||||
fn activity_row(ix: usize, event: &Event, cx: &App) -> AnyElement {
|
||||
let kind = event.kind;
|
||||
let subject = SharedString::from(activity_subject(event));
|
||||
let repo = repo_name(event.tags.coordinates().next().as_ref(), cx);
|
||||
let age = relative_time(event.created_at);
|
||||
|
||||
h_flex()
|
||||
v_flex()
|
||||
.id(("activity-row", ix))
|
||||
.h_16()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.items_center()
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.child(div().flex_shrink_0().child(kind_icon(kind)))
|
||||
.justify_center()
|
||||
.bg(cx.theme().secondary)
|
||||
.hover(|this| this.bg(cx.theme().secondary_hover))
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(subject),
|
||||
)
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(kind_label(kind)))
|
||||
.when_some(repo, |this, repo| {
|
||||
this.child(SharedString::from("on")).child(repo)
|
||||
})
|
||||
.child(SharedString::from("·"))
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
.size_6()
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(kind_icon(kind)),
|
||||
)
|
||||
.child(subject),
|
||||
)
|
||||
.child(
|
||||
h_flex().gap_2().child(div().w_6().flex_shrink_0()).child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(kind_label(kind)))
|
||||
.child("·")
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Name to show for a repository, its `name` tag or its id.
|
||||
fn display_name(announcement: &Announcement) -> SharedString {
|
||||
announcement
|
||||
.name
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
|
||||
}
|
||||
|
||||
/// Leading icon for a notification or activity kind.
|
||||
fn kind_icon(kind: Kind) -> Icon {
|
||||
if kind == COVER_NOTE_KIND {
|
||||
@@ -589,7 +688,7 @@ fn kind_icon(kind: Kind) -> Icon {
|
||||
Icon::new(CustomIconName::GitPullRequest)
|
||||
}
|
||||
Kind::GitPatch => Icon::new(CustomIconName::GitCommit),
|
||||
Kind::Comment => Icon::new(IconName::FileText),
|
||||
Kind::Comment => Icon::new(IconName::File),
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
@@ -665,31 +764,54 @@ impl Focusable for InboxView {
|
||||
|
||||
impl Render for InboxView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let unread = self.unread_count;
|
||||
let notifications = self.notifications.clone();
|
||||
let activity = self.activity.clone();
|
||||
let rows = self.rows.clone();
|
||||
|
||||
let visible: Vec<usize> = notifications
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, item)| !item.archived)
|
||||
.map(|(ix, _)| ix)
|
||||
.collect();
|
||||
|
||||
if self.notifications_list.item_count() != visible.len() {
|
||||
self.notifications_list.reset(visible.len());
|
||||
}
|
||||
|
||||
if self.activity_list.item_count() != activity.len() {
|
||||
self.activity_list.reset(activity.len());
|
||||
if self.list.item_count() != rows.len() {
|
||||
self.list.reset(rows.len());
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.image_cache(gpui::retain_all("inbox"))
|
||||
.gap_4()
|
||||
.p_4()
|
||||
.child(self.render_inbox_panel(unread, notifications, visible, cx))
|
||||
.child(self.render_activity_panel(activity, cx))
|
||||
.size_full()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_4()
|
||||
.h_12()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Inbox")),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
Button::new("mark-all")
|
||||
.icon(IconName::CircleCheck)
|
||||
.secondary()
|
||||
.tooltip("Mark all as read")
|
||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||
this.mark_all_read(cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(div().relative().flex_1().min_h_0().px_4().pb_4().when_else(
|
||||
rows.is_empty(),
|
||||
|this| this.child(empty_state(IconName::Inbox, "You're all caught up.", cx)),
|
||||
|this| {
|
||||
this.child(
|
||||
list(
|
||||
self.list.clone(),
|
||||
cx.processor(|this, ix, _window, cx| this.render_entry(ix, cx)),
|
||||
)
|
||||
.size_full()
|
||||
.min_h_0()
|
||||
.into_any_element(),
|
||||
)
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use signed_core::{Announcement, RepoAddr, identifier_from_name};
|
||||
use signed_state::{
|
||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore,
|
||||
};
|
||||
use signed_ui::{CountBadge, NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
|
||||
use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
|
||||
|
||||
use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel};
|
||||
|
||||
@@ -39,8 +39,6 @@ pub struct SidebarPanel {
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
inbox: Option<WeakEntity<InboxView>>,
|
||||
explore: Option<WeakEntity<RepoListView>>,
|
||||
/// Unread notification groups, shown as the inbox nav item's badge.
|
||||
unread: usize,
|
||||
/// Artwork for the sign-in screen.
|
||||
banner: SharedString,
|
||||
/// The signed-in user's announced repositories, newest first.
|
||||
@@ -74,6 +72,7 @@ impl SidebarPanel {
|
||||
|
||||
if signer_required {
|
||||
this.banner = pick_banner();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
if this.refresh(cx) || signer_required {
|
||||
@@ -102,44 +101,20 @@ impl SidebarPanel {
|
||||
}
|
||||
}));
|
||||
|
||||
// The inbox nav item shows the unread notification count as a badge.
|
||||
let inbox = backend.read(cx).inbox();
|
||||
subscriptions.push(cx.observe(&inbox, |this, inbox, cx| {
|
||||
let unread = inbox.read(cx).unread_count;
|
||||
if this.unread != unread {
|
||||
this.unread = unread;
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
let mut this = Self {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
inbox: None,
|
||||
explore: None,
|
||||
unread: inbox.read(cx).unread_count,
|
||||
banner: pick_banner(),
|
||||
announcements: Arc::new(Vec::new()),
|
||||
local_repos: Arc::new(Vec::new()),
|
||||
scanning: false,
|
||||
unpushed: HashMap::new(),
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
|
||||
// Seed the snapshot right away.
|
||||
// The stores may already hold data from before the panel opened.
|
||||
// The first render must not depend on a later store update.
|
||||
this.refresh(cx);
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
/// The sidebar renders only its own derived fields, never the stores
|
||||
/// directly. Because the panel is a cached view, a store update alone does
|
||||
/// not re-render it: the observers notify this panel, which re-runs
|
||||
/// `render` over the fresh snapshot.
|
||||
///
|
||||
/// Returns `true` when a rendered field changed.
|
||||
fn refresh(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let backend = Backend::global(cx);
|
||||
let user = backend.read(cx).current_user();
|
||||
@@ -644,9 +619,6 @@ impl Render for SidebarPanel {
|
||||
.justify_start()
|
||||
.child(
|
||||
NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small())
|
||||
.when(self.unread > 0, |this| {
|
||||
this.suffix(CountBadge::new(self.unread))
|
||||
})
|
||||
.on_click(cx.listener(|this, _ev, window, cx| {
|
||||
this.open_inbox(window, cx)
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user