still trying to fix the default onebox snatch.

This commit is contained in:
2026-03-18 12:43:50 -04:00
parent 822a5bcceb
commit f5b9d1a1f7

View File

@@ -12,7 +12,22 @@ export default apiInitializer("1.8.0", (api) => {
}
// -----------------------------------------------------------------------
// Inject a helper button + status banner into the composer
// Intercept Discourse's native title URL lookup at the CLASS level.
// Instance-patching is too late — Discourse's own titleChanged observer
// may fire before ours. This wraps the prototype method so the flag wins
// regardless of observer order.
// -----------------------------------------------------------------------
api.modifyClass("model:composer", {
pluginId: "url-to-article",
_titleLookup() {
if (this._urlToArticleSuppressLookup) return;
return this._super(...arguments);
},
});
// -----------------------------------------------------------------------
// Inject helper button + status banner into the composer
// -----------------------------------------------------------------------
api.modifyClass("component:composer-editor", {
pluginId: "url-to-article",
@@ -28,20 +43,51 @@ export default apiInitializer("1.8.0", (api) => {
},
_setupUrlToArticle() {
// Watch the title field — it lives outside the composer-editor DOM,
// so we observe via the composer model's `title` property.
const composer = this.get("composer");
if (!composer) return;
this._titleObserver = () => this._onTitleChanged();
composer.addObserver("model.title", this, "_titleObserver");
// Intercept paste on the title input BEFORE Ember's data-binding fires,
// so the suppression flag is set before Discourse's titleChanged observer runs.
this._attachTitlePasteListener();
},
_attachTitlePasteListener() {
const tryAttach = (attempts = 0) => {
const input = document.querySelector("#reply-title");
if (input) {
this._titlePasteHandler = (e) => {
const text = (
e.clipboardData || window.clipboardData
).getData("text/plain").trim();
if (URL_REGEX.test(text)) {
const model = this.get("composer.model");
if (model) model._urlToArticleSuppressLookup = true;
}
};
input.addEventListener("paste", this._titlePasteHandler, true);
this._titleInputEl = input;
} else if (attempts < 10) {
setTimeout(() => tryAttach(attempts + 1), 200);
}
};
tryAttach();
},
_teardownUrlToArticle() {
const composer = this.get("composer");
if (!composer) return;
composer.removeObserver("model.title", this, "_titleObserver");
this._restoreNativeTitleLookup();
this._clearSuppression();
if (this._titleInputEl && this._titlePasteHandler) {
this._titleInputEl.removeEventListener(
"paste",
this._titlePasteHandler,
true
);
}
},
_onTitleChanged() {
@@ -49,18 +95,19 @@ export default apiInitializer("1.8.0", (api) => {
const match = title.trim().match(URL_REGEX);
if (!match) {
this._restoreNativeTitleLookup();
this._clearSuppression();
this._hideArticleBar();
return;
}
const url = match[1];
if (this._lastDetectedUrl === url) return; // Same URL — no-op
if (this._lastDetectedUrl === url) return;
this._lastDetectedUrl = url;
// Suppress Discourse's native title lookup so it doesn't race our bar
this._suppressNativeTitleLookup();
// Belt-and-suspenders: ensure suppression is set even if paste listener
// missed it (e.g. URL typed manually rather than pasted).
const model = this.get("composer.model");
if (model) model._urlToArticleSuppressLookup = true;
const autoPopulate = api.container
.lookup("site-settings:main")
@@ -73,26 +120,15 @@ export default apiInitializer("1.8.0", (api) => {
}
},
// ---- Native title-lookup suppression ------------------------------
// ---- Suppression helpers ------------------------------------------
_suppressNativeTitleLookup() {
_clearSuppression() {
const model = this.get("composer.model");
if (!model || this._originalTitleLookup) return;
if (typeof model._titleLookup === "function") {
this._originalTitleLookup = model._titleLookup.bind(model);
model._titleLookup = () => {};
}
if (model) delete model._urlToArticleSuppressLookup;
},
_restoreNativeTitleLookup() {
const model = this.get("composer.model");
if (!model || !this._originalTitleLookup) return;
model._titleLookup = this._originalTitleLookup;
this._originalTitleLookup = null;
},
_triggerNativeTitleLookup() {
this._restoreNativeTitleLookup();
_triggerNativeLookup() {
this._clearSuppression();
const model = this.get("composer.model");
if (model && typeof model._titleLookup === "function") {
model._titleLookup();
@@ -102,7 +138,7 @@ export default apiInitializer("1.8.0", (api) => {
// ---- Bar UI -------------------------------------------------------
_showArticleBar(url) {
this._hideArticleBar(); // remove any existing bar first
this._hideArticleBar();
const bar = document.createElement("div");
bar.className = "url-to-article-bar";
@@ -126,13 +162,13 @@ export default apiInitializer("1.8.0", (api) => {
bar.querySelector(".url-to-article-onebox-btn").addEventListener("click", () => {
this._hideArticleBar();
this._lastDetectedUrl = null;
this._triggerNativeTitleLookup();
this._triggerNativeLookup();
});
bar.querySelector(".url-to-article-dismiss").addEventListener("click", () => {
this._hideArticleBar();
this._restoreNativeTitleLookup();
this._lastDetectedUrl = null; // Allow re-detection if title changes
this._clearSuppression();
this._lastDetectedUrl = null;
});
const toolbarEl = this.element.querySelector(".d-editor-container");
@@ -142,7 +178,9 @@ export default apiInitializer("1.8.0", (api) => {
},
_hideArticleBar() {
this.element?.querySelectorAll(".url-to-article-bar").forEach((el) => el.remove());
this.element
?.querySelectorAll(".url-to-article-bar")
.forEach((el) => el.remove());
},
_setStatus(message, type = "info") {
@@ -182,14 +220,19 @@ export default apiInitializer("1.8.0", (api) => {
}
this._populateComposer(data);
this._restoreNativeTitleLookup();
this._clearSuppression();
this._setStatus(I18n.t("url_to_article.success"), "success");
// Auto-hide bar after 3 seconds on success
setTimeout(() => this._hideArticleBar(), 3000);
} catch (err) {
const msg = err.jqXHR?.responseJSON?.error || err.message || I18n.t("url_to_article.error_generic");
this._setStatus(`${I18n.t("url_to_article.error_prefix")} ${msg}`, "error");
const msg =
err.jqXHR?.responseJSON?.error ||
err.message ||
I18n.t("url_to_article.error_generic");
this._setStatus(
`${I18n.t("url_to_article.error_prefix")} ${msg}`,
"error"
);
if (btn) {
btn.disabled = false;
btn.textContent = I18n.t("url_to_article.retry_button");
@@ -201,10 +244,8 @@ export default apiInitializer("1.8.0", (api) => {
const composerModel = this.get("composer.model");
if (!composerModel) return;
// Build the article body in Markdown
const lines = [];
// Attribution header
const siteName = data.site_name ? `**${data.site_name}**` : "";
const byline = data.byline ? ` — *${data.byline}*` : "";
if (siteName || byline) {
@@ -227,7 +268,6 @@ export default apiInitializer("1.8.0", (api) => {
const body = lines.join("\n");
// Only set title if it's still the raw URL (avoid overwriting edited titles)
const currentTitle = composerModel.get("title") || "";
if (currentTitle.trim() === data.url || currentTitle.trim() === "") {
composerModel.set("title", data.title || data.url);