update
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run

This commit is contained in:
2026-09-12 09:55:27 +07:00
parent f62fa9884d
commit 4684e01e89
5 changed files with 443 additions and 273 deletions
+148 -216
View File
@@ -12,10 +12,8 @@ use gpui::{
};
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, Timestamp};
use signed_core::{
COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, activity_subject, filters,
};
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
use signed_core::{COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, filters};
use signed_state::{
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
};
@@ -30,29 +28,21 @@ 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.
/// Maximum number of sub-activity lines shown under a thread row.
const MAX_SUB_ACTIVITIES: usize = 5;
/// A repository's slice of the inbox: the threads that belong to it.
struct InboxSection {
/// Repository the section groups, `None` for items without one.
address: Option<RepoAddr>,
/// Number of notification groups with an unread event.
/// Number of threads with an unread event.
unread: usize,
/// Notification and activity rows, newest first.
entries: Vec<InboxEntry>,
/// Indices into the threads, newest activity first.
entries: Vec<usize>,
/// 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),
@@ -63,15 +53,13 @@ enum InboxRow {
pub struct InboxView {
focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
/// 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>>,
/// The notification and activity lists grouped by repository, newest first.
/// One row per thread, merging notifications and own activity, newest first.
threads: Arc<Vec<InboxItem>>,
/// The threads 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.
/// Number of non-archived threads with an unread event.
unread_count: usize,
/// Copy of the global read state the current lists were derived with.
state: InboxReadState,
@@ -118,8 +106,7 @@ impl InboxView {
Self {
focus_handle: cx.focus_handle(),
dock_area,
notifications: Arc::new(Vec::new()),
activity: Arc::new(Vec::new()),
threads: Arc::new(Vec::new()),
sections: Arc::new(Vec::new()),
rows: Arc::new(Vec::new()),
unread_count: 0,
@@ -144,17 +131,17 @@ impl InboxView {
/// 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 backend = Backend::global(cx);
let inbox = backend.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.sections.is_empty();
let was_present =
self.state_loaded || !self.threads.is_empty() || !self.sections.is_empty();
self.clear();
if was_present {
cx.notify();
@@ -240,7 +227,7 @@ impl InboxView {
let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await });
self.tasks.push(cx.spawn(async move |this, cx| {
let (notifications, activity, unread_count) = match work.await {
let (threads, unread_count) = match work.await {
Ok(results) => results,
Err(error) => {
log::warn!("inbox refresh failed: {error}");
@@ -254,8 +241,7 @@ impl InboxView {
return false;
}
this.notifications = Arc::new(notifications);
this.activity = Arc::new(activity);
this.threads = Arc::new(threads);
this.unread_count = unread_count;
this.rebuild(cx);
cx.notify();
@@ -273,22 +259,22 @@ impl InboxView {
/// Recompute the unread and archived flags from the current state.
fn regroup(&mut self, cx: &mut Context<Self>) {
let mut items = (*self.notifications).clone();
let mut items = (*self.threads).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);
self.threads = Arc::new(items);
self.rebuild(cx);
}
/// Regroup the current lists by repository and flatten them into rows.
/// Regroup the current threads 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);
let mut sections = group_sections(&self.threads);
if let Some(me) = backend.read(cx).current_user() {
for announcement in repo_list.read(cx).announcements_of(&me) {
@@ -317,8 +303,7 @@ impl InboxView {
/// Forget everything derived for the current user.
fn clear(&mut self) {
self.notifications = Arc::new(Vec::new());
self.activity = Arc::new(Vec::new());
self.threads = Arc::new(Vec::new());
self.sections = Arc::new(Vec::new());
self.rows = Arc::new(Vec::new());
self.unread_count = 0;
@@ -328,9 +313,9 @@ impl InboxView {
self.refresh = RefreshGate::default();
}
/// Every event in every group, archived groups included.
/// Every notification event in every thread, archived threads included.
fn all_notification_events(&self) -> Vec<Event> {
self.notifications
self.threads
.iter()
.flat_map(|item| item.events.iter().cloned())
.collect()
@@ -354,44 +339,36 @@ impl InboxView {
return div().into_any_element();
};
let Some(entry) = section.entries.get(entry_ix) else {
let Some(&thread_ix) = section.entries.get(entry_ix) else {
return div().into_any_element();
};
match *entry {
InboxEntry::Notification(item_ix) => {
let Some(item) = self.notifications.get(item_ix) else {
return div().into_any_element();
};
let Some(item) = self.threads.get(thread_ix) else {
return div().into_any_element();
};
let root = item.root;
let kind = item.root_kind;
let address = section.address.clone();
let dock_area = self.dock_area.clone();
let root = item.root;
let kind = item.root_kind;
let address = section.address.clone();
let dock_area = self.dock_area.clone();
let first = entry_ix == 0;
let last = entry_ix + 1 == section.entries.len();
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)
}
}
thread("inbox-row", ix, item, first, last, cx)
.on_click(move |_, window, cx| {
open_item(&dock_area, root, kind, address.clone(), window, cx);
})
.into_any_element()
}
}
}
}
/// Group the notification groups and own activity into one section per repository.
fn group_sections(notifications: &[InboxItem], activity: &[Event]) -> Vec<InboxSection> {
/// Group the threads into one section per repository.
fn group_sections(threads: &[InboxItem]) -> Vec<InboxSection> {
let mut by_repo: HashMap<Option<RepoAddr>, InboxSection> = HashMap::new();
for (ix, item) in notifications.iter().enumerate() {
for (ix, item) in threads.iter().enumerate() {
if item.archived {
continue;
}
@@ -411,29 +388,16 @@ fn group_sections(notifications: &[InboxItem], activity: &[Event]) -> Vec<InboxS
}
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));
section.entries.push(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))
threads[*b]
.latest_activity()
.cmp(&threads[*a].latest_activity())
});
}
@@ -441,20 +405,6 @@ fn group_sections(notifications: &[InboxItem], activity: &[Event]) -> Vec<InboxS
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();
@@ -475,14 +425,6 @@ fn flatten_rows(sections: &[InboxSection]) -> Vec<InboxRow> {
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);
@@ -526,15 +468,13 @@ fn empty_section_row(cx: &App) -> AnyElement {
.w_full()
.px_3()
.text_xs()
.text_color(cx.theme().muted_foreground)
.bg(cx.theme().secondary)
.text_color(cx.theme().secondary_foreground)
.bg(cx.theme().secondary.alpha(0.6))
.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,
@@ -572,30 +512,40 @@ fn open_item(
open_repo_item(dock_area, store, item, window, cx);
}
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 profile_store = ProfileStore::global(cx);
let profile = profile_store.read(cx).get(&newest.pubkey);
let author = profile.name();
let picture = profile.picture();
let kind = item.root_kind.unwrap_or(newest.kind);
let subject = SharedString::from(activity_subject(newest));
let age = relative_time(item.latest_activity());
fn thread(
prefix: &'static str,
ix: usize,
item: &InboxItem,
first: bool,
last: bool,
cx: &App,
) -> Stateful<Div> {
let title = SharedString::from(item.title());
let kind = item.kind().unwrap_or(Kind::Comment);
let unread = item.is_unread();
let backend = Backend::global(cx);
let me = backend.read(cx).current_user();
let mut timeline = v_flex().gap_2().w_full();
for event in item.timeline(MAX_SUB_ACTIVITIES) {
timeline = timeline.child(sub_activity_line(&event, me, cx));
}
v_flex()
.id((prefix, ix))
.h_16()
.w_full()
.px_3()
.gap_1()
.justify_center()
.bg(cx.theme().secondary)
.hover(|this| this.bg(cx.theme().secondary_hover))
.py_2()
.gap_2()
.bg(cx.theme().secondary.alpha(0.6))
.when(first, |this| this.rounded_t(cx.theme().radius))
.when(last, |this| this.rounded_b(cx.theme().radius))
.when(!last, |this| {
this.border_b_1().border_color(cx.theme().border)
})
.hover(|this| this.bg(cx.theme().secondary_hover.alpha(0.8)))
.child(
h_flex()
.gap_2()
@@ -613,7 +563,7 @@ fn notification_row(prefix: &'static str, ix: usize, item: &InboxItem, cx: &App)
.min_w_0()
.whitespace_nowrap()
.text_ellipsis()
.child(subject),
.child(title),
)
.child(div().flex_1())
.when(unread, |this| {
@@ -626,72 +576,44 @@ fn notification_row(prefix: &'static str, ix: usize, item: &InboxItem, cx: &App)
)
}),
)
.child(
h_flex()
.w_full()
.gap_2()
.child(div().w_6().flex_shrink_0())
.child(
h_flex()
.flex_1()
.gap_1()
.items_center()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(
h_flex()
.gap_1()
.child(UserAvatar::new(author.clone()).picture(picture))
.child(author.clone()),
)
.child("opened")
.child(SharedString::from(kind_label(kind)))
.child(div().flex_1())
.child(SharedString::from(age)),
),
)
.child(timeline)
}
fn activity_row(ix: usize, event: &Event, cx: &App) -> AnyElement {
let kind = event.kind;
let subject = SharedString::from(activity_subject(event));
let age = relative_time(event.created_at);
fn sub_activity_line(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement {
let profile_store = ProfileStore::global(cx).read(cx);
let profile = profile_store.get(&event.pubkey);
v_flex()
.id(("activity-row", ix))
.h_16()
let name = if Some(event.pubkey) == me {
SharedString::from("You")
} else {
profile.name()
};
h_flex()
.w_full()
.px_3()
.gap_1()
.justify_center()
.bg(cx.theme().secondary)
.hover(|this| this.bg(cx.theme().secondary_hover))
.gap_2()
.items_center()
.child(div().w_6().flex_shrink_0())
.child(
h_flex()
.gap_2()
.text_sm()
.whitespace_nowrap()
.text_ellipsis()
.flex_1()
.min_w_0()
.gap_1()
.items_center()
.text_xs()
.child(
h_flex()
.size_6()
.flex_shrink_0()
.items_center()
.justify_center()
.child(kind_icon(kind)),
UserAvatar::new(name.clone())
.picture(profile.picture())
.xsmall(),
)
.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)),
),
.child(name)
.child(SharedString::from(activity_phrase(event.kind)))
.child(div().flex_1())
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(relative_time(event.created_at))),
),
)
.into_any_element()
}
@@ -718,23 +640,23 @@ fn kind_icon(kind: Kind) -> Icon {
.small()
}
/// Short noun for a notification or activity kind.
fn kind_label(kind: Kind) -> &'static str {
/// Phrase describing an activity event, read as `[name] [phrase]`.
fn activity_phrase(kind: Kind) -> &'static str {
if kind == COVER_NOTE_KIND {
return "note";
return "added a note";
}
match kind {
Kind::GitIssue => "issue",
Kind::GitPullRequest => "PR",
Kind::GitPullRequestUpdate => "PR update",
Kind::GitPatch => "patch",
Kind::Comment => "comment",
Kind::GitStatusOpen
| Kind::GitStatusApplied
| Kind::GitStatusClosed
| Kind::GitStatusDraft => "status",
_ => "activity",
Kind::GitIssue => "opened an issue",
Kind::GitPullRequest => "opened a PR",
Kind::GitPullRequestUpdate => "updated a PR",
Kind::GitPatch => "created a patch",
Kind::Comment => "commented",
Kind::GitStatusOpen => "opened a status",
Kind::GitStatusApplied => "applied a status",
Kind::GitStatusClosed => "closed a status",
Kind::GitStatusDraft => "drafted a status",
_ => "did something",
}
}
@@ -818,20 +740,30 @@ impl Render for InboxView {
})),
),
)
.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(),
.child(
div()
.relative()
.flex_1()
.min_h_0()
.px_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(),
)
},
)
},
))
.child(div().h_6().w_full().flex_shrink_0()),
)
}
}