add inbox view
This commit is contained in:
@@ -34,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)]
|
||||||
@@ -79,9 +75,6 @@ impl BackendEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The global backend entity.
|
|
||||||
///
|
|
||||||
/// Owns the nostr client, the signer, the notification pump and the inbox.
|
|
||||||
pub struct Backend {
|
pub struct Backend {
|
||||||
client: Client,
|
client: Client,
|
||||||
signer: UniversalSigner,
|
signer: UniversalSigner,
|
||||||
@@ -162,9 +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, |this, cx| {
|
if let Err(e) =
|
||||||
this.emit(BackendEvent::NostrUpdate(batch), cx)
|
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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,9 +214,7 @@ impl Backend {
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||||
this.emit(BackendEvent::error(e.to_string()), cx)
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok::<(), Error>(())
|
Ok::<(), Error>(())
|
||||||
@@ -231,14 +222,13 @@ impl Backend {
|
|||||||
notify_task.detach();
|
notify_task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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") {
|
||||||
self.emit(BackendEvent::SignerRequired, cx);
|
cx.emit(BackendEvent::SignerRequired);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,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, |this, cx| this.emit(BackendEvent::SignerRequired, cx))?;
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::SignerRequired))?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -272,10 +262,10 @@ impl Backend {
|
|||||||
// A passphrase is required to decrypt it before the session can resume.
|
// A passphrase is required to decrypt it before the session can resume.
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.passphrase_required = true;
|
this.passphrase_required = true;
|
||||||
this.emit(BackendEvent::PassphraseRequired, cx);
|
cx.emit(BackendEvent::PassphraseRequired);
|
||||||
})?;
|
})?;
|
||||||
} else {
|
} else {
|
||||||
this.update(cx, |this, cx| this.emit(BackendEvent::SignerRequired, cx))?;
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::SignerRequired))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok::<_, Error>(())
|
Ok::<_, Error>(())
|
||||||
@@ -283,9 +273,9 @@ impl Backend {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |_this, cx| {
|
||||||
this.emit(BackendEvent::error(e.to_string()), cx);
|
cx.emit(BackendEvent::error(e.to_string()));
|
||||||
this.emit(BackendEvent::SignerRequired, cx);
|
cx.emit(BackendEvent::SignerRequired);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,8 +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);
|
||||||
this.emit(BackendEvent::SignerChanged, cx);
|
|
||||||
|
cx.emit(BackendEvent::SignerChanged);
|
||||||
this.sync_inbox(cx);
|
this.sync_inbox(cx);
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let relays: Vec<(RelayUrl, Option<RelayMetadata>)> = [
|
let relays: Vec<(RelayUrl, Option<RelayMetadata>)> = [
|
||||||
@@ -978,7 +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 {
|
||||||
self.emit(BackendEvent::error("Unsupported credential."), cx);
|
cx.emit(BackendEvent::error("Unsupported credential."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -996,7 +988,7 @@ impl Backend {
|
|||||||
let keys = match SecretKey::parse(nsec) {
|
let keys = match SecretKey::parse(nsec) {
|
||||||
Ok(secret) => Keys::new(secret),
|
Ok(secret) => Keys::new(secret),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.emit(BackendEvent::error(e.to_string()), cx);
|
cx.emit(BackendEvent::error(e.to_string()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1007,9 +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, |this, cx| {
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||||
this.emit(BackendEvent::error(e.to_string()), cx)
|
|
||||||
})?;
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
||||||
@@ -1025,7 +1015,7 @@ impl Backend {
|
|||||||
let connect_uri = match NostrConnectUri::parse(&uri_string) {
|
let connect_uri = match NostrConnectUri::parse(&uri_string) {
|
||||||
Ok(uri) => uri,
|
Ok(uri) => uri,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.emit(BackendEvent::error(e.to_string()), cx);
|
cx.emit(BackendEvent::error(e.to_string()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1055,9 +1045,7 @@ impl Backend {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||||
this.emit(BackendEvent::error(e.to_string()), cx)
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1076,8 +1064,8 @@ impl Backend {
|
|||||||
this.signer.swap_inner(Keys::generate());
|
this.signer.swap_inner(Keys::generate());
|
||||||
this.current_user = None;
|
this.current_user = None;
|
||||||
this.passphrase_required = false;
|
this.passphrase_required = false;
|
||||||
this.emit(BackendEvent::SignerChanged, cx);
|
cx.emit(BackendEvent::SignerChanged);
|
||||||
this.emit(BackendEvent::SignerRequired, cx);
|
cx.emit(BackendEvent::SignerRequired);
|
||||||
this.sync_inbox(cx);
|
this.sync_inbox(cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
@@ -1109,9 +1097,7 @@ impl Backend {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||||
this.emit(BackendEvent::error(e.to_string()), cx)
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1155,31 +1141,15 @@ impl Backend {
|
|||||||
|
|
||||||
/// Surface an error message through [`BackendEvent::Error`].
|
/// Surface an error message through [`BackendEvent::Error`].
|
||||||
pub fn emit_error(&mut self, message: impl Into<String>, cx: &mut Context<Self>) {
|
pub fn emit_error(&mut self, message: impl Into<String>, cx: &mut Context<Self>) {
|
||||||
self.emit(BackendEvent::error(message), cx);
|
cx.emit(BackendEvent::error(message));
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the inbox, then emit `event` to the other stores.
|
|
||||||
fn emit(&self, event: BackendEvent, cx: &mut Context<Self>) {
|
|
||||||
let inbox = self.inbox.downgrade();
|
|
||||||
let inbox_event = event.clone();
|
|
||||||
|
|
||||||
cx.defer(move |cx| {
|
|
||||||
if let Err(error) = inbox.update(cx, |inbox, cx| {
|
|
||||||
inbox.handle_backend_event(&inbox_event, cx);
|
|
||||||
}) {
|
|
||||||
log::warn!("inbox dropped before handling backend event: {error}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.emit(event);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attach the inbox to the current signer and activate or clear it.
|
/// Attach the inbox to the current signer and activate or clear it.
|
||||||
///
|
|
||||||
/// The inbox's own update is deferred because activating reads `Backend`,
|
|
||||||
/// which every call site is in the middle of updating.
|
|
||||||
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
|
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
|
||||||
if let Some(me) = self.current_user {
|
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(filters::notifications(me), cx);
|
||||||
self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
|
self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
|
||||||
|
|
||||||
@@ -1197,20 +1167,9 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let inbox = self.inbox.downgrade();
|
self.inbox.update(cx, |inbox, cx| match me {
|
||||||
|
Some(me) => inbox.activate(me, client, cx),
|
||||||
cx.defer(move |cx| {
|
None => inbox.reset(cx),
|
||||||
let updated = inbox.update(cx, |inbox, cx| {
|
|
||||||
if Backend::global(cx).read(cx).current_user().is_some() {
|
|
||||||
inbox.activate(cx);
|
|
||||||
} else {
|
|
||||||
inbox.reset(cx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Err(error) = updated {
|
|
||||||
log::warn!("inbox dropped before syncing with the signer: {error}");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,14 +1194,14 @@ impl Backend {
|
|||||||
this.current_user = Some(public_key);
|
this.current_user = Some(public_key);
|
||||||
this.passphrase_required = false;
|
this.passphrase_required = false;
|
||||||
this.bootstrap_user(public_key, cx);
|
this.bootstrap_user(public_key, cx);
|
||||||
this.emit(BackendEvent::SignerChanged, cx);
|
cx.emit(BackendEvent::SignerChanged);
|
||||||
this.sync_inbox(cx);
|
this.sync_inbox(cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |_this, cx| {
|
||||||
this.emit(BackendEvent::error(e.to_string()), cx);
|
cx.emit(BackendEvent::error(e.to_string()));
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1279,8 +1238,8 @@ impl Backend {
|
|||||||
|
|
||||||
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| {
|
this.update(cx, |_this, cx| {
|
||||||
this.emit(BackendEvent::error(e.to_string()), cx);
|
cx.emit(BackendEvent::error(e.to_string()));
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Ok::<(), Error>(())
|
Ok::<(), Error>(())
|
||||||
@@ -1308,13 +1267,10 @@ impl Backend {
|
|||||||
|
|
||||||
let alive = this.update(cx, |this, cx| {
|
let alive = this.update(cx, |this, cx| {
|
||||||
this.sync_progress = Some((progress.total, progress.current));
|
this.sync_progress = Some((progress.total, progress.current));
|
||||||
this.emit(
|
cx.emit(BackendEvent::SyncProgress {
|
||||||
BackendEvent::SyncProgress {
|
|
||||||
total: progress.total,
|
total: progress.total,
|
||||||
current: progress.current,
|
current: progress.current,
|
||||||
},
|
});
|
||||||
cx,
|
|
||||||
);
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1343,14 +1299,14 @@ impl Backend {
|
|||||||
);
|
);
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.sync_progress = None;
|
this.sync_progress = None;
|
||||||
this.emit(BackendEvent::Synced, cx);
|
cx.emit(BackendEvent::Synced);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.sync_progress = None;
|
this.sync_progress = None;
|
||||||
this.emit(BackendEvent::error(e.to_string()), cx)
|
cx.emit(BackendEvent::error(e.to_string()))
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1364,7 +1320,7 @@ impl Backend {
|
|||||||
/// Callers publish with `client.send_event(...)` directly, then call this
|
/// Callers publish with `client.send_event(...)` directly, then call this
|
||||||
/// so stores like `RepoListStore` refresh without re-querying the relays.
|
/// so stores like `RepoListStore` refresh without re-querying the relays.
|
||||||
pub fn announce_published(&self, event: Event, cx: &mut Context<Self>) {
|
pub fn announce_published(&self, event: Event, cx: &mut Context<Self>) {
|
||||||
self.emit(BackendEvent::Published(Box::new(event)), cx);
|
cx.emit(BackendEvent::Published(Box::new(event)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a NIP-09 deletion for each of `events`, best-effort.
|
/// Publish a NIP-09 deletion for each of `events`, best-effort.
|
||||||
|
|||||||
+229
-124
@@ -16,131 +16,87 @@ const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
|||||||
/// Maximum number of "continue where you left off" activity events kept.
|
/// Maximum number of "continue where you left off" activity events kept.
|
||||||
const ACTIVITY_LIMIT: usize = 50;
|
const ACTIVITY_LIMIT: usize = 50;
|
||||||
|
|
||||||
/// State backing the inbox home screen.
|
/// The user's persisted inbox read state.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Inbox {
|
pub struct Inbox {
|
||||||
/// Notifications grouped by thread root, newest activity first.
|
|
||||||
pub notifications: Arc<Vec<InboxItem>>,
|
|
||||||
/// The user's own recent git activity, newest first.
|
|
||||||
pub activity: Arc<Vec<Event>>,
|
|
||||||
/// Number of non-archived groups with an unread event.
|
|
||||||
pub unread_count: usize,
|
|
||||||
state: InboxReadState,
|
state: InboxReadState,
|
||||||
/// Set once the stored state has been read for the current user.
|
/// Set once the stored state has been read for the current user.
|
||||||
state_loaded: bool,
|
state_loaded: bool,
|
||||||
refresh: RefreshGate,
|
/// Unread notification groups, published by [`InboxStore`] for the sidebar badge.
|
||||||
|
pub unread_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Inbox {
|
impl Inbox {
|
||||||
/// Mark every event in the group rooted at `root` as read.
|
/// The current read/archive cutoffs.
|
||||||
pub fn mark_read(&mut self, root: EventId, cx: &mut Context<Self>) {
|
pub fn state(&self) -> &InboxReadState {
|
||||||
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
&self.state
|
||||||
return;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let Some(events) = self.group_events(root) else {
|
/// Whether the stored state has been read for the current user.
|
||||||
return;
|
pub fn is_loaded(&self) -> bool {
|
||||||
};
|
self.state_loaded
|
||||||
|
}
|
||||||
|
|
||||||
for event in &events {
|
/// Publish the unread count derived by [`InboxStore`].
|
||||||
|
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.mark_read(event);
|
||||||
}
|
}
|
||||||
|
self.state.advance_read(all, me, Timestamp::now());
|
||||||
let all = self.all_notification_events();
|
self.persist(cx);
|
||||||
self.state.advance_read(&all, me, Timestamp::now());
|
cx.notify();
|
||||||
self.after_state_change(cx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Archive the group rooted at `root`. Archived events are always read too.
|
/// Archive one notification group. Archived events are always read too.
|
||||||
pub fn mark_archived(&mut self, root: EventId, cx: &mut Context<Self>) {
|
pub fn mark_archived(
|
||||||
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
&mut self,
|
||||||
return;
|
group: &[Event],
|
||||||
};
|
all: &[Event],
|
||||||
|
me: PublicKey,
|
||||||
let Some(events) = self.group_events(root) else {
|
cx: &mut Context<Self>,
|
||||||
return;
|
) {
|
||||||
};
|
for event in group {
|
||||||
|
|
||||||
for event in &events {
|
|
||||||
self.state.mark_archived(event);
|
self.state.mark_archived(event);
|
||||||
self.state.mark_read(event);
|
self.state.mark_read(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
let all = self.all_notification_events();
|
|
||||||
let now = Timestamp::now();
|
let now = Timestamp::now();
|
||||||
|
self.state.advance_archived(all, me, now);
|
||||||
self.state.advance_archived(&all, me, now);
|
self.state.advance_read(all, me, now);
|
||||||
self.state.advance_read(&all, me, now);
|
self.persist(cx);
|
||||||
self.after_state_change(cx);
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark every known notification read.
|
/// Mark every known notification read.
|
||||||
pub fn mark_all_read(&mut self, cx: &mut Context<Self>) {
|
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context<Self>) {
|
||||||
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
self.state.mark_all_read(all, me, Timestamp::now());
|
||||||
return;
|
self.persist(cx);
|
||||||
};
|
|
||||||
|
|
||||||
let all = self.all_notification_events();
|
|
||||||
self.state.mark_all_read(&all, me, Timestamp::now());
|
|
||||||
self.after_state_change(cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle a backend event that can change the inbox contents.
|
|
||||||
pub(crate) 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Activate the inbox for the backend's current user.
|
|
||||||
pub(crate) fn activate(&mut self, cx: &mut Context<Self>) {
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let Some(me) = backend.read(cx).current_user() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
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();
|
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
self.load_state(me, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Forget everything for the current user.
|
|
||||||
pub(crate) fn reset(&mut self, cx: &mut Context<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;
|
|
||||||
self.refresh = RefreshGate::default();
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the stored state, then run the first refresh.
|
/// Load the stored state for current user.
|
||||||
fn load_state(&mut self, me: PublicKey, cx: &mut Context<Self>) {
|
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) {
|
||||||
let backend = Backend::global(cx);
|
self.state = InboxReadState::default();
|
||||||
let client = backend.read(cx).client();
|
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 });
|
let work = cx.background_spawn(async move { load_state(&client, me).await });
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
@@ -158,7 +114,7 @@ impl Inbox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.state_loaded = true;
|
this.state_loaded = true;
|
||||||
this.refresh_initial(cx);
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok::<(), Error>(())
|
Ok::<(), Error>(())
|
||||||
@@ -166,6 +122,161 @@ impl Inbox {
|
|||||||
.detach();
|
.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives the inbox home screen's notification and activity lists.
|
||||||
|
///
|
||||||
|
/// Created by the inbox panel, so the database work only happens while the
|
||||||
|
/// panel is open. The persisted read state stays in the global [`Inbox`].
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct InboxStore {
|
||||||
|
/// Notifications grouped by thread root, newest activity first.
|
||||||
|
pub notifications: Arc<Vec<InboxItem>>,
|
||||||
|
/// The user's own recent git activity, newest first.
|
||||||
|
pub activity: Arc<Vec<Event>>,
|
||||||
|
/// Number of non-archived groups with an unread event.
|
||||||
|
pub 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InboxStore {
|
||||||
|
/// Create the store and derive the lists from the current global state.
|
||||||
|
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
let weak = cx.entity().downgrade();
|
||||||
|
cx.defer(move |cx| {
|
||||||
|
if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) {
|
||||||
|
log::warn!("inbox store dropped before bootstrap could run: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub 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.
|
/// One-shot initial load, no debounce.
|
||||||
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
||||||
debug_assert!(!self.refresh.debouncing());
|
debug_assert!(!self.refresh.debouncing());
|
||||||
@@ -198,12 +309,13 @@ impl Inbox {
|
|||||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
self.refresh.begin();
|
self.refresh.begin();
|
||||||
|
|
||||||
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
let backend = Backend::global(cx);
|
||||||
|
let Some(me) = backend.read(cx).current_user() else {
|
||||||
self.refresh.abort();
|
self.refresh.abort();
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = Backend::global(cx).read(cx).client();
|
let client = backend.read(cx).client();
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
|
|
||||||
let work = cx.background_spawn(async move {
|
let work = cx.background_spawn(async move {
|
||||||
@@ -259,6 +371,7 @@ impl Inbox {
|
|||||||
this.notifications = Arc::new(notifications);
|
this.notifications = Arc::new(notifications);
|
||||||
this.activity = Arc::new(activity);
|
this.activity = Arc::new(activity);
|
||||||
this.unread_count = unread_count;
|
this.unread_count = unread_count;
|
||||||
|
this.publish_unread_count(cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
this.refresh.finish()
|
this.refresh.finish()
|
||||||
@@ -274,13 +387,6 @@ impl Inbox {
|
|||||||
task.detach();
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Advance the cutoffs, re-derive the groups and persist the state.
|
|
||||||
fn after_state_change(&mut self, cx: &mut Context<Self>) {
|
|
||||||
self.regroup();
|
|
||||||
self.persist(cx);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recompute the unread and archived flags from the current state.
|
/// Recompute the unread and archived flags from the current state.
|
||||||
fn regroup(&mut self) {
|
fn regroup(&mut self) {
|
||||||
let mut items = (*self.notifications).clone();
|
let mut items = (*self.notifications).clone();
|
||||||
@@ -293,23 +399,22 @@ impl Inbox {
|
|||||||
self.notifications = Arc::new(items);
|
self.notifications = Arc::new(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sign the state with a random key and store it locally.
|
/// Publish the derived unread count for the sidebar badge.
|
||||||
fn persist(&mut self, cx: &mut Context<Self>) {
|
fn publish_unread_count(&self, cx: &mut Context<Self>) {
|
||||||
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
let count = self.unread_count;
|
||||||
return;
|
let inbox = Backend::global(cx).read(cx).inbox();
|
||||||
};
|
inbox.update(cx, |inbox, cx| inbox.set_unread_count(count, cx));
|
||||||
|
|
||||||
let client = Backend::global(cx).read(cx).client();
|
|
||||||
let state = self.state.clone();
|
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |_this, _cx| {
|
|
||||||
if let Err(error) = save_state(&client, me, &state).await {
|
|
||||||
log::warn!("failed to save inbox state: {error}");
|
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
task.detach();
|
/// 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`.
|
/// Events of the group rooted at `root`.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ 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};
|
use gpui::{App, AppContext};
|
||||||
pub use inbox::Inbox;
|
pub use inbox::{Inbox, InboxStore};
|
||||||
pub use nostr_sdk::prelude::Timestamp;
|
pub use nostr_sdk::prelude::Timestamp;
|
||||||
pub use profile::{Profile, ProfileStore};
|
pub use profile::{Profile, ProfileStore};
|
||||||
pub use repo::RepoStore;
|
pub use repo::RepoStore;
|
||||||
|
|||||||
@@ -0,0 +1,610 @@
|
|||||||
|
use assets::CustomIconName;
|
||||||
|
use dock::{BasePanel, DockArea, Panel, PanelEvent};
|
||||||
|
use gpui::prelude::*;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
|
||||||
|
Subscription, WeakEntity, Window, div, px,
|
||||||
|
};
|
||||||
|
use gpui_component::input::{Input, InputState};
|
||||||
|
use gpui_component::scroll::ScrollableElement;
|
||||||
|
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
|
||||||
|
use nostr::prelude::{Event, Kind};
|
||||||
|
use signed_core::{Announcement, COVER_NOTE_KIND, InboxItem, RepoAddr, activity_subject};
|
||||||
|
use signed_state::{Backend, BackendEvent, InboxStore, ProfileStore, RepoListStore};
|
||||||
|
use signed_ui::{CountBadge, SegmentButton, UserAvatar};
|
||||||
|
use utils::relative_time;
|
||||||
|
|
||||||
|
use super::open_repo_panel;
|
||||||
|
use super::sidebar::create_repo_dialog;
|
||||||
|
|
||||||
|
/// Notification groups shown before the `Show all` toggle is used.
|
||||||
|
const NOTIFICATION_PREVIEW: usize = 5;
|
||||||
|
|
||||||
|
/// Activity rows shown in `Continue where you left off`.
|
||||||
|
const ACTIVITY_SHOWN: usize = 15;
|
||||||
|
|
||||||
|
pub struct InboxView {
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
dock_area: WeakEntity<DockArea>,
|
||||||
|
/// Derives the notification and activity lists while the panel is open.
|
||||||
|
store: Entity<InboxStore>,
|
||||||
|
/// Search box filtering the `My repositories` column.
|
||||||
|
search: Entity<InputState>,
|
||||||
|
/// Whether the inbox list is expanded past [`NOTIFICATION_PREVIEW`].
|
||||||
|
show_all: bool,
|
||||||
|
_subscriptions: Vec<Subscription>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InboxView {
|
||||||
|
pub fn new(
|
||||||
|
dock_area: WeakEntity<DockArea>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Self {
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let inbox = backend.read(cx).inbox();
|
||||||
|
|
||||||
|
let store = cx.new(InboxStore::new);
|
||||||
|
let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search..."));
|
||||||
|
|
||||||
|
// Drive the store from the global inbox state and from backend events.
|
||||||
|
let _subscriptions = vec![
|
||||||
|
cx.observe(&inbox, |this, _inbox, cx| {
|
||||||
|
this.store.update(cx, |store, cx| store.sync_state(cx));
|
||||||
|
}),
|
||||||
|
cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
|
if matches!(
|
||||||
|
event,
|
||||||
|
BackendEvent::SignerChanged | BackendEvent::SignerRequired
|
||||||
|
) {
|
||||||
|
this.show_all = false;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store
|
||||||
|
.update(cx, |store, cx| store.handle_backend_event(event, cx));
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
Self {
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
dock_area,
|
||||||
|
store,
|
||||||
|
search,
|
||||||
|
show_all: false,
|
||||||
|
_subscriptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark every known notification read.
|
||||||
|
fn mark_all_read(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.store.update(cx, |store, cx| store.mark_all_read(cx));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a repository's detail panel.
|
||||||
|
fn open_repo(
|
||||||
|
&mut self,
|
||||||
|
announcement: &Announcement,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the existing Create Repository dialog.
|
||||||
|
fn open_create_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
create_repo_dialog::open(self.dock_area.clone(), window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display name of the repository at `addr`, from the announcement store.
|
||||||
|
fn repo_name(&self, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bordered card with a header bar and a body.
|
||||||
|
fn section(&self, header: impl IntoElement, body: impl IntoElement, cx: &App) -> AnyElement {
|
||||||
|
v_flex()
|
||||||
|
.w_full()
|
||||||
|
.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,
|
||||||
|
visible: &[&InboxItem],
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
let shown = if self.show_all {
|
||||||
|
visible.len()
|
||||||
|
} else {
|
||||||
|
visible.len().min(NOTIFICATION_PREVIEW)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut body = v_flex().w_full();
|
||||||
|
|
||||||
|
if visible.is_empty() {
|
||||||
|
body = body.child(empty_state(IconName::Inbox, "You're all caught up.", cx));
|
||||||
|
} else {
|
||||||
|
for (ix, item) in visible.iter().take(shown).enumerate() {
|
||||||
|
body = body.child(self.render_notification_row(ix, item, cx));
|
||||||
|
}
|
||||||
|
|
||||||
|
if visible.len() > NOTIFICATION_PREVIEW {
|
||||||
|
let label = if self.show_all {
|
||||||
|
"Show less"
|
||||||
|
} else {
|
||||||
|
"Show all"
|
||||||
|
};
|
||||||
|
body = body.child(div().px_3().py_2().child(
|
||||||
|
SegmentButton::new("show-all", label).on_click(cx.listener(
|
||||||
|
|this, _event, _window, cx| {
|
||||||
|
this.show_all = !this.show_all;
|
||||||
|
cx.notify();
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let header = h_flex()
|
||||||
|
.w_full()
|
||||||
|
.gap_2()
|
||||||
|
.child(Icon::new(IconName::Inbox).small())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.font_semibold()
|
||||||
|
.child(SharedString::from("Inbox")),
|
||||||
|
)
|
||||||
|
.when(unread > 0, |this| this.child(CountBadge::new(unread)))
|
||||||
|
.child(div().flex_1())
|
||||||
|
.child(
|
||||||
|
SegmentButton::new("mark-all-read", "Mark all read")
|
||||||
|
.on_click(cx.listener(|this, _event, _window, cx| this.mark_all_read(cx))),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.section(header, body, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_notification_row(
|
||||||
|
&self,
|
||||||
|
ix: usize,
|
||||||
|
item: &InboxItem,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
let Some(newest) = item.events.first() else {
|
||||||
|
return div().into_any_element();
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = self.repo_name(item.address.as_ref(), cx);
|
||||||
|
let age = relative_time(item.latest_activity());
|
||||||
|
let unread = item.is_unread();
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.id(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),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_activity_panel(&self, activity: &[Event], cx: &mut Context<Self>) -> AnyElement {
|
||||||
|
let mut body = v_flex().w_full();
|
||||||
|
|
||||||
|
if activity.is_empty() {
|
||||||
|
body = body.child(empty_state(
|
||||||
|
CustomIconName::Recent,
|
||||||
|
"No recent activity.",
|
||||||
|
cx,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
for (ix, event) in activity.iter().take(ACTIVITY_SHOWN).enumerate() {
|
||||||
|
body = body.child(self.render_activity_row(ix, event, cx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.section(header, body, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_activity_row(&self, ix: usize, event: &Event, cx: &mut Context<Self>) -> AnyElement {
|
||||||
|
let kind = event.kind;
|
||||||
|
let subject = SharedString::from(activity_subject(event));
|
||||||
|
let repo = self.repo_name(event.tags.coordinates().next().as_ref(), cx);
|
||||||
|
let age = relative_time(event.created_at);
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.id(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()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_repos_panel(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
|
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||||
|
let header = h_flex()
|
||||||
|
.w_full()
|
||||||
|
.gap_2()
|
||||||
|
.child(Icon::new(CustomIconName::GitBranch).small())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.font_semibold()
|
||||||
|
.child(SharedString::from("My repositories")),
|
||||||
|
);
|
||||||
|
let body = v_flex().w_full().child(empty_state(
|
||||||
|
CustomIconName::GitBranch,
|
||||||
|
"Sign in to see your repositories.",
|
||||||
|
cx,
|
||||||
|
));
|
||||||
|
return self.section(header, body, cx);
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = self.search.read(cx).value().trim().to_lowercase();
|
||||||
|
let repos: Vec<Announcement> = RepoListStore::global(cx)
|
||||||
|
.read(cx)
|
||||||
|
.announcements_of(&me)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|announcement| {
|
||||||
|
query.is_empty()
|
||||||
|
|| display_name(announcement).to_lowercase().contains(&query)
|
||||||
|
|| announcement.id.to_lowercase().contains(&query)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut body = v_flex().w_full().child(
|
||||||
|
div().px_3().py_2().child(
|
||||||
|
Input::new(&self.search)
|
||||||
|
.cleanable(true)
|
||||||
|
.w_full()
|
||||||
|
.text_sm()
|
||||||
|
.border_color(cx.theme().muted)
|
||||||
|
.bg(cx.theme().muted)
|
||||||
|
.prefix(Icon::new(IconName::Search).small()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if repos.is_empty() {
|
||||||
|
body = body.child(empty_state(
|
||||||
|
CustomIconName::GitBranch,
|
||||||
|
"No repositories yet.",
|
||||||
|
cx,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
for (ix, announcement) in repos.iter().enumerate() {
|
||||||
|
body = body.child(self.render_repo_row(ix, announcement, cx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let header = h_flex()
|
||||||
|
.w_full()
|
||||||
|
.gap_2()
|
||||||
|
.child(Icon::new(CustomIconName::GitBranch).small())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.font_semibold()
|
||||||
|
.child(SharedString::from("My repositories")),
|
||||||
|
)
|
||||||
|
.when(!repos.is_empty(), |this| {
|
||||||
|
this.child(CountBadge::new(repos.len()))
|
||||||
|
})
|
||||||
|
.child(div().flex_1())
|
||||||
|
.child(
|
||||||
|
SegmentButton::new("new-repo", "New")
|
||||||
|
.icon(Icon::new(CustomIconName::CirclePlus))
|
||||||
|
.primary()
|
||||||
|
.on_click(
|
||||||
|
cx.listener(|this, _event, window, cx| this.open_create_repo(window, cx)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.section(header, body, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_repo_row(
|
||||||
|
&self,
|
||||||
|
ix: usize,
|
||||||
|
announcement: &Announcement,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
let name = display_name(announcement);
|
||||||
|
let description = announcement.description.clone().unwrap_or_default();
|
||||||
|
let activity = RepoListStore::global(cx)
|
||||||
|
.read(cx)
|
||||||
|
.last_activity
|
||||||
|
.get(&announcement.addr())
|
||||||
|
.copied();
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.id(ix)
|
||||||
|
.w_full()
|
||||||
|
.gap_3()
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.items_center()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.hover(|this| this.bg(cx.theme().list_hover))
|
||||||
|
.child(
|
||||||
|
Icon::new(CustomIconName::GitBranch)
|
||||||
|
.small()
|
||||||
|
.text_color(cx.theme().muted_foreground),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.text_ellipsis()
|
||||||
|
.child(name),
|
||||||
|
)
|
||||||
|
.when(!description.is_empty(), |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.text_ellipsis()
|
||||||
|
.child(SharedString::from(description)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.when_some(activity, |this, activity| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(SharedString::from(relative_time(activity))),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.on_click(cx.listener({
|
||||||
|
let announcement = announcement.clone();
|
||||||
|
move |this, _event, window, cx| this.open_repo(&announcement, window, cx)
|
||||||
|
}))
|
||||||
|
.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()
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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, notifications, activity) = {
|
||||||
|
let store = self.store.read(cx);
|
||||||
|
(
|
||||||
|
store.unread_count,
|
||||||
|
store.notifications.clone(),
|
||||||
|
store.activity.clone(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let visible: Vec<&InboxItem> = notifications.iter().filter(|item| !item.archived).collect();
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.image_cache(gpui::retain_all("inbox"))
|
||||||
|
.child(
|
||||||
|
v_flex().size_full().overflow_y_scrollbar().child(
|
||||||
|
h_flex()
|
||||||
|
.items_start()
|
||||||
|
.gap_4()
|
||||||
|
.p_4()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.gap_4()
|
||||||
|
.child(self.render_inbox_panel(unread, &visible, cx))
|
||||||
|
.child(self.render_activity_panel(&activity, cx)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.w(px(300.))
|
||||||
|
.flex_shrink_0()
|
||||||
|
.child(self.render_repos_panel(cx)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
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::open_repo_panel;
|
||||||
pub use repo_list::RepoListView;
|
pub use repo_list::RepoListView;
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ 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;
|
pub(crate) mod create_repo_dialog;
|
||||||
pub(crate) mod grasp_servers;
|
pub(crate) mod grasp_servers;
|
||||||
mod import_dialog;
|
mod import_dialog;
|
||||||
mod onboarding_dialog;
|
mod onboarding_dialog;
|
||||||
@@ -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(), window, 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(
|
||||||
|
|||||||
+193
-125
@@ -6,11 +6,13 @@ Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for
|
|||||||
> page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `<Dashboard />` when
|
> 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.
|
> an account is active, and that home screen is the inbox.
|
||||||
|
|
||||||
> **Status.** Phases 0 and 1 are implemented and green on `feat/inbox`:
|
> **Status.** Phases 0, 1 and 2 are implemented and green on `feat/inbox`:
|
||||||
> `cargo test -p signed_core` (68), `cargo test -p signed_state` (24),
|
> `cargo test -p signed_core` (68), `cargo test -p signed_state` (24),
|
||||||
> `cargo clippy -p signed_state --all-targets` clean, `cargo check --workspace` succeeds.
|
> `cargo test -p workspace` (7), `cargo clippy -p workspace --all-targets` clean,
|
||||||
> Phases 2-5 are not started. This document reflects the implementation as it stands, including
|
> `cargo check --workspace --all-targets` succeeds.
|
||||||
> the Phase 1 refactors (§4.3).
|
> Phases 3-5 are not started. This document reflects the implementation as it stands, including the
|
||||||
|
> Phase 1 refactors and the §4.3 split of the inbox into a thin global `Inbox` and a panel-scoped
|
||||||
|
> `InboxStore`.
|
||||||
|
|
||||||
## 1. What the GitWorkshop home screen is
|
## 1. What the GitWorkshop home screen is
|
||||||
|
|
||||||
@@ -267,12 +269,14 @@ key in the store (see §4.3). An earlier implementation deleted the previous eve
|
|||||||
id across saves; that was removed as more derived state than it was worth.
|
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.
|
`NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs.
|
||||||
|
|
||||||
### 4.3 `signed_state`: `Inbox`, a child entity of `Backend`
|
### 4.3 `signed_state`: a thin global `Inbox` and a panel-scoped `InboxStore`
|
||||||
|
|
||||||
The inbox is not an app-wide global. It is a child `Entity<Inbox>` owned by `Backend`
|
The inbox is split in two, because the expensive derivation is only needed while the home screen is
|
||||||
(`inbox: Entity<Inbox>`), following the project's child-entity pattern
|
open.
|
||||||
(`docs/backend-rearchitecture.md` §11): its observer set (the inbox screen, the sidebar badge) is a
|
|
||||||
strict subset of the backend's, so it is observed independently.
|
**`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
|
```rust
|
||||||
// backend.rs
|
// backend.rs
|
||||||
@@ -282,80 +286,90 @@ pub struct Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// inbox.rs
|
// inbox.rs
|
||||||
|
#[derive(Default)]
|
||||||
pub struct Inbox {
|
pub struct Inbox {
|
||||||
/// Activity directed at the user, grouped by thread root, newest first.
|
|
||||||
pub notifications: Arc<Vec<InboxItem>>,
|
|
||||||
/// The user's own recent git activity, newest first.
|
|
||||||
pub activity: Arc<Vec<Event>>,
|
|
||||||
/// Unread notification count (non-archived).
|
|
||||||
pub unread_count: usize,
|
|
||||||
state: InboxReadState,
|
state: InboxReadState,
|
||||||
/// Set once the stored state has been read for the current user.
|
|
||||||
state_loaded: bool,
|
state_loaded: bool,
|
||||||
refresh: RefreshGate,
|
/// Published by `InboxStore` 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);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`Backend::new` builds it with `cx.new(|_| Inbox::default())`, and callers reach it through
|
**`InboxStore`** is created by `InboxView` and therefore only exists while the panel is open. It
|
||||||
`Backend::global(cx).read(cx).inbox()` or `Backend::inbox()`. All inbox operations (`mark_read`,
|
derives the notification groups and the activity list, coalesces refreshes and applies the read
|
||||||
`mark_archived`, `mark_all_read`, `refresh`) live on `Inbox`.
|
state for rendering.
|
||||||
|
|
||||||
**The store holds no derived state.** The current user is read from `Backend::current_user()` at
|
|
||||||
each use site, repo relays are queried from `RepoListStore` in `Backend::sync_inbox`, and the
|
|
||||||
signing key is random per save rather than cached. This follows the project rule against caching
|
|
||||||
derived state.
|
|
||||||
|
|
||||||
**Lifespan: idle until a signer exists.** `Inbox` is created with the backend but does nothing
|
|
||||||
until the user has a signer. It is never wired from the `desktop` crate, and `signed_state::init`
|
|
||||||
gains no parameters (the `InboxStore::set_global` idea was dropped).
|
|
||||||
|
|
||||||
The dependency is strictly one-way: **`Backend` → `Inbox`**. `Inbox` holds no `Backend` handle, so
|
|
||||||
there is no reference cycle and no `cx.subscribe`. Two mechanisms connect them.
|
|
||||||
|
|
||||||
**`Backend::emit`** is the single funnel for every `BackendEvent`. It updates the inbox on a
|
|
||||||
deferred effect and then emits to the other subscribers:
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
/// Update the inbox, then emit `event` to the other stores.
|
pub struct InboxStore {
|
||||||
fn emit(&self, event: BackendEvent, cx: &mut Context<Self>) {
|
pub notifications: Arc<Vec<InboxItem>>,
|
||||||
let inbox = self.inbox.downgrade();
|
pub activity: Arc<Vec<Event>>,
|
||||||
let inbox_event = event.clone();
|
pub unread_count: usize,
|
||||||
|
state: InboxReadState,
|
||||||
cx.defer(move |cx| {
|
state_loaded: bool,
|
||||||
if let Err(error) = inbox.update(cx, |inbox, cx| {
|
refresh: RefreshGate,
|
||||||
inbox.handle_backend_event(&inbox_event, cx);
|
_subscriptions: Vec<Subscription>,
|
||||||
}) {
|
|
||||||
log::warn!("inbox dropped before handling backend event: {error}");
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
cx.emit(event);
|
impl InboxStore {
|
||||||
|
pub fn new(cx: &mut Context<Self>) -> Self;
|
||||||
|
pub fn refresh(&mut self, cx: &mut Context<Self>);
|
||||||
|
pub fn mark_read(&mut self, root: EventId, cx);
|
||||||
|
pub fn mark_archived(&mut self, root: EventId, cx);
|
||||||
|
pub fn mark_all_read(&mut self, cx);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The `cx.defer` is load-bearing: every emit site runs inside `Backend::update`, and the inbox
|
`InboxStore` has no subscriptions of its own. The panel owns the two subscriptions that carry
|
||||||
handlers read `Backend`, so a synchronous call would re-enter the borrowed entity and panic. All
|
logic: it observes the global `Inbox` (`InboxStore::sync_state`) and subscribes to `Backend`
|
||||||
`cx.emit(...)` sites route through `self.emit(...)`.
|
(`InboxStore::handle_backend_event`, plus resetting `show_all` on a signer change). Re-rendering
|
||||||
|
needs no subscription: GPUI invalidates a window for every entity it read during render, so the
|
||||||
|
panel tracks the store, the search `InputState` and `RepoListStore` just by reading them in
|
||||||
|
`render`. The store never writes derived data back to `Backend` except the unread count.
|
||||||
|
|
||||||
`Inbox::handle_backend_event` reacts to only three shapes:
|
**Lifespan.** `Inbox` is created with the backend but idles until the user has a signer. `InboxStore`
|
||||||
|
is created and dropped with the panel. Neither is wired from the `desktop` crate and
|
||||||
|
`signed_state::init` gains no parameters.
|
||||||
|
|
||||||
- `NostrUpdate(updates)`: refresh when any update kind is in `NOTIFICATION_KINDS`, is
|
**Badge trade-off.** The unread count is derived by the store, so the sidebar badge is only current
|
||||||
`Kind::Comment`, or is a deletion (`EventDeletion` / `RequestToVanish`).
|
after the inbox has been opened once in the session. Keeping it always live would require the
|
||||||
- `Synced` / `Published`: refresh.
|
expensive derivation to run globally, which is exactly what this split avoids.
|
||||||
|
|
||||||
|
The dependency chain is `Backend` → `Inbox` and `InboxView` → `InboxStore`; the store reaches back
|
||||||
|
only to publish the unread count.
|
||||||
|
|
||||||
|
`Backend` no longer funnels its events through the inbox: `InboxStore` 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.
|
||||||
|
|
||||||
|
`InboxStore::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.
|
- everything else: ignored.
|
||||||
|
|
||||||
`BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: `CheckoutsStore`
|
**Signer lifecycle: `Backend::sync_inbox`.** `Backend` owns the wiring and calls `sync_inbox` from the
|
||||||
and `SidebarPanel` consume them. They no longer drive the inbox.
|
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
|
||||||
**Signer lifecycle: `Backend::sync_inbox`.** The inbox does not match `SignerChanged` /
|
`Inbox::reset`. The client is passed into `activate`, so the global inbox never reads `Backend`
|
||||||
`SignerRequired`. `Backend` owns the wiring and calls `sync_inbox` from the three real signer
|
while `sync_inbox` is mid-update:
|
||||||
transitions: `create_identity`, `set_signer` (covers nsec, bunker and passphrase restore) and
|
|
||||||
`logout`. The fetch work that used to live in `Inbox::activate` moved here, because the filters and
|
|
||||||
repo relays need `Backend`'s state:
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
|
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
|
||||||
if let Some(me) = self.current_user {
|
let me = self.current_user;
|
||||||
|
|
||||||
|
if let Some(me) = me {
|
||||||
self.subscribe_bootstrap(filters::notifications(me), cx);
|
self.subscribe_bootstrap(filters::notifications(me), cx);
|
||||||
self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
|
self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
|
||||||
|
|
||||||
@@ -373,38 +387,26 @@ fn sync_inbox(&mut self, cx: &mut Context<Self>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let inbox = self.inbox.downgrade();
|
let client = self.client.clone();
|
||||||
cx.defer(move |cx| {
|
self.inbox.update(cx, |inbox, cx| match me {
|
||||||
let updated = inbox.update(cx, |inbox, cx| {
|
Some(me) => inbox.activate(me, client, cx),
|
||||||
if Backend::global(cx).read(cx).current_user().is_some() {
|
None => inbox.reset(cx),
|
||||||
inbox.activate(cx);
|
|
||||||
} else {
|
|
||||||
inbox.reset(cx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if let Err(error) = updated {
|
|
||||||
log::warn!("inbox dropped before syncing with the signer: {error}");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The repo relays are read from `RepoListStore::global(cx).read(cx).announcements_of(&me)` at call
|
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
|
time and never cached. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind
|
||||||
10002 yet.) The deferred `update` is required because `activate` reads `Backend`, which every
|
10002 yet.) `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` has no `subscribe_remote`
|
||||||
caller is mid-update on. `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` no longer
|
/ `connect_own_repo_relays`.
|
||||||
has `subscribe_remote` / `connect_own_repo_relays`.
|
|
||||||
|
|
||||||
**Activation** (`activate`) clears the user's data, drops any in-flight or pending run belonging to
|
**Activation** clears the state and loads the NIP-78 state from LMDB. `InboxStore` clears its own
|
||||||
the previous user (`self.refresh = RefreshGate::default()`), then loads the NIP-78 state from LMDB
|
lists and in-flight refresh when it sees the unloaded state, then refreshes once it is loaded:
|
||||||
and chains the first refresh once it is loaded:
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub(crate) fn activate(&mut self, cx: &mut Context<Self>) {
|
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) {
|
||||||
let Some(me) = Backend::global(cx).read(cx).current_user() else { return };
|
// state = default; state_loaded = false; unread_count = 0; cx.notify();
|
||||||
// clear notifications, activity, unread_count; state = default; state_loaded = false;
|
// spawn load_state(client, me), then set state and state_loaded = true
|
||||||
// self.refresh = RefreshGate::default();
|
|
||||||
self.load_state(me, cx);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -413,11 +415,11 @@ pub(crate) fn activate(&mut self, cx: &mut Context<Self>) {
|
|||||||
Reading the state event needs no signer at all (the `d` tag carries the identity); activation is
|
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.
|
still gated on the signer because the fetch filters need the user's pubkey.
|
||||||
|
|
||||||
**Fetch** reuses `Backend::subscribe_bootstrap` and `Backend::connect_repo_relays`, as shown in
|
**Fetch** reuses `Backend::subscribe_bootstrap` and `Backend::connect_repo_relays` through
|
||||||
`sync_inbox` above. The query that follows is intentionally the offline-first cache read, not a
|
`Backend::sync_inbox` (see above). The query the store runs is intentionally the offline-first cache
|
||||||
wait on the network; see the note below.
|
read, not a wait on the network; see the note below.
|
||||||
|
|
||||||
**Refresh** (mirrors `RepoListStore::run_refresh`):
|
**Refresh** (`InboxStore::run_refresh`, mirrors `RepoListStore::run_refresh`):
|
||||||
|
|
||||||
- `cx.background_spawn`: query the notification filters and the activity filter from
|
- `cx.background_spawn`: query the notification filters and the activity filter from
|
||||||
`client.database()`.
|
`client.database()`.
|
||||||
@@ -428,21 +430,26 @@ wait on the network; see the note below.
|
|||||||
their `K` tag is a git kind; sort newest first; take the top N.
|
their `K` tag is a git kind; sort newest first; take the top N.
|
||||||
- Cross back to the main thread: guard on `Backend::global(cx).read(cx).current_user() ==
|
- 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
|
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`,
|
previous user's results never land. Then set `notifications`, `activity`, `unread_count`, publish
|
||||||
`cx.notify()`, `refresh.finish()`.
|
the unread count to the global `Inbox`, `cx.notify()`, `refresh.finish()`.
|
||||||
|
|
||||||
|
The store's `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,
|
**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
|
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
|
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
|
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
|
timing: received events are written to LMDB and surfaced as `ClientNotification::Event`, so
|
||||||
`Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the inbox refreshes. This was
|
`Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the store refreshes. This was
|
||||||
reviewed and left as-is.
|
reviewed and left as-is.
|
||||||
|
|
||||||
**Actions**: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each group's events are
|
**Actions**: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()` live on `InboxStore`, which
|
||||||
marked, then the cutoffs are advanced against *all* notification events to bound the id sets. After
|
passes the group and every known notification event to the global `Inbox`. The global marks the
|
||||||
each change the groups are re-derived (`InboxItem::apply_state`) and the state is saved to LMDB,
|
group, advances the cutoffs against *all* notification events to bound the id sets, saves the state
|
||||||
signed with a fresh random key (see 4.2).
|
to LMDB (signed with a fresh random key, see 4.2), and notifies. The store then re-derives and
|
||||||
|
publishes the unread count.
|
||||||
|
|
||||||
**My repositories needs no new store**: `RepoListStore` already holds every announcement and exposes
|
**My repositories needs no new store**: `RepoListStore` already holds every announcement and exposes
|
||||||
`announcements_of(user)`.
|
`announcements_of(user)`.
|
||||||
@@ -457,19 +464,24 @@ signed with a fresh random key (see 4.2).
|
|||||||
### 5.1 `InboxView` center panel
|
### 5.1 `InboxView` center panel
|
||||||
|
|
||||||
New `crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`.
|
New `crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`.
|
||||||
One scrollable two-column flex row.
|
It owns an `Entity<InboxStore>`; GPUI re-renders the panel when the store changes because the panel
|
||||||
|
reads it during render. One `overflow_y_scrollbar` container holding a two-column flex row, left
|
||||||
|
column flexible and right column fixed at 300px. Both columns are bordered cards with a header bar.
|
||||||
|
|
||||||
- **Inbox column**: header with the unread count badge and actions **Unread**, **Archived**,
|
- **Inbox column**: header with the unread count badge and **Mark all read**; then the non-archived
|
||||||
**Mark all read**; then the non-archived notification items (top 5, with a **Show all** toggle
|
notification items (top 5, with a **Show all** toggle expanding inline). Rows show the actor
|
||||||
expanding inline). Rows show the actor avatar, a kind badge, the subject, the repo name, a
|
avatar, a kind icon, the subject, the kind label, the repo name, a relative time, and an unread
|
||||||
relative time, and an unread dot. Empty state: "You're all caught up." with `IconName::Inbox`.
|
dot (the subject is semibold while unread). Empty state: "You're all caught up." with
|
||||||
- **Continue where you left off**: `Inbox::activity`, top 15, each row a kind icon, subject,
|
`IconName::Inbox`.
|
||||||
repo name, and relative time.
|
- **Continue where you left off**: `Inbox::activity`, top 15, each row a kind icon, subject, kind
|
||||||
- **My repositories**: `RepoListStore::announcements_of(me)` with a small search `InputState` (same
|
label, repo name, and relative time.
|
||||||
|
- **My repositories**: `RepoListStore::announcements_of(me)` with a search `InputState` (same
|
||||||
pattern as `RepoListView`) and a **New** button opening the existing `create_repo_dialog`. Rows
|
pattern as `RepoListView`) and a **New** button opening the existing `create_repo_dialog`. Rows
|
||||||
open `open_repo_panel`.
|
open `open_repo_panel`. Empty state "No repositories yet."; without a signer it says
|
||||||
|
"Sign in to see your repositories.".
|
||||||
|
|
||||||
No greeting header.
|
No greeting header. The **Unread** and **Archived** header buttons belong to Phase 3 and are not
|
||||||
|
rendered until `add_bottom_panel` / `InboxFilterView` exist (see 5.2).
|
||||||
|
|
||||||
### 5.2 Unread / Archived as bottom-dock panels
|
### 5.2 Unread / Archived as bottom-dock panels
|
||||||
|
|
||||||
@@ -490,17 +502,19 @@ pub fn add_bottom_panel(
|
|||||||
The workspace already supports a bottom dock and prunes it when empty (`workspace.rs`). Then:
|
The workspace already supports a bottom dock and prunes it when empty (`workspace.rs`). Then:
|
||||||
|
|
||||||
- New `InboxFilterView` panel taking a mode `InboxFilter::Unread | InboxFilter::Archived` and the
|
- New `InboxFilterView` panel taking a mode `InboxFilter::Unread | InboxFilter::Archived` and the
|
||||||
`Entity<Inbox>`. It renders the matching subset of `Inbox::notifications` as a list.
|
`Entity<InboxStore>`. It renders the matching subset of `InboxStore::notifications` as a list.
|
||||||
- The **Unread** and **Archived** header buttons in `InboxView` call `add_bottom_panel` with the
|
- The **Unread** and **Archived** header buttons in `InboxView` (added in Phase 3) call
|
||||||
requested mode. `InboxView` keeps `filter_view: Option<WeakEntity<InboxFilterView>>`; when it
|
`add_bottom_panel` with the requested mode. `InboxView` keeps
|
||||||
already exists, update its mode and focus instead of adding a duplicate.
|
`filter_view: Option<WeakEntity<InboxFilterView>>`; when it already exists, update its mode and
|
||||||
|
focus instead of adding a duplicate. Until then the inbox header has only **Mark all read**.
|
||||||
|
|
||||||
### 5.3 Sidebar
|
### 5.3 Sidebar
|
||||||
|
|
||||||
In `views/sidebar/mod.rs`:
|
In `views/sidebar/mod.rs`:
|
||||||
|
|
||||||
- Add `inbox: Option<WeakEntity<InboxView>>` (mirrors `explore`).
|
- Add `inbox: Option<WeakEntity<InboxView>>` (mirrors `explore`) and `unread: usize`.
|
||||||
- Add `fn open_inbox(&mut self, window, cx)` that focuses the existing panel or adds a center panel.
|
- 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).
|
||||||
- Point the existing nav item at it and add an unread suffix:
|
- Point the existing nav item at it and add an unread suffix:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
@@ -509,7 +523,8 @@ In `views/sidebar/mod.rs`:
|
|||||||
.on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))),
|
.on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))),
|
||||||
```
|
```
|
||||||
|
|
||||||
- `cx.observe` `Backend::global(cx).read(cx).inbox()` so the badge updates.
|
- `cx.observe` `Backend::global(cx).read(cx).inbox()` so the badge follows the count the store
|
||||||
|
publishes.
|
||||||
|
|
||||||
### 5.4 Click-through (P1)
|
### 5.4 Click-through (P1)
|
||||||
|
|
||||||
@@ -551,16 +566,19 @@ patch-root click opens the repo panel. Note as a known limitation.
|
|||||||
| `crates/signed_core/src/inbox.rs` | **new**: `InboxItem`, `notification_root`, `group`, `InboxReadState`, tests |
|
| `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_core/src/lib.rs` | `mod inbox;` and re-exports |
|
||||||
| `crates/signed_state/Cargo.toml` | add `serde_json` |
|
| `crates/signed_state/Cargo.toml` | add `serde_json` |
|
||||||
| `crates/signed_state/src/inbox.rs` | **new**: `Inbox` child entity, NIP-78 load/save, refresh, actions; no `Backend` handle, no `cx.subscribe` |
|
| `crates/signed_state/src/inbox.rs` | thin global `Inbox` (NIP-78 read state) and panel-scoped `InboxStore` (query, grouping, activity, actions) |
|
||||||
| `crates/signed_state/src/backend.rs` | `inbox: Entity<Inbox>` field, construction, `inbox()` accessor, private `emit` funnel, `sync_inbox`, `RepoListStore` import |
|
| `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/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users |
|
||||||
| `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` (no global install) |
|
| `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` (no global install) |
|
||||||
| `crates/dock/src/lib.rs` | `add_bottom_panel` helper |
|
| `crates/dock/src/lib.rs` | `add_bottom_panel` helper |
|
||||||
| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel and `InboxFilterView` |
|
| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel owning `Entity<InboxStore>` (`InboxFilterView` is Phase 3) |
|
||||||
| `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;` |
|
| `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;` |
|
||||||
| `crates/workspace/src/views/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring and badge |
|
| `crates/workspace/src/views/sidebar/mod.rs` | `inbox`/`unread` fields, `open_inbox`, nav wiring and badge, `create_repo_dialog` visibility |
|
||||||
| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `RepoDetailView::open_item` (P1) |
|
| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `RepoDetailView::open_item` (P1) |
|
||||||
|
|
||||||
|
`create_repo_dialog` changes from private (`mod`) to `pub(crate) mod` inside `sidebar`, so the inbox's
|
||||||
|
New button can open it.
|
||||||
|
|
||||||
No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; `Backend::sync_inbox`
|
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.
|
activates the `Inbox` child entity at each signer transition.
|
||||||
|
|
||||||
@@ -577,6 +595,7 @@ activates the `Inbox` child entity at each signer transition.
|
|||||||
exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the
|
exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the
|
||||||
implementation notes below.
|
implementation notes below.
|
||||||
3. **Phase 2 - screen**: `InboxView` (inbox + activity + my repositories), sidebar nav and badge.
|
3. **Phase 2 - screen**: `InboxView` (inbox + activity + my repositories), sidebar nav and badge.
|
||||||
|
**DONE.** See the implementation notes below.
|
||||||
4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived.
|
4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived.
|
||||||
5. **Phase 4 - click-through**: `open_item` and announcement lookup.
|
5. **Phase 4 - click-through**: `open_item` and announcement lookup.
|
||||||
6. **Phase 5 (optional)**: standalone notifications page, NIP-65 relays, pagination, patch detail
|
6. **Phase 5 (optional)**: standalone notifications page, NIP-65 relays, pagination, patch detail
|
||||||
@@ -631,16 +650,65 @@ and two additions to `crates/signed_core/src/inbox.rs`.
|
|||||||
- `cargo test -p signed_core` passes (68 tests), `cargo test -p signed_state` passes (24 tests);
|
- `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.
|
`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`. It owns an `Entity<InboxStore>` and
|
||||||
|
drives it; the sidebar holds a `WeakEntity<InboxView>` so there is no cycle. The panel's `Backend`
|
||||||
|
subscription also resets `show_all` on a signer change. Re-rendering relies on GPUI's render-time
|
||||||
|
entity tracking rather than explicit observations.
|
||||||
|
- The layout is one `overflow_y_scrollbar` container with a two-column `h_flex`. The left column
|
||||||
|
(`flex_1`, `min_w_0`) stacks the inbox card over the activity card; the right column is fixed at
|
||||||
|
300px. Each card is a bordered rounded `v_flex` with a header bar (`section`).
|
||||||
|
- 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 **Mark all read** plus the
|
||||||
|
inline **Show all** toggle, 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.
|
||||||
|
- `create_repo_dialog` became `pub(crate) mod` in `sidebar` so the inbox New button reuses it.
|
||||||
|
- `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.
|
||||||
|
- `InboxStore` is created by `InboxView` and owns the query, grouping, activity list, refresh gate
|
||||||
|
and actions. It holds no subscriptions: the panel observes the global `Inbox` and subscribes to
|
||||||
|
`Backend`, driving the store. Re-renders rely on GPUI's render-time entity tracking.
|
||||||
|
- `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.
|
||||||
|
- `InboxView` observes only its store; the two observations it used to hold moved into the store.
|
||||||
|
- `signed_core` is unchanged. `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-scoped store.
|
||||||
|
|
||||||
## 8. Validation
|
## 8. Validation
|
||||||
|
|
||||||
- `cargo test -p signed_core` (68 tests): root resolution, grouping, read-state cutoff, serde round-trip.
|
- `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` store paths that do not need GPUI (state
|
- `cargo test -p signed_state` (24 tests): the `Inbox` / `InboxStore` paths that do not need GPUI
|
||||||
round-trip, grouping helpers).
|
(state round-trip, grouping helpers).
|
||||||
- `cargo clippy -p signed_state --all-targets` and `cargo check --workspace` after each phase.
|
- `cargo test -p workspace` (7 tests): repository-detail helpers.
|
||||||
- Manual: log in with a repo-owning identity; confirm the inbox panel populates from another
|
- `cargo clippy -p signed_state --all-targets`, `cargo clippy -p workspace --all-targets` and
|
||||||
identity's issue/comment, the activity list shows your own items, the repositories panel matches
|
`cargo check --workspace --all-targets` after each phase.
|
||||||
the sidebar, and that no kind-30078 event is broadcast (watch the relays / `Published` events).
|
- Manual: log in with a repo-owning identity; open the inbox from the sidebar and confirm the panel
|
||||||
Restart to confirm the read state is read back from LMDB.
|
populates from another identity's issue/comment, the activity list shows your own items, the
|
||||||
|
repositories panel matches the sidebar, 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)
|
## 9. SDK APIs used (verified in the pinned `5c669a4` checkout)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user