diff --git a/client/components/LinkPreview.vue b/client/components/LinkPreview.vue
index 9a124ab8..89072e08 100644
--- a/client/components/LinkPreview.vue
+++ b/client/components/LinkPreview.vue
@@ -8,7 +8,11 @@
>
+
+
+ {{ link.filename }}
+
+
+ {{ link.filename }}
-
+
@@ -167,7 +193,9 @@ export default defineComponent({
const showMoreButton = ref(false);
const isContentShown = ref(false);
+ const useWideImageView = ref(false);
const imageViewer = inject(imageViewerKey);
+ const image = ref(null);
onBeforeRouteUpdate((to, from, next) => {
// cancel the navigation if the user is trying to close the image viewer
@@ -217,6 +245,20 @@ export default defineComponent({
}
};
+ const updateWideImageViewDecision = () => {
+ if (window.innerWidth < 480) {
+ // Mobile
+ useWideImageView.value =
+ (image.value && image.value.naturalWidth / image.value.naturalHeight <= 1.34) ||
+ false; // aspect ratio around 4:3 and slimmer
+ } else {
+ // Desktop
+ useWideImageView.value =
+ (image.value && image.value.naturalWidth / image.value.naturalHeight <= 1.6) ||
+ false; // aspect ratio 16:10 and slimmer
+ }
+ };
+
const onPreviewUpdate = () => {
// Don't display previews while they are loading on the server
if (props.link.type === "loading") {
@@ -233,6 +275,8 @@ export default defineComponent({
handleResize();
props.keepScrollPosition();
}
+
+ updateWideImageViewDecision();
};
const onThumbnailError = () => {
@@ -321,8 +365,11 @@ export default defineComponent({
onPreviewUpdate,
showMoreButton,
isContentShown,
+ useWideImageView,
+ image,
content,
container,
+ updateWideImageViewDecision,
};
},
});
diff --git a/client/css/style.css b/client/css/style.css
index 0285ef5a..a0cc98be 100644
--- a/client/css/style.css
+++ b/client/css/style.css
@@ -1624,6 +1624,24 @@ textarea.input {
white-space: normal;
}
+#chat .toggle-content .wide-view {
+ display: flex;
+ flex-direction: row;
+}
+
+#chat .toggle-content .wide-view .image-filename {
+ order: 2;
+}
+
+#chat .toggle-content .wide-view .image-filename span {
+ width: auto;
+ max-width: 500px;
+}
+
+#chat .toggle-content .wide-view img {
+ order: 1;
+}
+
/* This applies to images of preview-type-image and thumbnails of preview-type-link */
#chat .toggle-content img {
max-width: 100%;
@@ -1718,8 +1736,50 @@ textarea.input {
max-width: 100%;
}
+#chat .toggle-type-image > a {
+ position: relative;
+ font-weight: 700;
+ color: inherit;
+}
+
+#chat .toggle-type-image.with-filename .image-filename {
+ padding: 8px 10px 10px;
+ display: flex;
+}
+
+#chat .toggle-type-image .inner-image-filename,
+#chat .toggle-type-audio .inner-audio-filename {
+ width: 0;
+ flex-grow: 1;
+}
+
+#chat .toggle-content.toggle-type-audio.with-filename {
+ padding: 8px 10px 10px;
+}
+
+#chat .toggle-content.toggle-type-audio .audio-filename {
+ font-weight: 700;
+ padding-bottom: 10px;
+ color: inherit;
+ display: flex;
+}
+
+#chat .toggle-type-audio .head {
+ padding-bottom: 10px;
+}
+
#chat .toggle-type-video {
max-width: 640px;
+ position: relative;
+}
+
+#chat .toggle-type-video .video-filename {
+ position: absolute;
+ top: 0;
+ width: 100%;
+ padding: 1em;
+ background: linear-gradient(black, transparent);
+ color: white;
}
#chat video {
diff --git a/server/plugins/irc-events/link.ts b/server/plugins/irc-events/link.ts
index 63a86dc0..a6277025 100644
--- a/server/plugins/irc-events/link.ts
+++ b/server/plugins/irc-events/link.ts
@@ -10,11 +10,14 @@ import storage from "../storage";
import Client from "../../client";
import Chan from "../../models/chan";
import Msg from "../../models/msg";
+import contentDisposition from "content-disposition";
+import path from "path";
type FetchRequest = {
data: Buffer;
type: string;
size: number;
+ filename: string | null;
};
const currentFetchPromises = new Map>();
const imageTypeRegex = /^image\/.+/;
@@ -30,6 +33,7 @@ export type LinkPreview = {
shown?: boolean | null;
error?: string;
message?: string;
+ filename: string | null;
media?: string;
mediaType?: string;
@@ -68,6 +72,7 @@ export default function (client: Client, chan: Chan, msg: Msg, cleanText: string
size: -1,
link: link.link, // Send original matched link to the client
shown: null,
+ filename: null,
};
cleanLinks.push(preview);
@@ -243,6 +248,7 @@ function parse(msg: Msg, chan: Chan, preview: LinkPreview, res: FetchRequest, cl
let promise: Promise | null = null;
preview.size = res.size;
+ preview.filename = res.filename;
switch (res.type) {
case "text/html":
@@ -431,6 +437,7 @@ function fetch(uri: string, headers: Record) {
let contentLength = 0;
let contentType: string | undefined;
let limit = Config.values.prefetchMaxImageSize * 1024;
+ let filename: string | null = null;
try {
const gotStream = got.stream(uri, {
@@ -445,6 +452,17 @@ function fetch(uri: string, headers: Record) {
contentLength = parseInt(res.headers["content-length"], 10) || 0;
contentType = res.headers["content-type"];
+ filename =
+ "content-disposition" in res.headers
+ ? contentDisposition?.parse(res.headers["content-disposition"])
+ .parameters.filename || null
+ : null;
+
+ if (filename === null) {
+ const basename = decodeURI(path.basename(new URL(uri).pathname));
+ filename = basename.indexOf(".") > 0 ? basename : null;
+ }
+
if (contentType && imageTypeRegex.test(contentType)) {
// response is an image
// if Content-Length header reports a size exceeding the prefetch limit, abort fetch
@@ -488,7 +506,7 @@ function fetch(uri: string, headers: Record) {
type = contentType.split(/ *; */).shift() || "";
}
- resolve({data: buffer, type, size});
+ resolve({data: buffer, type, size, filename});
});
} catch (e: any) {
return reject(e);
diff --git a/server/plugins/uploader.ts b/server/plugins/uploader.ts
index 0a5e53a8..04fe659c 100644
--- a/server/plugins/uploader.ts
+++ b/server/plugins/uploader.ts
@@ -20,6 +20,7 @@ const inlineContentDispositionTypes = {
"audio/mpeg": "audio.mp3",
"audio/ogg": "audio.ogg",
"audio/vnd.wave": "audio.wav",
+ "audio/flac": "audio.flac",
"audio/x-flac": "audio.flac",
"audio/x-m4a": "audio.m4a",
"image/bmp": "image.bmp",
@@ -124,6 +125,12 @@ class Uploader {
detectedMimeType = "video/mp4";
}
+ if (detectedMimeType === "audio/x-flac") {
+ // Send a more common mime type for wave audio files
+ // so that browsers can play them correctly
+ detectedMimeType = "audio/flac";
+ }
+
res.setHeader("Content-Disposition", disposition);
res.setHeader("Cache-Control", "max-age=86400");
res.contentType(detectedMimeType);
diff --git a/test/plugins/link.ts b/test/plugins/link.ts
index 26f6070d..1b15cb7a 100644
--- a/test/plugins/link.ts
+++ b/test/plugins/link.ts
@@ -56,6 +56,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
expect(message.previews).to.deep.equal([
{
body: "",
+ filename: null,
head: "",
link: url,
thumb: "",
@@ -93,6 +94,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
expect(message.previews).to.deep.equal([
{
body: "",
+ filename: null,
head: "",
link: url,
thumb: "",
@@ -425,6 +427,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
expect(message.previews).to.eql([
{
body: "",
+ filename: null,
head: "",
link: url_one,
thumb: "",
@@ -434,6 +437,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
},
{
body: "",
+ filename: null,
head: "",
link: url_two,
thumb: "",
@@ -608,6 +612,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
expect(message.previews).to.deep.equal([
{
type: "loading",
+ filename: null,
head: "",
body: "",
thumb: "",