use std::collections::{HashMap, HashSet};
use image::{DynamicImage, ImageBuffer, Rgba, imageops::FilterType};
use ratatui::layout::Rect;
use ratatui_image::{Resize, picker::Picker};
mod preview;
mod targets;
pub(super) use preview::{ImagePreviewCache, ImagePreviewDecodeResult, spawn_image_preview_decode};
#[cfg(test)]
use targets::image_preview_height_for_dimensions;
pub(super) use targets::{
AvatarTarget, EmojiImageTarget, ImagePreviewTarget, image_preview_album_layout,
visible_avatar_targets, visible_emoji_image_targets, visible_image_preview_targets,
};
#[cfg(test)]
use preview::{ImagePreviewEntry, MAX_IMAGE_PREVIEW_CACHE_ENTRIES, decode_original_preview_image};
use crate::{
discord::{AppCommand, AppEvent},
logging,
};
use super::ui::{AvatarImage, EmojiReactionImage};
const AVATAR_PREVIEW_WIDTH: u16 = 2;
const AVATAR_PREVIEW_HEIGHT: u16 = 2;
const PROFILE_POPUP_AVATAR_WIDTH: u16 = 8;
const PROFILE_POPUP_AVATAR_HEIGHT: u16 = 4;
const AVATAR_SOURCE_PIXELS_PER_COLUMN: u64 = 10;
const AVATAR_SOURCE_PIXELS_PER_ROW: u64 = AVATAR_SOURCE_PIXELS_PER_COLUMN * 3;
const DISCORD_AVATAR_CDN_PREFIX: &str = "https://cdn.discordapp.com/avatars/";
const DISCORD_AVATAR_MIN_SIZE: u64 = 16;
const DISCORD_AVATAR_MAX_SIZE: u64 = 1024;
const EMOJI_REACTION_THUMB_WIDTH: u16 = 2;
const EMOJI_REACTION_THUMB_HEIGHT: u16 = 1;
const MAX_AVATAR_IMAGE_CACHE_ENTRIES: usize = 32;
fn query_image_picker(target: &str, unavailable_message: &str) -> Option<Picker> {
match Picker::from_query_stdio() {
Ok(picker) => Some(picker),
Err(error) => {
logging::error(target, format!("{unavailable_message}: {error}"));
None
}
}
}
fn avatar_preview_url(url: &str, width_columns: u16, height_rows: u16) -> String {
if !is_discord_avatar_url(url) {
return url.to_owned();
}
let size = avatar_preview_size(width_columns, height_rows);
let (base, query) = url.split_once('?').unwrap_or((url, ""));
let mut params = query
.split('&')
.filter(|param| !param.is_empty())
.filter(|param| {
let key = param.split_once('=').map_or(*param, |(key, _)| key);
key != "size"
})
.map(str::to_owned)
.collect::<Vec<_>>();
params.push(format!("size={size}"));
format!("{base}?{}", params.join("&"))
}
fn is_discord_avatar_url(url: &str) -> bool {
url.starts_with(DISCORD_AVATAR_CDN_PREFIX)
}
fn avatar_preview_size(width_columns: u16, height_rows: u16) -> u64 {
let width = u64::from(width_columns).saturating_mul(AVATAR_SOURCE_PIXELS_PER_COLUMN);
let height = u64::from(height_rows).saturating_mul(AVATAR_SOURCE_PIXELS_PER_ROW);
let needed = width.max(height).max(1);
needed
.clamp(DISCORD_AVATAR_MIN_SIZE, DISCORD_AVATAR_MAX_SIZE)
.next_power_of_two()
.min(DISCORD_AVATAR_MAX_SIZE)
}
pub(super) struct AvatarImageCache {
picker: Option<Picker>,
entries: HashMap<String, AvatarImageEntry>,
tick: u64,
}
enum AvatarImageEntry {
Loading { last_used: u64 },
Ready { image: DynamicImage, last_used: u64 },
Failed { last_used: u64 },
}
impl AvatarImageEntry {
fn last_used(&self) -> u64 {
match self {
AvatarImageEntry::Loading { last_used }
| AvatarImageEntry::Ready { last_used, .. }
| AvatarImageEntry::Failed { last_used } => *last_used,
}
}
fn touch(&mut self, tick: u64) {
match self {
AvatarImageEntry::Loading { last_used }
| AvatarImageEntry::Ready { last_used, .. }
| AvatarImageEntry::Failed { last_used } => *last_used = tick,
}
}
}
const MAX_EMOJI_IMAGE_CACHE_ENTRIES: usize = 128;
pub(super) struct EmojiImageCache {
picker: Option<Picker>,
entries: HashMap<String, EmojiImageEntry>,
tick: u64,
}
enum EmojiImageEntry {
Loading {
last_used: u64,
},
Ready {
protocol: ratatui_image::protocol::Protocol,
last_used: u64,
},
Failed {
last_used: u64,
},
}
impl EmojiImageEntry {
fn last_used(&self) -> u64 {
match self {
EmojiImageEntry::Loading { last_used }
| EmojiImageEntry::Ready { last_used, .. }
| EmojiImageEntry::Failed { last_used } => *last_used,
}
}
fn touch(&mut self, tick: u64) {
match self {
EmojiImageEntry::Loading { last_used }
| EmojiImageEntry::Ready { last_used, .. }
| EmojiImageEntry::Failed { last_used } => *last_used = tick,
}
}
}
impl AvatarImageCache {
pub(super) fn new() -> Self {
Self {
picker: query_image_picker("avatar", "avatar image picker unavailable"),
entries: HashMap::new(),
tick: 0,
}
}
pub(super) fn render_state(&mut self, targets: &[AvatarTarget]) -> Vec<AvatarImage> {
let touch_tick = self.next_tick();
for target in targets {
let url = avatar_preview_url(&target.url, AVATAR_PREVIEW_WIDTH, AVATAR_PREVIEW_HEIGHT);
if let Some(entry) = self.entries.get_mut(&url) {
entry.touch(touch_tick);
}
}
let Some(picker) = self.picker.as_ref() else {
return Vec::new();
};
targets
.iter()
.filter_map(|target| {
let url =
avatar_preview_url(&target.url, AVATAR_PREVIEW_WIDTH, AVATAR_PREVIEW_HEIGHT);
let AvatarImageEntry::Ready { image, .. } = self.entries.get(&url)? else {
return None;
};
let render_info = ImagePreviewRenderInfo {
viewer: false,
message_index: 0,
preview_x_offset_columns: 0,
preview_y_offset_rows: 0,
preview_width: AVATAR_PREVIEW_WIDTH,
preview_height: AVATAR_PREVIEW_HEIGHT,
preview_overflow_count: 0,
visible_preview_height: target.visible_height,
top_clip_rows: target.top_clip_rows,
accent_color: None,
};
clipped_preview_protocol(picker, image, render_info).map(|protocol| AvatarImage {
row: target.row,
visible_height: target.visible_height,
protocol,
})
})
.collect()
}
pub(super) fn next_requests(&mut self, targets: &[AvatarTarget]) -> Vec<AppCommand> {
let commands = targets
.iter()
.take(MAX_AVATAR_IMAGE_CACHE_ENTRIES)
.filter_map(|target| {
let url =
avatar_preview_url(&target.url, AVATAR_PREVIEW_WIDTH, AVATAR_PREVIEW_HEIGHT);
self.next_request_for_cache_url(&url)
})
.collect();
self.prune_to_limit(targets);
commands
}
pub(super) fn next_request_for_url(&mut self, url: &str) -> Option<AppCommand> {
let url = avatar_preview_url(url, PROFILE_POPUP_AVATAR_WIDTH, PROFILE_POPUP_AVATAR_HEIGHT);
self.next_request_for_cache_url(&url)
}
fn next_request_for_cache_url(&mut self, url: &str) -> Option<AppCommand> {
if self.entries.contains_key(url) {
return None;
}
let last_used = self.next_tick();
self.entries
.insert(url.to_owned(), AvatarImageEntry::Loading { last_used });
self.prune_to_limit(&[]);
Some(AppCommand::LoadAttachmentPreview {
url: url.to_owned(),
})
}
pub(super) fn popup_avatar_image(&mut self, url: &str) -> Option<AvatarImage> {
let url = avatar_preview_url(url, PROFILE_POPUP_AVATAR_WIDTH, PROFILE_POPUP_AVATAR_HEIGHT);
let touch_tick = self.next_tick();
self.entries.get_mut(&url)?.touch(touch_tick);
let picker = self.picker.as_ref()?;
let AvatarImageEntry::Ready { image, .. } = self.entries.get(&url)? else {
return None;
};
let render_info = ImagePreviewRenderInfo {
viewer: false,
message_index: 0,
preview_x_offset_columns: 0,
preview_y_offset_rows: 0,
preview_width: PROFILE_POPUP_AVATAR_WIDTH,
preview_height: PROFILE_POPUP_AVATAR_HEIGHT,
preview_overflow_count: 0,
visible_preview_height: PROFILE_POPUP_AVATAR_HEIGHT,
top_clip_rows: 0,
accent_color: None,
};
clipped_preview_protocol(picker, image, render_info).map(|protocol| AvatarImage {
row: 0,
visible_height: PROFILE_POPUP_AVATAR_HEIGHT,
protocol,
})
}
pub(super) fn record_event(&mut self, event: &AppEvent) {
match event {
AppEvent::AttachmentPreviewLoaded { url, bytes } => self.store_loaded(url, bytes),
AppEvent::AttachmentPreviewLoadFailed { url, .. } => self.store_failed(url),
_ => {}
}
}
fn store_loaded(&mut self, url: &str, bytes: &[u8]) {
if !self.entries.contains_key(url) {
return;
}
let last_used = self.next_tick();
if self.picker.is_none() {
self.entries
.insert(url.to_owned(), AvatarImageEntry::Failed { last_used });
return;
}
match image::load_from_memory(bytes) {
Ok(image) => {
self.entries
.insert(url.to_owned(), AvatarImageEntry::Ready { image, last_used });
}
Err(_) => {
self.entries
.insert(url.to_owned(), AvatarImageEntry::Failed { last_used });
}
}
}
fn store_failed(&mut self, url: &str) {
if self.entries.contains_key(url) {
let last_used = self.next_tick();
self.entries
.insert(url.to_owned(), AvatarImageEntry::Failed { last_used });
}
}
fn next_tick(&mut self) -> u64 {
self.tick = self.tick.saturating_add(1);
self.tick
}
fn prune_to_limit(&mut self, targets: &[AvatarTarget]) {
if self.entries.len() <= MAX_AVATAR_IMAGE_CACHE_ENTRIES {
return;
}
let protected = targets
.iter()
.take(MAX_AVATAR_IMAGE_CACHE_ENTRIES)
.map(|target| {
avatar_preview_url(&target.url, AVATAR_PREVIEW_WIDTH, AVATAR_PREVIEW_HEIGHT)
})
.collect::<HashSet<_>>();
let mut removable = self
.entries
.iter()
.filter(|(url, _)| !protected.contains(url.as_str()))
.map(|(url, entry)| (url.clone(), entry.last_used()))
.collect::<Vec<_>>();
removable.sort_by_key(|(_, last_used)| *last_used);
for (url, _) in removable {
if self.entries.len() <= MAX_AVATAR_IMAGE_CACHE_ENTRIES {
break;
}
self.entries.remove(&url);
}
}
}
impl EmojiImageCache {
pub(super) fn new() -> Self {
Self {
picker: query_image_picker("emoji", "emoji image picker unavailable"),
entries: HashMap::new(),
tick: 0,
}
}
pub(super) fn render_state(
&mut self,
targets: &[EmojiImageTarget],
) -> Vec<EmojiReactionImage<'_>> {
let touch_tick = self.next_tick();
for target in targets {
if let Some(entry) = self.entries.get_mut(&target.url) {
entry.touch(touch_tick);
}
}
targets
.iter()
.filter_map(|target| {
let EmojiImageEntry::Ready { protocol, .. } = self.entries.get(&target.url)? else {
return None;
};
Some(EmojiReactionImage {
url: target.url.clone(),
protocol,
})
})
.collect()
}
pub(super) fn next_requests(&mut self, targets: &[EmojiImageTarget]) -> Vec<AppCommand> {
if self.picker.is_none() {
return Vec::new();
}
let mut commands = Vec::new();
let mut requested_urls = HashSet::new();
for target in targets.iter().take(MAX_EMOJI_IMAGE_CACHE_ENTRIES) {
if self.entries.contains_key(&target.url) {
continue;
}
let last_used = self.next_tick();
self.entries
.insert(target.url.clone(), EmojiImageEntry::Loading { last_used });
if requested_urls.insert(target.url.clone()) {
commands.push(AppCommand::LoadAttachmentPreview {
url: target.url.clone(),
});
}
}
self.prune_to_limit(targets);
commands
}
pub(super) fn record_event(&mut self, event: &AppEvent) {
match event {
AppEvent::AttachmentPreviewLoaded { url, bytes } => self.store_loaded(url, bytes),
AppEvent::AttachmentPreviewLoadFailed { url, .. } => self.store_failed(url),
_ => {}
}
}
fn next_tick(&mut self) -> u64 {
self.tick = self.tick.saturating_add(1);
self.tick
}
fn prune_to_limit(&mut self, targets: &[EmojiImageTarget]) {
if self.entries.len() <= MAX_EMOJI_IMAGE_CACHE_ENTRIES {
return;
}
let protected: HashSet<&str> = targets
.iter()
.take(MAX_EMOJI_IMAGE_CACHE_ENTRIES)
.map(|target| target.url.as_str())
.collect();
let mut removable: Vec<(String, u64)> = self
.entries
.iter()
.filter(|(url, _)| !protected.contains(url.as_str()))
.map(|(url, entry)| (url.clone(), entry.last_used()))
.collect();
removable.sort_by_key(|(_, last_used)| *last_used);
for (url, _) in removable {
if self.entries.len() <= MAX_EMOJI_IMAGE_CACHE_ENTRIES {
break;
}
self.entries.remove(&url);
}
}
fn store_loaded(&mut self, url: &str, bytes: &[u8]) {
if !self.entries.contains_key(url) {
return;
}
let last_used = self.next_tick();
let Some(picker) = self.picker.as_ref() else {
self.entries
.insert(url.to_owned(), EmojiImageEntry::Failed { last_used });
return;
};
match image::load_from_memory(bytes) {
Ok(image) => {
let render_info = ImagePreviewRenderInfo {
viewer: false,
message_index: 0,
preview_x_offset_columns: 0,
preview_y_offset_rows: 0,
preview_width: EMOJI_REACTION_THUMB_WIDTH,
preview_height: EMOJI_REACTION_THUMB_HEIGHT,
preview_overflow_count: 0,
visible_preview_height: EMOJI_REACTION_THUMB_HEIGHT,
top_clip_rows: 0,
accent_color: None,
};
if let Some(protocol) = clipped_preview_protocol(picker, &image, render_info) {
self.entries.insert(
url.to_owned(),
EmojiImageEntry::Ready {
protocol,
last_used,
},
);
} else {
self.entries
.insert(url.to_owned(), EmojiImageEntry::Failed { last_used });
}
}
Err(_) => {
self.entries
.insert(url.to_owned(), EmojiImageEntry::Failed { last_used });
}
}
}
fn store_failed(&mut self, url: &str) {
if self.entries.contains_key(url) {
let last_used = self.next_tick();
self.entries
.insert(url.to_owned(), EmojiImageEntry::Failed { last_used });
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct ImagePreviewRenderInfo {
viewer: bool,
message_index: usize,
preview_x_offset_columns: u16,
preview_y_offset_rows: usize,
preview_width: u16,
preview_height: u16,
preview_overflow_count: usize,
visible_preview_height: u16,
top_clip_rows: u16,
accent_color: Option<u32>,
}
fn clipped_preview_image(
image: &DynamicImage,
font_size: (u16, u16),
render_info: ImagePreviewRenderInfo,
) -> Option<DynamicImage> {
if render_info.preview_width == 0
|| render_info.preview_height == 0
|| render_info.visible_preview_height == 0
{
return None;
}
let (font_width, font_height) = font_size;
let full_width = u32::from(render_info.preview_width).checked_mul(u32::from(font_width))?;
let full_height = u32::from(render_info.preview_height).checked_mul(u32::from(font_height))?;
let crop_top = u32::from(render_info.top_clip_rows).checked_mul(u32::from(font_height))?;
let crop_height = u32::from(render_info.visible_preview_height)
.checked_mul(u32::from(font_height))?
.min(full_height.saturating_sub(crop_top));
if full_width == 0 || crop_height == 0 {
return None;
}
let fitted = fit_image_to_canvas(image, full_width, full_height);
Some(fitted.crop_imm(0, crop_top, full_width, crop_height))
}
fn clipped_preview_protocol(
picker: &Picker,
image: &DynamicImage,
render_info: ImagePreviewRenderInfo,
) -> Option<ratatui_image::protocol::Protocol> {
let image = clipped_preview_image(image, picker.font_size(), render_info)?;
picker
.new_protocol(
image,
Rect::new(
0,
0,
render_info.preview_width,
render_info.visible_preview_height,
),
Resize::Fit(None),
)
.ok()
}
fn fit_image_to_canvas(image: &DynamicImage, width: u32, height: u32) -> DynamicImage {
let resized = image.resize(width, height, FilterType::Nearest);
if resized.width() == width && resized.height() == height {
return resized;
}
let mut canvas =
DynamicImage::ImageRgba8(ImageBuffer::from_pixel(width, height, Rgba([0, 0, 0, 0])));
image::imageops::overlay(&mut canvas, &resized, 0, 0);
canvas
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use crate::discord::ids::{Id, marker::MessageMarker};
use image::{DynamicImage, ImageBuffer, Rgba};
use crate::{
config::{DisplayOptions, ImagePreviewQualityPreset},
discord::{
AppCommand, AppEvent, AttachmentInfo, ChannelInfo, CustomEmojiInfo, EmbedInfo,
MessageInfo, MessageSnapshotInfo, ReactionEmoji, ReactionInfo,
},
tui::{
state::{DashboardState, FocusPane},
ui::ImagePreviewLayout,
},
};
use super::*;
fn layout(list_height: usize) -> ImagePreviewLayout {
ImagePreviewLayout {
list_height,
content_width: 200,
preview_width: 16,
max_preview_height: 3,
viewer_preview_width: 76,
viewer_max_preview_height: 13,
}
}
#[test]
fn image_preview_targets_stop_at_rendered_row_budget() {
let mut state = state_with_image_messages(6, &[1, 3, 6]);
state.set_message_view_height(6);
let targets = visible_image_preview_targets(&state, layout(6));
assert_eq!(target_message_ids(&targets), vec![Id::new(1)]);
}
#[test]
fn disabled_image_previews_create_no_targets_or_requests() {
let mut state = state_with_image_messages(1, &[1]);
state.open_options_popup();
state.toggle_selected_display_option();
state.set_message_view_height(6);
let targets = visible_image_preview_targets(&state, layout(6));
let mut cache = ImagePreviewCache::new();
assert!(targets.is_empty());
assert!(cache.next_requests(&targets).is_empty());
}
#[test]
fn image_preview_targets_include_preview_that_would_be_clipped() {
let mut state = state_with_image_messages(2, &[1, 2]);
state.set_message_view_height(6);
let targets = visible_image_preview_targets(&state, layout(6));
assert_eq!(target_message_ids(&targets), vec![Id::new(1)]);
}
#[test]
fn image_preview_targets_include_multiple_attachments_from_one_message() {
let mut state = state_with_image_messages(0, &[]);
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(1),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("album".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: vec![image_attachment(1), image_attachment(2)],
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
let targets = visible_image_preview_targets(&state, layout(12));
assert_eq!(target_message_ids(&targets), vec![Id::new(1), Id::new(1)]);
assert_eq!(
targets
.iter()
.map(|target| target.url.as_str())
.collect::<Vec<_>>(),
vec![
"https://cdn.discordapp.com/image-1.png",
"https://cdn.discordapp.com/image-2.png",
]
);
assert_eq!(
targets
.iter()
.map(|target| (
target.preview_x_offset_columns,
target.preview_y_offset_rows,
target.preview_width,
target.preview_height,
))
.collect::<Vec<_>>(),
vec![(0, 0, 8, 3), (8, 0, 8, 3)]
);
}
#[test]
fn image_preview_targets_use_resized_discord_media_proxy_url() {
let mut state = state_with_image_messages(0, &[]);
let mut attachment = image_attachment(1);
attachment.proxy_url = concat!(
"https://media.discordapp.net/attachments/691/150/photo.png",
"?ex=abc&is=def&hm=123&format=png&width=4000&height=3000"
)
.to_owned();
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(1),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("photo".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: vec![attachment],
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
let target = visible_image_preview_targets(&state, layout(12))
.into_iter()
.next()
.expect("image attachment should produce preview target");
assert_eq!(
target.url,
concat!(
"https://media.discordapp.net/attachments/691/150/photo.png",
"?ex=abc&is=def&hm=123&format=webp&width=320&height=240"
)
);
}
#[test]
fn image_preview_quality_rewrites_attachment_preview_urls() {
let cases = [
(
ImagePreviewQualityPreset::Efficient,
None,
None,
concat!(
"https://media.discordapp.net/attachments/691/150/photo.png",
"?ex=abc&is=def&hm=123&format=png&quality=lossless&width=4000&height=3000"
),
concat!(
"https://media.discordapp.net/attachments/691/150/photo.png",
"?ex=abc&is=def&hm=123&format=webp&quality=low&width=192&height=144"
),
),
(
ImagePreviewQualityPreset::Efficient,
Some(1000),
Some(2000),
concat!(
"https://media.discordapp.net/attachments/691/150/photo.png",
"?ex=abc&is=def&hm=123&format=png&width=1000&height=2000"
),
concat!(
"https://media.discordapp.net/attachments/691/150/photo.png",
"?ex=abc&is=def&hm=123&format=webp&quality=low&width=300&height=600"
),
),
(
ImagePreviewQualityPreset::High,
None,
None,
concat!(
"https://media.discordapp.net/attachments/691/150/photo.png",
"?ex=abc&is=def&hm=123&format=png&width=4000&height=3000"
),
concat!(
"https://media.discordapp.net/attachments/691/150/photo.png",
"?ex=abc&is=def&hm=123&format=webp&quality=lossless&width=640&height=480"
),
),
(
ImagePreviewQualityPreset::Original,
None,
None,
concat!(
"https://media.discordapp.net/attachments/691/150/photo.png",
"?ex=abc&is=def&hm=123&format=png&width=4000&height=3000"
),
"https://cdn.discordapp.com/image-1.png",
),
];
for (quality, width, height, proxy_url, expected_url) in cases {
let mut state = state_with_image_messages_and_display_options(
0,
&[],
DisplayOptions {
image_preview_quality: quality,
..DisplayOptions::default()
},
);
let mut attachment = image_attachment(1);
if width.is_some() || height.is_some() {
attachment.width = width;
attachment.height = height;
}
attachment.proxy_url = proxy_url.to_owned();
push_attachment_message(&mut state, attachment);
let target = visible_image_preview_targets(&state, layout(12))
.into_iter()
.next()
.expect("image attachment should produce preview target");
assert_eq!(target.url, expected_url);
}
}
#[test]
fn original_image_preview_quality_applies_to_image_viewer_preview() {
let mut state = state_with_image_messages_and_display_options(
0,
&[],
DisplayOptions {
image_preview_quality: ImagePreviewQualityPreset::Original,
..DisplayOptions::default()
},
);
let mut attachment = image_attachment(1);
attachment.proxy_url = concat!(
"https://media.discordapp.net/attachments/691/150/photo.png",
"?ex=abc&is=def&hm=123&format=png&width=4000&height=3000"
)
.to_owned();
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(1),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("photo".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: vec![attachment],
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
state.focus_pane(FocusPane::Messages);
assert!(state.open_image_viewer_for_selected_message());
let target = visible_image_preview_targets(&state, layout(12))
.into_iter()
.next()
.expect("image viewer should produce preview target");
assert!(target.viewer);
assert_eq!(target.url, "https://cdn.discordapp.com/image-1.png");
}
#[test]
fn image_preview_quality_does_not_change_avatar_or_custom_emoji_requests() {
let mut state = state_with_image_messages_and_display_options(
0,
&[],
DisplayOptions {
image_preview_quality: ImagePreviewQualityPreset::Original,
..DisplayOptions::default()
},
);
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(1),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: Some("https://cdn.discordapp.com/avatars/1/hash.png".to_owned()),
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("hello <:party:50>".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
assert_eq!(
state.image_preview_quality(),
ImagePreviewQualityPreset::Original
);
assert_eq!(
visible_avatar_targets(&state, layout(2))[0].url,
"https://cdn.discordapp.com/avatars/1/hash.png"
);
assert_eq!(
avatar_preview_url("https://cdn.discordapp.com/avatars/1/hash.png", 2, 2),
"https://cdn.discordapp.com/avatars/1/hash.png?size=64"
);
assert_eq!(
visible_emoji_image_targets(&state),
vec![EmojiImageTarget {
url: "https://cdn.discordapp.com/emojis/50.png".to_owned(),
}]
);
}
#[test]
fn image_preview_targets_use_resized_embed_media_proxy_url() {
let mut embed = youtube_embed();
embed.thumbnail_url = Some("https://example.com/photo.png".to_owned());
embed.thumbnail_proxy_url = Some(
concat!(
"https://media.discordapp.net/external/cache-key/https/example.com/photo.png",
"?ex=abc&is=def&hm=123&format=png&width=4000&height=3000"
)
.to_owned(),
);
let mut state = state_with_image_messages(1, &[]);
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(2),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("https://example.com/post".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: vec![embed],
forwarded_snapshots: Vec::new(),
});
let targets = visible_image_preview_targets(&state, layout(8));
assert_eq!(target_message_ids(&targets), vec![Id::new(2)]);
assert_eq!(
targets[0].url,
concat!(
"https://media.discordapp.net/external/cache-key/https/example.com/photo.png",
"?ex=abc&is=def&hm=123&format=webp&width=240&height=180"
)
);
assert_eq!(targets[0].filename, "embed-thumbnail");
}
#[test]
fn image_preview_targets_use_resized_ephemeral_media_proxy_url() {
let mut state = state_with_image_messages(0, &[]);
let mut attachment = image_attachment(1);
attachment.proxy_url = concat!(
"https://media.discordapp.net/ephemeral-attachments/691/150/photo.png",
"?ex=abc&is=def&hm=123&width=4000&height=3000"
)
.to_owned();
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(1),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("photo".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: vec![attachment],
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
let target = visible_image_preview_targets(&state, layout(12))
.into_iter()
.next()
.expect("image attachment should produce preview target");
assert_eq!(
target.url,
concat!(
"https://media.discordapp.net/ephemeral-attachments/691/150/photo.png",
"?ex=abc&is=def&hm=123&format=webp&width=320&height=240"
)
);
}
#[test]
fn image_preview_targets_ignore_unsupported_embed_proxy_url() {
let mut embed = youtube_embed();
embed.thumbnail_url = Some("https://example.com/photo.png".to_owned());
embed.thumbnail_proxy_url =
Some("https://media.discordapp.net/avatars/1/hash.png".to_owned());
let mut state = state_with_image_messages(1, &[]);
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(2),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("https://example.com/post".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: vec![embed],
forwarded_snapshots: Vec::new(),
});
let targets = visible_image_preview_targets(&state, layout(8));
assert_eq!(target_message_ids(&targets), vec![Id::new(2)]);
assert_eq!(targets[0].url, "https://example.com/photo.png");
assert_eq!(targets[0].filename, "embed-thumbnail");
}
#[test]
fn image_preview_targets_use_resized_images_ext_embed_proxy_url() {
let mut embed = youtube_embed();
embed.thumbnail_url = Some("https://example.com/photo.png".to_owned());
embed.thumbnail_proxy_url = Some(concat!(
"https://images-ext-1.discordapp.net/external/cache-key/https/example.com/photo.png",
"?width=4000&height=3000"
).to_owned());
let mut state = state_with_image_messages(1, &[]);
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(2),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("https://example.com/post".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: vec![embed],
forwarded_snapshots: Vec::new(),
});
let targets = visible_image_preview_targets(&state, layout(8));
assert_eq!(target_message_ids(&targets), vec![Id::new(2)]);
assert_eq!(
targets[0].url,
concat!(
"https://images-ext-1.discordapp.net/external/cache-key/https/example.com/photo.png",
"?format=webp&width=240&height=180"
)
);
}
#[test]
fn image_preview_targets_layout_album_grids() {
let cases = [
(
3,
vec![(0, 0, 0, 8, 3), (1, 8, 0, 8, 2), (2, 8, 2, 8, 1)],
vec![0, 0, 0],
),
(
4,
vec![
(0, 0, 0, 8, 2),
(1, 8, 0, 8, 2),
(2, 0, 2, 8, 1),
(3, 8, 2, 8, 1),
],
vec![0, 0, 0, 0],
),
(
5,
vec![
(0, 0, 0, 8, 2),
(1, 8, 0, 8, 2),
(2, 0, 2, 8, 1),
(3, 8, 2, 8, 1),
],
vec![0, 0, 0, 1],
),
];
for (attachment_count, expected_geometry, expected_overflow) in cases {
let mut state = state_with_image_messages(0, &[]);
push_album_message(&mut state, 1, attachment_count);
let targets = visible_image_preview_targets(&state, layout(12));
assert_eq!(
targets
.iter()
.map(|target| (
target.preview_index,
target.preview_x_offset_columns,
target.preview_y_offset_rows,
target.preview_width,
target.preview_height,
))
.collect::<Vec<_>>(),
expected_geometry
);
assert_eq!(
targets
.iter()
.map(|target| target.preview_overflow_count)
.collect::<Vec<_>>(),
expected_overflow
);
}
}
#[test]
fn image_viewer_target_uses_viewer_layout_dimensions() {
let mut state = state_with_image_messages(1, &[1]);
state.focus_pane(FocusPane::Messages);
state.open_selected_message_actions();
state.move_message_action_down();
state.activate_selected_message_action();
let target = visible_image_preview_targets(&state, layout(12))
.into_iter()
.next()
.expect("viewer should create one image target");
assert!(target.viewer);
assert_eq!(target.preview_width, 76);
assert_eq!(target.preview_height, 13);
assert_eq!(target.visible_preview_height, 13);
}
#[test]
fn image_preview_targets_account_for_first_message_line_offset() {
let mut state = state_with_image_messages(1, &[1]);
state.focus_pane(FocusPane::Messages);
state.clamp_message_viewport_for_image_previews(200, 16, 3);
state.scroll_message_viewport_down();
state.clamp_message_viewport_for_image_previews(200, 16, 3);
state.scroll_message_viewport_down();
state.clamp_message_viewport_for_image_previews(200, 16, 3);
let targets = visible_image_preview_targets(&state, layout(2));
assert_eq!(
target_message_ids(&targets),
Vec::<Id<MessageMarker>>::new()
);
}
#[test]
fn avatar_targets_include_visible_author_avatar() {
let state = state_with_avatar_messages(1);
let targets = visible_avatar_targets(&state, layout(2));
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].row, 1);
assert_eq!(targets[0].visible_height, 1);
assert_eq!(targets[0].top_clip_rows, 0);
assert_eq!(targets[0].url, "https://cdn.discordapp.com/avatar-1.png");
}
#[test]
fn disabled_avatar_previews_create_no_targets_or_requests() {
let mut state = state_with_avatar_messages(1);
state.open_options_popup();
state.move_option_down();
state.toggle_selected_display_option();
let targets = visible_avatar_targets(&state, layout(2));
let mut cache = AvatarImageCache::new();
assert!(targets.is_empty());
assert!(cache.next_requests(&targets).is_empty());
}
#[test]
fn avatar_preview_url_adds_power_of_two_size_for_user_avatar() {
assert_eq!(
avatar_preview_url("https://cdn.discordapp.com/avatars/1/hash.png", 2, 2),
"https://cdn.discordapp.com/avatars/1/hash.png?size=64"
);
assert_eq!(
avatar_preview_url(
"https://cdn.discordapp.com/avatars/1/hash.png?size=1024&foo=bar",
8,
4
),
"https://cdn.discordapp.com/avatars/1/hash.png?foo=bar&size=128"
);
}
#[test]
fn avatar_preview_url_leaves_default_avatar_unchanged() {
assert_eq!(
avatar_preview_url("https://cdn.discordapp.com/embed/avatars/0.png", 8, 4),
"https://cdn.discordapp.com/embed/avatars/0.png"
);
}
#[test]
fn avatar_targets_clip_first_message_avatar_after_line_scroll() {
let mut state = state_with_avatar_messages(1);
state.focus_pane(FocusPane::Messages);
state.clamp_message_viewport_for_image_previews(200, 16, 3);
state.scroll_message_viewport_down();
state.clamp_message_viewport_for_image_previews(200, 16, 3);
let targets = visible_avatar_targets(&state, layout(1));
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].row, 0);
assert_eq!(targets[0].visible_height, 1);
assert_eq!(targets[0].top_clip_rows, 0);
}
#[test]
fn avatar_image_cache_evicts_least_recently_used_when_over_capacity() {
let mut cache = AvatarImageCache {
picker: None,
entries: HashMap::new(),
tick: 0,
};
for id in 0..MAX_AVATAR_IMAGE_CACHE_ENTRIES {
let url = avatar_preview_url(
&format!("https://cdn.discordapp.com/avatars/{id}.png"),
AVATAR_PREVIEW_WIDTH,
AVATAR_PREVIEW_HEIGHT,
);
cache.entries.insert(
url,
AvatarImageEntry::Failed {
last_used: id as u64,
},
);
}
cache.tick = MAX_AVATAR_IMAGE_CACHE_ENTRIES as u64;
cache.entries.insert(
"https://cdn.discordapp.com/avatars/oldest.png".to_owned(),
AvatarImageEntry::Failed { last_used: 0 },
);
let visible_url = "https://cdn.discordapp.com/avatars/0.png".to_owned();
let visible_cache_url =
avatar_preview_url(&visible_url, AVATAR_PREVIEW_WIDTH, AVATAR_PREVIEW_HEIGHT);
let targets = vec![AvatarTarget {
row: 0,
visible_height: 1,
top_clip_rows: 0,
url: visible_url.clone(),
}];
cache.prune_to_limit(&targets);
assert_eq!(cache.entries.len(), MAX_AVATAR_IMAGE_CACHE_ENTRIES);
assert!(cache.entries.contains_key(&visible_cache_url));
assert!(
!cache
.entries
.contains_key("https://cdn.discordapp.com/avatars/oldest.png")
);
}
#[test]
fn avatar_popup_request_prunes_cache_to_limit() {
let mut cache = AvatarImageCache {
picker: None,
entries: HashMap::new(),
tick: 0,
};
for id in 0..MAX_AVATAR_IMAGE_CACHE_ENTRIES {
cache.entries.insert(
format!("https://cdn.discordapp.com/avatars/{id}.png"),
AvatarImageEntry::Failed {
last_used: id as u64,
},
);
}
let request = cache.next_request_for_url("https://cdn.discordapp.com/avatars/new.png");
assert_eq!(
request,
Some(AppCommand::LoadAttachmentPreview {
url: "https://cdn.discordapp.com/avatars/new.png?size=128".to_owned(),
})
);
assert_eq!(cache.entries.len(), MAX_AVATAR_IMAGE_CACHE_ENTRIES);
assert!(
cache
.entries
.contains_key("https://cdn.discordapp.com/avatars/new.png?size=128")
);
}
#[test]
fn image_preview_targets_include_top_clipped_preview_rows() {
let mut state = state_with_image_messages(1, &[1]);
state.focus_pane(FocusPane::Messages);
state.clamp_message_viewport_for_image_previews(200, 16, 3);
for _ in 0..4 {
state.scroll_message_viewport_down();
state.clamp_message_viewport_for_image_previews(200, 16, 3);
}
let targets = visible_image_preview_targets(&state, layout(2));
assert_eq!(target_message_ids(&targets), vec![Id::new(1)]);
assert_eq!(targets[0].visible_preview_height, 2);
assert_eq!(targets[0].top_clip_rows, 0);
}
#[test]
fn image_preview_targets_clip_album_bottom_row_after_line_scroll() {
let mut state = state_with_image_messages(0, &[]);
push_album_message(&mut state, 1, 4);
state.focus_pane(FocusPane::Messages);
state.clamp_message_viewport_for_image_previews(200, 16, 3);
for _ in 0..16 {
state.scroll_message_viewport_down();
let targets = visible_image_preview_targets(&state, layout(2));
if targets
.first()
.is_some_and(|target| target.preview_index == 2)
{
break;
}
}
let targets = visible_image_preview_targets(&state, layout(2));
assert_eq!(
targets
.iter()
.map(|target| (
target.preview_index,
target.preview_y_offset_rows,
target.visible_preview_height,
target.top_clip_rows,
))
.collect::<Vec<_>>(),
vec![(2, 2, 1, 0), (3, 2, 1, 0)]
);
}
#[test]
fn image_preview_targets_skip_preview_when_no_preview_row_is_visible() {
let mut state = state_with_image_messages(2, &[1, 2]);
state.set_message_view_height(5);
let targets = visible_image_preview_targets(&state, layout(5));
assert_eq!(target_message_ids(&targets), vec![Id::new(1)]);
}
#[test]
fn image_preview_targets_account_for_date_separator_rows() {
let mut state = state_with_cross_day_image_message();
state.set_message_view_height(4);
let targets = visible_image_preview_targets(&state, layout(4));
assert!(targets.is_empty());
}
#[test]
fn video_attachment_does_not_request_original_as_image_preview() {
let mut state = state_with_image_messages(1, &[]);
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(2),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("clip".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: vec![video_attachment(2)],
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
let targets = visible_image_preview_targets(&state, layout(6));
assert!(targets.is_empty());
}
#[test]
fn image_preview_targets_include_embed_thumbnail() {
let mut state = state_with_image_messages(1, &[]);
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(2),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: vec![youtube_embed()],
forwarded_snapshots: Vec::new(),
});
let targets = visible_image_preview_targets(&state, layout(8));
assert_eq!(target_message_ids(&targets), vec![Id::new(2)]);
assert_eq!(
targets[0].url,
"https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg"
);
assert_eq!(targets[0].filename, "embed-thumbnail");
}
#[test]
fn image_preview_targets_downscale_youtube_embed_image_url() {
let mut embed = youtube_embed();
embed.thumbnail_url = None;
embed.thumbnail_width = None;
embed.thumbnail_height = None;
embed.image_url =
Some("https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg?token=abc".to_owned());
embed.image_width = Some(1280);
embed.image_height = Some(720);
let mut state = state_with_image_messages(1, &[]);
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(2),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: vec![embed],
forwarded_snapshots: Vec::new(),
});
let targets = visible_image_preview_targets(&state, layout(8));
assert_eq!(target_message_ids(&targets), vec![Id::new(2)]);
assert_eq!(
targets[0].url,
"https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg?token=abc"
);
assert_eq!(targets[0].filename, "embed-image");
}
#[test]
fn image_viewer_target_caps_large_youtube_embed_image_url() {
let mut embed = youtube_embed();
embed.thumbnail_url = None;
embed.thumbnail_width = None;
embed.thumbnail_height = None;
embed.image_url = Some("https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg".to_owned());
embed.image_width = Some(1280);
embed.image_height = Some(720);
let mut state = state_with_image_messages(1, &[]);
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(2),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: vec![embed],
forwarded_snapshots: Vec::new(),
});
state.focus_pane(FocusPane::Messages);
state.open_selected_message_actions();
state.move_message_action_down();
state.activate_selected_message_action();
let target = visible_image_preview_targets(&state, layout(12))
.into_iter()
.next()
.expect("viewer should create one image target");
assert!(target.viewer);
assert_eq!(
target.url,
"https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg"
);
assert_eq!(target.filename, "embed-image");
}
#[test]
fn image_preview_targets_keep_small_youtube_thumbnail_url() {
let mut embed = youtube_embed();
embed.thumbnail_url = Some("https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg".to_owned());
embed.thumbnail_width = Some(120);
embed.thumbnail_height = Some(90);
let mut state = state_with_image_messages(1, &[]);
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(2),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: vec![embed],
forwarded_snapshots: Vec::new(),
});
let targets = visible_image_preview_targets(&state, layout(8));
assert_eq!(target_message_ids(&targets), vec![Id::new(2)]);
assert_eq!(
targets[0].url,
"https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg"
);
assert_eq!(targets[0].filename, "embed-thumbnail");
}
#[test]
fn image_preview_targets_include_forwarded_image_attachments() {
let mut state = state_with_image_messages(1, &[]);
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(2),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some(String::new()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: Vec::new(),
forwarded_snapshots: vec![forwarded_snapshot(2)],
});
let targets = visible_image_preview_targets(&state, layout(6));
assert_eq!(target_message_ids(&targets), vec![Id::new(2)]);
assert_eq!(targets[0].url, "https://cdn.discordapp.com/image-2.png");
}
#[test]
fn image_preview_targets_follow_the_scrolled_message_window() {
let mut state = state_with_image_messages(8, &[1, 6]);
state.set_message_view_height(6);
let targets = visible_image_preview_targets(&state, layout(7));
assert!(target_message_ids(&targets).is_empty());
}
#[test]
fn image_preview_targets_include_image_messages_in_scrolloff_context() {
let mut state = state_with_image_messages(8, &[5, 6, 7]);
state.focus_pane(FocusPane::Messages);
state.set_message_view_height(14);
while state.selected_message() > 3 {
state.move_up();
}
state.clamp_message_viewport_for_image_previews(200, 16, 3);
let targets = visible_image_preview_targets(&state, layout(14));
assert_eq!(target_message_ids(&targets), vec![Id::new(5)]);
}
#[test]
fn image_preview_request_is_created_for_draw_target() {
let mut cache = ImagePreviewCache {
picker: None,
entries: HashMap::new(),
tick: 0,
decode_generation: 0,
};
let target = image_preview_target(1);
assert!(cache.entries.is_empty());
assert_eq!(cache.render_state(std::slice::from_ref(&target)).len(), 1);
assert!(cache.entries.is_empty());
let requests = cache.next_requests(std::slice::from_ref(&target));
assert_eq!(
requests,
vec![AppCommand::LoadAttachmentPreview {
url: target.url.clone()
}]
);
assert_eq!(cache.entries.len(), 1);
}
#[test]
fn image_preview_render_state_preserves_target_order() {
let mut cache = ImagePreviewCache {
picker: None,
entries: HashMap::new(),
tick: 0,
decode_generation: 0,
};
let first = image_preview_target(1);
let second = ImagePreviewTarget {
message_id: Id::new(1),
preview_index: 1,
preview_x_offset_columns: 8,
..image_preview_target(2)
};
cache.entries.insert(
second.key(),
ImagePreviewEntry::Loading {
filename: second.filename.clone(),
render_info: second.preview_render_info(),
last_used: 1,
},
);
cache.entries.insert(
first.key(),
ImagePreviewEntry::Loading {
filename: first.filename.clone(),
render_info: first.preview_render_info(),
last_used: 2,
},
);
let previews = cache.render_state(&[first, second]);
assert_eq!(
previews
.into_iter()
.map(|preview| match preview.state {
super::super::ui::ImagePreviewState::Loading { filename } => filename,
_ => "unexpected state".to_owned(),
})
.collect::<Vec<_>>(),
vec!["image-1.png", "image-2.png"]
);
}
#[test]
fn image_preview_cache_keeps_duplicate_urls_as_separate_preview_instances() {
let mut cache = ImagePreviewCache {
picker: None,
entries: HashMap::new(),
tick: 0,
decode_generation: 0,
};
let first = image_preview_target(1);
let second = ImagePreviewTarget {
preview_index: 1,
preview_x_offset_columns: 8,
..image_preview_target(1)
};
let requests = cache.next_requests(&[first, second]);
assert_eq!(requests.len(), 1);
assert_eq!(cache.entries.len(), 2);
let previews = cache.render_state(&[
image_preview_target(1),
ImagePreviewTarget {
preview_index: 1,
preview_x_offset_columns: 8,
..image_preview_target(1)
},
]);
assert_eq!(previews.len(), 2);
assert_eq!(previews[0].preview_x_offset_columns, 0);
assert_eq!(previews[1].preview_x_offset_columns, 8);
}
#[test]
fn image_preview_cache_deduplicates_url_already_loading_from_previous_frame() {
let mut cache = ImagePreviewCache {
picker: None,
entries: HashMap::new(),
tick: 0,
decode_generation: 0,
};
let first = image_preview_target(1);
cache.next_requests(std::slice::from_ref(&first));
let second = ImagePreviewTarget {
preview_index: 1,
preview_x_offset_columns: 8,
..image_preview_target(1)
};
let requests = cache.next_requests(std::slice::from_ref(&second));
assert!(requests.is_empty());
assert_eq!(cache.entries.len(), 2);
}
#[test]
fn image_preview_cache_keeps_viewer_and_inline_entries_separate() {
let mut cache = ImagePreviewCache {
picker: None,
entries: HashMap::new(),
tick: 0,
decode_generation: 0,
};
let inline = image_preview_target(1);
let viewer = ImagePreviewTarget {
viewer: true,
preview_width: 76,
preview_height: 13,
visible_preview_height: 13,
..image_preview_target(1)
};
let inline_requests = cache.next_requests(std::slice::from_ref(&inline));
let viewer_requests = cache.next_requests(std::slice::from_ref(&viewer));
assert_eq!(inline_requests.len(), 1);
assert!(viewer_requests.is_empty());
assert_eq!(cache.entries.len(), 2);
assert!(cache.entries.contains_key(&inline.key()));
assert!(cache.entries.contains_key(&viewer.key()));
}
#[test]
fn image_preview_cache_evicts_least_recently_used_entries() {
let mut cache = ImagePreviewCache {
picker: None,
entries: HashMap::new(),
tick: 0,
decode_generation: 0,
};
let existing_targets = (1..=MAX_IMAGE_PREVIEW_CACHE_ENTRIES as u64)
.map(image_preview_target)
.collect::<Vec<_>>();
cache.next_requests(&existing_targets);
cache.render_state(std::slice::from_ref(&existing_targets[0]));
let new_target = image_preview_target(999);
cache.next_requests(std::slice::from_ref(&new_target));
assert_eq!(cache.entries.len(), MAX_IMAGE_PREVIEW_CACHE_ENTRIES);
assert!(cache.entries.contains_key(&existing_targets[0].key()));
assert!(!cache.entries.contains_key(&existing_targets[1].key()));
assert!(cache.entries.contains_key(&new_target.key()));
}
#[test]
fn image_preview_cache_limits_visible_requests() {
let mut cache = ImagePreviewCache {
picker: None,
entries: HashMap::new(),
tick: 0,
decode_generation: 0,
};
let targets = (1..=MAX_IMAGE_PREVIEW_CACHE_ENTRIES as u64 + 2)
.map(image_preview_target)
.collect::<Vec<_>>();
let requests = cache.next_requests(&targets);
assert_eq!(cache.entries.len(), MAX_IMAGE_PREVIEW_CACHE_ENTRIES);
assert_eq!(requests.len(), MAX_IMAGE_PREVIEW_CACHE_ENTRIES);
assert!(cache.entries.contains_key(&targets[0].key()));
assert!(
!cache
.entries
.contains_key(&targets[MAX_IMAGE_PREVIEW_CACHE_ENTRIES].key())
);
}
#[test]
fn image_preview_store_loaded_preserves_existing_non_loading_entries() {
let mut cache = ImagePreviewCache {
picker: None,
entries: HashMap::new(),
tick: 0,
decode_generation: 0,
};
let existing = image_preview_target(1).key();
let loading = ImagePreviewTarget {
message_id: Id::new(2),
..image_preview_target(1)
}
.key();
cache.entries.insert(
existing.clone(),
ImagePreviewEntry::Failed {
filename: "existing.png".to_owned(),
message: "existing failure".to_owned(),
last_used: 1,
},
);
cache.entries.insert(
loading.clone(),
ImagePreviewEntry::Loading {
filename: "loading.png".to_owned(),
render_info: image_preview_target(1).preview_render_info(),
last_used: 2,
},
);
cache.store_loaded(&existing.url, &[]);
assert!(matches!(
cache.entries.get(&existing),
Some(ImagePreviewEntry::Failed { message, .. }) if message == "existing failure"
));
assert!(matches!(
cache.entries.get(&loading),
Some(ImagePreviewEntry::Failed { message, .. })
if message == "inline preview unavailable in this terminal"
));
}
#[test]
fn image_preview_loaded_bytes_start_decode_jobs_for_loading_entries() {
let mut cache = ImagePreviewCache {
picker: None,
entries: HashMap::new(),
tick: 0,
decode_generation: 0,
};
let target = image_preview_target(1);
let key = target.key();
let render_info = target.preview_render_info();
cache.entries.insert(
key.clone(),
ImagePreviewEntry::Loading {
filename: "loading.png".to_owned(),
render_info,
last_used: 1,
},
);
let jobs = cache.decode_jobs_for_loaded_keys(vec![key.clone()], b"image bytes", (10, 20));
assert_eq!(jobs.len(), 1);
assert_eq!(jobs[0].key, key);
assert_eq!(jobs[0].generation, 1);
assert_eq!(jobs[0].bytes.as_ref(), b"image bytes");
assert!(matches!(
cache.entries.get(&jobs[0].key),
Some(ImagePreviewEntry::Decoding { filename, generation, .. })
if filename == "loading.png" && *generation == 1
));
}
#[test]
fn image_preview_store_decoded_records_decode_failure() {
let mut cache = ImagePreviewCache {
picker: None,
entries: HashMap::new(),
tick: 0,
decode_generation: 0,
};
let target = image_preview_target(1);
let key = target.key();
let render_info = target.preview_render_info();
cache.entries.insert(
key.clone(),
ImagePreviewEntry::Decoding {
filename: "loading.png".to_owned(),
generation: 1,
render_info,
last_used: 1,
},
);
cache.store_decoded(ImagePreviewDecodeResult {
key: key.clone(),
generation: 1,
result: Err("decode failed: invalid image".to_owned()),
});
assert!(matches!(
cache.entries.get(&key),
Some(ImagePreviewEntry::Failed { filename, message, .. })
if filename == "loading.png" && message == "decode failed: invalid image"
));
}
#[test]
fn image_preview_store_decoded_ignores_stale_results() {
let mut cache = ImagePreviewCache {
picker: None,
entries: HashMap::new(),
tick: 0,
decode_generation: 0,
};
let key = image_preview_target(1).key();
cache.entries.insert(
key.clone(),
ImagePreviewEntry::Failed {
filename: "existing.png".to_owned(),
message: "existing failure".to_owned(),
last_used: 1,
},
);
cache.store_decoded(ImagePreviewDecodeResult {
key: key.clone(),
generation: 1,
result: Err("decode failed: stale".to_owned()),
});
assert!(matches!(
cache.entries.get(&key),
Some(ImagePreviewEntry::Failed { filename, message, .. })
if filename == "existing.png" && message == "existing failure"
));
}
#[test]
fn image_preview_store_decoded_ignores_replaced_decoding_generation() {
let mut cache = ImagePreviewCache {
picker: None,
entries: HashMap::new(),
tick: 0,
decode_generation: 0,
};
let target = image_preview_target(1);
let key = target.key();
let render_info = target.preview_render_info();
cache.entries.insert(
key.clone(),
ImagePreviewEntry::Decoding {
filename: "newer.png".to_owned(),
generation: 2,
render_info,
last_used: 2,
},
);
cache.store_decoded(ImagePreviewDecodeResult {
key: key.clone(),
generation: 1,
result: Err("decode failed: old generation".to_owned()),
});
assert!(matches!(
cache.entries.get(&key),
Some(ImagePreviewEntry::Decoding { filename, generation, .. })
if filename == "newer.png" && *generation == 2
));
}
#[test]
fn decode_original_preview_image_reports_invalid_bytes() {
let error = decode_original_preview_image(b"not an image")
.expect_err("invalid bytes should fail to decode");
assert!(error.starts_with("decode failed:"));
}
#[test]
fn image_preview_store_failed_preserves_existing_non_loading_entries() {
let mut cache = ImagePreviewCache {
picker: None,
entries: HashMap::new(),
tick: 0,
decode_generation: 0,
};
let existing = image_preview_target(1).key();
let loading = ImagePreviewTarget {
message_id: Id::new(2),
..image_preview_target(1)
}
.key();
cache.entries.insert(
existing.clone(),
ImagePreviewEntry::Failed {
filename: "existing.png".to_owned(),
message: "existing failure".to_owned(),
last_used: 1,
},
);
cache.entries.insert(
loading.clone(),
ImagePreviewEntry::Loading {
filename: "loading.png".to_owned(),
render_info: image_preview_target(1).preview_render_info(),
last_used: 2,
},
);
cache.store_failed(&existing.url, "new failure".to_owned());
assert!(matches!(
cache.entries.get(&existing),
Some(ImagePreviewEntry::Failed { message, .. }) if message == "existing failure"
));
assert!(matches!(
cache.entries.get(&loading),
Some(ImagePreviewEntry::Failed { message, .. }) if message == "new failure"
));
}
#[test]
fn clipped_preview_image_stays_within_preview_pixel_bounds() {
let image =
DynamicImage::ImageRgba8(ImageBuffer::from_pixel(400, 400, Rgba([0, 0, 0, 255])));
let render_info = ImagePreviewRenderInfo {
viewer: false,
message_index: 0,
preview_x_offset_columns: 0,
preview_y_offset_rows: 0,
preview_width: 16,
preview_height: 3,
preview_overflow_count: 0,
visible_preview_height: 3,
top_clip_rows: 0,
accent_color: None,
};
let resized = clipped_preview_image(&image, (10, 20), render_info)
.expect("preview dimensions should produce resized image");
assert!(resized.width() <= 160);
assert!(resized.height() <= 60);
assert!(resized.width() < image.width());
assert!(resized.height() < image.height());
}
#[test]
fn emoji_image_targets_include_visible_custom_reactions() {
let mut state = state_with_image_messages(1, &[]);
state.push_event(AppEvent::GuildEmojisUpdate {
guild_id: Id::new(1),
emojis: vec![CustomEmojiInfo {
id: Id::new(50),
name: "party".to_owned(),
animated: false,
available: true,
}],
});
state.focus_pane(FocusPane::Messages);
state.open_selected_message_actions();
state.move_message_action_down();
state.activate_selected_message_action();
let targets = visible_emoji_image_targets(&state);
assert_eq!(
targets,
vec![EmojiImageTarget {
url: "https://cdn.discordapp.com/emojis/50.png".to_owned(),
}]
);
}
#[test]
fn disabled_custom_emoji_images_create_no_targets_or_requests() {
let mut state = state_with_image_messages(1, &[]);
state.push_event(AppEvent::GuildEmojisUpdate {
guild_id: Id::new(1),
emojis: vec![CustomEmojiInfo {
id: Id::new(50),
name: "party".to_owned(),
animated: false,
available: true,
}],
});
state.focus_pane(FocusPane::Messages);
state.open_selected_message_actions();
state.move_message_action_down();
state.activate_selected_message_action();
state.open_options_popup();
for _ in 0..4 {
state.move_option_down();
}
state.toggle_selected_display_option();
let targets = visible_emoji_image_targets(&state);
let mut cache = EmojiImageCache::new();
assert!(targets.is_empty());
assert!(cache.next_requests(&targets).is_empty());
}
#[test]
fn emoji_image_targets_include_visible_forum_preview_custom_reactions() {
let guild_id = Id::new(1);
let forum_id = Id::new(20);
let thread_id = Id::new(30);
let mut state = DashboardState::new();
state.push_event(AppEvent::GuildCreate {
guild_id,
name: "guild".to_owned(),
member_count: None,
channels: vec![ChannelInfo {
guild_id: Some(guild_id),
channel_id: forum_id,
parent_id: None,
position: None,
last_message_id: None,
name: "forum".to_owned(),
kind: "GuildForum".to_owned(),
message_count: None,
total_message_sent: None,
thread_archived: None,
thread_locked: None,
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
}],
members: Vec::new(),
presences: Vec::new(),
roles: Vec::new(),
emojis: Vec::new(),
owner_id: None,
});
state.confirm_selected_guild();
state.confirm_selected_channel();
state.push_event(AppEvent::ForumPostsLoaded {
channel_id: forum_id,
archive_state: crate::discord::ForumPostArchiveState::Active,
offset: 0,
next_offset: 1,
posts: vec![ChannelInfo {
guild_id: Some(guild_id),
channel_id: thread_id,
parent_id: Some(forum_id),
position: None,
last_message_id: Some(Id::new(300)),
name: "welcome".to_owned(),
kind: "GuildPublicThread".to_owned(),
message_count: Some(1),
total_message_sent: Some(1),
thread_archived: Some(false),
thread_locked: Some(false),
thread_pinned: Some(false),
recipients: None,
permission_overwrites: Vec::new(),
}],
preview_messages: vec![MessageInfo {
guild_id: Some(guild_id),
channel_id: thread_id,
message_id: Id::new(300),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
pinned: false,
reactions: vec![ReactionInfo {
emoji: ReactionEmoji::Custom {
id: Id::new(50),
name: Some("party".to_owned()),
animated: false,
},
count: 1,
me: false,
}],
content: Some("first post".to_owned()),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
..MessageInfo::default()
}],
has_more: false,
});
let targets = visible_emoji_image_targets(&state);
assert_eq!(
targets,
vec![EmojiImageTarget {
url: "https://cdn.discordapp.com/emojis/50.png".to_owned(),
}]
);
}
#[test]
fn emoji_image_request_is_created_for_visible_target() {
let mut cache = EmojiImageCache::new();
let target = EmojiImageTarget {
url: "https://cdn.discordapp.com/emojis/50.png".to_owned(),
};
if cache.picker.is_none() {
return;
}
let requests = cache.next_requests(std::slice::from_ref(&target));
assert_eq!(
requests,
vec![AppCommand::LoadAttachmentPreview {
url: target.url.clone(),
}]
);
assert_eq!(cache.entries.len(), 1);
}
#[test]
fn emoji_image_cache_skips_requests_without_image_protocol() {
let mut cache = EmojiImageCache {
picker: None,
entries: HashMap::new(),
tick: 0,
};
let target = EmojiImageTarget {
url: "https://cdn.discordapp.com/emojis/50.png".to_owned(),
};
let requests = cache.next_requests(std::slice::from_ref(&target));
assert!(requests.is_empty());
assert!(cache.entries.is_empty());
}
#[test]
fn emoji_image_cache_evicts_least_recently_used_when_over_capacity() {
let mut cache = EmojiImageCache {
picker: None,
entries: HashMap::new(),
tick: 0,
};
for id in 0..MAX_EMOJI_IMAGE_CACHE_ENTRIES {
cache.entries.insert(
format!("https://cdn.discordapp.com/emojis/{id}.png"),
EmojiImageEntry::Failed {
last_used: id as u64,
},
);
}
cache.tick = MAX_EMOJI_IMAGE_CACHE_ENTRIES as u64;
cache.entries.insert(
"https://cdn.discordapp.com/emojis/oldest.png".to_owned(),
EmojiImageEntry::Failed { last_used: 0 },
);
let visible_url = "https://cdn.discordapp.com/emojis/0.png".to_owned();
let targets = vec![EmojiImageTarget {
url: visible_url.clone(),
}];
cache.prune_to_limit(&targets);
assert_eq!(cache.entries.len(), MAX_EMOJI_IMAGE_CACHE_ENTRIES);
assert!(cache.entries.contains_key(&visible_url));
}
#[test]
fn image_preview_height_respects_dimensions_and_fallbacks() {
let cases = [
(60, 10, Some(2400), Some(600), 5),
(60, 10, Some(800), Some(800), 10),
(72, 10, Some(481), Some(160), 6),
(72, 10, Some(100), Some(100), 4),
(72, 10, Some(32), Some(32), 3),
(72, 10, Some(100), Some(40), 3),
(72, 10, Some(128), Some(128), 5),
(60, 10, None, None, 10),
(60, 10, Some(0), Some(100), 10),
];
for (width, max_height, image_width, image_height, expected) in cases {
assert_eq!(
image_preview_height_for_dimensions(width, max_height, image_width, image_height),
expected
);
}
assert!(
image_preview_height_for_dimensions(60, 10, Some(2400), Some(600))
< image_preview_height_for_dimensions(60, 10, Some(800), Some(800))
);
}
fn state_with_image_messages(count: u64, image_message_ids: &[u64]) -> DashboardState {
state_with_image_messages_and_display_options(
count,
image_message_ids,
DisplayOptions::default(),
)
}
fn state_with_image_messages_and_display_options(
count: u64,
image_message_ids: &[u64],
display_options: DisplayOptions,
) -> DashboardState {
let guild_id = Id::new(1);
let channel_id = Id::new(2);
let mut state = DashboardState::new_with_display_options(display_options);
state.push_event(AppEvent::GuildCreate {
guild_id,
name: "guild".to_owned(),
member_count: None,
channels: vec![ChannelInfo {
guild_id: Some(guild_id),
channel_id,
parent_id: None,
position: None,
last_message_id: None,
name: "general".to_owned(),
kind: "GuildText".to_owned(),
message_count: None,
total_message_sent: None,
thread_archived: None,
thread_locked: None,
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
}],
members: Vec::new(),
presences: Vec::new(),
roles: Vec::new(),
emojis: Vec::new(),
owner_id: None,
});
state.confirm_selected_guild();
state.confirm_selected_channel();
for id in 1..=count {
state.push_event(AppEvent::MessageCreate {
guild_id: Some(guild_id),
channel_id,
message_id: Id::new(id),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some(format!("msg {id}")),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: image_message_ids
.contains(&id)
.then(|| image_attachment(id))
.into_iter()
.collect(),
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
}
state
}
fn state_with_avatar_messages(count: u64) -> DashboardState {
let guild_id = Id::new(1);
let channel_id = Id::new(2);
let mut state = DashboardState::new();
state.push_event(AppEvent::GuildCreate {
guild_id,
name: "guild".to_owned(),
member_count: None,
channels: vec![ChannelInfo {
guild_id: Some(guild_id),
channel_id,
parent_id: None,
position: None,
last_message_id: None,
name: "general".to_owned(),
kind: "GuildText".to_owned(),
message_count: None,
total_message_sent: None,
thread_archived: None,
thread_locked: None,
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
}],
members: Vec::new(),
presences: Vec::new(),
roles: Vec::new(),
emojis: Vec::new(),
owner_id: None,
});
state.confirm_selected_guild();
state.confirm_selected_channel();
for id in 1..=count {
state.push_event(AppEvent::MessageCreate {
guild_id: Some(guild_id),
channel_id,
message_id: Id::new(id),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: Some(format!("https://cdn.discordapp.com/avatar-{id}.png")),
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some(format!("msg {id}")),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
}
state
}
fn state_with_cross_day_image_message() -> DashboardState {
let guild_id = Id::new(1);
let channel_id = Id::new(2);
let mut state = DashboardState::new();
state.push_event(AppEvent::GuildCreate {
guild_id,
name: "guild".to_owned(),
member_count: None,
channels: vec![ChannelInfo {
guild_id: Some(guild_id),
channel_id,
parent_id: None,
position: None,
last_message_id: None,
name: "general".to_owned(),
kind: "GuildText".to_owned(),
message_count: None,
total_message_sent: None,
thread_archived: None,
thread_locked: None,
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
}],
members: Vec::new(),
presences: Vec::new(),
roles: Vec::new(),
emojis: Vec::new(),
owner_id: None,
});
state.confirm_selected_guild();
state.confirm_selected_channel();
let day_one = snowflake_for_unix_ms(1_743_465_600_000);
let day_two = snowflake_for_unix_ms(1_743_465_600_000 + 24 * 60 * 60 * 1000);
for (message_id, attachments) in
[(day_one, Vec::new()), (day_two, vec![image_attachment(2)])]
{
state.push_event(AppEvent::MessageCreate {
guild_id: Some(guild_id),
channel_id,
message_id,
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("msg".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments,
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
}
state
}
fn target_message_ids(targets: &[ImagePreviewTarget]) -> Vec<Id<MessageMarker>> {
targets.iter().map(|target| target.message_id).collect()
}
fn push_album_message(state: &mut DashboardState, message_id: u64, attachment_count: u64) {
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(message_id),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("album".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: (1..=attachment_count).map(image_attachment).collect(),
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
}
fn push_attachment_message(state: &mut DashboardState, attachment: AttachmentInfo) {
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(1),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("photo".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: vec![attachment],
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
}
fn image_preview_target(id: u64) -> ImagePreviewTarget {
ImagePreviewTarget {
viewer: false,
message_index: 0,
preview_index: 0,
preview_x_offset_columns: 0,
preview_y_offset_rows: 0,
preview_width: 16,
preview_height: 3,
preview_overflow_count: 0,
visible_preview_height: 3,
top_clip_rows: 0,
accent_color: None,
message_id: Id::new(id),
url: format!("https://cdn.discordapp.com/image-{id}.png"),
filename: format!("image-{id}.png"),
}
}
fn snowflake_for_unix_ms(unix_ms: u64) -> Id<MessageMarker> {
const DISCORD_EPOCH_MILLIS: u64 = 1_420_070_400_000;
const SNOWFLAKE_TIMESTAMP_SHIFT: u8 = 22;
let raw = (unix_ms - DISCORD_EPOCH_MILLIS) << SNOWFLAKE_TIMESTAMP_SHIFT;
Id::new(raw.max(1))
}
fn image_attachment(id: u64) -> AttachmentInfo {
AttachmentInfo {
id: Id::new(id),
filename: format!("image-{id}.png"),
url: format!("https://cdn.discordapp.com/image-{id}.png"),
proxy_url: format!("https://media.discordapp.net/image-{id}.png"),
content_type: Some("image/png".to_owned()),
size: 2048,
width: Some(640),
height: Some(480),
description: None,
}
}
fn video_attachment(id: u64) -> AttachmentInfo {
AttachmentInfo {
id: Id::new(id),
filename: format!("clip-{id}.mp4"),
url: format!("https://cdn.discordapp.com/clip-{id}.mp4"),
proxy_url: format!("https://media.discordapp.net/clip-{id}.mp4"),
content_type: Some("video/mp4".to_owned()),
size: 78_364_758,
width: Some(1920),
height: Some(1080),
description: None,
}
}
fn youtube_embed() -> EmbedInfo {
EmbedInfo {
color: Some(0xff0000),
provider_name: Some("YouTube".to_owned()),
author_name: None,
title: Some("Example Video".to_owned()),
description: Some("A video description".to_owned()),
fields: Vec::new(),
footer_text: None,
url: Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_owned()),
thumbnail_url: Some("https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg".to_owned()),
thumbnail_proxy_url: None,
thumbnail_width: Some(480),
thumbnail_height: Some(360),
image_url: None,
image_proxy_url: None,
image_width: None,
image_height: None,
video_url: Some("https://www.youtube.com/embed/dQw4w9WgXcQ".to_owned()),
}
}
fn forwarded_snapshot(id: u64) -> MessageSnapshotInfo {
MessageSnapshotInfo {
content: Some(format!("forwarded {id}")),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: vec![image_attachment(id)],
embeds: Vec::new(),
source_channel_id: None,
timestamp: None,
}
}
}