From fd382405fd8343e04bc79929fb5cf6918933f934 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 12 Nov 2026 08:55:31 +0700 Subject: [PATCH] add inbox view with notifications panel --- Cargo.lock | 16 +- Cargo.toml | 2 +- crates/assets/src/lib.rs | 2 + crates/signed_app/src/identity/import.rs | 8 +- crates/signed_app/src/identity/onboarding.rs | 8 +- crates/signed_app/src/identity/passphrase.rs | 4 +- crates/signed_app/src/inbox/helpers.rs | 193 ++++++++ crates/signed_app/src/inbox/mod.rs | 467 ++++++++++++++++++ crates/signed_app/src/issues/detail.rs | 3 +- crates/signed_app/src/issues/new_issue.rs | 3 +- crates/signed_app/src/lib.rs | 1 + crates/signed_app/src/pull_requests/detail.rs | 9 +- .../src/pull_requests/dialogs/update.rs | 3 +- crates/signed_app/src/pull_requests/new.rs | 4 +- .../src/pull_requests/send_patch.rs | 16 +- crates/signed_app/src/repo/actions.rs | 61 +++ crates/signed_app/src/repo/dialogs/create.rs | 8 +- crates/signed_app/src/repo/dialogs/init.rs | 7 +- crates/signed_app/src/sidebar/mod.rs | 34 +- 19 files changed, 790 insertions(+), 59 deletions(-) create mode 100644 crates/signed_app/src/inbox/helpers.rs create mode 100644 crates/signed_app/src/inbox/mod.rs diff --git a/Cargo.lock b/Cargo.lock index f24a715..be22fa7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3059,7 +3059,7 @@ dependencies = [ [[package]] name = "gpui-native-core" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781" dependencies = [ "cpal", "flume 0.12.0", @@ -3074,7 +3074,7 @@ dependencies = [ [[package]] name = "gpui-native-data" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781" dependencies = [ "gpui", "gpui-native-core", @@ -3086,7 +3086,7 @@ dependencies = [ [[package]] name = "gpui-native-editor" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781" dependencies = [ "gpui", "gpui-native-core", @@ -3105,7 +3105,7 @@ dependencies = [ [[package]] name = "gpui-native-icons" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781" dependencies = [ "anyhow", "gpui", @@ -3114,7 +3114,7 @@ dependencies = [ [[package]] name = "gpui-native-kit" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781" dependencies = [ "gpui", "gpui-native-core", @@ -3128,7 +3128,7 @@ dependencies = [ [[package]] name = "gpui-native-layout" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781" dependencies = [ "gpui", "gpui-native-core", @@ -3141,7 +3141,7 @@ dependencies = [ [[package]] name = "gpui-native-theme" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781" dependencies = [ "anyhow", "async-channel", @@ -3162,7 +3162,7 @@ dependencies = [ [[package]] name = "gpui-native-utils" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781" dependencies = [ "gpui", "gpui-native-theme", diff --git a/Cargo.toml b/Cargo.toml index 79dde44..cd40028 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ gpui = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503de gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc", features = ["font-kit"] } reqwest_client = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc" } -gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "72118bdd6c6c6a90f17f92f767c6ad342c1516b8", features = ["layout", "editor"] } +gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "ccd8997801186a33d4ed299d5bc9446c0f015781", features = ["layout", "editor"] } settings = { path = "crates/settings" } signed_app = { path = "crates/signed_app" } diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 7462a1c..5dd3587 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -58,6 +58,7 @@ pub enum CustomIconName { ArrowUp, Trash, Globe, + Inbox, } impl IconNamed for CustomIconName { @@ -92,6 +93,7 @@ impl IconNamed for CustomIconName { CustomIconName::ArrowUp => "icons/arrow-up.svg", CustomIconName::Trash => "icons/trash.svg", CustomIconName::Globe => "icons/globe.svg", + CustomIconName::Inbox => "icons/inbox.svg", } .into() } diff --git a/crates/signed_app/src/identity/import.rs b/crates/signed_app/src/identity/import.rs index d2e6663..a02a0b1 100644 --- a/crates/signed_app/src/identity/import.rs +++ b/crates/signed_app/src/identity/import.rs @@ -67,12 +67,8 @@ impl ImportDialog { pub fn new(cx: &mut App) -> Entity { let state = cx.new(|_| ImportState::default()); let secret_input = InputState::new(cx); - - let pass_input = InputState::new(cx); - pass_input.update(cx, |state, cx| state.set_password(true, cx)); - - let repass_input = InputState::new(cx); - repass_input.update(cx, |state, cx| state.set_password(true, cx)); + let pass_input = InputState::builder().password().build(cx); + let repass_input = InputState::builder().password().build(cx); cx.new(|cx| { let mut subscriptions = Vec::new(); diff --git a/crates/signed_app/src/identity/onboarding.rs b/crates/signed_app/src/identity/onboarding.rs index 9b6fccb..236c3fa 100644 --- a/crates/signed_app/src/identity/onboarding.rs +++ b/crates/signed_app/src/identity/onboarding.rs @@ -25,12 +25,8 @@ impl OnboardingDialog { pub fn new(cx: &mut App) -> Entity { let state = cx.new(|_| DialogProgress::default()); let name_input = InputState::new(cx); - - let pass_input = InputState::new(cx); - pass_input.update(cx, |state, cx| state.set_password(true, cx)); - - let repass_input = InputState::new(cx); - repass_input.update(cx, |state, cx| state.set_password(true, cx)); + let pass_input = InputState::builder().password().build(cx); + let repass_input = InputState::builder().password().build(cx); cx.new(|_| Self { name_input, diff --git a/crates/signed_app/src/identity/passphrase.rs b/crates/signed_app/src/identity/passphrase.rs index e9cd270..258a207 100644 --- a/crates/signed_app/src/identity/passphrase.rs +++ b/crates/signed_app/src/identity/passphrase.rs @@ -25,9 +25,7 @@ pub struct PassphraseDialog { impl PassphraseDialog { pub fn new(cx: &mut App) -> Entity { let state = cx.new(|_| DialogProgress::default()); - - let pass_input = InputState::new(cx); - pass_input.update(cx, |state, cx| state.set_password(true, cx)); + let pass_input = InputState::builder().password().build(cx); cx.new(|cx| { let enter_subscription = diff --git a/crates/signed_app/src/inbox/helpers.rs b/crates/signed_app/src/inbox/helpers.rs new file mode 100644 index 0000000..e2a8cc2 --- /dev/null +++ b/crates/signed_app/src/inbox/helpers.rs @@ -0,0 +1,193 @@ +use assets::CustomIconName; +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, Div, Entity, InteractiveElement as _, IntoElement, ParentElement as _, + SharedString, Stateful, Styled as _, div, px, +}; +use gpui_native_kit::avatar::Avatar; +use gpui_native_kit::badge::Badge; +use gpui_native_kit::common::{Sizable as _, h_flex, v_flex}; +use gpui_native_kit::icons::Icon; +use gpui_native_kit::theme::ActiveTheme as _; +use metadata::ProfileDirectory; +use nostr::prelude::{Event, Kind, PublicKey}; +use utils::relative_time; + +pub fn repo_header(name: SharedString, unread: usize) -> AnyElement { + h_flex() + .h_12() + .w_full() + .gap_2() + .child( + div() + .min_w_0() + .text_sm() + .whitespace_nowrap() + .text_ellipsis() + .child(name), + ) + .when(unread > 0, |this| { + this.child(Badge::new("inbox-unread", unread.to_string())) + }) + .into_any_element() +} + +pub fn empty_section_row(cx: &App) -> AnyElement { + h_flex() + .h_12() + .w_full() + .px_3() + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .bg(cx.theme().colors.secondary) + .rounded(cx.theme().radius.control) + .child(SharedString::from("No activity yet.")) + .into_any_element() +} + +#[allow(clippy::too_many_arguments)] +pub fn thread( + prefix: &'static str, + ix: usize, + title: SharedString, + unread: bool, + first: bool, + last: bool, + events: Vec, + me: Option, + directory: &Entity, + cx: &App, +) -> Stateful
{ + let mut timeline = v_flex().gap_2().w_full(); + for event in events { + timeline = timeline.child(sub_activity(&event, me, directory, cx)); + } + + v_flex() + .id((prefix, ix)) + .w_full() + .px_3() + .py_2() + .gap_2() + .bg(cx.theme().colors.secondary) + .when(first, |this| this.rounded_t(cx.theme().radius.control)) + .when(last, |this| this.rounded_b(cx.theme().radius.control)) + .when(!last, |this| { + this.border_b_1().border_color(cx.theme().colors.background) + }) + .hover(|this| this.bg(cx.theme().colors.muted_hover)) + .child( + h_flex() + .gap_2() + .text_sm() + .child( + h_flex() + .size_6() + .flex_shrink_0() + .items_center() + .justify_center() + .child( + Icon::named(CustomIconName::Inbox) + .size(px(14.)) + .color(cx.theme().colors.muted_foreground), + ), + ) + .child( + div() + .min_w_0() + .whitespace_nowrap() + .text_ellipsis() + .child(title), + ) + .child(div().flex_1()) + .when(unread, |this| { + this.child( + div() + .flex_shrink_0() + .size_2() + .rounded_full() + .bg(cx.theme().colors.primary), + ) + }), + ) + .child(timeline) +} + +fn sub_activity( + event: &Event, + me: Option, + directory: &Entity, + cx: &App, +) -> AnyElement { + let profile = directory.read(cx).profile(&event.pubkey, cx); + let name = if Some(event.pubkey) == me { + SharedString::from("You") + } else { + profile.name() + }; + + h_flex() + .w_full() + .gap_2() + .child(div().w_6().flex_shrink_0()) + .child( + h_flex() + .flex_1() + .min_w_0() + .gap_1() + .text_xs() + .child( + Avatar::new(event.pubkey) + .name(name.clone()) + .picture(profile.picture()) + .small(), + ) + .child(name) + .child(SharedString::from(activity_phrase(event.kind))) + .child(div().flex_1()) + .child( + div() + .text_color(cx.theme().colors.muted_foreground) + .child(SharedString::from(relative_time(event.created_at))), + ), + ) + .into_any_element() +} + +fn activity_phrase(kind: Kind) -> &'static str { + match kind { + Kind::GitIssue => "opened an issue", + Kind::GitPullRequest => "opened a PR", + Kind::GitPullRequestUpdate => "updated a PR", + Kind::GitPatch => "created a patch", + Kind::Comment => "commented", + Kind::GitStatusOpen => "opened a status", + Kind::GitStatusApplied => "applied a status", + Kind::GitStatusClosed => "closed a status", + Kind::GitStatusDraft => "drafted a status", + _ => "did something", + } +} + +pub fn empty_state(message: &'static str, cx: &App) -> AnyElement { + v_flex() + .w_full() + .flex_1() + .min_h_0() + .items_center() + .justify_center() + .gap_2() + .py_8() + .child( + Icon::named(CustomIconName::Inbox) + .size(px(24.)) + .color(cx.theme().colors.muted_foreground), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().colors.muted_foreground) + .child(SharedString::from(message)), + ) + .into_any_element() +} diff --git a/crates/signed_app/src/inbox/mod.rs b/crates/signed_app/src/inbox/mod.rs new file mode 100644 index 0000000..5542f2d --- /dev/null +++ b/crates/signed_app/src/inbox/mod.rs @@ -0,0 +1,467 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::Error; +use assets::CustomIconName; +use gpui::{ + AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, + SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task, WeakEntity, + Window, div, +}; +use gpui_native_kit::button::Button; +use gpui_native_kit::common::{h_flex, v_flex}; +use gpui_native_kit::icons::{Icon, IconRole}; +use gpui_native_kit::layout::dock::{DockState, Panel}; +use gpui_native_kit::scroll_area::ScrollArea; +use gpui_native_kit::theme::ActiveTheme as _; +use metadata::ProfileDirectory; +use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp}; +use signed_core::{InboxItem, InboxReadState, RepoAddr}; +use signed_state::{Backend, BackendEvent, Inbox, RefreshGate, RefreshRequest, RepoListStore}; + +mod helpers; + +use helpers::{empty_section_row, empty_state, repo_header, thread}; + +use crate::repo::actions::{RepoItem, open_repo_item}; + +const MAX_SUB_ACTIVITIES: usize = 5; + +struct InboxSection { + address: Option, + unread: usize, + entries: Vec, + latest: Timestamp, +} + +#[derive(Clone, Copy)] +enum InboxRow { + Repo(usize), + Entry(usize, usize), + Empty, +} + +pub struct InboxView { + dock: WeakEntity, + threads: Arc>, + sections: Arc>, + rows: Arc>, + unread_count: usize, + state: InboxReadState, + state_loaded: bool, + refresh: RefreshGate, + tasks: Vec>>, + directory: Entity, + _subscriptions: Vec, +} + +impl InboxView { + pub fn new(dock: WeakEntity, cx: &mut Context) -> Self { + let backend = Backend::global(cx); + let inbox = backend.read(cx).inbox(); + + let repos = RepoListStore::global(cx); + let weak = cx.entity().downgrade(); + + let mut subscriptions = vec![]; + + subscriptions.push(cx.observe(&inbox, |this, _inbox, cx| { + this.sync_state(cx); + })); + + subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| { + this.handle_backend_event(event, cx); + })); + + subscriptions.push(cx.observe(&repos, |this, _repos, cx| { + this.rebuild(cx); + cx.notify(); + })); + + cx.defer(move |cx| { + if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) { + log::warn!("inbox dropped before bootstrap could run: {error}"); + } + }); + + Self { + dock, + threads: Arc::new(Vec::new()), + sections: Arc::new(Vec::new()), + rows: Arc::new(Vec::new()), + unread_count: 0, + state: InboxReadState::default(), + state_loaded: false, + refresh: RefreshGate::default(), + tasks: vec![], + directory: ProfileDirectory::new(cx), + _subscriptions: subscriptions, + } + } + + pub fn mark_all_read(&mut self, cx: &mut Context) { + let Some(me) = Backend::global(cx).read(cx).current_user() else { + return; + }; + let all: Vec = self + .threads + .iter() + .flat_map(|item| item.events.iter().cloned()) + .collect(); + let backend = Backend::global(cx); + let inbox = backend.read(cx).inbox(); + inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx)); + } + + pub fn sync_state(&mut self, cx: &mut Context) { + let backend = Backend::global(cx); + let inbox = backend.read(cx).inbox(); + let (loaded, state) = { + let inbox = inbox.read(cx); + (inbox.is_loaded(), inbox.state().clone()) + }; + + if !loaded { + let was_present = + self.state_loaded || !self.threads.is_empty() || !self.sections.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(cx); + cx.notify(); + } + } + + fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context) { + match event { + BackendEvent::RepoUpdates(_) | BackendEvent::Synced => self.refresh(cx), + _ => {} + } + } + + fn refresh_initial(&mut self, cx: &mut Context) { + if self.refresh.running() { + self.refresh.request(); + return; + } + self.run_refresh(cx); + } + + fn refresh(&mut self, cx: &mut Context) { + if !self.state_loaded || self.refresh.request() != RefreshRequest::Schedule { + return; + } + self.run_refresh(cx); + } + + fn run_refresh(&mut self, cx: &mut Context) { + self.refresh.begin(); + let backend = Backend::global(cx); + let (me, client) = { + let backend = backend.read(cx); + (backend.current_user(), backend.client()) + }; + let Some(me) = me else { + self.refresh.abort(); + return; + }; + let state = self.state.clone(); + let work = cx.background_spawn(async move { Inbox::query(&client, me, &state).await }); + + self.tasks.push(cx.spawn(async move |this, cx| { + let (threads, unread_count) = match work.await { + Ok(results) => results, + Err(error) => { + log::warn!("inbox refresh failed: {error}"); + return this.update(cx, |this, _cx| this.refresh.abort()); + } + }; + + let again = this.update(cx, |this, cx| { + if backend.read(cx).current_user() != Some(me) { + this.refresh.abort(); + return false; + } + this.threads = Arc::new(threads); + this.unread_count = unread_count; + this.rebuild(cx); + cx.notify(); + this.refresh.finish() + })?; + + if again { + this.update(cx, |this, cx| this.refresh(cx))?; + } + Ok(()) + })); + } + + fn regroup(&mut self, cx: &mut Context) { + let mut items = (*self.threads).clone(); + for item in items.iter_mut() { + item.apply_state(&self.state); + } + self.unread_count = items.iter().filter(|item| item.is_unread()).count(); + self.threads = Arc::new(items); + self.rebuild(cx); + } + + fn rebuild(&mut self, cx: &mut Context) { + let backend = Backend::global(cx); + let repo_list = RepoListStore::global(cx); + let mut sections = self.group_sections(); + + if let Some(me) = backend.read(cx).current_user() { + for announcement in repo_list.read(cx).announcements_of(&me) { + let address = announcement.addr(); + let known = sections + .iter() + .any(|section| section.address.as_ref() == Some(&address)); + if !known { + sections.push(InboxSection { + address: Some(address), + unread: 0, + entries: Vec::new(), + latest: Timestamp::default(), + }); + } + } + } + + sections.sort_by_key(|section| std::cmp::Reverse(section.latest)); + let rows = self.flatten_rows(§ions); + self.sections = Arc::new(sections); + self.rows = Arc::new(rows); + + let mut keys: Vec = self + .threads + .iter() + .flat_map(|item| item.events.iter().map(|event| event.pubkey)) + .collect(); + keys.sort_by_key(PublicKey::to_hex); + keys.dedup(); + self.directory + .update(cx, |directory, cx| directory.track(keys, cx)); + } + + fn group_sections(&self) -> Vec { + let mut by_repo: HashMap, InboxSection> = HashMap::new(); + for (ix, item) in self.threads.iter().enumerate() { + if item.archived { + continue; + } + let address = item.address.clone(); + let section = by_repo + .entry(address.clone()) + .or_insert_with(move || InboxSection { + address, + unread: 0, + entries: Vec::new(), + latest: Timestamp::default(), + }); + if item.is_unread() { + section.unread += 1; + } + section.latest = section.latest.max(item.latest_activity()); + section.entries.push(ix); + } + + let mut sections: Vec = by_repo.into_values().collect(); + for section in &mut sections { + section.entries.sort_by(|a, b| { + self.threads[*b] + .latest_activity() + .cmp(&self.threads[*a].latest_activity()) + }); + } + sections.sort_by_key(|section| std::cmp::Reverse(section.latest)); + sections + } + + fn flatten_rows(&self, sections: &[InboxSection]) -> Vec { + let mut rows = Vec::new(); + for (section_ix, section) in sections.iter().enumerate() { + rows.push(InboxRow::Repo(section_ix)); + if section.entries.is_empty() { + rows.push(InboxRow::Empty); + continue; + } + rows.extend( + (0..section.entries.len()).map(|entry_ix| InboxRow::Entry(section_ix, entry_ix)), + ); + } + rows + } + + fn clear(&mut self) { + self.threads = Arc::new(Vec::new()); + self.sections = Arc::new(Vec::new()); + self.rows = Arc::new(Vec::new()); + self.unread_count = 0; + self.state = InboxReadState::default(); + self.state_loaded = false; + self.refresh = RefreshGate::default(); + } + + fn open( + &self, + root: EventId, + kind: Option, + address: Option, + window: &mut Window, + cx: &mut Context, + ) { + let Some(address) = address else { + return; + }; + let item = match kind { + Some(Kind::GitIssue) => RepoItem::Issue(root), + Some(Kind::GitPullRequest) => RepoItem::PullRequest(root), + Some(Kind::GitPatch) => RepoItem::Patch, + _ => return, + }; + open_repo_item(&self.dock, &address, None, item, window, cx); + } + + fn render_entry(&self, ix: usize, cx: &mut Context) -> AnyElement { + let Some(row) = self.rows.get(ix) else { + return div().into_any_element(); + }; + + match *row { + InboxRow::Empty => empty_section_row(cx), + InboxRow::Repo(section_ix) => { + let Some(section) = self.sections.get(section_ix) else { + return div().into_any_element(); + }; + let name = repo_name(section.address.as_ref(), cx); + repo_header(name, section.unread) + } + InboxRow::Entry(section_ix, entry_ix) => { + let Some(section) = self.sections.get(section_ix) else { + return div().into_any_element(); + }; + let Some(&thread_ix) = section.entries.get(entry_ix) else { + return div().into_any_element(); + }; + let Some(item) = self.threads.get(thread_ix) else { + return div().into_any_element(); + }; + + let root = item.root; + let kind = item.root_event.as_ref().map(|event| event.kind); + let address = section.address.clone(); + let first = entry_ix == 0; + let last = entry_ix + 1 == section.entries.len(); + let me = Backend::global(cx).read(cx).current_user(); + + thread( + "inbox-row", + ix, + SharedString::from(item.title()), + item.is_unread(), + first, + last, + item.timeline(MAX_SUB_ACTIVITIES), + me, + &self.directory, + cx, + ) + .on_click(cx.listener(move |this, _ev, window, cx| { + this.open(root, kind, address.clone(), window, cx) + })) + .into_any_element() + } + } + } +} + +fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> SharedString { + let Some(addr) = addr else { + return SharedString::from("Untitled"); + }; + RepoListStore::global(cx) + .read(cx) + .announcements + .iter() + .find(|announcement| announcement.addr() == *addr) + .and_then(|announcement| announcement.name.as_deref().map(SharedString::from)) + .unwrap_or_else(|| SharedString::from("Untitled")) +} + +impl Panel for InboxView { + fn title(&self, _cx: &App) -> Option { + Some(SharedString::from("Inbox")) + } + + fn lead(&self, cx: &App) -> Option { + Some( + Icon::named(CustomIconName::Inbox) + .size(gpui::px(14.)) + .color(cx.theme().colors.muted_foreground) + .into_any_element(), + ) + } +} + +impl Render for InboxView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let rows = self.rows.clone(); + let mut children = Vec::with_capacity(rows.len()); + for ix in 0..rows.len() { + children.push(self.render_entry(ix, cx)); + } + + v_flex() + .size_full() + .child( + h_flex() + .px_4() + .h_12() + .w_full() + .gap_2() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(SharedString::from("Inbox")), + ) + .child(div().flex_1()) + .child( + Button::new("mark-all") + .icon(Icon::named(IconRole::Check)) + .label("Mark all read") + .on_click(cx.listener(|this, _, _, cx| this.mark_all_read(cx))), + ), + ) + .child( + div() + .relative() + .flex_1() + .min_h_0() + .px_4() + .child(if rows.is_empty() { + empty_state("You're all caught up.", cx) + } else { + ScrollArea::new("inbox-scroll") + .flex_1() + .child(v_flex().gap_2().py_2().children(children)) + .into_any_element() + }), + ) + .into_any_element() + } +} diff --git a/crates/signed_app/src/issues/detail.rs b/crates/signed_app/src/issues/detail.rs index dc9edd9..402c707 100644 --- a/crates/signed_app/src/issues/detail.rs +++ b/crates/signed_app/src/issues/detail.rs @@ -30,8 +30,7 @@ pub struct IssueDetailView { impl IssueDetailView { pub fn new(store: Entity, issue_id: EventId, cx: &mut Context) -> Self { - let comment_input = InputState::new(cx); - comment_input.update(cx, |state, cx| state.set_multiline(true, cx)); + let comment_input = InputState::builder().multiline().build(cx); let subscription = cx.observe(&store, |this, _store, cx| { this.track_participants(cx); diff --git a/crates/signed_app/src/issues/new_issue.rs b/crates/signed_app/src/issues/new_issue.rs index 257ef16..d88d274 100644 --- a/crates/signed_app/src/issues/new_issue.rs +++ b/crates/signed_app/src/issues/new_issue.rs @@ -21,8 +21,7 @@ pub struct NewIssueDialog { impl NewIssueDialog { pub fn new(store: Entity, cx: &mut App) -> Entity { let subject = InputState::new(cx); - let content = InputState::new(cx); - content.update(cx, |state, cx| state.set_multiline(true, cx)); + let content = InputState::builder().multiline().build(cx); cx.new(|_| Self { store, diff --git a/crates/signed_app/src/lib.rs b/crates/signed_app/src/lib.rs index 86005c4..bc56ca0 100644 --- a/crates/signed_app/src/lib.rs +++ b/crates/signed_app/src/lib.rs @@ -1,6 +1,7 @@ mod app; mod explore; mod identity; +mod inbox; mod issues; mod pull_requests; mod repo; diff --git a/crates/signed_app/src/pull_requests/detail.rs b/crates/signed_app/src/pull_requests/detail.rs index 8a8e384..901d95c 100644 --- a/crates/signed_app/src/pull_requests/detail.rs +++ b/crates/signed_app/src/pull_requests/detail.rs @@ -76,17 +76,14 @@ impl PullRequestDetailView { cx: &mut Context, ) -> Self { let pane = cx.new(DiffPane::new); - - let comment_input = InputState::new(cx); - comment_input.update(cx, |state, cx| state.set_multiline(true, cx)); + let comment_input = InputState::builder().multiline().build(cx); + let weak = cx.entity().downgrade(); + let repo_name = store.read(cx).name(); let subscription = cx.observe(&store, |this, _store, cx| { this.sync(cx); }); - let weak = cx.entity().downgrade(); - let repo_name = store.read(cx).name(); - cx.defer(move |cx| { let _ = weak.update(cx, |this, cx| this.sync(cx)); }); diff --git a/crates/signed_app/src/pull_requests/dialogs/update.rs b/crates/signed_app/src/pull_requests/dialogs/update.rs index 76b75b4..a93a97a 100644 --- a/crates/signed_app/src/pull_requests/dialogs/update.rs +++ b/crates/signed_app/src/pull_requests/dialogs/update.rs @@ -25,8 +25,7 @@ pub struct UpdatePrDialog { impl UpdatePrDialog { pub fn new(store: Entity, root: Event, cx: &mut App) -> Entity { - let patch = InputState::new(cx); - patch.update(cx, |state, cx| state.set_multiline(true, cx)); + let patch = InputState::builder().multiline().build(cx); cx.new(|_| Self { store, root, patch }) } diff --git a/crates/signed_app/src/pull_requests/new.rs b/crates/signed_app/src/pull_requests/new.rs index ffa5667..0ebe8e7 100644 --- a/crates/signed_app/src/pull_requests/new.rs +++ b/crates/signed_app/src/pull_requests/new.rs @@ -72,9 +72,7 @@ impl NewPullRequestView { let weak = cx.entity().downgrade(); let subject = InputState::new(cx); - - let description = InputState::new(cx); - description.update(cx, |state, cx| state.set_multiline(true, cx)); + let description = InputState::builder().multiline().build(cx); let base_select = cx.new(|cx| { ComboboxState::new( diff --git a/crates/signed_app/src/pull_requests/send_patch.rs b/crates/signed_app/src/pull_requests/send_patch.rs index 79d6319..7b03471 100644 --- a/crates/signed_app/src/pull_requests/send_patch.rs +++ b/crates/signed_app/src/pull_requests/send_patch.rs @@ -36,21 +36,19 @@ impl SendPatchView { _window: &mut Window, cx: &mut Context, ) -> Self { + let subject = InputState::new(cx); + let description = InputState::builder().multiline().build(cx); + let patch = InputState::builder().multiline().build(cx); + let repo_name = store.read(cx).name(); let directory = ProfileDirectory::new(cx); if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) { - directory.update(cx, |directory, cx| directory.track([owner], cx)); + directory.update(cx, |directory, cx| { + directory.track([owner], cx); + }); } - let subject = InputState::new(cx); - - let description = InputState::new(cx); - description.update(cx, |state, cx| state.set_multiline(true, cx)); - - let patch = InputState::new(cx); - patch.update(cx, |state, cx| state.set_multiline(true, cx)); - Self { dock, panel_id, diff --git a/crates/signed_app/src/repo/actions.rs b/crates/signed_app/src/repo/actions.rs index 63951af..af1a827 100644 --- a/crates/signed_app/src/repo/actions.rs +++ b/crates/signed_app/src/repo/actions.rs @@ -1,6 +1,8 @@ use gpui::{App, AppContext as _, SharedString, WeakEntity, Window}; use gpui_native_kit::layout::dock::DockState; +use nostr::prelude::EventId; use signed_core::{Announcement, RepoAddr}; +use signed_state::RepoStore; use crate::repo::RepoDetailView; use crate::shared::panels; @@ -30,3 +32,62 @@ pub fn open_repo_panel( cx, ); } + +/// An item of a repository to open from outside its detail panel. +pub enum RepoItem { + Issue(EventId), + PullRequest(EventId), + Patch, +} + +/// Opens an issue or pull request using a store built here. +pub fn open_repo_item( + dock: &WeakEntity, + addr: &RepoAddr, + hint: Option<&Announcement>, + item: RepoItem, + window: &mut Window, + cx: &mut App, +) { + let store = cx.new(|cx| RepoStore::new(addr.clone(), hint.cloned(), cx)); + match item { + RepoItem::Issue(issue_id) => { + let id = SharedString::from(format!("issue:{issue_id}")); + let store = store.clone(); + panels::open( + dock, + id, + move |_, cx| { + cx.new(|cx| { + crate::issues::detail::IssueDetailView::new(store.clone(), issue_id, cx) + }) + }, + window, + cx, + ); + } + RepoItem::PullRequest(pr_id) => { + let id = SharedString::from(format!("pr:{pr_id}")); + let dock_for_build = dock.clone(); + let store = store.clone(); + panels::open( + dock, + id, + move |window, cx| { + cx.new(|cx| { + crate::pull_requests::PullRequestDetailView::new( + dock_for_build.clone(), + store.clone(), + pr_id, + window, + cx, + ) + }) + }, + window, + cx, + ); + } + RepoItem::Patch => {} + } +} diff --git a/crates/signed_app/src/repo/dialogs/create.rs b/crates/signed_app/src/repo/dialogs/create.rs index 1b674a1..2841dab 100644 --- a/crates/signed_app/src/repo/dialogs/create.rs +++ b/crates/signed_app/src/repo/dialogs/create.rs @@ -46,10 +46,12 @@ impl CreateRepoDialog { let grasp_settings = settings.read(cx).settings().grasp_servers.clone(); let name_input = InputState::new(cx); - let desc_input = InputState::new(cx); - desc_input.update(cx, |state, cx| state.set_multiline(true, cx)); - let folder_input = InputState::new_with_text(default_folder.to_string_lossy(), cx); + let desc_input = InputState::builder().multiline().build(cx); + let folder_input = InputState::builder() + .text(default_folder.to_string_lossy()) + .build(cx); folder_input.update(cx, |state, cx| state.set_read_only(true, cx)); + let relay_input = InputState::new(cx); let state = cx.new(|_| DialogProgress::default()); let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings)); diff --git a/crates/signed_app/src/repo/dialogs/init.rs b/crates/signed_app/src/repo/dialogs/init.rs index 30aa0db..6332241 100644 --- a/crates/signed_app/src/repo/dialogs/init.rs +++ b/crates/signed_app/src/repo/dialogs/init.rs @@ -54,10 +54,11 @@ impl InitRepoDialog { let state = cx.new(|_| DialogProgress::default()); let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings)); - let name_input = InputState::new_with_text(default_name, cx); + + let name_input = InputState::builder().text(default_name).build(cx); name_input.update(cx, |state, cx| state.set_read_only(true, cx)); - let desc_input = InputState::new(cx); - desc_input.update(cx, |state, cx| state.set_multiline(true, cx)); + + let desc_input = InputState::builder().multiline().build(cx); let relay_input = InputState::new(cx); load_user_grasp_servers(grasp_state.clone(), window, cx); diff --git a/crates/signed_app/src/sidebar/mod.rs b/crates/signed_app/src/sidebar/mod.rs index 51226f3..d0aab00 100644 --- a/crates/signed_app/src/sidebar/mod.rs +++ b/crates/signed_app/src/sidebar/mod.rs @@ -17,6 +17,7 @@ use gpui_native_kit::layout::dock::DockState; use gpui_native_kit::menu::{Menu, MenuItem}; use gpui_native_kit::theme::ActiveTheme as _; use gpui_native_kit::tooltip::Tooltip; +use helpers::{local_avatar, local_platform, pick_banner}; use metadata::{Profile, ProfileDirectory}; use nostr::prelude::PublicKey; use signed_core::{Announcement, RepoAddr}; @@ -25,12 +26,12 @@ use signed_state::{ ResolvedLocalRepo, SyncStatusDirectory, }; -mod helpers; - use crate::explore::ExploreView; use crate::identity; +use crate::inbox::InboxView; use crate::repo::dialogs; -use helpers::{local_avatar, local_platform, pick_banner}; + +mod helpers; pub struct SidebarView { weak: WeakEntity, @@ -228,6 +229,11 @@ impl SidebarView { .entries(move |_, cx| { let muted = cx.theme().colors.muted_foreground; let mut entries = vec![ + SidebarEntry::row("inbox", "Inbox").prefix( + Icon::named(CustomIconName::Inbox) + .size(px(14.)) + .color(muted), + ), SidebarEntry::row("browse", "Browse").prefix( Icon::named(CustomIconName::Globe) .size(px(14.)) @@ -340,14 +346,18 @@ impl SidebarView { }; match index { - 0 | 1 => { + 0 => { + self.open_inbox(window, cx); + return; + } + 1 | 2 => { self.open_explore(window, cx); return; } _ => {} } - let Some(row) = index.checked_sub(3) else { + let Some(row) = index.checked_sub(4) else { return; }; @@ -429,9 +439,23 @@ impl SidebarView { ); } + fn open_inbox(&mut self, window: &mut Window, cx: &mut Context) { + let dock = self.dock.clone(); + let dock_for_build = dock.clone(); + + crate::shared::panels::open( + &dock, + "inbox", + move |_, cx| cx.new(|cx| InboxView::new(dock_for_build.clone(), cx)), + window, + cx, + ); + } + fn open_explore(&mut self, window: &mut Window, cx: &mut Context) { let dock = self.dock.clone(); let dock_for_build = dock.clone(); + crate::shared::panels::open( &dock, "explore",