feat: add support for basic web of trust (#65)
Reviewed-on: #65
This commit was merged in pull request #65.
This commit is contained in:
+45
-5
@@ -14,6 +14,7 @@ use gpui::{
|
||||
use instant::Duration;
|
||||
use nip4e::{DeviceEvent, DeviceRegistry};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP};
|
||||
|
||||
@@ -139,13 +140,13 @@ impl ChatRegistry {
|
||||
/// Create a new chat registry instance
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let wot = PersonRegistry::global(cx).read(cx).wot();
|
||||
let entity = cx.entity().downgrade();
|
||||
|
||||
let (tx, rx) = flume::unbounded::<Signal>();
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
let device = DeviceRegistry::global(cx);
|
||||
|
||||
subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
||||
if event.signer_changed() {
|
||||
this.reset(cx);
|
||||
@@ -163,6 +164,10 @@ impl ChatRegistry {
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&wot, |this, _wot, _event, cx| {
|
||||
this.promote_trusted(cx);
|
||||
}));
|
||||
|
||||
cx.defer(move |cx| {
|
||||
let _ = entity.update(cx, |this, cx| {
|
||||
this.get_rooms(cx);
|
||||
@@ -567,7 +572,23 @@ impl ChatRegistry {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Add a new room to the start of list.
|
||||
/// Promote request rooms whose senders are inside the web of trust
|
||||
fn promote_trusted(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let Some(me) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
for room in &self.rooms {
|
||||
room.update(cx, |room, cx| {
|
||||
if room.kind == RoomKind::Request && room.auto_trusted(me, cx) {
|
||||
room.set_ongoing(cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Promote a room to ongoing when its latest message is from the current user
|
||||
pub(crate) fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
|
||||
where
|
||||
I: Into<Room>,
|
||||
@@ -577,7 +598,12 @@ impl ChatRegistry {
|
||||
return;
|
||||
};
|
||||
|
||||
let room: Room = room.into().organize(&public_key);
|
||||
let mut room: Room = room.into().organize(&public_key);
|
||||
|
||||
if room.auto_trusted(public_key, cx) {
|
||||
room.kind = RoomKind::Ongoing;
|
||||
}
|
||||
|
||||
let room_id = room.id;
|
||||
let entity = cx.new(|_| room);
|
||||
entity.update(cx, |room, cx| room.ensure_directory(cx));
|
||||
@@ -652,6 +678,11 @@ impl ChatRegistry {
|
||||
|
||||
/// Extend the registry with new rooms.
|
||||
fn extend_rooms(&mut self, rooms: HashSet<Room>, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let Some(me) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut room_map: HashMap<u64, usize> = self
|
||||
.rooms
|
||||
.iter()
|
||||
@@ -660,6 +691,13 @@ impl ChatRegistry {
|
||||
.collect();
|
||||
|
||||
for new_room in rooms.into_iter() {
|
||||
// Auto-trust also applies to rooms loaded from the database
|
||||
let new_room = if new_room.kind == RoomKind::Request && new_room.auto_trusted(me, cx) {
|
||||
new_room.kind(RoomKind::Ongoing)
|
||||
} else {
|
||||
new_room
|
||||
};
|
||||
|
||||
// Check if we already have a room with this ID
|
||||
if let Some(&index) = room_map.get(&new_room.id) {
|
||||
self.rooms[index].update(cx, |this, cx| {
|
||||
@@ -789,7 +827,9 @@ impl ChatRegistry {
|
||||
match self.room_index.get(&message.room).cloned() {
|
||||
Some(room) => {
|
||||
room.update(cx, |this, cx| {
|
||||
if this.kind == RoomKind::Request && message.rumor.pubkey == public_key {
|
||||
if this.kind == RoomKind::Request
|
||||
&& (message.rumor.pubkey == public_key || this.auto_trusted(public_key, cx))
|
||||
{
|
||||
this.set_ongoing(cx);
|
||||
}
|
||||
this.push_message(message, cx);
|
||||
|
||||
+57
-2
@@ -8,8 +8,8 @@ use instant::Duration;
|
||||
use itertools::Itertools;
|
||||
use nip4e::DeviceRegistry;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Directory, Person};
|
||||
use settings::{RoomConfig, SignerKind};
|
||||
use person::{Directory, Person, PersonRegistry};
|
||||
use settings::{AppSettings, RoomConfig, SignerKind};
|
||||
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
||||
|
||||
use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
|
||||
@@ -183,6 +183,61 @@ impl Room {
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether the auto-trust setting promotes this room out of the requests:
|
||||
/// everyone other than the current user is inside the web of trust
|
||||
pub fn auto_trusted(&self, me: PublicKey, cx: &App) -> bool {
|
||||
if !AppSettings::get_wot_auto_trust(cx) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let wot = PersonRegistry::global(cx).read(cx).wot().read(cx);
|
||||
|
||||
Self::trusted_by(&self.members, me, |key| wot.is_in_network(key))
|
||||
}
|
||||
|
||||
/// Whether the room looks like spam: the web of trust is loaded and
|
||||
/// everyone other than the current user is outside of it
|
||||
pub fn is_spam(&self, cx: &App) -> bool {
|
||||
let person = PersonRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(me) = nostr.read(cx).current_user() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let wot = person.read(cx).wot().read(cx);
|
||||
|
||||
// An unloaded graph cannot classify anything as spam
|
||||
if !wot.is_loaded() {
|
||||
return false;
|
||||
}
|
||||
|
||||
Self::trusted_by(&self.members, me, |key| wot.is_outside(key))
|
||||
}
|
||||
|
||||
/// Whether every member other than `me` passes the check
|
||||
fn trusted_by(
|
||||
members: &[PublicKey],
|
||||
me: PublicKey,
|
||||
check: impl Fn(&PublicKey) -> bool,
|
||||
) -> bool {
|
||||
let mut others = false;
|
||||
|
||||
for key in members {
|
||||
if *key == me {
|
||||
continue;
|
||||
}
|
||||
|
||||
others = true;
|
||||
|
||||
if !check(key) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
others
|
||||
}
|
||||
|
||||
/// Sets this room is ongoing conversation
|
||||
pub(crate) fn set_ongoing(&mut self, cx: &mut Context<Self>) {
|
||||
self.kind = RoomKind::Ongoing;
|
||||
|
||||
@@ -11,9 +11,11 @@ use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
|
||||
|
||||
mod directory;
|
||||
mod person;
|
||||
mod wot;
|
||||
|
||||
pub use directory::*;
|
||||
pub use person::*;
|
||||
pub use wot::*;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
|
||||
@@ -60,6 +62,8 @@ enum Dispatch {
|
||||
pub struct PersonRegistry {
|
||||
/// Public keys with a metadata request in flight
|
||||
in_flight: RwLock<HashSet<PublicKey>>,
|
||||
/// Web of trust graph of the signed-in user
|
||||
wot: Entity<WebOfTrust>,
|
||||
sender: flume::Sender<PublicKey>,
|
||||
tasks: SmallVec<[Task<()>; 4]>,
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
@@ -80,6 +84,7 @@ impl PersonRegistry {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let wot = cx.new(WebOfTrust::new);
|
||||
|
||||
// Channel for communication between nostr and gpui
|
||||
let (tx, rx) = flume::bounded::<Dispatch>(100);
|
||||
@@ -122,17 +127,28 @@ impl PersonRegistry {
|
||||
let subscription = cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
||||
if event.signer_changed() {
|
||||
this.get_emojis(cx);
|
||||
|
||||
this.wot.update(cx, |wot, cx| {
|
||||
wot.reset(cx);
|
||||
wot.hydrate(cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
in_flight: RwLock::new(HashSet::new()),
|
||||
wot,
|
||||
sender: metadata_tx,
|
||||
tasks,
|
||||
_subscriptions: smallvec![subscription],
|
||||
}
|
||||
}
|
||||
|
||||
/// The web of trust graph of the signed-in user
|
||||
pub fn wot(&self) -> Entity<WebOfTrust> {
|
||||
self.wot.clone()
|
||||
}
|
||||
|
||||
/// Handle nostr notifications
|
||||
async fn handle_notifications(client: &Client, tx: &flume::Sender<Dispatch>) {
|
||||
let mut notifications = client.notifications();
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use gpui::{Context, EventEmitter, Task};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
|
||||
/// Disable scoring when the follow set exceeds this size
|
||||
const MAX_FOLLOWS: usize = 2000;
|
||||
/// Cap a followed author's contact list to bound hostile publishers
|
||||
const MAX_TAGS_PER_LIST: usize = 5000;
|
||||
/// Time between reconciliations with the relays
|
||||
const SYNC_INTERVAL: u64 = 15 * 60;
|
||||
/// Longest wait between retries while the graph has no data yet
|
||||
const MAX_RETRY_WAIT: u64 = 60;
|
||||
|
||||
/// The web of trust graph changed
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WoTEvent;
|
||||
|
||||
/// A built graph: the follow set and, per public key, who follows it
|
||||
#[derive(Debug)]
|
||||
struct Snapshot {
|
||||
follows: HashSet<PublicKey>,
|
||||
scores: HashMap<PublicKey, usize>,
|
||||
mutuals: HashMap<PublicKey, Vec<PublicKey>>,
|
||||
disabled: bool,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
/// Build the snapshot from the newest stored contact list of every author
|
||||
fn build(self_key: PublicKey, follows: HashSet<PublicKey>, events: Vec<Event>) -> Self {
|
||||
let disabled = follows.len() > MAX_FOLLOWS;
|
||||
|
||||
let mut snapshot = Self {
|
||||
follows,
|
||||
scores: HashMap::new(),
|
||||
mutuals: HashMap::new(),
|
||||
disabled,
|
||||
};
|
||||
|
||||
if snapshot.disabled {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// Keep the newest contact list per author
|
||||
let mut latest: HashMap<PublicKey, Event> = HashMap::new();
|
||||
|
||||
for event in events {
|
||||
if event.pubkey == self_key || !snapshot.follows.contains(&event.pubkey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match latest.get(&event.pubkey) {
|
||||
Some(current) if current.created_at > event.created_at => continue,
|
||||
_ => {
|
||||
latest.insert(event.pubkey, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (author, event) in latest {
|
||||
for key in event.tags.public_keys().take(MAX_TAGS_PER_LIST) {
|
||||
if key == author || key == self_key {
|
||||
continue;
|
||||
}
|
||||
|
||||
*snapshot.scores.entry(key).or_default() += 1;
|
||||
snapshot.mutuals.entry(key).or_default().push(author);
|
||||
}
|
||||
}
|
||||
|
||||
for contacts in snapshot.mutuals.values_mut() {
|
||||
contacts.sort();
|
||||
}
|
||||
|
||||
snapshot
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<WoTEvent> for WebOfTrust {}
|
||||
|
||||
/// Web of trust
|
||||
///
|
||||
/// Scores public keys by how many of the current user's contacts follow them.
|
||||
#[derive(Debug)]
|
||||
pub struct WebOfTrust {
|
||||
/// Current user's public key
|
||||
self_key: Option<PublicKey>,
|
||||
/// Latest built snapshot, none until the first refresh completes
|
||||
snapshot: Option<Snapshot>,
|
||||
/// In-flight refresh, replaced by newer ones
|
||||
refresh_task: Option<Task<()>>,
|
||||
/// Keeps the periodic reconciliation loop alive
|
||||
_tasks: SmallVec<[Task<()>; 4]>,
|
||||
}
|
||||
|
||||
impl WebOfTrust {
|
||||
/// Create a new web of trust instance
|
||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(cx.spawn(async move |this, cx| {
|
||||
let mut delay = Duration::from_secs(5);
|
||||
|
||||
loop {
|
||||
cx.background_executor().timer(delay).await;
|
||||
|
||||
let mut has_data = false;
|
||||
|
||||
// Ignore errors, the next refresh will retry
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
this.refresh(cx);
|
||||
has_data = this.has_data();
|
||||
});
|
||||
|
||||
delay = if has_data {
|
||||
Duration::from_secs(SYNC_INTERVAL)
|
||||
} else {
|
||||
(delay * 2).min(Duration::from_secs(MAX_RETRY_WAIT))
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
cx.defer(move |cx| {
|
||||
let _ = entity.update(cx, |this, cx| {
|
||||
this.hydrate(cx);
|
||||
});
|
||||
});
|
||||
|
||||
Self {
|
||||
self_key: None,
|
||||
snapshot: None,
|
||||
refresh_task: None,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the graph for the current account
|
||||
pub(crate) fn hydrate(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let Some(self_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.self_key = Some(self_key);
|
||||
self.refresh(cx);
|
||||
}
|
||||
|
||||
/// Clear the graph on sign out or account switch
|
||||
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.self_key = None;
|
||||
self.snapshot = None;
|
||||
self.refresh_task = None;
|
||||
|
||||
cx.emit(WoTEvent);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Reconcile the follow set's contact lists with the relays and rebuild.
|
||||
fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(self_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.refresh_task = Some(cx.spawn(async move |this, cx| {
|
||||
// Scope the reconcile to the follow set known so far
|
||||
let known: HashSet<PublicKey> = stored_contact_list(&client, self_key)
|
||||
.await
|
||||
.map(|event| event.tags.public_keys().take(MAX_FOLLOWS + 1).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut authors: Vec<PublicKey> = known.iter().copied().collect();
|
||||
authors.push(self_key);
|
||||
|
||||
// Negentropy transfers only the lists the relays hold
|
||||
// and the database is missing
|
||||
let filter = Filter::new().kind(Kind::ContactList).authors(authors);
|
||||
if let Err(error) = client.sync(filter).await {
|
||||
log::warn!("Failed to reconcile contact lists: {error}");
|
||||
}
|
||||
|
||||
// The follow set after the reconcile
|
||||
let Some(own) = stored_contact_list(&client, self_key).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let follows: HashSet<PublicKey> =
|
||||
own.tags.public_keys().take(MAX_FOLLOWS + 1).collect();
|
||||
|
||||
let mut authors: Vec<PublicKey> = follows.iter().copied().collect();
|
||||
authors.push(self_key);
|
||||
|
||||
let events: Vec<Event> = if follows.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let filter = Filter::new().kind(Kind::ContactList).authors(authors);
|
||||
client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect()
|
||||
};
|
||||
|
||||
let snapshot = Snapshot::build(self_key, follows.clone(), events);
|
||||
log::debug!(
|
||||
"Web of trust rebuilt: {} follows, {} scored keys",
|
||||
snapshot.follows.len(),
|
||||
snapshot.scores.len()
|
||||
);
|
||||
|
||||
// Newly followed authors were not part of the reconcile
|
||||
let grew = !known.is_superset(&follows);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.snapshot = Some(snapshot);
|
||||
cx.emit(WoTEvent);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
|
||||
if grew {
|
||||
this.update(cx, |this, cx| {
|
||||
this.refresh(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// How many of the current user's contacts follow the public key
|
||||
pub fn score(&self, public_key: &PublicKey) -> usize {
|
||||
self.snapshot.as_ref().map_or(0, |snapshot| {
|
||||
snapshot.scores.get(public_key).copied().unwrap_or(0)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the public key is followed by the user or by any of their contacts
|
||||
pub fn is_in_network(&self, public_key: &PublicKey) -> bool {
|
||||
self.snapshot
|
||||
.as_ref()
|
||||
.is_some_and(|snapshot| snapshot.follows.contains(public_key))
|
||||
|| self.score(public_key) > 0
|
||||
}
|
||||
|
||||
/// Whether the graph is loaded and the public key is outside the network
|
||||
pub fn is_outside(&self, public_key: &PublicKey) -> bool {
|
||||
self.is_loaded() && !self.is_in_network(public_key)
|
||||
}
|
||||
|
||||
/// The current user's contacts who follow the public key
|
||||
pub fn mutuals(&self, public_key: &PublicKey) -> Vec<PublicKey> {
|
||||
self.snapshot.as_ref().map_or_else(Vec::new, |snapshot| {
|
||||
snapshot
|
||||
.mutuals
|
||||
.get(public_key)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the graph is loaded and score queries are meaningful
|
||||
pub fn is_loaded(&self) -> bool {
|
||||
self.snapshot
|
||||
.as_ref()
|
||||
.is_some_and(|snapshot| !snapshot.disabled)
|
||||
}
|
||||
|
||||
/// Whether the snapshot covers a follow set
|
||||
fn has_data(&self) -> bool {
|
||||
self.snapshot
|
||||
.as_ref()
|
||||
.is_some_and(|snapshot| !snapshot.follows.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// The newest stored contact list of the public key
|
||||
async fn stored_contact_list(client: &Client, self_key: PublicKey) -> Option<Event> {
|
||||
let filter = Filter::new()
|
||||
.author(self_key)
|
||||
.kind(Kind::ContactList)
|
||||
.limit(1);
|
||||
|
||||
client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.next()
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub fn init(cx: &mut App) {
|
||||
|
||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
||||
|
||||
/// Built-in image shown behind chat and group panels
|
||||
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
||||
|
||||
@@ -58,6 +59,7 @@ setting_accessors! {
|
||||
pub appearance_mode: AppearanceMode,
|
||||
pub hide_avatar: bool,
|
||||
pub screening: bool,
|
||||
pub wot_auto_trust: bool,
|
||||
pub nip4e: bool,
|
||||
pub hide_group_notice: bool,
|
||||
pub file_server: Url,
|
||||
@@ -125,38 +127,30 @@ impl RoomConfig {
|
||||
pub struct Settings {
|
||||
#[serde(default, alias = "theme_mode")]
|
||||
pub appearance_mode: AppearanceMode,
|
||||
|
||||
/// Hide user avatars
|
||||
pub hide_avatar: bool,
|
||||
|
||||
/// Enable screening for unknown chat requests
|
||||
pub screening: bool,
|
||||
|
||||
/// Promote chat requests from people followed by the user's contacts
|
||||
pub wot_auto_trust: bool,
|
||||
/// Enable decoupling encryption key
|
||||
pub nip4e: bool,
|
||||
|
||||
/// Hide the notice that messages in public groups are not encrypted
|
||||
#[serde(default)]
|
||||
pub hide_group_notice: bool,
|
||||
|
||||
/// Show an image behind chat and group panels
|
||||
#[serde(default)]
|
||||
pub panel_background: bool,
|
||||
|
||||
/// Custom image for the panel background; the built-in one when unset
|
||||
#[serde(default)]
|
||||
pub panel_background_path: Option<PathBuf>,
|
||||
|
||||
/// Trusted relays; Coop will automatically authenticate with these relays
|
||||
pub trusted_relays: Vec<String>,
|
||||
|
||||
/// Server for blossom media attachments
|
||||
pub file_server: Url,
|
||||
|
||||
/// Pinned chat rooms, by conversation ID
|
||||
#[serde(default)]
|
||||
pub pinned_rooms: Vec<u64>,
|
||||
|
||||
/// Pinned groups, by cache tag
|
||||
#[serde(default)]
|
||||
pub pinned_groups: Vec<String>,
|
||||
@@ -168,6 +162,7 @@ impl Default for Settings {
|
||||
appearance_mode: AppearanceMode::default(),
|
||||
hide_avatar: false,
|
||||
screening: true,
|
||||
wot_auto_trust: true,
|
||||
nip4e: false,
|
||||
hide_group_notice: false,
|
||||
panel_background: true,
|
||||
@@ -339,30 +334,3 @@ impl AppSettings {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn json_with(partial: &str) -> Result<Settings, serde_json::Error> {
|
||||
// Every field the persisted format has always required; `partial`
|
||||
// contributes the fields under test.
|
||||
serde_json::from_str(&format!(
|
||||
r#"{{ {} "hide_avatar": false, "screening": true, "nip4e": false,
|
||||
"trusted_relays": [], "file_server": "https://nostr.download/" }}"#,
|
||||
partial
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_theme_mode_key_keeps_its_pinned_choice() {
|
||||
let settings = json_with(r#""theme_mode": "Dark", "#).unwrap();
|
||||
assert_eq!(settings.appearance_mode, AppearanceMode::Dark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_appearance_mode_falls_back_to_dark() {
|
||||
let settings = json_with("").unwrap();
|
||||
assert_eq!(settings.appearance_mode, AppearanceMode::Dark);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-19
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -260,15 +261,9 @@ impl NostrRegistry {
|
||||
let uri = NostrConnectUri::parse(base)?;
|
||||
|
||||
// Construct the nostr connect signer
|
||||
let mut signer = NostrConnect::new(
|
||||
uri,
|
||||
keys,
|
||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||
None,
|
||||
)?;
|
||||
|
||||
// Handle auth url with the default browser
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
let signer = NostrConnect::builder(uri, keys)
|
||||
.auth_url_handler(Arc::new(CoopAuthUrlHandler))
|
||||
.build()?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(signer, cx);
|
||||
@@ -447,16 +442,9 @@ impl NostrRegistry {
|
||||
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
// Construct the nostr connect signer
|
||||
let mut signer = NostrConnect::new(
|
||||
connect_uri,
|
||||
keys,
|
||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||
None,
|
||||
)?;
|
||||
|
||||
// Handle auth url with the default browser
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
let signer = NostrConnect::builder(connect_uri, keys)
|
||||
.auth_url_handler(Arc::new(CoopAuthUrlHandler))
|
||||
.build()?;
|
||||
|
||||
// Verify the bunker responds before persisting the credential
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
@@ -459,9 +459,24 @@ impl Render for ProfileDialog {
|
||||
.filter(|website| !website.trim().is_empty())
|
||||
.map(SharedString::from);
|
||||
|
||||
let score = {
|
||||
let wot = PersonRegistry::global(cx).read(cx).wot().read(cx);
|
||||
wot.is_loaded().then(|| wot.score(&self.public_key))
|
||||
}
|
||||
.map(|score| {
|
||||
SharedString::from(format!(
|
||||
"{} mutual {}",
|
||||
score,
|
||||
if score == 1 { "contact" } else { "contacts" }
|
||||
))
|
||||
});
|
||||
|
||||
this.child(field("Name", name, "Unknown", cx))
|
||||
.child(field("About", about, "No bio", cx))
|
||||
.child(field("Website", website, "No website", cx))
|
||||
.when(!self.is_current_user, |this| {
|
||||
this.child(field("Web of Trust", score, "Not loaded yet", cx))
|
||||
})
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
|
||||
@@ -9,7 +9,7 @@ use gpui::{
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Directory, Person, shorten_pubkey};
|
||||
use person::{Directory, Person, PersonRegistry, shorten_pubkey};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
|
||||
@@ -33,14 +33,19 @@ pub struct Screening {
|
||||
last_active: Option<Timestamp>,
|
||||
/// All mutual contacts of the person being screened.
|
||||
mutual_contacts: Vec<PublicKey>,
|
||||
/// Whether the fallback lookup for mutual contacts has run.
|
||||
mutuals_fallback: bool,
|
||||
/// Person directory for the person and their mutual contacts
|
||||
directory: Entity<Directory>,
|
||||
tasks: SmallVec<[Task<()>; 3]>,
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl Screening {
|
||||
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let wot = PersonRegistry::global(cx).read(cx).wot();
|
||||
let directory = Directory::new(cx);
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.on_release_in(window, move |this, window, cx| {
|
||||
@@ -48,22 +53,28 @@ impl Screening {
|
||||
window.close_all_dialogs(cx);
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&wot, |_this, _wot, _event, cx| {
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
directory.update(cx, |directory, cx| {
|
||||
directory.track([public_key], cx);
|
||||
});
|
||||
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.check_contact(cx);
|
||||
this.check_wot(cx);
|
||||
this.fetch_fallback(cx);
|
||||
this.check_last_activity(cx);
|
||||
this.verify_identifier(cx);
|
||||
});
|
||||
|
||||
let directory = Directory::new(cx);
|
||||
directory.update(cx, |directory, cx| directory.track([public_key], cx));
|
||||
|
||||
Self {
|
||||
public_key,
|
||||
verified: false,
|
||||
followed: false,
|
||||
last_active: None,
|
||||
mutual_contacts: vec![],
|
||||
mutuals_fallback: false,
|
||||
directory,
|
||||
tasks: smallvec![],
|
||||
_subscriptions: subscriptions,
|
||||
@@ -110,42 +121,48 @@ impl Screening {
|
||||
}));
|
||||
}
|
||||
|
||||
fn check_wot(&mut self, cx: &mut Context<Self>) {
|
||||
/// Fetch mutual contacts from the database while the web of trust loads
|
||||
fn fetch_fallback(&mut self, cx: &mut Context<Self>) {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
if self.mutuals_fallback || persons.read(cx).wot().read(cx).is_loaded() {
|
||||
return;
|
||||
}
|
||||
self.mutuals_fallback = true;
|
||||
|
||||
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 task: Task<Vec<PublicKey>> = cx.background_spawn(async move {
|
||||
// Check mutual contacts among the locally stored contact lists
|
||||
let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key);
|
||||
let mut mutual_contacts = vec![];
|
||||
let mut 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);
|
||||
contacts.push(event.pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(mutual_contacts)
|
||||
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();
|
||||
let contacts = task.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
if persons.read(cx).wot().read(cx).is_loaded() {
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch mutual contacts: {}", e);
|
||||
}
|
||||
};
|
||||
this.mutual_contacts = contacts;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -263,8 +280,12 @@ impl Screening {
|
||||
}));
|
||||
}
|
||||
|
||||
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let contacts = self.mutual_contacts.clone();
|
||||
fn mutual_contacts(
|
||||
&mut self,
|
||||
contacts: Vec<PublicKey>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let directory = self.directory.clone();
|
||||
|
||||
window.open_dialog(cx, move |this, _window, _cx| {
|
||||
@@ -317,11 +338,23 @@ impl Render for Screening {
|
||||
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 persons = PersonRegistry::global(cx);
|
||||
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();
|
||||
|
||||
// Pull from the web of trust once loaded, else use the database fallback
|
||||
let mutual_contacts = {
|
||||
let wot = persons.read(cx).wot().read(cx);
|
||||
if wot.is_loaded() {
|
||||
wot.mutuals(&self.public_key)
|
||||
} else {
|
||||
self.mutual_contacts.clone()
|
||||
}
|
||||
};
|
||||
|
||||
let mutuals = mutual_contacts.len();
|
||||
let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals);
|
||||
|
||||
v_flex()
|
||||
@@ -365,7 +398,7 @@ impl Render for Screening {
|
||||
Button::new("njump")
|
||||
.icon(IconName::Link)
|
||||
.label("njump.me")
|
||||
.secondary()
|
||||
.ghost_alt()
|
||||
.small()
|
||||
.rounded()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
@@ -376,7 +409,6 @@ impl Render for Screening {
|
||||
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| {
|
||||
@@ -515,7 +547,11 @@ impl Render for Screening {
|
||||
.rounded()
|
||||
.disabled(mutuals == 0)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.mutual_contacts(window, cx);
|
||||
this.mutual_contacts(
|
||||
mutual_contacts.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -83,12 +83,14 @@ impl Preferences {
|
||||
impl Render for Preferences {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
|
||||
const WOT: &str = "Treat people followed by your contacts as known senders.";
|
||||
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
|
||||
const MODE: &str = "Follow the OS, or always use light or dark.";
|
||||
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
|
||||
const BACKGROUND: &str = "Show an image behind chat and group panels.";
|
||||
|
||||
let screening = AppSettings::get_screening(cx);
|
||||
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
let nip4e = AppSettings::get_nip4e(cx);
|
||||
let appearance_mode = AppSettings::get_appearance_mode(cx);
|
||||
@@ -116,6 +118,15 @@ impl Render for Preferences {
|
||||
AppSettings::update_screening(!screening, cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Switch::new("wot-auto-trust")
|
||||
.label("Web of Trust")
|
||||
.description(WOT)
|
||||
.checked(wot_auto_trust)
|
||||
.on_click(move |_, _window, cx| {
|
||||
AppSettings::update_wot_auto_trust(!wot_auto_trust, cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Switch::new("avatar")
|
||||
.label("Hide user avatar")
|
||||
|
||||
@@ -10,6 +10,7 @@ use gpui::{
|
||||
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, px, svg,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{NostrRegistry, StateEvent};
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
|
||||
@@ -57,13 +58,14 @@ pub struct GreeterPanel {
|
||||
/// The coalesced reload scheduled after registry updates
|
||||
refresh_task: Option<Task<Result<(), Error>>>,
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
_subscriptions: SmallVec<[Subscription; 3]>,
|
||||
}
|
||||
|
||||
impl GreeterPanel {
|
||||
fn new(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let wot = PersonRegistry::global(cx).read(cx).wot();
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
@@ -76,6 +78,13 @@ impl GreeterPanel {
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Redraw when the web of trust loads or changes
|
||||
cx.subscribe(&wot, |_, _, _, cx| {
|
||||
cx.notify();
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Reload soon after rooms load, messages arrive, or requests change
|
||||
cx.observe(&chat, |this, _, cx| {
|
||||
@@ -176,120 +185,137 @@ impl GreeterPanel {
|
||||
}));
|
||||
}
|
||||
|
||||
fn render_requests(&mut self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
if self.requests.is_empty() {
|
||||
return None;
|
||||
}
|
||||
fn render_requests(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
||||
// Spam requests stay out of the greeter entirely
|
||||
let requests: Vec<_> = self
|
||||
.requests
|
||||
.iter()
|
||||
.filter(|(room, _)| !room.read(cx).is_spam(cx))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let dock = self.dock.clone();
|
||||
|
||||
let rows: Vec<AnyElement> = self
|
||||
.requests
|
||||
.iter()
|
||||
.take(SECTION_SIZE)
|
||||
.enumerate()
|
||||
.map(|(index, (room, preview))| {
|
||||
let room = room.clone();
|
||||
let dock = dock.clone();
|
||||
let name = room.read(cx).display_name(cx);
|
||||
let picture = room.read(cx).display_image(cx);
|
||||
let seed = room.read(cx).display_image_seed(cx);
|
||||
let created_at = room.read(cx).created_at.to_ago();
|
||||
let preview = preview.clone();
|
||||
|
||||
let rows: Vec<AnyElement> = if requests.is_empty() {
|
||||
vec![
|
||||
h_flex()
|
||||
.id(format!("request-{index}"))
|
||||
.group("")
|
||||
.h_16()
|
||||
.w_full()
|
||||
.h_9()
|
||||
.px_1p5()
|
||||
.gap_2()
|
||||
.justify_center()
|
||||
.border_1()
|
||||
.border_dashed()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(px(button_radius()))
|
||||
.cursor_pointer()
|
||||
.hover(|this| this.bg(cx.theme().element_hover))
|
||||
.on_click({
|
||||
let room = room.clone();
|
||||
let dock = dock.clone();
|
||||
move |_, window, cx| {
|
||||
open_room(&room, &dock, window, cx);
|
||||
}
|
||||
})
|
||||
.child(
|
||||
Avatar::from_source(picture)
|
||||
.seed(seed)
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(name),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.italic()
|
||||
.child(preview),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.flex_shrink_0()
|
||||
.h_6()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.top_0()
|
||||
.bottom_0()
|
||||
.group_hover("", |this| this.invisible())
|
||||
.child(
|
||||
div()
|
||||
.text_align(gpui::TextAlign::Right)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(created_at),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new(format!("response-{index}"))
|
||||
.label("Response")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.on_click({
|
||||
let room = room.clone();
|
||||
let dock = dock.clone();
|
||||
move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
open_room(&room, &dock, window, cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect();
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child("No chat requests")
|
||||
.into_any_element(),
|
||||
]
|
||||
} else {
|
||||
requests
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (room, preview))| {
|
||||
let room = room.clone();
|
||||
let dock = dock.clone();
|
||||
let name = room.read(cx).display_name(cx);
|
||||
let picture = room.read(cx).display_image(cx);
|
||||
let seed = room.read(cx).display_image_seed(cx);
|
||||
let created_at = room.read(cx).created_at.to_ago();
|
||||
let preview = preview.clone();
|
||||
|
||||
Some(
|
||||
GroupBox::new()
|
||||
.id("requests")
|
||||
.title("Requests")
|
||||
.fill()
|
||||
.child(v_flex().gap_0().w_full().children(rows))
|
||||
.into_any_element(),
|
||||
)
|
||||
h_flex()
|
||||
.id(format!("request-{index}"))
|
||||
.group("")
|
||||
.w_full()
|
||||
.h_9()
|
||||
.px_1p5()
|
||||
.gap_2()
|
||||
.rounded(px(button_radius()))
|
||||
.cursor_pointer()
|
||||
.hover(|this| this.bg(cx.theme().element_hover))
|
||||
.on_click({
|
||||
let room = room.clone();
|
||||
let dock = dock.clone();
|
||||
move |_, window, cx| {
|
||||
open_room(&room, &dock, window, cx);
|
||||
}
|
||||
})
|
||||
.child(
|
||||
Avatar::from_source(picture)
|
||||
.seed(seed)
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(name),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.italic()
|
||||
.child(preview),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.flex_shrink_0()
|
||||
.h_6()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.top_0()
|
||||
.bottom_0()
|
||||
.group_hover("", |this| this.invisible())
|
||||
.child(
|
||||
div()
|
||||
.text_align(gpui::TextAlign::Right)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(created_at),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new(format!("response-{index}"))
|
||||
.label("Response")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.on_click({
|
||||
let room = room.clone();
|
||||
let dock = dock.clone();
|
||||
move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
open_room(&room, &dock, window, cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
GroupBox::new()
|
||||
.id("requests")
|
||||
.title("Requests")
|
||||
.fill()
|
||||
.child(v_flex().gap_0().w_full().children(rows))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_follow_ups(&mut self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
@@ -503,7 +529,7 @@ impl Render for GreeterPanel {
|
||||
.size_full()
|
||||
.p_2()
|
||||
.gap_6()
|
||||
.children(self.render_requests(cx))
|
||||
.child(self.render_requests(cx))
|
||||
.children(self.render_follow_ups(cx))
|
||||
.child(self.render_find_friends(cx)),
|
||||
)
|
||||
|
||||
@@ -120,7 +120,7 @@ impl Sidebar {
|
||||
.px_4()
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_faint)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(list_title),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ use gpui::{
|
||||
use instant::Duration;
|
||||
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::Directory;
|
||||
use person::{Directory, PersonRegistry};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{FIND_DELAY, NostrRegistry, StateEvent};
|
||||
use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _};
|
||||
@@ -103,6 +103,7 @@ impl Sidebar {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let groups = GroupsRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let wot = PersonRegistry::global(cx).read(cx).wot();
|
||||
|
||||
let find_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
@@ -121,6 +122,12 @@ impl Sidebar {
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&wot, window, |_this, _wot, _event, _window, cx| {
|
||||
cx.notify();
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&groups, window, |_this, _, event, window, cx| {
|
||||
match event {
|
||||
|
||||
@@ -118,7 +118,7 @@ impl Sidebar {
|
||||
.label(label)
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_faint)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.into_any_element()
|
||||
}
|
||||
SidebarRow::Empty(text) => v_flex()
|
||||
@@ -136,17 +136,20 @@ impl Sidebar {
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(px(button_radius()))
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(*text),
|
||||
)
|
||||
.into_any_element(),
|
||||
SidebarRow::Room { room } => {
|
||||
let id = room.read(cx).id;
|
||||
let name = room.read(cx).display_name(cx);
|
||||
let picture = room.read(cx).display_image(cx);
|
||||
let seed = room.read(cx).display_image_seed(cx);
|
||||
let created_at = room.read(cx).created_at.to_ago();
|
||||
let id = room.read(cx).id;
|
||||
let member = room.read(cx).members().first().copied();
|
||||
let spam =
|
||||
room.read(cx).is_spam(cx) && room.read(cx).kind == RoomKind::Request;
|
||||
|
||||
let pinned = AppSettings::global(cx).read(cx).pinned_room(id, cx);
|
||||
let dock = self.dock.clone();
|
||||
let room = room.clone();
|
||||
@@ -163,11 +166,28 @@ impl Sidebar {
|
||||
)
|
||||
})
|
||||
.suffix(
|
||||
div()
|
||||
.font_normal()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(created_at),
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.when(spam, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.px_1()
|
||||
.rounded(px(button_radius()))
|
||||
.bg(cx.theme().danger.opacity(0.15))
|
||||
.text_size(px(8.0))
|
||||
.text_color(cx.theme().danger)
|
||||
.child("SPAM"),
|
||||
)
|
||||
})
|
||||
.when(!spam, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.font_normal()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(created_at),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.on_click(move |_event, window, cx| {
|
||||
open_room(&room, &dock, window, cx);
|
||||
|
||||
Reference in New Issue
Block a user