Merge profiling editing #625

Changelog-Added: Added profile editing
This commit is contained in:
William Casarin
2025-01-04 13:15:25 -08:00
24 changed files with 893 additions and 209 deletions

View File

@@ -330,6 +330,14 @@ impl Accounts {
None
}
pub fn contains_full_kp(&self, pubkey: &enostr::Pubkey) -> bool {
if let Some(contains) = self.contains_account(pubkey.bytes()) {
contains.has_nsec
} else {
false
}
}
#[must_use = "UnknownIdAction's must be handled. Use .process_unknown_id_action()"]
pub fn add_account(&mut self, account: Keypair) -> AddAccountAction {
let pubkey = account.pubkey;
@@ -567,6 +575,18 @@ impl Accounts {
self.needs_relay_config = false;
}
}
pub fn get_full<'a>(&'a self, pubkey: &[u8; 32]) -> Option<FilledKeypair<'a>> {
if let Some(contains) = self.contains_account(pubkey) {
if contains.has_nsec {
if let Some(kp) = self.get_account(contains.index) {
return kp.to_full();
}
}
}
None
}
}
fn get_selected_index(accounts: &[UserAccount], keystore: &KeyStorageType) -> Option<usize> {

View File

@@ -49,4 +49,11 @@ impl NotedeckTextStyle {
pub fn get_font_id(&self, ctx: &Context) -> FontId {
FontId::new(get_font_size(ctx, self), self.font_family())
}
pub fn get_bolded_font(&self, ctx: &Context) -> FontId {
FontId::new(
get_font_size(ctx, self),
egui::FontFamily::Name(crate::NamedFontFamily::Bold.as_str().into()),
)
}
}

View File

@@ -3,6 +3,7 @@ use notedeck_chrome::setup::generate_native_options;
use notedeck_chrome::Notedeck;
use notedeck_columns::ui::configure_deck::ConfigureDeckView;
use notedeck_columns::ui::edit_deck::EditDeckView;
use notedeck_columns::ui::profile::EditProfileView;
use notedeck_columns::ui::{
account_login_view::AccountLoginView, PostView, Preview, PreviewApp, PreviewConfig, ProfilePic,
ProfilePreview, RelayView,
@@ -95,5 +96,6 @@ async fn main() {
PostView,
ConfigureDeckView,
EditDeckView,
EditProfileView,
);
}

View File

@@ -3,3 +3,4 @@ use egui::Color32;
pub const ALMOST_WHITE: Color32 = Color32::from_rgb(0xFA, 0xFA, 0xFA);
pub const MID_GRAY: Color32 = Color32::from_rgb(0xbd, 0xbd, 0xbd);
pub const PINK: Color32 = Color32::from_rgb(0xE4, 0x5A, 0xC9);
pub const TEAL: Color32 = Color32::from_rgb(0x77, 0xDC, 0xE1);

View File

@@ -23,6 +23,7 @@ mod nav;
mod notes_holder;
mod post;
mod profile;
mod profile_state;
pub mod relay_pool_manager;
mod route;
mod subscriptions;
@@ -42,6 +43,6 @@ pub mod storage;
pub use app::Damus;
pub use error::Error;
pub use profile::DisplayName;
pub use profile::NostrName;
pub type Result<T> = std::result::Result<T, error::Error>;

View File

@@ -6,7 +6,8 @@ use crate::{
deck_state::DeckState,
decks::{Deck, DecksAction, DecksCache},
notes_holder::NotesHolder,
profile::Profile,
profile::{Profile, ProfileAction, SaveProfileChanges},
profile_state::ProfileState,
relay_pool_manager::RelayPoolManager,
route::Route,
thread::Thread,
@@ -21,6 +22,7 @@ use crate::{
configure_deck::ConfigureDeckView,
edit_deck::{EditDeckResponse, EditDeckView},
note::{PostAction, PostType},
profile::EditProfileView,
support::SupportView,
RelayView, View,
},
@@ -39,6 +41,7 @@ pub enum RenderNavAction {
RemoveColumn,
PostAction(PostAction),
NoteAction(NoteAction),
ProfileAction(ProfileAction),
SwitchingAction(SwitchingAction),
}
@@ -168,6 +171,16 @@ impl RenderNavResponse {
RenderNavAction::SwitchingAction(switching_action) => {
switching_occured = switching_action.process(&mut app.decks_cache, ctx);
}
RenderNavAction::ProfileAction(profile_action) => {
profile_action.process(
&mut app.view_state.pubkey_to_profile_state,
ctx.ndb,
ctx.pool,
get_active_columns_mut(ctx.accounts, &mut app.decks_cache)
.column_mut(col)
.router_mut(),
);
}
}
}
@@ -368,6 +381,35 @@ fn render_nav_body(
action
}
Route::EditProfile(pubkey) => {
let mut action = None;
if let Some(kp) = ctx.accounts.get_full(pubkey.bytes()) {
let state = app
.view_state
.pubkey_to_profile_state
.entry(*kp.pubkey)
.or_insert_with(|| {
let txn = Transaction::new(ctx.ndb).expect("txn");
if let Ok(record) = ctx.ndb.get_profile_by_pubkey(&txn, kp.pubkey.bytes()) {
ProfileState::from_profile(&record)
} else {
ProfileState::default()
}
});
if EditProfileView::new(state, ctx.img_cache).ui(ui) {
if let Some(taken_state) =
app.view_state.pubkey_to_profile_state.remove(kp.pubkey)
{
action = Some(RenderNavAction::ProfileAction(ProfileAction::SaveChanges(
SaveProfileChanges::new(kp.to_full(), taken_state),
)))
}
}
} else {
error!("Pubkey in EditProfile route did not have an nsec attached in Accounts");
}
action
}
}
}

