From f5b9d1a1f7bfb9e5f3f401191799102b51b27d81 Mon Sep 17 00:00:00 2001 From: robert Date: Wed, 18 Mar 2026 12:43:50 -0400 Subject: [PATCH] still trying to fix the default onebox snatch. --- .../discourse/initializers/url-to-article.js | 114 ++++++++++++------ 1 file changed, 77 insertions(+), 37 deletions(-) diff --git a/assets/javascripts/discourse/initializers/url-to-article.js b/assets/javascripts/discourse/initializers/url-to-article.js index 8344e23..bf0ff90 100644 --- a/assets/javascripts/discourse/initializers/url-to-article.js +++ b/assets/javascripts/discourse/initializers/url-to-article.js @@ -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);