From 467e24734449850547b98f43c671efd46a8cf4b8 Mon Sep 17 00:00:00 2001 From: Cory Forsstrom Date: Mon, 20 Jan 2025 07:37:48 -0800 Subject: [PATCH] Add url & opengraph card previews --- Cargo.lock | 2 + Cargo.toml | 1 + data/Cargo.toml | 1 + data/src/environment.rs | 6 + data/src/history.rs | 2 + data/src/history/manager.rs | 21 + data/src/lib.rs | 2 + data/src/message.rs | 18 +- data/src/message/broadcast.rs | 3 + data/src/preview.rs | 260 ++++++++++ data/src/preview/cache.rs | 96 ++++ data/src/preview/card.rs | 13 + data/src/preview/image.rs | 61 +++ src/buffer.rs | 14 +- src/buffer/channel.rs | 17 +- src/buffer/highlights.rs | 19 +- src/buffer/logs.rs | 21 +- src/buffer/query.rs | 35 +- src/buffer/scroll_view.rs | 817 +++++++++++++++++++++++--------- src/buffer/server.rs | 11 +- src/main.rs | 4 +- src/screen/dashboard.rs | 73 ++- src/screen/dashboard/pane.rs | 16 +- src/widget.rs | 2 + src/widget/notify_visibility.rs | 63 +++ 25 files changed, 1324 insertions(+), 254 deletions(-) create mode 100644 data/src/preview.rs create mode 100644 data/src/preview/cache.rs create mode 100644 data/src/preview/card.rs create mode 100644 data/src/preview/image.rs create mode 100644 src/widget/notify_visibility.rs diff --git a/Cargo.lock b/Cargo.lock index 5b9622698..664211c99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1034,6 +1034,7 @@ dependencies = [ "futures", "hex", "iced_core", + "image", "irc", "itertools 0.12.1", "log", @@ -1813,6 +1814,7 @@ dependencies = [ "tokio", "tokio-stream", "unicode-segmentation", + "url", "uuid", "windows_exe_info", ] diff --git a/Cargo.toml b/Cargo.toml index b17d62fc2..3e66b3317 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ itertools = "0.13.0" rodio = "0.19.0" strum = { version = "0.26.3", features = ["derive"] } tokio-stream = { version = "0.1.16", features = ["fs"] } +url = "2.5.0" # change to 1.2.0 when it is released https://github.com/frewsxcv/rust-dark-light/issues/38 dark-light = { git = "https://github.com/frewsxcv/rust-dark-light", rev = "3eb3e93dd0fa30733c3e93082dd9517fb580ae95" } diff --git a/data/Cargo.toml b/data/Cargo.toml index e47028cd4..0d28fea86 100644 --- a/data/Cargo.toml +++ b/data/Cargo.toml @@ -40,6 +40,7 @@ const_format = "0.2.32" strum = { version = "0.26.3", features = ["derive"] } derive_more = { version = "1.0.0", features = ["full"] } anyhow = "1.0.91" +image = "0.24.9" [dependencies.irc] path = "../irc" diff --git a/data/src/environment.rs b/data/src/environment.rs index 55e59b5df..59a7a9aed 100644 --- a/data/src/environment.rs +++ b/data/src/environment.rs @@ -30,6 +30,12 @@ pub fn data_dir() -> PathBuf { }) } +pub fn cache_dir() -> PathBuf { + dirs_next::cache_dir() + .expect("expected valid cache dir") + .join("halloy") +} + /// Checks if a config file exists in the same directory as the executable. /// If so, it'll use that directory for both config & data dirs. fn portable_dir() -> Option { diff --git a/data/src/history.rs b/data/src/history.rs index 1d17ec01e..af2e4cddb 100644 --- a/data/src/history.rs +++ b/data/src/history.rs @@ -573,6 +573,8 @@ fn has_matching_content(message: &Message, other: &Message) -> bool { #[derive(Debug)] pub struct View<'a> { pub total: usize, + pub has_more_older_messages: bool, + pub has_more_newer_messages: bool, pub old_messages: Vec<&'a Message>, pub new_messages: Vec<&'a Message>, pub max_nick_chars: Option, diff --git a/data/src/history/manager.rs b/data/src/history/manager.rs index a65be41c6..e9e859dee 100644 --- a/data/src/history/manager.rs +++ b/data/src/history/manager.rs @@ -652,8 +652,14 @@ impl Data { }) .unwrap_or_default(); + let first_without_limit = filtered.first().copied(); + let last_without_limit = filtered.last().copied(); + let limited = with_limit(limit, filtered.into_iter()); + let first_with_limit = limited.first(); + let last_with_limit = limited.last(); + let split_at = read_marker.map_or(0, |read_marker| { limited .iter() @@ -674,8 +680,23 @@ impl Data { let (old, new) = limited.split_at(split_at); + let has_more_older_messages = + first_without_limit + .zip(first_with_limit) + .is_some_and(|(without_limit, with_limit)| { + without_limit.server_time < with_limit.server_time + }); + let has_more_newer_messages = + last_without_limit + .zip(last_with_limit) + .is_some_and(|(without_limit, with_limit)| { + without_limit.server_time > with_limit.server_time + }); + Some(history::View { total, + has_more_older_messages, + has_more_newer_messages, old_messages: old.to_vec(), new_messages: new.to_vec(), max_nick_chars, diff --git a/data/src/lib.rs b/data/src/lib.rs index c298162a0..f7c0ae05d 100644 --- a/data/src/lib.rs +++ b/data/src/lib.rs @@ -9,6 +9,7 @@ pub use self::input::Input; pub use self::message::Message; pub use self::mode::Mode; pub use self::pane::Pane; +pub use self::preview::Preview; pub use self::server::Server; pub use self::shortcut::Shortcut; pub use self::url::Url; @@ -36,6 +37,7 @@ pub mod log; pub mod message; pub mod mode; pub mod pane; +pub mod preview; pub mod server; pub mod shortcut; pub mod stream; diff --git a/data/src/message.rs b/data/src/message.rs index 10aaaaab9..0999098c6 100644 --- a/data/src/message.rs +++ b/data/src/message.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::collections::HashSet; use std::hash::{DefaultHasher, Hash as _, Hasher}; use std::iter; @@ -167,6 +168,7 @@ pub struct Message { pub content: Content, pub id: Option, pub hash: Hash, + pub hidden_urls: HashSet, } impl Message { @@ -255,6 +257,7 @@ impl Message { content, id, hash, + hidden_urls: HashSet::default(), }) } @@ -270,6 +273,7 @@ impl Message { content, id: None, hash, + hidden_urls: HashSet::default(), } } @@ -293,6 +297,7 @@ impl Message { content, id: None, hash, + hidden_urls: HashSet::default(), } } @@ -312,6 +317,7 @@ impl Message { content, id: None, hash, + hidden_urls: HashSet::default(), } } @@ -341,6 +347,7 @@ impl Message { content, id: None, hash, + hidden_urls: HashSet::default(), } } @@ -449,11 +456,12 @@ impl<'de> Deserialize<'de> for Message { content, id, hash, + hidden_urls: HashSet::default(), }) } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Hash(u64); impl Hash { @@ -652,6 +660,14 @@ pub enum Fragment { } impl Fragment { + pub fn url(&self) -> Option<&Url> { + if let Self::Url(url) = self { + Some(url) + } else { + None + } + } + pub fn as_str(&self) -> &str { match self { Fragment::Text(s) => s, diff --git a/data/src/message/broadcast.rs b/data/src/message/broadcast.rs index 5de04da79..c865dfb16 100644 --- a/data/src/message/broadcast.rs +++ b/data/src/message/broadcast.rs @@ -1,4 +1,6 @@ //! Generate messages that can be broadcast into every buffer +use std::collections::HashSet; + use chrono::{DateTime, Utc}; use super::{parse_fragments, plain, source, Content, Direction, Message, Source, Target}; @@ -32,6 +34,7 @@ fn expand( content, id: None, hash, + hidden_urls: HashSet::default(), } }; diff --git a/data/src/preview.rs b/data/src/preview.rs new file mode 100644 index 000000000..754d510cd --- /dev/null +++ b/data/src/preview.rs @@ -0,0 +1,260 @@ +use std::{collections::HashMap, io, sync::LazyLock, time::Duration}; + +use log::debug; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::{ + fs::{self, File}, + io::AsyncWriteExt, + sync::Semaphore, + time, +}; +use url::Url; + +pub use self::card::Card; +pub use self::image::Image; + +mod cache; +pub mod card; +pub mod image; + +// TODO: Make these configurable at request level +const TIMEOUT: Duration = Duration::from_secs(10); +const RATE_LIMIT_DELAY: Duration = Duration::from_millis(100); + +// Prevent us from rate limiting ourselves +static RATE_LIMIT: Semaphore = Semaphore::const_new(4); +static CLIENT: LazyLock = LazyLock::new(|| { + reqwest::Client::builder() + .user_agent("halloy") + .timeout(TIMEOUT) + .build() + .expect("build client") +}); +static OPENGRAPH_REGEX: LazyLock = LazyLock::new(|| { + Regex::new( + r#"(?m)]+(name|property|content)=("[^"]+"|'[^']+')[^>]+(name|property|content)=("[^"]+"|'[^']+')[^>]*\/?>"#, + ) + .expect("valid opengraph regex") +}); + +pub type Collection = HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Preview { + Card(Card), + Image(Image), +} + +#[derive(Debug)] +pub enum State { + Loading, + Loaded(Preview), + Error(LoadError), +} + +pub async fn load(url: Url) -> Result { + if let Some(state) = cache::load(&url).await { + match state { + cache::State::Ok(preview) => return Ok(preview), + cache::State::Error => return Err(LoadError::CachedFailed), + } + } + + match load_uncached(url.clone()).await { + Ok(preview) => { + cache::save(&url, cache::State::Ok(preview.clone())).await; + + Ok(preview) + } + Err(error) => { + cache::save(&url, cache::State::Error).await; + + Err(error) + } + } +} + +async fn load_uncached(url: Url) -> Result { + debug!("Loading preview for {url}"); + + match fetch(url.clone()).await? { + Fetched::Image(image) => Ok(Preview::Image(image)), + Fetched::Other(bytes) => { + let mut canonical_url = None; + let mut image_url = None; + let mut title = None; + let mut description = None; + + for (_, [key_1, value_1, key_2, value_2]) in OPENGRAPH_REGEX + .captures_iter(&String::from_utf8_lossy(&bytes)) + .map(|c| c.extract()) + { + let value_1 = unescape( + value_1 + .trim_start_matches(['\'', '"']) + .trim_end_matches(['\'', '"']), + ); + let value_2 = unescape( + value_2 + .trim_start_matches(['\'', '"']) + .trim_end_matches(['\'', '"']), + ); + + let (property, content) = + if (key_1 == "property" || key_1 == "name") && key_2 == "content" { + (value_1, value_2) + } else if key_1 == "content" && (key_2 == "property" || key_2 == "name") { + (value_2, value_1) + } else { + continue; + }; + + match property.as_str() { + "og:url" => canonical_url = Some(content.parse()?), + "og:image" => image_url = Some(content.parse()?), + "og:title" => title = Some(content), + "og:description" => description = Some(content), + _ => {} + } + } + + let image_url = image_url.ok_or(LoadError::MissingProperty("image"))?; + + let Fetched::Image(image) = fetch(image_url).await? else { + return Err(LoadError::NotImage); + }; + + Ok(Preview::Card(Card { + url: url.clone(), + canonical_url: canonical_url.ok_or(LoadError::MissingProperty("url"))?, + image, + title: title.ok_or(LoadError::MissingProperty("title"))?, + description, + })) + } + } +} + +enum Fetched { + Image(Image), + Other(Vec), +} + +async fn fetch(url: Url) -> Result { + // TODO: Make these configurable + // 10 mb + const MAX_IMAGE_SIZE: usize = 10 * 1024 * 1024; + // 500 kb + const MAX_OTHER_SIZE: usize = 500 * 1024; + + let _permit = RATE_LIMIT.acquire().await; + + let mut resp = CLIENT.get(url.clone()).send().await?.error_for_status()?; + + let Some(first_chunk) = resp.chunk().await? else { + return Err(LoadError::EmptyBody); + }; + + // First chunk should always be enough bytes to detect + // image MAGIC value (<32 bytes) + let fetched = match image::format(&first_chunk) { + Some(format) => { + // Store image to disk, we don't want to explode memory + let temp_path = cache::download_path(&url); + + if let Some(parent) = temp_path.parent().filter(|p| !p.exists()) { + fs::create_dir_all(&parent).await?; + } + + let mut file = File::create(&temp_path).await?; + let mut hasher = Sha256::default(); + + file.write_all(&first_chunk).await?; + hasher.update(&first_chunk); + + let mut written = first_chunk.len(); + + while let Some(chunk) = resp.chunk().await? { + if written + chunk.len() > MAX_IMAGE_SIZE { + return Err(LoadError::ImageTooLarge); + } + + file.write_all(&chunk).await?; + hasher.update(&chunk); + + written += chunk.len(); + } + + let digest = image::Digest::new(&hasher.finalize()); + let image_path = cache::image_path(&format, &digest); + + if let Some(parent) = image_path.parent().filter(|p| !p.exists()) { + fs::create_dir_all(&parent).await?; + } + + fs::rename(temp_path, &image_path).await?; + + Fetched::Image(Image::new(format, url, digest)) + } + None => { + let mut buffer = Vec::with_capacity(MAX_OTHER_SIZE); + buffer.extend(first_chunk); + + while let Some(mut chunk) = resp.chunk().await? { + if buffer.len() + chunk.len() > MAX_OTHER_SIZE { + buffer.extend(chunk.split_to(MAX_OTHER_SIZE.saturating_sub(buffer.len()))); + break; + } else { + buffer.extend(chunk); + } + } + + Fetched::Other(buffer) + } + }; + + // Artifically wait before releasing this + // RATE_LIMIT permit + time::sleep(RATE_LIMIT_DELAY).await; + + Ok(fetched) +} + +fn unescape(s: &str) -> String { + s.replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") +} + +#[derive(Debug, thiserror::Error)] +pub enum LoadError { + #[error("cached failed attempt")] + CachedFailed, + #[error("url doesn't contain open graph data")] + MissingOpenGraphData, + #[error("empty body")] + EmptyBody, + #[error("url is not html")] + NotHtml, + #[error("url is not an image")] + NotImage, + #[error("image exceeds max file size")] + ImageTooLarge, + #[error("failed to parse image: {0}")] + ParseImage(#[from] image::Error), + #[error("missing required property {0}")] + MissingProperty(&'static str), + #[error("request failed: {0}")] + Reqwest(#[from] reqwest::Error), + #[error("failed to parse url: {0}")] + ParseUrl(#[from] url::ParseError), + #[error("io error: {0}")] + Io(#[from] io::Error), +} diff --git a/data/src/preview/cache.rs b/data/src/preview/cache.rs new file mode 100644 index 000000000..081e4b0c3 --- /dev/null +++ b/data/src/preview/cache.rs @@ -0,0 +1,96 @@ +use std::path::PathBuf; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use tokio::fs; +use url::Url; + +use crate::environment; + +use super::{image, Preview}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum State { + Ok(Preview), + Error, +} + +pub async fn load(url: &Url) -> Option { + let path = state_path(url); + + if !path.exists() { + return None; + } + + let state: State = serde_json::from_slice(&fs::read(&path).await.ok()?).ok()?; + + // Ensure the actual image is cached + match &state { + State::Ok(Preview::Card(card)) => { + if !card.image.path.exists() { + super::fetch(card.image.url.clone()).await.ok()?; + } + } + State::Ok(Preview::Image(image)) => { + if !image.path.exists() { + super::fetch(image.url.clone()).await.ok()?; + } + } + State::Error => {} + } + + Some(state) +} + +pub async fn save(url: &Url, state: State) { + let path = state_path(url); + + if let Some(parent) = path.parent().filter(|p| !p.exists()) { + let _ = fs::create_dir_all(parent).await; + } + + let Ok(bytes) = serde_json::to_vec(&state) else { + return; + }; + + let _ = fs::write(path, &bytes).await; +} + +fn state_path(url: &Url) -> PathBuf { + let hash = hex::encode(seahash::hash(url.as_str().as_bytes()).to_be_bytes()); + + environment::cache_dir() + .join("previews") + .join("state") + .join(&hash[..2]) + .join(&hash[2..4]) + .join(&hash[4..6]) + .join(format!("{hash}.json")) +} + +pub(super) fn download_path(url: &Url) -> PathBuf { + let hash = seahash::hash(url.as_str().as_bytes()); + // Unique download path so if 2 identical URLs are downloading + // at the same time, they don't clobber eachother + let nanos = Utc::now().timestamp_nanos_opt().unwrap_or_default(); + + environment::cache_dir() + .join("previews") + .join("downloads") + .join(format!("{hash}-{nanos}.part")) +} + +pub(super) fn image_path(format: &image::Format, digest: &image::Digest) -> PathBuf { + environment::cache_dir() + .join("previews") + .join("images") + .join(&digest.as_ref()[..2]) + .join(&digest.as_ref()[2..4]) + .join(&digest.as_ref()[4..6]) + .join(format!( + "{}.{}", + digest.as_ref(), + format.extensions_str()[0] + )) +} diff --git a/data/src/preview/card.rs b/data/src/preview/card.rs new file mode 100644 index 000000000..9c1ca5e32 --- /dev/null +++ b/data/src/preview/card.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; +use url::Url; + +use super::Image; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Card { + pub url: Url, + pub canonical_url: Url, + pub image: Image, + pub title: String, + pub description: Option, +} diff --git a/data/src/preview/image.rs b/data/src/preview/image.rs new file mode 100644 index 000000000..6ccef2cf5 --- /dev/null +++ b/data/src/preview/image.rs @@ -0,0 +1,61 @@ +use std::path::PathBuf; + +use derive_more::derive::AsRef; +use serde::{Deserialize, Serialize}; +use url::Url; + +use super::cache; + +pub type Format = image::ImageFormat; +pub type Error = image::ImageError; + +/// SHA256 digest of image +#[derive(Debug, Clone, Serialize, Deserialize, AsRef)] +pub struct Digest(String); + +impl Digest { + pub fn new(data: &[u8]) -> Self { + Self(hex::encode(data)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Image { + #[serde(with = "serde_format")] + pub format: Format, + pub url: Url, + pub digest: Digest, + pub path: PathBuf, +} + +impl Image { + pub fn new(format: Format, url: Url, digest: Digest) -> Self { + let path = cache::image_path(&format, &digest); + + Self { + format, + url, + digest, + path, + } + } +} + +pub fn format(bytes: &[u8]) -> Option { + image::guess_format(bytes).ok() +} + +mod serde_format { + use super::Format; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub fn serialize(format: &Format, serializer: S) -> Result { + format.to_mime_type().serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + + Format::from_mime_type(s).ok_or(serde::de::Error::custom("invalid mime type")) + } +} diff --git a/src/buffer.rs b/src/buffer.rs index 3b44b35f6..1b3de4292 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -1,6 +1,6 @@ pub use data::buffer::{Internal, Settings, Upstream}; use data::user::Nick; -use data::{buffer, file_transfer, history, message, target, Config}; +use data::{buffer, file_transfer, history, message, preview, target, Config}; use iced::Task; pub use self::channel::Channel; @@ -51,6 +51,7 @@ pub enum Event { GoToMessage(data::Server, target::Channel, message::Hash), History(Task), RequestOlderChatHistory, + PreviewChanged, } impl Buffer { @@ -109,6 +110,7 @@ impl Buffer { channel::Event::OpenChannel(channel) => Event::OpenChannel(channel), channel::Event::History(task) => Event::History(task), channel::Event::RequestOlderChatHistory => Event::RequestOlderChatHistory, + channel::Event::PreviewChanged => Event::PreviewChanged, }); (command.map(Message::Channel), event) @@ -132,6 +134,7 @@ impl Buffer { query::Event::OpenChannel(channel) => Event::OpenChannel(channel), query::Event::History(task) => Event::History(task), query::Event::RequestOlderChatHistory => Event::RequestOlderChatHistory, + query::Event::PreviewChanged => Event::PreviewChanged, }); (command.map(Message::Query), event) @@ -142,7 +145,7 @@ impl Buffer { (command.map(Message::FileTransfers), None) } (Buffer::Logs(state), Message::Logs(message)) => { - let (command, event) = state.update(message); + let (command, event) = state.update(message, history, clients, config); let event = event.map(|event| match event { logs::Event::UserContext(event) => Event::UserContext(event), @@ -153,7 +156,7 @@ impl Buffer { (command.map(Message::Logs), event) } (Buffer::Highlights(state), Message::Highlights(message)) => { - let (command, event) = state.update(message); + let (command, event) = state.update(message, history, clients, config); let event = event.map(|event| match event { highlights::Event::UserContext(event) => Event::UserContext(event), @@ -175,6 +178,7 @@ impl Buffer { clients: &'a data::client::Map, file_transfers: &'a file_transfer::Manager, history: &'a history::Manager, + previews: &'a preview::Collection, settings: &'a buffer::Settings, config: &'a Config, theme: &'a Theme, @@ -187,6 +191,7 @@ impl Buffer { state, clients, history, + previews, &settings.channel, config, theme, @@ -198,7 +203,8 @@ impl Buffer { .map(Message::Server) } Buffer::Query(state) => { - query::view(state, clients, history, config, theme, is_focused).map(Message::Query) + query::view(state, clients, history, previews, config, theme, is_focused) + .map(Message::Query) } Buffer::FileTransfers(state) => { file_transfers::view(state, file_transfers).map(Message::FileTransfers) diff --git a/src/buffer/channel.rs b/src/buffer/channel.rs index 3e2e57413..47dd7f2f6 100644 --- a/src/buffer/channel.rs +++ b/src/buffer/channel.rs @@ -1,6 +1,6 @@ use data::server::Server; use data::user::Nick; -use data::{buffer, User}; +use data::{buffer, preview, User}; use data::{channel, history, message, target, Config}; use iced::widget::{column, container, row}; use iced::{alignment, padding, Length, Task}; @@ -24,12 +24,14 @@ pub enum Event { OpenChannel(target::Channel), History(Task), RequestOlderChatHistory, + PreviewChanged, } pub fn view<'a>( state: &'a Channel, clients: &'a data::client::Map, history: &'a history::Manager, + previews: &'a preview::Collection, settings: &'a channel::Settings, config: &'a Config, theme: &'a Theme, @@ -55,6 +57,7 @@ pub fn view<'a>( &state.scroll_view, scroll_view::Kind::Channel(&state.server, channel), history, + Some(previews), chathistory_state, config, move |message, max_nick_width, max_prefix_width| { @@ -363,9 +366,14 @@ impl Channel { ) -> (Task, Option) { match message { Message::ScrollView(message) => { - let (command, event) = self - .scroll_view - .update(message, config.buffer.chathistory.infinite_scroll); + let (command, event) = self.scroll_view.update( + message, + config.buffer.chathistory.infinite_scroll, + scroll_view::Kind::Channel(&self.server, &self.target), + history, + clients, + config, + ); let event = event.and_then(|event| match event { scroll_view::Event::UserContext(event) => Some(Event::UserContext(event)), @@ -374,6 +382,7 @@ impl Channel { scroll_view::Event::RequestOlderChatHistory => { Some(Event::RequestOlderChatHistory) } + scroll_view::Event::PreviewChanged => Some(Event::PreviewChanged), }); (command.map(Message::ScrollView), event) diff --git a/src/buffer/highlights.rs b/src/buffer/highlights.rs index e52f205bb..da6275605 100644 --- a/src/buffer/highlights.rs +++ b/src/buffer/highlights.rs @@ -31,6 +31,7 @@ pub fn view<'a>( scroll_view::Kind::Highlights, history, None, + None, config, move |message, _, _| match &message.target { message::Target::Highlights { @@ -189,10 +190,23 @@ impl Highlights { Self::default() } - pub fn update(&mut self, message: Message) -> (Task, Option) { + pub fn update( + &mut self, + message: Message, + history: &history::Manager, + clients: &data::client::Map, + config: &Config, + ) -> (Task, Option) { match message { Message::ScrollView(message) => { - let (command, event) = self.scroll_view.update(message, false); + let (command, event) = self.scroll_view.update( + message, + false, + scroll_view::Kind::Highlights, + history, + clients, + config, + ); let event = event.and_then(|event| match event { scroll_view::Event::UserContext(event) => Some(Event::UserContext(event)), @@ -201,6 +215,7 @@ impl Highlights { Some(Event::GoToMessage(server, channel, message)) } scroll_view::Event::RequestOlderChatHistory => None, + scroll_view::Event::PreviewChanged => None, }); (command.map(Message::ScrollView), event) diff --git a/src/buffer/logs.rs b/src/buffer/logs.rs index deaa78eb0..df100cb5e 100644 --- a/src/buffer/logs.rs +++ b/src/buffer/logs.rs @@ -1,4 +1,4 @@ -use data::{history, isupport, message, target, Config}; +use data::{client, history, isupport, message, target, Config}; use iced::widget::container; use iced::{Length, Task}; @@ -29,6 +29,7 @@ pub fn view<'a>( scroll_view::Kind::Logs, history, None, + None, config, move |message, _, _| match message.target.source() { message::Source::Internal(message::source::Internal::Logs) => Some( @@ -66,16 +67,30 @@ impl Logs { Self::default() } - pub fn update(&mut self, message: Message) -> (Task, Option) { + pub fn update( + &mut self, + message: Message, + history: &history::Manager, + clients: &client::Map, + config: &Config, + ) -> (Task, Option) { match message { Message::ScrollView(message) => { - let (command, event) = self.scroll_view.update(message, false); + let (command, event) = self.scroll_view.update( + message, + false, + scroll_view::Kind::Logs, + history, + clients, + config, + ); let event = event.and_then(|event| match event { scroll_view::Event::UserContext(event) => Some(Event::UserContext(event)), scroll_view::Event::OpenChannel(channel) => Some(Event::OpenChannel(channel)), scroll_view::Event::GoToMessage(_, _, _) => None, scroll_view::Event::RequestOlderChatHistory => None, + scroll_view::Event::PreviewChanged => None, }); (command.map(Message::ScrollView), event) diff --git a/src/buffer/query.rs b/src/buffer/query.rs index b910c1698..a1ae98fb9 100644 --- a/src/buffer/query.rs +++ b/src/buffer/query.rs @@ -1,4 +1,4 @@ -use data::{buffer, history, message, target, Config, Server}; +use data::{buffer, history, message, preview, target, Config, Server}; use iced::widget::{column, container, row, vertical_space}; use iced::{alignment, Length, Task}; @@ -17,12 +17,14 @@ pub enum Event { OpenChannel(target::Channel), History(Task), RequestOlderChatHistory, + PreviewChanged, } pub fn view<'a>( state: &'a Query, clients: &'a data::client::Map, history: &'a history::Manager, + previews: &'a preview::Collection, config: &'a Config, theme: &'a Theme, is_focused: bool, @@ -42,6 +44,7 @@ pub fn view<'a>( &state.scroll_view, scroll_view::Kind::Query(server, query), history, + Some(previews), chathistory_state, config, move |message, max_nick_width, _| { @@ -109,12 +112,18 @@ pub fn view<'a>( }); match &config.buffer.nickname.alignment { - data::buffer::Alignment::Left | data::buffer::Alignment::Right => { - Some(row![].push(timestamp_nickname_row).push(text_container).into()) - } - data::buffer::Alignment::Top => { - Some(column![].push(timestamp_nickname_row).push(text_container).into()) - } + data::buffer::Alignment::Left | data::buffer::Alignment::Right => Some( + row![] + .push(timestamp_nickname_row) + .push(text_container) + .into(), + ), + data::buffer::Alignment::Top => Some( + column![] + .push(timestamp_nickname_row) + .push(text_container) + .into(), + ), } } message::Source::Server(server) => { @@ -265,9 +274,14 @@ impl Query { ) -> (Task, Option) { match message { Message::ScrollView(message) => { - let (command, event) = self - .scroll_view - .update(message, config.buffer.chathistory.infinite_scroll); + let (command, event) = self.scroll_view.update( + message, + config.buffer.chathistory.infinite_scroll, + scroll_view::Kind::Query(&self.server, &self.target), + history, + clients, + config, + ); let event = event.and_then(|event| match event { scroll_view::Event::UserContext(event) => Some(Event::UserContext(event)), @@ -276,6 +290,7 @@ impl Query { scroll_view::Event::RequestOlderChatHistory => { Some(Event::RequestOlderChatHistory) } + scroll_view::Event::PreviewChanged => Some(Event::PreviewChanged), }); (command.map(Message::ScrollView), event) diff --git a/src/buffer/scroll_view.rs b/src/buffer/scroll_view.rs index 38d117340..3678abb84 100644 --- a/src/buffer/scroll_view.rs +++ b/src/buffer/scroll_view.rs @@ -1,31 +1,38 @@ +use std::collections::HashMap; + use chrono::{DateTime, Local, NaiveDate, Utc}; use data::isupport::ChatHistoryState; use data::message::{self, Limit}; use data::server::Server; -use data::{history, target, Config}; +use data::{client, history, preview, target, Config}; use iced::widget::{ - button, column, container, horizontal_rule, horizontal_space, row, scrollable, text, Scrollable, + button, column, container, horizontal_rule, horizontal_space, image, row, scrollable, text, + Scrollable, }; -use iced::{padding, Length, Task}; +use iced::{padding, ContentFit, Length, Task}; +use self::correct_viewport::correct_viewport; use self::keyed::keyed; use super::user_context; -use crate::widget::{Element, MESSAGE_MARKER_TEXT}; +use crate::widget::{notify_visibility, Element, MESSAGE_MARKER_TEXT}; use crate::{font, theme}; #[derive(Debug, Clone)] pub enum Message { Scrolled { count: usize, - remaining: bool, + has_more_older_messages: bool, + has_more_newer_messages: bool, oldest: DateTime, status: Status, viewport: scrollable::Viewport, }, UserContext(user_context::Message), Link(message::Link), - ScrollTo(keyed::Bounds), + ScrollTo(keyed::Hit), RequestOlderChatHistory, + EnteringViewport(message::Hash, Vec), + ExitingViewport(message::Hash), } #[derive(Debug, Clone)] @@ -34,6 +41,7 @@ pub enum Event { OpenChannel(target::Channel), GoToMessage(Server, target::Channel, message::Hash), RequestOlderChatHistory, + PreviewChanged, } #[derive(Debug, Clone, Copy)] @@ -45,6 +53,17 @@ pub enum Kind<'a> { Highlights, } +impl Kind<'_> { + fn server(&self) -> Option<&Server> { + match self { + Kind::Server(server) | Kind::Channel(server, _) | Kind::Query(server, _) => { + Some(server) + } + Kind::Logs | Kind::Highlights => None, + } + } +} + impl From> for history::Kind { fn from(value: Kind<'_>) -> Self { match value { @@ -63,6 +82,7 @@ pub fn view<'a>( state: &State, kind: Kind, history: &'a history::Manager, + previews: Option<&'a preview::Collection>, chathistory_state: Option, config: &'a Config, format: impl Fn(&'a data::Message, Option, Option) -> Option> + 'a, @@ -70,43 +90,45 @@ pub fn view<'a>( let divider_font_size = config.font.size.map(f32::from).unwrap_or(theme::TEXT_SIZE) - 1.0; let Some(history::View { - total, + has_more_older_messages, + has_more_newer_messages, old_messages, new_messages, max_nick_chars, max_prefix_chars, + .. }) = history.get_messages(&kind.into(), Some(state.limit), &config.buffer) else { return column![].into(); }; - let top_row = if let Some(chathistory_state) = chathistory_state { - let (content, message) = match chathistory_state { - ChatHistoryState::Exhausted => ("No Older Chat History Messages Available", None), - ChatHistoryState::PendingRequest => ("...", None), - ChatHistoryState::Ready => ( - "Request Older Chat History Messages", - Some(Message::RequestOlderChatHistory), - ), + let top_row = + if let (false, Some(chathistory_state)) = (has_more_older_messages, chathistory_state) { + let (content, message) = match chathistory_state { + ChatHistoryState::Exhausted => ("No Older Chat History Messages Available", None), + ChatHistoryState::PendingRequest => ("...", None), + ChatHistoryState::Ready => ( + "Request Older Chat History Messages", + Some(Message::RequestOlderChatHistory), + ), + }; + + let top_row_button = button(text(content).size(divider_font_size)) + .padding([3, 5]) + .style(|theme, status| theme::button::primary(theme, status, false)) + .on_press_maybe(message); + + Some( + row![horizontal_space(), top_row_button, horizontal_space()] + .padding(padding::top(2).bottom(6)) + .width(Length::Fill) + .align_y(iced::Alignment::Center), + ) + } else { + None }; - let top_row_button = button(text(content).size(divider_font_size)) - .padding([3, 5]) - .style(|theme, status| theme::button::primary(theme, status, false)) - .on_press_maybe(message); - - Some( - row![horizontal_space(), top_row_button, horizontal_space()] - .padding(padding::top(2).bottom(6)) - .width(Length::Fill) - .align_y(iced::Alignment::Center), - ) - } else { - None - }; - let count = old_messages.len() + new_messages.len(); - let remaining = count < total; let oldest = old_messages .iter() .chain(&new_messages) @@ -138,29 +160,98 @@ pub fn view<'a>( *last_date = Some(date); - if is_new_day && config.buffer.date_separators.show { + let content = if let (message::Content::Fragments(fragments), Some(previews)) = + (&message.content, previews) + { + let urls = fragments + .iter() + .filter_map(message::Fragment::url) + .cloned() + .collect::>(); + + if !urls.is_empty() { + let is_message_visible = + state.visible_url_messages.contains_key(&message.hash); + + let element = if is_message_visible { + notify_visibility( + element, + 2000.0, + notify_visibility::When::NotVisible, + Message::ExitingViewport(message.hash), + ) + } else { + notify_visibility( + element, + 1000.0, + notify_visibility::When::Visible, + Message::EnteringViewport(message.hash, urls.clone()), + ) + }; + + let mut column = column![element]; + + for (idx, url) in urls.into_iter().enumerate() { + if message.hidden_urls.contains(&url) { + continue; + } + if let (true, Some(preview::State::Loaded(preview))) = + (is_message_visible, previews.get(&url)) + { + let content = match preview { + data::Preview::Card(preview::Card { + image: preview::Image { path, .. }, + .. + }) => keyed( + keyed::Key::Image(message.hash, idx), + container(image(path).content_fit(ContentFit::ScaleDown)) + .max_height(200), + ), + data::Preview::Image(preview::Image { path, .. }) => keyed( + keyed::Key::Image(message.hash, idx), + container(image(path).content_fit(ContentFit::ScaleDown)) + .max_height(200), + ), + }; + + column = column.push(content); + } + } + + column.into() + } else { + element + } + } else { + element + }; + + if is_new_day && config.buffer.date_separators.show { Some( column![ row![ container(horizontal_rule(1)) .width(Length::Fill) .padding(padding::right(6)), - text(date.format(&config.buffer.date_separators.format).to_string()) - .size(divider_font_size) - .style(theme::text::secondary), + text( + date.format(&config.buffer.date_separators.format) + .to_string() + ) + .size(divider_font_size) + .style(theme::text::secondary), container(horizontal_rule(1)) .width(Length::Fill) .padding(padding::left(6)) ] .padding(2) .align_y(iced::Alignment::Center), - element + content ] .into(), ) } else { - Some(element) + Some(content) } }) .collect::>() @@ -174,8 +265,9 @@ pub fn view<'a>( &new_messages, ); - let show_divider = - !new.is_empty() || matches!(status, Status::Idle(Anchor::Bottom) | Status::ScrollTo); + // TODO: Any reason we need to hide it? + let show_divider = true; + // !new.is_empty() || matches!(status, Status::Idle(Anchor::Bottom) | Status::ScrollTo); let divider = if show_divider { row![ @@ -201,22 +293,26 @@ pub fn view<'a>( .push(keyed(keyed::Key::Divider, divider)) .push(column(new)); - Scrollable::new(container(content).width(Length::Fill).padding([0, 8])) - .direction(scrollable::Direction::Vertical( - scrollable::Scrollbar::default() - .anchor(status.alignment()) - .width(5) - .scroller_width(5), - )) - .on_scroll(move |viewport| Message::Scrolled { - count, - remaining, - oldest, - status, - viewport, - }) - .id(state.scrollable.clone()) - .into() + correct_viewport( + Scrollable::new(container(content).width(Length::Fill).padding([0, 8])) + .direction(scrollable::Direction::Vertical( + scrollable::Scrollbar::default() + .anchor(status.alignment()) + .width(5) + .scroller_width(5), + )) + .on_scroll(move |viewport| Message::Scrolled { + has_more_older_messages, + has_more_newer_messages, + count, + oldest, + status, + viewport, + }) + .id(state.scrollable.clone()), + state.scrollable.clone(), + !matches!(state.status, Status::Idle(Anchor::Bottom)), + ) } #[derive(Debug, Clone)] @@ -225,6 +321,7 @@ pub struct State { limit: Limit, status: Status, pending_scroll_to: Option, + visible_url_messages: HashMap>, } impl Default for State { @@ -234,6 +331,7 @@ impl Default for State { limit: Limit::bottom(), status: Status::default(), pending_scroll_to: None, + visible_url_messages: HashMap::new(), } } } @@ -247,77 +345,93 @@ impl State { &mut self, message: Message, infinite_scroll: bool, + kind: Kind, + history: &history::Manager, + clients: &client::Map, + config: &Config, ) -> (Task, Option) { match message { Message::Scrolled { count, - remaining, + has_more_older_messages, + has_more_newer_messages, oldest, status: old_status, viewport, } => { let relative_offset = viewport.relative_offset().y; - match old_status { - Status::ScrollTo => { - return (Task::none(), None); - } - Status::Loading(anchor) => { - self.status = Status::Unlocked(anchor); + let mut tasks = vec![]; + let mut event = None; - if matches!(anchor, Anchor::Bottom) { - self.limit = Limit::Since(oldest); - } - // Top anchor can get stuck in loading state at - // end of scrollable. - else if old_status.is_end(relative_offset) { - if remaining { - self.status = Status::Loading(Anchor::Top); - self.limit = Limit::Top(count + Limit::DEFAULT_STEP); - } else { - self.status = Status::Idle(Anchor::Bottom); - self.limit = Limit::bottom(); - } - } + match old_status { + // Scrolling down from top & have more to load + _ if old_status.is_bottom(relative_offset) && has_more_newer_messages => { + self.limit = Limit::Top(count + Limit::DEFAULT_STEP); } - _ if old_status.is_end(relative_offset) && remaining => { - match old_status.anchor() { - Anchor::Top => { - self.status = Status::Loading(Anchor::Top); - self.limit = Limit::Top(count + Limit::DEFAULT_STEP); - } - Anchor::Bottom => { - self.status = Status::Loading(Anchor::Bottom); - self.limit = Limit::Bottom(count + Limit::DEFAULT_STEP); + // Scrolling up from bottom & have more to load + _ if old_status.is_top(relative_offset) && has_more_older_messages => { + self.limit = Limit::Bottom(count + Limit::DEFAULT_STEP); + + // Get new oldest message w/ new limit and use that w/ Since + if let Some(history::View { + old_messages, + new_messages, + .. + }) = history.get_messages(&kind.into(), Some(self.limit), &config.buffer) + { + if let Some(oldest) = old_messages.iter().chain(&new_messages).next() { + self.limit = Limit::Since(oldest.server_time); } } } + // Hit bottom, anchor it _ if old_status.is_bottom(relative_offset) => { self.status = Status::Idle(Anchor::Bottom); self.limit = Limit::bottom(); } + // Hit top _ if old_status.is_top(relative_offset) => { - self.status = Status::Idle(Anchor::Top); - self.limit = Limit::top(); + // If we're infinite scroll & out of messages, load more via chathistory + if let Some(server) = kind + .server() + .filter(|_| infinite_scroll && !has_more_older_messages) + { + // Load more history & ensure scrollable is unlocked + event = Some(Event::RequestOlderChatHistory); + self.limit = Limit::Top( + clients.get_server_chathistory_limit(server) as usize + + Limit::DEFAULT_COUNT, + ); + + if !matches!(self.status, Status::Unlocked) { + self.status = Status::Unlocked; + } + } else { + // Anchor it + self.status = Status::Idle(Anchor::Top); + self.limit = Limit::top(); + } } + // Moving away from anchored, unlock it Status::Idle(anchor) if !old_status.is_start(relative_offset) => { - self.status = Status::Unlocked(anchor); + self.status = Status::Unlocked; if matches!(anchor, Anchor::Bottom) { self.limit = Limit::Since(oldest); } } - Status::Unlocked(_) | Status::Idle(_) => {} + // Normal scrolling w/ no side-effects + Status::Unlocked | Status::Idle(_) => {} } - if let Some(new_offset) = self.status.new_offset(old_status, viewport) { - return ( - scrollable::scroll_to(self.scrollable.clone(), new_offset), - None, - ); - } else if infinite_scroll && self.status.is_top(relative_offset) { - return (Task::none(), Some(Event::RequestOlderChatHistory)); + // If alignment changes, we need to flip the scrollable translation + // for the new offset + if let Some(new_offset) = self.status.alignment_flipped(old_status, viewport) { + tasks.push(scrollable::scroll_to(self.scrollable.clone(), new_offset)); } + + return (Task::batch(tasks), event); } Message::UserContext(message) => { return ( @@ -345,47 +459,69 @@ impl State { Some(Event::GoToMessage(server, channel, message)), ) } - Message::ScrollTo(keyed::Bounds { - scrollable_bounds, + Message::ScrollTo(keyed::Hit { hit_bounds, + scrollable, prev_bounds, + .. }) => { - let total_offset = - scrollable_bounds.content.height - scrollable_bounds.viewport.height; - - let absolute = hit_bounds.y - scrollable_bounds.content.y; - let relative = (absolute / total_offset).min(1.0); + let max_translation = scrollable.content.height - scrollable.viewport.height; - self.status = Status::Idle(Anchor::Bottom); + // Did this cause us to hit the bottom? If so, anchor it + if (scrollable.translation.y - max_translation).abs() <= f32::EPSILON { + self.status = Status::Idle(Anchor::Bottom); - // Offsets are given relative to top, - // and we must scroll to offsets relative to - // the bottom - let offset = if relative == 1.0 { - 0.0 - } else { + return ( + scrollable::scroll_to( + self.scrollable.clone(), + scrollable::AbsoluteOffset { x: 0.0, y: 0.0 }, + ), + None, + ); + } + // Otherwise scroll to the hit bounds + else { // If a prev element exists, put scrollable halfway over prev // element so it's obvious user can scroll up - if let Some(bounds) = prev_bounds { - let absolute = - (bounds.y - scrollable_bounds.content.y) + bounds.height / 2.0; - - total_offset - absolute + let offset = if let Some(bounds) = prev_bounds { + (bounds.y - scrollable.content.y) + bounds.height / 2.0 } else { - total_offset - absolute + hit_bounds.y - scrollable.content.y } - }; + .min(max_translation); - return ( - scrollable::scroll_to( - self.scrollable.clone(), - scrollable::AbsoluteOffset { x: 0.0, y: offset }, - ), - None, - ); + self.status = Status::Unlocked; + + return ( + scrollable::scroll_to( + self.scrollable.clone(), + scrollable::AbsoluteOffset { x: 0.0, y: offset }, + ), + None, + ); + } } Message::RequestOlderChatHistory => { - return (Task::none(), Some(Event::RequestOlderChatHistory)) + if let Some(server) = kind.server() { + self.limit = Limit::Top( + clients.get_server_chathistory_limit(server) as usize + + Limit::DEFAULT_COUNT, + ); + + if !matches!(self.status, Status::Unlocked) { + self.status = Status::Unlocked; + } + + return (Task::none(), Some(Event::RequestOlderChatHistory)); + } + } + Message::EnteringViewport(hash, urls) => { + self.visible_url_messages.insert(hash, urls); + return (Task::none(), Some(Event::PreviewChanged)); + } + Message::ExitingViewport(hash) => { + self.visible_url_messages.remove(&hash); + return (Task::none(), Some(Event::PreviewChanged)); } } @@ -448,10 +584,8 @@ impl State { let offset = total - pos + 1; self.limit = Limit::Bottom(offset.max(Limit::DEFAULT_COUNT)); - self.status = Status::ScrollTo; - keyed::find_bounds(self.scrollable.clone(), keyed::Key::Message(message)) - .map(Message::ScrollTo) + keyed::find(self.scrollable.clone(), keyed::Key::Message(message)).map(Message::ScrollTo) } pub fn scroll_to_backlog( @@ -481,18 +615,19 @@ impl State { let offset = total - old_messages.len() + 1; self.limit = Limit::Bottom(offset.max(Limit::DEFAULT_COUNT)); - self.status = Status::ScrollTo; - keyed::find_bounds(self.scrollable.clone(), keyed::Key::Divider).map(Message::ScrollTo) + keyed::find(self.scrollable.clone(), keyed::Key::Divider).map(Message::ScrollTo) + } + + pub fn visible_urls(&self) -> impl Iterator { + self.visible_url_messages.values().flatten() } } #[derive(Debug, Clone, Copy)] pub enum Status { Idle(Anchor), - Unlocked(Anchor), - Loading(Anchor), - ScrollTo, + Unlocked, } #[derive(Debug, Clone, Copy)] @@ -505,9 +640,7 @@ impl Status { fn anchor(self) -> Anchor { match self { Status::Idle(anchor) => anchor, - Status::Unlocked(anchor) => anchor, - Status::Loading(anchor) => anchor, - Status::ScrollTo => Anchor::Bottom, + Status::Unlocked => Anchor::Top, } } @@ -517,19 +650,7 @@ impl Status { Anchor::Top => scrollable::Anchor::Start, Anchor::Bottom => scrollable::Anchor::End, }, - Status::Unlocked(_) => scrollable::Anchor::Start, - Status::Loading(anchor) => match anchor { - Anchor::Top => scrollable::Anchor::Start, - Anchor::Bottom => scrollable::Anchor::End, - }, - Status::ScrollTo => scrollable::Anchor::Start, - } - } - - fn is_end(self, relative_offset: f32) -> bool { - match self.anchor() { - Anchor::Top => self.is_bottom(relative_offset), - Anchor::Bottom => self.is_top(relative_offset), + Status::Unlocked => scrollable::Anchor::Start, } } @@ -554,7 +675,7 @@ impl Status { } } - fn new_offset( + fn alignment_flipped( self, other: Self, viewport: scrollable::Viewport, @@ -595,6 +716,7 @@ mod keyed { pub enum Key { Divider, Message(message::Hash), + Image(message::Hash, usize), } impl Key { @@ -624,98 +746,353 @@ mod keyed { } #[derive(Debug, Clone, Copy)] - pub struct Bounds { - pub scrollable_bounds: ScrollableBounds, + pub struct Hit { + pub key: Key, pub hit_bounds: Rectangle, pub prev_bounds: Option, + pub scrollable: Scrollable, } #[derive(Debug, Clone, Copy)] - pub struct ScrollableBounds { + pub struct Scrollable { pub viewport: Rectangle, pub content: Rectangle, + pub translation: Vector, + } + + pub fn find(scrollable: scrollable::Id, key: Key) -> Task { + widget::operate(Find { + active: false, + scrollable_id: scrollable, + key, + scrollable: None, + hit_bounds: None, + prev_bounds: None, + }) + } + + #[derive(Debug, Clone)] + pub struct Find { + pub active: bool, + pub key: Key, + pub scrollable_id: scrollable::Id, + pub scrollable: Option, + pub hit_bounds: Option, + pub prev_bounds: Option, } - pub fn find_bounds(scrollable: scrollable::Id, key: Key) -> Task { - #[derive(Debug, Clone)] - struct State { - active: bool, - key: Key, - scrollable: scrollable::Id, - scrollable_bounds: Option, - hit_bounds: Option, - prev_bounds: Option, + impl Operation for Find { + fn scrollable( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + content_bounds: Rectangle, + translation: Vector, + _state: &mut dyn widget::operation::Scrollable, + ) { + if id == Some(&self.scrollable_id.clone().into()) { + self.scrollable = Some(Scrollable { + viewport: bounds, + content: content_bounds, + translation, + }); + self.active = true; + } else { + self.active = false; + } } - impl Operation for State { - fn scrollable( - &mut self, - id: Option<&widget::Id>, - bounds: Rectangle, - content_bounds: Rectangle, - _translation: Vector, - _state: &mut dyn widget::operation::Scrollable, - ) { - if id == Some(&self.scrollable.clone().into()) { - self.scrollable_bounds = Some(ScrollableBounds { - viewport: bounds, - content: content_bounds, - }); - self.active = true; - } else { - self.active = false; + fn container( + &mut self, + _id: Option<&widget::Id>, + _bounds: Rectangle, + operate_on_children: &mut dyn FnMut(&mut dyn Operation), + ) { + operate_on_children(self) + } + + fn custom( + &mut self, + _id: Option<&widget::Id>, + bounds: Rectangle, + state: &mut dyn std::any::Any, + ) { + if self.active { + if let Some(key) = state.downcast_ref::() { + if self.key == *key { + self.hit_bounds = Some(bounds); + } else if self.hit_bounds.is_none() { + self.prev_bounds = Some(bounds); + } } } + } - fn container( - &mut self, - _id: Option<&widget::Id>, - _bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), - ) { - operate_on_children(self) + fn finish(&self) -> widget::operation::Outcome { + match self + .scrollable + .zip(self.hit_bounds) + .map(|(scrollable, hit_bounds)| Hit { + key: self.key, + scrollable, + hit_bounds, + prev_bounds: self.prev_bounds, + }) { + Some(hit) => widget::operation::Outcome::Some(hit), + None => widget::operation::Outcome::None, } + } + } - fn custom( - &mut self, - _id: Option<&widget::Id>, - bounds: Rectangle, - state: &mut dyn std::any::Any, - ) { - if self.active { - if let Some(key) = state.downcast_ref::() { - if self.key == *key { - self.hit_bounds = Some(bounds); - } else if self.hit_bounds.is_none() { - self.prev_bounds = Some(bounds); - } + #[derive(Debug, Clone)] + pub struct TopOfViewport { + pub active: bool, + pub scrollable_id: scrollable::Id, + pub scrollable: Option, + pub hit_bounds: Option<(Key, Rectangle)>, + } + + impl Operation for TopOfViewport { + fn scrollable( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + content_bounds: Rectangle, + translation: Vector, + _state: &mut dyn widget::operation::Scrollable, + ) { + if id == Some(&self.scrollable_id.clone().into()) { + self.scrollable = Some(Scrollable { + viewport: bounds, + content: content_bounds, + translation, + }); + self.active = true; + } else { + self.active = false; + } + } + + fn container( + &mut self, + _id: Option<&widget::Id>, + _bounds: Rectangle, + operate_on_children: &mut dyn FnMut(&mut dyn Operation), + ) { + operate_on_children(self) + } + + fn custom( + &mut self, + _id: Option<&widget::Id>, + bounds: Rectangle, + state: &mut dyn std::any::Any, + ) { + if self.active { + if let Some(key) = state.downcast_ref::() { + if self.hit_bounds.is_none() + && self.scrollable.is_some_and(|scrollable| { + scrollable + .viewport + .intersects(&(bounds - scrollable.translation)) + }) + { + self.hit_bounds = Some((*key, bounds)); } } } + } - fn finish(&self) -> widget::operation::Outcome { - widget::operation::Outcome::Some(self.clone()) + fn finish(&self) -> widget::operation::Outcome { + match self + .scrollable + .zip(self.hit_bounds) + .map(|(scrollable, (key, hit_bounds))| Hit { + key, + scrollable, + hit_bounds, + prev_bounds: None, + }) { + Some(hit) => widget::operation::Outcome::Some(hit), + None => widget::operation::Outcome::None, } } + } +} - widget::operate(State { - active: false, - scrollable, - key, - scrollable_bounds: None, - hit_bounds: None, - prev_bounds: None, - }) - .map(|state| { - state - .scrollable_bounds - .zip(state.hit_bounds) - .map(|(scrollable_bounds, hit_bounds)| Bounds { - scrollable_bounds, - hit_bounds, - prev_bounds: state.prev_bounds, - }) - }) - .and_then(Task::done) +mod correct_viewport { + use std::sync::{Arc, Mutex}; + + use iced::advanced::widget::operation::scrollable; + use iced::advanced::widget::Operation; + use iced::advanced::{self, widget}; + + use crate::widget::decorate; + use crate::widget::{Element, Renderer}; + + use super::keyed; + use super::Message; + + pub fn correct_viewport<'a>( + inner: impl Into>, + scrollable: iced::widget::scrollable::Id, + enabled: bool, + ) -> Element<'a, Message> { + decorate(inner) + .update( + move |state: &mut Option, + inner: &mut Element<'a, Message>, + tree: &mut advanced::widget::Tree, + event: iced::Event, + layout: advanced::Layout<'_>, + cursor: advanced::mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn advanced::Clipboard, + shell: &mut advanced::Shell<'_, Message>, + viewport: &iced::Rectangle| { + let is_redraw = matches!( + event, + iced::Event::Window(iced::window::Event::RedrawRequested(_)) + ); + + // Check if top-of-viewport element has shifted since we last scrolled and adjust + if let (true, true, Some(old)) = (enabled, is_redraw, &state) { + let hit = Arc::new(Mutex::new(None)); + + let mut operation = widget::operation::map( + keyed::Find { + active: false, + key: old.key, + scrollable_id: scrollable.clone(), + scrollable: None, + hit_bounds: None, + prev_bounds: None, + }, + { + let hit = hit.clone(); + move |result| { + *hit.lock().unwrap() = Some(result); + } + }, + ); + + inner + .as_widget() + .operate(tree, layout, renderer, &mut operation); + operation.finish(); + drop(operation); + + if let Some(new) = Arc::into_inner(hit) + .and_then(|m| m.into_inner().ok()) + .flatten() + { + // Something shifted this, let's put it back to the + // top of the viewport + if new.hit_bounds.y != old.hit_bounds.y { + let viewport_offset = old.scrollable.viewport.y + - (old.hit_bounds - old.scrollable.translation).y; + + // New offset needed to place same element back to same offset + // from top of viewport + let new_offset = f32::min( + (new.hit_bounds.y + viewport_offset) + - new.scrollable.viewport.y, + new.scrollable.content.height - new.scrollable.viewport.height, + ); + + let mut operation = scrollable::scroll_to( + scrollable.clone().into(), + scrollable::AbsoluteOffset { + x: 0.0, + y: new_offset, + }, + ); + inner + .as_widget() + .operate(tree, layout, renderer, &mut operation); + operation.finish(); + } + } + } + + let mut messages = vec![]; + let mut local_shell = advanced::Shell::new(&mut messages); + + inner.as_widget_mut().update( + tree, + event, + layout, + cursor, + renderer, + clipboard, + &mut local_shell, + viewport, + ); + + // Merge shell (we can't use Shell::merge as we'd lose access to messages) + { + if let Some(new) = local_shell.redraw_request() { + match new { + iced::window::RedrawRequest::NextFrame => shell.request_redraw(), + iced::window::RedrawRequest::At(instant) => { + shell.request_redraw_at(instant) + } + } + } + + if local_shell.is_layout_invalid() { + shell.invalidate_layout(); + } + + if local_shell.are_widgets_invalid() { + shell.invalidate_widgets(); + } + + if local_shell.is_event_captured() { + shell.capture_event(); + } + } + + let is_scrolled = messages + .clone() + .iter() + .any(|message| matches!(message, Message::Scrolled { .. })); + + for message in messages { + shell.publish(message); + } + + // Re-query top of viewport any-time we scroll + if is_scrolled { + let hit = Arc::new(Mutex::new(None)); + + let mut operation = widget::operation::map( + keyed::TopOfViewport { + active: false, + scrollable_id: scrollable.clone(), + scrollable: None, + hit_bounds: None, + }, + { + let hit = hit.clone(); + move |result| { + *hit.lock().unwrap() = Some(result); + } + }, + ); + + inner + .as_widget() + .operate(tree, layout, renderer, &mut operation); + operation.finish(); + drop(operation); + + *state = Arc::into_inner(hit) + .and_then(|m| m.into_inner().ok()) + .flatten(); + } + }, + ) + .into() } } diff --git a/src/buffer/server.rs b/src/buffer/server.rs index 2cd4e7f4a..0f7911b3d 100644 --- a/src/buffer/server.rs +++ b/src/buffer/server.rs @@ -37,6 +37,7 @@ pub fn view<'a>( scroll_view::Kind::Server(&state.server), history, None, + None, config, move |message, _, _| { let timestamp = @@ -132,13 +133,21 @@ impl Server { ) -> (Task, Option) { match message { Message::ScrollView(message) => { - let (command, event) = self.scroll_view.update(message, false); + let (command, event) = self.scroll_view.update( + message, + false, + scroll_view::Kind::Server(&self.server), + history, + clients, + config, + ); let event = event.and_then(|event| match event { scroll_view::Event::UserContext(event) => Some(Event::UserContext(event)), scroll_view::Event::OpenChannel(channel) => Some(Event::OpenChannel(channel)), scroll_view::Event::GoToMessage(_, _, _) => None, scroll_view::Event::RequestOlderChatHistory => None, + scroll_view::Event::PreviewChanged => None, }); (command.map(Message::ScrollView), event) diff --git a/src/main.rs b/src/main.rs index 67671be5b..653e70c82 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,7 +71,7 @@ pub fn main() -> Result<(), Box> { .enable_all() .build()?; - rt.block_on(async { + rt.block_on(async { let config = Config::load().await; let window = data::Window::load().await; @@ -237,7 +237,7 @@ impl Halloy { url_received: Option, log_stream: ReceiverStream>, ) -> (Halloy, Task) { - let data::Window { size, position} = window_load.unwrap_or_default(); + let data::Window { size, position } = window_load.unwrap_or_default(); let position = position.map(window::Position::Specific).unwrap_or_default(); let (main_window, open_main_window) = window::open(window::Settings { diff --git a/src/screen/dashboard.rs b/src/screen/dashboard.rs index d08527e60..b97e5d32e 100644 --- a/src/screen/dashboard.rs +++ b/src/screen/dashboard.rs @@ -1,22 +1,23 @@ -use chrono::{DateTime, Utc}; -use data::dashboard::BufferAction; -use data::environment::{RELEASE_WEBSITE, WIKI_WEBSITE}; -use data::history::ReadMarker; -use std::collections::HashMap; +use std::collections::{hash_map, HashMap, HashSet}; use std::path::PathBuf; use std::time::{Duration, Instant}; use std::{convert, slice}; -use data::config; +use chrono::{DateTime, Utc}; +use data::dashboard::BufferAction; +use data::environment::{RELEASE_WEBSITE, WIKI_WEBSITE}; use data::file_transfer; use data::history::manager::Broadcast; +use data::history::ReadMarker; use data::isupport::{self, ChatHistorySubcommand, MessageReference}; use data::target::{self, Target}; use data::user::Nick; use data::{client, environment, history, Config, Server, Version}; +use data::{config, preview}; use iced::widget::pane_grid::{self, PaneGrid}; use iced::widget::{column, container, row, Space}; use iced::{clipboard, Length, Task, Vector}; +use log::{debug, error}; use self::command_bar::CommandBar; use self::pane::Pane; @@ -46,6 +47,7 @@ pub struct Dashboard { file_transfers: file_transfer::Manager, theme_editor: Option, notifications: notification::Notifications, + previews: preview::Collection, } #[derive(Debug)] @@ -63,6 +65,7 @@ pub enum Message { ThemeEditor(theme_editor::Message), ConfigReloaded(Result), Client(client::Message), + LoadPreview((url::Url, Result)), } #[derive(Debug)] @@ -91,6 +94,7 @@ impl Dashboard { file_transfers: file_transfer::Manager::new(config.file_transfer.clone()), theme_editor: None, notifications: notification::Notifications::new(), + previews: preview::Collection::default(), }; let command = dashboard.track(); @@ -398,6 +402,34 @@ impl Dashboard { self.request_older_chathistory(clients, &buffer); } } + buffer::Event::PreviewChanged => { + let visible = self.panes.visible_urls(); + let tracking = + self.previews.keys().cloned().collect::>(); + let missing = + visible.difference(&tracking).cloned().collect::>(); + let removed = tracking.difference(&visible); + + for url in &missing { + self.previews.insert(url.clone(), preview::State::Loading); + } + + for url in removed { + self.previews.remove(url); + } + + return ( + Task::batch(missing.into_iter().map(|url| { + Task::perform( + data::preview::load(url.clone()), + move |result| { + Message::LoadPreview((url.clone(), result)) + }, + ) + })), + None, + ); + } } return (task, None); @@ -1027,6 +1059,18 @@ impl Dashboard { ); } }, + Message::LoadPreview((url, Ok(preview))) => { + debug!("Preview loaded for {url}"); + if let hash_map::Entry::Occupied(mut entry) = self.previews.entry(url) { + *entry.get_mut() = preview::State::Loaded(preview); + } + } + Message::LoadPreview((url, Err(error))) => { + error!("Failed to load preview for {url}: {error}"); + if self.previews.contains_key(&url) { + self.previews.insert(url, preview::State::Error(error)); + } + } } (Task::none(), None) @@ -1053,6 +1097,7 @@ impl Dashboard { clients, &self.file_transfers, &self.history, + &self.previews, &self.side_menu, config, theme, @@ -1098,6 +1143,7 @@ impl Dashboard { clients, &self.file_transfers, &self.history, + &self.previews, &self.side_menu, config, theme, @@ -1968,6 +2014,7 @@ impl Dashboard { file_transfers: file_transfer::Manager::new(config.file_transfer.clone()), theme_editor: None, notifications: notification::Notifications::new(), + previews: preview::Collection::default(), }; let mut tasks = vec![]; @@ -2254,6 +2301,20 @@ impl Panes { .flat_map(|state| state.panes.values().filter_map(Pane::resource)), ) } + + fn visible_urls(&self) -> HashSet { + self.main + .panes + .values() + .flat_map(Pane::visible_urls) + .chain( + self.popout + .values() + .flat_map(|state| state.panes.values().flat_map(Pane::visible_urls)), + ) + .cloned() + .collect() + } } fn all_buffers(clients: &client::Map, history: &history::Manager) -> Vec { diff --git a/src/screen/dashboard/pane.rs b/src/screen/dashboard/pane.rs index 4ac36d478..060f94a08 100644 --- a/src/screen/dashboard/pane.rs +++ b/src/screen/dashboard/pane.rs @@ -1,4 +1,4 @@ -use data::{file_transfer, history, Config}; +use data::{file_transfer, history, preview, Config}; use iced::widget::{button, center, container, pane_grid, row, text}; use crate::buffer::{self, Buffer}; @@ -57,6 +57,7 @@ impl Pane { clients: &'a data::client::Map, file_transfers: &'a file_transfer::Manager, history: &'a history::Manager, + previews: &'a preview::Collection, sidebar: &'a sidebar::Sidebar, config: &'a Config, theme: &'a Theme, @@ -106,6 +107,7 @@ impl Pane { clients, file_transfers, history, + previews, &self.settings, config, theme, @@ -137,6 +139,18 @@ impl Pane { } } + pub fn visible_urls(&self) -> Vec<&url::Url> { + match &self.buffer { + Buffer::Channel(channel) => channel.scroll_view.visible_urls().collect(), + Buffer::Query(query) => query.scroll_view.visible_urls().collect(), + Buffer::Empty + | Buffer::Server(_) + | Buffer::FileTransfers(_) + | Buffer::Logs(_) + | Buffer::Highlights(_) => vec![], + } + } + pub fn update_settings(&mut self, f: impl FnOnce(&mut buffer::Settings)) { f(&mut self.settings); } diff --git a/src/widget.rs b/src/widget.rs index 0d9491079..074372596 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -12,6 +12,7 @@ pub use self::double_pass::double_pass; pub use self::key_press::key_press; pub use self::message_content::message_content; pub use self::modal::modal; +pub use self::notify_visibility::notify_visibility; pub use self::selectable_rich_text::selectable_rich_text; pub use self::selectable_text::selectable_text; pub use self::shortcut::shortcut; @@ -28,6 +29,7 @@ pub mod double_pass; pub mod key_press; pub mod message_content; pub mod modal; +pub mod notify_visibility; pub mod selectable_rich_text; pub mod selectable_text; pub mod shortcut; diff --git a/src/widget/notify_visibility.rs b/src/widget/notify_visibility.rs new file mode 100644 index 000000000..ca09a7e3b --- /dev/null +++ b/src/widget/notify_visibility.rs @@ -0,0 +1,63 @@ +use std::cell::RefCell; + +use iced::{ + advanced::{widget, Clipboard, Layout, Shell}, + window, Padding, +}; +use iced::{mouse, Event, Rectangle}; + +use super::{decorate, Element, Renderer}; + +#[derive(Debug, Clone, Copy)] +pub enum When { + Visible, + NotVisible, +} + +pub fn notify_visibility<'a, Message>( + content: impl Into>, + margin: impl Into, + when: When, + message: Message, +) -> Element<'a, Message> +where + Message: 'a + Clone, +{ + let margin = margin.into(); + let sent = RefCell::new(false); + + decorate(content) + .update( + move |_state: &mut (), + inner: &mut Element<'a, Message>, + tree: &mut widget::Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle| { + if let Event::Window(window::Event::RedrawRequested(_)) = &event { + let mut sent = sent.borrow_mut(); + + let is_visible = viewport.expand(margin).intersects(&layout.bounds()); + + let should_notify = match when { + When::Visible => is_visible, + When::NotVisible => !is_visible, + }; + + if should_notify && !*sent { + shell.publish(message.clone()); + *sent = true; + } + } + + inner.as_widget_mut().update( + tree, event, layout, cursor, renderer, clipboard, shell, viewport, + ); + }, + ) + .into() +}