Navigate to rooms using message links list

This commit is contained in:
Joscha 2025-02-23 21:32:44 +01:00
parent 8040b82ff1
commit bf9a9d640b
5 changed files with 123 additions and 52 deletions

View file

@ -21,6 +21,7 @@ Procedure when bumping the version number:
- Unicode-based grapheme width estimation method - Unicode-based grapheme width estimation method
- `width_estimation_method` config option - `width_estimation_method` config option
- `--width-estimation-method` option - `--width-estimation-method` option
- Room links are now included in the `I` message links list
### Changed ### Changed

View file

@ -7,16 +7,25 @@ use toss::{
widgets::{Join2, Text}, widgets::{Join2, Text},
}; };
use crate::ui::{ use crate::{
UiError, key_bindings, util, euph::{self, SpanType},
widgets::{ListBuilder, ListState, Popup}, ui::{
UiError, key_bindings, util,
widgets::{ListBuilder, ListState, Popup},
},
}; };
use super::popup::PopupResult; use super::popup::PopupResult;
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
enum Link {
Url(String),
Room(String),
}
pub struct LinksState { pub struct LinksState {
config: &'static Config, config: &'static Config,
links: Vec<String>, links: Vec<Link>,
list: ListState<usize>, list: ListState<usize>,
} }
@ -24,12 +33,34 @@ const NUMBER_KEYS: [char; 10] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0
impl LinksState { impl LinksState {
pub fn new(config: &'static Config, content: &str) -> Self { pub fn new(config: &'static Config, content: &str) -> Self {
let links = LinkFinder::new() let mut links = vec![];
// Collect URL-like links
for link in LinkFinder::new()
.url_must_have_scheme(false) .url_must_have_scheme(false)
.kinds(&[LinkKind::Url]) .kinds(&[LinkKind::Url])
.links(content) .links(content)
.map(|l| l.as_str().to_string()) {
.collect(); links.push((
link.start(),
link.end(),
Link::Url(link.as_str().to_string()),
));
}
// Collect room links
for (span, range) in euph::find_spans(content) {
if span == SpanType::Room {
let name = &content[range.start + 1..range.end];
links.push((range.start, range.end, Link::Room(name.to_string())));
}
}
links.sort();
let links = links
.into_iter()
.map(|(_, _, link)| link)
.collect::<Vec<_>>();
Self { Self {
config, config,
@ -49,29 +80,29 @@ impl LinksState {
for (id, link) in self.links.iter().enumerate() { for (id, link) in self.links.iter().enumerate() {
let link = link.clone(); let link = link.clone();
if let Some(&number_key) = NUMBER_KEYS.get(id) { list_builder.add_sel(id, move |selected| {
list_builder.add_sel(id, move |selected| { let mut text = Styled::default();
let text = if selected {
Styled::new(format!("[{number_key}]"), style_selected.bold()) // Number key indicator
.then(" ", style_selected) text = match NUMBER_KEYS.get(id) {
.then(link, style_selected) None if selected => text.then(" ", style_selected),
} else { None => text.then_plain(" "),
Styled::new(format!("[{number_key}]"), Style::new().dark_grey().bold()) Some(key) if selected => text.then(format!("[{key}] "), style_selected.bold()),
.then_plain(" ") Some(key) => text.then(format!("[{key}] "), Style::new().dark_grey().bold()),
.then_plain(link) };
};
Text::new(text) // The link itself
}); text = match link {
} else { Link::Url(url) if selected => text.then(url, style_selected),
list_builder.add_sel(id, move |selected| { Link::Url(url) => text.then_plain(url),
let text = if selected { Link::Room(name) if selected => {
Styled::new(format!(" {link}"), style_selected) text.then(format!("&{name}"), style_selected.bold())
} else { }
Styled::new_plain(format!(" {link}")) Link::Room(name) => text.then(format!("&{name}"), Style::new().blue().bold()),
}; };
Text::new(text)
}); Text::new(text)
} });
} }
let hint_style = Style::new().grey().italic(); let hint_style = Style::new().grey().italic();
@ -95,18 +126,24 @@ impl LinksState {
} }
fn open_link_by_id(&self, id: usize) -> PopupResult { fn open_link_by_id(&self, id: usize) -> PopupResult {
if let Some(link) = self.links.get(id) { match self.links.get(id) {
// The `http://` or `https://` schema is necessary for open::that to Some(Link::Url(url)) => {
// successfully open the link in the browser. // The `http://` or `https://` schema is necessary for
let link = if link.starts_with("http://") || link.starts_with("https://") { // open::that to successfully open the link in the browser.
link.clone() let link = if url.starts_with("http://") || url.starts_with("https://") {
} else { url.clone()
format!("https://{link}") } else {
}; format!("https://{url}")
};
if let Err(error) = open::that(&link) { if let Err(error) = open::that(&link) {
return PopupResult::ErrorOpeningLink { link, error }; return PopupResult::ErrorOpeningLink { link, error };
}
} }
Some(Link::Room(name)) => return PopupResult::SwitchToRoom { name: name.clone() },
_ => {}
} }
PopupResult::Handled PopupResult::Handled
} }

View file

@ -35,5 +35,6 @@ pub enum PopupResult {
NotHandled, NotHandled,
Handled, Handled,
Close, Close,
SwitchToRoom { name: String },
ErrorOpeningLink { link: String, error: io::Error }, ErrorOpeningLink { link: String, error: io::Error },
} }

View file

@ -27,7 +27,7 @@ use crate::{
util, util,
widgets::ListState, widgets::ListState,
}, },
vault::EuphRoomVault, vault::{EuphRoomVault, RoomIdentifier},
}; };
use super::{ use super::{
@ -500,18 +500,22 @@ impl EuphRoom {
false false
} }
pub async fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool { pub async fn handle_input_event(
&mut self,
event: &mut InputEvent<'_>,
keys: &Keys,
) -> RoomResult {
if !self.popups.is_empty() { if !self.popups.is_empty() {
if event.matches(&keys.general.abort) { if event.matches(&keys.general.abort) {
self.popups.pop_back(); self.popups.pop_back();
return true; return RoomResult::Handled;
} }
// Prevent event from reaching anything below the popup // Prevent event from reaching anything below the popup
return false; return RoomResult::NotHandled;
} }
let result = match &mut self.state { let result = match &mut self.state {
State::Normal => return self.handle_normal_input_event(event, keys).await, State::Normal => return self.handle_normal_input_event(event, keys).await.into(),
State::Auth(editor) => auth::handle_input_event(event, keys, &self.room, editor), State::Auth(editor) => auth::handle_input_event(event, keys, &self.room, editor),
State::Nick(editor) => nick::handle_input_event(event, keys, &self.room, editor), State::Nick(editor) => nick::handle_input_event(event, keys, &self.room, editor),
State::Account(account) => account.handle_input_event(event, keys, &self.room), State::Account(account) => account.handle_input_event(event, keys, &self.room),
@ -522,18 +526,24 @@ impl EuphRoom {
}; };
match result { match result {
PopupResult::NotHandled => false, PopupResult::NotHandled => RoomResult::NotHandled,
PopupResult::Handled => true, PopupResult::Handled => RoomResult::Handled,
PopupResult::Close => { PopupResult::Close => {
self.state = State::Normal; self.state = State::Normal;
true RoomResult::Handled
} }
PopupResult::SwitchToRoom { name } => RoomResult::SwitchToRoom {
room: RoomIdentifier {
domain: self.vault().room().domain.clone(),
name,
},
},
PopupResult::ErrorOpeningLink { link, error } => { PopupResult::ErrorOpeningLink { link, error } => {
self.popups.push_front(RoomPopup::Error { self.popups.push_front(RoomPopup::Error {
description: format!("Failed to open link: {link}"), description: format!("Failed to open link: {link}"),
reason: format!("{error}"), reason: format!("{error}"),
}); });
true RoomResult::Handled
} }
} }
} }
@ -638,3 +648,18 @@ impl EuphRoom {
true true
} }
} }
pub enum RoomResult {
NotHandled,
Handled,
SwitchToRoom { room: RoomIdentifier },
}
impl From<bool> for RoomResult {
fn from(value: bool) -> Self {
match value {
true => Self::Handled,
false => Self::NotHandled,
}
}
}

View file

@ -29,7 +29,7 @@ use crate::{
use super::{ use super::{
UiError, UiEvent, UiError, UiEvent,
euph::room::EuphRoom, euph::room::{EuphRoom, RoomResult},
key_bindings, util, key_bindings, util,
widgets::{ListBuilder, ListState}, widgets::{ListBuilder, ListState},
}; };
@ -574,8 +574,15 @@ impl Rooms {
} }
State::ShowRoom(name) => { State::ShowRoom(name) => {
if let Some(room) = self.euph_rooms.get_mut(name) { if let Some(room) = self.euph_rooms.get_mut(name) {
if room.handle_input_event(event, keys).await { match room.handle_input_event(event, keys).await {
return true; RoomResult::NotHandled => {}
RoomResult::Handled => return true,
RoomResult::SwitchToRoom { room } => {
self.list.move_cursor_to_id(&room);
self.connect_to_room(room.clone()).await;
self.state = State::ShowRoom(room);
return true;
}
} }
if event.matches(&keys.general.abort) { if event.matches(&keys.general.abort) {
self.state = State::ShowList; self.state = State::ShowList;