add recents tab

This commit is contained in:
2026-09-20 07:54:25 +07:00
parent 52f92c226f
commit 564382756d
5 changed files with 152 additions and 568 deletions
+18
View File
@@ -46,8 +46,11 @@ setting_accessors! {
pub nip4e: bool,
pub trusted_relays: Vec<String>,
pub file_server: Url,
pub recent_communities: Vec<String>,
}
const RECENT_COMMUNITIES_CAP: usize = 10;
/// Signer kind
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum SignerKind {
@@ -130,6 +133,10 @@ pub struct Settings {
/// Server for blossom media attachments
pub file_server: Url,
/// Recently opened community ids, newest first
#[serde(default)]
pub recent_communities: Vec<String>,
}
impl Default for Settings {
@@ -142,6 +149,7 @@ impl Default for Settings {
nip4e: false,
trusted_relays: vec![],
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
recent_communities: vec![],
}
}
}
@@ -324,4 +332,14 @@ impl AppSettings {
}
});
}
/// Move a community to the front of the recently opened list
pub fn record_recent_community(&mut self, id: String, cx: &mut Context<Self>) {
self.inner.update(cx, |this, cx| {
this.recent_communities.retain(|existing| existing != &id);
this.recent_communities.insert(0, id);
this.recent_communities.truncate(RECENT_COMMUNITIES_CAP);
cx.notify();
});
}
}
-1
View File
@@ -1,4 +1,3 @@
pub mod import;
pub mod restore;
pub mod screening;
pub mod settings;
-553
View File
@@ -1,553 +0,0 @@
use std::collections::HashMap;
use anyhow::Error;
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render,
SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry, shorten_pubkey};
use smallvec::{SmallVec, smallvec};
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
cx.new(|cx| Screening::new(public_key, window, cx))
}
/// Screening
pub struct Screening {
/// Public Key of the person being screened.
public_key: PublicKey,
/// Whether the person's address is verified.
verified: bool,
/// Whether the person is followed by current user.
followed: bool,
/// Last time the person was active.
last_active: Option<Timestamp>,
/// All mutual contacts of the person being screened.
mutual_contacts: Vec<PublicKey>,
/// Async tasks
tasks: SmallVec<[Task<()>; 3]>,
/// Subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl Screening {
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let mut subscriptions = smallvec![];
subscriptions.push(cx.on_release_in(window, move |this, window, cx| {
this.tasks.clear();
window.close_all_modals(cx);
}));
cx.defer_in(window, |this, _window, cx| {
this.check_contact(cx);
this.check_wot(cx);
this.check_last_activity(cx);
this.verify_identifier(cx);
});
Self {
public_key,
verified: false,
followed: false,
last_active: None,
mutual_contacts: vec![],
tasks: smallvec![],
_subscriptions: subscriptions,
}
}
fn check_contact(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.public_key;
let Some(current_user) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
// Check if user is in contact list
let filter = Filter::new()
.author(current_user)
.kind(Kind::ContactList)
.limit(1);
let followed = client
.database()
.query(filter)
.await
.unwrap_or_default()
.into_iter()
.next()
.map(|event| event.tags.public_keys().any(|k| k == public_key))
.unwrap_or(false);
Ok(followed)
});
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await.unwrap_or(false);
this.update(cx, |this, cx| {
this.followed = result;
cx.notify();
})
.ok();
}));
}
fn check_wot(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.public_key;
let Some(current_user) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
// Check mutual contacts
let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key);
let mut mutual_contacts = vec![];
if let Ok(events) = client.database().query(filter).await {
for event in events.into_iter().filter(|ev| ev.pubkey != current_user) {
mutual_contacts.push(event.pubkey);
}
}
Ok(mutual_contacts)
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(contacts) => {
this.update(cx, |this, cx| {
this.mutual_contacts = contacts;
cx.notify();
})
.ok();
}
Err(e) => {
log::error!("Failed to fetch mutual contacts: {}", e);
}
};
}));
}
fn check_last_activity(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.public_key;
let task: Task<Option<Timestamp>> = cx.background_spawn(async move {
let filter = Filter::new().author(public_key).limit(1);
let mut activity: Option<Timestamp> = None;
// Construct target for subscription
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect();
if let Ok(mut stream) = client
.stream_events(target)
.timeout(Duration::from_secs(TIMEOUT))
.await
{
while let Some((_url, event)) = stream.next().await {
if let Ok(event) = event {
activity = Some(event.created_at);
}
}
}
activity
});
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await;
this.update(cx, |this, cx| {
this.last_active = result;
cx.notify();
})
.ok();
}));
}
fn verify_identifier(&mut self, cx: &mut Context<Self>) {
let http_client = cx.http_client();
let public_key = self.public_key;
// Skip if the user doesn't have a NIP-05 identifier
let Some(address) = self.address(cx) else {
return;
};
let task: Task<Result<bool, Error>> =
cx.background_spawn(async move { address.verify(&http_client, &public_key).await });
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await.unwrap_or(false);
this.update(cx, |this, cx| {
this.verified = result;
cx.notify();
})
.ok();
}));
}
fn profile(&self, cx: &Context<Self>) -> Person {
let persons = PersonRegistry::global(cx);
persons.read(cx).get(&self.public_key, cx)
}
fn address(&self, cx: &Context<Self>) -> Option<Nip05Address> {
self.profile(cx)
.metadata()
.nip05
.and_then(|addr| Nip05Address::parse(&addr).ok())
}
fn open_njump(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let Ok(bech32) = self.profile(cx).public_key().to_bech32();
cx.open_url(&format!("https://njump.me/{bech32}"));
}
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let public_key = self.public_key;
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let tag = Tag::from(Nip56Tag::PublicKey {
public_key,
report: Report::Impersonation,
});
let event = EventBuilder::new(Kind::Reporting, "")
.tag(tag)
.finalize_async(&signer)
.await?;
// Send the report to the public relays
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
Ok(())
});
self.tasks.push(cx.spawn_in(window, async move |_, cx| {
if task.await.is_ok() {
cx.update(|window, cx| {
window.close_modal(cx);
window.push_notification("Report submitted successfully", cx);
})
.ok();
}
}));
}
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let contacts = self.mutual_contacts.clone();
window.open_modal(cx, move |this, _window, _cx| {
let contacts = contacts.clone();
let total = contacts.len();
this.title("Mutual contacts").child(
v_flex().gap_1().pb_2().child(
uniform_list("contacts", total, move |range, _window, cx| {
let persons = PersonRegistry::global(cx);
let mut items = Vec::with_capacity(total);
for ix in range {
let Some(contact) = contacts.get(ix) else {
continue;
};
let profile = persons.read(cx).get(contact, cx);
items.push(
h_flex()
.h_11()
.w_full()
.px_2()
.gap_1p5()
.rounded(cx.theme().radius)
.text_sm()
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.small(),
)
.child(profile.name()),
);
}
items
})
.h(px(300.)),
),
)
});
}
}
impl Render for Screening {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const CONTACT: &str = "This person is one of your contacts.";
const NOT_CONTACT: &str = "This person is not one of your contacts.";
const NO_ACTIVITY: &str = "This person hasn't had any activity.";
const RELAY_INFO: &str = "Only checked on public relays; may be inaccurate.";
const NO_MUTUAL: &str = "You don't have any mutual contacts.";
const NIP05_MATCH: &str = "The address matches the user's public key.";
const NIP05_NOT_MATCH: &str = "The address does not match the user's public key.";
const NO_NIP05: &str = "This person has not set up their friendly address";
let profile = self.profile(cx);
let shorten_pubkey = shorten_pubkey(self.public_key, 8);
let last_active = self.last_active.map(|_| true);
let mutuals = self.mutual_contacts.len();
let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals);
v_flex()
.gap_4()
.child(
v_flex()
.gap_3()
.items_center()
.justify_center()
.text_center()
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.large(),
)
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(profile.name()),
),
)
.child(
h_flex()
.gap_3()
.child(
h_flex()
.p_1()
.flex_1()
.h_7()
.justify_center()
.rounded_full()
.bg(cx.theme().elevated_surface_background)
.text_sm()
.truncate()
.text_ellipsis()
.text_center()
.line_height(relative(1.))
.child(shorten_pubkey),
)
.child(
h_flex()
.gap_1()
.child(
Button::new("njump")
.icon(IconName::Link)
.label("njump.me")
.secondary()
.small()
.rounded()
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_njump(window, cx);
})),
)
.child(
Button::new("report")
.tooltip("Report as a scam or impostor")
.icon(IconName::Warning)
.small()
.warning()
.rounded()
.on_click(cx.listener(move |this, _e, window, cx| {
this.report(window, cx);
})),
),
),
)
.child(
v_flex()
.gap_3()
.child(
h_flex()
.items_start()
.gap_2()
.text_sm()
.child(status_badge(Some(self.followed), cx))
.child(
v_flex().text_sm().child("Contact").child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.followed {
SharedString::from(CONTACT)
} else {
SharedString::from(NOT_CONTACT)
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.text_sm()
.child(status_badge(last_active, cx))
.child(
v_flex()
.text_sm()
.child(
h_flex()
.gap_0p5()
.child("Activity on Public Relays")
.child(
Button::new("active")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.tooltip(RELAY_INFO),
),
)
.child(
div()
.w_full()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.map(|this| {
if let Some(t) = self.last_active {
this.child(SharedString::from(format!(
"Last active: {}.",
t.to_human_time()
)))
} else {
this.child(SharedString::from(NO_ACTIVITY))
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.child(status_badge(Some(self.verified), cx))
.child(
v_flex()
.text_sm()
.child({
if let Some(addr) = self.address(cx) {
SharedString::from(format!("{} validation", addr))
} else {
SharedString::from(
"Friendly Address (NIP-05) validation",
)
}
})
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.address(cx).is_some() {
if self.verified {
SharedString::from(NIP05_MATCH)
} else {
SharedString::from(NIP05_NOT_MATCH)
}
} else {
SharedString::from(NO_NIP05)
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.child(status_badge(Some(mutuals > 0), cx))
.child(
h_flex()
.text_sm()
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if mutuals > 0 {
SharedString::from(mutuals_str)
} else {
SharedString::from(NO_MUTUAL)
}
}),
)
.child(
Button::new("mutuals")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.disabled(mutuals == 0)
.on_click(cx.listener(move |this, _, window, cx| {
this.mutual_contacts(window, cx);
})),
),
),
),
)
}
}
fn status_badge(status: Option<bool>, cx: &App) -> Div {
h_flex()
.size_6()
.justify_center()
.flex_shrink_0()
.map(|this| {
if let Some(status) = status {
this.child(Icon::new(IconName::CheckCircle).small().text_color({
if status {
cx.theme().icon_accent
} else {
cx.theme().icon_muted
}
}))
} else {
this.child(Indicator::new().small())
}
})
}
+108 -10
View File
@@ -4,14 +4,15 @@ use std::rc::Rc;
use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry, RoomKind};
use common::TimestampExt;
use community::{CommunityEvent, CommunityRegistry};
use community::{Community, CommunityEvent, CommunityRegistry};
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, Context, ElementId, EventEmitter, FocusHandle, Focusable, InteractiveElement,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
UniformListScrollHandle, Window, div, px, retain_all, uniform_list,
};
use person::PersonRegistry;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use theme::{ActiveTheme, TABBAR_HEIGHT};
@@ -91,6 +92,17 @@ impl Sidebar {
cx.notify();
}
fn open_community(&mut self, community: Entity<Community>, cx: &mut Context<Self>) {
let id = community.read(cx).id().to_hex();
let settings = AppSettings::global(cx);
settings.update(cx, |settings, cx| {
settings.record_recent_community(id, cx);
});
cx.notify();
}
fn render_user(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let current_user = nostr.read(cx).current_user();
@@ -195,11 +207,78 @@ impl Sidebar {
}
}
fn recent_communities(cx: &App) -> Vec<Entity<Community>> {
const LIMIT: usize = 3;
let registry = CommunityRegistry::global(cx);
let communities = registry.read(cx).communities();
let recent = AppSettings::get_recent_communities(cx);
let mut rows: Vec<Entity<Community>> = recent
.iter()
.filter_map(|id| {
communities
.iter()
.find(|community| community.read(cx).id().to_hex() == *id)
})
.take(LIMIT)
.cloned()
.collect();
if rows.is_empty() {
rows = communities.iter().take(LIMIT).cloned().collect();
}
rows
}
fn rows_for(tab: SidebarTab, cx: &App) -> Vec<SidebarRow> {
match tab {
SidebarTab::Recents => vec![SidebarRow::Hint {
text: "Nothing recent yet".into(),
}],
SidebarTab::Recents => {
let chat = ChatRegistry::global(cx);
let rooms = chat.read(cx).rooms(&RoomKind::Ongoing, cx);
let registry = CommunityRegistry::global(cx);
let community_count = registry.read(cx).communities().len();
let communities = recent_communities(cx);
if communities.is_empty() && rooms.is_empty() {
return vec![SidebarRow::Hint {
text: "Nothing recent yet".into(),
}];
}
let mut rows = vec![SidebarRow::Section {
label: "Communities".into(),
count: community_count,
}];
rows.extend(
communities
.into_iter()
.map(|community| SidebarRow::Community { community }),
);
rows.push(SidebarRow::Action {
label: "Show all communities".into(),
tab: SidebarTab::Communities,
});
rows.push(SidebarRow::Section {
label: "Chats".into(),
count: rooms.len(),
});
rows.extend(
rooms
.into_iter()
.take(5)
.map(|room| SidebarRow::Room { room }),
);
rows.push(SidebarRow::Action {
label: "Show all chats".into(),
tab: SidebarTab::Chats,
});
rows
}
SidebarTab::Chats => {
let chat = ChatRegistry::global(cx);
let chat = chat.read(cx);
@@ -288,15 +367,34 @@ fn render_rows(range: Range<usize>, rows: &[SidebarRow], cx: &Context<Sidebar>)
.into_any_element()
}
SidebarRow::Community { community } => {
let community = community.read(cx);
let name = community.read(cx).name();
let seed = community.read(cx).id().to_hex();
let picture = community.read(cx).icon();
let community = community.clone();
TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Community,
community.name(),
name,
)
.avatar(community.id().to_hex())
.picture(community.icon())
.avatar(seed)
.picture(picture)
.on_click(cx.listener(move |this, _event, _window, cx| {
this.open_community(community.clone(), cx);
}))
.into_any_element()
}
SidebarRow::Action { label, tab } => {
let tab = *tab;
TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Action,
label.clone(),
)
.on_click(cx.listener(move |this, _event, _window, cx| {
this.select_tab(tab, cx);
}))
.into_any_element()
}
SidebarRow::Hint { text } => TreeRow::new(
+26 -4
View File
@@ -12,11 +12,26 @@ use theme::ActiveTheme;
use ui::avatar::{Avatar, PixelAvatar};
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex};
use super::tab::SidebarTab;
pub enum SidebarRow {
Section { label: SharedString, count: usize },
Room { room: Entity<Room> },
Community { community: Entity<Community> },
Hint { text: SharedString },
Section {
label: SharedString,
count: usize,
},
Room {
room: Entity<Room>,
},
Community {
community: Entity<Community>,
},
Action {
label: SharedString,
tab: SidebarTab,
},
Hint {
text: SharedString,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -24,6 +39,7 @@ pub enum TreeRowKind {
Section,
Room,
Community,
Action,
Hint,
}
@@ -109,6 +125,7 @@ impl RenderOnce for TreeRow {
let is_section = self.kind == TreeRowKind::Section;
let is_room = self.kind == TreeRowKind::Room;
let is_community = self.kind == TreeRowKind::Community;
let is_action = self.kind == TreeRowKind::Action;
let is_hint = self.kind == TreeRowKind::Hint;
let is_selected = self.selected;
@@ -146,6 +163,11 @@ impl RenderOnce for TreeRow {
.font_semibold()
})
.when(is_room || is_community, |this| this.text_sm())
.when(is_action, |this| {
this.text_sm()
.font_medium()
.text_color(cx.theme().text_accent)
})
.when(is_hint, |this| {
this.text_xs()
.font_normal()