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
+175 -19
View File
@@ -4,7 +4,7 @@ use std::time::Duration;
use nostr::prelude::*;
use serde::{Deserialize, Serialize};
use crate::{COVER_NOTE_KIND, RepoAddr};
use crate::{COVER_NOTE_KIND, RepoAddr, activity_subject};
/// Window before `now` that an advanced cutoff retreats to.
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
@@ -12,33 +12,92 @@ const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
/// Window before `now` that a mark-all cutoff retreats to.
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
/// A thread of notification events sharing one root
/// A thread of notification and own-activity events sharing one root.
#[derive(Debug, Clone)]
pub struct InboxItem {
/// The root issue, patch or pull request the notifications belong to.
/// The root issue, patch or pull request the events belong to.
pub root: EventId,
/// The root event itself, when it is known locally.
pub root_event: Option<Event>,
/// Kind of the root event, when it is known locally.
pub root_kind: Option<Kind>,
/// Repository the root belongs to, from the root's `a` tag.
pub address: Option<RepoAddr>,
/// Events in the group, newest first.
/// Notification events directed at the user, newest first.
pub events: Vec<Event>,
/// The user's own events in the thread, newest first.
pub own_events: Vec<Event>,
/// Unread event ids, oldest first.
pub unread_ids: Vec<EventId>,
/// Whether every event in the group is archived.
/// Whether every notification event in the thread is archived.
pub archived: bool,
}
impl InboxItem {
/// Timestamp of the newest event in the group.
/// Title of the thread, read from its root issue/patch/PR when known.
pub fn title(&self) -> String {
self.root_event
.as_ref()
.or_else(|| self.own_events.first())
.or_else(|| self.events.first())
.map(activity_subject)
.unwrap_or_else(|| "Untitled".to_string())
}
/// Kind shown for the thread.
pub fn kind(&self) -> Option<Kind> {
self.root_kind.or_else(|| {
self.root_event
.as_ref()
.or_else(|| self.own_events.first())
.or_else(|| self.events.first())
.map(|event| event.kind)
})
}
/// Timestamp of the newest event in the thread.
pub fn latest_activity(&self) -> Timestamp {
self.events
.first()
self.root_event
.as_ref()
.into_iter()
.chain(self.own_events.first())
.chain(self.events.first())
.map(|event| event.created_at)
.max()
.unwrap_or_default()
}
/// Whether the group has an unread event still visible in the inbox.
/// Up to `limit` events of the thread, oldest first.
pub fn timeline(&self, limit: usize) -> Vec<Event> {
let mut seen: HashSet<EventId> = HashSet::new();
let mut events: Vec<Event> = Vec::new();
if let Some(root) = &self.root_event {
seen.insert(root.id);
events.push(root.clone());
}
let mut rest: Vec<Event> = self
.own_events
.iter()
.chain(self.events.iter())
.filter(|event| seen.insert(event.id))
.cloned()
.collect();
rest.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
});
rest.truncate(limit.saturating_sub(events.len()));
events.extend(rest);
events.sort_by_key(|event| event.created_at);
events
}
/// Whether the thread has an unread event still visible in the inbox.
pub fn is_unread(&self) -> bool {
!self.archived && !self.unread_ids.is_empty()
}
@@ -53,7 +112,9 @@ impl InboxItem {
.map(|event| event.id)
.collect();
self.archived = self.events.iter().all(|event| state.is_archived(event));
// A thread without notification events is never archived.
self.archived =
!self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event));
}
}
@@ -97,10 +158,17 @@ where
}
}
/// Group notification events by root, newest activity first.
pub fn group<E, L>(events: E, me: PublicKey, state: &InboxReadState, lookup: &L) -> Vec<InboxItem>
/// Group notification events and the user's own events into one item per thread.
pub fn group<E, O, L>(
events: E,
own: O,
me: PublicKey,
state: &InboxReadState,
lookup: &L,
) -> Vec<InboxItem>
where
E: IntoIterator<Item = Event>,
O: IntoIterator<Item = Event>,
L: Fn(EventId) -> Option<Event>,
{
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
@@ -114,14 +182,23 @@ where
groups.entry(root).or_default().push(event);
}
let mut items: Vec<InboxItem> = groups
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
for event in own {
let root = notification_root(&event, lookup).unwrap_or(event.id);
own_groups.entry(root).or_default().push(event);
}
let mut roots: Vec<EventId> = groups.keys().chain(own_groups.keys()).copied().collect();
roots.sort();
roots.dedup();
let mut items: Vec<InboxItem> = roots
.into_iter()
.map(|(root, mut events)| {
events.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
});
.map(|root| {
let mut events = groups.remove(&root).unwrap_or_default();
let mut own_events = own_groups.remove(&root).unwrap_or_default();
sort_newest_first(&mut events);
sort_newest_first(&mut own_events);
let root_event = lookup(root);
@@ -131,7 +208,9 @@ where
address: root_event
.as_ref()
.and_then(|event| event.tags.coordinates().next()),
root_event,
events,
own_events,
unread_ids: Vec::new(),
archived: false,
};
@@ -149,6 +228,15 @@ where
items
}
/// Sort thread events newest first, ties broken by id.
fn sort_newest_first(events: &mut [Event]) {
events.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
});
}
/// Read and archive state of the inbox, a high-water-mark model.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct InboxReadState {
@@ -392,6 +480,15 @@ mod tests {
signed(author, Kind::GitIssue, Vec::new(), at)
}
fn titled_issue(author: &Keys, title: &str, at: u64) -> Event {
signed(
author,
Kind::GitIssue,
vec![Tag::parse(["subject", title]).expect("valid subject tag")],
at,
)
}
#[test]
fn issue_and_pull_request_are_their_own_root() {
let events = [
@@ -496,6 +593,7 @@ mod tests {
let events = [issue.clone(), comment.clone(), other_issue.clone(), mine];
let items = group(
events,
Vec::new(),
me.public_key(),
&InboxReadState::default(),
&lookup(&[]),
@@ -518,6 +616,7 @@ mod tests {
let events = [issue.clone(), older.clone(), newer.clone()];
let items = group(
events,
Vec::new(),
me.public_key(),
&InboxReadState::default(),
&lookup(&[]),
@@ -532,6 +631,7 @@ mod tests {
};
let items = group(
[issue.clone(), older, newer],
Vec::new(),
me.public_key(),
&state,
&lookup(&[]),
@@ -557,6 +657,7 @@ mod tests {
let events = [issue.clone(), comment];
let items = group(
events.clone(),
Vec::new(),
me.public_key(),
&InboxReadState::default(),
&lookup(&events),
@@ -566,6 +667,59 @@ mod tests {
assert_eq!(items[0].address, issue.tags.coordinates().next());
}
#[test]
fn group_merges_own_events_into_the_matching_thread() {
let me = keys(1);
let issue = titled_issue(&me, "Add retry logic", 100);
let mine = signed(
&me,
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("K tag"),
],
150,
);
let reply = signed(
&keys(2),
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("K tag"),
],
200,
);
let context = [issue.clone(), mine.clone(), reply.clone()];
let items = group(
[reply.clone()],
[issue.clone(), mine.clone()],
me.public_key(),
&InboxReadState::default(),
&lookup(&context),
);
assert_eq!(items.len(), 1);
assert_eq!(items[0].root, issue.id);
assert_eq!(
items[0].root_event.as_ref().map(|event| event.id),
Some(issue.id)
);
assert_eq!(items[0].kind(), Some(Kind::GitIssue));
assert_eq!(items[0].title(), "Add retry logic");
assert_eq!(items[0].events, vec![reply.clone()]);
// The own events are kept apart from the notifications, newest first.
assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]);
assert_eq!(
items[0]
.timeline(5)
.iter()
.map(|event| event.id)
.collect::<Vec<_>>(),
vec![issue.id, mine.id, reply.id]
);
}
#[test]
fn mark_all_read_marks_known_recent_events() {
let me = keys(1);
@@ -663,9 +817,11 @@ mod tests {
let second = issue(&keys(2), now.as_secs() - 1000);
let mut item = InboxItem {
root: first.id,
root_event: None,
root_kind: None,
address: None,
events: vec![second.clone(), first.clone()],
own_events: Vec::new(),
unread_ids: Vec::new(),
archived: false,
};
+9 -15
View File
@@ -128,23 +128,16 @@ impl Inbox {
}
}
/// Derive the inbox home screen's lists for `me` from the local database.
///
/// Returns the notification groups, the user's own git activity and the number
/// of non-archived groups with an unread event.
/// Derive the inbox home screen's threads for `me` from the local database.
pub async fn query_inbox(
client: &Client,
me: PublicKey,
state: &InboxReadState,
) -> Result<(Vec<InboxItem>, Vec<Event>, usize), Error> {
) -> Result<(Vec<InboxItem>, usize), Error> {
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 (notification_events, mut by_id) = fetch_notifications(client, me, &deletions).await?;
let mut activity = Vec::new();
for event in client
@@ -155,16 +148,17 @@ pub async fn query_inbox(
if deletions.is_deleted(&event) || !filters::is_git_activity(&event) {
continue;
}
by_id.entry(event.id).or_insert_with(|| event.clone());
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()))
let items = inbox::group(notification_events, activity, me, state, &|id| {
by_id.get(&id).cloned()
});
Ok((notifications, activity, unread_count))
let unread_count = items.iter().filter(|item| item.is_unread()).count();
Ok((items, unread_count))
}
/// `d` tag identifying the inbox state event of `me`.
+11 -2
View File
@@ -1,7 +1,7 @@
use gpui::prelude::*;
use gpui::{App, SharedString, StyleRefinement, Window};
use gpui_component::avatar::Avatar;
use gpui_component::{ActiveTheme, Sizable, StyledExt};
use gpui_component::{ActiveTheme, Sizable, Size, StyledExt};
/// A small user avatar from gpui-component [`Avatar`], rounded with the theme radius.
/// It shows the user's picture or falls back to name initials.
@@ -9,6 +9,7 @@ use gpui_component::{ActiveTheme, Sizable, StyledExt};
pub struct UserAvatar {
name: SharedString,
picture: Option<SharedString>,
size: Size,
style: StyleRefinement,
}
@@ -19,6 +20,7 @@ impl UserAvatar {
Self {
name: name.into(),
picture: None,
size: Size::Small,
style: StyleRefinement::default(),
}
}
@@ -30,6 +32,13 @@ impl UserAvatar {
}
}
impl Sizable for UserAvatar {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl Styled for UserAvatar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
@@ -43,6 +52,6 @@ impl RenderOnce for UserAvatar {
.when_some(self.picture, |this, url| this.src(url))
.rounded(cx.theme().radius)
.refine_style(&self.style)
.small()
.with_size(self.size)
}
}
+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()),
)
}
}