use std::io::Cursor;
use crate::discord::ids::{Id, marker::MessageMarker};
use crate::discord::test_builders::{
ForumPostsLoadedFixture, GuildCreateFixture, MessageCreateFixture,
empty_latest_message_history_loaded_event, forum_posts_loaded_event, guild_create_event,
guild_message_create_fixture, message_create_event,
};
use image::{DynamicImage, ImageBuffer, ImageFormat, Rgba};
use crate::{
config::{DisplayOptions, ImagePreviewQualityPreset},
discord::{
ActivityEmoji, ActivityInfo, ActivityKind, AppCommand, AppEvent, AttachmentInfo,
ChannelInfo, ChannelRecipientInfo, CustomEmojiInfo, EmbedInfo, MessageInfo,
MessageSnapshotInfo, PresenceEventFields, PresenceStatus, ProfileAvatarUpload,
ReactionEmoji, ReactionInfo,
},
tui::{
message::time::test_message_id_for_unix_millis,
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,
font_size: None,
}
}
fn image_preview_cache_without_picker() -> ImagePreviewCache {
ImagePreviewCache {
picker: None,
cache: super::cache::MediaImageCacheCore::new(),
}
}
fn avatar_cache_without_picker() -> AvatarImageCache {
AvatarImageCache {
picker: None,
cache: super::cache::MediaImageCacheCore::new(),
active_popup_avatar_url: None,
}
}
fn emoji_cache_without_picker() -> EmojiImageCache {
EmojiImageCache {
picker: None,
cache: super::cache::MediaImageCacheCore::new(),
}
}
fn push_media_message(state: &mut DashboardState, event: MessageCreateFixture) {
state.push_event(message_create_event(event));
}
fn encoded_png(width: u32, height: u32) -> Vec<u8> {
let image =
DynamicImage::ImageRgba8(ImageBuffer::from_pixel(width, height, Rgba([0, 0, 0, 0])));
let mut bytes = Cursor::new(Vec::new());
image
.write_to(&mut bytes, ImageFormat::Png)
.expect("test image should encode");
bytes.into_inner()
}
#[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 image_preview_targets_keep_background_previews_while_modal_is_open() {
let mut state = state_with_image_messages(1, &[1]);
state.set_message_view_height(6);
state.open_channel_switcher();
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, &[]);
push_media_message(
&mut state,
MessageCreateFixture {
message_id: Id::new(1),
content: Some("album".to_owned()),
attachments: vec![image_attachment(1), image_attachment(2)],
..guild_message_create_fixture()
},
);
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_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_attachment_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();
push_media_message(
&mut state,
MessageCreateFixture {
message_id: Id::new(1),
content: Some("photo".to_owned()),
attachments: vec![attachment],
..guild_message_create_fixture()
},
);
state.focus_pane(FocusPane::Messages);
assert!(state.open_attachment_viewer_for_selected_message());
let target = visible_image_preview_targets(&state, layout(12))
.into_iter()
.next()
.expect("attachment 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()
},
);
push_media_message(
&mut state,
MessageCreateFixture {
message_id: Id::new(1),
author_avatar_url: Some("https://cdn.discordapp.com/avatars/1/hash.png".to_owned()),
content: Some("hello <:party:50>".to_owned()),
..guild_message_create_fixture()
},
);
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_choose_embed_thumbnail_url() {
for (name, embed, content, expected_url) in [
(
"youtube thumbnail is downgraded to a preview size",
youtube_embed(),
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg",
),
(
"youtube thumbnail that is already small is kept",
EmbedInfo {
thumbnail_url: Some("https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg".to_owned()),
thumbnail_width: Some(120),
thumbnail_height: Some(90),
..youtube_embed()
},
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg",
),
(
"media proxy is resized",
EmbedInfo {
thumbnail_url: Some("https://example.com/photo.png".to_owned()),
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(),
),
..youtube_embed()
},
"https://example.com/post",
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"
),
),
(
"images-ext proxy is resized",
EmbedInfo {
thumbnail_url: Some("https://example.com/photo.png".to_owned()),
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(),
),
..youtube_embed()
},
"https://example.com/post",
concat!(
"https://images-ext-1.discordapp.net/external/cache-key/https/example.com/photo.png",
"?format=webp&width=240&height=180"
),
),
(
"proxy outside the resizable routes falls back to the source url",
EmbedInfo {
thumbnail_url: Some("https://example.com/photo.png".to_owned()),
thumbnail_proxy_url: Some(
"https://media.discordapp.net/avatars/1/hash.png".to_owned(),
),
..youtube_embed()
},
"https://example.com/post",
"https://example.com/photo.png",
),
] {
let mut state = state_with_image_messages(1, &[]);
push_media_message(
&mut state,
MessageCreateFixture {
message_id: Id::new(2),
content: Some(content.to_owned()),
embeds: vec![embed],
..guild_message_create_fixture()
},
);
let targets = visible_image_preview_targets(&state, layout(8));
assert_eq!(target_message_ids(&targets), vec![Id::new(2)], "{name}");
assert_eq!(targets[0].url, expected_url, "{name}");
assert_eq!(targets[0].filename, "embed-thumbnail", "{name}");
assert!(targets[0].show_play_marker, "{name}");
}
}
#[test]
fn image_preview_targets_do_not_mark_plain_image_embed_thumbnail_as_playable() {
let mut state = state_with_image_messages(1, &[]);
push_media_message(
&mut state,
MessageCreateFixture {
message_id: Id::new(2),
content: Some("https://example.com/post".to_owned()),
embeds: vec![EmbedInfo {
thumbnail_url: Some("https://example.com/photo.png".to_owned()),
thumbnail_width: Some(640),
thumbnail_height: Some(480),
..EmbedInfo::test()
}],
..guild_message_create_fixture()
},
);
let targets = visible_image_preview_targets(&state, layout(8));
assert_eq!(target_message_ids(&targets), vec![Id::new(2)]);
assert_eq!(targets[0].filename, "embed-thumbnail");
assert!(!targets[0].show_play_marker);
}
#[test]
fn image_preview_targets_layout_album_grids() {
let portrait_album = {
let mut first = image_attachment(1);
first.width = Some(1080);
first.height = Some(1920);
let mut second = image_attachment(2);
second.width = Some(1080);
second.height = Some(1920);
vec![first, second]
};
let cases = [
(
(1..=3).map(image_attachment).collect::<Vec<_>>(),
vec![(0, 0, 0, 8, 3), (1, 8, 0, 8, 2), (2, 8, 2, 4, 1)],
),
(
(1..=4).map(image_attachment).collect::<Vec<_>>(),
vec![
(0, 0, 0, 8, 2),
(1, 8, 0, 8, 2),
(2, 0, 2, 4, 1),
(3, 4, 2, 4, 1),
],
),
(
(1..=5).map(image_attachment).collect::<Vec<_>>(),
vec![
(0, 0, 0, 8, 2),
(1, 8, 0, 8, 2),
(2, 0, 2, 4, 1),
(3, 4, 2, 4, 1),
],
),
(portrait_album, vec![(0, 0, 0, 5, 3), (1, 5, 0, 5, 3)]),
];
for (attachments, expected_geometry) in cases {
let mut state = state_with_image_messages(0, &[]);
push_media_message(
&mut state,
MessageCreateFixture {
message_id: Id::new(1),
content: Some("album".to_owned()),
attachments,
..guild_message_create_fixture()
},
);
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
);
}
}
#[test]
fn attachment_viewer_target_fits_source_image_inside_viewer_layout() {
let mut state = state_with_image_messages(1, &[1]);
state.focus_pane(FocusPane::Messages);
state.direct_open_selected_message_attachment_viewer();
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, 52);
assert_eq!(target.preview_height, 13);
assert_eq!(target.visible_preview_height, 13);
}
#[test]
fn attachment_viewer_target_shows_video_thumbnail_preview() {
let mut state = state_with_image_messages(1, &[]);
push_media_message(
&mut state,
MessageCreateFixture {
message_id: Id::new(2),
content: Some("clip".to_owned()),
attachments: vec![video_attachment(2)],
..guild_message_create_fixture()
},
);
state.focus_pane(FocusPane::Messages);
state.move_down();
state.direct_open_selected_message_attachment_viewer();
let target = visible_image_preview_targets(&state, layout(12))
.into_iter()
.next()
.expect("viewer should create one video thumbnail target");
assert!(target.viewer);
assert!(target.show_play_marker);
assert_eq!(
target.url,
"https://media.discordapp.net/attachments/691/150/clip-2.mp4?format=webp&width=563&height=1000"
);
}
#[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 avatar_preview_url_sizes_user_avatars_but_not_default_ones() {
for (url, width, height, expected) in [
(
"https://cdn.discordapp.com/avatars/1/hash.png",
2,
2,
"https://cdn.discordapp.com/avatars/1/hash.png?size=64",
),
(
"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",
),
(
"https://cdn.discordapp.com/embed/avatars/0.png",
8,
4,
"https://cdn.discordapp.com/embed/avatars/0.png",
),
] {
assert_eq!(avatar_preview_url(url, width, height), expected, "{url}");
}
}
#[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 = avatar_cache_without_picker();
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.cache.entries.insert(
url,
AvatarImageEntry::Failed {
last_used: id as u64,
},
);
}
cache.cache.tick = MAX_AVATAR_IMAGE_CACHE_ENTRIES as u64;
cache.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.cache.entries.len(), MAX_AVATAR_IMAGE_CACHE_ENTRIES);
assert!(cache.cache.entries.contains_key(&visible_cache_url));
assert!(
!cache
.cache
.entries
.contains_key("https://cdn.discordapp.com/avatars/oldest.png")
);
}
#[test]
fn avatar_protocol_key_tracks_render_clipping() {
let full = AvatarTarget {
row: 0,
visible_height: AVATAR_PREVIEW_HEIGHT,
top_clip_rows: 0,
url: "https://cdn.discordapp.com/avatars/1.png".to_owned(),
};
let clipped = AvatarTarget {
visible_height: 1,
top_clip_rows: 1,
..full.clone()
};
assert_ne!(
AvatarProtocolKey::message_avatar(&full, false),
AvatarProtocolKey::message_avatar(&clipped, false)
);
assert_ne!(
AvatarProtocolKey::message_avatar(&full, false),
AvatarProtocolKey::profile_popup(false)
);
assert_ne!(
AvatarProtocolKey::message_avatar(&full, false),
AvatarProtocolKey::message_avatar(&full, true)
);
}
#[test]
fn avatar_popup_request_prunes_cache_to_limit() {
let mut cache = avatar_cache_without_picker();
for id in 0..MAX_AVATAR_IMAGE_CACHE_ENTRIES {
cache.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.cache.entries.len(), MAX_AVATAR_IMAGE_CACHE_ENTRIES);
assert!(
cache
.cache
.entries
.contains_key("https://cdn.discordapp.com/avatars/new.png?size=128")
);
}
#[test]
fn avatar_popup_upload_request_uses_local_preview_command() {
let mut cache = avatar_cache_without_picker();
let upload = ProfileAvatarUpload::from_bytes("avatar.png".to_owned(), vec![1, 2, 3]);
let request = cache.next_request_for_profile_upload("pending-avatar", || Some(upload.clone()));
assert_eq!(
request,
Some(AppCommand::LoadProfileAvatarPreview {
key: "pending-avatar".to_owned(),
upload,
})
);
assert!(cache.cache.entries.contains_key("pending-avatar"));
}
#[test]
fn avatar_cache_pruning_preserves_active_popup_avatar() {
let popup_url = "https://cdn.discordapp.com/avatars/popup.png?size=128";
let mut cache = AvatarImageCache {
active_popup_avatar_url: Some(popup_url.to_owned()),
..avatar_cache_without_picker()
};
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.cache.entries.insert(
url,
AvatarImageEntry::Failed {
last_used: id as u64,
},
);
}
cache.cache.entries.insert(
popup_url.to_owned(),
AvatarImageEntry::Failed { last_used: 0 },
);
let targets = (0..MAX_AVATAR_IMAGE_CACHE_ENTRIES)
.map(|id| AvatarTarget {
row: 0,
visible_height: 1,
top_clip_rows: 0,
url: format!("https://cdn.discordapp.com/avatars/{id}.png"),
})
.collect::<Vec<_>>();
cache.prune_to_limit(&targets);
assert_eq!(
cache.cache.entries.len(),
MAX_AVATAR_IMAGE_CACHE_ENTRIES + 1
);
assert!(cache.cache.entries.contains_key(popup_url));
}
#[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_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_uses_proxy_webp_thumbnail_as_image_preview() {
let mut state = state_with_image_messages(1, &[]);
push_media_message(
&mut state,
MessageCreateFixture {
message_id: Id::new(2),
content: Some("clip".to_owned()),
attachments: vec![video_attachment(2)],
..guild_message_create_fixture()
},
);
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://media.discordapp.net/attachments/691/150/clip-2.mp4?format=webp&width=540&height=960"
);
assert_eq!(targets[0].filename, "clip-2.mp4");
assert!(targets[0].show_play_marker);
assert_eq!(targets[0].preview_width, 5);
assert_eq!(targets[0].preview_height, 3);
}
#[test]
fn original_quality_video_attachment_still_uses_proxy_webp_thumbnail() {
let mut state = state_with_image_messages_and_display_options(
0,
&[],
DisplayOptions {
image_preview_quality: ImagePreviewQualityPreset::Original,
..DisplayOptions::default()
},
);
let mut attachment = video_attachment(2);
attachment.proxy_url = concat!(
"https://media.discordapp.net/attachments/691/150/clip.mp4",
"?ex=abc&is=def&hm=123&format=png&width=4000&height=3000"
)
.to_owned();
push_attachment_message(&mut state, attachment);
let target = visible_image_preview_targets(&state, layout(12))
.into_iter()
.next()
.expect("video attachment should produce preview target");
assert_eq!(
target.url,
concat!(
"https://media.discordapp.net/attachments/691/150/clip.mp4",
"?ex=abc&is=def&hm=123&format=webp&width=563&height=1000"
)
);
}
#[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, &[]);
push_media_message(
&mut state,
MessageCreateFixture {
message_id: Id::new(2),
content: Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_owned()),
embeds: vec![embed],
..guild_message_create_fixture()
},
);
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");
assert!(targets[0].show_play_marker);
}
#[test]
fn image_preview_targets_include_forwarded_image_attachments() {
let mut state = state_with_image_messages(1, &[]);
push_media_message(
&mut state,
MessageCreateFixture {
message_id: Id::new(2),
content: Some(String::new()),
forwarded_snapshots: vec![forwarded_snapshot(2)],
..guild_message_create_fixture()
},
);
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_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), Id::new(6)]);
}
#[test]
fn image_preview_request_is_created_for_draw_target() {
let mut cache = image_preview_cache_without_picker();
let target = image_preview_target(1);
assert!(cache.cache.entries.is_empty());
assert_eq!(cache.render_state(std::slice::from_ref(&target)).len(), 1);
assert!(cache.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.cache.entries.len(), 1);
}
#[test]
fn image_preview_render_state_preserves_target_order() {
let mut cache = image_preview_cache_without_picker();
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.cache.entries.insert(
second.key(),
ImagePreviewEntry::Loading {
filename: second.filename.clone(),
render_info: second.preview_render_info(),
last_used: 1,
},
);
cache.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 = image_preview_cache_without_picker();
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.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 = image_preview_cache_without_picker();
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.cache.entries.len(), 2);
}
#[test]
fn image_preview_cache_keeps_viewer_and_inline_entries_separate() {
let mut cache = image_preview_cache_without_picker();
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.cache.entries.len(), 2);
assert!(cache.cache.entries.contains_key(&inline.key()));
assert!(cache.cache.entries.contains_key(&viewer.key()));
}
#[test]
fn image_preview_cache_evicts_least_recently_used_entries() {
let mut cache = image_preview_cache_without_picker();
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.cache.entries.len(), MAX_IMAGE_PREVIEW_CACHE_ENTRIES);
assert!(cache.cache.entries.contains_key(&existing_targets[0].key()));
assert!(!cache.cache.entries.contains_key(&existing_targets[1].key()));
assert!(cache.cache.entries.contains_key(&new_target.key()));
}
#[test]
fn image_preview_cache_limits_visible_requests() {
let mut cache = image_preview_cache_without_picker();
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.cache.entries.len(), MAX_IMAGE_PREVIEW_CACHE_ENTRIES);
assert_eq!(requests.len(), MAX_IMAGE_PREVIEW_CACHE_ENTRIES);
assert!(cache.cache.entries.contains_key(&targets[0].key()));
assert!(
!cache
.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 = image_preview_cache_without_picker();
let existing = image_preview_target(1).key();
let loading = ImagePreviewTarget {
message_id: Id::new(2),
..image_preview_target(1)
}
.key();
cache.cache.entries.insert(
existing.clone(),
ImagePreviewEntry::Failed {
filename: "existing.png".to_owned(),
message: "existing failure".to_owned(),
last_used: 1,
},
);
cache.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.cache.entries.get(&existing),
Some(ImagePreviewEntry::Failed { message, .. }) if message == "existing failure"
));
assert!(matches!(
cache.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 = image_preview_cache_without_picker();
let target = image_preview_target(1);
let key = target.key();
let render_info = target.preview_render_info();
cache.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");
assert_eq!(jobs.len(), 1);
assert_eq!(jobs[0].key, MediaImageDecodeKey::Preview(key.clone()));
assert_eq!(jobs[0].generation, 1);
assert_eq!(jobs[0].bytes.as_ref(), b"image bytes");
assert!(matches!(
cache.cache.entries.get(&key),
Some(ImagePreviewEntry::Decoding { filename, generation, .. })
if filename == "loading.png" && *generation == 1
));
}
#[test]
fn image_preview_store_decoded_records_decode_failure() {
let mut cache = image_preview_cache_without_picker();
let target = image_preview_target(1);
let key = target.key();
let render_info = target.preview_render_info();
cache.cache.entries.insert(
key.clone(),
ImagePreviewEntry::Decoding {
filename: "loading.png".to_owned(),
generation: 1,
render_info,
last_used: 1,
},
);
cache.store_decoded(
key.clone(),
1,
Err("decode failed: invalid image".to_owned()),
);
assert!(matches!(
cache.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_replaced_decoding_generation() {
let mut cache = image_preview_cache_without_picker();
let target = image_preview_target(1);
let key = target.key();
let render_info = target.preview_render_info();
cache.cache.entries.insert(
key.clone(),
ImagePreviewEntry::Decoding {
filename: "newer.png".to_owned(),
generation: 2,
render_info,
last_used: 2,
},
);
cache.store_decoded(
key.clone(),
1,
Err("decode failed: old generation".to_owned()),
);
assert!(matches!(
cache.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 media_decode_rejects_oversized_image_dimensions() {
for (width, height) in [
(MAX_DECODED_IMAGE_WIDTH + 1, 1),
(1, MAX_DECODED_IMAGE_HEIGHT + 1),
] {
let bytes = encoded_png(width, height);
let error = decode_image_bytes(&bytes).expect_err("oversized image should be rejected");
assert!(
error.starts_with("decode failed:"),
"oversized {width}x{height} image should fail with decode context, got {error:?}"
);
}
}
#[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,
visible_preview_height: 3,
top_clip_rows: 0,
accent_color: None,
show_play_marker: false,
mask_circular: false,
};
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 clipped_video_preview_draws_play_marker_into_image_pixels() {
let image =
DynamicImage::ImageRgba8(ImageBuffer::from_pixel(200, 400, Rgba([20, 30, 40, 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,
visible_preview_height: 3,
top_clip_rows: 0,
accent_color: None,
show_play_marker: true,
mask_circular: false,
};
let marked = clipped_preview_image(&image, (10, 20), render_info)
.expect("preview dimensions should produce resized image")
.to_rgba8();
let center = marked.get_pixel(marked.width() / 2, marked.height() / 2);
assert!(
center.0[0] > 150 && center.0[1] > 150 && center.0[2] > 150,
"center pixel should contain the bright play triangle, got {center:?}"
);
assert_eq!(
center.0[3], 255,
"play marker should be drawn over fitted image content, got {center:?}"
);
let left_edge = marked.get_pixel(0, marked.height() / 2);
assert_eq!(
left_edge.0[3], 0,
"portrait thumbnail should be centered inside transparent canvas, got {left_edge:?}"
);
}
#[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::test(Id::new(50), "party")],
});
state.focus_pane(FocusPane::Messages);
state.open_emoji_reaction_picker();
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_targets_include_visible_composer_custom_emoji_picker_candidates() {
for (emoji, query) in [
(CustomEmojiInfo::test(Id::new(50), "party"), ":pa"),
(
CustomEmojiInfo {
available: false,
..CustomEmojiInfo::test(Id::new(51), "gone")
},
":go",
),
] {
let expected_url = format!("https://cdn.discordapp.com/emojis/{}.png", emoji.id.get());
let mut state = state_with_image_messages(1, &[]);
state.push_event(AppEvent::GuildEmojisUpdate {
guild_id: Id::new(1),
emojis: vec![emoji],
});
state.start_composer();
for ch in query.chars() {
state.push_composer_char(ch);
}
let targets = visible_emoji_image_targets(&state);
assert_eq!(targets, vec![EmojiImageTarget { url: expected_url }]);
}
}
#[test]
fn emoji_image_targets_include_confirmed_composer_custom_emoji() {
let mut state = state_with_image_messages(1, &[]);
state.push_event(AppEvent::GuildEmojisUpdate {
guild_id: Id::new(1),
emojis: vec![CustomEmojiInfo::test(Id::new(60), "wave")],
});
state.start_composer();
for ch in ":wa".chars() {
state.push_composer_char(ch);
}
assert!(state.confirm_composer_emoji());
let targets = visible_emoji_image_targets(&state);
assert_eq!(
targets,
vec![EmojiImageTarget {
url: "https://cdn.discordapp.com/emojis/60.png".to_owned(),
}]
);
}
#[test]
fn emoji_image_targets_include_visible_non_selected_dm_activity() {
let alice = Id::new(10);
let bob = Id::new(20);
let mut state = DashboardState::new();
for (channel_id, user_id, name, last_message_id) in [
(Id::new(100), alice, "alice", Id::new(200)),
(Id::new(101), bob, "bob", Id::new(100)),
] {
state.push_event(AppEvent::ChannelUpsert(ChannelInfo {
last_message_id: Some(last_message_id),
recipients: Some(vec![ChannelRecipientInfo {
status: Some(PresenceStatus::Online),
..ChannelRecipientInfo::test(user_id, name)
}]),
..ChannelInfo::test(channel_id, "dm")
}));
}
state.push_event(AppEvent::PresenceUpdate {
guild_id: None,
presence: PresenceEventFields {
user_id: bob,
status: PresenceStatus::Online,
activities: vec![ActivityInfo {
emoji: Some(ActivityEmoji {
name: "coffee".to_owned(),
id: Some(Id::new(70)),
animated: false,
}),
state: Some("Taking a break".to_owned()),
..ActivityInfo::test(ActivityKind::Custom, "Custom Status")
}],
},
});
state.confirm_selected_guild();
state.confirm_selected_channel();
state.set_channel_view_height(10);
let targets = visible_emoji_image_targets(&state);
assert_eq!(
targets,
vec![EmojiImageTarget {
url: "https://cdn.discordapp.com/emojis/70.png".to_owned(),
}]
);
}
#[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(guild_create_event(GuildCreateFixture {
channels: vec![ChannelInfo {
guild_id: Some(guild_id),
name: "forum".to_owned(),
..ChannelInfo::test(forum_id, "GuildForum")
}],
..GuildCreateFixture::new(guild_id)
}));
state.confirm_selected_guild();
state.confirm_selected_channel();
state.push_event(forum_posts_loaded_event(ForumPostsLoadedFixture {
channel_id: forum_id,
archive_state: crate::discord::ForumPostArchiveState::Active,
next_offset: 1,
threads: vec![ChannelInfo {
guild_id: Some(guild_id),
parent_id: Some(forum_id),
last_message_id: Some(Id::new(300)),
name: "welcome".to_owned(),
message_count: Some(1),
total_message_sent: Some(1),
thread_metadata: Some(crate::discord::ThreadMetadataInfo::test(false, false)),
flags: Some(0),
..ChannelInfo::test(thread_id, "GuildPublicThread")
}],
first_messages: vec![MessageInfo {
guild_id: Some(guild_id),
channel_id: thread_id,
message_id: Id::new(thread_id.get()),
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::test(ReactionEmoji::Custom {
id: Id::new(50),
name: Some("party".to_owned()),
animated: false,
})],
content: Some("first post".to_owned()),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
..MessageInfo::default()
}],
..ForumPostsLoadedFixture::new()
}));
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_targets_include_visible_forum_post_custom_tag_emoji() {
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(guild_create_event(GuildCreateFixture {
channels: vec![ChannelInfo {
guild_id: Some(guild_id),
name: "forum".to_owned(),
available_tags: vec![
crate::discord::ForumTagInfo {
id: Id::new(101),
name: "bug".to_owned(),
moderated: false,
emoji_id: Some(Id::new(77)),
emoji_name: None,
},
crate::discord::ForumTagInfo {
id: Id::new(102),
name: "fire".to_owned(),
moderated: false,
emoji_id: None,
emoji_name: Some("🔥".to_owned()),
},
],
..ChannelInfo::test(forum_id, "GuildForum")
}],
..GuildCreateFixture::new(guild_id)
}));
state.confirm_selected_guild();
state.confirm_selected_channel();
state.push_event(forum_posts_loaded_event(ForumPostsLoadedFixture {
channel_id: forum_id,
archive_state: crate::discord::ForumPostArchiveState::Active,
next_offset: 1,
threads: vec![ChannelInfo {
guild_id: Some(guild_id),
parent_id: Some(forum_id),
last_message_id: Some(Id::new(300)),
name: "welcome".to_owned(),
message_count: Some(1),
total_message_sent: Some(1),
thread_metadata: Some(crate::discord::ThreadMetadataInfo::test(false, false)),
flags: Some(0),
applied_tags: vec![Id::new(101), Id::new(102)],
..ChannelInfo::test(thread_id, "GuildPublicThread")
}],
..ForumPostsLoadedFixture::new()
}));
let targets = visible_emoji_image_targets(&state);
assert_eq!(
targets,
vec![EmojiImageTarget {
url: "https://cdn.discordapp.com/emojis/77.png".to_owned(),
}]
);
}
#[test]
fn emoji_image_cache_skips_requests_without_image_protocol() {
let mut cache = emoji_cache_without_picker();
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.cache.entries.is_empty());
}
#[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(guild_create_event(GuildCreateFixture {
channels: vec![ChannelInfo {
guild_id: Some(guild_id),
name: "general".to_owned(),
..ChannelInfo::test(channel_id, "GuildText")
}],
..GuildCreateFixture::new(guild_id)
}));
state.confirm_selected_guild();
state.confirm_selected_channel();
for id in 1..=count {
push_media_message(
&mut state,
MessageCreateFixture {
channel_id,
message_id: Id::new(id),
content: Some(format!("msg {id}")),
attachments: image_message_ids
.contains(&id)
.then(|| image_attachment(id))
.into_iter()
.collect(),
..guild_message_create_fixture()
},
);
}
state.push_event(empty_latest_message_history_loaded_event(channel_id));
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(guild_create_event(GuildCreateFixture {
channels: vec![ChannelInfo {
guild_id: Some(guild_id),
name: "general".to_owned(),
..ChannelInfo::test(channel_id, "GuildText")
}],
..GuildCreateFixture::new(guild_id)
}));
state.confirm_selected_guild();
state.confirm_selected_channel();
for id in 1..=count {
push_media_message(
&mut state,
MessageCreateFixture {
channel_id,
message_id: Id::new(id),
author_avatar_url: Some(format!("https://cdn.discordapp.com/avatar-{id}.png")),
content: Some(format!("msg {id}")),
..guild_message_create_fixture()
},
);
}
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(guild_create_event(GuildCreateFixture {
channels: vec![ChannelInfo {
guild_id: Some(guild_id),
name: "general".to_owned(),
..ChannelInfo::test(channel_id, "GuildText")
}],
..GuildCreateFixture::new(guild_id)
}));
state.confirm_selected_guild();
state.confirm_selected_channel();
let day_one = test_message_id_for_unix_millis(1_743_465_600_000);
let day_two = test_message_id_for_unix_millis(1_743_465_600_000 + 24 * 60 * 60 * 1000);
for (message_id, attachments) in [(day_one, Vec::new()), (day_two, vec![image_attachment(2)])] {
push_media_message(
&mut state,
MessageCreateFixture {
channel_id,
message_id,
content: Some("msg".to_owned()),
attachments,
..guild_message_create_fixture()
},
);
}
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) {
push_media_message(
state,
MessageCreateFixture {
message_id: Id::new(message_id),
content: Some("album".to_owned()),
attachments: (1..=attachment_count).map(image_attachment).collect(),
..guild_message_create_fixture()
},
);
}
fn push_attachment_message(state: &mut DashboardState, attachment: AttachmentInfo) {
push_media_message(
state,
MessageCreateFixture {
message_id: Id::new(1),
content: Some("photo".to_owned()),
attachments: vec![attachment],
..guild_message_create_fixture()
},
);
}
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,
visible_preview_height: 3,
top_clip_rows: 0,
accent_color: None,
show_play_marker: false,
message_id: Id::new(id),
url: format!("https://cdn.discordapp.com/image-{id}.png"),
filename: format!("image-{id}.png"),
}
}
fn image_attachment(id: u64) -> AttachmentInfo {
AttachmentInfo {
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),
..AttachmentInfo::test(Id::new(id), format!("image-{id}.png"))
}
}
fn video_attachment(id: u64) -> AttachmentInfo {
AttachmentInfo {
url: format!("https://cdn.discordapp.com/clip-{id}.mp4"),
proxy_url: format!("https://media.discordapp.net/attachments/691/150/clip-{id}.mp4"),
content_type: Some("video/mp4".to_owned()),
size: 78_364_758,
width: Some(1080),
height: Some(1920),
..AttachmentInfo::test(Id::new(id), format!("clip-{id}.mp4"))
}
}
fn youtube_embed() -> EmbedInfo {
EmbedInfo {
color: Some(0xff0000),
provider_name: Some("YouTube".to_owned()),
title: Some("Example Video".to_owned()),
description: Some("A video description".to_owned()),
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_width: Some(480),
thumbnail_height: Some(360),
video_url: Some("https://www.youtube.com/embed/dQw4w9WgXcQ".to_owned()),
..EmbedInfo::test()
}
}
fn forwarded_snapshot(id: u64) -> MessageSnapshotInfo {
MessageSnapshotInfo {
content: Some(format!("forwarded {id}")),
attachments: vec![image_attachment(id)],
..MessageSnapshotInfo::test()
}
}
#[test]
fn image_preview_targets_track_the_visible_message_window() {
let cases = [
(
"a preview clipped by the viewport still counts",
2,
&[1, 2][..],
6,
6,
1,
),
(
"no visible preview row still targets the message",
2,
&[1, 2][..],
5,
5,
1,
),
(
"scrolling moves the target to the visible message",
8,
&[1, 6][..],
6,
7,
6,
),
];
for (label, count, image_ids, view_height, layout_height, expected) in cases {
let mut state = state_with_image_messages(count, image_ids);
state.set_message_view_height(view_height);
let targets = visible_image_preview_targets(&state, layout(layout_height));
assert_eq!(
target_message_ids(&targets),
vec![Id::new(expected)],
"{label}"
);
}
}
#[test]
fn image_preview_targets_resize_every_media_proxy_url_shape() {
let cases = [
(
"https://media.discordapp.net/attachments/691/150/photo.png?ex=abc&is=def&hm=123&format=png&width=4000&height=3000",
"https://media.discordapp.net/attachments/691/150/photo.png?ex=abc&is=def&hm=123&format=webp&width=320&height=240",
),
(
"https://media.discordapp.net/ephemeral-attachments/691/150/photo.png?ex=abc&is=def&hm=123&width=4000&height=3000",
"https://media.discordapp.net/ephemeral-attachments/691/150/photo.png?ex=abc&is=def&hm=123&format=webp&width=320&height=240",
),
];
for (proxy_url, expected) in cases {
let mut state = state_with_image_messages(0, &[]);
let mut attachment = image_attachment(1);
attachment.proxy_url = proxy_url.to_owned();
push_media_message(
&mut state,
MessageCreateFixture {
message_id: Id::new(1),
content: Some("photo".to_owned()),
attachments: vec![attachment],
..guild_message_create_fixture()
},
);
let target = visible_image_preview_targets(&state, layout(12))
.into_iter()
.next()
.expect("image attachment should produce preview target");
assert_eq!(target.url, expected, "{proxy_url}");
}
}