feat(tui): tabbed browsing — browse several users at once
Replace the single browse view with BrowseTabs (one tab per user), mirroring the chat-room tabs. Browsing a new user (from search results or a chat room's member list) opens or focuses their tab; Tab/Shift-Tab switch between open users, 'w' closes the active tab, Esc hides the popup while keeping the tabs. A tab bar shows the open users when more than one is open, and each tab polls/loads independently. Re-browsing a timed-out user retries. BrowseTabs is unit-tested.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
use crate::models::{BrowseState, FileDisplayData, RoomsState};
|
use crate::models::{BrowseTabs, FileDisplayData, RoomsState};
|
||||||
use ratatui::{layout::Rect, widgets::TableState};
|
use ratatui::{layout::Rect, widgets::TableState};
|
||||||
use soulseek_rs::{DownloadStatus, types::Download};
|
use soulseek_rs::{DownloadStatus, types::Download};
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
@@ -94,8 +94,8 @@ pub struct AppState {
|
|||||||
/// Incoming private messages received while the inbox was closed.
|
/// Incoming private messages received while the inbox was closed.
|
||||||
pub unread_messages: usize,
|
pub unread_messages: usize,
|
||||||
|
|
||||||
// Browse a user's shared files
|
// Browse users' shared files (one tab per user)
|
||||||
pub browse: Option<BrowseState>,
|
pub browse: BrowseTabs,
|
||||||
pub show_browse: bool,
|
pub show_browse: bool,
|
||||||
pub browse_table_state: TableState,
|
pub browse_table_state: TableState,
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ impl AppState {
|
|||||||
show_messages: false,
|
show_messages: false,
|
||||||
unread_messages: 0,
|
unread_messages: 0,
|
||||||
|
|
||||||
browse: None,
|
browse: BrowseTabs::new(),
|
||||||
show_browse: false,
|
show_browse: false,
|
||||||
browse_table_state: TableState::default(),
|
browse_table_state: TableState::default(),
|
||||||
|
|
||||||
|
|||||||
@@ -319,6 +319,82 @@ impl BrowseState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A set of open browse views (tabs), one per user, like the chat-room tabs.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct BrowseTabs {
|
||||||
|
pub tabs: Vec<BrowseState>,
|
||||||
|
pub active: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowseTabs {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
tabs: Vec::new(),
|
||||||
|
active: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_empty(&self) -> bool {
|
||||||
|
self.tabs.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn active_tab(&self) -> Option<&BrowseState> {
|
||||||
|
self.tabs.get(self.active)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn active_tab_mut(&mut self) -> Option<&mut BrowseState> {
|
||||||
|
self.tabs.get_mut(self.active)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Focus the tab for `username`, or open a fresh loading one. Returns
|
||||||
|
/// `true` if the caller should (re)issue a browse request — i.e. the tab is
|
||||||
|
/// new or was retried after a timeout.
|
||||||
|
pub fn open(&mut self, username: &str) -> bool {
|
||||||
|
if let Some(idx) = self.tabs.iter().position(|t| t.username == username)
|
||||||
|
{
|
||||||
|
self.active = idx;
|
||||||
|
// Re-request only if the previous attempt gave up.
|
||||||
|
if self.tabs[idx].status == BrowseStatus::TimedOut {
|
||||||
|
self.tabs[idx] = BrowseState::loading(username.to_string());
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.tabs.push(BrowseState::loading(username.to_string()));
|
||||||
|
self.active = self.tabs.len() - 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the active tab, returning `false` when no tabs remain.
|
||||||
|
pub fn close_active(&mut self) -> bool {
|
||||||
|
if self.active < self.tabs.len() {
|
||||||
|
self.tabs.remove(self.active);
|
||||||
|
}
|
||||||
|
if self.active >= self.tabs.len() {
|
||||||
|
self.active = self.tabs.len().saturating_sub(1);
|
||||||
|
}
|
||||||
|
!self.tabs.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn next_tab(&mut self) {
|
||||||
|
if !self.tabs.is_empty() {
|
||||||
|
self.active = (self.active + 1) % self.tabs.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn prev_tab(&mut self) {
|
||||||
|
if !self.tabs.is_empty() {
|
||||||
|
self.active = (self.active + self.tabs.len() - 1) % self.tabs.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -427,6 +503,48 @@ mod tests {
|
|||||||
assert_eq!(all.len(), 3);
|
assert_eq!(all.len(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browse_tabs_open_focus_close_and_cycle() {
|
||||||
|
let mut tabs = BrowseTabs::new();
|
||||||
|
assert!(tabs.is_empty());
|
||||||
|
|
||||||
|
// New users open new tabs and request a browse.
|
||||||
|
assert!(tabs.open("alice"));
|
||||||
|
assert!(tabs.open("bob"));
|
||||||
|
assert_eq!(tabs.tabs.len(), 2);
|
||||||
|
assert_eq!(tabs.active, 1);
|
||||||
|
assert_eq!(tabs.active_tab().unwrap().username, "bob");
|
||||||
|
|
||||||
|
// Re-opening a live tab just focuses it (no new request).
|
||||||
|
assert!(!tabs.open("alice"));
|
||||||
|
assert_eq!(tabs.active, 0);
|
||||||
|
assert_eq!(tabs.tabs.len(), 2);
|
||||||
|
|
||||||
|
// Cycling wraps.
|
||||||
|
tabs.next_tab();
|
||||||
|
assert_eq!(tabs.active, 1);
|
||||||
|
tabs.next_tab();
|
||||||
|
assert_eq!(tabs.active, 0);
|
||||||
|
tabs.prev_tab();
|
||||||
|
assert_eq!(tabs.active, 1);
|
||||||
|
|
||||||
|
// Closing the active tab returns whether any remain.
|
||||||
|
assert!(tabs.close_active());
|
||||||
|
assert_eq!(tabs.tabs.len(), 1);
|
||||||
|
assert!(!tabs.close_active());
|
||||||
|
assert!(tabs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browse_tabs_reopen_after_timeout_requests_again() {
|
||||||
|
let mut tabs = BrowseTabs::new();
|
||||||
|
tabs.open("alice");
|
||||||
|
tabs.active_tab_mut().unwrap().status = BrowseStatus::TimedOut;
|
||||||
|
// Re-opening a timed-out tab retries (returns true) and resets it.
|
||||||
|
assert!(tabs.open("alice"));
|
||||||
|
assert_eq!(tabs.active_tab().unwrap().status, BrowseStatus::Loading);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn browse_state_load_auto_expands_top_level() {
|
fn browse_state_load_auto_expands_top_level() {
|
||||||
let mut state = BrowseState::loading("bob".to_string());
|
let mut state = BrowseState::loading("bob".to_string());
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ pub use app_state::{
|
|||||||
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane,
|
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane,
|
||||||
MessageDirection, SearchEntry, SearchStatus,
|
MessageDirection, SearchEntry, SearchStatus,
|
||||||
};
|
};
|
||||||
pub use browse::{BrowseState, BrowseStatus, files_under, find_node};
|
pub use browse::{
|
||||||
|
BrowseState, BrowseStatus, BrowseTabs, files_under, find_node,
|
||||||
|
};
|
||||||
pub use file_display_data::FileDisplayData;
|
pub use file_display_data::FileDisplayData;
|
||||||
pub use rooms::{RoomLine, RoomsState, RoomsView};
|
pub use rooms::{RoomLine, RoomsState, RoomsView};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::models::{
|
use crate::models::{
|
||||||
AppState, BrowseState, BrowseStatus, ChatMessage, CommandBarMode,
|
AppState, BrowseStatus, ChatMessage, CommandBarMode, DownloadEntry,
|
||||||
DownloadEntry, FileDisplayData, FocusedPane, MessageDirection, RoomsView,
|
FileDisplayData, FocusedPane, MessageDirection, RoomsView, SearchEntry,
|
||||||
SearchEntry, SearchStatus, files_under, find_node,
|
SearchStatus, files_under, find_node,
|
||||||
};
|
};
|
||||||
use crate::ui::panes::{
|
use crate::ui::panes::{
|
||||||
ResultsPaneParams, render_browse_pane, render_download_info_pane,
|
ResultsPaneParams, render_browse_pane, render_download_info_pane,
|
||||||
@@ -264,15 +264,13 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Browse tree overlays everything when open.
|
// Browse tree overlays everything when open.
|
||||||
if self.state.show_browse
|
if self.state.show_browse && !self.state.browse.is_empty() {
|
||||||
&& let Some(browse) = self.state.browse.as_ref()
|
|
||||||
{
|
|
||||||
let area = centered_rect(80, 80, frame.area());
|
let area = centered_rect(80, 80, frame.area());
|
||||||
frame.render_widget(ratatui::widgets::Clear, area);
|
frame.render_widget(ratatui::widgets::Clear, area);
|
||||||
render_browse_pane(
|
render_browse_pane(
|
||||||
frame,
|
frame,
|
||||||
area,
|
area,
|
||||||
browse,
|
&self.state.browse,
|
||||||
&mut self.state.browse_table_state,
|
&mut self.state.browse_table_state,
|
||||||
self.spinner_state,
|
self.spinner_state,
|
||||||
);
|
);
|
||||||
@@ -389,7 +387,9 @@ impl MainTui {
|
|||||||
("→←", "expand/collapse"),
|
("→←", "expand/collapse"),
|
||||||
("Enter", "open/download"),
|
("Enter", "open/download"),
|
||||||
("d", "download folder"),
|
("d", "download folder"),
|
||||||
("Esc", "close"),
|
("Tab", "switch user"),
|
||||||
|
("w", "close tab"),
|
||||||
|
("Esc", "hide"),
|
||||||
]
|
]
|
||||||
} else if self.state.command_bar_active {
|
} else if self.state.command_bar_active {
|
||||||
match self.state.command_bar_mode {
|
match self.state.command_bar_mode {
|
||||||
@@ -612,9 +612,31 @@ impl MainTui {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tab management: switch between browsed users or close the active tab.
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Tab => {
|
||||||
|
self.state.browse.next_tab();
|
||||||
|
self.sync_browse_selection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KeyCode::BackTab => {
|
||||||
|
self.state.browse.prev_tab();
|
||||||
|
self.sync_browse_selection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KeyCode::Char('w') => {
|
||||||
|
if !self.state.browse.close_active() {
|
||||||
|
self.state.show_browse = false;
|
||||||
|
}
|
||||||
|
self.sync_browse_selection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
// Snapshot the flattened rows + current selection, then drop the borrow.
|
// Snapshot the flattened rows + current selection, then drop the borrow.
|
||||||
let (rows, sel, row) = {
|
let (rows, sel, row) = {
|
||||||
let Some(browse) = self.state.browse.as_ref() else {
|
let Some(browse) = self.state.browse.active_tab() else {
|
||||||
self.state.show_browse = false;
|
self.state.show_browse = false;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -652,7 +674,7 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Navigation and expand/collapse mutate the browse state.
|
// Navigation and expand/collapse mutate the browse state.
|
||||||
if let Some(browse) = self.state.browse.as_mut() {
|
if let Some(browse) = self.state.browse.active_tab_mut() {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Up | KeyCode::Char('k') => {
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
browse.selected_row = sel.saturating_sub(1);
|
browse.selected_row = sel.saturating_sub(1);
|
||||||
@@ -692,24 +714,29 @@ impl MainTui {
|
|||||||
browse.selected_row.min(new_len.saturating_sub(1));
|
browse.selected_row.min(new_len.saturating_sub(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
let selected = self.state.browse.as_ref().map(|b| b.selected_row);
|
self.sync_browse_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point the browse table cursor at the active tab's selected row.
|
||||||
|
fn sync_browse_selection(&mut self) {
|
||||||
|
let selected = self.state.browse.active_tab().map(|b| b.selected_row);
|
||||||
self.state.browse_table_state.select(selected);
|
self.state.browse_table_state.select(selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Files (`path`, `size`) under the browse-tree folder at `path`.
|
/// Files (`path`, `size`) under the active browse tab's folder at `path`.
|
||||||
fn browse_folder_files(&self, path: &str) -> Vec<(String, u64)> {
|
fn browse_folder_files(&self, path: &str) -> Vec<(String, u64)> {
|
||||||
self.state
|
self.state
|
||||||
.browse
|
.browse
|
||||||
.as_ref()
|
.active_tab()
|
||||||
.and_then(|b| find_node(&b.tree, path))
|
.and_then(|b| find_node(&b.tree, path))
|
||||||
.map(files_under)
|
.map(files_under)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue downloads of `files` (path, size) from the currently-browsed user.
|
/// Queue downloads of `files` (path, size) from the active browse tab's user.
|
||||||
fn queue_browse_files(&mut self, files: Vec<(String, u64)>) {
|
fn queue_browse_files(&mut self, files: Vec<(String, u64)>) {
|
||||||
let Some(username) =
|
let Some(username) =
|
||||||
self.state.browse.as_ref().map(|b| b.username.clone())
|
self.state.browse.active_tab().map(|b| b.username.clone())
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -1527,16 +1554,19 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request a user's shared files and open the browse popup.
|
/// Request a user's shared files and open (or focus) their browse tab.
|
||||||
fn start_browse(&mut self, username: String) {
|
fn start_browse(&mut self, username: String) {
|
||||||
let username = username.trim().to_string();
|
let username = username.trim().to_string();
|
||||||
if username.is_empty() {
|
if username.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = self.client.browse_user(&username);
|
// Open/focus the tab; only (re)issue the request when it's new or a
|
||||||
self.state.browse = Some(BrowseState::loading(username));
|
// previous attempt timed out.
|
||||||
|
if self.state.browse.open(&username) {
|
||||||
|
let _ = self.client.browse_user(&username);
|
||||||
|
}
|
||||||
self.state.show_browse = true;
|
self.state.show_browse = true;
|
||||||
self.state.browse_table_state.select(Some(0));
|
self.sync_browse_selection();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The username of the highlighted search result (filter-aware).
|
/// The username of the highlighted search result (filter-aware).
|
||||||
@@ -1550,28 +1580,33 @@ impl MainTui {
|
|||||||
items.get(selected).map(|f| f.username.clone())
|
items.get(selected).map(|f| f.username.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain a browse response into the browse state, or time it out.
|
/// Drain browse responses into any loading tabs, or time them out.
|
||||||
fn poll_browse_result(&mut self) {
|
fn poll_browse_result(&mut self) {
|
||||||
let Some((username, status, requested_at)) = self
|
// Which loading tabs are waiting, and for whom.
|
||||||
|
let loading: Vec<(usize, String, std::time::Instant)> = self
|
||||||
.state
|
.state
|
||||||
.browse
|
.browse
|
||||||
.as_ref()
|
.tabs
|
||||||
.map(|b| (b.username.clone(), b.status, b.requested_at))
|
.iter()
|
||||||
else {
|
.enumerate()
|
||||||
return;
|
.filter(|(_, b)| b.status == BrowseStatus::Loading)
|
||||||
};
|
.map(|(i, b)| (i, b.username.clone(), b.requested_at))
|
||||||
if status != BrowseStatus::Loading {
|
.collect();
|
||||||
return;
|
|
||||||
}
|
for (idx, username, requested_at) in loading {
|
||||||
if let Some(directories) = self.client.take_browse_result(&username) {
|
if let Some(directories) = self.client.take_browse_result(&username)
|
||||||
if let Some(browse) = self.state.browse.as_mut() {
|
{
|
||||||
browse.load(&directories);
|
if let Some(browse) = self.state.browse.tabs.get_mut(idx) {
|
||||||
|
browse.load(&directories);
|
||||||
|
}
|
||||||
|
if idx == self.state.browse.active {
|
||||||
|
self.state.browse_table_state.select(Some(0));
|
||||||
|
}
|
||||||
|
} else if requested_at.elapsed() > BROWSE_TIMEOUT
|
||||||
|
&& let Some(browse) = self.state.browse.tabs.get_mut(idx)
|
||||||
|
{
|
||||||
|
browse.status = BrowseStatus::TimedOut;
|
||||||
}
|
}
|
||||||
self.state.browse_table_state.select(Some(0));
|
|
||||||
} else if requested_at.elapsed() > BROWSE_TIMEOUT
|
|
||||||
&& let Some(browse) = self.state.browse.as_mut()
|
|
||||||
{
|
|
||||||
browse.status = BrowseStatus::TimedOut;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,62 @@
|
|||||||
use crate::models::{BrowseState, BrowseStatus};
|
use crate::models::{BrowseState, BrowseStatus, BrowseTabs};
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
HIGHLIGHT_SYMBOL, border_style, border_type, error_style, format_bytes,
|
HIGHLIGHT_SYMBOL, border_style, border_type, dimmed_style, error_style,
|
||||||
get_spinner_char, highlight_style, primary_style,
|
format_bytes, get_spinner_char, highlight_style, primary_style,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Constraint, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
text::Line,
|
style::Modifier,
|
||||||
|
text::{Line, Span},
|
||||||
widgets::{
|
widgets::{
|
||||||
Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table,
|
Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table,
|
||||||
TableState,
|
TableState,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Render the browse popup: a collapsible tree of a peer's shared files.
|
/// Render the browse popup: a tab bar of browsed users (when more than one is
|
||||||
|
/// open) above the active user's collapsible shared-file tree.
|
||||||
pub fn render_browse_pane(
|
pub fn render_browse_pane(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
tabs: &BrowseTabs,
|
||||||
|
table_state: &mut TableState,
|
||||||
|
spinner_state: usize,
|
||||||
|
) {
|
||||||
|
let Some(active) = tabs.active_tab() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// With multiple users open, reserve the top row for a tab bar.
|
||||||
|
let tree_area = if tabs.tabs.len() > 1 {
|
||||||
|
let chunks =
|
||||||
|
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)])
|
||||||
|
.split(area);
|
||||||
|
render_browse_tabs(frame, chunks[0], tabs);
|
||||||
|
chunks[1]
|
||||||
|
} else {
|
||||||
|
area
|
||||||
|
};
|
||||||
|
|
||||||
|
render_browse_one(frame, tree_area, active, table_state, spinner_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_browse_tabs(frame: &mut Frame, area: Rect, tabs: &BrowseTabs) {
|
||||||
|
let mut spans: Vec<Span> = Vec::new();
|
||||||
|
for (i, tab) in tabs.tabs.iter().enumerate() {
|
||||||
|
let style = if i == tabs.active {
|
||||||
|
highlight_style().add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
dimmed_style()
|
||||||
|
};
|
||||||
|
spans.push(Span::styled(format!(" {} ", tab.username), style));
|
||||||
|
spans.push(Span::raw(" "));
|
||||||
|
}
|
||||||
|
frame.render_widget(Paragraph::new(Line::from(spans)), area);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render one browsed user's shared-file tree.
|
||||||
|
fn render_browse_one(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
browse: &BrowseState,
|
browse: &BrowseState,
|
||||||
@@ -23,7 +65,7 @@ pub fn render_browse_pane(
|
|||||||
) {
|
) {
|
||||||
let title = match browse.status {
|
let title = match browse.status {
|
||||||
BrowseStatus::Loaded => format!(
|
BrowseStatus::Loaded => format!(
|
||||||
" Browse {} — {} files, {} folders (Enter/d: download, Esc: close) ",
|
" Browse {} — {} files, {} folders (Enter/d: download, Tab: user, w: close, Esc: hide) ",
|
||||||
browse.username, browse.file_count, browse.folder_count
|
browse.username, browse.file_count, browse.folder_count
|
||||||
),
|
),
|
||||||
_ => format!(" Browse {} ", browse.username),
|
_ => format!(" Browse {} ", browse.username),
|
||||||
|
|||||||
Reference in New Issue
Block a user