diff --git a/frontend/discourse/app/lib/lightbox.js b/frontend/discourse/app/lib/lightbox.js
index e2c37ccbbce7d..8f34de5424a9d 100644
--- a/frontend/discourse/app/lib/lightbox.js
+++ b/frontend/discourse/app/lib/lightbox.js
@@ -5,13 +5,14 @@ import { isRailsTesting, isTesting } from "discourse/lib/environment";
import { helperContext } from "discourse/lib/helpers";
import { renderIcon } from "discourse/lib/icon-library";
import { SELECTORS } from "discourse/lib/lightbox/constants";
-import quoteImage, { canQuoteImage } from "discourse/lib/lightbox/quote-image";
+import quoteImage, {
+ canBuildImageQuote,
+} from "discourse/lib/lightbox/quote-image";
import { isDocumentRTL } from "discourse/lib/text-direction";
import {
escapeExpression,
postRNWebviewMessage,
} from "discourse/lib/utilities";
-import User from "discourse/models/user";
import { i18n } from "discourse-i18n";
export async function loadMagnificPopup() {
@@ -27,10 +28,12 @@ export default async function lightbox(
return;
}
+ const currentUser = helperContext()?.currentUser;
const caps = helperContext().capabilities;
const imageClickNavigation = caps.touch;
const canDownload =
- !siteSettings.prevent_anons_from_downloading_files || User.current();
+ !siteSettings.prevent_anons_from_downloading_files || !!currentUser;
+ const canQuoteImage = !!currentUser;
if (siteSettings.experimental_lightbox) {
const { default: PhotoSwipeLightbox } = await import("photoswipe/lightbox");
@@ -204,37 +207,39 @@ export default async function lightbox(
},
});
- lightboxEl.pswp.ui.registerElement({
- name: "quote-image",
- order: 10,
- isButton: true,
- title: i18n("lightbox.quote"),
- html: {
- isCustomSVG: true,
- inner:
- '',
- outlineID: "pswp__icn-quote",
- size: 512,
- },
- onInit: (el, pswp) => {
- pswp.on("change", () => {
- const slideData = pswp.currSlide?.data;
+ if (canQuoteImage) {
+ lightboxEl.pswp.ui.registerElement({
+ name: "quote-image",
+ order: 10,
+ isButton: true,
+ title: i18n("lightbox.quote"),
+ html: {
+ isCustomSVG: true,
+ inner:
+ '',
+ outlineID: "pswp__icn-quote",
+ size: 512,
+ },
+ onInit: (el, pswp) => {
+ pswp.on("change", () => {
+ const slideData = pswp.currSlide?.data;
+ const slideElement = slideData?.element;
+ el.style.display = canBuildImageQuote(slideElement, slideData)
+ ? ""
+ : "none";
+ });
+ },
+ onClick: () => {
+ const slideData = lightboxEl.pswp.currSlide?.data;
const slideElement = slideData?.element;
- el.style.display = canQuoteImage(slideElement, slideData)
- ? ""
- : "none";
- });
- },
- onClick: () => {
- const slideData = lightboxEl.pswp.currSlide?.data;
- const slideElement = slideData?.element;
- quoteImage(slideElement, slideData).then((didQuote) => {
- if (didQuote) {
- lightboxEl.pswp.close();
- }
- });
- },
- });
+ quoteImage(slideElement, slideData).then((didQuote) => {
+ if (didQuote) {
+ lightboxEl.pswp.close();
+ }
+ });
+ },
+ });
+ }
lightboxEl.pswp.ui.registerElement({
name: "custom-counter",
diff --git a/frontend/discourse/app/lib/lightbox/quote-image.js b/frontend/discourse/app/lib/lightbox/quote-image.js
index 5e7a1bde6d7a4..cbabc7fcf9fd6 100644
--- a/frontend/discourse/app/lib/lightbox/quote-image.js
+++ b/frontend/discourse/app/lib/lightbox/quote-image.js
@@ -1,6 +1,9 @@
import { getOwner } from "@ember/owner";
import { helperContext } from "discourse/lib/helpers";
-import { buildImageMarkdown as buildImageMarkdownShared } from "discourse/lib/markdown-image-builder";
+import {
+ buildImageMarkdown as buildImageMarkdownShared,
+ extensionFromUrl,
+} from "discourse/lib/markdown-image-builder";
import { buildQuote } from "discourse/lib/quote";
import Composer from "discourse/models/composer";
import Draft from "discourse/models/draft";
@@ -16,7 +19,11 @@ function buildImageMarkdown(slideElement, slideData) {
// Check for base62 SHA1 to use short upload:// URL format (same as to-markdown.js)
if (slideData.base62SHA1) {
+ const extension = extensionFromUrl(slideData.src);
src = `upload://${slideData.base62SHA1}`;
+ if (extension) {
+ src += `.${extension}`;
+ }
} else {
// Prefer data-orig-src (same as to-markdown.js)
src = slideData.origSrc || slideData.src;
@@ -35,7 +42,14 @@ function buildImageMarkdown(slideElement, slideData) {
});
}
-export function canQuoteImage(slideElement, slideData) {
+/**
+ * Checks if the slide element and data can be used to build a quote.
+ *
+ * @param {Element} slideElement
+ * @param {object} slideData
+ * @returns {boolean}
+ */
+export function canBuildImageQuote(slideElement, slideData) {
return buildImageMarkdown(slideElement, slideData) !== null;
}
diff --git a/frontend/discourse/app/lib/markdown-image-builder.js b/frontend/discourse/app/lib/markdown-image-builder.js
index cd4d6b1d79d97..1e578033ba2ac 100644
--- a/frontend/discourse/app/lib/markdown-image-builder.js
+++ b/frontend/discourse/app/lib/markdown-image-builder.js
@@ -13,6 +13,22 @@ export function sanitizeAlt(text, options = {}) {
return trimmed.replace(/\|/g, "|").replace(/([\\\[\]])/g, "\\$1");
}
+/**
+ * Extracts the extension (without dot) from a URL or path.
+ * Returns null when no extension is present.
+ *
+ * @param {string} url
+ * @returns {string|null}
+ */
+export function extensionFromUrl(url) {
+ if (!url) {
+ return null;
+ }
+
+ const match = url.match(/\.([a-zA-Z0-9]+)(?:\?|$)/);
+ return match ? match[1] : null;
+}
+
export function buildImageMarkdown(imageData) {
const {
src,
diff --git a/frontend/discourse/app/lib/to-markdown.js b/frontend/discourse/app/lib/to-markdown.js
index 83895edbf4c06..440b392b2ec3e 100644
--- a/frontend/discourse/app/lib/to-markdown.js
+++ b/frontend/discourse/app/lib/to-markdown.js
@@ -1,4 +1,7 @@
-import { buildImageMarkdown } from "discourse/lib/markdown-image-builder";
+import {
+ buildImageMarkdown,
+ extensionFromUrl,
+} from "discourse/lib/markdown-image-builder";
const MSO_LIST_CLASSES = [
"MsoListParagraphCxSpFirst",
@@ -365,6 +368,14 @@ export class Tag {
if (base62SHA1) {
href = `upload://${base62SHA1}`;
+ const extension =
+ extensionFromUrl(img.attributes.src) ||
+ extensionFromUrl(attr.href) ||
+ extensionFromUrl(attr["data-download-href"]);
+
+ if (extension) {
+ href += `.${extension}`;
+ }
}
const width = img.attributes.width;
@@ -413,6 +424,13 @@ export class Tag {
const base62SHA1 = attr["data-base62-sha1"];
if (base62SHA1) {
src = `upload://${base62SHA1}`;
+ const extension =
+ extensionFromUrl(attr.src || pAttr.src) ||
+ extensionFromUrl(attr["data-orig-src"]);
+
+ if (extension) {
+ src += `.${extension}`;
+ }
}
if (cssClass?.includes("emoji")) {
diff --git a/frontend/discourse/tests/unit/lib/lightbox/quote-image-test.js b/frontend/discourse/tests/unit/lib/lightbox/quote-image-test.js
index 8ddd61c313015..1191337a77cee 100644
--- a/frontend/discourse/tests/unit/lib/lightbox/quote-image-test.js
+++ b/frontend/discourse/tests/unit/lib/lightbox/quote-image-test.js
@@ -3,8 +3,12 @@ import Service from "@ember/service";
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import sinon from "sinon";
-import quoteImage, { canQuoteImage } from "discourse/lib/lightbox/quote-image";
+import { createHelperContext, helperContext } from "discourse/lib/helpers";
+import quoteImage, {
+ canBuildImageQuote,
+} from "discourse/lib/lightbox/quote-image";
import Draft from "discourse/models/draft";
+import { logIn } from "discourse/tests/helpers/qunit-helpers";
class ComposerStub extends Service {
model = { viewOpen: false };
@@ -27,6 +31,15 @@ module("Unit | Lib | lightbox | quote image", function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
+ this.originalHelperContext = helperContext();
+
+ logIn(this.owner);
+
+ createHelperContext({
+ ...(this.originalHelperContext || {}),
+ currentUser: this.owner.lookup("service:current-user"),
+ });
+
this.owner.unregister("service:composer");
this.owner.unregister("service:app-events");
@@ -55,6 +68,7 @@ module("Unit | Lib | lightbox | quote image", function (hooks) {
hooks.afterEach(function () {
document.querySelectorAll(".topic-post").forEach((el) => el.remove());
this.draftGetStub.restore();
+ createHelperContext(this.originalHelperContext);
});
function buildLightbox(context, overrides = {}) {
@@ -132,12 +146,12 @@ module("Unit | Lib | lightbox | quote image", function (hooks) {
);
});
- test("canQuoteImage only returns true when context and metadata exist", function (assert) {
+ test("canBuildImageQuote only returns true when context and metadata exist", function (assert) {
const invalid = document.createElement("a");
- assert.false(canQuoteImage(invalid, {}));
+ assert.false(canBuildImageQuote(invalid, {}));
const { element, slideData } = buildLightbox(this);
- assert.true(canQuoteImage(element, slideData));
+ assert.true(canBuildImageQuote(element, slideData));
});
test("builds markdown using data-orig-src and dimensions when composer is closed", async function (assert) {
@@ -187,7 +201,7 @@ module("Unit | Lib | lightbox | quote image", function (hooks) {
);
});
- test("uses short upload:// URL when data-base62-sha1 is present", async function (assert) {
+ test("uses short upload:// URL with extension when data-base62-sha1 is present", async function (assert) {
const { element, slideData } = buildLightbox(this, {
base62SHA1: "a4bcwvmLAy8cGHKPUrK4G3AUbt9",
href: "//localhost:4200/uploads/default/original/1X/468eb8aa1f0126f1ce7e7ea7a2f64f25da0b58db.png",
@@ -200,9 +214,9 @@ module("Unit | Lib | lightbox | quote image", function (hooks) {
const quote = this.composer.openCalls[0].quote;
assert.true(
quote.includes(
- ""
+ ""
),
- "uses short upload:// URL format with base62-sha1"
+ "uses short upload:// URL format with base62-sha1 and extension"
);
});
diff --git a/frontend/discourse/tests/unit/lib/to-markdown-test.js b/frontend/discourse/tests/unit/lib/to-markdown-test.js
index 69288e94c6e3f..b40caac4c07de 100644
--- a/frontend/discourse/tests/unit/lib/to-markdown-test.js
+++ b/frontend/discourse/tests/unit/lib/to-markdown-test.js
@@ -189,7 +189,7 @@ module("Unit | Utility | to-markdown", function (hooks) {
html = `
`;
assert.strictEqual(
toMarkdown(html),
- ``
+ ``
);
html = `

`;
@@ -437,7 +437,7 @@ helloWorld();consectetur.`;
sherlock3_sig.jpg5496×3664 2 MB
`;
- markdown = ``;
+ markdown = ``;
assert.strictEqual(toMarkdown(html), markdown);
});