feat: out-of-box experience #2
Generated
+9
@@ -7826,6 +7826,7 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"signed_core",
|
"signed_core",
|
||||||
"signed_nostr",
|
"signed_nostr",
|
||||||
|
"utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9091,6 +9092,13 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utils"
|
||||||
|
version = "1.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"nostr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.24.0"
|
version = "1.24.0"
|
||||||
@@ -10348,6 +10356,7 @@ dependencies = [
|
|||||||
"gpui-component",
|
"gpui-component",
|
||||||
"signed_core",
|
"signed_core",
|
||||||
"signed_state",
|
"signed_state",
|
||||||
|
"utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ pub struct Announcement {
|
|||||||
pub euc: Option<String>,
|
pub euc: Option<String>,
|
||||||
/// Other recognized maintainers.
|
/// Other recognized maintainers.
|
||||||
pub maintainers: Vec<PublicKey>,
|
pub maintainers: Vec<PublicKey>,
|
||||||
|
/// Hashtags labelling the repository (`t` tags).
|
||||||
|
pub hashtags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Announcement {
|
impl Announcement {
|
||||||
@@ -38,13 +40,20 @@ impl Announcement {
|
|||||||
let mut relays: Vec<String> = Vec::new();
|
let mut relays: Vec<String> = Vec::new();
|
||||||
let mut euc: Option<String> = None;
|
let mut euc: Option<String> = None;
|
||||||
let mut maintainers: Vec<PublicKey> = Vec::new();
|
let mut maintainers: Vec<PublicKey> = Vec::new();
|
||||||
|
let mut hashtags: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for tag in event.tags.iter() {
|
for tag in event.tags.iter() {
|
||||||
// The `d` tag isn't part of the NIP-34 tag codec; parse it directly.
|
// The `d` and `t` tags aren't part of the NIP-34 tag codec; parse them directly.
|
||||||
if tag.kind() == "d" {
|
if tag.kind() == "d" {
|
||||||
id = tag.content().map(str::to_owned);
|
id = tag.content().map(str::to_owned);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if tag.kind() == "t" {
|
||||||
|
if let Some(value) = tag.content() {
|
||||||
|
hashtags.push(value.to_owned());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
match Nip34Tag::parse(tag.as_slice()) {
|
match Nip34Tag::parse(tag.as_slice()) {
|
||||||
Ok(Nip34Tag::Name(value)) => name = Some(value),
|
Ok(Nip34Tag::Name(value)) => name = Some(value),
|
||||||
@@ -73,6 +82,7 @@ impl Announcement {
|
|||||||
relays,
|
relays,
|
||||||
euc,
|
euc,
|
||||||
maintainers,
|
maintainers,
|
||||||
|
hashtags,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +129,8 @@ mod tests {
|
|||||||
&["relays", "wss://relay.example.com"],
|
&["relays", "wss://relay.example.com"],
|
||||||
&["r", "aa231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
&["r", "aa231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
||||||
&["maintainers", MAINTAINER_HEX],
|
&["maintainers", MAINTAINER_HEX],
|
||||||
|
&["t", "rust"],
|
||||||
|
&["t", "nostr"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let announcement = Announcement::from_event(&event).expect("parses");
|
let announcement = Announcement::from_event(&event).expect("parses");
|
||||||
@@ -141,6 +153,7 @@ mod tests {
|
|||||||
announcement.maintainers,
|
announcement.maintainers,
|
||||||
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
|
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
|
||||||
);
|
);
|
||||||
|
assert_eq!(announcement.hashtags, vec!["rust", "nostr"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ publish.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
signed_core = { path = "../signed_core" }
|
signed_core = { path = "../signed_core" }
|
||||||
signed_nostr = { path = "../signed_nostr" }
|
signed_nostr = { path = "../signed_nostr" }
|
||||||
|
utils = { path = "../utils" }
|
||||||
|
|
||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ use std::path::Path;
|
|||||||
|
|
||||||
pub use backend::{Backend, BackendEvent};
|
pub use backend::{Backend, BackendEvent};
|
||||||
use gpui::{App, AppContext, Entity};
|
use gpui::{App, AppContext, Entity};
|
||||||
pub use profile::{Profile, ProfileStore, shorten_pubkey};
|
pub use nostr_sdk::prelude::Timestamp;
|
||||||
|
pub use profile::{Profile, ProfileStore};
|
||||||
|
pub use utils::shorten_pubkey;
|
||||||
pub use repo::RepoStore;
|
pub use repo::RepoStore;
|
||||||
pub use repo_list::RepoListStore;
|
pub use repo_list::RepoListStore;
|
||||||
use signed_nostr::new_backend;
|
use signed_nostr::new_backend;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use anyhow::Error;
|
|||||||
use flume::{Receiver, RecvTimeoutError, Sender};
|
use flume::{Receiver, RecvTimeoutError, Sender};
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Task};
|
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use utils::shorten_pubkey;
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
||||||
|
|
||||||
@@ -59,12 +60,6 @@ impl Profile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form.
|
|
||||||
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
|
|
||||||
let npub = public_key.to_bech32().unwrap();
|
|
||||||
format!("{}...{}", &npub[..(len + 5)], &npub[npub.len() - len..])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Message from the fetch task to the main thread.
|
/// Message from the fetch task to the main thread.
|
||||||
enum Dispatch {
|
enum Dispatch {
|
||||||
/// A batched sync finished; re-read seen profiles from the database.
|
/// A batched sync finished; re-read seen profiles from the database.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::time::Duration;
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use gpui::{AppContext, Context, Subscription, Task};
|
use gpui::{AppContext, Context, Subscription, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use signed_core::{Announcement, RepoAddr, filters};
|
use signed_core::{Announcement, RepoAddr, filters, repo_addr};
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent};
|
use crate::backend::{Backend, BackendEvent};
|
||||||
|
|
||||||
@@ -13,10 +13,16 @@ use crate::backend::{Backend, BackendEvent};
|
|||||||
/// events (e.g. sync progress ticks) collapse into one query.
|
/// events (e.g. sync progress ticks) collapse into one query.
|
||||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
|
/// How far back activity events count toward a repository's last activity.
|
||||||
|
const ACTIVITY_WINDOW: Duration = Duration::from_secs(90 * 86_400);
|
||||||
|
|
||||||
/// Store listing repository announcements (global discovery or per-author).
|
/// Store listing repository announcements (global discovery or per-author).
|
||||||
pub struct RepoListStore {
|
pub struct RepoListStore {
|
||||||
/// Shared so views can clone the list per frame without a deep copy.
|
/// Shared so views can clone the list per frame without a deep copy.
|
||||||
pub announcements: Arc<Vec<Announcement>>,
|
pub announcements: Arc<Vec<Announcement>>,
|
||||||
|
/// Latest known activity timestamp per repository
|
||||||
|
/// (announcements, state updates, patches, PRs, issues, statuses).
|
||||||
|
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
||||||
author: Option<PublicKey>,
|
author: Option<PublicKey>,
|
||||||
refreshing: bool,
|
refreshing: bool,
|
||||||
refresh_dirty: bool,
|
refresh_dirty: bool,
|
||||||
@@ -32,14 +38,22 @@ impl RepoListStore {
|
|||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
|
||||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
let git_kind = Kind::GitRepoAnnouncement;
|
|
||||||
|
|
||||||
let relevant = match event {
|
let relevant = match event {
|
||||||
BackendEvent::NostrUpdate(update) => {
|
BackendEvent::NostrUpdate(update) => {
|
||||||
update.kind == git_kind && this.author.is_none_or(|a| a == update.author)
|
// Activity (patches, issues, ...) is addressed to repos via
|
||||||
|
// `a` tags, so its author isn't the repo owner; always refresh.
|
||||||
|
if filters::ACTIVITY_KINDS.contains(&update.kind) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
let is_announcement = update.kind == Kind::GitRepoAnnouncement;
|
||||||
|
let is_repo_state = update.kind == Kind::RepoState;
|
||||||
|
let tracked = is_announcement || is_repo_state;
|
||||||
|
tracked && this.author.is_none_or(|a| a == update.author)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
BackendEvent::Published(event) => {
|
BackendEvent::Published(event) => {
|
||||||
event.kind == git_kind && this.author.is_none_or(|a| a == event.pubkey)
|
event.kind == Kind::GitRepoAnnouncement
|
||||||
|
&& this.author.is_none_or(|a| a == event.pubkey)
|
||||||
}
|
}
|
||||||
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
|
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
|
||||||
_ => false,
|
_ => false,
|
||||||
@@ -52,6 +66,7 @@ impl RepoListStore {
|
|||||||
|
|
||||||
let mut store = Self {
|
let mut store = Self {
|
||||||
announcements: Arc::new(Vec::new()),
|
announcements: Arc::new(Vec::new()),
|
||||||
|
last_activity: Arc::new(HashMap::new()),
|
||||||
author,
|
author,
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
refresh_dirty: false,
|
refresh_dirty: false,
|
||||||
@@ -151,12 +166,59 @@ impl RepoListStore {
|
|||||||
let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
|
let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
|
||||||
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
|
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
|
||||||
|
|
||||||
Ok::<_, Error>(announcements)
|
// Last activity per repository: state updates plus all NIP-34
|
||||||
|
// activity events (patches, PRs, issues, statuses).
|
||||||
|
let mut last_activity: HashMap<RepoAddr, Timestamp> = announcements
|
||||||
|
.iter()
|
||||||
|
.map(|a| (a.addr(), a.created_at))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let state_filter = Filter::new().kind(Kind::RepoState);
|
||||||
|
for event in client.database().query(state_filter).await? {
|
||||||
|
let Some(id) = event.tags.identifier() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let addr = repo_addr(event.pubkey, id);
|
||||||
|
let Some(entry) = last_activity.get_mut(&addr) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
*entry = (*entry).max(event.created_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bound the activity query to a recent window; older repos fall
|
||||||
|
// back to their announcement / state timestamps.
|
||||||
|
let activity_filter = Filter::new()
|
||||||
|
.kinds(filters::ACTIVITY_KINDS)
|
||||||
|
.since(Timestamp::now() - ACTIVITY_WINDOW);
|
||||||
|
for event in client.database().query(activity_filter).await? {
|
||||||
|
for tag in event.tags.iter() {
|
||||||
|
if tag.kind() != "a" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(content) = tag.content() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(addr) = Coordinate::parse(content) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if addr.kind != Kind::GitRepoAnnouncement {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Skip events for repos we don't list, so the map can't
|
||||||
|
// grow beyond the number of announcements.
|
||||||
|
let Some(entry) = last_activity.get_mut(&addr) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
*entry = (*entry).max(event.created_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok::<_, Error>((announcements, last_activity))
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let announcements = match work.await {
|
let (announcements, last_activity) = match work.await {
|
||||||
Ok(announcements) => announcements,
|
Ok(results) => results,
|
||||||
// Database errors are transient; keep the last list.
|
// Database errors are transient; keep the last list.
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return this.update(cx, |this, _cx| {
|
return this.update(cx, |this, _cx| {
|
||||||
@@ -167,6 +229,7 @@ impl RepoListStore {
|
|||||||
|
|
||||||
let again = this.update(cx, |this, cx| {
|
let again = this.update(cx, |this, cx| {
|
||||||
this.announcements = Arc::new(announcements);
|
this.announcements = Arc::new(announcements);
|
||||||
|
this.last_activity = Arc::new(last_activity);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
this.refreshing = false;
|
this.refreshing = false;
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "utils"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nostr.workspace = true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
mod pubkey;
|
||||||
|
mod time;
|
||||||
|
|
||||||
|
pub use pubkey::shorten_pubkey;
|
||||||
|
pub use time::relative_time;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form.
|
||||||
|
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
|
||||||
|
let npub = public_key.to_bech32().unwrap();
|
||||||
|
format!("{}...{}", &npub[..(len + 5)], &npub[npub.len() - len..])
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
/// Format a timestamp as a short relative time (e.g. "3h ago").
|
||||||
|
pub fn relative_time(timestamp: Timestamp) -> String {
|
||||||
|
let now = Timestamp::now().as_secs();
|
||||||
|
let secs = now.saturating_sub(timestamp.as_secs());
|
||||||
|
|
||||||
|
if secs < 60 {
|
||||||
|
"just now".to_string()
|
||||||
|
} else if secs < 3600 {
|
||||||
|
format!("{}m ago", secs / 60)
|
||||||
|
} else if secs < 86_400 {
|
||||||
|
format!("{}h ago", secs / 3600)
|
||||||
|
} else if secs < 30 * 86_400 {
|
||||||
|
format!("{}d ago", secs / 86_400)
|
||||||
|
} else if secs < 365 * 86_400 {
|
||||||
|
format!("{}mo ago", secs / (30 * 86_400))
|
||||||
|
} else {
|
||||||
|
format!("{}y ago", secs / (365 * 86_400))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_relative_time() {
|
||||||
|
let now = Timestamp::now();
|
||||||
|
|
||||||
|
assert_eq!(relative_time(now), "just now");
|
||||||
|
assert_eq!(relative_time(now - 300), "5m ago");
|
||||||
|
assert_eq!(relative_time(now - 7_200), "2h ago");
|
||||||
|
assert_eq!(relative_time(now - 3 * 86_400), "3d ago");
|
||||||
|
assert_eq!(relative_time(now - 60 * 86_400), "2mo ago");
|
||||||
|
assert_eq!(relative_time(now - 800 * 86_400), "2y ago");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clamps_future_timestamps() {
|
||||||
|
assert_eq!(relative_time(Timestamp::now() + 600), "just now");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ publish.workspace = true
|
|||||||
assets = { path = "../assets" }
|
assets = { path = "../assets" }
|
||||||
signed_core = { path = "../signed_core" }
|
signed_core = { path = "../signed_core" }
|
||||||
signed_state = { path = "../signed_state" }
|
signed_state = { path = "../signed_state" }
|
||||||
|
utils = { path = "../utils" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui-component.workspace = true
|
gpui-component.workspace = true
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ use gpui::{
|
|||||||
use gpui_component::dock::{Panel, PanelEvent};
|
use gpui_component::dock::{Panel, PanelEvent};
|
||||||
use gpui_component::{ActiveTheme, StyledExt};
|
use gpui_component::{ActiveTheme, StyledExt};
|
||||||
use signed_core::Announcement;
|
use signed_core::Announcement;
|
||||||
use signed_state::{ProfileStore, RepoListStore};
|
use signed_state::{ProfileStore, RepoListStore, Timestamp};
|
||||||
|
use utils::relative_time;
|
||||||
|
|
||||||
/// Browse all announced repositories (works anonymously).
|
/// Browse all announced repositories (works anonymously).
|
||||||
pub struct RepoListView {
|
pub struct RepoListView {
|
||||||
@@ -27,7 +28,12 @@ impl RepoListView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_card(&self, announcement: &Announcement, cx: &mut App) -> AnyElement {
|
fn render_card(
|
||||||
|
&self,
|
||||||
|
announcement: &Announcement,
|
||||||
|
last_activity: Option<Timestamp>,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> AnyElement {
|
||||||
let name = announcement
|
let name = announcement
|
||||||
.name
|
.name
|
||||||
.clone()
|
.clone()
|
||||||
@@ -40,9 +46,41 @@ impl RepoListView {
|
|||||||
|
|
||||||
let description = announcement.description.clone().unwrap_or_default();
|
let description = announcement.description.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
let mut hashtags: Vec<AnyElement> = announcement
|
||||||
|
.hashtags
|
||||||
|
.iter()
|
||||||
|
.take(3)
|
||||||
|
.map(|tag| {
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.child(SharedString::from(format!("#{tag}")))
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let remaining = announcement.hashtags.len().saturating_sub(3);
|
||||||
|
|
||||||
|
if remaining > 0 {
|
||||||
|
hashtags.push(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.child(SharedString::from(format!("+{remaining}")))
|
||||||
|
.into_any_element(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let activity = last_activity
|
||||||
|
.map(relative_time)
|
||||||
|
.map(|label| SharedString::from(format!("Updated {label}")))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.v_flex()
|
.v_flex()
|
||||||
.h(px(60.))
|
.h(px(78.))
|
||||||
.w_full()
|
.w_full()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
@@ -78,6 +116,24 @@ impl RepoListView {
|
|||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.child(description),
|
.child(description),
|
||||||
)
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.items_center()
|
||||||
|
.overflow_hidden()
|
||||||
|
.children(hashtags)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.h_flex()
|
||||||
|
.justify_end()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.child(activity),
|
||||||
|
),
|
||||||
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,6 +159,7 @@ impl Focusable for RepoListView {
|
|||||||
impl Render for RepoListView {
|
impl Render for RepoListView {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let announcements = self.store.read(cx).announcements.clone();
|
let announcements = self.store.read(cx).announcements.clone();
|
||||||
|
let last_activity = self.store.read(cx).last_activity.clone();
|
||||||
let has_announcements = !announcements.is_empty();
|
let has_announcements = !announcements.is_empty();
|
||||||
let count = announcements.len();
|
let count = announcements.len();
|
||||||
|
|
||||||
@@ -147,7 +204,9 @@ impl Render for RepoListView {
|
|||||||
let mut items = vec![];
|
let mut items = vec![];
|
||||||
|
|
||||||
for ix in range {
|
for ix in range {
|
||||||
items.push(this.render_card(&announcements[ix], cx));
|
let announcement: &Announcement = &announcements[ix];
|
||||||
|
let activity = last_activity.get(&announcement.addr()).copied();
|
||||||
|
items.push(this.render_card(announcement, activity, cx));
|
||||||
}
|
}
|
||||||
|
|
||||||
items
|
items
|
||||||
|
|||||||
Reference in New Issue
Block a user