From ba3169aa85df333cb4645226006f922cc5ff7b4a Mon Sep 17 00:00:00 2001 From: "Javier G. Montoya S" Date: Tue, 3 Mar 2026 15:36:05 -0300 Subject: [PATCH 1/3] feat(user_search profile): add follow management --- src/action.rs | 18 ++++ src/app.rs | 215 +++++++++++++++++++++++++++++++++++++- src/main.rs | 45 ++++++++ src/screen/profile.rs | 89 +++++++++++++--- src/screen/user_search.rs | 15 +++ 5 files changed, 369 insertions(+), 13 deletions(-) diff --git a/src/action.rs b/src/action.rs index f342938..4e35139 100644 --- a/src/action.rs +++ b/src/action.rs @@ -54,6 +54,11 @@ pub enum Action { SettingsUpdateSuccess(String), SettingsUpdateError(String), + // Follows + FollowsLoaded(Vec), + FollowSuccess(String), + FollowError(String), + // User search SearchResult(Value), SearchStreamEnded, @@ -153,6 +158,19 @@ pub enum Effect { value: String, }, + // Follows + LoadFollows { + account: String, + }, + FollowUser { + account: String, + pubkey: String, + }, + UnfollowUser { + account: String, + pubkey: String, + }, + // User search SearchUsers { account: String, diff --git a/src/app.rs b/src/app.rs index da9752c..2e16301 100644 --- a/src/app.rs +++ b/src/app.rs @@ -116,6 +116,10 @@ pub struct App { #[allow(dead_code)] pub selected_setting: usize, + // Follows + pub follows: Vec, + pub selected_follow: usize, + // User search pub search_input: Input, pub search_results: Vec, @@ -161,6 +165,8 @@ impl App { profile: None, settings_data: None, selected_setting: 0, + follows: Vec::new(), + selected_follow: 0, search_input: Input::new(), search_results: Vec::new(), selected_result: 0, @@ -319,6 +325,25 @@ impl App { }); } + // Follows + Action::FollowsLoaded(list) => { + self.follows = list; + if self.selected_follow >= self.follows.len() { + self.selected_follow = self.follows.len().saturating_sub(1); + } + } + Action::FollowSuccess(_msg) => { + // Reload follows list to stay in sync + if let Some(account) = &self.account { + return vec![Effect::LoadFollows { + account: account.clone(), + }]; + } + } + Action::FollowError(msg) => { + self.popup = Some(Popup::Error { message: msg }); + } + // User search Action::SearchResult(val) => { self.search_results.push(val); @@ -387,10 +412,12 @@ impl App { self.connected = false; self.popup = None; let acct = account.clone(); + let acct2 = acct.clone(); vec![ Effect::SubscribeNotifications, Effect::SubscribeChats { account }, Effect::LoadProfile { account: acct }, + Effect::LoadFollows { account: acct2 }, Effect::TailDaemonLog, ] } @@ -408,6 +435,13 @@ impl App { .count() } + /// Check if a pubkey is in the follows list. + pub fn is_following(&self, pubkey: &str) -> bool { + self.follows + .iter() + .any(|f| f.get("pubkey").and_then(|v| v.as_str()) == Some(pubkey)) + } + fn handle_accounts_loaded(&mut self, accounts: Vec) -> Vec { if accounts.len() == 1 { if let Some(id) = extract_account_id(&accounts[0]) { @@ -797,7 +831,12 @@ impl App { }; self.screen = Screen::Profile; self.profile = None; - vec![Effect::LoadProfile { account }] + self.selected_follow = 0; + let acct = account.clone(); + vec![ + Effect::LoadProfile { account }, + Effect::LoadFollows { account: acct }, + ] } KeyCode::Char('S') => { let account = match &self.account { @@ -1135,6 +1174,35 @@ impl App { } vec![] } + KeyCode::Char('j') | KeyCode::Down => { + if !self.follows.is_empty() { + self.selected_follow = (self.selected_follow + 1).min(self.follows.len() - 1); + } + vec![] + } + KeyCode::Char('k') | KeyCode::Up => { + self.selected_follow = self.selected_follow.saturating_sub(1); + vec![] + } + KeyCode::Char('d') => { + // Unfollow selected + let account = match &self.account { + Some(a) => a.clone(), + None => return vec![], + }; + let pubkey = self + .follows + .get(self.selected_follow) + .and_then(|f| f.get("pubkey").and_then(|v| v.as_str())) + .map(|s| s.to_string()); + match pubkey { + Some(pk) => vec![Effect::UnfollowUser { + account, + pubkey: pk, + }], + None => vec![], + } + } _ => vec![], } } @@ -1226,6 +1294,7 @@ impl App { self.selected_result = self.selected_result.saturating_sub(1); vec![] } + KeyCode::Tab => self.toggle_follow_selected(), KeyCode::Char(ch) => { self.search_input.insert(ch); vec![] @@ -1258,6 +1327,31 @@ impl App { } } + fn toggle_follow_selected(&mut self) -> Vec { + let account = match &self.account { + Some(a) => a.clone(), + None => return vec![], + }; + let pubkey = self + .search_results + .get(self.selected_result) + .and_then(|u| { + u.get("pubkey") + .or_else(|| u.get("npub")) + .and_then(|v| v.as_str()) + }) + .map(|s| s.to_string()); + let Some(pubkey) = pubkey else { + return vec![]; + }; + + if self.is_following(&pubkey) { + vec![Effect::UnfollowUser { account, pubkey }] + } else { + vec![Effect::FollowUser { account, pubkey }] + } + } + // ── Login key handling ─────────────────────────────────────────── fn handle_login_key(&mut self, key: KeyEvent) -> Vec { @@ -2307,6 +2401,125 @@ mod tests { .any(|e| matches!(e, Effect::UnsubscribeSearch))); } + // ── Follows ─────────────────────────────────────────────────────── + + #[test] + fn follows_loaded_populates_list() { + let mut app = app_on_main(); + app.update(Action::FollowsLoaded(vec![ + json!({"pubkey": "abc"}), + json!({"pubkey": "def"}), + ])); + assert_eq!(app.follows.len(), 2); + assert!(app.is_following("abc")); + assert!(app.is_following("def")); + } + + #[test] + fn follows_loaded_replaces_previous() { + let mut app = app_on_main(); + app.follows = vec![json!({"pubkey": "old"})]; + app.update(Action::FollowsLoaded(vec![json!({"pubkey": "new"})])); + assert_eq!(app.follows.len(), 1); + assert!(app.is_following("new")); + assert!(!app.is_following("old")); + } + + #[test] + fn follow_success_reloads_follows() { + let mut app = app_on_main(); + let effects = app.update(Action::FollowSuccess("Followed abc".into())); + assert!(effects + .iter() + .any(|e| matches!(e, Effect::LoadFollows { .. }))); + } + + #[test] + fn follow_error_shows_popup() { + let mut app = app_on_main(); + app.update(Action::FollowError("Network error".into())); + assert!(matches!(app.popup, Some(Popup::Error { .. }))); + } + + #[test] + fn login_loads_follows() { + let mut app = App::new(); + let effects = app.update(Action::LoginSuccess("npub1xyz".into())); + assert!(effects + .iter() + .any(|e| matches!(e, Effect::LoadFollows { .. }))); + } + + #[test] + fn profile_loads_follows() { + let mut app = app_on_main(); + let effects = app.update(Action::Key(key(KeyCode::Char('p')))); + assert_eq!(app.screen, Screen::Profile); + assert!(effects + .iter() + .any(|e| matches!(e, Effect::LoadFollows { .. }))); + } + + #[test] + fn profile_j_navigates_follows() { + let mut app = app_on_main(); + app.screen = Screen::Profile; + app.follows = vec![ + json!({"pubkey": "a", "name": "Alice"}), + json!({"pubkey": "b", "name": "Bob"}), + ]; + app.update(Action::Key(key(KeyCode::Char('j')))); + assert_eq!(app.selected_follow, 1); + } + + #[test] + fn profile_d_unfollows_selected() { + let mut app = app_on_main(); + app.screen = Screen::Profile; + app.follows = vec![ + json!({"pubkey": "abc", "name": "Alice"}), + json!({"pubkey": "def", "name": "Bob"}), + ]; + app.selected_follow = 1; + let effects = app.update(Action::Key(key(KeyCode::Char('d')))); + assert!(effects + .iter() + .any(|e| matches!(e, Effect::UnfollowUser { ref pubkey, .. } if pubkey == "def"))); + } + + #[test] + fn follows_selected_clamped_after_reload() { + let mut app = app_on_main(); + app.selected_follow = 5; + app.update(Action::FollowsLoaded(vec![json!({"pubkey": "a"})])); + assert_eq!(app.selected_follow, 0); + } + + #[test] + fn search_tab_follows_user() { + let mut app = app_on_main(); + app.screen = Screen::UserSearch; + app.search_results = vec![json!({"pubkey": "abc123", "name": "Alice"})]; + app.selected_result = 0; + let effects = app.update(Action::Key(key(KeyCode::Tab))); + assert!(effects + .iter() + .any(|e| matches!(e, Effect::FollowUser { ref pubkey, .. } if pubkey == "abc123"))); + } + + #[test] + fn search_tab_unfollows_if_already_following() { + let mut app = app_on_main(); + app.screen = Screen::UserSearch; + app.search_results = vec![json!({"pubkey": "abc123", "name": "Alice"})]; + app.selected_result = 0; + app.follows = vec![json!({"pubkey": "abc123"})]; + let effects = app.update(Action::Key(key(KeyCode::Tab))); + assert!(effects + .iter() + .any(|e| matches!(e, Effect::UnfollowUser { ref pubkey, .. } if pubkey == "abc123"))); + } + // ── Help overlay ────────────────────────────────────────────────── #[test] diff --git a/src/main.rs b/src/main.rs index 5a9f90c..05f3580 100644 --- a/src/main.rs +++ b/src/main.rs @@ -163,6 +163,9 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::UpdateProfile { .. } => "UpdateProfile", Effect::LoadSettings { .. } => "LoadSettings", Effect::UpdateSetting { .. } => "UpdateSetting", + Effect::LoadFollows { .. } => "LoadFollows", + Effect::FollowUser { .. } => "FollowUser", + Effect::UnfollowUser { .. } => "UnfollowUser", Effect::SearchUsers { .. } => "SearchUsers", Effect::UnsubscribeSearch => "UnsubscribeSearch", Effect::TailDaemonLog => "TailDaemonLog", @@ -596,6 +599,48 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m }); } + Effect::LoadFollows { account } => { + let tx = tx.clone(); + tokio::spawn(async move { + let action = match wn::exec(&["--account", &account, "follows", "list"]).await { + Ok(val) => { + let list = match val { + serde_json::Value::Array(arr) => arr, + serde_json::Value::Null => vec![], + other => vec![other], + }; + Action::FollowsLoaded(list) + } + Err(e) => Action::FollowError(format!("Load follows: {e}")), + }; + let _ = tx.send(Event::Action(action)); + }); + } + + Effect::FollowUser { account, pubkey } => { + let tx = tx.clone(); + tokio::spawn(async move { + let action = + match wn::exec(&["--account", &account, "follows", "add", &pubkey]).await { + Ok(_) => Action::FollowSuccess(format!("Followed {}", &pubkey)), + Err(e) => Action::FollowError(format!("Follow failed: {e}")), + }; + let _ = tx.send(Event::Action(action)); + }); + } + + Effect::UnfollowUser { account, pubkey } => { + let tx = tx.clone(); + tokio::spawn(async move { + let action = + match wn::exec(&["--account", &account, "follows", "remove", &pubkey]).await { + Ok(_) => Action::FollowSuccess(format!("Unfollowed {}", &pubkey)), + Err(e) => Action::FollowError(format!("Unfollow failed: {e}")), + }; + let _ = tx.send(Event::Action(action)); + }); + } + Effect::SearchUsers { account, query } => { streams.kill_search(); let tx = tx.clone(); diff --git a/src/screen/profile.rs b/src/screen/profile.rs index 981f8f8..fe18169 100644 --- a/src/screen/profile.rs +++ b/src/screen/profile.rs @@ -2,7 +2,7 @@ use ratatui::{ layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph, StatefulWidget}, Frame, }; @@ -46,8 +46,9 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { let vertical = Layout::vertical([ Constraint::Length(7), // Profile info - Constraint::Fill(1), // spacer - Constraint::Length(2), // Hints + Constraint::Length(1), // Follows header + Constraint::Fill(1), // Follows list + Constraint::Length(1), // Hints ]) .split(inner); @@ -94,20 +95,84 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { ]; frame.render_widget(Paragraph::new(lines), vertical[0]); - // Hints - let rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(vertical[2]); + // Follows header + let follows_header = Line::from(vec![Span::styled( + format!(" Following ({})", app.follows.len()), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )]); + frame.render_widget(Paragraph::new(follows_header), vertical[1]); + + // Follows list + if app.follows.is_empty() { + let empty = Paragraph::new(Span::styled( + " Not following anyone", + Style::default().fg(Color::DarkGray), + )); + frame.render_widget(empty, vertical[2]); + } else { + let items: Vec = app + .follows + .iter() + .enumerate() + .map(|(i, user)| { + let name = user + .get("metadata") + .and_then(|m| m.get("display_name").or_else(|| m.get("name"))) + .and_then(|v| v.as_str()) + .or_else(|| { + user.get("display_name") + .or_else(|| user.get("name")) + .and_then(|v| v.as_str()) + }) + .unwrap_or("unknown"); + let pk = user.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""); + let short = if pk.len() > 16 { + format!("{}...{}", &pk[..8], &pk[pk.len() - 6..]) + } else { + pk.to_string() + }; + let marker = if i == app.selected_follow { ">" } else { " " }; + let style = if i == app.selected_follow { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + ListItem::new(Line::from(vec![ + Span::styled(format!(" {marker} "), Style::default().fg(Color::Cyan)), + Span::styled(name.to_string(), style), + Span::styled(format!(" {short}"), Style::default().fg(Color::DarkGray)), + ])) + }) + .collect(); + + let list = List::new(items); + let mut state = ListState::default(); + state.select(Some(app.selected_follow)); + StatefulWidget::render(list, vertical[2], frame.buffer_mut(), &mut state); + } - let line1 = Line::from(vec![ + // Hints + let mut hints = vec![ Span::styled(" [n] ", Style::default().fg(Color::Cyan)), Span::raw("Edit name "), Span::styled("[a] ", Style::default().fg(Color::Cyan)), Span::raw("Edit about "), - ]); - frame.render_widget(Paragraph::new(line1), rows[0]); - - let line2 = Line::from(vec![ - Span::styled(" [Esc] ", Style::default().fg(Color::Cyan)), + ]; + if !app.follows.is_empty() { + hints.extend([ + Span::styled("[j/k] ", Style::default().fg(Color::Cyan)), + Span::raw("Navigate "), + Span::styled("[d] ", Style::default().fg(Color::Cyan)), + Span::raw("Unfollow "), + ]); + } + hints.extend([ + Span::styled("[Esc] ", Style::default().fg(Color::Cyan)), Span::raw("Back"), ]); - frame.render_widget(Paragraph::new(line2), rows[1]); + frame.render_widget(Paragraph::new(Line::from(hints)), vertical[3]); } diff --git a/src/screen/user_search.rs b/src/screen/user_search.rs index 98aee13..9fcf944 100644 --- a/src/screen/user_search.rs +++ b/src/screen/user_search.rs @@ -80,6 +80,14 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { npub.to_string() }; + let pubkey = user + .get("pubkey") + .or_else(|| user.get("npub")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let is_following = app.is_following(pubkey); + let follow_badge = if is_following { " [following]" } else { "" }; + let marker = if i == app.selected_result { ">" } else { " " }; let name_style = if i == app.selected_result { Style::default() @@ -96,6 +104,7 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { format!(" {short_npub}"), Style::default().fg(Color::DarkGray), ), + Span::styled(follow_badge.to_string(), Style::default().fg(Color::Green)), ])) }) .collect(); @@ -117,6 +126,12 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { } else { hint_spans.push(Span::raw("Search ")); } + if !app.search_results.is_empty() { + hint_spans.extend([ + Span::styled("[Tab] ", Style::default().fg(Color::Cyan)), + Span::raw("Follow/Unfollow "), + ]); + } hint_spans.extend([ Span::styled("[↑/↓] ", Style::default().fg(Color::Cyan)), Span::raw("Navigate "), From 08d7c8703e56b69f29d2d96b69062af12a6f1dc4 Mon Sep 17 00:00:00 2001 From: "Javier G. Montoya S" Date: Tue, 3 Mar 2026 15:36:14 -0300 Subject: [PATCH 2/3] chore(License): add License --- LICENSE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE.md diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..11f4b15 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-2026 Internet Privacy Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From b25d91aef4250380cbb87c414e99d11257ff6924 Mon Sep 17 00:00:00 2001 From: "Javier G. Montoya S" Date: Tue, 3 Mar 2026 16:01:51 -0300 Subject: [PATCH 3/3] chore(.github): add ci workflow to check formatting, tests and build --- .github/workflows/ci.yml | 83 +++++++++++++++++++++++++++++++++++++++ README.md | 33 +++++++++++++++- src/app.rs | 5 ++- src/main.rs | 4 +- src/screen/user_search.rs | 14 ++----- 5 files changed, 125 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3565d9d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,83 @@ +name: CI + +on: + push: + branches: ["master", "main"] + pull_request: + branches: ["master", "main"] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + fmt: + name: Format Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + components: rustfmt + + - name: Format check + run: cargo fmt --all -- --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + components: clippy + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + + - name: Clippy check + run: cargo clippy --all-targets --no-deps -- -D warnings + + test: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + + - name: Run tests + run: cargo test + + build: + name: Build (release) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + + - name: Build release + run: cargo build --release diff --git a/README.md b/README.md index a784160..72095cf 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,37 @@ src/ | `Enter` | Send message | | `Esc` | Stop composing | +### Group detail + +| Key | Action | +| --------- | ------------------- | +| `j` / `k` | Navigate members | +| `a` | Search & add member | +| `A` | Add by pubkey/npub | +| `x` | Remove member | +| `R` | Rename group | +| `L` | Leave group | +| `Esc` | Back | + +### Profile + +| Key | Action | +| --------- | ---------------- | +| `n` | Edit name | +| `a` | Edit about | +| `j` / `k` | Navigate follows | +| `d` | Unfollow | +| `Esc` | Back | + +### User search + +| Key | Action | +| --------- | ----------------- | +| `Enter` | Search | +| `↑` / `↓` | Navigate results | +| `Tab` | Follow / Unfollow | +| `Esc` | Back | + ## License -TBD +MIT License diff --git a/src/app.rs b/src/app.rs index 2e16301..2510af6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1620,6 +1620,8 @@ fn help_lines(screen: &Screen) -> Vec> { Screen::Profile => { lines.push(hint("n", "Edit name")); lines.push(hint("a", "Edit about")); + lines.push(hint("j / k", "Navigate follows")); + lines.push(hint("d", "Unfollow")); lines.push(hint("Esc", "Back")); } Screen::Settings => { @@ -1632,7 +1634,8 @@ fn help_lines(screen: &Screen) -> Vec> { } Screen::UserSearch => { lines.push(hint("Enter", "Search")); - lines.push(hint("j / k", "Navigate results")); + lines.push(hint("↑ / ↓", "Navigate results")); + lines.push(hint("Tab", "Follow / Unfollow")); lines.push(hint("Esc", "Back")); } } diff --git a/src/main.rs b/src/main.rs index 05f3580..e338c11 100644 --- a/src/main.rs +++ b/src/main.rs @@ -709,10 +709,10 @@ fn extract_npub_login(val: serde_json::Value) -> Action { /// Find the daemon log directory based on platform and build mode. fn daemon_logs_dir() -> Option { - let home = dirs::home_dir()?; + let _home = dirs::home_dir()?; // Check both release and dev dirs — daemon build mode may differ from TUI build mode #[cfg(target_os = "macos")] - let base = home.join("Library").join("Logs").join("whitenoise-cli"); + let base = _home.join("Library").join("Logs").join("whitenoise-cli"); #[cfg(not(target_os = "macos"))] let base = dirs::data_dir()?.join("whitenoise-cli").join("logs"); Some(base) diff --git a/src/screen/user_search.rs b/src/screen/user_search.rs index 9fcf944..1e75a9e 100644 --- a/src/screen/user_search.rs +++ b/src/screen/user_search.rs @@ -69,22 +69,16 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { .and_then(|v| v.as_str()) }) .unwrap_or("unknown"); - let npub = user + let pubkey = user .get("pubkey") .or_else(|| user.get("npub")) .and_then(|v| v.as_str()) .unwrap_or(""); - let short_npub = if npub.len() > 20 { - format!("{}...", &npub[..17]) + let short_npub = if pubkey.len() > 20 { + format!("{}...", &pubkey[..17]) } else { - npub.to_string() + pubkey.to_string() }; - - let pubkey = user - .get("pubkey") - .or_else(|| user.get("npub")) - .and_then(|v| v.as_str()) - .unwrap_or(""); let is_following = app.is_following(pubkey); let follow_badge = if is_following { " [following]" } else { "" };