View File

@@ -1,28 +1,43 @@
use enostr::{Filter, Pubkey};
use nostrdb::{FilterBuilder, Ndb, ProfileRecord, Transaction};
use std::collections::HashMap;
use enostr::{Filter, FullKeypair, Pubkey, RelayPool};
use nostrdb::{
FilterBuilder, Ndb, Note, NoteBuildOptions, NoteBuilder, ProfileRecord, Transaction,
};
use notedeck::{filter::default_limit, FilterState, MuteFun, NoteCache, NoteRef};
use tracing::info;
use crate::{
multi_subscriber::MultiSubscriber,
notes_holder::NotesHolder,
profile_state::ProfileState,
route::{Route, Router},
timeline::{copy_notes_into_timeline, PubkeySource, Timeline, TimelineKind, TimelineTab},
};
pub enum DisplayName<'a> {
One(&'a str),
Both {
username: &'a str,
display_name: &'a str,
},
pub struct NostrName<'a> {
pub username: Option<&'a str>,
pub display_name: Option<&'a str>,
pub nip05: Option<&'a str>,
}
impl<'a> DisplayName<'a> {
pub fn username(&self) -> &'a str {
match self {
Self::One(n) => n,
Self::Both { username, .. } => username,
impl<'a> NostrName<'a> {
pub fn name(&self) -> &'a str {
if let Some(name) = self.username {
name
} else if let Some(name) = self.display_name {
name
} else {
self.nip05.unwrap_or("??")
}
}
pub fn unknown() -> Self {
Self {
username: None,
display_name: None,
nip05: None,
}
}
}
@@ -31,19 +46,35 @@ fn is_empty(s: &str) -> bool {
s.chars().all(|c| c.is_whitespace())
}
pub fn get_profile_name<'a>(record: &ProfileRecord<'a>) -> Option<DisplayName<'a>> {
let profile = record.record().profile()?;
let display_name = profile.display_name().filter(|n| !is_empty(n));
let name = profile.name().filter(|n| !is_empty(n));
pub fn get_display_name<'a>(record: Option<&ProfileRecord<'a>>) -> NostrName<'a> {
if let Some(record) = record {
if let Some(profile) = record.record().profile() {
let display_name = profile.display_name().filter(|n| !is_empty(n));
let username = profile.name().filter(|n| !is_empty(n));
let nip05 = if let Some(raw_nip05) = profile.nip05() {
if let Some(at_pos) = raw_nip05.find('@') {
if raw_nip05.starts_with('_') {
raw_nip05.get(at_pos + 1..)
} else {
Some(raw_nip05)
}
} else {
None
}
} else {
None
};
match (display_name, name) {
(None, None) => None,
(Some(disp), None) => Some(DisplayName::One(disp)),
(None, Some(username)) => Some(DisplayName::One(username)),
(Some(display_name), Some(username)) => Some(DisplayName::Both {
display_name,
username,
}),
NostrName {
username,
display_name,
nip05,
}
} else {
NostrName::unknown()
}
} else {
NostrName::unknown()
}
}
@@ -131,3 +162,62 @@ impl NotesHolder for Profile {
self.multi_subscriber = Some(subscriber);
}
}
pub struct SaveProfileChanges {
pub kp: FullKeypair,
pub state: ProfileState,
}
impl SaveProfileChanges {
pub fn new(kp: FullKeypair, state: ProfileState) -> Self {
Self { kp, state }
}
pub fn to_note(&self) -> Note {
let sec = &self.kp.secret_key.to_secret_bytes();
add_client_tag(NoteBuilder::new())
.kind(0)
.content(&self.state.to_json())
.options(NoteBuildOptions::default().created_at(true).sign(sec))
.build()
.expect("should build")
}
}
fn add_client_tag(builder: NoteBuilder<'_>) -> NoteBuilder<'_> {
builder
.start_tag()
.tag_str("client")
.tag_str("Damus Notedeck")
}
pub enum ProfileAction {
Edit(FullKeypair),
SaveChanges(SaveProfileChanges),
}
impl ProfileAction {
pub fn process(
&self,
state_map: &mut HashMap<Pubkey, ProfileState>,
ndb: &Ndb,
pool: &mut RelayPool,
router: &mut Router<Route>,
) {
match self {
ProfileAction::Edit(kp) => {
router.route_to(Route::EditProfile(kp.pubkey));
}
ProfileAction::SaveChanges(changes) => {
let raw_msg = format!("[\"EVENT\",{}]", changes.to_note().json().unwrap());
let _ = ndb.process_client_event(raw_msg.as_str());
let _ = state_map.remove_entry(&changes.kp.pubkey);
info!("sending {}", raw_msg);
pool.send(&enostr::ClientMessage::raw(raw_msg));
router.go_back();
}
}
}
}

View File

@@ -0,0 +1,79 @@
use nostrdb::{NdbProfile, ProfileRecord};
#[derive(Default, Debug)]
pub struct ProfileState {
pub display_name: String,
pub name: String,
pub picture: String,
pub banner: String,
pub about: String,
pub website: String,
pub lud16: String,
pub nip05: String,
}
impl ProfileState {
pub fn from_profile(record: &ProfileRecord<'_>) -> Self {
let display_name = get_item(record, |p| p.display_name());
let username = get_item(record, |p| p.name());
let profile_picture = get_item(record, |p| p.picture());
let cover_image = get_item(record, |p| p.banner());
let about = get_item(record, |p| p.about());
let website = get_item(record, |p| p.website());
let lud16 = get_item(record, |p| p.lud16());
let nip05 = get_item(record, |p| p.nip05());
Self {
display_name,
name: username,
picture: profile_picture,
banner: cover_image,
about,
website,
lud16,
nip05,
}
}
pub fn to_json(&self) -> String {
let mut fields = Vec::new();
if !self.display_name.is_empty() {
fields.push(format!(r#""display_name":"{}""#, self.display_name));
}
if !self.name.is_empty() {
fields.push(format!(r#""name":"{}""#, self.name));
}
if !self.picture.is_empty() {
fields.push(format!(r#""picture":"{}""#, self.picture));
}
if !self.banner.is_empty() {
fields.push(format!(r#""banner":"{}""#, self.banner));
}
if !self.about.is_empty() {
fields.push(format!(r#""about":"{}""#, self.about));
}
if !self.website.is_empty() {
fields.push(format!(r#""website":"{}""#, self.website));
}
if !self.lud16.is_empty() {
fields.push(format!(r#""lud16":"{}""#, self.lud16));
}
if !self.nip05.is_empty() {
fields.push(format!(r#""nip05":"{}""#, self.nip05));
}
format!("{{{}}}", fields.join(","))
}
}
fn get_item<'a>(
record: &ProfileRecord<'a>,
item_retriever: fn(NdbProfile<'a>) -> Option<&'a str>,
) -> String {
record
.record()
.profile()
.and_then(item_retriever)
.map_or_else(String::new, ToString::to_string)
}

View File

@@ -16,6 +16,7 @@ pub enum Route {
Relays,
ComposeNote,
AddColumn(AddColumnRoute),
EditProfile(Pubkey),
Support,
NewDeck,
EditDeck(usize),
@@ -104,6 +105,7 @@ impl Route {
Route::Support => ColumnTitle::simple("Damus Support"),
Route::NewDeck => ColumnTitle::simple("Add Deck"),
Route::EditDeck(_) => ColumnTitle::simple("Edit Deck"),
Route::EditProfile(_) => ColumnTitle::simple("Edit Profile"),
}
}
}
@@ -215,6 +217,7 @@ impl fmt::Display for Route {
Route::Support => write!(f, "Support"),
Route::NewDeck => write!(f, "Add Deck"),
Route::EditDeck(_) => write!(f, "Edit Deck"),
Route::EditProfile(_) => write!(f, "Edit Profile"),
}
}
}

View File

@@ -541,6 +541,11 @@ fn serialize_route(route: &Route, columns: &Columns) -> Option<String> {
selections.push(Selection::Keyword(Keyword::Edit));
selections.push(Selection::Payload(index.to_string()));
}
Route::EditProfile(pubkey) => {
selections.push(Selection::Keyword(Keyword::Profile));
selections.push(Selection::Keyword(Keyword::Edit));
selections.push(Selection::Payload(pubkey.hex()));
}
}
if selections.is_empty() {
@@ -649,6 +654,15 @@ fn selections_to_route(selections: Vec<Selection>) -> Option<CleanIntermediaryRo
Selection::Keyword(Keyword::DeckAuthor) => Some(CleanIntermediaryRoute::ToTimeline(
TimelineKind::profile(PubkeySource::DeckAuthor),
)),
Selection::Keyword(Keyword::Edit) => {
if let Selection::Payload(hex) = selections.get(2)? {
Some(CleanIntermediaryRoute::ToRoute(Route::EditProfile(
Pubkey::from_hex(hex.as_str()).ok()?,
)))
} else {
None
}
}
_ => None,
},
Selection::Keyword(Keyword::Universe) => {

View File

@@ -241,10 +241,9 @@ impl<'a> TitleNeedsDb<'a> {
let pubkey = pubkey_source.to_pubkey(deck_author);
let profile = ndb.get_profile_by_pubkey(txn, pubkey);
let m_name = profile
.ok()
.as_ref()
.and_then(|p| crate::profile::get_profile_name(p))
.map(|display_name| display_name.username());
.ok()
.map(|p| crate::profile::get_display_name(Some(p)).name());
m_name.unwrap_or("Profile")
} else {

View File

@@ -3,7 +3,7 @@ use crate::{
draft::Drafts,
nav::RenderNavAction,
notes_holder::NotesHolderStorage,
profile::Profile,
profile::{Profile, ProfileAction},
thread::Thread,
timeline::{TimelineId, TimelineKind},
ui::{
@@ -117,6 +117,7 @@ pub fn render_timeline_route(
TimelineRoute::Profile(pubkey) => render_profile_route(
&pubkey,
accounts,
ndb,
profiles,
img_cache,
@@ -155,6 +156,7 @@ pub fn render_timeline_route(
#[allow(clippy::too_many_arguments)]
pub fn render_profile_route(
pubkey: &Pubkey,
accounts: &Accounts,
ndb: &Ndb,
profiles: &mut NotesHolderStorage<Profile>,
img_cache: &mut ImageCache,
@@ -163,8 +165,9 @@ pub fn render_profile_route(
ui: &mut egui::Ui,
is_muted: &MuteFun,
) -> Option<RenderNavAction> {
let note_action = ProfileView::new(
let action = ProfileView::new(
pubkey,
accounts,
col,
profiles,
ndb,
@@ -174,5 +177,16 @@ pub fn render_profile_route(
)
.ui(ui, is_muted);
note_action.map(RenderNavAction::NoteAction)
if let Some(action) = action {
match action {
ui::profile::ProfileViewAction::EditProfile => accounts
.get_full(pubkey.bytes())
.map(|kp| RenderNavAction::ProfileAction(ProfileAction::Edit(kp.to_full()))),
ui::profile::ProfileViewAction::Note(note_action) => {
Some(RenderNavAction::NoteAction(note_action))
}
}
} else {
None
}
}

View File

@@ -245,15 +245,7 @@ impl<'a> NavTitle<'a> {
TimelineRoute::Quote(_note_id) => {}
TimelineRoute::Profile(pubkey) => {
let txn = Transaction::new(self.ndb).unwrap();
if let Some(pfp) = self.pubkey_pfp(&txn, pubkey.bytes(), pfp_size) {
ui.add(pfp);
} else {
ui.add(
ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url())
.size(pfp_size),
);
}
self.show_profile(ui, pubkey, pfp_size);
}
},
@@ -264,9 +256,23 @@ impl<'a> NavTitle<'a> {
Route::Relays => {}
Route::NewDeck => {}
Route::EditDeck(_) => {}
Route::EditProfile(pubkey) => {
self.show_profile(ui, pubkey, pfp_size);
}
}
}
fn show_profile(&mut self, ui: &mut egui::Ui, pubkey: &Pubkey, pfp_size: f32) {
let txn = Transaction::new(self.ndb).unwrap();
if let Some(pfp) = self.pubkey_pfp(&txn, pubkey.bytes(), pfp_size) {
ui.add(pfp);
} else {
ui.add(
ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()).size(pfp_size),
);
};
}
fn title_label_value(title: &str) -> egui::Label {
egui::Label::new(RichText::new(title).text_style(NotedeckTextStyle::Body.text_style()))
.selectable(false)

View File

@@ -1,5 +1,5 @@
use crate::actionbar::NoteAction;
use crate::ui;
use crate::{actionbar::NoteAction, profile::get_display_name};
use egui::Sense;
use enostr::Pubkey;
use nostrdb::{Ndb, Transaction};
@@ -79,12 +79,7 @@ fn mention_ui(
ui.horizontal(|ui| {
let profile = ndb.get_profile_by_pubkey(txn, pk).ok();
let name: String =
if let Some(name) = profile.as_ref().and_then(crate::profile::get_profile_name) {
format!("@{}", name.username())
} else {
"@???".to_string()
};
let name: String = format!("@{}", get_display_name(profile.as_ref()).name());
let resp = ui.add(
egui::Label::new(egui::RichText::new(name).color(link_color).size(size))

View File

@@ -16,6 +16,7 @@ pub use reply_description::reply_desc;
use crate::{
actionbar::NoteAction,
profile::get_display_name,
ui::{self, View},
};
@@ -25,7 +26,7 @@ use enostr::{NoteId, Pubkey};
use nostrdb::{Ndb, Note, NoteKey, Transaction};
use notedeck::{CachedNote, ImageCache, NoteCache, NotedeckTextStyle};
use super::profile::preview::{get_display_name, one_line_display_name_widget};
use super::profile::preview::one_line_display_name_widget;
pub struct NoteView<'a> {
ndb: &'a Ndb,

View File

@@ -0,0 +1,205 @@
use core::f32;
use egui::{vec2, Button, Layout, Margin, RichText, Rounding, ScrollArea, TextEdit};
use notedeck::{ImageCache, NotedeckTextStyle};
use crate::{colors, profile_state::ProfileState};
use super::{banner, unwrap_profile_url, ProfilePic};
pub struct EditProfileView<'a> {
state: &'a mut ProfileState,
img_cache: &'a mut ImageCache,
}
impl<'a> EditProfileView<'a> {
pub fn new(state: &'a mut ProfileState, img_cache: &'a mut ImageCache) -> Self {
Self { state, img_cache }
}
// return true to save
pub fn ui(&mut self, ui: &mut egui::Ui) -> bool {
ScrollArea::vertical()
.show(ui, |ui| {
banner(ui, Some(&self.state.banner), 188.0);
let padding = 24.0;
crate::ui::padding(padding, ui, |ui| {
self.inner(ui, padding);
});
ui.separator();
let mut save = false;
crate::ui::padding(padding, ui, |ui| {
ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(button("Save changes", 119.0).fill(colors::PINK))
.clicked()
{
save = true;
}
});
});
save
})
.inner
}
fn inner(&mut self, ui: &mut egui::Ui, padding: f32) {
ui.spacing_mut().item_spacing = egui::vec2(0.0, 16.0);
let mut pfp_rect = ui.available_rect_before_wrap();
let size = 80.0;
pfp_rect.set_width(size);
pfp_rect.set_height(size);
let pfp_rect = pfp_rect.translate(egui::vec2(0.0, -(padding + 2.0 + (size / 2.0))));
let pfp_url = unwrap_profile_url(if self.state.picture.is_empty() {
None
} else {
Some(&self.state.picture)
});
ui.put(
pfp_rect,
ProfilePic::new(self.img_cache, pfp_url).size(size),
);
in_frame(ui, |ui| {
ui.add(label("Display name"));
ui.add(singleline_textedit(&mut self.state.display_name));
});
in_frame(ui, |ui| {
ui.add(label("Username"));
ui.add(singleline_textedit(&mut self.state.name));
});
in_frame(ui, |ui| {
ui.add(label("Profile picture"));
ui.add(multiline_textedit(&mut self.state.picture));
});
in_frame(ui, |ui| {
ui.add(label("Banner"));
ui.add(multiline_textedit(&mut self.state.banner));
});
in_frame(ui, |ui| {
ui.add(label("About"));
ui.add(multiline_textedit(&mut self.state.about));
});
in_frame(ui, |ui| {
ui.add(label("Website"));
ui.add(singleline_textedit(&mut self.state.website));
});
in_frame(ui, |ui| {
ui.add(label("Lightning network address (lud16)"));
ui.add(multiline_textedit(&mut self.state.lud16));
});
in_frame(ui, |ui| {
ui.add(label("NIP-05 verification"));
ui.add(singleline_textedit(&mut self.state.nip05));
let split = &mut self.state.nip05.split('@');
let prefix = split.next();
let suffix = split.next();
if let Some(prefix) = prefix {
if let Some(suffix) = suffix {
let use_domain = if let Some(f) = prefix.chars().next() {
f == '_'
} else {
false
};
ui.colored_label(
ui.visuals().noninteractive().fg_stroke.color,
RichText::new(if use_domain {
format!("\"{}\" will be used for verification", suffix)
} else {
format!(
"\"{}\" at \"{}\" will be used for verification",
prefix, suffix
)
}),
);
}
}
});
}
}
fn label(text: &str) -> impl egui::Widget + '_ {
move |ui: &mut egui::Ui| -> egui::Response {
ui.label(RichText::new(text).font(NotedeckTextStyle::Body.get_bolded_font(ui.ctx())))
}
}
fn singleline_textedit(data: &mut String) -> impl egui::Widget + '_ {
TextEdit::singleline(data)
.min_size(vec2(0.0, 40.0))
.vertical_align(egui::Align::Center)
.margin(Margin::symmetric(12.0, 10.0))
.desired_width(f32::INFINITY)
}
fn multiline_textedit(data: &mut String) -> impl egui::Widget + '_ {
TextEdit::multiline(data)
// .min_size(vec2(0.0, 40.0))
.vertical_align(egui::Align::TOP)
.margin(Margin::symmetric(12.0, 10.0))
.desired_width(f32::INFINITY)
.desired_rows(1)
}
fn in_frame(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui)) {
egui::Frame::none().show(ui, |ui| {
ui.spacing_mut().item_spacing = egui::vec2(0.0, 8.0);
contents(ui);
});
}
fn button(text: &str, width: f32) -> egui::Button<'static> {
Button::new(text)
.rounding(Rounding::same(8.0))
.min_size(vec2(width, 40.0))
}
mod preview {
use notedeck::App;
use crate::{
profile_state::ProfileState,
test_data,
ui::{Preview, PreviewConfig},
};
use super::EditProfileView;
pub struct EditProfilePreivew {
state: ProfileState,
}
impl Default for EditProfilePreivew {
fn default() -> Self {
Self {
state: ProfileState::from_profile(&test_data::test_profile_record()),
}
}
}
impl App for EditProfilePreivew {
fn update(&mut self, ctx: &mut notedeck::AppContext<'_>, ui: &mut egui::Ui) {
EditProfileView::new(&mut self.state, ctx.img_cache).ui(ui);
}
}
impl<'a> Preview for EditProfileView<'a> {
type Prev = EditProfilePreivew;
fn preview(_cfg: PreviewConfig) -> Self::Prev {
EditProfilePreivew::default()
}
}
}

View File

@@ -1,11 +1,16 @@
pub mod edit;
pub mod picture;
pub mod preview;
use crate::notes_holder::NotesHolder;
use crate::profile::get_display_name;
use crate::ui::note::NoteOptions;
use egui::{ScrollArea, Widget};
use crate::{colors, images};
use crate::{notes_holder::NotesHolder, NostrName};
pub use edit::EditProfileView;
use egui::load::TexturePoll;
use egui::{vec2, Color32, Label, Layout, Rect, RichText, Rounding, ScrollArea, Sense, Stroke};
use enostr::Pubkey;
use nostrdb::{Ndb, Transaction};
use nostrdb::{Ndb, ProfileRecord, Transaction};
pub use picture::ProfilePic;
pub use preview::ProfilePreview;
use tracing::error;
@@ -13,10 +18,11 @@ use tracing::error;
use crate::{actionbar::NoteAction, notes_holder::NotesHolderStorage, profile::Profile};
use super::timeline::{tabs_ui, TimelineTabView};
use notedeck::{ImageCache, MuteFun, NoteCache};
use notedeck::{Accounts, ImageCache, MuteFun, NoteCache, NotedeckTextStyle};
pub struct ProfileView<'a> {
pubkey: &'a Pubkey,
accounts: &'a Accounts,
col_id: usize,
profiles: &'a mut NotesHolderStorage<Profile>,
note_options: NoteOptions,
@@ -25,9 +31,16 @@ pub struct ProfileView<'a> {
img_cache: &'a mut ImageCache,
}
pub enum ProfileViewAction {
EditProfile,
Note(NoteAction),
}
impl<'a> ProfileView<'a> {
#[allow(clippy::too_many_arguments)]
pub fn new(
pubkey: &'a Pubkey,
accounts: &'a Accounts,
col_id: usize,
profiles: &'a mut NotesHolderStorage<Profile>,
ndb: &'a Ndb,
@@ -37,6 +50,7 @@ impl<'a> ProfileView<'a> {
) -> Self {
ProfileView {
pubkey,
accounts,
col_id,
profiles,
ndb,
@@ -46,15 +60,18 @@ impl<'a> ProfileView<'a> {
}
}
pub fn ui(&mut self, ui: &mut egui::Ui, is_muted: &MuteFun) -> Option<NoteAction> {
pub fn ui(&mut self, ui: &mut egui::Ui, is_muted: &MuteFun) -> Option<ProfileViewAction> {
let scroll_id = egui::Id::new(("profile_scroll", self.col_id, self.pubkey));
ScrollArea::vertical()
.id_salt(scroll_id)
.show(ui, |ui| {
let mut action = None;
let txn = Transaction::new(self.ndb).expect("txn");
if let Ok(profile) = self.ndb.get_profile_by_pubkey(&txn, self.pubkey.bytes()) {
ProfilePreview::new(&profile, self.img_cache).ui(ui);
if self.profile_body(ui, profile) {
action = Some(ProfileViewAction::EditProfile);
}
}
let profile = self
.profiles
@@ -77,7 +94,7 @@ impl<'a> ProfileView<'a> {
let reversed = false;
TimelineTabView::new(
if let Some(note_action) = TimelineTabView::new(
profile.timeline.current_view(),
reversed,
self.note_options,
@@ -87,7 +104,327 @@ impl<'a> ProfileView<'a> {
self.img_cache,
)
.show(ui)
{
action = Some(ProfileViewAction::Note(note_action));
}
action
})
.inner
}
fn profile_body(&mut self, ui: &mut egui::Ui, profile: ProfileRecord<'_>) -> bool {
let mut action = false;
ui.vertical(|ui| {
banner(
ui,
profile.record().profile().and_then(|p| p.banner()),
120.0,
);
let padding = 12.0;
crate::ui::padding(padding, ui, |ui| {
let mut pfp_rect = ui.available_rect_before_wrap();
let size = 80.0;
pfp_rect.set_width(size);
pfp_rect.set_height(size);
let pfp_rect = pfp_rect.translate(egui::vec2(0.0, -(padding + 2.0 + (size / 2.0))));
ui.horizontal(|ui| {
ui.put(
pfp_rect,
ProfilePic::new(self.img_cache, get_profile_url(Some(&profile))).size(size),
);
if ui.add(copy_key_widget(&pfp_rect)).clicked() {
ui.output_mut(|w| {
w.copied_text = if let Some(bech) = self.pubkey.to_bech() {
bech
} else {
error!("Could not convert Pubkey to bech");
String::new()
}
});
}
if self.accounts.contains_full_kp(self.pubkey) {
ui.with_layout(Layout::right_to_left(egui::Align::Max), |ui| {
if ui.add(edit_profile_button()).clicked() {
action = true;
}
});
}
});
ui.add_space(18.0);
ui.add(display_name_widget(get_display_name(Some(&profile)), false));
ui.add_space(8.0);
ui.add(about_section_widget(&profile));
ui.horizontal_wrapped(|ui| {
if let Some(website_url) = profile
.record()
.profile()
.and_then(|p| p.website())
.filter(|s| !s.is_empty())
{
handle_link(ui, website_url);
}
if let Some(lud16) = profile
.record()
.profile()
.and_then(|p| p.lud16())
.filter(|s| !s.is_empty())
{
handle_lud16(ui, lud16);
}
});
});
});
action
}
}
fn handle_link(ui: &mut egui::Ui, website_url: &str) {
ui.image(egui::include_image!(
"../../../../../assets/icons/links_4x.png"
));
if ui
.label(RichText::new(website_url).color(colors::PINK))
.on_hover_cursor(egui::CursorIcon::PointingHand)
.interact(Sense::click())
.clicked()
{
if let Err(e) = open::that(website_url) {
error!("Failed to open URL {} because: {}", website_url, e);
};
}
}
fn handle_lud16(ui: &mut egui::Ui, lud16: &str) {
ui.image(egui::include_image!(
"../../../../../assets/icons/zap_4x.png"
));
let _ = ui.label(RichText::new(lud16).color(colors::PINK));
}
fn copy_key_widget(pfp_rect: &egui::Rect) -> impl egui::Widget + '_ {
|ui: &mut egui::Ui| -> egui::Response {
let painter = ui.painter();
let copy_key_rect = painter.round_rect_to_pixels(egui::Rect::from_center_size(
pfp_rect.center_bottom(),
egui::vec2(48.0, 28.0),
));
let resp = ui.interact(
copy_key_rect,
ui.id().with("custom_painter"),
Sense::click(),
);
let copy_key_rounding = Rounding::same(100.0);
let fill_color = if resp.hovered() {
ui.visuals().widgets.inactive.weak_bg_fill
} else {
ui.visuals().noninteractive().bg_stroke.color
};
painter.rect_filled(copy_key_rect, copy_key_rounding, fill_color);
let stroke_color = ui.visuals().widgets.inactive.weak_bg_fill;
painter.rect_stroke(
copy_key_rect.shrink(1.0),
copy_key_rounding,
Stroke::new(1.0, stroke_color),
);
egui::Image::new(egui::include_image!(
"../../../../../assets/icons/key_4x.png"
))
.paint_at(
ui,
painter.round_rect_to_pixels(egui::Rect::from_center_size(
copy_key_rect.center(),
egui::vec2(16.0, 16.0),
)),
);
resp
}
}
fn edit_profile_button() -> impl egui::Widget + 'static {
|ui: &mut egui::Ui| -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(vec2(124.0, 32.0), Sense::click());
let painter = ui.painter_at(rect);
let rect = painter.round_rect_to_pixels(rect);
painter.rect_filled(
rect,
Rounding::same(8.0),
if resp.hovered() {
ui.visuals().widgets.active.bg_fill
} else {
ui.visuals().widgets.inactive.bg_fill
},
);
painter.rect_stroke(
rect.shrink(1.0),
Rounding::same(8.0),
if resp.hovered() {
ui.visuals().widgets.active.bg_stroke
} else {
ui.visuals().widgets.inactive.bg_stroke
},
);
let edit_icon_size = vec2(16.0, 16.0);
let galley = painter.layout(
"Edit Profile".to_owned(),
NotedeckTextStyle::Button.get_font_id(ui.ctx()),
ui.visuals().text_color(),
rect.width(),
);
let space_between_icon_galley = 8.0;
let half_icon_size = edit_icon_size.x / 2.0;
let galley_rect = {
let galley_rect = Rect::from_center_size(rect.center(), galley.rect.size());
galley_rect.translate(vec2(half_icon_size + space_between_icon_galley / 2.0, 0.0))
};
let edit_icon_rect = {
let mut center = galley_rect.left_center();
center.x -= half_icon_size + space_between_icon_galley;
painter.round_rect_to_pixels(Rect::from_center_size(
painter.round_pos_to_pixel_center(center),
edit_icon_size,
))
};
painter.galley(galley_rect.left_top(), galley, Color32::WHITE);
egui::Image::new(egui::include_image!(
"../../../../../assets/icons/edit_icon_4x_dark.png"
))
.paint_at(ui, edit_icon_rect);
resp
}
}
fn display_name_widget(name: NostrName<'_>, add_placeholder_space: bool) -> impl egui::Widget + '_ {
move |ui: &mut egui::Ui| -> egui::Response {
let disp_resp = name.display_name.map(|disp_name| {
ui.add(
Label::new(
RichText::new(disp_name).text_style(NotedeckTextStyle::Heading3.text_style()),
)
.selectable(false),
)
});
let (username_resp, nip05_resp) = ui
.horizontal(|ui| {
let username_resp = name.username.map(|username| {
ui.add(
Label::new(
RichText::new(format!("@{}", username))
.size(16.0)
.color(colors::MID_GRAY),
)
.selectable(false),
)
});
let nip05_resp = name.nip05.map(|nip05| {
ui.image(egui::include_image!(
"../../../../../assets/icons/verified_4x.png"
));
ui.add(Label::new(
RichText::new(nip05).size(16.0).color(colors::TEAL),
))
});
(username_resp, nip05_resp)
})
.inner;
let resp = match (disp_resp, username_resp, nip05_resp) {
(Some(disp), Some(username), Some(nip05)) => disp.union(username).union(nip05),
(Some(disp), Some(username), None) => disp.union(username),
(Some(disp), None, None) => disp,
(None, Some(username), Some(nip05)) => username.union(nip05),
(None, Some(username), None) => username,
_ => ui.add(Label::new(RichText::new(name.name()))),
};
if add_placeholder_space {
ui.add_space(16.0);
}
resp
}
}
pub fn get_profile_url<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str {
unwrap_profile_url(profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())))
}
pub fn unwrap_profile_url(maybe_url: Option<&str>) -> &str {
if let Some(url) = maybe_url {
url
} else {
ProfilePic::no_pfp_url()
}
}
fn about_section_widget<'a, 'b>(profile: &'b ProfileRecord<'a>) -> impl egui::Widget + 'b
where
'b: 'a,
{
move |ui: &mut egui::Ui| {
if let Some(about) = profile.record().profile().and_then(|p| p.about()) {
let resp = ui.label(about);
ui.add_space(8.0);
resp
} else {
// need any Response so we dont need an Option
ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover())
}
}
}
fn banner_texture(ui: &mut egui::Ui, banner_url: &str) -> Option<egui::load::SizedTexture> {
// TODO: cache banner
if !banner_url.is_empty() {
let texture_load_res =
egui::Image::new(banner_url).load_for_size(ui.ctx(), ui.available_size());
if let Ok(texture_poll) = texture_load_res {
match texture_poll {
TexturePoll::Pending { .. } => {}
TexturePoll::Ready { texture, .. } => return Some(texture),
}
}
}
None
}
fn banner(ui: &mut egui::Ui, banner_url: Option<&str>, height: f32) -> egui::Response {
ui.add_sized([ui.available_size().x, height], |ui: &mut egui::Ui| {
banner_url
.and_then(|url| banner_texture(ui, url))
.map(|texture| {
images::aspect_fill(
ui,
Sense::hover(),
texture.id,
texture.size.x / texture.size.y,
)
})
.unwrap_or_else(|| ui.label(""))
})
}

View File

@@ -1,13 +1,13 @@
use crate::ui::ProfilePic;
use crate::{colors, images, DisplayName};
use egui::load::TexturePoll;
use egui::{Frame, Label, RichText, Sense, Widget};
use crate::NostrName;
use egui::{Frame, Label, RichText, Widget};
use egui_extras::Size;
use enostr::{NoteId, Pubkey};
use nostrdb::{Ndb, ProfileRecord, Transaction};
use nostrdb::ProfileRecord;
use notedeck::{ImageCache, NotedeckTextStyle, UserAccount};
use super::{about_section_widget, banner, display_name_widget, get_display_name, get_profile_url};
pub struct ProfilePreview<'a, 'cache> {
profile: &'a ProfileRecord<'a>,
cache: &'cache mut ImageCache,
@@ -28,41 +28,6 @@ impl<'a, 'cache> ProfilePreview<'a, 'cache> {
self.banner_height = size;
}
fn banner_texture(
ui: &mut egui::Ui,
profile: &ProfileRecord<'_>,
) -> Option<egui::load::SizedTexture> {
// TODO: cache banner
let banner = profile.record().profile().and_then(|p| p.banner());
if let Some(banner) = banner {
let texture_load_res =
egui::Image::new(banner).load_for_size(ui.ctx(), ui.available_size());
if let Ok(texture_poll) = texture_load_res {
match texture_poll {
TexturePoll::Pending { .. } => {}
TexturePoll::Ready { texture, .. } => return Some(texture),
}
}
}
None
}
fn banner(ui: &mut egui::Ui, profile: &ProfileRecord<'_>) -> egui::Response {
if let Some(texture) = Self::banner_texture(ui, profile) {
images::aspect_fill(
ui,
Sense::hover(),
texture.id,
texture.size.x / texture.size.y,
)
} else {
// TODO: default banner texture
ui.label("")
}
}
fn body(self, ui: &mut egui::Ui) {
let padding = 12.0;
crate::ui::padding(padding, ui, |ui| {
@@ -88,9 +53,11 @@ impl<'a, 'cache> ProfilePreview<'a, 'cache> {
impl egui::Widget for ProfilePreview<'_, '_> {
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
ui.vertical(|ui| {
ui.add_sized([ui.available_size().x, 80.0], |ui: &mut egui::Ui| {
ProfilePreview::banner(ui, self.profile)
});
banner(
ui,
self.profile.record().profile().and_then(|p| p.banner()),
80.0,
);
self.body(ui);
})
@@ -183,22 +150,6 @@ mod previews {
}
}
pub fn get_display_name<'a>(profile: Option<&ProfileRecord<'a>>) -> DisplayName<'a> {
if let Some(name) = profile.and_then(|p| crate::profile::get_profile_name(p)) {
name
} else {
DisplayName::One("??")
}
}
pub fn get_profile_url<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str {
if let Some(url) = profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())) {
url
} else {
ProfilePic::no_pfp_url()
}
}
pub fn get_profile_url_owned(profile: Option<ProfileRecord<'_>>) -> &str {
if let Some(url) = profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())) {
url
@@ -223,106 +174,19 @@ pub fn get_account_url<'a>(
}
}
fn display_name_widget(
display_name: DisplayName<'_>,
add_placeholder_space: bool,
) -> impl egui::Widget + '_ {
move |ui: &mut egui::Ui| match display_name {
DisplayName::One(n) => {
let name_response = ui.add(
Label::new(RichText::new(n).text_style(NotedeckTextStyle::Heading3.text_style()))
.selectable(false),
);
if add_placeholder_space {
ui.add_space(16.0);
}
name_response
}
DisplayName::Both {
display_name,
username,
} => {
ui.add(
Label::new(
RichText::new(display_name)
.text_style(NotedeckTextStyle::Heading3.text_style()),
)
.selectable(false),
);
ui.add(
Label::new(
RichText::new(format!("@{}", username))
.size(12.0)
.color(colors::MID_GRAY),
)
.selectable(false),
)
}
}
}
pub fn one_line_display_name_widget<'a>(
visuals: &egui::Visuals,
display_name: DisplayName<'a>,
display_name: NostrName<'a>,
style: NotedeckTextStyle,
) -> impl egui::Widget + 'a {
let text_style = style.text_style();
let color = visuals.noninteractive().fg_stroke.color;
move |ui: &mut egui::Ui| match display_name {
DisplayName::One(n) => ui.label(RichText::new(n).text_style(text_style).color(color)),
DisplayName::Both {
display_name,
username: _,
} => ui.label(
RichText::new(display_name)
move |ui: &mut egui::Ui| -> egui::Response {
ui.label(
RichText::new(display_name.name())
.text_style(text_style)
.color(color),
),
)
}
}
fn about_section_widget<'a, 'b>(profile: &'b ProfileRecord<'a>) -> impl egui::Widget + 'b
where
'b: 'a,
{
move |ui: &mut egui::Ui| {
if let Some(about) = profile.record().profile().and_then(|p| p.about()) {
ui.label(about)
} else {
// need any Response so we dont need an Option
ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover())
}
}
}
fn get_display_name_as_string<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str {
let display_name = get_display_name(profile);
match display_name {
DisplayName::One(n) => n,
DisplayName::Both { display_name, .. } => display_name,
}
}
pub fn get_profile_displayname_string<'a>(txn: &'a Transaction, ndb: &Ndb, pk: &Pubkey) -> &'a str {
let profile = ndb.get_profile_by_pubkey(txn, pk.bytes()).ok();
get_display_name_as_string(profile.as_ref())
}
pub fn get_note_users_displayname_string<'a>(
txn: &'a Transaction,
ndb: &Ndb,
id: &NoteId,
) -> &'a str {
let note = ndb.get_note_by_id(txn, id.bytes());
let profile = if let Ok(note) = note {
ndb.get_profile_by_pubkey(txn, note.pubkey()).ok()
} else {
None
};
get_display_name_as_string(profile.as_ref())
}

View File

@@ -1,7 +1,10 @@
use std::collections::HashMap;
use enostr::Pubkey;
use crate::deck_state::DeckState;
use crate::login_manager::AcquireKeyState;
use crate::profile_state::ProfileState;
/// Various state for views
#[derive(Default)]
@@ -10,6 +13,7 @@ pub struct ViewState {
pub id_to_deck_state: HashMap<egui::Id, DeckState>,
pub id_state_map: HashMap<egui::Id, AcquireKeyState>,
pub id_string_map: HashMap<egui::Id, String>,
pub pubkey_to_profile_state: HashMap<Pubkey, ProfileState>,
}
impl ViewState {