Merge remote-tracking branch 'bushmann/feature/people-list-ui' into unstable

This commit is contained in:
Mike Dilger 2023-12-08 18:01:59 +13:00
commit 0b855bf0af
12 changed files with 920 additions and 520 deletions

View File

@ -9,9 +9,9 @@ use egui_winit::egui::text::CCursor;
use egui_winit::egui::text_edit::{CCursorRange, TextEditOutput};
use egui_winit::egui::Id;
use gossip_lib::comms::ToOverlordMessage;
use gossip_lib::DmChannel;
use gossip_lib::Relay;
use gossip_lib::GLOBALS;
use gossip_lib::{DmChannel, Person};
use memoize::memoize;
use nostr_types::{ContentSegment, NostrBech32, NostrUrl, ShatteredContent, Tag};
use std::collections::HashMap;
@ -396,38 +396,11 @@ fn real_posting_area(app: &mut GossipUi, ctx: &Context, frame: &mut eframe::Fram
let enter_key;
(app.draft_data.tagging_search_selected, enter_key) =
if app.draft_data.tagging_search_substring.is_some() {
ui.input_mut(|i| {
// enter
let enter = i.count_and_consume_key(Modifiers::NONE, Key::Enter) > 0;
// up / down
let mut index = app.draft_data.tagging_search_selected.unwrap_or(0);
let down = i.count_and_consume_key(Modifiers::NONE, Key::ArrowDown);
let up = i.count_and_consume_key(Modifiers::NONE, Key::ArrowUp);
index += down;
index = index.min(
app.draft_data
.tagging_search_results
.len()
.saturating_sub(1),
);
index = index.saturating_sub(up);
// tab will cycle down and wrap
let tab = i.count_and_consume_key(Modifiers::NONE, Key::Tab);
index += tab;
if index
> app
.draft_data
.tagging_search_results
.len()
.saturating_sub(1)
{
index = 0;
}
(Some(index), enter)
})
widgets::capture_keyboard_for_search(
ui,
app.draft_data.tagging_search_results.len(),
app.draft_data.tagging_search_selected,
)
} else {
(None, false)
};
@ -565,7 +538,7 @@ fn real_posting_area(app: &mut GossipUi, ctx: &Context, frame: &mut eframe::Fram
app.draft_data.draft.push(emoji);
}
});
ui.add_space(20.0);
btn_h_space!(ui);
if ui
.button(RichText::new("🥩"))
.on_hover_text("raw content preview")
@ -687,131 +660,17 @@ fn show_tagging_result(
output: &mut TextEditOutput,
enter_key: bool,
) {
let pos = if let Some(cursor) = output.cursor_range {
let rect = output.galley.pos_from_cursor(&cursor.primary); // position within textedit
output.text_draw_pos + rect.center_bottom().to_vec2()
} else {
let rect = output.galley.pos_from_cursor(&output.galley.end()); // position within textedit
output.text_draw_pos + rect.center_bottom().to_vec2()
};
// always compute the tooltip, but it is only shown when
// is_open is true. This is so we get the animation.
let frame = egui::Frame::popup(ui.style())
.rounding(egui::Rounding::ZERO)
.inner_margin(egui::Margin::same(0.0));
let area = egui::Area::new(ui.auto_id_with("compose-tagging-tooltip"))
.fixed_pos(pos)
.movable(false)
.constrain(true)
.interactable(true)
.order(egui::Order::Middle);
// show search results
if !app.draft_data.tagging_search_results.is_empty() {
area.show(ui.ctx(), |ui| {
frame.show(ui, |ui| {
egui::ScrollArea::vertical()
.max_width(widgets::TAGG_WIDTH)
.max_height(250.0)
.show(ui, |ui| {
// need to clone results to avoid immutable borrow error on app.
let pairs = app.draft_data.tagging_search_results.clone();
for (i, pair) in pairs.iter().enumerate() {
let avatar = if let Some(avatar) = app.try_get_avatar(ui.ctx(), &pair.1)
{
avatar
} else {
app.placeholder_avatar.clone()
};
let frame = egui::Frame::none()
.rounding(egui::Rounding::ZERO)
.inner_margin(egui::Margin::symmetric(10.0, 5.0));
let mut prepared = frame.begin(ui);
prepared.content_ui.set_min_width(widgets::TAGG_WIDTH);
prepared.content_ui.set_max_width(widgets::TAGG_WIDTH);
prepared.content_ui.set_min_height(27.0);
let frame_rect = (prepared.frame.inner_margin
+ prepared.frame.outer_margin)
.expand_rect(prepared.content_ui.min_rect());
let response = ui
.interact(
frame_rect,
ui.auto_id_with(pair.1.as_hex_string()),
egui::Sense::click(),
)
.on_hover_cursor(egui::CursorIcon::PointingHand);
// mouse hover moves selected index
app.draft_data.tagging_search_selected = if response.hovered() {
Some(i)
} else {
app.draft_data.tagging_search_selected
};
let is_selected = Some(i) == app.draft_data.tagging_search_selected;
{
// render inside of frame using prepared.content_ui
let ui = &mut prepared.content_ui;
if is_selected {
app.theme.on_accent_style(ui.style_mut())
}
let person = GLOBALS
.storage
.read_person(&pair.1)
.unwrap_or(Some(Person::new(pair.1)))
.unwrap_or(Person::new(pair.1));
ui.horizontal(|ui| {
widgets::paint_avatar(
let mut selected = app.draft_data.tagging_search_selected;
widgets::show_contact_search(
ui,
&person,
&avatar,
widgets::AvatarSize::Mini,
);
ui.vertical(|ui| {
widgets::truncated_label(
ui,
RichText::new(&pair.0).small(),
widgets::TAGG_WIDTH - 33.0,
);
let mut nip05 =
RichText::new(person.nip05().unwrap_or_default())
.weak()
.small();
if !person.nip05_valid {
nip05 = nip05.strikethrough()
}
widgets::truncated_label(
ui,
nip05,
widgets::TAGG_WIDTH - 33.0,
);
});
})
};
prepared.frame.fill = if is_selected {
app.theme.accent_color()
} else {
egui::Color32::TRANSPARENT
};
prepared.end(ui);
if is_selected {
response.scroll_to_me(None)
}
let clicked = response.clicked();
if clicked || (enter_key && is_selected) {
app,
output,
&mut selected,
app.draft_data.tagging_search_results.clone(),
enter_key,
|ui, app, output, pair| {
// remove @ and search text
let search = if let Some(search) =
app.draft_data.tagging_search_searched.as_ref()
{
let search = if let Some(search) = app.draft_data.tagging_search_searched.as_ref() {
search.clone()
} else {
"".to_string()
@ -846,17 +705,12 @@ fn show_tagging_result(
// clear tagging search
app.draft_data.tagging_search_substring = None;
}
}
}
});
});
});
}
},
);
let is_open = app.draft_data.tagging_search_substring.is_some();
area.show_open_close_animation(ui.ctx(), &frame, is_open);
app.draft_data.tagging_search_selected = selected;
if !is_open {
if app.draft_data.tagging_search_substring.is_none() {
// no more search substring, clear results
app.draft_data.tagging_search_searched = None;
app.draft_data.tagging_search_results.clear();

View File

@ -10,6 +10,12 @@ macro_rules! text_edit_multiline {
};
}
macro_rules! btn_h_space {
($ui:ident) => {
$ui.add_space(20.0)
};
}
mod components;
mod dm_chat_list;
mod feed;
@ -352,6 +358,9 @@ struct GossipUi {
// RelayUi
relays: relays::RelayUi,
// people::ListUi
people_list: people::ListUi,
// Post rendering
render_raw: Option<Id>,
render_qr: Option<Id>,
@ -401,10 +410,8 @@ struct GossipUi {
delegatee_tag_str: String,
// User entry: general
entering_follow_someone_on_list: bool,
follow_someone: String,
add_contact: String,
add_relay: String, // dep
clear_list_needs_confirm: bool,
password: String,
password2: String,
password3: String,
@ -607,6 +614,7 @@ impl GossipUi {
qr_codes: HashMap::new(),
notes: Notes::new(),
relays: relays::RelayUi::new(),
people_list: people::ListUi::new(),
render_raw: None,
render_qr: None,
approved: HashSet::new(),
@ -644,10 +652,8 @@ impl GossipUi {
editing_metadata: false,
metadata: Metadata::new(),
delegatee_tag_str: "".to_owned(),
entering_follow_someone_on_list: false,
follow_someone: "".to_owned(),
add_contact: "".to_owned(),
add_relay: "".to_owned(),
clear_list_needs_confirm: false,
password: "".to_owned(),
password2: "".to_owned(),
password3: "".to_owned(),
@ -729,6 +735,7 @@ impl GossipUi {
self.close_all_menus(ctx);
}
Page::PeopleLists => {
people::enter_page(self);
self.close_all_menus(ctx);
}
Page::Person(pubkey) => {
@ -1331,6 +1338,22 @@ impl GossipUi {
}
}
pub fn richtext_from_person_nip05(person: &Person) -> RichText {
if let Some(mut nip05) = person.nip05().map(|s| s.to_owned()) {
if nip05.starts_with("_@") {
nip05 = nip05.get(2..).unwrap().to_string();
}
if person.nip05_valid {
RichText::new(nip05).monospace()
} else {
RichText::new(nip05).monospace().strikethrough()
}
} else {
RichText::default()
}
}
pub fn render_person_name_line(
app: &mut GossipUi,
ui: &mut Ui,

View File

@ -1,12 +1,61 @@
use std::time::{Duration, Instant};
use super::{GossipUi, Page};
use crate::ui::widgets;
use eframe::egui;
use egui::{Context, RichText, Ui, Vec2};
use egui_winit::egui::text_edit::TextEditOutput;
use egui_winit::egui::vec2;
use gossip_lib::comms::ToOverlordMessage;
use gossip_lib::{Person, PersonList, GLOBALS};
use nostr_types::{Profile, PublicKey};
pub(crate) struct ListUi {
// cache
cache_last_list: Option<PersonList>,
cache_next_refresh: Instant,
cache_people: Vec<(Person, bool)>,
cache_remote_tag: String,
cache_local_tag: String,
// add contact
add_contact_search: String,
add_contact_searched: Option<String>,
add_contact_search_results: Vec<(String, PublicKey)>,
add_contact_search_selected: Option<usize>,
configure_list_menu_active: bool,
entering_follow_someone_on_list: bool,
clear_list_needs_confirm: bool,
}
impl ListUi {
pub(crate) fn new() -> Self {
Self {
// cache
cache_last_list: None,
cache_next_refresh: Instant::now(),
cache_people: Vec::new(),
cache_remote_tag: String::new(),
cache_local_tag: String::new(),
// add contact
add_contact_search: String::new(),
add_contact_searched: None,
add_contact_search_results: Vec::new(),
add_contact_search_selected: None,
configure_list_menu_active: false,
entering_follow_someone_on_list: false,
clear_list_needs_confirm: false,
}
}
}
pub(super) fn enter_page(app: &mut GossipUi, list: PersonList) {
refresh_list_data(app, list);
}
pub(super) fn update(
app: &mut GossipUi,
ctx: &Context,
@ -14,63 +63,68 @@ pub(super) fn update(
ui: &mut Ui,
list: PersonList,
) {
let people = {
let members = GLOBALS.storage.get_people_in_list(list).unwrap_or_default();
let mut people: Vec<(Person, bool)> = Vec::new();
for (pk, public) in &members {
if let Ok(Some(person)) = GLOBALS.storage.read_person(pk) {
people.push((person, *public));
} else {
let person = Person::new(pk.to_owned());
let _ = GLOBALS.storage.write_person(&person, None);
people.push((person, *public));
}
}
people.sort_by(|a, b| a.0.cmp(&b.0));
people
};
ui.add_space(12.0);
let latest_event_data = GLOBALS
.people
.latest_person_list_event_data
.get(&list)
.map(|v| v.value().clone())
.unwrap_or_default();
let mut asof = "unknown".to_owned();
if let Ok(stamp) = time::OffsetDateTime::from_unix_timestamp(latest_event_data.when.0) {
if let Ok(formatted) = stamp.format(time::macros::format_description!(
"[year]-[month repr:short]-[day] ([weekday repr:short]) [hour]:[minute]"
)) {
asof = formatted;
}
if app.people_list.cache_next_refresh < Instant::now()
|| app.people_list.cache_last_list.is_none()
|| app.people_list.cache_last_list.unwrap() != list
{
refresh_list_data(app, list);
}
let txt = if let Some(private_len) = latest_event_data.private_len {
format!(
"REMOTE: {} (public_len={} private_len={})",
asof, latest_event_data.public_len, private_len
)
} else {
format!(
"REMOTE: {} (public_len={})",
asof, latest_event_data.public_len
)
};
// process popups first
if app.people_list.clear_list_needs_confirm {
render_clear_list_confirm_popup(ui, app, list);
}
if app.people_list.entering_follow_someone_on_list {
render_add_contact_popup(ui, app, list);
}
ui.label(RichText::new(txt).size(15.0))
.on_hover_text("This is the data in the latest list event fetched from relays");
// disable rest of ui when popups are open
let enabled = !app.people_list.entering_follow_someone_on_list
&& !app.people_list.clear_list_needs_confirm;
ui.add_space(10.0);
// render page
widgets::page_header(
ui,
format!("{} ({})", list.name(), app.people_list.cache_people.len()),
|ui| {
ui.add_enabled_ui(enabled, |ui| {
let min_size = vec2(50.0, 20.0);
ui.horizontal(|ui| {
ui.add_space(30.0);
widgets::MoreMenu::new(&app).with_min_size(min_size).show(
ui,
&mut app.people_list.configure_list_menu_active,
|ui| {
// since we are displaying over an accent color background, load that style
app.theme.accent_button_2_style(ui.style_mut());
if ui.button("Clear All").clicked() {
app.people_list.clear_list_needs_confirm = true;
}
// ui.add_space(8.0);
},
);
});
btn_h_space!(ui);
if ui.button("Add contact").clicked() {
app.people_list.entering_follow_someone_on_list = true;
}
},
);
ui.set_enabled(enabled);
if GLOBALS.signer.is_ready() {
ui.vertical(|ui| {
ui.label(RichText::new(&app.people_list.cache_remote_tag))
.on_hover_text("This is the data in the latest list event fetched from relays");
ui.add_space(5.0);
// remote <-> local buttons
ui.horizontal(|ui|{
if ui
.button("↓ Overwrite ↓")
.on_hover_text(
@ -109,52 +163,16 @@ pub(super) fn update(
.to_overlord
.send(ToOverlordMessage::PushPersonList(list));
}
}
if GLOBALS.signer.is_ready() {
if app.clear_list_needs_confirm {
if ui.button("CANCEL").clicked() {
app.clear_list_needs_confirm = false;
}
if ui.button("YES, CLEAR ALL").clicked() {
let _ = GLOBALS
.to_overlord
.send(ToOverlordMessage::ClearPersonList(list));
app.clear_list_needs_confirm = false;
}
} else {
if ui.button("Clear All").clicked() {
app.clear_list_needs_confirm = true;
}
}
}
});
ui.add_space(10.0);
ui.add_space(5.0);
let last_list_edit = match GLOBALS.storage.get_person_list_last_edit_time(list) {
Ok(Some(date)) => date,
Ok(None) => 0,
Err(e) => {
tracing::error!("{}", e);
0
}
};
let mut ledit = "unknown".to_owned();
if let Ok(stamp) = time::OffsetDateTime::from_unix_timestamp(last_list_edit) {
if let Ok(formatted) = stamp.format(time::macros::format_description!(
"[year]-[month repr:short]-[day] ([weekday repr:short]) [hour]:[minute]"
)) {
ledit = formatted;
}
}
ui.label(RichText::new(format!("LOCAL: {} (size={})", ledit, people.len())).size(15.0))
// local timestamp
ui.label(RichText::new(&app.people_list.cache_local_tag))
.on_hover_text("This is the local (and effective) list");
if !GLOBALS.signer.is_ready() {
ui.add_space(10.0);
ui.horizontal_wrapped(|ui| {
});
} else {
ui.horizontal(|ui| {
ui.label("You need to ");
if ui.link("setup your identity").clicked() {
app.set_page(ctx, Page::YourKeys);
@ -165,20 +183,11 @@ pub(super) fn update(
ui.add_space(10.0);
if ui.button("Follow New").clicked() {
app.entering_follow_someone_on_list = true;
}
ui.separator();
ui.add_space(10.0);
ui.heading(format!("{} ({})", list.name(), people.len()));
ui.add_space(14.0);
ui.separator();
app.vert_scroll_area().show(ui, |ui| {
// not nice but needed because of 'app' borrow in closure
let people = app.people_list.cache_people.clone();
for (person, public) in people.iter() {
let row_response = widgets::list_entry::make_frame(ui).show(ui, |ui| {
ui.horizontal(|ui| {
// Avatar first
let avatar = if let Some(avatar) = app.try_get_avatar(ctx, &person.pubkey) {
@ -186,13 +195,20 @@ pub(super) fn update(
} else {
app.placeholder_avatar.clone()
};
if widgets::paint_avatar(ui, person, &avatar, widgets::AvatarSize::Feed).clicked() {
app.set_page(ctx, Page::Person(person.pubkey));
};
let avatar_height =
widgets::paint_avatar(ui, person, &avatar, widgets::AvatarSize::Feed)
.rect
.height();
ui.add_space(20.0);
ui.vertical(|ui| {
ui.label(RichText::new(gossip_lib::names::pubkey_short(&person.pubkey)).weak());
GossipUi::render_person_name_line(app, ui, person, false);
ui.set_min_height(avatar_height);
ui.horizontal(|ui| {
ui.label(GossipUi::person_name(person));
ui.add_space(10.0);
if !GLOBALS
.storage
.have_persons_relays(person.pubkey)
@ -203,8 +219,34 @@ pub(super) fn update(
.color(app.theme.warning_marker_text_color()),
);
}
});
ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new(gossip_lib::names::pubkey_short(&person.pubkey))
.weak(),
);
ui.add_space(10.0);
ui.label(GossipUi::richtext_from_person_nip05(person));
});
});
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
ui.set_min_height(avatar_height);
// actions
if ui.link("Remove").clicked() {
let _ =
GLOBALS
.storage
.remove_person_from_list(&person.pubkey, list, None);
}
ui.add_space(20.0);
// private / public switch
if crate::ui::components::switch_simple(ui, *public).clicked() {
let _ = GLOBALS.storage.add_person_to_list(
&person.pubkey,
@ -212,76 +254,305 @@ pub(super) fn update(
!*public,
None,
);
mark_refresh(app);
}
ui.label(if *public { "public" } else { "private" });
});
});
});
if ui.button("Remove").clicked() {
let _ = GLOBALS
.storage
.remove_person_from_list(&person.pubkey, list, None);
if row_response
.response
.interact(egui::Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
app.set_page(ctx, Page::Person(person.pubkey));
}
ui.add_space(4.0);
ui.separator();
}
});
}
if app.entering_follow_someone_on_list {
const DLG_SIZE: Vec2 = vec2(400.0, 200.0);
fn render_add_contact_popup(ui: &mut Ui, app: &mut GossipUi, list: gossip_lib::PersonList1) {
const DLG_SIZE: Vec2 = vec2(400.0, 240.0);
let ret = crate::ui::widgets::modal_popup(ui, DLG_SIZE, |ui| {
ui.heading("Follow someone");
let enter_key;
(app.people_list.add_contact_search_selected, enter_key) =
if app.people_list.add_contact_search_results.is_empty() {
(None, false)
} else {
widgets::capture_keyboard_for_search(
ui,
app.people_list.add_contact_search_results.len(),
app.people_list.add_contact_search_selected,
)
};
ui.heading("Add contact to the list");
ui.add_space(8.0);
ui.label("Search for known contacts to add");
ui.add_space(8.0);
let mut output =
widgets::search_field(ui, &mut app.people_list.add_contact_search, f32::INFINITY);
let mut selected = app.people_list.add_contact_search_selected;
widgets::show_contact_search(
ui,
app,
&mut output,
&mut selected,
app.people_list.add_contact_search_results.clone(),
enter_key,
|_, app, _, pair| {
app.people_list.add_contact_search = pair.0.clone();
app.people_list.add_contact_search_results.clear();
app.people_list.add_contact_search_selected = None;
app.add_contact = pair.1.as_bech32_string();
},
);
app.people_list.add_contact_search_selected = selected;
recalc_add_contact_search(app, &mut output);
ui.add_space(8.0);
ui.label("To add a new contact to this list enter their npub, hex key, nprofile or nip-05 address");
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label("Enter");
ui.add(
text_edit_line!(app, app.follow_someone)
text_edit_multiline!(app, app.add_contact)
.desired_width(f32::INFINITY)
.hint_text("npub1, hex key, nprofile1, or user@domain"),
);
});
if ui.button("follow").clicked() {
ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
let mut try_add = false;
let mut want_close = false;
let mut can_close = false;
app.theme.accent_button_1_style(ui.style_mut());
if ui.button("Add and close").clicked() {
try_add |= true;
want_close = true;
}
btn_h_space!(ui);
app.theme.accent_button_2_style(ui.style_mut());
if ui.button("Add and continue").clicked() {
try_add |= true;
}
if try_add {
let mut add_failed = false;
if let Ok(pubkey) =
PublicKey::try_from_bech32_string(app.follow_someone.trim(), true)
PublicKey::try_from_bech32_string(app.add_contact.trim(), true)
{
let _ = GLOBALS
.to_overlord
.send(ToOverlordMessage::FollowPubkey(pubkey, list, true));
app.entering_follow_someone_on_list = false;
can_close = true;
} else if let Ok(pubkey) =
PublicKey::try_from_hex_string(app.follow_someone.trim(), true)
PublicKey::try_from_hex_string(app.add_contact.trim(), true)
{
let _ = GLOBALS
.to_overlord
.send(ToOverlordMessage::FollowPubkey(pubkey, list, true));
app.entering_follow_someone_on_list = false;
can_close = true;
} else if let Ok(profile) =
Profile::try_from_bech32_string(app.follow_someone.trim(), true)
Profile::try_from_bech32_string(app.add_contact.trim(), true)
{
let _ = GLOBALS.to_overlord.send(ToOverlordMessage::FollowNprofile(
profile.clone(),
list,
true,
));
app.entering_follow_someone_on_list = false;
} else if gossip_lib::nip05::parse_nip05(app.follow_someone.trim()).is_ok() {
can_close = true;
} else if gossip_lib::nip05::parse_nip05(app.add_contact.trim()).is_ok() {
let _ = GLOBALS.to_overlord.send(ToOverlordMessage::FollowNip05(
app.follow_someone.trim().to_owned(),
app.add_contact.trim().to_owned(),
list,
true,
));
} else {
add_failed = true;
GLOBALS
.status_queue
.write()
.write("Invalid pubkey.".to_string());
}
app.follow_someone = "".to_owned();
if !add_failed {
app.add_contact = "".to_owned();
app.people_list.add_contact_search.clear();
app.people_list.add_contact_searched = None;
app.people_list.add_contact_search_selected = None;
app.people_list.add_contact_search_results.clear();
}
if want_close && can_close {
app.people_list.entering_follow_someone_on_list = false;
mark_refresh(app);
}
}
});
});
});
});
if ret.inner.clicked() {
app.entering_follow_someone_on_list = false;
}
app.people_list.entering_follow_someone_on_list = false;
app.people_list.add_contact_search.clear();
app.people_list.add_contact_searched = None;
app.people_list.add_contact_search_selected = None;
app.people_list.add_contact_search_results.clear();
}
}
fn recalc_add_contact_search(app: &mut GossipUi, output: &mut TextEditOutput) {
// only recalc if search text changed
if app.people_list.add_contact_search.len() > 2 && output.cursor_range.is_some() {
if Some(&app.people_list.add_contact_search)
!= app.people_list.add_contact_searched.as_ref()
{
let mut pairs = GLOBALS
.people
.search_people_to_tag(app.people_list.add_contact_search.as_str())
.unwrap_or_default();
// followed contacts first
pairs.sort_by(|(_, ak), (_, bk)| {
let af = GLOBALS
.storage
.is_person_in_list(ak, gossip_lib::PersonList::Followed)
.unwrap_or(false);
let bf = GLOBALS
.storage
.is_person_in_list(bk, gossip_lib::PersonList::Followed)
.unwrap_or(false);
bf.cmp(&af).then(std::cmp::Ordering::Greater)
});
app.people_list.add_contact_searched = Some(app.people_list.add_contact_search.clone());
app.people_list.add_contact_search_results = pairs.to_owned();
}
} else {
app.people_list.add_contact_searched = None;
app.people_list.add_contact_search_results.clear();
}
}
fn render_clear_list_confirm_popup(ui: &mut Ui, app: &mut GossipUi, list: PersonList) {
const DLG_SIZE: Vec2 = vec2(250.0, 40.0);
if widgets::modal_popup(ui, DLG_SIZE, |ui| {
ui.vertical(|ui| {
ui.label("Are you sure you want to clear this list?");
ui.add_space(10.0);
ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
ui.horizontal(|ui| {
app.theme.accent_button_2_style(ui.style_mut());
if ui.button("Cancel").clicked() {
app.people_list.clear_list_needs_confirm = false;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::default()), |ui| {
app.theme.accent_button_1_style(ui.style_mut());
if ui.button("YES, CLEAR ALL").clicked() {
let _ = GLOBALS
.to_overlord
.send(ToOverlordMessage::ClearPersonList(list));
app.people_list.clear_list_needs_confirm = false;
mark_refresh(app);
}
});
});
});
});
})
.inner
.clicked()
{
app.people_list.clear_list_needs_confirm = false;
}
}
fn mark_refresh(app: &mut GossipUi) {
app.people_list.cache_next_refresh = Instant::now();
}
fn refresh_list_data(app: &mut GossipUi, list: gossip_lib::PersonList1) {
// prepare data
app.people_list.cache_people = {
let members = GLOBALS.storage.get_people_in_list(list).unwrap_or_default();
let mut people: Vec<(Person, bool)> = Vec::new();
for (pk, public) in &members {
if let Ok(Some(person)) = GLOBALS.storage.read_person(pk) {
people.push((person, *public));
} else {
let person = Person::new(pk.to_owned());
let _ = GLOBALS.storage.write_person(&person, None);
people.push((person, *public));
}
}
people.sort_by(|a, b| a.0.cmp(&b.0));
people
};
let latest_event_data = GLOBALS
.people
.latest_person_list_event_data
.get(&list)
.map(|v| v.value().clone())
.unwrap_or_default();
let mut asof = "time unknown".to_owned();
if let Ok(stamp) = time::OffsetDateTime::from_unix_timestamp(latest_event_data.when.0) {
if let Ok(formatted) = stamp.format(time::macros::format_description!(
"[year]-[month repr:short]-[day] ([weekday repr:short]) [hour]:[minute]"
)) {
asof = formatted;
}
}
app.people_list.cache_remote_tag = if latest_event_data.when.0 == 0 {
"REMOTE: not found on Active Relays".to_owned()
} else if let Some(private_len) = latest_event_data.private_len {
format!(
"REMOTE: {} (public_len={} private_len={})",
asof, latest_event_data.public_len, private_len
)
} else {
format!(
"REMOTE: {} (public_len={})",
asof, latest_event_data.public_len
)
};
let last_list_edit = match GLOBALS.storage.get_person_list_last_edit_time(list) {
Ok(Some(date)) => date,
Ok(None) => 0,
Err(e) => {
tracing::error!("{}", e);
0
}
};
let mut ledit = "time unknown".to_owned();
if last_list_edit > 0 {
if let Ok(stamp) = time::OffsetDateTime::from_unix_timestamp(last_list_edit) {
if let Ok(formatted) = stamp.format(time::macros::format_description!(
"[year]-[month repr:short]-[day] ([weekday repr:short]) [hour]:[minute]"
)) {
ledit = formatted;
}
}
}
app.people_list.cache_local_tag = format!(
"LOCAL: {} (size={})",
ledit,
app.people_list.cache_people.len()
);
app.people_list.cache_next_refresh = Instant::now() + Duration::new(1, 0);
app.people_list.cache_last_list = Some(list);
}

View File

@ -6,6 +6,18 @@ mod list;
mod lists;
mod person;
pub(crate) use list::ListUi;
pub(super) fn enter_page(app: &mut GossipUi) {
if app.page == Page::PeopleLists {
// nothing yet
} else if let Page::PeopleList(plist) = app.page {
list::enter_page(app, plist);
} else if matches!(app.page, Page::Person(_)) {
// nothing yet
}
}
pub(super) fn update(app: &mut GossipUi, ctx: &Context, _frame: &mut eframe::Frame, ui: &mut Ui) {
if app.page == Page::PeopleLists {
lists::update(app, ctx, _frame, ui);

View File

@ -15,12 +15,12 @@ pub(super) fn update(app: &mut GossipUi, ctx: &Context, _frame: &mut eframe::Fra
widgets::page_header(ui, Page::RelaysActivityMonitor.name(), |ui| {
ui.set_enabled(!is_editing);
super::configure_list_btn(app, ui);
ui.add_space(20.0);
btn_h_space!(ui);
super::relay_filter_combo(app, ui);
ui.add_space(20.0);
btn_h_space!(ui);
super::relay_sort_combo(app, ui);
ui.add_space(20.0);
widgets::search_filter_field(ui, &mut app.relays.search, 200.0);
btn_h_space!(ui);
widgets::search_field(ui, &mut app.relays.search, 200.0);
ui.add_space(200.0); // search_field somehow doesn't "take up" space
if ui
.button(RichText::new(Page::RelaysCoverage.name()))

View File

@ -11,12 +11,12 @@ pub(super) fn update(app: &mut GossipUi, _ctx: &Context, _frame: &mut eframe::Fr
widgets::page_header(ui, Page::RelaysKnownNetwork.name(), |ui| {
ui.set_enabled(!is_editing);
super::configure_list_btn(app, ui);
ui.add_space(20.0);
btn_h_space!(ui);
super::relay_filter_combo(app, ui);
ui.add_space(20.0);
btn_h_space!(ui);
super::relay_sort_combo(app, ui);
ui.add_space(20.0);
widgets::search_filter_field(ui, &mut app.relays.search, 200.0);
btn_h_space!(ui);
widgets::search_field(ui, &mut app.relays.search, 200.0);
});
// TBD time how long this takes. We don't want expensive code in the UI

View File

@ -12,12 +12,12 @@ pub(super) fn update(app: &mut GossipUi, _ctx: &Context, _frame: &mut eframe::Fr
widgets::page_header(ui, Page::RelaysMine.name(), |ui| {
ui.set_enabled(!is_editing);
super::configure_list_btn(app, ui);
ui.add_space(20.0);
btn_h_space!(ui);
super::relay_filter_combo(app, ui);
ui.add_space(20.0);
btn_h_space!(ui);
super::relay_sort_combo(app, ui);
ui.add_space(20.0);
widgets::search_filter_field(ui, &mut app.relays.search, 200.0);
btn_h_space!(ui);
widgets::search_field(ui, &mut app.relays.search, 200.0);
ui.add_space(200.0); // search_field somehow doesn't "take up" space
widgets::set_important_button_visuals(ui, app);
if ui.button("Advertise Relay List")

View File

@ -1,7 +1,7 @@
use std::cmp::Ordering;
use super::{widgets, GossipUi, Page};
use eframe::{egui, epaint::PathShape};
use eframe::egui;
use egui::{Context, Ui};
use egui_winit::egui::{vec2, Id, Rect, RichText};
use gossip_lib::{comms::ToOverlordMessage, Relay, GLOBALS};
@ -448,63 +448,13 @@ fn entry_dialog_step2(ui: &mut Ui, app: &mut GossipUi) {
/// Draw button with configure popup
///
pub(super) fn configure_list_btn(app: &mut GossipUi, ui: &mut Ui) {
let (response, painter) = ui.allocate_painter(vec2(20.0, 20.0), egui::Sense::click());
let response = response.on_hover_cursor(egui::CursorIcon::PointingHand);
let response = if !app.relays.configure_list_menu_active {
response.on_hover_text("Configure List View")
} else {
response
};
let btn_rect = response.rect;
let color = if response.hovered() {
app.theme.accent_color()
} else {
ui.visuals().text_color()
};
let mut mesh = egui::Mesh::with_texture((&app.options_symbol).into());
mesh.add_rect_with_uv(
btn_rect.shrink(2.0),
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
color,
);
painter.add(egui::Shape::mesh(mesh));
ui.add_enabled_ui(true, |ui| {
let min_size = vec2(180.0, 20.0);
if response.clicked() {
app.relays.configure_list_menu_active ^= true;
}
let button_center_bottom = response.rect.center_bottom();
let seen_on_popup_position = button_center_bottom + vec2(-180.0, widgets::DROPDOWN_DISTANCE);
let id: Id = "configure-list-menu".into();
let mut frame = egui::Frame::popup(ui.style());
let area = egui::Area::new(id)
.movable(false)
.interactable(true)
.order(egui::Order::Foreground)
.fixed_pos(seen_on_popup_position)
.constrain(true);
if app.relays.configure_list_menu_active {
let menuresp = area.show(ui.ctx(), |ui| {
frame.fill = app.theme.accent_color();
frame.stroke = egui::Stroke::NONE;
// frame.shadow = egui::epaint::Shadow::NONE;
frame.rounding = egui::Rounding::same(5.0);
frame.inner_margin = egui::Margin::symmetric(20.0, 16.0);
frame.show(ui, |ui| {
let path = PathShape::convex_polygon(
[
button_center_bottom,
button_center_bottom
+ vec2(widgets::DROPDOWN_DISTANCE, widgets::DROPDOWN_DISTANCE),
button_center_bottom
+ vec2(-widgets::DROPDOWN_DISTANCE, widgets::DROPDOWN_DISTANCE),
]
.to_vec(),
app.theme.accent_color(),
egui::Stroke::NONE,
);
ui.painter().add(path);
widgets::MoreMenu::new(app)
.with_min_size(min_size)
.with_hover_text("Configure List View".to_owned())
.show(ui,&mut app.relays.configure_list_menu_active, |ui|{
let size = ui.spacing().interact_size.y * egui::vec2(1.6, 0.8);
// since we are displaying over an accent color background, load that style
@ -521,10 +471,6 @@ pub(super) fn configure_list_btn(app: &mut GossipUi, ui: &mut Ui) {
});
});
});
if menuresp.response.clicked_elsewhere() && !response.clicked() {
app.relays.configure_list_menu_active = false;
}
}
}
///

View File

@ -0,0 +1,170 @@
use egui_winit::egui::{self, text_edit::TextEditOutput, Key, Modifiers, RichText, Ui};
use gossip_lib::{Person, GLOBALS};
use nostr_types::PublicKey;
use crate::ui::GossipUi;
pub(in crate::ui) fn show_contact_search(
ui: &mut Ui,
app: &mut GossipUi,
output: &mut TextEditOutput,
selected: &mut Option<usize>,
search_results: Vec<(String, PublicKey)>,
enter_key: bool,
on_select_callback: impl Fn(&mut Ui, &mut GossipUi, &mut TextEditOutput, &(String, PublicKey)),
) {
let pos = if let Some(cursor) = output.cursor_range {
let rect = output.galley.pos_from_cursor(&cursor.primary); // position within textedit
output.text_draw_pos + rect.center_bottom().to_vec2()
} else {
let rect = output.galley.pos_from_cursor(&output.galley.end()); // position within textedit
output.text_draw_pos + rect.center_bottom().to_vec2()
};
// always compute the tooltip, but it is only shown when
// is_open is true. This is so we get the animation.
let frame = egui::Frame::popup(ui.style())
.rounding(egui::Rounding::ZERO)
.inner_margin(egui::Margin::same(0.0));
let area = egui::Area::new(ui.auto_id_with("compose-tagging-tooltip"))
.fixed_pos(pos)
.movable(false)
.constrain(true)
.interactable(true)
.order(egui::Order::Foreground);
let is_open = !search_results.is_empty();
// show search results
if is_open {
area.show(ui.ctx(), |ui| {
frame.show(ui, |ui| {
egui::ScrollArea::vertical()
.max_width(super::TAGG_WIDTH)
.max_height(250.0)
.show(ui, |ui| {
for (i, pair) in search_results.iter().enumerate() {
let avatar = if let Some(avatar) = app.try_get_avatar(ui.ctx(), &pair.1)
{
avatar
} else {
app.placeholder_avatar.clone()
};
let frame = egui::Frame::none()
.rounding(egui::Rounding::ZERO)
.inner_margin(egui::Margin::symmetric(10.0, 5.0));
let mut prepared = frame.begin(ui);
prepared.content_ui.set_min_width(super::TAGG_WIDTH);
prepared.content_ui.set_max_width(super::TAGG_WIDTH);
prepared.content_ui.set_min_height(27.0);
let frame_rect = (prepared.frame.inner_margin
+ prepared.frame.outer_margin)
.expand_rect(prepared.content_ui.min_rect());
let response = ui
.interact(
frame_rect,
ui.auto_id_with(pair.1.as_hex_string()),
egui::Sense::click(),
)
.on_hover_cursor(egui::CursorIcon::PointingHand);
// mouse hover moves selected index
*selected = if response.hovered() {
Some(i)
} else {
*selected
};
let is_selected = Some(i) == *selected;
{
// render inside of frame using prepared.content_ui
let ui = &mut prepared.content_ui;
if is_selected {
app.theme.on_accent_style(ui.style_mut())
}
let person = GLOBALS
.storage
.read_person(&pair.1)
.unwrap_or(Some(Person::new(pair.1)))
.unwrap_or(Person::new(pair.1));
ui.horizontal(|ui| {
super::paint_avatar(
ui,
&person,
&avatar,
super::AvatarSize::Mini,
);
ui.vertical(|ui| {
super::truncated_label(
ui,
RichText::new(&pair.0).small(),
super::TAGG_WIDTH - 33.0,
);
let mut nip05 =
RichText::new(person.nip05().unwrap_or_default())
.weak()
.small();
if !person.nip05_valid {
nip05 = nip05.strikethrough()
}
super::truncated_label(ui, nip05, super::TAGG_WIDTH - 33.0);
});
})
};
prepared.frame.fill = if is_selected {
app.theme.accent_color()
} else {
egui::Color32::TRANSPARENT
};
prepared.end(ui);
if is_selected {
response.scroll_to_me(None)
}
let clicked = response.interact(egui::Sense::click()).clicked();
if clicked || (enter_key && is_selected) {
on_select_callback(ui, app, output, pair);
}
}
});
});
});
}
area.show_open_close_animation(ui.ctx(), &frame, is_open);
}
pub(in crate::ui) fn capture_keyboard_for_search(
ui: &mut Ui,
result_len: usize,
selected: Option<usize>,
) -> (Option<usize>, bool) {
ui.input_mut(|i| {
// enter
let enter = i.count_and_consume_key(Modifiers::NONE, Key::Enter) > 0;
// up / down
let mut index = selected.unwrap_or(0);
let down = i.count_and_consume_key(Modifiers::NONE, Key::ArrowDown);
let up = i.count_and_consume_key(Modifiers::NONE, Key::ArrowUp);
index += down;
index = index.min(result_len.saturating_sub(1));
index = index.saturating_sub(up);
// tab will cycle down and wrap
let tab = i.count_and_consume_key(Modifiers::NONE, Key::Tab);
index += tab;
if index > result_len.saturating_sub(1) {
index = 0;
}
(Some(index), enter)
})
}

View File

@ -1,13 +1,17 @@
mod avatar;
pub(crate) use avatar::{paint_avatar, AvatarSize};
mod contact_search;
pub(super) use contact_search::{capture_keyboard_for_search, show_contact_search};
mod copy_button;
pub(crate) mod list_entry;
pub use copy_button::{CopyButton, COPY_SYMBOL_SIZE};
mod nav_item;
use egui_winit::egui::text_edit::TextEditOutput;
use egui_winit::egui::{
self, vec2, FontSelection, Rect, Response, Sense, TextEdit, Ui, WidgetText,
self, vec2, FontSelection, Rect, Sense, TextEdit, Ui, WidgetText,
};
pub use nav_item::NavItem;
@ -17,6 +21,9 @@ pub use relay_entry::{RelayEntry, RelayEntryView};
mod modal_popup;
pub use modal_popup::modal_popup;
mod more_menu;
pub(super) use more_menu::MoreMenu;
mod information_popup;
pub use information_popup::InformationPopup;
pub use information_popup::ProfilePopup;
@ -87,16 +94,16 @@ pub fn break_anywhere_hyperlink_to(ui: &mut Ui, text: impl Into<WidgetText>, url
ui.hyperlink_to(job.job, url);
}
pub fn search_filter_field(ui: &mut Ui, field: &mut String, width: f32) -> Response {
pub fn search_field(ui: &mut Ui, field: &mut String, width: f32) -> TextEditOutput {
// search field
let response = ui.add(
TextEdit::singleline(field)
let output = TextEdit::singleline(field)
.text_color(ui.visuals().widgets.inactive.fg_stroke.color)
.desired_width(width),
);
.desired_width(width)
.show(ui);
let rect = Rect::from_min_size(
response.rect.right_top() - vec2(response.rect.height(), 0.0),
vec2(response.rect.height(), response.rect.height()),
output.response.rect.right_top() - vec2(output.response.rect.height(), 0.0),
vec2(output.response.rect.height(), output.response.rect.height()),
);
// search clear button
@ -114,7 +121,7 @@ pub fn search_filter_field(ui: &mut Ui, field: &mut String, width: f32) -> Respo
field.clear();
}
response
output
}
pub(super) fn set_important_button_visuals(ui: &mut Ui, app: &GossipUi) {

View File

@ -0,0 +1,117 @@
use eframe::epaint::PathShape;
use egui_winit::egui::{Ui, self, vec2, Rect, Id, Vec2, TextureHandle, Color32};
use crate::ui::GossipUi;
pub(in crate::ui) struct MoreMenu {
id: Id,
min_size: Vec2,
hover_text: Option<String>,
accent_color: Color32,
options_symbol: TextureHandle,
}
impl MoreMenu {
pub fn new(app: &GossipUi) -> Self {
Self {
id: "more-menu".into(),
min_size: Vec2 { x: 0.0, y: 0.0 },
hover_text: None,
accent_color: app.theme.accent_color(),
options_symbol: app.options_symbol.clone(),
}
}
#[allow(unused)]
pub fn with_id(mut self, id: impl Into<Id>) -> Self {
self.id = id.into();
self
}
#[allow(unused)]
pub fn with_min_size(mut self, min_size: Vec2) -> Self {
self.min_size = min_size;
self
}
#[allow(unused)]
pub fn with_hover_text(mut self, text: String) -> Self {
self.hover_text = Some(text);
self
}
pub fn show(&self, ui: &mut Ui, active: &mut bool, content: impl FnOnce(&mut Ui) ) {
let (response, painter) = ui.allocate_painter(vec2(20.0, 20.0), egui::Sense::click());
let response = response.on_hover_cursor(egui::CursorIcon::PointingHand);
let response = if let Some(text) = &self.hover_text {
if !*active {
response.on_hover_text(text)
} else {
response
}
} else {
response
};
let btn_rect = response.rect;
let color = if response.hovered() {
self.accent_color
} else {
ui.visuals().text_color()
};
let mut mesh = egui::Mesh::with_texture((&self.options_symbol).into());
mesh.add_rect_with_uv(
btn_rect.shrink(2.0),
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
color,
);
painter.add(egui::Shape::mesh(mesh));
if response.clicked() {
*active ^= true;
}
let button_center_bottom = response.rect.center_bottom();
let seen_on_popup_position = button_center_bottom + vec2(-(self.min_size.x - 2.0*super::DROPDOWN_DISTANCE), super::DROPDOWN_DISTANCE);
let mut frame = egui::Frame::popup(ui.style());
let area = egui::Area::new(self.id)
.movable(false)
.interactable(true)
.order(egui::Order::Foreground)
.fixed_pos(seen_on_popup_position)
.constrain(true);
if *active {
let menuresp = area.show(ui.ctx(), |ui| {
frame.fill = self.accent_color;
frame.stroke = egui::Stroke::NONE;
// frame.shadow = egui::epaint::Shadow::NONE;
frame.rounding = egui::Rounding::same(5.0);
frame.inner_margin = egui::Margin::symmetric(20.0, 16.0);
frame.show(ui, |ui| {
ui.set_min_size(self.min_size);
let path = PathShape::convex_polygon(
[
button_center_bottom,
button_center_bottom
+ vec2(super::DROPDOWN_DISTANCE, super::DROPDOWN_DISTANCE),
button_center_bottom
+ vec2(-super::DROPDOWN_DISTANCE, super::DROPDOWN_DISTANCE),
]
.to_vec(),
self.accent_color,
egui::Stroke::NONE,
);
ui.painter().add(path);
// now show menu content
content(ui);
});
});
if menuresp.response.clicked_elsewhere() && !response.clicked() {
*active = false;
}
}
}
}

View File

@ -86,7 +86,7 @@ pub(super) fn update(app: &mut GossipUi, _ctx: &Context, _frame: &mut eframe::Fr
ui.horizontal(|ui| {
ui.label("Follow Someone:");
if ui
.add(text_edit_line!(app, app.follow_someone).hint_text(
.add(text_edit_line!(app, app.add_contact).hint_text(
"Enter a key (bech32 npub1 or hex), or an nprofile, or a DNS id (user@domain)",
))
.changed()
@ -94,14 +94,14 @@ pub(super) fn update(app: &mut GossipUi, _ctx: &Context, _frame: &mut eframe::Fr
app.wizard_state.error = None;
}
if ui.button("follow").clicked() {
if let Ok(pubkey) = PublicKey::try_from_bech32_string(app.follow_someone.trim(), true) {
if let Ok(pubkey) = PublicKey::try_from_bech32_string(app.add_contact.trim(), true) {
let _ = GLOBALS.to_overlord.send(ToOverlordMessage::FollowPubkey(
pubkey,
PersonList::Followed,
true,
));
} else if let Ok(pubkey) =
PublicKey::try_from_hex_string(app.follow_someone.trim(), true)
PublicKey::try_from_hex_string(app.add_contact.trim(), true)
{
let _ = GLOBALS.to_overlord.send(ToOverlordMessage::FollowPubkey(
pubkey,
@ -109,23 +109,23 @@ pub(super) fn update(app: &mut GossipUi, _ctx: &Context, _frame: &mut eframe::Fr
true,
));
} else if let Ok(profile) =
Profile::try_from_bech32_string(app.follow_someone.trim(), true)
Profile::try_from_bech32_string(app.add_contact.trim(), true)
{
let _ = GLOBALS.to_overlord.send(ToOverlordMessage::FollowNprofile(
profile,
PersonList::Followed,
true,
));
} else if gossip_lib::nip05::parse_nip05(app.follow_someone.trim()).is_ok() {
} else if gossip_lib::nip05::parse_nip05(app.add_contact.trim()).is_ok() {
let _ = GLOBALS.to_overlord.send(ToOverlordMessage::FollowNip05(
app.follow_someone.trim().to_owned(),
app.add_contact.trim().to_owned(),
PersonList::Followed,
true,
));
} else {
app.wizard_state.error = Some("ERROR: Invalid pubkey".to_owned());
}
app.follow_someone = "".to_owned();
app.add_contact = "".to_owned();
}
});