feat: add contact-based chat creation and re-add chat screening #54

Merged
reya merged 9 commits from new-panels into master 2026-09-29 06:28:45 +00:00
8 changed files with 100 additions and 207 deletions
Showing only changes of commit 4d116036b9 - Show all commits
+2 -2
View File
@@ -299,9 +299,9 @@ impl Room {
} }
/// A stable seed for the room's generated avatar /// A stable seed for the room's generated avatar
pub fn display_image_seed(&self, cx: &App) -> SharedString { pub fn display_image_seed(&self, cx: &App) -> String {
if self.is_group() { if self.is_group() {
SharedString::from(self.id.to_string()) self.id.to_string()
} else { } else {
self.display_member(cx).avatar_seed() self.display_member(cx).avatar_seed()
} }
+2 -8
View File
@@ -641,10 +641,7 @@ impl CommunityPanel {
.community .community
.read_with(cx, |community, _cx| { .read_with(cx, |community, _cx| {
let seed = community.id().to_hex(); let seed = community.id().to_hex();
let avatar = match community.icon() { let avatar = Avatar::from_source(community.icon()).seed(seed).large();
Some(path) => Avatar::from_source(path).seed(seed).large(),
None => Avatar::new(None).seed(seed).large(),
};
(community.name(), avatar) (community.name(), avatar)
}) })
@@ -762,10 +759,7 @@ impl Panel for CommunityPanel {
self.community self.community
.read_with(cx, |community, _cx| { .read_with(cx, |community, _cx| {
let seed = community.id().to_hex(); let seed = community.id().to_hex();
let avatar = match community.icon() { let avatar = Avatar::from_source(community.icon()).seed(seed).xsmall();
Some(path) => Avatar::from_source(path).seed(seed).xsmall(),
None => Avatar::new(None).seed(seed).xsmall(),
};
h_flex() h_flex()
.gap_1() .gap_1()
+2 -2
View File
@@ -113,8 +113,8 @@ impl Person {
} }
/// A stable seed for this profile's generated avatar /// A stable seed for this profile's generated avatar
pub fn avatar_seed(&self) -> SharedString { pub fn avatar_seed(&self) -> String {
SharedString::from(self.public_key().to_hex()) self.public_key().to_hex()
} }
/// Get profile name /// Get profile name
+24 -132
View File
@@ -10,7 +10,10 @@ use crate::{Selectable, Sizable, Size, StyledExt};
/// Number of rows and columns in the generated pixel grid. /// Number of rows and columns in the generated pixel grid.
const PIXEL_GRID: usize = 8; const PIXEL_GRID: usize = 8;
/// Probability that a cell in the left half of the grid is filled. /// Empty cells kept between the pattern and the avatar edge, so the art
/// gathers in the center instead of filling the whole avatar.
const MARGIN: usize = 1;
/// Probability that a cell in the left half of the pattern area is filled.
const FILL_PROBABILITY: f32 = 0.42; const FILL_PROBABILITY: f32 = 0.42;
/// Probability that a filled cell uses the accent shade instead of the main color. /// Probability that a filled cell uses the accent shade instead of the main color.
const ACCENT_PROBABILITY: f32 = 0.25; const ACCENT_PROBABILITY: f32 = 0.25;
@@ -33,19 +36,6 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
} }
/// A deterministic, offline pixel-art avatar derived from a seed. /// A deterministic, offline pixel-art avatar derived from a seed.
///
/// Use it for entities that have no profile picture: the same seed always
/// renders the same pattern, so identities stay recognizable without a
/// network round trip. The pattern is painted as geometry and cropped to a
/// circle, at the same sizes as [`Avatar`].
///
/// # Examples
///
/// ```
/// use ui::avatar::PixelAvatar;
///
/// PixelAvatar::new("alice");
/// ```
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct PixelAvatar { pub struct PixelAvatar {
seed: u64, seed: u64,
@@ -87,7 +77,7 @@ impl RenderOnce for PixelAvatar {
move |bounds, seed, window, cx| { move |bounds, seed, window, cx| {
let theme = cx.theme(); let theme = cx.theme();
let main = Hsla { let main = Hsla {
h: (theme.icon_accent.h + seed as f32 / u64::MAX as f32) % 1., h: seed as f32 / u64::MAX as f32,
s: 0.6, s: 0.6,
l: if theme.is_dark() { 0.6 } else { 0.45 }, l: if theme.is_dark() { 0.6 } else { 0.45 },
a: 1., a: 1.,
@@ -142,8 +132,11 @@ fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] {
let mut pattern = [0u8; PIXEL_GRID * PIXEL_GRID]; let mut pattern = [0u8; PIXEL_GRID * PIXEL_GRID];
let mut filled = 0usize; let mut filled = 0usize;
for row in 0..PIXEL_GRID { let art_rows = PIXEL_GRID - 2 * MARGIN;
for col in 0..PIXEL_GRID / 2 { let art_columns = PIXEL_GRID / 2 - MARGIN;
for row in MARGIN..PIXEL_GRID - MARGIN {
for col in MARGIN..PIXEL_GRID / 2 {
if rng.chance(FILL_PROBABILITY) { if rng.chance(FILL_PROBABILITY) {
let accent = rng.chance(ACCENT_PROBABILITY); let accent = rng.chance(ACCENT_PROBABILITY);
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 }); set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
@@ -153,17 +146,17 @@ fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] {
} }
if filled < MIN_FILLED { if filled < MIN_FILLED {
let half = PIXEL_GRID * PIXEL_GRID / 2; let total = art_rows * art_columns;
let start = (rng.next() % half as u64) as usize; let start = (rng.next() % total as u64) as usize;
for offset in 0..half { for offset in 0..total {
if filled >= MIN_FILLED { if filled >= MIN_FILLED {
break; break;
} }
let ix = (start + offset) % half; let ix = (start + offset) % total;
let row = ix / (PIXEL_GRID / 2); let row = MARGIN + ix / art_columns;
let col = ix % (PIXEL_GRID / 2); let col = MARGIN + ix % art_columns;
if pattern[row * PIXEL_GRID + col] == 0 { if pattern[row * PIXEL_GRID + col] == 0 {
set_cell(&mut pattern, row, col, 1); set_cell(&mut pattern, row, col, 1);
@@ -358,24 +351,12 @@ fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement {
} }
/// An element that renders a user avatar with customizable appearance options. /// An element that renders a user avatar with customizable appearance options.
///
/// Entities without a picture still get a stable identity: the avatar falls
/// back to a [`PixelAvatar`] seeded through [`Avatar::seed`], both when there
/// is no picture and when the picture fails to load.
///
/// # Examples
///
/// ```
/// use ui::avatar::Avatar;
///
/// Avatar::new(None).seed("alice");
/// ```
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct Avatar { pub struct Avatar {
base: Div, base: Div,
picture: Option<ImageSource>, picture: Option<ImageSource>,
grayscale: bool, grayscale: bool,
seed: Option<SharedString>, seed: Option<String>,
style: StyleRefinement, style: StyleRefinement,
size: Size, size: Size,
border_color: Option<Hsla>, border_color: Option<Hsla>,
@@ -384,16 +365,13 @@ pub struct Avatar {
impl Avatar { impl Avatar {
/// Creates an avatar for an entity whose profile picture may be missing. /// Creates an avatar for an entity whose profile picture may be missing.
///
/// Use [`Avatar::seed`] to choose the generated
/// pixel avatar rendered when `picture` is `None`.
pub fn new(picture: Option<SharedString>) -> Self { pub fn new(picture: Option<SharedString>) -> Self {
Self::from_picture(picture.map(ImageSource::from)) Self::from_source(picture)
} }
/// Creates an avatar from an already-resolved source. /// Creates an avatar from an already-resolved source, which may be missing.
pub fn from_source(picture: impl Into<ImageSource>) -> Self { pub fn from_source(picture: Option<impl Into<ImageSource>>) -> Self {
Self::from_picture(Some(picture.into())) Self::from_picture(picture.map(Into::into))
} }
fn from_picture(picture: Option<ImageSource>) -> Self { fn from_picture(picture: Option<ImageSource>) -> Self {
@@ -410,33 +388,18 @@ impl Avatar {
} }
/// Sets the seed for the generated pixel avatar. /// Sets the seed for the generated pixel avatar.
/// pub fn seed(mut self, seed: impl Into<String>) -> Self {
/// The seed should be a stable identifier of the entity the avatar
/// represents, such as a public key.
pub fn seed(mut self, seed: impl Into<SharedString>) -> Self {
self.seed = Some(seed.into()); self.seed = Some(seed.into());
self self
} }
/// Applies a grayscale filter to the avatar image. /// Applies a grayscale filter to the avatar image.
///
/// # Examples
///
/// ```
/// use ui::avatar::Avatar;
///
/// Avatar::new(None).grayscale(true);
/// ```
pub fn grayscale(mut self, grayscale: bool) -> Self { pub fn grayscale(mut self, grayscale: bool) -> Self {
self.grayscale = grayscale; self.grayscale = grayscale;
self self
} }
/// Sets the border color of the avatar. /// Sets the border color of the avatar.
///
/// This might be used to match the border to the background color of
/// the parent element to create the illusion of cropping another
/// shape underneath (for example in face piles.)
pub fn border_color(mut self, color: impl Into<Hsla>) -> Self { pub fn border_color(mut self, color: impl Into<Hsla>) -> Self {
self.border_color = Some(color.into()); self.border_color = Some(color.into());
self self
@@ -480,6 +443,7 @@ impl RenderOnce for Avatar {
} else { } else {
px(0.) px(0.)
}; };
let image_size = avatar_size(self.size).to_pixels(window.rem_size()); let image_size = avatar_size(self.size).to_pixels(window.rem_size());
let container_size = image_size + border_width * 2.; let container_size = image_size + border_width * 2.;
@@ -487,6 +451,7 @@ impl RenderOnce for Avatar {
Some(picture) => { Some(picture) => {
let seed = self.seed; let seed = self.seed;
let grayscale = self.grayscale; let grayscale = self.grayscale;
img(picture) img(picture)
.size(image_size) .size(image_size)
.rounded_full() .rounded_full()
@@ -510,76 +475,3 @@ impl RenderOnce for Avatar {
.child(content) .child(content)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pixel_patterns_are_symmetric_and_stable() {
for seed in 0..50 {
let pattern = pixel_pattern(seed);
let filled = pattern.iter().filter(|&&cell| cell != 0).count();
assert!(
filled >= MIN_FILLED * 2,
"pattern too sparse for seed {seed}"
);
for row in 0..PIXEL_GRID {
for col in 0..PIXEL_GRID {
assert_eq!(
pattern[row * PIXEL_GRID + col],
pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)],
"asymmetric pattern for seed {seed} at ({row}, {col})"
);
}
}
}
for seed in [0, 1, 42, u64::MAX] {
assert_eq!(pixel_pattern(seed), pixel_pattern(seed));
}
assert_ne!(pixel_pattern(42), pixel_pattern(43));
}
fn area(polygon: &[Point<Pixels>]) -> f32 {
let mut sum: f32 = 0.;
for (&a, &b) in polygon.iter().zip(polygon.iter().cycle().skip(1)) {
sum += a.x.as_f32() * b.y.as_f32() - b.x.as_f32() * a.y.as_f32();
}
(sum / 2.).abs()
}
#[test]
fn clipping_keeps_only_the_part_inside_the_circle() {
let circle = circle_polygon(point(px(10.), px(10.)), 10.);
let square = |left: f32, top: f32| {
[
point(px(left), px(top)),
point(px(left + 4.), px(top)),
point(px(left + 4.), px(top + 4.)),
point(px(left), px(top + 4.)),
]
};
let inside = clip_polygon(&square(8., 8.), &circle);
assert!((area(&inside) - 16.).abs() < 0.05, "area {}", area(&inside));
assert!(clip_polygon(&square(20., 20.), &circle).is_empty());
let straddling = clip_polygon(&square(0., 0.), &circle);
for vertex in &straddling {
let delta_x = vertex.x.as_f32() - 10.;
let delta_y = vertex.y.as_f32() - 10.;
assert!(
delta_x.hypot(delta_y) <= 10. + 0.1,
"clipped vertex outside the circle"
);
}
let area = area(&straddling);
assert!(area > 0. && area < 16., "area {area}");
}
}
+15 -9
View File
@@ -11,6 +11,7 @@ use gpui::{
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry; use person::PersonRegistry;
use settings::AppSettings;
use state::NostrRegistry; use state::NostrRegistry;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::avatar::Avatar; use ui::avatar::Avatar;
@@ -21,8 +22,6 @@ use ui::input::{Input, InputEvent, InputState};
use ui::nav::Nav; use ui::nav::Nav;
use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex}; use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex};
use crate::sidebar::nav_avatar;
/// Maximum number of members a chat can have. /// Maximum number of members a chat can have.
const MAX_MEMBERS: usize = 3; const MAX_MEMBERS: usize = 3;
@@ -349,10 +348,14 @@ impl NewChat {
Nav::new(SharedString::from(format!("contact-{}", start + offset))) Nav::new(SharedString::from(format!("contact-{}", start + offset)))
.label(profile.name()) .label(profile.name())
.text_sm() .text_sm()
.when_some( .when(!AppSettings::get_hide_avatar(cx), |this| {
nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx), this.prefix(
|this, avatar| this.prefix(avatar), Avatar::from_source(profile.avatar())
) .seed(profile.avatar_seed())
.small()
.flex_shrink_0(),
)
})
.on_click(cx.listener(move |this, _event, _window, cx| { .on_click(cx.listener(move |this, _event, _window, cx| {
this.toggle_member(&public_key, cx); this.toggle_member(&public_key, cx);
})) }))
@@ -380,9 +383,12 @@ impl NewChat {
.bg(cx.theme().ghost_element_background_alt) .bg(cx.theme().ghost_element_background_alt)
.text_size(px(10.)) .text_size(px(10.))
.hover(|this| this.bg(cx.theme().ghost_element_hover)) .hover(|this| this.bg(cx.theme().ghost_element_hover))
.when_some(profile.avatar(), |this, avatar| { .child(
this.child(Avatar::from_source(avatar).xsmall().flex_shrink_0()) Avatar::from_source(profile.avatar())
}) .seed(profile.avatar_seed())
.xsmall()
.flex_shrink_0(),
)
.child(div().max_w_32().truncate().child(profile.name())) .child(div().max_w_32().truncate().child(profile.name()))
.on_click(cx.listener(move |this, _event, _window, cx| { .on_click(cx.listener(move |this, _event, _window, cx| {
this.toggle_member(&public_key, cx); this.toggle_member(&public_key, cx);
+20 -10
View File
@@ -13,9 +13,11 @@ use gpui::{
use instant::Duration; use instant::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry; use person::PersonRegistry;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry}; use state::{FIND_DELAY, NostrRegistry};
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
use ui::input::{Input, InputEvent, InputState}; use ui::input::{Input, InputEvent, InputState};
@@ -23,8 +25,6 @@ use ui::nav::Nav;
use ui::notification::Notification; use ui::notification::Notification;
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use crate::sidebar::nav_avatar;
const INPUT_PLACEHOLDER: &str = "Find or start a conversation"; const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
pub fn init(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) -> Entity<SearchPanel> { pub fn init(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) -> Entity<SearchPanel> {
@@ -337,6 +337,7 @@ impl SearchPanel {
range: Range<usize>, range: Range<usize>,
cx: &Context<Self>, cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> { ) -> Vec<impl IntoElement + use<>> {
let hide_avatar = AppSettings::get_hide_avatar(cx);
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
// Get the results // Get the results
@@ -365,10 +366,14 @@ impl SearchPanel {
.label(profile.name()) .label(profile.name())
.text_sm() .text_sm()
.font_medium() .font_medium()
.when_some( .when(!hide_avatar, |this| {
nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx), this.prefix(
|this, avatar| this.prefix(avatar), Avatar::from_source(profile.avatar())
) .seed(profile.avatar_seed())
.small()
.flex_shrink_0(),
)
})
.on_click(handler) .on_click(handler)
.selected(selected) .selected(selected)
.into_any_element() .into_any_element()
@@ -382,6 +387,7 @@ impl SearchPanel {
range: Range<usize>, range: Range<usize>,
cx: &Context<Self>, cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> { ) -> Vec<impl IntoElement + use<>> {
let hide_avatar = AppSettings::get_hide_avatar(cx);
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
// Get the contact list // Get the contact list
@@ -410,10 +416,14 @@ impl SearchPanel {
.label(profile.name().trim()) .label(profile.name().trim())
.text_sm() .text_sm()
.font_medium() .font_medium()
.when_some( .when(!hide_avatar, |this| {
nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx), this.prefix(
|this, avatar| this.prefix(avatar), Avatar::from_source(profile.avatar())
) .seed(profile.avatar_seed())
.small()
.flex_shrink_0(),
)
})
.on_click(handler) .on_click(handler)
.selected(selected) .selected(selected)
.into_any_element() .into_any_element()
+34 -13
View File
@@ -44,7 +44,7 @@ mod tab;
mod utils; mod utils;
use tab::SidebarTab; use tab::SidebarTab;
pub(crate) use utils::{nav_avatar, nav_icon, pick_banner}; pub(crate) use utils::{nav_icon, pick_banner};
pub enum SidebarRow { pub enum SidebarRow {
Room { room: Entity<Room> }, Room { room: Entity<Room> },
@@ -833,6 +833,8 @@ impl Sidebar {
rows: &[SidebarRow], rows: &[SidebarRow],
cx: &Context<Sidebar>, cx: &Context<Sidebar>,
) -> Vec<AnyElement> { ) -> Vec<AnyElement> {
let hide_avatar = AppSettings::get_hide_avatar(cx);
rows.get(range.clone()) rows.get(range.clone())
.into_iter() .into_iter()
.flatten() .flatten()
@@ -855,8 +857,13 @@ impl Sidebar {
.label(name) .label(name)
.text_sm() .text_sm()
.font_medium() .font_medium()
.when_some(nav_avatar(Some(seed), picture, cx), |this, avatar| { .when(!hide_avatar, |this| {
this.prefix(avatar) this.prefix(
Avatar::from_source(picture)
.seed(seed)
.small()
.flex_shrink_0(),
)
}) })
.suffix( .suffix(
div() div()
@@ -904,10 +911,14 @@ impl Sidebar {
.label(person.name()) .label(person.name())
.text_sm() .text_sm()
.font_medium() .font_medium()
.when_some( .when(!hide_avatar, |this| {
nav_avatar(Some(person.avatar_seed()), person.avatar(), cx), this.prefix(
|this, avatar| this.prefix(avatar), Avatar::from_source(person.avatar())
) .seed(person.avatar_seed())
.small()
.flex_shrink_0(),
)
})
.selected(selected) .selected(selected)
.on_click(move |_event, _window, cx| { .on_click(move |_event, _window, cx| {
if let Err(error) = sidebar.update(cx, |this, cx| { if let Err(error) = sidebar.update(cx, |this, cx| {
@@ -929,8 +940,13 @@ impl Sidebar {
Nav::new(SharedString::from(format!("com-{index}"))) Nav::new(SharedString::from(format!("com-{index}")))
.label(name) .label(name)
.text_sm() .text_sm()
.when_some(nav_avatar(Some(seed), picture, cx), |this, avatar| { .when(!hide_avatar, |this| {
this.prefix(avatar) this.prefix(
Avatar::from_source(picture)
.seed(seed)
.small()
.flex_shrink_0(),
)
}) })
.on_click(move |_event, window, cx| { .on_click(move |_event, window, cx| {
ui::dock::add_panel_to( ui::dock::add_panel_to(
@@ -1021,6 +1037,7 @@ impl Sidebar {
} }
fn member_row(&self, public_key: &PublicKey, cx: &App) -> AnyElement { fn member_row(&self, public_key: &PublicKey, cx: &App) -> AnyElement {
let hide_avatar = AppSettings::get_hide_avatar(cx);
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
let person = persons.read(cx).get(public_key, cx); let person = persons.read(cx).get(public_key, cx);
@@ -1028,10 +1045,14 @@ impl Sidebar {
.label(person.name()) .label(person.name())
.text_sm() .text_sm()
.font_medium() .font_medium()
.when_some( .when(!hide_avatar, |this| {
nav_avatar(Some(person.avatar_seed()), person.avatar(), cx), this.prefix(
|this, avatar| this.prefix(avatar), Avatar::from_source(person.avatar())
) .seed(person.avatar_seed())
.small()
.flex_shrink_0(),
)
})
.into_any_element() .into_any_element()
} }
} }
+1 -31
View File
@@ -1,39 +1,9 @@
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use gpui::prelude::FluentBuilder as _; use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, px};
use gpui::{AnyElement, App, ImageSource, IntoElement, ParentElement, SharedString, Styled, px};
use settings::AppSettings;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::avatar::{Avatar, PixelAvatar};
use ui::{Icon, IconName, Sizable, h_flex}; use ui::{Icon, IconName, Sizable, h_flex};
pub(crate) fn nav_avatar(
seed: Option<impl Into<SharedString>>,
picture: Option<impl Into<ImageSource>>,
cx: &App,
) -> Option<AnyElement> {
if AppSettings::get_hide_avatar(cx) {
return None;
}
match (seed.map(Into::into), picture.map(Into::into)) {
(None, None) => None,
(seed, Some(picture)) => Some(
Avatar::from_source(picture)
.when_some(seed, |avatar, seed| avatar.seed(seed))
.small()
.flex_shrink_0()
.into_any_element(),
),
(Some(seed), None) => Some(
PixelAvatar::new(seed)
.small()
.flex_shrink_0()
.into_any_element(),
),
}
}
pub(crate) fn nav_icon(icon: IconName, cx: &App) -> AnyElement { pub(crate) fn nav_icon(icon: IconName, cx: &App) -> AnyElement {
h_flex() h_flex()
.flex_shrink_0() .flex_shrink_0()