update ui

This commit is contained in:
2026-09-11 21:35:03 +07:00
parent 625587fd52
commit 81e421d121
4 changed files with 578 additions and 429 deletions
+384 -262
View File
@@ -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 [&notifications_list, &activity_list] {
let weak = weak.clone();
list_state.set_scroll_handler(move |_, _, cx| {
let weak = weak.clone();
cx.defer(move |cx| {
let _ = weak.update(cx, |_, cx| cx.notify());
});
});
}
// Drive the lists from the global inbox state and from backend events.
let 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(&sections);
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(),
)
},
))
}
}
+4 -32
View File
@@ -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)
})),