Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
568b6c0e41 | ||
|
|
1e7cf0020c | ||
|
|
e63e58c125 | ||
|
|
b7221ef814 | ||
|
|
b7a020767e |
Generated
+197
-152
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,19 @@ pub fn add_center_panel(
|
|||||||
area.add_panel_view(panel, DockPlacement::Center, None, window, cx);
|
area.add_panel_view(panel, DockPlacement::Center, None, window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add an already-wrapped panel handle to the bottom dock of `area`.
|
||||||
|
///
|
||||||
|
/// Used for sub-views that hang under the center, such as the inbox's Unread
|
||||||
|
/// and Archived lists.
|
||||||
|
pub fn add_bottom_panel(
|
||||||
|
area: &mut DockArea,
|
||||||
|
panel: Arc<dyn PanelView>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<DockArea>,
|
||||||
|
) {
|
||||||
|
area.add_panel_view(panel, DockPlacement::Bottom, None, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
/// The fixed height of the tab bar, which doubles as the window title bar.
|
/// The fixed height of the tab bar, which doubles as the window title bar.
|
||||||
pub const TAB_BAR_HEIGHT: Pixels = px(44.);
|
pub const TAB_BAR_HEIGHT: Pixels = px(44.);
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,7 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
use crate::RepoAddr;
|
use crate::{COVER_NOTE_KIND, RepoAddr};
|
||||||
|
|
||||||
/// Kinds that make up the activity of a repository.
|
/// Kinds that make up the activity of a repository.
|
||||||
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
||||||
@@ -17,6 +17,41 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [
|
|||||||
Kind::GitStatusDraft,
|
Kind::GitStatusDraft,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Kinds that notify a user when they tag them via their `p` tag.
|
||||||
|
pub const NOTIFICATION_KINDS: [Kind; 9] = [
|
||||||
|
Kind::GitIssue,
|
||||||
|
Kind::GitPullRequest,
|
||||||
|
Kind::GitPatch,
|
||||||
|
Kind::GitPullRequestUpdate,
|
||||||
|
COVER_NOTE_KIND,
|
||||||
|
Kind::GitStatusOpen,
|
||||||
|
Kind::GitStatusApplied,
|
||||||
|
Kind::GitStatusClosed,
|
||||||
|
Kind::GitStatusDraft,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Git root kinds that make a comment or cover note count as git activity.
|
||||||
|
const GIT_ROOT_KINDS: [Kind; 4] = [
|
||||||
|
Kind::GitIssue,
|
||||||
|
Kind::GitPatch,
|
||||||
|
Kind::GitPullRequest,
|
||||||
|
Kind::GitRepoAnnouncement,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Value of the first tag named `name` on `event`.
|
||||||
|
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find(|tag| tag.kind() == name)
|
||||||
|
.and_then(|tag| tag.content())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kind named by the first tag `name` on `event`.
|
||||||
|
fn tag_kind(event: &Event, name: &str) -> Option<Kind> {
|
||||||
|
tag_value(event, name)?.parse::<Kind>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// Latest announcement event for a repository.
|
/// Latest announcement event for a repository.
|
||||||
pub fn announcement(addr: &RepoAddr) -> Filter {
|
pub fn announcement(addr: &RepoAddr) -> Filter {
|
||||||
Filter::new()
|
Filter::new()
|
||||||
@@ -94,6 +129,68 @@ pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// NIP-22 comments on our issues, patches and pull requests.
|
||||||
|
/// They are matched via the uppercase `P` and `K` tags, not authorship.
|
||||||
|
pub fn notification_comments(me: PublicKey) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kind(Kind::Comment)
|
||||||
|
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
|
||||||
|
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activity directed at us: comments on our roots, and git events tagging us
|
||||||
|
/// via their lowercase `p` tag. `Filter::pubkey` sets that `p` tag.
|
||||||
|
pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
||||||
|
vec![
|
||||||
|
notification_comments(me),
|
||||||
|
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Git activity authored by `me`, for "Continue where you left off".
|
||||||
|
///
|
||||||
|
/// A comment on an unrelated kind is matched too, so results must be filtered
|
||||||
|
/// through [`is_git_activity`] before display.
|
||||||
|
pub fn authored_activity(me: PublicKey) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kinds(
|
||||||
|
ACTIVITY_KINDS
|
||||||
|
.into_iter()
|
||||||
|
.chain(std::iter::once(COVER_NOTE_KIND)),
|
||||||
|
)
|
||||||
|
.author(me)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
|
||||||
|
fn is_git_comment(event: &Event) -> bool {
|
||||||
|
event.kind == Kind::Comment
|
||||||
|
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a kind-1624 cover note targets a git root, checked via its `k` tag.
|
||||||
|
fn is_git_cover_note(event: &Event) -> bool {
|
||||||
|
event.kind == COVER_NOTE_KIND
|
||||||
|
&& tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a status event references a git root, checked via its `k` tag.
|
||||||
|
fn is_git_status(event: &Event) -> bool {
|
||||||
|
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `event` is git activity worth showing in the activity list.
|
||||||
|
pub fn is_git_activity(event: &Event) -> bool {
|
||||||
|
match event.kind {
|
||||||
|
Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true,
|
||||||
|
Kind::Comment => is_git_comment(event),
|
||||||
|
Kind::GitStatusOpen
|
||||||
|
| Kind::GitStatusApplied
|
||||||
|
| Kind::GitStatusClosed
|
||||||
|
| Kind::GitStatusDraft => is_git_status(event),
|
||||||
|
kind => kind == COVER_NOTE_KIND && is_git_cover_note(event),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// All repository announcements, for global discovery.
|
/// All repository announcements, for global discovery.
|
||||||
pub fn all_announcements() -> Filter {
|
pub fn all_announcements() -> Filter {
|
||||||
Filter::new().kind(Kind::GitRepoAnnouncement)
|
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||||
@@ -134,3 +231,94 @@ pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> {
|
|||||||
Filter::new().kind(Kind::EventDeletion).coordinate(addr),
|
Filter::new().kind(Kind::EventDeletion).coordinate(addr),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn keys(seed: u8) -> Keys {
|
||||||
|
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
||||||
|
hex.push_str(&format!("{seed:02x}"));
|
||||||
|
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>) -> Event {
|
||||||
|
EventBuilder::new(kind, "")
|
||||||
|
.tags(tags)
|
||||||
|
.finalize(author)
|
||||||
|
.expect("signed event")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind_tag(name: &str, kind: Kind) -> Tag {
|
||||||
|
Tag::parse([name, &kind.as_u16().to_string()]).expect("valid kind tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_git_kinds_are_activity() {
|
||||||
|
for kind in [Kind::GitIssue, Kind::GitPatch, Kind::GitPullRequest] {
|
||||||
|
assert!(is_git_activity(&signed(&keys(1), kind, Vec::new())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comment_activity_depends_on_the_uppercase_k_tag() {
|
||||||
|
let on_git = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::GitIssue)]);
|
||||||
|
let on_repo = signed(
|
||||||
|
&keys(1),
|
||||||
|
Kind::Comment,
|
||||||
|
vec![kind_tag("K", Kind::GitRepoAnnouncement)],
|
||||||
|
);
|
||||||
|
let on_note = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::TextNote)]);
|
||||||
|
|
||||||
|
assert!(is_git_activity(&on_git));
|
||||||
|
assert!(is_git_activity(&on_repo));
|
||||||
|
assert!(!is_git_activity(&on_note));
|
||||||
|
assert!(!is_git_activity(&signed(
|
||||||
|
&keys(1),
|
||||||
|
Kind::Comment,
|
||||||
|
Vec::new()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_and_cover_note_activity_depend_on_the_lowercase_k_tag() {
|
||||||
|
let status = signed(
|
||||||
|
&keys(1),
|
||||||
|
Kind::GitStatusClosed,
|
||||||
|
vec![kind_tag("k", Kind::GitPullRequest)],
|
||||||
|
);
|
||||||
|
let cover = signed(
|
||||||
|
&keys(1),
|
||||||
|
COVER_NOTE_KIND,
|
||||||
|
vec![kind_tag("k", Kind::GitPatch)],
|
||||||
|
);
|
||||||
|
let unrelated = signed(
|
||||||
|
&keys(1),
|
||||||
|
Kind::GitStatusClosed,
|
||||||
|
vec![kind_tag("k", Kind::Metadata)],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(is_git_activity(&status));
|
||||||
|
assert!(is_git_activity(&cover));
|
||||||
|
assert!(!is_git_activity(&unrelated));
|
||||||
|
assert!(!is_git_activity(&signed(
|
||||||
|
&keys(1),
|
||||||
|
Kind::GitStatusClosed,
|
||||||
|
Vec::new()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_git_kinds_are_not_activity() {
|
||||||
|
assert!(!is_git_activity(&signed(
|
||||||
|
&keys(1),
|
||||||
|
Kind::TextNote,
|
||||||
|
Vec::new()
|
||||||
|
)));
|
||||||
|
assert!(!is_git_activity(&signed(
|
||||||
|
&keys(1),
|
||||||
|
Kind::GitPullRequestUpdate,
|
||||||
|
Vec::new(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,699 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use nostr::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{COVER_NOTE_KIND, RepoAddr};
|
||||||
|
|
||||||
|
/// Window before `now` that an advanced cutoff retreats to.
|
||||||
|
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
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct InboxItem {
|
||||||
|
/// The root issue, patch or pull request the notifications belong to.
|
||||||
|
pub root: EventId,
|
||||||
|
/// 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.
|
||||||
|
pub events: Vec<Event>,
|
||||||
|
/// Unread event ids, oldest first.
|
||||||
|
pub unread_ids: Vec<EventId>,
|
||||||
|
/// Whether every event in the group is archived.
|
||||||
|
pub archived: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InboxItem {
|
||||||
|
/// Timestamp of the newest event in the group.
|
||||||
|
pub fn latest_activity(&self) -> Timestamp {
|
||||||
|
self.events
|
||||||
|
.first()
|
||||||
|
.map(|event| event.created_at)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the group has an unread event still visible in the inbox.
|
||||||
|
pub fn is_unread(&self) -> bool {
|
||||||
|
!self.archived && !self.unread_ids.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recompute the unread and archived flags from `state`.
|
||||||
|
pub fn apply_state(&mut self, state: &InboxReadState) {
|
||||||
|
self.unread_ids = self
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.filter(|event| !state.is_read(event))
|
||||||
|
.map(|event| event.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
self.archived = self.events.iter().all(|event| state.is_archived(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Root issue, patch or pull request of a notification event.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the event is not git-related, or when its root is a
|
||||||
|
/// coordinate rather than an event.
|
||||||
|
///
|
||||||
|
/// - issue (1621) / PR (1618): itself
|
||||||
|
/// - patch (1617): its `e` parent patch, else itself
|
||||||
|
/// - NIP-22 comment (1111): uppercase `E` root pointer
|
||||||
|
/// - PR update (1619): uppercase `E`
|
||||||
|
/// - statuses (1630-1633) / cover note (1624): NIP-10 root `e`
|
||||||
|
pub fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
|
||||||
|
where
|
||||||
|
L: Fn(EventId) -> Option<Event>,
|
||||||
|
{
|
||||||
|
if event.kind == COVER_NOTE_KIND {
|
||||||
|
return nip10_root_id(event).map(|root| resolve_thread_root(root, lookup));
|
||||||
|
}
|
||||||
|
match event.kind {
|
||||||
|
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
||||||
|
Kind::GitPatch => Some(match first_e_id(event) {
|
||||||
|
Some(parent) => resolve_thread_root(parent, lookup),
|
||||||
|
None => event.id,
|
||||||
|
}),
|
||||||
|
Kind::Comment => match nip22::extract_root(event) {
|
||||||
|
Some(CommentTarget::Event { id, .. }) => Some(resolve_thread_root(id, lookup)),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
Kind::GitPullRequestUpdate => {
|
||||||
|
first_uppercase_e_id(event).map(|root| resolve_thread_root(root, lookup))
|
||||||
|
}
|
||||||
|
Kind::GitStatusOpen
|
||||||
|
| Kind::GitStatusApplied
|
||||||
|
| Kind::GitStatusClosed
|
||||||
|
| Kind::GitStatusDraft => {
|
||||||
|
nip10_root_id(event).map(|root| resolve_thread_root(root, lookup))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group notification events by root, newest activity first.
|
||||||
|
pub fn group<E, L>(events: E, me: PublicKey, state: &InboxReadState, lookup: &L) -> Vec<InboxItem>
|
||||||
|
where
|
||||||
|
E: IntoIterator<Item = Event>,
|
||||||
|
L: Fn(EventId) -> Option<Event>,
|
||||||
|
{
|
||||||
|
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||||
|
for event in events {
|
||||||
|
if event.pubkey == me {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(root) = notification_root(&event, lookup) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
groups.entry(root).or_default().push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut items: Vec<InboxItem> = groups
|
||||||
|
.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()))
|
||||||
|
});
|
||||||
|
|
||||||
|
let root_event = lookup(root);
|
||||||
|
|
||||||
|
let mut item = InboxItem {
|
||||||
|
root,
|
||||||
|
root_kind: root_event.as_ref().map(|event| event.kind),
|
||||||
|
address: root_event
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|event| event.tags.coordinates().next()),
|
||||||
|
events,
|
||||||
|
unread_ids: Vec::new(),
|
||||||
|
archived: false,
|
||||||
|
};
|
||||||
|
item.apply_state(state);
|
||||||
|
item
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
items.sort_by(|a, b| {
|
||||||
|
b.latest_activity()
|
||||||
|
.cmp(&a.latest_activity())
|
||||||
|
.then_with(|| b.root.to_hex().cmp(&a.root.to_hex()))
|
||||||
|
});
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and archive state of the inbox, a high-water-mark model.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct InboxReadState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub read_before: Timestamp,
|
||||||
|
#[serde(default)]
|
||||||
|
pub read_ids: HashSet<EventId>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_before: Timestamp,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_ids: HashSet<EventId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InboxReadState {
|
||||||
|
/// Whether `event` is at or before the read cutoff, or marked read.
|
||||||
|
pub fn is_read(&self, event: &Event) -> bool {
|
||||||
|
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `event` is at or before the archived cutoff, or marked archived.
|
||||||
|
pub fn is_archived(&self, event: &Event) -> bool {
|
||||||
|
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark one event read. Events at or before the cutoff are already read.
|
||||||
|
pub fn mark_read(&mut self, event: &Event) {
|
||||||
|
if event.created_at > self.read_before {
|
||||||
|
self.read_ids.insert(event.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark one event archived. Events at or before the cutoff are already archived.
|
||||||
|
pub fn mark_archived(&mut self, event: &Event) {
|
||||||
|
if event.created_at > self.archived_before {
|
||||||
|
self.archived_ids.insert(event.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark every non-self event read, anchoring the cutoff ten days back.
|
||||||
|
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||||
|
let cutoff = now - MARK_ALL_WINDOW;
|
||||||
|
self.read_before = cutoff;
|
||||||
|
self.read_ids = all
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event.pubkey != me && event.created_at > cutoff)
|
||||||
|
.map(|event| event.id)
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance the read cutoff to the newest point that keeps unread events
|
||||||
|
/// unread, then prune the id set.
|
||||||
|
pub fn advance_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||||
|
let cutoff = advance_cutoff(all, me, now, self.read_before, |event| self.is_read(event));
|
||||||
|
self.read_before = cutoff;
|
||||||
|
prune_ids(&mut self.read_ids, all, cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance the archived cutoff, mirroring [`Self::advance_read`].
|
||||||
|
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||||
|
let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| {
|
||||||
|
self.is_archived(event)
|
||||||
|
});
|
||||||
|
self.archived_before = cutoff;
|
||||||
|
prune_ids(&mut self.archived_ids, all, cutoff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newest cutoff that keeps unread events unread, never earlier than `current`.
|
||||||
|
fn advance_cutoff<M>(
|
||||||
|
all: &[Event],
|
||||||
|
me: PublicKey,
|
||||||
|
now: Timestamp,
|
||||||
|
current: Timestamp,
|
||||||
|
is_marked: M,
|
||||||
|
) -> Timestamp
|
||||||
|
where
|
||||||
|
M: Fn(&Event) -> bool,
|
||||||
|
{
|
||||||
|
let fallback = now - ADVANCE_WINDOW;
|
||||||
|
|
||||||
|
let oldest = all
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event.pubkey != me && !is_marked(event))
|
||||||
|
.map(|event| event.created_at)
|
||||||
|
.min();
|
||||||
|
|
||||||
|
let candidate = match oldest {
|
||||||
|
Some(at) if at < fallback => at - 1,
|
||||||
|
_ => fallback,
|
||||||
|
};
|
||||||
|
|
||||||
|
candidate.max(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop ids whose event is unknown or now covered by the cutoff.
|
||||||
|
fn prune_ids(ids: &mut HashSet<EventId>, all: &[Event], cutoff: Timestamp) {
|
||||||
|
let created_at: HashMap<EventId, Timestamp> = all
|
||||||
|
.iter()
|
||||||
|
.map(|event| (event.id, event.created_at))
|
||||||
|
.collect();
|
||||||
|
ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follow NIP-10/NIP-22 parent pointers until a root item is reached.
|
||||||
|
fn resolve_thread_root(id: EventId, lookup: &impl Fn(EventId) -> Option<Event>) -> EventId {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut root = id;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if !seen.insert(root) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(event) = lookup(root) else {
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
|
||||||
|
if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
match parent_id(&event) {
|
||||||
|
Some(parent) => root = parent,
|
||||||
|
None => return root,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parent of a thread event, mirroring gitworkshop's `getParentId`.
|
||||||
|
fn parent_id(event: &Event) -> Option<EventId> {
|
||||||
|
for marker in ["reply", "root"] {
|
||||||
|
if let Some(id) = event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find_map(|tag| e_tag_with_marker(tag, marker))
|
||||||
|
{
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(id) = event.tags.iter().find_map(|tag| {
|
||||||
|
if tag.kind() != "e" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let slice = tag.as_slice();
|
||||||
|
let is_mention = slice.len() == 4 && slice[3] == "mention";
|
||||||
|
|
||||||
|
if is_mention {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
tag.content()
|
||||||
|
.and_then(|content| EventId::from_hex(content).ok())
|
||||||
|
}) {
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
first_uppercase_e_id(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NIP-10 root of an event: the `e` tag marked `root`, else the first `e` tag.
|
||||||
|
fn nip10_root_id(event: &Event) -> Option<EventId> {
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find_map(|tag| e_tag_with_marker(tag, "root"))
|
||||||
|
.or_else(|| first_e_id(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First `e` tag id, in document order.
|
||||||
|
fn first_e_id(event: &Event) -> Option<EventId> {
|
||||||
|
first_tag_id(event, "e")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First uppercase `E` tag id, in document order.
|
||||||
|
fn first_uppercase_e_id(event: &Event) -> Option<EventId> {
|
||||||
|
first_tag_id(event, "E")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_tag_id(event: &Event, name: &str) -> Option<EventId> {
|
||||||
|
event.tags.iter().find_map(|tag| {
|
||||||
|
if tag.kind() != name {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
tag.content()
|
||||||
|
.and_then(|content| EventId::from_hex(content).ok())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event id from a four-element `e` tag carrying `marker`.
|
||||||
|
fn e_tag_with_marker(tag: &Tag, marker: &str) -> Option<EventId> {
|
||||||
|
let slice = tag.as_slice();
|
||||||
|
if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
tag.content()
|
||||||
|
.and_then(|content| EventId::from_hex(content).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn keys(seed: u8) -> Keys {
|
||||||
|
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
||||||
|
hex.push_str(&format!("{seed:02x}"));
|
||||||
|
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||||
|
EventBuilder::new(kind, "")
|
||||||
|
.tags(tags)
|
||||||
|
.custom_created_at(Timestamp::from_secs(created_at))
|
||||||
|
.finalize(author)
|
||||||
|
.expect("signed event")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn e_tag(event: &Event) -> Tag {
|
||||||
|
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn marked_e_tag(event: &Event, marker: &str) -> Tag {
|
||||||
|
Tag::parse(["e", &event.id.to_hex(), "wss://relay.example.com", marker])
|
||||||
|
.expect("valid e tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uppercase_e_tag(event: &Event) -> Tag {
|
||||||
|
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn a_tag(owner: &PublicKey, id: &str) -> Tag {
|
||||||
|
Tag::parse(["a", &format!("30617:{}:{id}", owner.to_hex())]).expect("valid a tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
|
||||||
|
move |id| events.iter().find(|event| event.id == id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn issue(author: &Keys, at: u64) -> Event {
|
||||||
|
signed(author, Kind::GitIssue, Vec::new(), at)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn issue_and_pull_request_are_their_own_root() {
|
||||||
|
let events = [
|
||||||
|
issue(&keys(1), 100),
|
||||||
|
signed(&keys(1), Kind::GitPullRequest, Vec::new(), 100),
|
||||||
|
];
|
||||||
|
let lookup = lookup(&events);
|
||||||
|
for event in &events {
|
||||||
|
assert_eq!(notification_root(event, &lookup), Some(event.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comment_resolves_to_its_uppercase_root() {
|
||||||
|
let issue = issue(&keys(1), 100);
|
||||||
|
let comment = signed(
|
||||||
|
&keys(2),
|
||||||
|
Kind::Comment,
|
||||||
|
vec![
|
||||||
|
uppercase_e_tag(&issue),
|
||||||
|
Tag::parse(["K", "1621"]).expect("valid K tag"),
|
||||||
|
],
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
let events = [issue.clone(), comment.clone()];
|
||||||
|
assert_eq!(
|
||||||
|
notification_root(&comment, &lookup(&events)),
|
||||||
|
Some(issue.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comment_without_root_pointer_has_no_root() {
|
||||||
|
let comment = signed(
|
||||||
|
&keys(2),
|
||||||
|
Kind::Comment,
|
||||||
|
vec![e_tag(&issue(&keys(1), 100))],
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assert_eq!(notification_root(&comment, &lookup(&[])), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn child_patch_resolves_to_the_root_patch() {
|
||||||
|
let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100);
|
||||||
|
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
|
||||||
|
let events = [root_patch.clone(), child_patch.clone()];
|
||||||
|
assert_eq!(
|
||||||
|
notification_root(&child_patch, &lookup(&events)),
|
||||||
|
Some(root_patch.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_resolves_via_the_root_marker() {
|
||||||
|
let issue = issue(&keys(1), 100);
|
||||||
|
let status = signed(
|
||||||
|
&keys(2),
|
||||||
|
Kind::GitStatusClosed,
|
||||||
|
vec![marked_e_tag(&issue, "root")],
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
let events = [issue.clone(), status.clone()];
|
||||||
|
assert_eq!(notification_root(&status, &lookup(&events)), Some(issue.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pull_request_update_resolves_via_uppercase_e() {
|
||||||
|
let pr = signed(&keys(1), Kind::GitPullRequest, Vec::new(), 100);
|
||||||
|
let update = signed(
|
||||||
|
&keys(2),
|
||||||
|
Kind::GitPullRequestUpdate,
|
||||||
|
vec![uppercase_e_tag(&pr)],
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
let events = [pr.clone(), update.clone()];
|
||||||
|
assert_eq!(notification_root(&update, &lookup(&events)), Some(pr.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_comment_chain_follows_to_the_root() {
|
||||||
|
let issue = issue(&keys(1), 100);
|
||||||
|
let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||||
|
let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300);
|
||||||
|
let events = [issue.clone(), reply, nested.clone()];
|
||||||
|
assert_eq!(notification_root(&nested, &lookup(&events)), Some(issue.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_excludes_self_and_sorts_groups_newest_first() {
|
||||||
|
let me = keys(1);
|
||||||
|
let issue = issue(&keys(2), 100);
|
||||||
|
let comment = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 300);
|
||||||
|
let other_issue = signed(
|
||||||
|
&keys(2),
|
||||||
|
Kind::GitIssue,
|
||||||
|
vec![Tag::parse(["p", &me.public_key().to_hex()]).expect("valid p tag")],
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
let mine = signed(&keys(1), Kind::Comment, vec![uppercase_e_tag(&issue)], 400);
|
||||||
|
|
||||||
|
let events = [issue.clone(), comment.clone(), other_issue.clone(), mine];
|
||||||
|
let items = group(
|
||||||
|
events,
|
||||||
|
me.public_key(),
|
||||||
|
&InboxReadState::default(),
|
||||||
|
&lookup(&[]),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(items.len(), 2);
|
||||||
|
assert_eq!(items[0].root, issue.id);
|
||||||
|
// The issue itself plus the comment; the self-authored comment is out.
|
||||||
|
assert_eq!(items[0].events.len(), 2);
|
||||||
|
assert_eq!(items[1].root, other_issue.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_reports_unread_oldest_first_and_archived() {
|
||||||
|
let me = keys(1);
|
||||||
|
let issue = issue(&keys(2), 100);
|
||||||
|
let older = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||||
|
let newer = signed(&keys(4), Kind::Comment, vec![uppercase_e_tag(&issue)], 300);
|
||||||
|
|
||||||
|
let events = [issue.clone(), older.clone(), newer.clone()];
|
||||||
|
let items = group(
|
||||||
|
events,
|
||||||
|
me.public_key(),
|
||||||
|
&InboxReadState::default(),
|
||||||
|
&lookup(&[]),
|
||||||
|
);
|
||||||
|
assert_eq!(items[0].unread_ids, vec![issue.id, older.id, newer.id]);
|
||||||
|
assert!(!items[0].archived);
|
||||||
|
assert!(items[0].is_unread());
|
||||||
|
|
||||||
|
let state = InboxReadState {
|
||||||
|
archived_before: Timestamp::from_secs(1000),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let items = group(
|
||||||
|
[issue.clone(), older, newer],
|
||||||
|
me.public_key(),
|
||||||
|
&state,
|
||||||
|
&lookup(&[]),
|
||||||
|
);
|
||||||
|
assert!(items[0].archived);
|
||||||
|
assert!(!items[0].unread_ids.is_empty());
|
||||||
|
assert!(!items[0].is_unread());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_reads_root_kind_and_address_from_the_root_event() {
|
||||||
|
let me = keys(1);
|
||||||
|
let owner_keys = keys(2);
|
||||||
|
let owner = owner_keys.public_key();
|
||||||
|
let issue = signed(
|
||||||
|
&owner_keys,
|
||||||
|
Kind::GitIssue,
|
||||||
|
vec![a_tag(&owner, "my-repo")],
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
let comment = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||||
|
|
||||||
|
let events = [issue.clone(), comment];
|
||||||
|
let items = group(
|
||||||
|
events.clone(),
|
||||||
|
me.public_key(),
|
||||||
|
&InboxReadState::default(),
|
||||||
|
&lookup(&events),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(items[0].root_kind, Some(Kind::GitIssue));
|
||||||
|
assert_eq!(items[0].address, issue.tags.coordinates().next());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mark_all_read_marks_known_recent_events() {
|
||||||
|
let me = keys(1);
|
||||||
|
let now = Timestamp::from_secs(1_000_000_000);
|
||||||
|
let recent = issue(&keys(2), now.as_secs() - 1000);
|
||||||
|
let old = issue(&keys(2), now.as_secs() - 5 * 24 * 60 * 60);
|
||||||
|
let ancient = issue(&keys(2), now.as_secs() - 20 * 24 * 60 * 60);
|
||||||
|
let mine = issue(&keys(1), now.as_secs() - 100);
|
||||||
|
|
||||||
|
let mut state = InboxReadState::default();
|
||||||
|
state.mark_all_read(
|
||||||
|
&[recent.clone(), old.clone(), ancient.clone(), mine.clone()],
|
||||||
|
me.public_key(),
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(state.read_before, now - MARK_ALL_WINDOW);
|
||||||
|
assert_eq!(state.read_ids, HashSet::from([recent.id, old.id]));
|
||||||
|
assert!(state.is_read(&recent));
|
||||||
|
assert!(state.is_read(&ancient));
|
||||||
|
assert!(!state.is_read(&mine));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advance_read_never_moves_the_cutoff_backwards() {
|
||||||
|
let me = keys(1);
|
||||||
|
let unread = issue(&keys(2), 1_000);
|
||||||
|
let all = [unread];
|
||||||
|
let now = Timestamp::from_secs(1_000_000_000);
|
||||||
|
|
||||||
|
let mut state = InboxReadState {
|
||||||
|
read_before: Timestamp::from_secs(999_999_999),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.advance_read(&all, me.public_key(), now);
|
||||||
|
|
||||||
|
assert_eq!(state.read_before, Timestamp::from_secs(999_999_999));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advance_read_moves_before_the_oldest_unread_and_prunes_ids() {
|
||||||
|
let me = keys(1);
|
||||||
|
let now = Timestamp::from_secs(1_000_000_000);
|
||||||
|
let five_days = 5 * 24 * 60 * 60;
|
||||||
|
let old_unread = issue(&keys(2), now.as_secs() - five_days);
|
||||||
|
// Read ids that fall before and after the new cutoff.
|
||||||
|
let stale = signed(
|
||||||
|
&keys(2),
|
||||||
|
Kind::GitIssue,
|
||||||
|
Vec::new(),
|
||||||
|
now.as_secs() - five_days - 1000,
|
||||||
|
);
|
||||||
|
let fresh = signed(
|
||||||
|
&keys(2),
|
||||||
|
Kind::GitIssue,
|
||||||
|
Vec::new(),
|
||||||
|
now.as_secs() - 100_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut state = InboxReadState {
|
||||||
|
read_ids: HashSet::from([stale.id, fresh.id]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.advance_read(
|
||||||
|
&[old_unread.clone(), stale.clone(), fresh.clone()],
|
||||||
|
me.public_key(),
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(state.read_before, old_unread.created_at - 1);
|
||||||
|
assert_eq!(state.read_ids, HashSet::from([fresh.id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mark_archived_skips_events_at_or_before_the_cutoff() {
|
||||||
|
let now = Timestamp::from_secs(1_000_000_000);
|
||||||
|
let event = issue(&keys(2), now.as_secs() - 1000);
|
||||||
|
|
||||||
|
let mut state = InboxReadState {
|
||||||
|
archived_before: now,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.mark_archived(&event);
|
||||||
|
assert!(state.archived_ids.is_empty());
|
||||||
|
|
||||||
|
let mut state = InboxReadState::default();
|
||||||
|
state.mark_archived(&event);
|
||||||
|
assert_eq!(state.archived_ids, HashSet::from([event.id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_state_recomputes_unread_and_archived() {
|
||||||
|
let now = Timestamp::from_secs(1_000_000_000);
|
||||||
|
let first = issue(&keys(2), now.as_secs() - 2000);
|
||||||
|
let second = issue(&keys(2), now.as_secs() - 1000);
|
||||||
|
let mut item = InboxItem {
|
||||||
|
root: first.id,
|
||||||
|
root_kind: None,
|
||||||
|
address: None,
|
||||||
|
events: vec![second.clone(), first.clone()],
|
||||||
|
unread_ids: Vec::new(),
|
||||||
|
archived: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = InboxReadState {
|
||||||
|
read_before: first.created_at,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
item.apply_state(&state);
|
||||||
|
|
||||||
|
assert_eq!(item.unread_ids, vec![second.id]);
|
||||||
|
assert!(!item.archived);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_round_trip_preserves_state() {
|
||||||
|
let first = issue(&keys(1), 100);
|
||||||
|
let second = issue(&keys(2), 200);
|
||||||
|
let state = InboxReadState {
|
||||||
|
read_before: Timestamp::from_secs(150),
|
||||||
|
read_ids: HashSet::from([second.id]),
|
||||||
|
archived_before: Timestamp::from_secs(50),
|
||||||
|
archived_ids: HashSet::from([first.id]),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&state).expect("serialized");
|
||||||
|
let parsed: InboxReadState = serde_json::from_str(&json).expect("deserialized");
|
||||||
|
|
||||||
|
assert_eq!(parsed, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ pub mod annotations;
|
|||||||
pub mod clone_url;
|
pub mod clone_url;
|
||||||
pub mod deletions;
|
pub mod deletions;
|
||||||
pub mod filters;
|
pub mod filters;
|
||||||
|
pub mod inbox;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
@@ -11,6 +12,10 @@ pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
|||||||
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
|
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
|
||||||
pub use clone_url::{CloneTarget, parse_clone_url};
|
pub use clone_url::{CloneTarget, parse_clone_url};
|
||||||
pub use deletions::Deletions;
|
pub use deletions::Deletions;
|
||||||
|
pub use filters::{
|
||||||
|
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
|
||||||
|
};
|
||||||
|
pub use inbox::{InboxItem, InboxReadState, group, notification_root};
|
||||||
pub use model::{
|
pub use model::{
|
||||||
Announcement, activity_subject, branch_name_of, clone_urls_of, current_commit_of,
|
Announcement, activity_subject, branch_name_of, clone_urls_of, current_commit_of,
|
||||||
fork_candidates, latest_update, merge_base_of, pull_request_patch, pull_request_patches,
|
fork_candidates, latest_update, merge_base_of, pull_request_patch, pull_request_patches,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ flume.workspace = true
|
|||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
rustls = "0.23"
|
rustls = "0.23"
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_
|
|||||||
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
|
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
|
||||||
|
|
||||||
use crate::git_store::GitStore;
|
use crate::git_store::GitStore;
|
||||||
|
use crate::inbox::Inbox;
|
||||||
|
use crate::repos::RepoListStore;
|
||||||
|
|
||||||
/// Keyring entry for the user credential.
|
/// Keyring entry for the user credential.
|
||||||
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
||||||
@@ -32,10 +34,6 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
|||||||
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
||||||
|
|
||||||
/// Delay the notification pump waits for more events before emitting a batch.
|
/// Delay the notification pump waits for more events before emitting a batch.
|
||||||
///
|
|
||||||
/// A negentropy sync can deliver hundreds of events in a burst; batching
|
|
||||||
/// them here means every subscriber debounces the burst once, not once per
|
|
||||||
/// subscriber.
|
|
||||||
const PUMP_DEBOUNCE: Duration = Duration::from_millis(200);
|
const PUMP_DEBOUNCE: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -77,21 +75,17 @@ impl BackendEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The global backend entity.
|
|
||||||
///
|
|
||||||
/// Owns the nostr client, the signer and the notification pump.
|
|
||||||
pub struct Backend {
|
pub struct Backend {
|
||||||
client: Client,
|
client: Client,
|
||||||
signer: UniversalSigner,
|
signer: UniversalSigner,
|
||||||
current_user: Option<PublicKey>,
|
current_user: Option<PublicKey>,
|
||||||
|
/// User's inbox, including notifications and recent activity.
|
||||||
|
inbox: Entity<Inbox>,
|
||||||
|
/// The progress of the current sync operation, if any.
|
||||||
sync_progress: Option<(u64, u64)>,
|
sync_progress: Option<(u64, u64)>,
|
||||||
/// True when the stored credential is NIP-49 encrypted.
|
/// True when the stored credential is NIP-49 encrypted.
|
||||||
passphrase_required: bool,
|
passphrase_required: bool,
|
||||||
/// Repositories with a push in flight, mirror or checkout based.
|
/// Repositories with a push in flight, mirror or checkout based.
|
||||||
///
|
|
||||||
/// A child entity: views that only care whether one repository is
|
|
||||||
/// pushing can `cx.observe` it without being invoked on unrelated
|
|
||||||
/// `Backend` changes (a `sync_progress` tick, a new relay connecting).
|
|
||||||
pushing_repos: Entity<HashSet<RepoAddr>>,
|
pushing_repos: Entity<HashSet<RepoAddr>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +106,7 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new(client: Client, signer: UniversalSigner, cx: &mut Context<Self>) -> Self {
|
pub(crate) fn new(client: Client, signer: UniversalSigner, cx: &mut Context<Self>) -> Self {
|
||||||
|
let weak = cx.entity().downgrade();
|
||||||
let pump_client = client.clone();
|
let pump_client = client.clone();
|
||||||
|
|
||||||
let pump: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
let pump: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
@@ -133,13 +128,17 @@ impl Backend {
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
|
||||||
if now >= deadline {
|
if now >= deadline {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let timer = cx.background_executor().timer(deadline - now);
|
let timer = cx.background_executor().timer(deadline - now);
|
||||||
futures::pin_mut!(timer);
|
futures::pin_mut!(timer);
|
||||||
|
|
||||||
let next = notifications.next();
|
let next = notifications.next();
|
||||||
futures::pin_mut!(next);
|
futures::pin_mut!(next);
|
||||||
|
|
||||||
match futures::future::select(next, timer).await {
|
match futures::future::select(next, timer).await {
|
||||||
futures::future::Either::Left((
|
futures::future::Either::Left((
|
||||||
Some(ClientNotification::Event { event, .. }),
|
Some(ClientNotification::Event { event, .. }),
|
||||||
@@ -156,7 +155,9 @@ impl Backend {
|
|||||||
// Collect and emit the collected events.
|
// Collect and emit the collected events.
|
||||||
let batch = std::mem::take(&mut pending);
|
let batch = std::mem::take(&mut pending);
|
||||||
|
|
||||||
if let Err(e) = this.update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(batch))) {
|
if let Err(e) =
|
||||||
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::NostrUpdate(batch)))
|
||||||
|
{
|
||||||
log::warn!("failed to emit nostr update: {e}");
|
log::warn!("failed to emit nostr update: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,7 +168,6 @@ impl Backend {
|
|||||||
pump.detach();
|
pump.detach();
|
||||||
|
|
||||||
// Bootstrap the client.
|
// Bootstrap the client.
|
||||||
let weak = cx.entity().downgrade();
|
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
if let Err(error) = weak.update(cx, |this, cx| this.bootstrap(cx)) {
|
if let Err(error) = weak.update(cx, |this, cx| this.bootstrap(cx)) {
|
||||||
log::warn!("backend dropped before bootstrap could run: {error}");
|
log::warn!("backend dropped before bootstrap could run: {error}");
|
||||||
@@ -178,53 +178,54 @@ impl Backend {
|
|||||||
client,
|
client,
|
||||||
signer,
|
signer,
|
||||||
current_user: None,
|
current_user: None,
|
||||||
|
inbox: cx.new(|_| Inbox::default()),
|
||||||
sync_progress: None,
|
sync_progress: None,
|
||||||
passphrase_required: false,
|
passphrase_required: false,
|
||||||
pushing_repos: cx.new(|_| HashSet::new()),
|
pushing_repos: cx.new(|_| HashSet::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bootstrap the client.
|
/// Bootstrap the client and restore the saved session, if any.
|
||||||
///
|
|
||||||
/// Restore the saved session, if any.
|
|
||||||
fn bootstrap(&mut self, cx: &mut Context<Self>) {
|
fn bootstrap(&mut self, cx: &mut Context<Self>) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
|
|
||||||
let task = cx.background_spawn(async move {
|
let task = cx.background_spawn(async move {
|
||||||
for url in BOOTSTRAP_RELAYS {
|
for url in BOOTSTRAP_RELAYS {
|
||||||
client.add_relay(url).and_connect().await?;
|
client.add_relay(url).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
for url in INDEXER_RELAYS {
|
for url in INDEXER_RELAYS {
|
||||||
client
|
client
|
||||||
.add_relay(url)
|
.add_relay(url)
|
||||||
.capabilities(RelayCapabilities::DISCOVERY)
|
.capabilities(RelayCapabilities::DISCOVERY)
|
||||||
.and_connect()
|
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client.connect().await;
|
||||||
|
|
||||||
Ok::<(), Error>(())
|
Ok::<(), Error>(())
|
||||||
});
|
});
|
||||||
|
|
||||||
let notify_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
let notify_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
match task.await {
|
match task.await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
this.update(cx, |_this, cx| cx.notify())?;
|
this.update(cx, |this, cx| {
|
||||||
|
this.restore_session(cx);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok::<(), Error>(())
|
||||||
});
|
});
|
||||||
notify_task.detach();
|
notify_task.detach();
|
||||||
|
|
||||||
self.restore_session(cx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore the saved session from the keyring.
|
/// Restore the saved session from the Keyring.
|
||||||
///
|
///
|
||||||
/// Emits [`BackendEvent::SignerRequired`] when no credential is stored.
|
/// - Emits [`BackendEvent::SignerRequired`] when no credential is stored.
|
||||||
///
|
/// - Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity.
|
||||||
/// Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity.
|
|
||||||
pub fn restore_session(&mut self, cx: &mut Context<Self>) {
|
pub fn restore_session(&mut self, cx: &mut Context<Self>) {
|
||||||
if cfg!(target_arch = "wasm32") {
|
if cfg!(target_arch = "wasm32") {
|
||||||
cx.emit(BackendEvent::SignerRequired);
|
cx.emit(BackendEvent::SignerRequired);
|
||||||
@@ -237,7 +238,7 @@ impl Backend {
|
|||||||
let content = match user.await {
|
let content = match user.await {
|
||||||
Ok(Some((_username, secret))) => String::from_utf8(secret)?,
|
Ok(Some((_username, secret))) => String::from_utf8(secret)?,
|
||||||
_ => {
|
_ => {
|
||||||
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?;
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::SignerRequired))?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -258,15 +259,13 @@ impl Backend {
|
|||||||
signer.auth_url_handler(SignedAuthUrlHandler);
|
signer.auth_url_handler(SignedAuthUrlHandler);
|
||||||
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
|
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
|
||||||
} else if content.starts_with("ncryptsec1") {
|
} else if content.starts_with("ncryptsec1") {
|
||||||
// Encrypted identity.
|
|
||||||
// A passphrase is required to decrypt it before the session can resume.
|
// A passphrase is required to decrypt it before the session can resume.
|
||||||
log::warn!("stored identity is ncryptsec-encrypted; waiting for passphrase");
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.passphrase_required = true;
|
this.passphrase_required = true;
|
||||||
cx.emit(BackendEvent::PassphraseRequired);
|
cx.emit(BackendEvent::PassphraseRequired);
|
||||||
})?;
|
})?;
|
||||||
} else {
|
} else {
|
||||||
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?;
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::SignerRequired))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok::<_, Error>(())
|
Ok::<_, Error>(())
|
||||||
@@ -274,7 +273,7 @@ impl Backend {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
this.update(cx, |_, cx| {
|
this.update(cx, |_this, cx| {
|
||||||
cx.emit(BackendEvent::error(e.to_string()));
|
cx.emit(BackendEvent::error(e.to_string()));
|
||||||
cx.emit(BackendEvent::SignerRequired);
|
cx.emit(BackendEvent::SignerRequired);
|
||||||
})?;
|
})?;
|
||||||
@@ -360,7 +359,10 @@ impl Backend {
|
|||||||
this.signer.swap_inner(keys);
|
this.signer.swap_inner(keys);
|
||||||
this.current_user = Some(public_key);
|
this.current_user = Some(public_key);
|
||||||
this.bootstrap_user(public_key, cx);
|
this.bootstrap_user(public_key, cx);
|
||||||
|
|
||||||
cx.emit(BackendEvent::SignerChanged);
|
cx.emit(BackendEvent::SignerChanged);
|
||||||
|
this.sync_inbox(cx);
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let relays: Vec<(RelayUrl, Option<RelayMetadata>)> = [
|
let relays: Vec<(RelayUrl, Option<RelayMetadata>)> = [
|
||||||
@@ -968,9 +970,7 @@ impl Backend {
|
|||||||
} else if credential.starts_with("bunker://") {
|
} else if credential.starts_with("bunker://") {
|
||||||
self.login_with_bunker(credential, cx);
|
self.login_with_bunker(credential, cx);
|
||||||
} else {
|
} else {
|
||||||
cx.emit(BackendEvent::error(
|
cx.emit(BackendEvent::error("Unsupported credential."));
|
||||||
"Unsupported credential, expected nsec1... or bunker://...",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -999,7 +999,7 @@ impl Backend {
|
|||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
if let Err(e) = write.await {
|
if let Err(e) = write.await {
|
||||||
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
||||||
@@ -1045,7 +1045,7 @@ impl Backend {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1066,6 +1066,7 @@ impl Backend {
|
|||||||
this.passphrase_required = false;
|
this.passphrase_required = false;
|
||||||
cx.emit(BackendEvent::SignerChanged);
|
cx.emit(BackendEvent::SignerChanged);
|
||||||
cx.emit(BackendEvent::SignerRequired);
|
cx.emit(BackendEvent::SignerRequired);
|
||||||
|
this.sync_inbox(cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -1096,7 +1097,7 @@ impl Backend {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1121,6 +1122,13 @@ impl Backend {
|
|||||||
self.pushing_repos.clone()
|
self.pushing_repos.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The inbox child entity backing the home screen.
|
||||||
|
///
|
||||||
|
/// A child entity: `cx.observe` it to react only to inbox changes.
|
||||||
|
pub fn inbox(&self) -> Entity<Inbox> {
|
||||||
|
self.inbox.clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the current user's public key.
|
/// Get the current user's public key.
|
||||||
pub fn current_user(&self) -> Option<PublicKey> {
|
pub fn current_user(&self) -> Option<PublicKey> {
|
||||||
self.current_user
|
self.current_user
|
||||||
@@ -1136,6 +1144,35 @@ impl Backend {
|
|||||||
cx.emit(BackendEvent::error(message));
|
cx.emit(BackendEvent::error(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach the inbox to the current signer and activate or clear it.
|
||||||
|
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let client = self.client.clone();
|
||||||
|
let me = self.current_user;
|
||||||
|
|
||||||
|
if let Some(me) = me {
|
||||||
|
self.subscribe_bootstrap(filters::notifications(me), cx);
|
||||||
|
self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
|
||||||
|
|
||||||
|
let relays: HashSet<RelayUrl> = RepoListStore::global(cx)
|
||||||
|
.read(cx)
|
||||||
|
.announcements_of(&me)
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|announcement| announcement.relays)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !relays.is_empty() {
|
||||||
|
let relays: Vec<RelayUrl> = relays.into_iter().collect();
|
||||||
|
self.connect_repo_relays(relays.clone(), filters::notifications(me), cx);
|
||||||
|
self.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.inbox.update(cx, |inbox, cx| match me {
|
||||||
|
Some(me) => inbox.activate(me, client, cx),
|
||||||
|
None => inbox.reset(cx),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Progress of the in-flight negentropy sync, if any.
|
/// Progress of the in-flight negentropy sync, if any.
|
||||||
pub fn sync_progress(&self) -> Option<(u64, u64)> {
|
pub fn sync_progress(&self) -> Option<(u64, u64)> {
|
||||||
self.sync_progress
|
self.sync_progress
|
||||||
@@ -1149,7 +1186,7 @@ impl Backend {
|
|||||||
<T as AsyncSignEvent>::Error: std::error::Error + Send + Sync + 'static,
|
<T as AsyncSignEvent>::Error: std::error::Error + Send + Sync + 'static,
|
||||||
<T as AsyncNip44>::Error: std::error::Error + Send + Sync + 'static,
|
<T as AsyncNip44>::Error: std::error::Error + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
match new_signer.get_public_key_async().await {
|
match new_signer.get_public_key_async().await {
|
||||||
Ok(public_key) => {
|
Ok(public_key) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -1158,6 +1195,7 @@ impl Backend {
|
|||||||
this.passphrase_required = false;
|
this.passphrase_required = false;
|
||||||
this.bootstrap_user(public_key, cx);
|
this.bootstrap_user(public_key, cx);
|
||||||
cx.emit(BackendEvent::SignerChanged);
|
cx.emit(BackendEvent::SignerChanged);
|
||||||
|
this.sync_inbox(cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
@@ -1168,15 +1206,12 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok::<(), Error>(())
|
||||||
});
|
})
|
||||||
task.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to a repository's announced relays, its NIP-34 `relays` tag.
|
/// Connect to a repository's announced relays, its NIP-34 `relays` tag.
|
||||||
///
|
|
||||||
/// Callers are responsible for not repeating this for relays they already
|
|
||||||
/// connected, e.g. `RepoStore::repo_relays`.
|
|
||||||
pub fn connect_repo_relays(
|
pub fn connect_repo_relays(
|
||||||
&mut self,
|
&mut self,
|
||||||
relays: Vec<RelayUrl>,
|
relays: Vec<RelayUrl>,
|
||||||
@@ -1185,13 +1220,13 @@ impl Backend {
|
|||||||
) {
|
) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |_this, _cx| {
|
cx.spawn(async move |_this, _cx| {
|
||||||
if let Err(e) = connect_repo_relays(&client, relays, filters).await {
|
if let Err(e) = connect_repo_relays(&client, relays, filters).await {
|
||||||
log::warn!("repo relay fetch failed: {e}");
|
log::warn!("repo relay fetch failed: {e}");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok::<(), Error>(())
|
||||||
});
|
})
|
||||||
task.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-shot subscription on the bootstrap relays only.
|
/// One-shot subscription on the bootstrap relays only.
|
||||||
@@ -1201,24 +1236,25 @@ impl Backend {
|
|||||||
let fetch =
|
let fetch =
|
||||||
cx.background_spawn(async move { subscribe_bootstrap_only(&client, filters).await });
|
cx.background_spawn(async move { subscribe_bootstrap_only(&client, filters).await });
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
if let Err(e) = fetch.await {
|
if let Err(e) = fetch.await {
|
||||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
this.update(cx, |_this, cx| {
|
||||||
|
cx.emit(BackendEvent::error(e.to_string()));
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok::<(), Error>(())
|
||||||
});
|
})
|
||||||
task.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Negentropy-sync the given filter against the bootstrap relays.
|
/// Negentropy-sync the given filter against the bootstrap relays.
|
||||||
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
|
let (tx, mut rx) = SyncProgress::channel();
|
||||||
|
|
||||||
self.sync_progress = Some((0, 0));
|
self.sync_progress = Some((0, 0));
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let (tx, mut rx) = SyncProgress::channel();
|
|
||||||
|
|
||||||
let progress_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
let progress_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
let mut last_percent: u64 = 0;
|
let mut last_percent: u64 = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use anyhow::Error;
|
||||||
|
use gpui::{AppContext, Context, Task};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use signed_core::{Deletions, InboxItem, InboxReadState, filters, inbox};
|
||||||
|
|
||||||
|
use crate::backend::Backend;
|
||||||
|
|
||||||
|
/// The user's persisted inbox read state.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Inbox {
|
||||||
|
state: InboxReadState,
|
||||||
|
/// Set once the stored state has been read for the current user.
|
||||||
|
state_loaded: bool,
|
||||||
|
/// Unread notification groups, published by the inbox panel for the sidebar badge.
|
||||||
|
pub unread_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Inbox {
|
||||||
|
/// The current read/archive cutoffs.
|
||||||
|
pub fn state(&self) -> &InboxReadState {
|
||||||
|
&self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the stored state has been read for the current user.
|
||||||
|
pub fn is_loaded(&self) -> bool {
|
||||||
|
self.state_loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish the unread count derived by the inbox panel.
|
||||||
|
pub fn set_unread_count(&mut self, count: usize, cx: &mut Context<Self>) {
|
||||||
|
if self.unread_count == count {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.unread_count = count;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the events of one notification group read, then bound the id sets.
|
||||||
|
pub fn mark_read(
|
||||||
|
&mut self,
|
||||||
|
group: &[Event],
|
||||||
|
all: &[Event],
|
||||||
|
me: PublicKey,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
for event in group {
|
||||||
|
self.state.mark_read(event);
|
||||||
|
}
|
||||||
|
self.state.advance_read(all, me, Timestamp::now());
|
||||||
|
self.persist(cx);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Archive one notification group. Archived events are always read too.
|
||||||
|
pub fn mark_archived(
|
||||||
|
&mut self,
|
||||||
|
group: &[Event],
|
||||||
|
all: &[Event],
|
||||||
|
me: PublicKey,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
for event in group {
|
||||||
|
self.state.mark_archived(event);
|
||||||
|
self.state.mark_read(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Timestamp::now();
|
||||||
|
self.state.advance_archived(all, me, now);
|
||||||
|
self.state.advance_read(all, me, now);
|
||||||
|
self.persist(cx);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark every known notification read.
|
||||||
|
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context<Self>) {
|
||||||
|
self.state.mark_all_read(all, me, Timestamp::now());
|
||||||
|
self.persist(cx);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the stored state for current user.
|
||||||
|
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) {
|
||||||
|
self.state = InboxReadState::default();
|
||||||
|
self.state_loaded = false;
|
||||||
|
self.unread_count = 0;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let work = cx.background_spawn(async move { load_state(&client, me).await });
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let loaded = work.await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
if backend.read(cx).current_user() != Some(me) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match loaded {
|
||||||
|
Ok(Some(state)) => this.state = state,
|
||||||
|
Ok(None) => this.state = InboxReadState::default(),
|
||||||
|
Err(error) => log::warn!("failed to load inbox state: {error}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state_loaded = true;
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok::<(), Error>(())
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the state of the signed-out user.
|
||||||
|
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.state = InboxReadState::default();
|
||||||
|
self.state_loaded = false;
|
||||||
|
self.unread_count = 0;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign the state with a random key and store it locally.
|
||||||
|
fn persist(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = Backend::global(cx).read(cx).client();
|
||||||
|
let state = self.state.clone();
|
||||||
|
|
||||||
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
|
if let Err(error) = save_state(&client, me, &state).await {
|
||||||
|
log::warn!("failed to save inbox state: {error}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
task.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub async fn query_inbox(
|
||||||
|
client: &Client,
|
||||||
|
me: PublicKey,
|
||||||
|
state: &InboxReadState,
|
||||||
|
) -> Result<(Vec<InboxItem>, Vec<Event>, usize), Error> {
|
||||||
|
let deletion_events = client.database().query(filters::deletions()).await?;
|
||||||
|
let deletions = Deletions::from_events(deletion_events);
|
||||||
|
|
||||||
|
let (notification_events, by_id) = fetch_notifications(client, me, &deletions).await?;
|
||||||
|
let notifications = inbox::group(notification_events, me, state, &|id| {
|
||||||
|
by_id.get(&id).cloned()
|
||||||
|
});
|
||||||
|
let unread_count = notifications.iter().filter(|item| item.is_unread()).count();
|
||||||
|
|
||||||
|
let mut activity = Vec::new();
|
||||||
|
for event in client
|
||||||
|
.database()
|
||||||
|
.query(filters::authored_activity(me))
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
if deletions.is_deleted(&event) || !filters::is_git_activity(&event) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
activity.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
activity.sort_by(|a, b| {
|
||||||
|
b.created_at
|
||||||
|
.cmp(&a.created_at)
|
||||||
|
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((notifications, activity, unread_count))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `d` tag identifying the inbox state event of `me`.
|
||||||
|
fn inbox_state_d_tag(me: PublicKey) -> String {
|
||||||
|
format!("signed-inbox-state:{}", me.to_hex())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newest stored state for `me`.
|
||||||
|
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.identifier(inbox_state_d_tag(me));
|
||||||
|
|
||||||
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
|
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
match serde_json::from_str(&event.content) {
|
||||||
|
Ok(state) => Ok(Some(state)),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign with a random key and store locally.
|
||||||
|
async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> {
|
||||||
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||||
|
.tags([Tag::identifier(inbox_state_d_tag(me))])
|
||||||
|
.finalize(&Keys::generate())?;
|
||||||
|
|
||||||
|
client.database().save_event(&event).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notification events and a lookup of every ancestor they reference.
|
||||||
|
async fn fetch_notifications(
|
||||||
|
client: &Client,
|
||||||
|
me: PublicKey,
|
||||||
|
deletions: &Deletions,
|
||||||
|
) -> Result<(Vec<Event>, HashMap<EventId, Event>), Error> {
|
||||||
|
let mut notifications: Vec<Event> = Vec::new();
|
||||||
|
let mut by_id: HashMap<EventId, Event> = HashMap::new();
|
||||||
|
|
||||||
|
for filter in filters::notifications(me) {
|
||||||
|
for event in client.database().query(filter).await? {
|
||||||
|
if deletions.is_deleted(&event) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if by_id.insert(event.id, event.clone()).is_none() {
|
||||||
|
notifications.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pending: Vec<EventId> = notifications.iter().flat_map(event_references).collect();
|
||||||
|
let mut seen: HashSet<EventId> = by_id.keys().copied().collect();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Keep only ids not walked yet, and remember them.
|
||||||
|
pending.retain(|id| seen.insert(*id));
|
||||||
|
|
||||||
|
if pending.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ancestors = client
|
||||||
|
.database()
|
||||||
|
.query(Filter::new().ids(pending.iter().copied()))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut next = Vec::new();
|
||||||
|
|
||||||
|
for event in ancestors {
|
||||||
|
if deletions.is_deleted(&event) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next.extend(event_references(&event).filter(|id| !seen.contains(id)));
|
||||||
|
by_id.entry(event.id).or_insert(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
pending = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((notifications, by_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event ids referenced by `event` through its `e` and `E` tags.
|
||||||
|
fn event_references(event: &Event) -> impl Iterator<Item = EventId> + '_ {
|
||||||
|
event.tags.iter().filter_map(|tag| {
|
||||||
|
if tag.kind() != "e" && tag.kind() != "E" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
tag.content()
|
||||||
|
.and_then(|content| EventId::from_hex(content).ok())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
mod backend;
|
mod backend;
|
||||||
mod checkouts;
|
mod checkouts;
|
||||||
mod git_store;
|
mod git_store;
|
||||||
|
mod inbox;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod refresh;
|
mod refresh;
|
||||||
mod repo;
|
mod repo;
|
||||||
@@ -11,22 +12,23 @@ use std::path::{Path, PathBuf};
|
|||||||
pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
|
pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
|
||||||
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
||||||
pub use git_store::GitStore;
|
pub use git_store::GitStore;
|
||||||
use gpui::{App, AppContext, Entity};
|
use gpui::{App, AppContext};
|
||||||
|
pub use inbox::{Inbox, query_inbox};
|
||||||
pub use nostr_sdk::prelude::Timestamp;
|
pub use nostr_sdk::prelude::Timestamp;
|
||||||
pub use profile::{Profile, ProfileStore};
|
pub use profile::{Profile, ProfileStore};
|
||||||
|
pub use refresh::{RefreshGate, RefreshRequest};
|
||||||
pub use repo::RepoStore;
|
pub use repo::RepoStore;
|
||||||
pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore};
|
pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore};
|
||||||
use signed_nostr::new_backend;
|
use signed_nostr::new_backend;
|
||||||
|
|
||||||
/// Initialize the backend and stores, and install them as globals.
|
/// Initialize the backend and stores, and install them as globals.
|
||||||
/// Call once at startup, before opening any window that uses the stores.
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn init(
|
pub fn init(
|
||||||
db_path: impl AsRef<Path>,
|
db_path: impl AsRef<Path>,
|
||||||
repos_root: impl Into<PathBuf>,
|
repos_root: impl Into<PathBuf>,
|
||||||
scan_paths: Vec<PathBuf>,
|
scan_paths: Vec<PathBuf>,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> Entity<Backend> {
|
) {
|
||||||
// rustls uses the `aws_lc_rs` provider by default.
|
// rustls uses the `aws_lc_rs` provider by default.
|
||||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||||
|
|
||||||
@@ -37,27 +39,22 @@ pub fn init(
|
|||||||
.expect("failed to initialize nostr backend")
|
.expect("failed to initialize nostr backend")
|
||||||
});
|
});
|
||||||
|
|
||||||
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
||||||
Backend::set_global(entity.clone(), cx);
|
|
||||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||||
GitStore::set_global(repos_root, cx);
|
GitStore::set_global(repos_root, cx);
|
||||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
||||||
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
||||||
|
|
||||||
entity
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize the backend with an in-memory database on wasm.
|
/// Initialize the backend with an in-memory database on wasm.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub fn init(cx: &mut App) -> Entity<Backend> {
|
pub fn init(cx: &mut App) {
|
||||||
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
|
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
|
||||||
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
||||||
Backend::set_global(entity.clone(), cx);
|
|
||||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||||
GitStore::set_global(PathBuf::new(), cx);
|
GitStore::set_global(PathBuf::new(), cx);
|
||||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
|
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
|
||||||
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
|
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
|
||||||
entity
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
/// Refresh coalescing shared by the event stores.
|
/// Refresh coalescing shared by the event stores.
|
||||||
///
|
|
||||||
/// [`crate::RepoStore`], [`crate::RepoListStore`] and [`crate::CheckoutsStore`]
|
|
||||||
/// re-query their inputs on a debounce timer with the same policy:
|
|
||||||
/// a request arriving while a run is in flight is folded into a follow-up run,
|
|
||||||
/// a request arriving while the debounce timer is pending is dropped by it.
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct RefreshGate {
|
pub struct RefreshGate {
|
||||||
/// A run is in flight.
|
/// A run is in flight.
|
||||||
|
|||||||
@@ -0,0 +1,953 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Error;
|
||||||
|
use assets::CustomIconName;
|
||||||
|
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, add_bottom_panel, panel_handle};
|
||||||
|
use gpui::prelude::*;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment,
|
||||||
|
ListState, Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div,
|
||||||
|
list, px,
|
||||||
|
};
|
||||||
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
|
use gpui_component::scroll::ScrollableElement;
|
||||||
|
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
|
||||||
|
use nostr::prelude::{Event, EventId, Kind};
|
||||||
|
use signed_core::{
|
||||||
|
Announcement, COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, activity_subject, filters,
|
||||||
|
};
|
||||||
|
use signed_state::{
|
||||||
|
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
|
||||||
|
};
|
||||||
|
use signed_ui::{CountBadge, SegmentButton, UserAvatar};
|
||||||
|
use utils::relative_time;
|
||||||
|
|
||||||
|
use super::{RepoItem, open_repo_item, open_repo_panel};
|
||||||
|
|
||||||
|
/// Delay between a refresh request and the actual re-query.
|
||||||
|
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
|
/// Extra list rows measured above and below the visible area.
|
||||||
|
const LIST_OVERDRAW: Pixels = px(400.);
|
||||||
|
|
||||||
|
pub struct InboxView {
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
/// The dock area the Unread / Archived sub-view is added to.
|
||||||
|
dock_area: WeakEntity<DockArea>,
|
||||||
|
/// The open Unread / Archived sub-view, if any. Reused instead of adding a
|
||||||
|
/// duplicate panel on every header click.
|
||||||
|
filter_view: Option<WeakEntity<InboxFilterView>>,
|
||||||
|
/// Notifications grouped by thread root, newest activity first.
|
||||||
|
notifications: Arc<Vec<InboxItem>>,
|
||||||
|
/// The user's own recent git activity, newest first.
|
||||||
|
activity: Arc<Vec<Event>>,
|
||||||
|
/// Number of non-archived groups with an unread event.
|
||||||
|
unread_count: usize,
|
||||||
|
/// Copy of the global read state the current lists were derived with.
|
||||||
|
state: InboxReadState,
|
||||||
|
/// Set once the global state has been read for the current user.
|
||||||
|
state_loaded: bool,
|
||||||
|
refresh: RefreshGate,
|
||||||
|
/// Virtual-list state of the notification list, kept in sync with the
|
||||||
|
/// rendered (non-archived) notifications.
|
||||||
|
notifications_list: ListState,
|
||||||
|
/// Virtual-list state of the activity list.
|
||||||
|
activity_list: ListState,
|
||||||
|
_subscriptions: Vec<Subscription>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InboxView {
|
||||||
|
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let inbox = backend.read(cx).inbox();
|
||||||
|
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 _subscriptions = vec![
|
||||||
|
cx.observe(&inbox, |this, _inbox, cx| this.sync_state(cx)),
|
||||||
|
cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
|
this.handle_backend_event(event, cx);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Derive the lists once the panel exists.
|
||||||
|
cx.defer({
|
||||||
|
let weak = weak.clone();
|
||||||
|
move |cx| {
|
||||||
|
if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) {
|
||||||
|
log::warn!("inbox dropped before bootstrap could run: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
dock_area,
|
||||||
|
filter_view: None,
|
||||||
|
notifications: Arc::new(Vec::new()),
|
||||||
|
activity: Arc::new(Vec::new()),
|
||||||
|
unread_count: 0,
|
||||||
|
state: InboxReadState::default(),
|
||||||
|
state_loaded: false,
|
||||||
|
refresh: RefreshGate::default(),
|
||||||
|
notifications_list,
|
||||||
|
activity_list,
|
||||||
|
_subscriptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark every event in the group rooted at `root` read.
|
||||||
|
pub fn mark_read(&mut self, root: EventId, cx: &mut Context<Self>) {
|
||||||
|
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(group) = self.group_events(root) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let all = self.all_notification_events();
|
||||||
|
let inbox = Backend::global(cx).read(cx).inbox();
|
||||||
|
inbox.update(cx, |inbox, cx| inbox.mark_read(&group, &all, me, cx));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Archive the group rooted at `root`.
|
||||||
|
pub fn mark_archived(&mut self, root: EventId, cx: &mut Context<Self>) {
|
||||||
|
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(group) = self.group_events(root) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let all = self.all_notification_events();
|
||||||
|
let inbox = Backend::global(cx).read(cx).inbox();
|
||||||
|
inbox.update(cx, |inbox, cx| inbox.mark_archived(&group, &all, me, cx));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark every known notification read.
|
||||||
|
pub fn mark_all_read(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let all = self.all_notification_events();
|
||||||
|
let inbox = Backend::global(cx).read(cx).inbox();
|
||||||
|
inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show `mode` in the bottom dock, reusing the panel when it is already open.
|
||||||
|
fn open_filter(&mut self, mode: InboxFilter, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Some(dock_area) = self.dock_area.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(filter) = self.filter_view.as_ref().and_then(WeakEntity::upgrade) {
|
||||||
|
filter.update(cx, |filter, cx| filter.set_mode(mode, cx));
|
||||||
|
|
||||||
|
let handle = filter.read(cx).focus_handle.clone();
|
||||||
|
window.focus(&handle, cx);
|
||||||
|
|
||||||
|
dock_area.update(cx, |dock_area, cx| {
|
||||||
|
if !dock_area.is_dock_open(DockPlacement::Bottom) {
|
||||||
|
dock_area.toggle_dock(DockPlacement::Bottom, window, cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inbox = cx.entity();
|
||||||
|
let panel = cx.new(|cx| InboxFilterView::new(mode, inbox, cx));
|
||||||
|
self.filter_view = Some(panel.downgrade());
|
||||||
|
|
||||||
|
dock_area.update(cx, |dock_area, cx| {
|
||||||
|
add_bottom_panel(dock_area, panel_handle(panel), window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-derive from the global state when it is loaded or changes.
|
||||||
|
pub fn sync_state(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let inbox = Backend::global(cx).read(cx).inbox();
|
||||||
|
let (loaded, state) = {
|
||||||
|
let inbox = inbox.read(cx);
|
||||||
|
(inbox.is_loaded(), inbox.state().clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
if !loaded {
|
||||||
|
let was_present =
|
||||||
|
self.state_loaded || !self.notifications.is_empty() || !self.activity.is_empty();
|
||||||
|
self.clear();
|
||||||
|
if was_present {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.state_loaded {
|
||||||
|
self.state_loaded = true;
|
||||||
|
self.state = state;
|
||||||
|
self.refresh_initial(cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.state != state {
|
||||||
|
self.state = state;
|
||||||
|
self.regroup();
|
||||||
|
self.publish_unread_count(cx);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a backend event that can change the derived lists.
|
||||||
|
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
|
||||||
|
match event {
|
||||||
|
BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx),
|
||||||
|
BackendEvent::NostrUpdate(updates) => {
|
||||||
|
let relevant = updates.iter().any(|update| {
|
||||||
|
let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind);
|
||||||
|
let is_comment = update.kind == Kind::Comment;
|
||||||
|
let is_event_deletion = update.kind == Kind::EventDeletion;
|
||||||
|
let is_request_to_vanish = update.kind == Kind::RequestToVanish;
|
||||||
|
|
||||||
|
is_notification || is_comment || is_event_deletion || is_request_to_vanish
|
||||||
|
});
|
||||||
|
if relevant {
|
||||||
|
self.refresh(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-shot initial load, no debounce.
|
||||||
|
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
||||||
|
debug_assert!(!self.refresh.debouncing());
|
||||||
|
if self.refresh.running() {
|
||||||
|
self.refresh.request();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.run_refresh(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-query the local database.
|
||||||
|
fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if !self.state_loaded {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.refresh.request() != RefreshRequest::Schedule {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
||||||
|
this.update(cx, |this, cx| this.run_refresh(cx))
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One query and apply cycle, the debounced entry point.
|
||||||
|
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.refresh.begin();
|
||||||
|
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let Some(me) = backend.read(cx).current_user() else {
|
||||||
|
self.refresh.abort();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = backend.read(cx).client();
|
||||||
|
let state = self.state.clone();
|
||||||
|
|
||||||
|
let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await });
|
||||||
|
|
||||||
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
|
let (notifications, activity, unread_count) = match work.await {
|
||||||
|
Ok(results) => results,
|
||||||
|
// Database errors are transient, keep the last lists.
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("inbox refresh failed: {error}");
|
||||||
|
return this.update(cx, |this, _cx| this.refresh.abort());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let again = this.update(cx, |this, cx| {
|
||||||
|
// The signer may have changed while the query ran, making
|
||||||
|
// these results belong to the previous user.
|
||||||
|
if Backend::global(cx).read(cx).current_user() != Some(me) {
|
||||||
|
this.refresh.abort();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notifications = Arc::new(notifications);
|
||||||
|
this.activity = Arc::new(activity);
|
||||||
|
this.unread_count = unread_count;
|
||||||
|
this.publish_unread_count(cx);
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
this.refresh.finish()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if again {
|
||||||
|
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
task.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recompute the unread and archived flags from the current state.
|
||||||
|
fn regroup(&mut self) {
|
||||||
|
let mut items = (*self.notifications).clone();
|
||||||
|
|
||||||
|
for item in items.iter_mut() {
|
||||||
|
item.apply_state(&self.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.unread_count = items.iter().filter(|item| item.is_unread()).count();
|
||||||
|
self.notifications = Arc::new(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish the derived unread count for the sidebar badge.
|
||||||
|
fn publish_unread_count(&self, cx: &mut Context<Self>) {
|
||||||
|
let count = self.unread_count;
|
||||||
|
let inbox = Backend::global(cx).read(cx).inbox();
|
||||||
|
inbox.update(cx, |inbox, cx| inbox.set_unread_count(count, cx));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget everything derived for the current user.
|
||||||
|
fn clear(&mut self) {
|
||||||
|
self.notifications = Arc::new(Vec::new());
|
||||||
|
self.activity = Arc::new(Vec::new());
|
||||||
|
self.unread_count = 0;
|
||||||
|
self.state = InboxReadState::default();
|
||||||
|
self.state_loaded = false;
|
||||||
|
// Drop any in-flight or pending run belonging to the previous user.
|
||||||
|
self.refresh = RefreshGate::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events of the group rooted at `root`.
|
||||||
|
fn group_events(&self, root: EventId) -> Option<Vec<Event>> {
|
||||||
|
self.notifications
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.root == root)
|
||||||
|
.map(|item| item.events.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every event in every group, archived groups included.
|
||||||
|
fn all_notification_events(&self) -> Vec<Event> {
|
||||||
|
self.notifications
|
||||||
|
.iter()
|
||||||
|
.flat_map(|item| item.events.iter().cloned())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bordered card with a header bar and a scrolling body.
|
||||||
|
///
|
||||||
|
/// Flexible so its body gets a definite height, which the virtual list
|
||||||
|
/// needs to know which rows to render.
|
||||||
|
fn section(&self, header: impl IntoElement, body: impl IntoElement, cx: &App) -> AnyElement {
|
||||||
|
v_flex()
|
||||||
|
.w_full()
|
||||||
|
.flex_1()
|
||||||
|
.min_h_0()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.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_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("inbox-unread", "Unread")
|
||||||
|
.icon(Icon::new(IconName::Inbox).small())
|
||||||
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
|
this.open_filter(InboxFilter::Unread, window, cx);
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
SegmentButton::new("inbox-archived", "Archived")
|
||||||
|
.icon(Icon::new(IconName::FolderClosed).small())
|
||||||
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
|
this.open_filter(InboxFilter::Archived, window, cx);
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.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 {
|
||||||
|
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 {
|
||||||
|
return div().into_any_element();
|
||||||
|
};
|
||||||
|
activity_row(ix, event, cx)
|
||||||
|
})
|
||||||
|
.size_full()
|
||||||
|
.min_h_0();
|
||||||
|
|
||||||
|
div()
|
||||||
|
.relative()
|
||||||
|
.flex_1()
|
||||||
|
.min_h_0()
|
||||||
|
.child(rows)
|
||||||
|
.vertical_scrollbar(&list_state)
|
||||||
|
.into_any_element()
|
||||||
|
};
|
||||||
|
|
||||||
|
self.section(header, body, cx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display name of the repository at `addr`, from the announcement store.
|
||||||
|
fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> {
|
||||||
|
let addr = addr?;
|
||||||
|
RepoListStore::global(cx)
|
||||||
|
.read(cx)
|
||||||
|
.announcements
|
||||||
|
.iter()
|
||||||
|
.find(|announcement| announcement.addr() == *addr)
|
||||||
|
.map(display_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
fn open_item(
|
||||||
|
dock_area: &WeakEntity<DockArea>,
|
||||||
|
root: EventId,
|
||||||
|
kind: Option<Kind>,
|
||||||
|
address: Option<RepoAddr>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
let Some(address) = address else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(announcement) = RepoListStore::global(cx)
|
||||||
|
.read(cx)
|
||||||
|
.announcements
|
||||||
|
.iter()
|
||||||
|
.find(|announcement| announcement.addr() == address)
|
||||||
|
.cloned()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let detail = open_repo_panel(dock_area, &announcement, window, cx);
|
||||||
|
let Some(store) = detail.read(cx).store() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = match kind {
|
||||||
|
Some(Kind::GitIssue) => RepoItem::Issue(root),
|
||||||
|
Some(Kind::GitPullRequest) => RepoItem::PullRequest(root),
|
||||||
|
Some(Kind::GitPatch) => RepoItem::Patch,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
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()
|
||||||
|
.id((prefix, ix))
|
||||||
|
.w_full()
|
||||||
|
.gap_3()
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.items_center()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.hover(|this| this.bg(cx.theme().list_hover))
|
||||||
|
.child(UserAvatar::new(profile.name()).picture(profile.picture()))
|
||||||
|
.child(div().flex_shrink_0().child(kind_icon(kind)))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.gap_0p5()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.when(unread, |this| this.font_semibold())
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.text_ellipsis()
|
||||||
|
.child(subject),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(SharedString::from(kind_label(kind)))
|
||||||
|
.when_some(repo, |this, repo| {
|
||||||
|
this.child(SharedString::from("on")).child(repo)
|
||||||
|
})
|
||||||
|
.child(SharedString::from("·"))
|
||||||
|
.child(SharedString::from(age)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.when(unread, |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.size(px(8.))
|
||||||
|
.rounded(px(4.))
|
||||||
|
.bg(cx.theme().primary),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of the user's own recent git activity.
|
||||||
|
fn activity_row(ix: usize, event: &Event, cx: &App) -> AnyElement {
|
||||||
|
let kind = event.kind;
|
||||||
|
let subject = SharedString::from(activity_subject(event));
|
||||||
|
let repo = repo_name(event.tags.coordinates().next().as_ref(), cx);
|
||||||
|
let age = relative_time(event.created_at);
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.id(("activity-row", ix))
|
||||||
|
.w_full()
|
||||||
|
.gap_3()
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.items_center()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.hover(|this| this.bg(cx.theme().list_hover))
|
||||||
|
.child(div().flex_shrink_0().child(kind_icon(kind)))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.gap_0p5()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.text_ellipsis()
|
||||||
|
.child(subject),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(SharedString::from(kind_label(kind)))
|
||||||
|
.when_some(repo, |this, repo| {
|
||||||
|
this.child(SharedString::from("on")).child(repo)
|
||||||
|
})
|
||||||
|
.child(SharedString::from("·"))
|
||||||
|
.child(SharedString::from(age)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name to show for a repository, its `name` tag or its id.
|
||||||
|
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 {
|
||||||
|
return Icon::new(IconName::FileText).small();
|
||||||
|
}
|
||||||
|
|
||||||
|
match kind {
|
||||||
|
Kind::GitIssue => Icon::new(CustomIconName::GitIssueOpen),
|
||||||
|
Kind::GitPullRequest | Kind::GitPullRequestUpdate => {
|
||||||
|
Icon::new(CustomIconName::GitPullRequest)
|
||||||
|
}
|
||||||
|
Kind::GitPatch => Icon::new(CustomIconName::GitCommit),
|
||||||
|
Kind::Comment => Icon::new(IconName::FileText),
|
||||||
|
Kind::GitStatusOpen
|
||||||
|
| Kind::GitStatusApplied
|
||||||
|
| Kind::GitStatusClosed
|
||||||
|
| Kind::GitStatusDraft => Icon::new(IconName::CircleCheck),
|
||||||
|
_ => Icon::new(IconName::Bell),
|
||||||
|
}
|
||||||
|
.small()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short noun for a notification or activity kind.
|
||||||
|
fn kind_label(kind: Kind) -> &'static str {
|
||||||
|
if kind == COVER_NOTE_KIND {
|
||||||
|
return "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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Centered muted icon and message filling its container.
|
||||||
|
fn empty_state(icon: impl IconNamed, message: &str, cx: &App) -> AnyElement {
|
||||||
|
v_flex()
|
||||||
|
.w_full()
|
||||||
|
.flex_1()
|
||||||
|
.min_h_0()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.gap_2()
|
||||||
|
.py_8()
|
||||||
|
.child(
|
||||||
|
Icon::new(icon)
|
||||||
|
.large()
|
||||||
|
.text_color(cx.theme().muted_foreground),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(SharedString::from(message)),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which subset of the inbox a bottom-dock sub-view shows.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum InboxFilter {
|
||||||
|
Unread,
|
||||||
|
Archived,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InboxFilter {
|
||||||
|
/// Tab and empty-state label of the sub-view.
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Unread => "Unread",
|
||||||
|
Self::Archived => "Archived",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `item` belongs in this sub-view.
|
||||||
|
fn matches(self, item: &InboxItem) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Unread => item.is_unread(),
|
||||||
|
Self::Archived => item.archived,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Unread / Archived sub-view of the inbox, opened in the bottom dock.
|
||||||
|
struct InboxFilterView {
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
/// The inbox panel whose groups are filtered. A strong handle: the panel
|
||||||
|
/// keeps only a weak one back, so the two entities do not form a cycle.
|
||||||
|
inbox: Entity<InboxView>,
|
||||||
|
mode: InboxFilter,
|
||||||
|
list: ListState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InboxFilterView {
|
||||||
|
fn new(mode: InboxFilter, inbox: Entity<InboxView>, cx: &mut Context<Self>) -> Self {
|
||||||
|
let list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW);
|
||||||
|
let weak = cx.entity().downgrade();
|
||||||
|
list.set_scroll_handler(move |_, _, cx| {
|
||||||
|
let weak = weak.clone();
|
||||||
|
cx.defer(move |cx| {
|
||||||
|
let _ = weak.update(cx, |_, cx| cx.notify());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
inbox,
|
||||||
|
mode,
|
||||||
|
list,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch which subset is shown, when the header asks for another mode.
|
||||||
|
fn set_mode(&mut self, mode: InboxFilter, cx: &mut Context<Self>) {
|
||||||
|
if self.mode == mode {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.mode = mode;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BasePanel for InboxFilterView {
|
||||||
|
fn panel_name(&self) -> &'static str {
|
||||||
|
"inbox_filter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Panel for InboxFilterView {
|
||||||
|
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div().text_sm().child(SharedString::from(self.mode.label()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<PanelEvent> for InboxFilterView {}
|
||||||
|
|
||||||
|
impl Focusable for InboxFilterView {
|
||||||
|
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||||
|
self.focus_handle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for InboxFilterView {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let mode = self.mode;
|
||||||
|
let notifications = self.inbox.read(cx).notifications.clone();
|
||||||
|
|
||||||
|
let visible: Vec<usize> = notifications
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, item)| mode.matches(item))
|
||||||
|
.map(|(ix, _)| ix)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if self.list.item_count() != visible.len() {
|
||||||
|
self.list.reset(visible.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
let list_state = self.list.clone();
|
||||||
|
let inbox = self.inbox.clone();
|
||||||
|
|
||||||
|
let body: AnyElement = if visible.is_empty() {
|
||||||
|
let (icon, message) = match mode {
|
||||||
|
InboxFilter::Unread => (IconName::Inbox, "Nothing unread."),
|
||||||
|
InboxFilter::Archived => (IconName::FolderClosed, "Nothing archived."),
|
||||||
|
};
|
||||||
|
empty_state(icon, message, cx)
|
||||||
|
} else {
|
||||||
|
let rows = list(list_state.clone(), move |ix, _window, cx| {
|
||||||
|
let Some(item) = visible.get(ix).and_then(|&ix| notifications.get(ix)) else {
|
||||||
|
return div().into_any_element();
|
||||||
|
};
|
||||||
|
|
||||||
|
match mode {
|
||||||
|
InboxFilter::Unread => {
|
||||||
|
let root = item.root;
|
||||||
|
let open = inbox.clone();
|
||||||
|
let archive = inbox.clone();
|
||||||
|
|
||||||
|
notification_row("inbox-filter-row", ix, item, cx)
|
||||||
|
.on_click(move |_, _, cx| {
|
||||||
|
open.update(cx, |view, cx| view.mark_read(root, cx));
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
Button::new(("inbox-filter-archive", ix))
|
||||||
|
.icon(IconName::FolderClosed)
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.tab_stop(false)
|
||||||
|
.tooltip("Archive")
|
||||||
|
.on_click(move |_, _, cx| {
|
||||||
|
cx.stop_propagation();
|
||||||
|
archive.update(cx, |view, cx| view.mark_archived(root, cx));
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
InboxFilter::Archived => {
|
||||||
|
notification_row("inbox-filter-row", ix, item, 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()
|
||||||
|
};
|
||||||
|
|
||||||
|
v_flex().size_full().min_h_0().p_2().child(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BasePanel for InboxView {
|
||||||
|
fn panel_name(&self) -> &'static str {
|
||||||
|
"inbox"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Panel for InboxView {
|
||||||
|
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div().text_sm().child(SharedString::from("Inbox"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<PanelEvent> for InboxView {}
|
||||||
|
|
||||||
|
impl Focusable for InboxView {
|
||||||
|
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||||
|
self.focus_handle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 visible: Vec<usize> = notifications
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, item)| !item.archived)
|
||||||
|
.map(|(ix, _)| ix)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if self.notifications_list.item_count() != visible.len() {
|
||||||
|
self.notifications_list.reset(visible.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.activity_list.item_count() != activity.len() {
|
||||||
|
self.activity_list.reset(activity.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.image_cache(gpui::retain_all("inbox"))
|
||||||
|
.gap_4()
|
||||||
|
.p_4()
|
||||||
|
.child(self.render_inbox_panel(unread, notifications, visible, cx))
|
||||||
|
.child(self.render_activity_panel(activity, cx))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
mod dialog_state;
|
mod dialog_state;
|
||||||
|
mod inbox;
|
||||||
mod repo_detail;
|
mod repo_detail;
|
||||||
mod repo_list;
|
mod repo_list;
|
||||||
pub(crate) mod sidebar;
|
pub(crate) mod sidebar;
|
||||||
|
|
||||||
|
pub use inbox::InboxView;
|
||||||
pub use repo_detail::RepoDetailView;
|
pub use repo_detail::RepoDetailView;
|
||||||
pub(crate) use repo_detail::open_repo_panel;
|
pub(crate) use repo_detail::{RepoItem, open_repo_item, open_repo_panel};
|
||||||
pub use repo_list::RepoListView;
|
pub use repo_list::RepoListView;
|
||||||
pub use sidebar::SidebarPanel;
|
pub use sidebar::SidebarPanel;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::collections::{HashMap, HashSet, VecDeque};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
@@ -13,6 +14,7 @@ use gpui::{
|
|||||||
Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, WeakEntity,
|
Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, WeakEntity,
|
||||||
Window, div, px, relative, size, transparent_white,
|
Window, div, px, relative, size, transparent_white,
|
||||||
};
|
};
|
||||||
|
use gpui_base::dock::PanelView;
|
||||||
use gpui_base::{Button as BaseButton, Disableable, Popover};
|
use gpui_base::{Button as BaseButton, Disableable, Popover};
|
||||||
use gpui_component::alert::Alert;
|
use gpui_component::alert::Alert;
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
@@ -24,7 +26,7 @@ use gpui_component::{
|
|||||||
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled,
|
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled,
|
||||||
VirtualListScrollHandle, h_flex, v_flex,
|
VirtualListScrollHandle, h_flex, v_flex,
|
||||||
};
|
};
|
||||||
use nostr::prelude::{RelayUrl, ToBech32, Url};
|
use nostr::prelude::{EventId, RelayUrl, ToBech32, Url};
|
||||||
use signed_core::{Announcement, RepoAddr, RepoStatus, filters};
|
use signed_core::{Announcement, RepoAddr, RepoStatus, filters};
|
||||||
use signed_git::{CommitList, FileCommit};
|
use signed_git::{CommitList, FileCommit};
|
||||||
use signed_state::{
|
use signed_state::{
|
||||||
@@ -57,7 +59,9 @@ use helpers::{
|
|||||||
ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, ref_selector_trigger,
|
ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, ref_selector_trigger,
|
||||||
tree_items,
|
tree_items,
|
||||||
};
|
};
|
||||||
|
use issue_detail::IssueDetailView;
|
||||||
use issues::{IssuesView, open_new_issue_dialog};
|
use issues::{IssuesView, open_new_issue_dialog};
|
||||||
|
use pull_request_detail::PullRequestDetailView;
|
||||||
use pull_requests::PullRequestsView;
|
use pull_requests::PullRequestsView;
|
||||||
use send_patch::open_send_patch_panel;
|
use send_patch::open_send_patch_panel;
|
||||||
|
|
||||||
@@ -227,6 +231,12 @@ impl RepoDetailView {
|
|||||||
view
|
view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The per-repository nostr store, so another panel can open one of its
|
||||||
|
/// items. `None` until a local repository is initialized to NIP-34.
|
||||||
|
pub(crate) fn store(&self) -> Option<Entity<RepoStore>> {
|
||||||
|
self.store.clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Open a local repository discovered by the scan.
|
/// Open a local repository discovered by the scan.
|
||||||
pub fn new_local(
|
pub fn new_local(
|
||||||
dock_area: WeakEntity<DockArea>,
|
dock_area: WeakEntity<DockArea>,
|
||||||
@@ -2649,3 +2659,41 @@ pub(crate) fn open_repo_panel(
|
|||||||
|
|
||||||
detail
|
detail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An item of a repository to open from outside its detail panel.
|
||||||
|
/// A patch has no detail view in Signed, so it opens nothing.
|
||||||
|
pub(crate) enum RepoItem {
|
||||||
|
Issue(EventId),
|
||||||
|
PullRequest(EventId),
|
||||||
|
Patch,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the detail panel of `item` in `store`'s repository, in the dock's center.
|
||||||
|
///
|
||||||
|
/// A patch opens nothing: patches are only consumed inside a pull request's
|
||||||
|
/// detail panel, and have no panel of their own.
|
||||||
|
pub(crate) fn open_repo_item(
|
||||||
|
dock_area: &WeakEntity<DockArea>,
|
||||||
|
store: Entity<RepoStore>,
|
||||||
|
item: RepoItem,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
let panel: Arc<dyn PanelView> = match item {
|
||||||
|
RepoItem::Issue(issue_id) => {
|
||||||
|
panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx)))
|
||||||
|
}
|
||||||
|
RepoItem::PullRequest(pr_id) => panel_handle(
|
||||||
|
cx.new(|cx| PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx)),
|
||||||
|
),
|
||||||
|
RepoItem::Patch => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(dock_area) = dock_area.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
dock_area.update(cx, |dock_area, cx| {
|
||||||
|
add_center_panel(dock_area, panel, window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ use signed_core::{Announcement, RepoAddr, identifier_from_name};
|
|||||||
use signed_state::{
|
use signed_state::{
|
||||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore,
|
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore,
|
||||||
};
|
};
|
||||||
use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
|
use signed_ui::{CountBadge, NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
|
||||||
|
|
||||||
use super::{RepoDetailView, RepoListView, open_repo_panel};
|
use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel};
|
||||||
|
|
||||||
mod create_repo_dialog;
|
mod create_repo_dialog;
|
||||||
pub(crate) mod grasp_servers;
|
pub(crate) mod grasp_servers;
|
||||||
@@ -37,7 +37,10 @@ use self::onboarding_dialog::OnboardingState;
|
|||||||
pub struct SidebarPanel {
|
pub struct SidebarPanel {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
dock_area: WeakEntity<DockArea>,
|
dock_area: WeakEntity<DockArea>,
|
||||||
|
inbox: Option<WeakEntity<InboxView>>,
|
||||||
explore: Option<WeakEntity<RepoListView>>,
|
explore: Option<WeakEntity<RepoListView>>,
|
||||||
|
/// Unread notification groups, shown as the inbox nav item's badge.
|
||||||
|
unread: usize,
|
||||||
/// Artwork for the sign-in screen.
|
/// Artwork for the sign-in screen.
|
||||||
banner: SharedString,
|
banner: SharedString,
|
||||||
/// The signed-in user's announced repositories, newest first.
|
/// The signed-in user's announced repositories, newest first.
|
||||||
@@ -99,10 +102,22 @@ 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 {
|
let mut this = Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
dock_area,
|
dock_area,
|
||||||
|
inbox: None,
|
||||||
explore: None,
|
explore: None,
|
||||||
|
unread: inbox.read(cx).unread_count,
|
||||||
banner: pick_banner(),
|
banner: pick_banner(),
|
||||||
announcements: Arc::new(Vec::new()),
|
announcements: Arc::new(Vec::new()),
|
||||||
local_repos: Arc::new(Vec::new()),
|
local_repos: Arc::new(Vec::new()),
|
||||||
@@ -203,6 +218,20 @@ impl SidebarPanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open the inbox home panel in the dock area's center.
|
||||||
|
pub fn open_inbox(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if self.inbox.as_ref().and_then(WeakEntity::upgrade).is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let panel = cx.new(|cx| InboxView::new(self.dock_area.clone(), cx));
|
||||||
|
self.inbox = Some(panel.downgrade());
|
||||||
|
|
||||||
|
let _ = self.dock_area.update(cx, |dock_area, cx| {
|
||||||
|
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Open the Explore repository list panel in the dock area's center.
|
/// Open the Explore repository list panel in the dock area's center.
|
||||||
pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if self
|
if self
|
||||||
@@ -615,8 +644,11 @@ impl Render for SidebarPanel {
|
|||||||
.justify_start()
|
.justify_start()
|
||||||
.child(
|
.child(
|
||||||
NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small())
|
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| {
|
.on_click(cx.listener(|this, _ev, window, cx| {
|
||||||
this.open_explore(window, cx)
|
this.open_inbox(window, cx)
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
@@ -0,0 +1,824 @@
|
|||||||
|
# Inbox (home screen) implementation plan
|
||||||
|
|
||||||
|
Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for a logged-in user.
|
||||||
|
|
||||||
|
> **Correction to the first draft.** The first draft assumed the inbox was the `/notifications`
|
||||||
|
> page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `<Dashboard />` when
|
||||||
|
> an account is active, and that home screen is the inbox.
|
||||||
|
|
||||||
|
> **Status.** Phases 0-4 are implemented and green on `feat/inbox`:
|
||||||
|
> `cargo test -p signed_core` (68), `cargo test -p signed_state` (24),
|
||||||
|
> `cargo test -p workspace` (7), `cargo clippy -p workspace --all-targets` clean,
|
||||||
|
> `cargo check --workspace --all-targets` succeeds.
|
||||||
|
> Phase 5 is not started. This document reflects the implementation as it stands, including the
|
||||||
|
> Phase 1 refactors, the §4.3 split of the inbox into a thin global `Inbox` and a panel-owned
|
||||||
|
> derivation, the Phase 3 bottom-dock sub-views, and the Phase 4 click-through.
|
||||||
|
|
||||||
|
## 1. What the GitWorkshop home screen is
|
||||||
|
|
||||||
|
`Index.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
if (account) return <Dashboard />;
|
||||||
|
return <LandingPage />;
|
||||||
|
```
|
||||||
|
|
||||||
|
`Dashboard.tsx` layout:
|
||||||
|
|
||||||
|
- Desktop: two columns.
|
||||||
|
- **Left column**: `GreetingHeader`, `NotificationsPanel`, `RecentActivitySection`.
|
||||||
|
- **Right column**: `MyRepositoriesPanel`, `AccessiblePrivateRepositoriesPanel`,
|
||||||
|
`FollowedReposPanel`.
|
||||||
|
- Mobile: a single column in a different order.
|
||||||
|
|
||||||
|
The panel that gives the screen its inbox identity is `NotificationsPanel`:
|
||||||
|
|
||||||
|
- heading **Notifications** with a bell icon and an unread count badge,
|
||||||
|
- a **Mark all read** action and a **View all** link to `/notifications`,
|
||||||
|
- a compact list of the first 5 **non-archived** notification items,
|
||||||
|
- the empty state reads **"Your inbox is empty"** (with an `Inbox` icon).
|
||||||
|
|
||||||
|
So in GitWorkshop's vocabulary, "inbox" is the non-archived activity directed at you, surfaced
|
||||||
|
inline on the home screen. The home screen also shows your own recent activity and your repositories.
|
||||||
|
|
||||||
|
Data hooks:
|
||||||
|
|
||||||
|
| Section | Hook | What it loads |
|
||||||
|
|---|---|---|
|
||||||
|
| Notifications (inbox) | `useNotifications()` | Notification model: grouped thread activity directed at you, read/archived state |
|
||||||
|
| Continue where you left off | `useUserActivity(pubkey)` | Git activity authored by you: kinds 1621/1617/1618/1111 (git `K`)/1624/1630-1633, newest first, limit 50 |
|
||||||
|
| My repositories | `useUserRepositories(pubkey)` | Kind 30617 announcements authored by you |
|
||||||
|
| Followed repositories | `useUserFollowedRepos(pubkey)` | Repos you follow |
|
||||||
|
| Accessible private repositories | `useAccessiblePrivateRepositories()` | Private repos from CI/services |
|
||||||
|
|
||||||
|
## 2. Scope for Signed
|
||||||
|
|
||||||
|
| Priority | Section | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| **P0** | Inbox panel | Activity directed at you, grouped by thread root; unread badge; mark all read; all groups shown |
|
||||||
|
| **P0** | Continue where you left off | Your own recent git activity, newest first |
|
||||||
|
| **P1** | Unread / Archived sub-views | Open as **bottom-dock panels**, not tabs inside the inbox panel |
|
||||||
|
| **P1** | Click-through | Open the repo panel at the relevant PR/issue |
|
||||||
|
| **P2 (defer)** | Standalone notifications page, NIP-65 relay discovery, pagination | Web-app concerns |
|
||||||
|
| **Out of scope** | Greeting header, my repositories, followed repositories, private repositories, pinned repositories | Not needed in Signed |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- There is **no greeting header**. The screen starts with the inbox panel.
|
||||||
|
- There is **no My repositories column**. The sidebar already lists the signed-in user's repositories, so the inbox is a single column.
|
||||||
|
- Unread and Archived are separate panels opened in the bottom dock, not tabs in the inbox panel.
|
||||||
|
|
||||||
|
## 3. The Signed screen
|
||||||
|
|
||||||
|
`InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item (currently a
|
||||||
|
placeholder that opens Explore). It is two flexible bordered cards, each a virtual list:
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
| Inbox (3 unread) [Unread] [Archived] [Mark all read] |
|
||||||
|
| [avatar] issue opened on you/repo 2m (scroll) |
|
||||||
|
| [avatar] commented on "Fix parser" 1h |
|
||||||
|
| [avatar] PR update on you/repo 3h |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
| Continue where you left off (scroll)|
|
||||||
|
| [icon] "Fix parser bug" you/repo opened 3d |
|
||||||
|
| [icon] "Add retry" you/repo PR 5d |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
| bottom dock: Unread or Archived list (opened by the header buttons) |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Data layer
|
||||||
|
|
||||||
|
### 4.1 `signed_core`: pure logic
|
||||||
|
|
||||||
|
**`filters.rs`** (extend, next to `activity`/`comments_for`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Kinds that notify a user when they tag them directly.
|
||||||
|
pub const NOTIFICATION_KINDS: [Kind; 9] = [
|
||||||
|
Kind::GitIssue,
|
||||||
|
Kind::GitPullRequest,
|
||||||
|
Kind::GitPatch,
|
||||||
|
Kind::GitPullRequestUpdate,
|
||||||
|
COVER_NOTE_KIND,
|
||||||
|
Kind::GitStatusOpen,
|
||||||
|
Kind::GitStatusApplied,
|
||||||
|
Kind::GitStatusClosed,
|
||||||
|
Kind::GitStatusDraft,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Comments on our issues/PRs/patches.
|
||||||
|
pub fn notification_comments(me: PublicKey) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kind(Kind::Comment)
|
||||||
|
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
|
||||||
|
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activity directed at us: comments on our roots, and git events tagging us.
|
||||||
|
pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
||||||
|
vec![
|
||||||
|
notification_comments(me),
|
||||||
|
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Git activity authored by `me`, for "Continue where you left off".
|
||||||
|
pub fn authored_activity(me: PublicKey) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kinds([ACTIVITY_KINDS.as_slice(), &[COVER_NOTE_KIND]].concat())
|
||||||
|
.author(me)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ACTIVITY_KINDS` already exists in this file. All builders use existing SDK APIs
|
||||||
|
(`Filter::kind/kinds/pubkey/custom_tags`, `SingleLetterTag::{UPPERCASE_P, UPPERCASE_K}`).
|
||||||
|
|
||||||
|
Comments authored by `me` are not all git comments, so the activity query needs a post-filter:
|
||||||
|
keep kind 1111 only when its uppercase `K` tag is a git root kind (1621/1617/1618/30617), matching
|
||||||
|
gitworkshop's `isGitComment`.
|
||||||
|
|
||||||
|
**`inbox.rs`** (new file):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct InboxItem {
|
||||||
|
pub root: EventId,
|
||||||
|
pub root_kind: Option<Kind>,
|
||||||
|
pub address: Option<RepoAddr>,
|
||||||
|
/// Events in the group, newest first.
|
||||||
|
pub events: Vec<Event>,
|
||||||
|
/// Unread event ids, oldest first.
|
||||||
|
pub unread_ids: Vec<EventId>,
|
||||||
|
pub archived: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The thread root of a notification event, or `None` if it isn't git-related.
|
||||||
|
pub fn notification_root(
|
||||||
|
event: &Event,
|
||||||
|
lookup: &impl Fn(EventId) -> Option<Event>,
|
||||||
|
) -> Option<EventId>;
|
||||||
|
|
||||||
|
/// Group notification events by root, newest activity first, self excluded.
|
||||||
|
pub fn group(
|
||||||
|
events: impl IntoIterator<Item = Event>,
|
||||||
|
me: PublicKey,
|
||||||
|
state: &InboxReadState,
|
||||||
|
lookup: &impl Fn(EventId) -> Option<Event>,
|
||||||
|
) -> Vec<InboxItem>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Root resolution, ported from `getNotificationRootId`:
|
||||||
|
|
||||||
|
- issue (1621) / PR (1618): itself
|
||||||
|
- patch (1617): its `e` parent patch, else itself
|
||||||
|
- NIP-22 comment (1111): uppercase `E` root pointer (SDK `nip22::extract_root`)
|
||||||
|
- PR update (1619): uppercase `E`
|
||||||
|
- statuses (1630-1633) / cover note (1624): NIP-10 root `e`
|
||||||
|
- self-authored events are excluded
|
||||||
|
|
||||||
|
Read/archive state, the compact high-water-mark model:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct InboxReadState {
|
||||||
|
#[serde(default)] pub read_before: Timestamp,
|
||||||
|
#[serde(default)] pub read_ids: HashSet<EventId>,
|
||||||
|
#[serde(default)] pub archived_before: Timestamp,
|
||||||
|
#[serde(default)] pub archived_ids: HashSet<EventId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InboxReadState {
|
||||||
|
pub fn is_read(&self, event: &Event) -> bool;
|
||||||
|
pub fn is_archived(&self, event: &Event) -> bool;
|
||||||
|
pub fn mark_read(&mut self, event: &Event);
|
||||||
|
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey);
|
||||||
|
/// Move the cutoff to `min(oldest unread - 1, now - 3 days)` and prune ids.
|
||||||
|
pub fn advance_read(&mut self, all: &[Event], me: PublicKey);
|
||||||
|
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`activity_subject` in `model.rs` already gives an issue/PR title from the `subject` tag or first
|
||||||
|
line; reuse it for the home screen rows.
|
||||||
|
|
||||||
|
### 4.2 Persistence: NIP-78 in the local database, never published
|
||||||
|
|
||||||
|
Read state is a normal NIP-78 (kind `30078`, `Kind::ApplicationSpecificData`) addressable event
|
||||||
|
**written to LMDB only**. It is never broadcast to a relay, so the read state stays on this device.
|
||||||
|
|
||||||
|
It is signed with a **random keypair**, never the user's signer. The event is local application
|
||||||
|
storage, so its author carries no identity; this avoids a signing round-trip and does not depend on
|
||||||
|
the signer type. The `d` tag identifies the owning user, so state does not leak across identities
|
||||||
|
when the signed-in key changes.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// d tag identifying the inbox read/archive state event of `me`.
|
||||||
|
fn inbox_state_d_tag(me: PublicKey) -> String {
|
||||||
|
format!("signed-inbox-state:{}", me.to_hex())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newest stored read state for `me`.
|
||||||
|
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
|
||||||
|
// No author filter: the signing key is random per save.
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.identifier(inbox_state_d_tag(me));
|
||||||
|
|
||||||
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
|
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
match serde_json::from_str(&event.content) {
|
||||||
|
Ok(state) => Ok(Some(state)),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign with a fresh random key and store locally.
|
||||||
|
async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> {
|
||||||
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||||
|
.tags([Tag::identifier(inbox_state_d_tag(me))])
|
||||||
|
.finalize(&Keys::generate())?; // synchronous: random key, no user signer
|
||||||
|
// Local-only: no `send_event`, no broadcast. The event lives in LMDB.
|
||||||
|
client.database().save_event(&event).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A fresh random key is generated on every save, so each save writes a new event rather than
|
||||||
|
replacing the previous one. LMDB only auto-replaces an addressable event when the incoming event
|
||||||
|
has the **same pubkey**, so old copies accumulate. Nothing prunes them; `load_state` reads the
|
||||||
|
newest by `created_at`, so the behavior is correct. This is a deliberate trade for not caching a
|
||||||
|
key in the store (see §4.3). An earlier implementation deleted the previous event by tracking its
|
||||||
|
id across saves; that was removed as more derived state than it was worth.
|
||||||
|
`NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs.
|
||||||
|
|
||||||
|
### 4.3 Data layer: a thin global `Inbox`, a panel-owned derivation
|
||||||
|
|
||||||
|
The inbox is split in two, because the expensive derivation is only needed while the home screen is
|
||||||
|
open.
|
||||||
|
|
||||||
|
**`Inbox`** is a child `Entity<Inbox>` owned by `Backend` (`inbox: Entity<Inbox>`) and is
|
||||||
|
deliberately thin: it owns only the read/archive state that must outlive the panel, the NIP-78
|
||||||
|
load/save, and the unread count the sidebar badge reads.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// backend.rs
|
||||||
|
pub struct Backend {
|
||||||
|
...
|
||||||
|
inbox: Entity<Inbox>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// inbox.rs
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Inbox {
|
||||||
|
state: InboxReadState,
|
||||||
|
state_loaded: bool,
|
||||||
|
/// Published by the inbox panel for the sidebar badge.
|
||||||
|
pub unread_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Inbox {
|
||||||
|
pub fn state(&self) -> &InboxReadState;
|
||||||
|
pub fn is_loaded(&self) -> bool;
|
||||||
|
pub fn set_unread_count(&mut self, count: usize, cx: &mut Context<Self>);
|
||||||
|
pub fn mark_read(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx);
|
||||||
|
pub fn mark_archived(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx);
|
||||||
|
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx);
|
||||||
|
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx);
|
||||||
|
pub(crate) fn reset(&mut self, cx);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**The panel owns the derivation.** `InboxView` itself holds the derived lists, the copy of the read
|
||||||
|
state they were computed with, and the refresh coalescing. There is no separate store entity: the
|
||||||
|
panel is the only consumer, so an `Entity<InboxStore>` would add an `update` indirection and a
|
||||||
|
forwarding subscription without buying any sharing.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct InboxView {
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
notifications: Arc<Vec<InboxItem>>,
|
||||||
|
activity: Arc<Vec<Event>>,
|
||||||
|
unread_count: usize,
|
||||||
|
state: InboxReadState,
|
||||||
|
state_loaded: bool,
|
||||||
|
refresh: RefreshGate,
|
||||||
|
_subscriptions: Vec<Subscription>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InboxView {
|
||||||
|
pub fn new(cx: &mut Context<Self>) -> Self; // cx.defer(… sync_state)
|
||||||
|
pub fn sync_state(&mut self, cx); // observes the global Inbox
|
||||||
|
pub fn mark_read(&mut self, root: EventId, cx); // Phase 3
|
||||||
|
pub fn mark_archived(&mut self, root: EventId, cx); // Phase 3
|
||||||
|
pub fn mark_all_read(&mut self, cx);
|
||||||
|
fn handle_backend_event(&mut self, event: &BackendEvent, cx);
|
||||||
|
fn refresh(&mut self, cx);
|
||||||
|
fn run_refresh(&mut self, cx);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The panel owns the two subscriptions that carry logic: it observes the global `Inbox`
|
||||||
|
(`InboxView::sync_state`) and subscribes to `Backend` (`InboxView::handle_backend_event`).
|
||||||
|
Re-rendering needs no subscription: GPUI invalidates a window for every entity it read during
|
||||||
|
render, so the panel tracks `RepoListStore` and `ProfileStore` just by reading them in `render`.
|
||||||
|
The panel writes back to `Backend` only to publish the unread count for the badge.
|
||||||
|
|
||||||
|
**`signed_state::query_inbox`.** The database work stays in `signed_state`, so the UI crate never
|
||||||
|
queries LMDB directly. `query_inbox` returns the grouped notifications, the user's own git
|
||||||
|
activity and the unread count; the panel applies the results on the main thread. `RefreshGate` is
|
||||||
|
re-exported for the panel's debounce.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn query_inbox(
|
||||||
|
client: &Client,
|
||||||
|
me: PublicKey,
|
||||||
|
state: &InboxReadState,
|
||||||
|
) -> Result<(Vec<InboxItem>, Vec<Event>, usize), Error>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lifespan.** `Inbox` is created with the backend but idles until the user has a signer. The
|
||||||
|
derived lists live only as long as the panel. Nothing is wired from the `desktop` crate and
|
||||||
|
`signed_state::init` gains no parameters.
|
||||||
|
|
||||||
|
**Badge trade-off.** The unread count is derived by the panel, so the sidebar badge is only current
|
||||||
|
after the inbox has been opened once in the session. Keeping it always live would require the
|
||||||
|
expensive derivation to run globally, which is exactly what this split avoids.
|
||||||
|
|
||||||
|
The dependency chain is `Backend` → `Inbox` and `InboxView` → `query_inbox`; the panel reaches back
|
||||||
|
only to publish the unread count.
|
||||||
|
|
||||||
|
`Backend` does not funnel its events through the inbox: the panel subscribes to `Backend` directly.
|
||||||
|
`BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: `CheckoutsStore`
|
||||||
|
and `SidebarPanel` consume them. They no longer drive the inbox.
|
||||||
|
|
||||||
|
`InboxView::handle_backend_event` refreshes on:
|
||||||
|
|
||||||
|
- `NostrUpdate(updates)`: when any update kind is in `NOTIFICATION_KINDS`, is `Kind::Comment`, or is
|
||||||
|
a deletion (`EventDeletion` / `RequestToVanish`).
|
||||||
|
- `Synced` / `Published`.
|
||||||
|
- everything else: ignored.
|
||||||
|
|
||||||
|
**Signer lifecycle: `Backend::sync_inbox`.** `Backend` owns the wiring and calls `sync_inbox` from the
|
||||||
|
three real signer transitions: `create_identity`, `set_signer` (nsec, bunker and passphrase restore)
|
||||||
|
and `logout`. It starts the subscriptions and repo-relay connects, then calls `Inbox::activate` or
|
||||||
|
`Inbox::reset`. The client is passed into `activate`, so the global inbox never reads `Backend`
|
||||||
|
while `sync_inbox` is mid-update:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let me = self.current_user;
|
||||||
|
|
||||||
|
if let Some(me) = me {
|
||||||
|
self.subscribe_bootstrap(filters::notifications(me), cx);
|
||||||
|
self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
|
||||||
|
|
||||||
|
let relays: HashSet<RelayUrl> = RepoListStore::global(cx)
|
||||||
|
.read(cx)
|
||||||
|
.announcements_of(&me)
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|announcement| announcement.relays)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !relays.is_empty() {
|
||||||
|
let relays: Vec<RelayUrl> = relays.into_iter().collect();
|
||||||
|
self.connect_repo_relays(relays.clone(), filters::notifications(me), cx);
|
||||||
|
self.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = self.client.clone();
|
||||||
|
self.inbox.update(cx, |inbox, cx| match me {
|
||||||
|
Some(me) => inbox.activate(me, client, cx),
|
||||||
|
None => inbox.reset(cx),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The repo relays are read from `RepoListStore::global(cx).read(cx).announcements_of(&me)` at call
|
||||||
|
time and never cached. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind
|
||||||
|
10002 yet.) `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` has no `subscribe_remote`
|
||||||
|
/ `connect_own_repo_relays`.
|
||||||
|
|
||||||
|
**Activation** clears the state and loads the NIP-78 state from LMDB. The panel clears its own
|
||||||
|
lists and in-flight refresh when it sees the unloaded state, then refreshes once it is loaded:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) {
|
||||||
|
// state = default; state_loaded = false; unread_count = 0; cx.notify();
|
||||||
|
// spawn load_state(client, me), then set state and state_loaded = true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`reset` performs the same clearing without a state load, and is used on logout.
|
||||||
|
|
||||||
|
Reading the state event needs no signer at all (the `d` tag carries the identity); activation is
|
||||||
|
still gated on the signer because the fetch filters need the user's pubkey.
|
||||||
|
|
||||||
|
**Fetch** reuses `Backend::subscribe_bootstrap` and `Backend::connect_repo_relays` through
|
||||||
|
`Backend::sync_inbox` (see above). The query the panel runs is intentionally the offline-first cache
|
||||||
|
read, not a wait on the network; see the note below.
|
||||||
|
|
||||||
|
**Refresh** (`InboxView::run_refresh`, mirrors `RepoListStore::run_refresh`):
|
||||||
|
|
||||||
|
- `cx.background_spawn`: query the notification filters and the activity filter from
|
||||||
|
`client.database()`.
|
||||||
|
- Query `filters::deletions()`, build `Deletions`, skip deleted events.
|
||||||
|
- Build `HashMap<EventId, Event>` for root walking; group the notification events with
|
||||||
|
`inbox::group`.
|
||||||
|
- Filter the activity events: keep issues/PRs/patches/statuses/cover notes, and comments only when
|
||||||
|
their `K` tag is a git kind; sort newest first.
|
||||||
|
- Cross back to the main thread: guard on `Backend::global(cx).read(cx).current_user() ==
|
||||||
|
Some(me)`; if the signer changed while the query ran, `refresh.abort()` instead of applying, so a
|
||||||
|
previous user's results never land. Then set `notifications`, `activity`, `unread_count`, publish
|
||||||
|
the unread count to the global `Inbox`, `cx.notify()`, `refresh.finish()`.
|
||||||
|
|
||||||
|
`InboxView::sync_state` reacts to the global `Inbox`: while the state is not loaded it clears the
|
||||||
|
lists, on the first load it runs the initial refresh, and on a state change (a mark action) it
|
||||||
|
re-derives the flags (`InboxItem::apply_state`) and publishes the new unread count.
|
||||||
|
|
||||||
|
**Fetch vs. the immediate query.** `subscribe_bootstrap` / `connect_repo_relays` return immediately,
|
||||||
|
so the query that follows them reads the local cache rather than waiting for the relays. That is
|
||||||
|
deliberate offline-first behavior: cached content appears at once on a warm start and with no
|
||||||
|
network, instead of blocking the home screen on the network. The gap is closed by the SDK, not by
|
||||||
|
timing: received events are written to LMDB and surfaced as `ClientNotification::Event`, so
|
||||||
|
`Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the store refreshes. This was
|
||||||
|
reviewed and left as-is.
|
||||||
|
|
||||||
|
**Actions**: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()` live on the panel, which
|
||||||
|
passes the group and every known notification event to the global `Inbox`. The global marks the
|
||||||
|
group, advances the cutoffs against *all* notification events to bound the id sets, saves the state
|
||||||
|
to LMDB (signed with a fresh random key, see 4.2), and notifies. The panel then re-derives and
|
||||||
|
publishes the unread count.
|
||||||
|
|
||||||
|
**Repository names need no new store**: `RepoListStore` already holds every announcement and
|
||||||
|
`repo_name` resolves an address to a display name.
|
||||||
|
|
||||||
|
### 4.4 `Cargo.toml`
|
||||||
|
|
||||||
|
- `signed_core`: add `serde.workspace` for the `InboxReadState` derives.
|
||||||
|
- `signed_state`: add `serde_json.workspace` for the NIP-78 content.
|
||||||
|
|
||||||
|
## 5. UI
|
||||||
|
|
||||||
|
### 5.1 `InboxView` center panel
|
||||||
|
|
||||||
|
New `crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`.
|
||||||
|
It owns the derived lists directly, so `cx.notify()` from an update re-renders it. The panel is a
|
||||||
|
column of two flexible bordered cards (`flex_1`, `min_h_0`), each with a header bar and a scrolling
|
||||||
|
body. Each body is a `gpui::list` virtual list (`ListState` + `ListAlignment::Top`, 400px
|
||||||
|
overdraw) with a `vertical_scrollbar`; the panel itself does not scroll, so both lists get a
|
||||||
|
definite viewport height. The list counts are reset from `render` whenever the rendered item count
|
||||||
|
changes. Row ids are prefixed (`("inbox-row", ix)` / `("activity-row", ix)`) so the two lists do not
|
||||||
|
collide.
|
||||||
|
|
||||||
|
- **Inbox card**: header with the unread count badge, the **Unread** and **Archived** sub-view buttons
|
||||||
|
(Phase 3) and **Mark all read**; then every non-archived notification item. Rows show the actor
|
||||||
|
avatar, a kind icon, the subject, the kind label, the repo name, a relative time, and an unread dot
|
||||||
|
(the subject is semibold while unread). Empty state: "You're all caught up." with
|
||||||
|
`IconName::Inbox`.
|
||||||
|
- **Continue where you left off**: every event in the activity list, each row a kind icon, subject,
|
||||||
|
kind label, repo name, and relative time.
|
||||||
|
|
||||||
|
No greeting header, and no **My repositories** column - the sidebar already lists the user's
|
||||||
|
repositories. The **Unread** and **Archived** header buttons open the sub-views of 5.2 in the bottom
|
||||||
|
dock.
|
||||||
|
|
||||||
|
### 5.2 Unread / Archived as bottom-dock panels
|
||||||
|
|
||||||
|
Add a bottom-panel helper next to `add_center_panel` in `crates/dock/src/lib.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Add an already-wrapped panel handle to the bottom dock of `area`.
|
||||||
|
pub fn add_bottom_panel(
|
||||||
|
area: &mut DockArea,
|
||||||
|
panel: Arc<dyn PanelView>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<DockArea>,
|
||||||
|
) {
|
||||||
|
area.add_panel_view(panel, DockPlacement::Bottom, None, window, cx);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The workspace already supports a bottom dock and prunes it when empty (`workspace.rs`). Then:
|
||||||
|
|
||||||
|
- `InboxFilterView` is a bottom-dock panel holding an `Entity<InboxView>` and a mode
|
||||||
|
`InboxFilter::Unread | InboxFilter::Archived`. It renders the matching subset of the panel's
|
||||||
|
notifications as a `gpui::list`, with the same rows as the inbox card. The mode filters on
|
||||||
|
`InboxItem::is_unread()` / `InboxItem::archived` and picks the tab title and empty state. It reads
|
||||||
|
the inbox entity during render, so GPUI's render-time tracking re-renders it whenever the inbox
|
||||||
|
re-derives; it needs no subscription of its own.
|
||||||
|
- The **Unread** and **Archived** header buttons in `InboxView` call `InboxView::open_filter`, which
|
||||||
|
keeps `filter_view: Option<WeakEntity<InboxFilterView>>`. When the panel already exists it updates
|
||||||
|
its mode, focuses it, and reopens the bottom dock if the user collapsed it, instead of adding a
|
||||||
|
duplicate. Otherwise it creates the panel and adds it to the bottom dock.
|
||||||
|
- **Unread rows**: clicking a row marks that group read (`InboxView::mark_read`); a trailing ghost
|
||||||
|
icon button archives it (`InboxView::mark_archived`). Both call back into the `InboxView` entity,
|
||||||
|
which updates the global `Inbox`. **Archived rows** are display-only, since the read state has no
|
||||||
|
un-archive operation.
|
||||||
|
|
||||||
|
### 5.3 Sidebar
|
||||||
|
|
||||||
|
In `views/sidebar/mod.rs`:
|
||||||
|
|
||||||
|
- Add `inbox: Option<WeakEntity<InboxView>>` (mirrors `explore`) and `unread: usize`.
|
||||||
|
- Add `fn open_inbox(&mut self, window, cx)` that returns when the panel is already open, else adds
|
||||||
|
a center panel (same shape as `open_explore`; there is no dock API to focus an existing tab).
|
||||||
|
`InboxView::new` takes the sidebar's `WeakEntity<DockArea>` so the panel can open its sub-view.
|
||||||
|
- Point the existing nav item at it and add an unread suffix:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small())
|
||||||
|
.when(unread > 0, |this| this.suffix(...badge...))
|
||||||
|
.on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))),
|
||||||
|
```
|
||||||
|
|
||||||
|
- `cx.observe` `Backend::global(cx).read(cx).inbox()` so the badge follows the count the panel
|
||||||
|
publishes.
|
||||||
|
|
||||||
|
### 5.4 Click-through (P1)
|
||||||
|
|
||||||
|
Reuse the single `RepoStore` that `RepoDetailView` already creates instead of making a second one.
|
||||||
|
The detail panels need a `Window`, and GPUI's `Entity::update_in` only exists on a `VisualContext`,
|
||||||
|
which a synchronous `App` + `Window` pair is not - so the entry point is a free function rather than
|
||||||
|
a `RepoDetailView::open_item` method. In `repo_detail/mod.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) enum RepoItem {
|
||||||
|
Issue(EventId),
|
||||||
|
PullRequest(EventId),
|
||||||
|
Patch,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn open_repo_item(
|
||||||
|
dock_area: &WeakEntity<DockArea>,
|
||||||
|
store: Entity<RepoStore>,
|
||||||
|
item: RepoItem,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) { /* new IssueDetailView / PullRequestDetailView, added to the center */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `RepoDetailView::store()` exposes its `Option<Entity<RepoStore>>`, so the caller reuses the repo
|
||||||
|
panel's store rather than building one. `views/mod.rs` re-exports `RepoItem` and `open_repo_item`.
|
||||||
|
- `InboxView` resolves `item.address` to an `Announcement` from `RepoListStore`, calls
|
||||||
|
`open_repo_panel` (which returns `Entity<RepoDetailView>`), takes its store, and calls
|
||||||
|
`open_repo_item` with the root id and kind. The detail panel renders a "not found" placeholder
|
||||||
|
until the store's fetch lands, then re-renders.
|
||||||
|
- The repository panel and the detail panel are two tabs of the center group; the detail is
|
||||||
|
activated. This matches the sidebar, which also opens a fresh repo panel per click.
|
||||||
|
|
||||||
|
Patches have no detail view in Signed (they are only consumed inside `PullRequestDetailView`), so a
|
||||||
|
patch-root click opens the repo panel only. `RepoItem::Patch` carries no id for that reason. A group
|
||||||
|
whose root is not an issue/PR/patch, or whose repository is not in `RepoListStore`, opens nothing.
|
||||||
|
|
||||||
|
## 6. File-by-file change list
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `crates/signed_core/Cargo.toml` | add `serde` |
|
||||||
|
| `crates/signed_core/src/filters.rs` | `NOTIFICATION_KINDS`, `notification_comments`, `notifications`, `authored_activity`, `is_git_activity`, `deletions` |
|
||||||
|
| `crates/signed_core/src/inbox.rs` | **new**: `InboxItem`, `notification_root`, `group`, `InboxReadState`, tests |
|
||||||
|
| `crates/signed_core/src/lib.rs` | `mod inbox;` and re-exports |
|
||||||
|
| `crates/signed_state/Cargo.toml` | add `serde_json` |
|
||||||
|
| `crates/signed_state/src/inbox.rs` | thin global `Inbox` (NIP-78 read state, mark actions) and `query_inbox` (query, grouping, activity) |
|
||||||
|
| `crates/signed_state/src/backend.rs` | `inbox: Entity<Inbox>` field, construction, `inbox()` accessor, `sync_inbox`, `RepoListStore` import |
|
||||||
|
| `crates/signed_state/src/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users |
|
||||||
|
| `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` and `query_inbox`; re-export `RefreshGate` (no global install) |
|
||||||
|
| `crates/dock/src/lib.rs` | `add_bottom_panel` helper |
|
||||||
|
| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel owning the derived lists directly, the `InboxFilterView` bottom-dock sub-view for Unread / Archived, and the notification click-through |
|
||||||
|
| `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;`; re-export `RepoItem`, `open_repo_item`, `open_repo_panel` |
|
||||||
|
| `crates/workspace/src/views/sidebar/mod.rs` | `inbox`/`unread` fields, `open_inbox`, nav wiring and badge |
|
||||||
|
| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `open_repo_item`, `RepoDetailView::store()` |
|
||||||
|
|
||||||
|
No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; `Backend::sync_inbox`
|
||||||
|
activates the `Inbox` child entity at each signer transition.
|
||||||
|
|
||||||
|
## 7. Phasing
|
||||||
|
|
||||||
|
1. **Phase 0 - pure logic**: `signed_core` filters and `inbox.rs` plus tests. **DONE.**
|
||||||
|
Implemented as `filters::{NOTIFICATION_KINDS, notification_comments, notifications, authored_activity, is_git_activity}`
|
||||||
|
and `inbox::{InboxItem, notification_root, group, InboxReadState}`. Two deviations from the sketch:
|
||||||
|
the cutoff methods take an explicit `now: Timestamp` so the pure logic stays deterministic and testable,
|
||||||
|
and `authored_activity` results must pass through `is_git_activity` before display (comments on
|
||||||
|
non-git roots are matched by the filter). `cargo test -p signed_core` passes (66 tests at the
|
||||||
|
end of Phase 0; 68 after the two Phase 1 additions).
|
||||||
|
2. **Phase 1 - store**: `Inbox` child entity, activated by `Backend::sync_inbox` once a signer
|
||||||
|
exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the
|
||||||
|
implementation notes below.
|
||||||
|
3. **Phase 2 - screen**: `InboxView` (inbox + activity), sidebar nav and badge.
|
||||||
|
**DONE.** See the implementation notes below.
|
||||||
|
4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived.
|
||||||
|
**DONE.** See the implementation notes below.
|
||||||
|
5. **Phase 4 - click-through**: `open_item` and announcement lookup. **DONE.** See the implementation
|
||||||
|
notes below.
|
||||||
|
6. **Phase 5 (optional)**: standalone notifications page, NIP-65 relays, pagination, patch detail
|
||||||
|
view.
|
||||||
|
|
||||||
|
Each phase compiles and is usable on its own.
|
||||||
|
|
||||||
|
### Phase 1 implementation notes
|
||||||
|
|
||||||
|
Files: `crates/signed_state/{Cargo.toml, src/inbox.rs, src/lib.rs, src/backend.rs, src/refresh.rs}`
|
||||||
|
and two additions to `crates/signed_core/src/inbox.rs`.
|
||||||
|
|
||||||
|
- `Inbox` is a child entity of `Backend` (`inbox: Entity<Inbox>`), created in `Backend::new` and
|
||||||
|
reached via `Backend::inbox()`. Nothing in `desktop` is wired and `signed_state::init` gains no
|
||||||
|
parameters. The dependency is strictly one-way: `Inbox` holds no `Backend` handle.
|
||||||
|
- `Backend::emit` is the single funnel for every `BackendEvent`. It updates the inbox through
|
||||||
|
`cx.defer` and then emits to the other subscribers. The defer is required: every emit site runs
|
||||||
|
inside `Backend::update`, and the inbox handlers read `Backend`, so a synchronous call panics on
|
||||||
|
a re-entrant entity access.
|
||||||
|
- The signer lifecycle lives in `Backend::sync_inbox`, called from `create_identity`, `set_signer`
|
||||||
|
and `logout`. It starts the subscriptions and repo-relay connects, then defers `inbox.activate`
|
||||||
|
/ `inbox.reset`. `SignerChanged` / `SignerRequired` are still emitted for `CheckoutsStore` and
|
||||||
|
`SidebarPanel`, but no longer drive the inbox.
|
||||||
|
- `Inbox` mirrors `RepoListStore`: `RefreshGate` coalescing, `cx.background_spawn` for the
|
||||||
|
database work, plain data applied on the main thread, refresh-on-`NostrUpdate`/`Synced`/`Published`.
|
||||||
|
- Added `state_loaded: bool`, not in the sketch. Groups are derived from the read state, so a refresh
|
||||||
|
before the stored state is read would briefly mark everything unread. The first refresh is chained
|
||||||
|
after `load_state`, and later `refresh` calls are ignored until `state_loaded` is set.
|
||||||
|
- Account switches are guarded. `activate` and `reset` both replace `self.refresh` with a fresh
|
||||||
|
`RefreshGate`, dropping any in-flight or pending run of the previous user, and the apply step of
|
||||||
|
`run_refresh` aborts instead of applying when `Backend::current_user()` no longer matches the
|
||||||
|
user the query was started for.
|
||||||
|
- Two additions to `signed_core::inbox` that Phase 1 needs: `InboxReadState::mark_archived` (mirrors
|
||||||
|
`mark_read`) and `InboxItem::apply_state` (recomputes `unread_ids`/`archived`; `group` now uses it).
|
||||||
|
Both are covered by tests.
|
||||||
|
- The thread-root lookup is built by walking every `e`/`E` ancestor transitively (`fetch_notifications`)
|
||||||
|
rather than a single hop, because a patch series chains through parent patches. Only the notification
|
||||||
|
events are grouped; ancestors are used solely as the lookup, so a root authored by someone else is
|
||||||
|
not mistaken for a notification.
|
||||||
|
- The read/archive state event is written to LMDB only (`database().save_event`), signed with a fresh
|
||||||
|
`Keys::generate()` on each save and never published. Filtering is by `d` tag only, no author, so the
|
||||||
|
random key is irrelevant across sessions. `d` tag uses `me.to_hex()` rather than `Display`.
|
||||||
|
- Actions: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each marks the group, advances
|
||||||
|
the relevant cutoffs against **all** notification events (matching GitWorkshop's use of `allEvents`),
|
||||||
|
re-derives the groups locally so the UI updates immediately, then persists in the background.
|
||||||
|
- The global `Inbox` keeps no derived state. The signing key is generated per save, the current user is read
|
||||||
|
from `Backend::current_user()` where needed, and the relays of the user's own repositories are
|
||||||
|
queried from `RepoListStore` in `Backend::sync_inbox` rather than cached. There is no prune logic
|
||||||
|
either: the newest state event is selected by `created_at`.
|
||||||
|
- `Inbox::activate` / `Inbox::reset` are `pub(crate)`; the former `subscribe_remote` and
|
||||||
|
`connect_own_repo_relays` methods were deleted once their work moved into `Backend::sync_inbox`.
|
||||||
|
- `cargo test -p signed_core` passes (68 tests), `cargo test -p signed_state` passes (24 tests);
|
||||||
|
`cargo clippy -p signed_state --all-targets` is clean; `cargo check --workspace` succeeds.
|
||||||
|
|
||||||
|
### Phase 2 implementation notes
|
||||||
|
|
||||||
|
Files: `crates/workspace/src/views/{inbox.rs, mod.rs, sidebar/mod.rs}`. No store changes.
|
||||||
|
|
||||||
|
- `InboxView` is a plain center panel like `RepoListView`; the sidebar holds a
|
||||||
|
`WeakEntity<InboxView>` so there is no cycle. Re-rendering relies on GPUI's render-time entity
|
||||||
|
tracking rather than explicit observations. (Phase 2 introduced an `Entity<InboxStore>` here; it
|
||||||
|
was later folded into the panel - see the store-merge note below.)
|
||||||
|
- The layout is a column of two flexible bordered cards (`gap_4`, `p_4`, each `flex_1`/`min_h_0`),
|
||||||
|
inbox over activity. Each card is a rounded `v_flex` with a header bar (`section`) and a
|
||||||
|
`gpui::list` body. There is no **My repositories** column: the sidebar already lists the user's
|
||||||
|
repositories, so the panel is a single column.
|
||||||
|
- Notification rows read the newest event of each group for the actor, subject and time, and the
|
||||||
|
root's kind for the icon. The repo name is resolved from `item.address` through a linear scan of
|
||||||
|
`RepoListStore::announcements` (`repo_name`); the list is small and this keeps the store unchanged.
|
||||||
|
- The **Unread** / **Archived** header buttons are intentionally absent: they need
|
||||||
|
`add_bottom_panel` / `InboxFilterView`, which are Phase 3. The header is only **Mark all read**,
|
||||||
|
so the panel is fully usable on its own.
|
||||||
|
- `kind_icon` / `kind_label` map a `Kind` to a `CustomIconName`/`IconName` and a short noun. The
|
||||||
|
cover note is compared with `==` rather than matched, since `Kind` cannot appear in a pattern arm.
|
||||||
|
- Sidebar: `open_inbox` mirrors `open_explore` (return if open, else add a center panel); the inbox
|
||||||
|
nav item is repointed and carries a `CountBadge` suffix driven by the observed unread count. The
|
||||||
|
screen is still opened by the nav item, not on app startup, matching the "idle until signer" rule;
|
||||||
|
auto-opening it as the post-login home is a possible follow-up.
|
||||||
|
- The **My repositories** column (search `InputState`, **New** button, `open_repo_panel` rows) was
|
||||||
|
removed after Phase 2 as redundant with the sidebar, along with the panel's `dock_area`,
|
||||||
|
`open_repo` / `open_create_repo` helpers and the `create_repo_dialog` / `open_repo_panel` imports.
|
||||||
|
`InboxView::new` now takes only `cx`. `create_repo_dialog` is private again.
|
||||||
|
- `cargo clippy -p workspace --all-targets` is clean and `cargo check --workspace --all-targets`
|
||||||
|
succeeds. `cargo test -p signed_core` (68) and `cargo test -p signed_state` (24) still pass.
|
||||||
|
|
||||||
|
### Architecture refactor (after Phase 2)
|
||||||
|
|
||||||
|
Phases 0-2 kept all derivation in the global `Inbox`, so every notification and activity query ran
|
||||||
|
whether or not the home screen was open, and `Backend::emit` carried a deferred side effect just to
|
||||||
|
feed it.
|
||||||
|
|
||||||
|
- The global `Inbox` is now thin: `state: InboxReadState`, `state_loaded`, and the `unread_count` the
|
||||||
|
sidebar badge reads, plus the NIP-78 load/save and the mark actions.
|
||||||
|
- `Backend::emit` is gone. All `BackendEvent`s are emitted with `cx.emit` again, and `sync_inbox`
|
||||||
|
updates the inbox synchronously, passing the client in so nothing reads `Backend` mid-update.
|
||||||
|
- The panel became the client-side owner of the derivation, initially through a panel-scoped
|
||||||
|
`Entity<InboxStore>`.
|
||||||
|
- `signed_core` is unchanged.
|
||||||
|
|
||||||
|
### Store merged into the panel (after Phase 2)
|
||||||
|
|
||||||
|
The `InboxStore` entity was then folded into `InboxView`, since the panel was its only consumer.
|
||||||
|
|
||||||
|
- `InboxView` holds `notifications`, `activity`, `unread_count`, `state`, `state_loaded` and
|
||||||
|
`RefreshGate` as fields, and the store's methods (`sync_state`, `handle_backend_event`,
|
||||||
|
`refresh`/`run_refresh`, `regroup`, `publish_unread_count`, `clear`, the mark actions) became panel
|
||||||
|
methods. The two subscriptions call them directly, with no `update` indirection.
|
||||||
|
- The database work stayed in `signed_state` as `pub async fn query_inbox(...)`; `RefreshGate` and
|
||||||
|
`RefreshRequest` are re-exported. The UI crate never queries LMDB directly.
|
||||||
|
- `mark_read`, `mark_archived` and their `group_events` helper carry a scoped `#[allow(dead_code)]`
|
||||||
|
until the Phase 3 sub-views wire them up.
|
||||||
|
- `cargo test -p signed_core` (68), `cargo test -p signed_state` (24) and `cargo test -p workspace`
|
||||||
|
(7) pass; clippy and `cargo check --workspace --all-targets` are clean.
|
||||||
|
|
||||||
|
Trade-off: the sidebar badge is only current after the inbox is opened once, because the unread
|
||||||
|
count is derived by the panel.
|
||||||
|
|
||||||
|
### Phase 3 implementation notes
|
||||||
|
|
||||||
|
Files: `crates/dock/src/lib.rs` and `crates/workspace/src/views/{inbox.rs, sidebar/mod.rs}`. No
|
||||||
|
store changes.
|
||||||
|
|
||||||
|
- `add_bottom_panel` sits next to `add_center_panel` and wraps
|
||||||
|
`DockArea::add_panel_view(panel, DockPlacement::Bottom, None, ...)`. A new bottom dock starts open,
|
||||||
|
and the workspace's existing `DockEvent::LayoutChanged` subscription removes an emptied bottom dock,
|
||||||
|
so a closed sub-view leaves no strip behind.
|
||||||
|
- `InboxFilterView` is private to `views/inbox.rs`. It holds an `Entity<InboxView>` (strong; the
|
||||||
|
panel keeps only the weak `filter_view` back, so there is no cycle), the mode, and its own
|
||||||
|
`ListState`. There is no subscription: it reads the inbox entity during render, which is enough for
|
||||||
|
GPUI to invalidate the window when the inbox notifies.
|
||||||
|
- `InboxFilter` is a private two-variant enum with `label()` and `matches(&InboxItem)`. The tab title
|
||||||
|
comes from `Panel::title`, so switching modes through `set_mode` retitles the same tab instead of
|
||||||
|
opening a second one.
|
||||||
|
- `InboxView` regained a `dock_area: WeakEntity<DockArea>` (removed with the My-repositories column)
|
||||||
|
and takes it in `new`. `open_filter` reuses the existing panel, focuses it, and reopens the bottom
|
||||||
|
dock when it is collapsed; otherwise it creates and adds the panel. `InboxView::new` is now called
|
||||||
|
as `InboxView::new(self.dock_area.clone(), cx)` from `SidebarPanel::open_inbox`.
|
||||||
|
- The three `#[allow(dead_code)]` markers on `mark_read`, `mark_archived` and `group_events` are gone:
|
||||||
|
Unread rows call `mark_read` on click and `mark_archived` from a trailing ghost icon button
|
||||||
|
(`Button` + `IconName::FolderClosed`, tooltip "Archive"). The button calls `cx.stop_propagation()`
|
||||||
|
so it does not also trigger the row's mark-read click. Archived rows are display-only; the read
|
||||||
|
state has no un-archive operation.
|
||||||
|
- `notification_row` takes an id `prefix` and returns `Stateful<Div>` rather than `AnyElement`, so
|
||||||
|
callers can attach a click handler and a trailing action. The inbox list passes `"inbox-row"` and
|
||||||
|
the sub-view `"inbox-filter-row"`, because the two lists render in the same window and would
|
||||||
|
otherwise collide on `(str, ix)` ids.
|
||||||
|
- `cargo clippy -p workspace -p dock --all-targets` is clean, `cargo check --workspace --all-targets`
|
||||||
|
succeeds, and `cargo test -p signed_core -p signed_state -p workspace` passes (68 / 24 / 7).
|
||||||
|
|
||||||
|
### Phase 4 implementation notes
|
||||||
|
|
||||||
|
Files: `crates/workspace/src/views/{inbox.rs, mod.rs, repo_detail/mod.rs}`. No store changes.
|
||||||
|
|
||||||
|
- `RepoItem { Issue(EventId), PullRequest(EventId), Patch }` and `pub(crate) fn open_repo_item` live
|
||||||
|
in `repo_detail/mod.rs`, next to `open_repo_panel`. `open_repo_item` takes the store as a
|
||||||
|
parameter, avoiding a second `RepoStore`.
|
||||||
|
- It is a free function, not `RepoDetailView::open_item`: the detail constructors take a `Window`, and
|
||||||
|
a synchronous `&mut App` + `&mut Window` pair is not a `VisualContext`, so `Entity::update_in` is
|
||||||
|
not available. `InboxView` already has the window in the list's `on_click`, so it drives the free
|
||||||
|
function directly. The plan's original `detail.update_in(window, cx, ...)` sketch could not compile.
|
||||||
|
- `RepoDetailView::store()` (`pub(crate)`) exposes the panel's `Option<Entity<RepoStore>>`. The repo
|
||||||
|
panel is opened first and its store reused, so the detail panel shares one store with the repo it
|
||||||
|
came from.
|
||||||
|
- `InboxView::open_item` is also a free function (it needs nothing but `dock_area`, which it captures
|
||||||
|
from the panel) because the `gpui::list` item closure only receives `&mut App`. It resolves
|
||||||
|
`item.address` through `RepoListStore`, returns silently when the repository is unknown, opens the
|
||||||
|
repo panel, then maps the root kind to a `RepoItem` and calls `open_repo_item`.
|
||||||
|
- Only the main notification list is clickable. Unread rows keep their Phase 3 behaviour (click marks
|
||||||
|
read, trailing button archives). Activity rows are unchanged.
|
||||||
|
- `RepoItem::Patch` is a unit variant because the id would be unused: patches have no detail panel, so
|
||||||
|
`open_repo_item` returns before doing anything and only the repository panel opens.
|
||||||
|
- `cargo clippy -p workspace --all-targets` is clean, `cargo check --workspace --all-targets` succeeds,
|
||||||
|
and `cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1).
|
||||||
|
|
||||||
|
## 8. Validation
|
||||||
|
|
||||||
|
- `cargo test -p signed_core` (68 tests): root resolution, grouping, read-state cutoff, serde round-trip.
|
||||||
|
- `cargo test -p signed_state` (24 tests): the `Inbox` / `query_inbox` paths that do not need GPUI
|
||||||
|
(state round-trip, grouping helpers).
|
||||||
|
- `cargo test -p workspace` (7 tests): repository-detail helpers.
|
||||||
|
- `cargo clippy -p signed_state --all-targets`, `cargo clippy -p workspace --all-targets` and
|
||||||
|
`cargo check --workspace --all-targets` after each phase.
|
||||||
|
- Manual: log in with a repo-owning identity; open the inbox from the sidebar and confirm the panel
|
||||||
|
populates from another identity's issue/comment, the activity list shows your own items, and that no
|
||||||
|
kind-30078 event is broadcast (watch the relays / `Published` events). Restart to confirm the read
|
||||||
|
state is read back from LMDB.
|
||||||
|
|
||||||
|
## 9. SDK APIs used (verified in the pinned `5c669a4` checkout)
|
||||||
|
|
||||||
|
- `Kind::{Comment, GitIssue, GitPullRequest, GitPatch, GitPullRequestUpdate,`
|
||||||
|
`GitStatusOpen/Applied/Closed/Draft, ApplicationSpecificData, EventDeletion, RequestToVanish}`
|
||||||
|
- `Filter::{kind, kinds, pubkey, pubkeys, custom_tags, limit, since, events, coordinate, identifier}`
|
||||||
|
- Non-obvious: `Filter::pubkey`/`pubkeys` set the lowercase **`p` tag**, not `authors`. Use
|
||||||
|
`Filter::author`/`authors` for authorship. The `notifications` filter relies on this.
|
||||||
|
- `SingleLetterTag::{LOWERCASE_P, LOWERCASE_E, UPPERCASE_P, UPPERCASE_K, UPPERCASE_E}`
|
||||||
|
- `nostr::nips::nip22::{extract_root, extract_parent, CommentTarget}`: NIP-22 root/parent pointers
|
||||||
|
- `Tags::{event_ids, public_keys, coordinates, identifier, hashtags}` iterators
|
||||||
|
- `Client::{database, subscribe, sync, notifications, send_event, add_relay}`;
|
||||||
|
`NostrDatabase::{save_event, query}`; `NostrLmdb`, `NostrGossipMemory`
|
||||||
|
- `EventBuilder::{new, tags, finalize}`, `Tag::identifier`, `Keys::generate`
|
||||||
|
- `Timestamp`, `EventId` (hex serde), `PublicKey`, `Coordinate`
|
||||||
|
- Fetch paths converge on the same notification: `client.subscribe(...)` and negentropy
|
||||||
|
`client.sync(...)` both persist received events to LMDB and surface them as
|
||||||
|
`ClientNotification::Event`, which `Backend`'s pump batches into `BackendEvent::NostrUpdate`.
|
||||||
|
This is why the query right after a fetch is a cache read, not a race.
|
||||||
Reference in New Issue
Block a user