add inbox view with notifications panel
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
This commit is contained in:
@@ -67,12 +67,8 @@ impl ImportDialog {
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
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();
|
||||
|
||||
@@ -25,12 +25,8 @@ impl OnboardingDialog {
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
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,
|
||||
|
||||
@@ -25,9 +25,7 @@ pub struct PassphraseDialog {
|
||||
impl PassphraseDialog {
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
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 =
|
||||
|
||||
@@ -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<Event>,
|
||||
me: Option<PublicKey>,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> Stateful<Div> {
|
||||
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<PublicKey>,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
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()
|
||||
}
|
||||
@@ -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<RepoAddr>,
|
||||
unread: usize,
|
||||
entries: Vec<usize>,
|
||||
latest: Timestamp,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum InboxRow {
|
||||
Repo(usize),
|
||||
Entry(usize, usize),
|
||||
Empty,
|
||||
}
|
||||
|
||||
pub struct InboxView {
|
||||
dock: WeakEntity<DockState>,
|
||||
threads: Arc<Vec<InboxItem>>,
|
||||
sections: Arc<Vec<InboxSection>>,
|
||||
rows: Arc<Vec<InboxRow>>,
|
||||
unread_count: usize,
|
||||
state: InboxReadState,
|
||||
state_loaded: bool,
|
||||
refresh: RefreshGate,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
directory: Entity<ProfileDirectory>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl InboxView {
|
||||
pub fn new(dock: WeakEntity<DockState>, cx: &mut Context<Self>) -> 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<Self>) {
|
||||
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
let all: Vec<Event> = 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<Self>) {
|
||||
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<Self>) {
|
||||
match event {
|
||||
BackendEvent::RepoUpdates(_) | BackendEvent::Synced => self.refresh(cx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
||||
if self.refresh.running() {
|
||||
self.refresh.request();
|
||||
return;
|
||||
}
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.state_loaded || self.refresh.request() != RefreshRequest::Schedule {
|
||||
return;
|
||||
}
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<PublicKey> = 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<InboxSection> {
|
||||
let mut by_repo: HashMap<Option<RepoAddr>, 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<InboxSection> = 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<InboxRow> {
|
||||
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<Kind>,
|
||||
address: Option<RepoAddr>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Self>) -> 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<SharedString> {
|
||||
Some(SharedString::from("Inbox"))
|
||||
}
|
||||
|
||||
fn lead(&self, cx: &App) -> Option<AnyElement> {
|
||||
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<Self>) -> 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()
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,7 @@ pub struct IssueDetailView {
|
||||
|
||||
impl IssueDetailView {
|
||||
pub fn new(store: Entity<RepoStore>, issue_id: EventId, cx: &mut Context<Self>) -> 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);
|
||||
|
||||
@@ -21,8 +21,7 @@ pub struct NewIssueDialog {
|
||||
impl NewIssueDialog {
|
||||
pub fn new(store: Entity<RepoStore>, cx: &mut App) -> Entity<Self> {
|
||||
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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod app;
|
||||
mod explore;
|
||||
mod identity;
|
||||
mod inbox;
|
||||
mod issues;
|
||||
mod pull_requests;
|
||||
mod repo;
|
||||
|
||||
@@ -76,17 +76,14 @@ impl PullRequestDetailView {
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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));
|
||||
});
|
||||
|
||||
@@ -25,8 +25,7 @@ pub struct UpdatePrDialog {
|
||||
|
||||
impl UpdatePrDialog {
|
||||
pub fn new(store: Entity<RepoStore>, root: Event, cx: &mut App) -> Entity<Self> {
|
||||
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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -36,21 +36,19 @@ impl SendPatchView {
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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,
|
||||
|
||||
@@ -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<DockState>,
|
||||
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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Self>,
|
||||
@@ -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<Self>) {
|
||||
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<Self>) {
|
||||
let dock = self.dock.clone();
|
||||
let dock_for_build = dock.clone();
|
||||
|
||||
crate::shared::panels::open(
|
||||
&dock,
|
||||
"explore",
|
||||
|
||||
Reference in New Issue
Block a user