From b28ee39ac07fdd4bf6de8c37da5500f2356b26da Mon Sep 17 00:00:00 2001 From: John Rayes Date: Tue, 26 Aug 2025 00:15:30 -0700 Subject: [PATCH 01/10] Introduce smc_Request --- Themes/default/scripts/script.js | 331 +++++++++++++++++-------------- Themes/default/scripts/topic.js | 2 +- 2 files changed, 184 insertions(+), 149 deletions(-) diff --git a/Themes/default/scripts/script.js b/Themes/default/scripts/script.js index bc5278d8517..65e86f1f7fe 100644 --- a/Themes/default/scripts/script.js +++ b/Themes/default/scripts/script.js @@ -22,85 +22,138 @@ var is_android = ua.indexOf('android') != -1; var ajax_indicator_ele = null; // Get a response from the server. -function getServerResponse(sUrl, funcCallback, sType, sDataType) -{ +function getServerResponse(sUrl, funcCallback, sType = 'GET', sDataType = 'json') { var oCaller = this; - return oMyDoc = $.ajax({ - type: sType, - url: sUrl, - headers: { - "X-SMF-AJAX": 1 - }, - xhrFields: { - withCredentials: typeof allow_xhjr_credentials !== "undefined" ? allow_xhjr_credentials : false - }, - cache: false, - dataType: sDataType, - success: function(response) { - if (typeof(funcCallback) != 'undefined') - { - funcCallback.call(oCaller, response); - } - }, + return smc_Request.fetch(sUrl, { + method: sType, + cache: 'no-cache' + }) + .then(response => { + if (sDataType === 'json') return response.json(); + + if (sDataType === 'text') return response.text(); + + if (sDataType === 'blob') return response.blob(); + + if (sDataType === 'arrayBuffer') return response.arrayBuffer(); + + return response; + }) + .then(data => { + if (typeof funcCallback !== 'undefined') { + funcCallback.call(oCaller, data); + } + + return data; + }) + .catch(error => { + if (typeof funcCallback !== 'undefined') { + funcCallback.call(oCaller, false); + } + + return Promise.reject(error); }); } +class smc_Request { + static fetch(sUrl, oOptions, iMilliseconds) { + let timeout; + let options = oOptions || {}; + + if (iMilliseconds) { + const controller = new AbortController(); + options.signal = controller.signal; + timeout = setTimeout(() => controller.abort(), iMilliseconds); + } + + if (typeof allow_xhjr_credentials !== "undefined" && allow_xhjr_credentials) { + options.credentials = 'include'; + } + + if (options.headers) { + if (options.headers instanceof Headers) { + options.headers.set("X-SMF-AJAX", 1); + } else { + options.headers["X-SMF-AJAX"] = 1; + } + } else { + options.headers = { + "X-SMF-AJAX": 1 + }; + } + + const promise = fetch(sUrl, options) + .then(res => res.ok ? res : Promise.reject(res)) + .catch(err => Promise.reject(new Error(`Network request failed: ${err.message}`))); + + if (iMilliseconds) { + return promise.finally(() => timeout && clearTimeout(timeout)); + } + + return promise; + } + + static fetchXML(sUrl, oOptions, iMilliseconds) { + return this.fetch(sUrl, oOptions, iMilliseconds) + .then(res => res.text()) + .then(str => new DOMParser().parseFromString(str, "text/xml")); + } +} + // Load an XML document. -function getXMLDocument(sUrl, funcCallback) -{ +function getXMLDocument(sUrl, funcCallback, iMilliseconds) { var oCaller = this; + const promise = smc_Request.fetchXML(sUrl, null, iMilliseconds); + + if (funcCallback) { + return promise + .then(data => { + funcCallback.call(oCaller, data); + return data; + }) + .catch(err => { + funcCallback.call(oCaller, false); + return Promise.reject(err); + }); + } - return $.ajax({ - type: 'GET', - url: sUrl, - headers: { - "X-SMF-AJAX": 1 - }, - xhrFields: { - withCredentials: typeof allow_xhjr_credentials !== "undefined" ? allow_xhjr_credentials : false - }, - cache: false, - dataType: 'xml', - success: function(responseXML) { - if (typeof(funcCallback) != 'undefined') - { - funcCallback.call(oCaller, responseXML); - } - }, - }); + return promise; } // Send a post form to the server. -function sendXMLDocument(sUrl, sContent, funcCallback) -{ +function sendXMLDocument(sUrl, sContent, funcCallback) { var oCaller = this; - var oSendDoc = $.ajax({ - type: 'POST', - url: sUrl, - headers: { - "X-SMF-AJAX": 1 - }, - xhrFields: { - withCredentials: typeof allow_xhjr_credentials !== "undefined" ? allow_xhjr_credentials : false - }, - data: sContent, - beforeSend: function(xhr) { - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - }, - dataType: 'xml', - success: function(responseXML) { - if (typeof(funcCallback) != 'undefined') - { - funcCallback.call(oCaller, responseXML); - } - }, - error: function(jqXHR, textStatus, errorThrown) { - console.error(jqXHR.responseText); - } + + const headers = {}; + if (typeof sContent === 'string' || sContent instanceof URLSearchParams) { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } else if (sContent instanceof Blob) { + headers['Content-Type'] = sContent.type || 'application/octet-stream'; + } else if (!(sContent instanceof FormData)) { + headers['Content-Type'] = 'application/json'; // Default to JSON + sContent = JSON.stringify(sContent); // Convert object to JSON string + } + + const promise = smc_Request.fetchXML(sUrl, { + method: 'POST', + headers, + body: sContent }); - return true; + if (funcCallback) { + return promise + .then(data => { + funcCallback.call(oCaller, data); + return data; + }) + .catch(err => { + funcCallback.call(oCaller, false); + return Promise.reject(err); + }); + } + + return promise; } // Convert a string to an 8 bit representation (like in PHP). @@ -196,76 +249,65 @@ function reqWin(desktopURL, alternateWidth, alternateHeight, noScrollbars) function reqOverlayDiv(desktopURL, sHeader, sIcon) { // Set up our div details - var sAjax_indicator = '
'; - var sHeader = typeof(sHeader) == 'string' ? sHeader : help_popup_heading_text; - - var containerOptions; - if (typeof(sIcon) == 'string' && sIcon.match(/\.(gif|png|jpe?g|svg|bmp|tiff)$/) != null) - containerOptions = {heading: sHeader, content: sAjax_indicator, icon: smf_images_url + '/' + sIcon}; - else - containerOptions = {heading: sHeader, content: sAjax_indicator, icon_class: 'main_icons ' + (typeof(sIcon) != 'string' ? 'help' : sIcon)}; + const sAjax_indicator = '
'; + sHeader = sHeader || help_popup_heading_text; + + let containerOptions; + if (sIcon && sIcon.match(/\.(gif|png|jpe?g|svg|bmp|tiff)$/) != null) { + containerOptions = { heading: sHeader, content: sAjax_indicator, icon: smf_images_url + '/' + sIcon }; + } else { + containerOptions = { heading: sHeader, content: sAjax_indicator, icon_class: 'main_icons ' + (sIcon || 'help') }; + } // Create the div that we are going to load - var oContainer = new smc_Popup(containerOptions); - var oPopup_body = $('#' + oContainer.popup_id).find('.popup_content'); + const oContainer = new smc_Popup(containerOptions); + const oPopup_body = oContainer.cover.querySelector('.popup_content'); // Load the help page content (we just want the text to show) - $.ajax({ - url: desktopURL + (desktopURL.includes('?') ? ';' : '?') + 'ajax', + fetch(desktopURL + (desktopURL.includes('?') ? ';' : '?') + 'ajax', { + method: 'GET', headers: { - 'X-SMF-AJAX': 1 - }, - xhrFields: { - withCredentials: typeof allow_xhjr_credentials !== "undefined" ? allow_xhjr_credentials : false - }, - type: "GET", - dataType: "html", - beforeSend: function () { - }, - success: function (data, textStatus, xhr) { - var help_content = $('
').html(data).find('a[href$="self.close();"]').hide().prev('br').hide().parent().html(); - oPopup_body.html(help_content); + 'X-SMF-AJAX': '1', - if (oPopup_body.find('*:not(:has(*)):visible').text().length > 1200) { - $('#' + oContainer.popup_id).find('.popup_window').addClass('large'); - } - }, - error: function (xhr, textStatus, errorThrown) { - oPopup_body.html(textStatus); + // @fixme This is checked for in SMF\Actions\Login2::checkAjax(). + "X-Requested-With": "XMLHttpRequest" }, - statusCode: { - 403: function(res, status, xhr) { - let errorMsg = res.getResponseHeader('x-smf-errormsg'); - oPopup_body.html(errorMsg ?? banned_text); - }, - 500: function() { - oPopup_body.html('500 Internal Server Error'); - } - } - }); + credentials: typeof allow_xhjr_credentials !== 'undefined' ? 'include' : 'omit' + }) + .then((res, rej) => res.ok ? res.text() : rej(res)) + .then(data => { + oPopup_body.innerHTML = data; + }) + .catch(error => { + const errorMsg = error.headers.get('x-smf-errormsg'); + oPopup_body.innerHTML = errorMsg || error.message || banned_text; + }); + return false; } // Create the popup menus for the top level/user menu area. function smc_PopupMenu(oOptions) { - this.opt = (typeof oOptions == 'object') ? oOptions : {}; + this.opt = oOptions || {}; this.opt.menus = {}; } smc_PopupMenu.prototype.add = function (sItem, sUrl) { - var $menu = $('#' + sItem + '_menu'), $item = $('#' + sItem + '_menu_top'); - if ($item.length == 0) + const menu = document.getElementById(sItem + '_menu'); + const item = document.getElementById(sItem + '_menu_top'); + + if (!item) { return; + } - this.opt.menus[sItem] = {open: false, loaded: false, sUrl: sUrl, itemObj: $item, menuObj: $menu }; + this.opt.menus[sItem] = { open: false, loaded: false, sUrl: sUrl, itemObj: item, menuObj: menu }; - $item.click({obj: this}, function (e) { + item.addEventListener('click', function(e) { e.preventDefault(); - - e.data.obj.toggle(sItem); - }); + this.toggle(sItem); + }.bind(this)); } smc_PopupMenu.prototype.toggle = function (sItem) @@ -280,57 +322,50 @@ smc_PopupMenu.prototype.open = function (sItem) { this.closeAll(); - if (!this.opt.menus[sItem].loaded) - { - this.opt.menus[sItem].menuObj.html('
' + (typeof(ajax_notification_text) != null ? ajax_notification_text : '') + '
'); + if (!this.opt.menus[sItem].loaded) { + this.opt.menus[sItem].menuObj.innerHTML = '
' + (ajax_notification_text || '') + '
'; - $.ajax({ - url: this.opt.menus[sItem].sUrl + (this.opt.menus[sItem].sUrl.includes('?') ? ';' : '?') + 'ajax', + fetch(this.opt.menus[sItem].sUrl + (this.opt.menus[sItem].sUrl.includes('?') ? ';' : '?') + 'ajax', { + method: "GET", headers: { - 'X-SMF-AJAX': 1 - }, - xhrFields: { - withCredentials: typeof allow_xhjr_credentials !== "undefined" ? allow_xhjr_credentials : false + 'X-SMF-AJAX': 1, }, - type: "GET", - dataType: "html", - beforeSend: function () { - }, - context: this.opt.menus[sItem].menuObj, - success: function (data, textStatus, xhr) { - this.html(data); - - if ($(this).hasClass('scrollable')) - $(this).customScrollbar({ - skin: "default-skin", - hScroll: false, - updateOnWindowResize: true - }); + credentials: typeof allow_xhjr_credentials !== "undefined" ? 'include' : 'same-origin', + }) + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); } + return response.text(); + }) + .then(data => { + this.opt.menus[sItem].menuObj.innerHTML = data; + this.opt.menus[sItem].loaded = true; }); - - this.opt.menus[sItem].loaded = true; } - this.opt.menus[sItem].menuObj.addClass('visible'); - this.opt.menus[sItem].itemObj.addClass('open'); + this.opt.menus[sItem].menuObj.classList.add('visible'); + this.opt.menus[sItem].itemObj.classList.add('open'); this.opt.menus[sItem].open = true; // Now set up closing the menu if we click off. - $(document).on('click.menu', {obj: this}, function(e) { - if ($(e.target).closest(e.data.obj.opt.menus[sItem].menuObj.parent()).length) + this.opt.menus[sItem].handleClickOutside = function(e) { + if (e.target.closest('#' + this.opt.menus[sItem].itemObj.id) || e.target.closest('#' + this.opt.menus[sItem].menuObj.id)) { return; - e.data.obj.closeAll(); - $(document).off('click.menu'); - }); + } + + this.closeAll(); + }.bind(this); + + document.addEventListener('click', this.opt.menus[sItem].handleClickOutside); } smc_PopupMenu.prototype.close = function (sItem) { - this.opt.menus[sItem].menuObj.removeClass('visible'); - this.opt.menus[sItem].itemObj.removeClass('open'); + this.opt.menus[sItem].menuObj.classList.remove('visible'); + this.opt.menus[sItem].itemObj.classList.remove('open'); this.opt.menus[sItem].open = false; - $(document).off('click.menu'); + document.removeEventListener('click', this.opt.menus[sItem].handleClickOutside); } smc_PopupMenu.prototype.closeAll = function () diff --git a/Themes/default/scripts/topic.js b/Themes/default/scripts/topic.js index 05279bd6740..2656f6e7b22 100755 --- a/Themes/default/scripts/topic.js +++ b/Themes/default/scripts/topic.js @@ -436,7 +436,7 @@ QuickModify.prototype.modifySave = function (sSessionId, sSessionVar) // Send in the XMLhttp request and let's hope for the best. ajax_indicator(true); - sendXMLDocument.call(this, smf_prepareScriptUrl(this.opt.sScriptUrl) + "action=jsmodify;topic=" + this.opt.iTopicId + ";" + smf_session_var + "=" + smf_session_id + ";xml", formData, this.onModifyDone); + sendXMLDocument.call(this, smf_prepareScriptUrl(this.opt.sScriptUrl) + "action=jsmodify;topic=" + this.opt.iTopicId + ";" + smf_session_var + "=" + smf_session_id + ";xml", new URLSearchParams(formData).toString(), this.onModifyDone); return false; } From 408e781e3e171c52200da9694c695cefcde6d6fc Mon Sep 17 00:00:00 2001 From: John Rayes Date: Thu, 11 Sep 2025 23:45:47 -0700 Subject: [PATCH 02/10] fix the jump to widget --- Themes/default/scripts/script.js | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Themes/default/scripts/script.js b/Themes/default/scripts/script.js index 65e86f1f7fe..45f4870e148 100644 --- a/Themes/default/scripts/script.js +++ b/Themes/default/scripts/script.js @@ -968,24 +968,24 @@ function create_ajax_indicator_ele() // This function will retrieve the contents needed for the jump to boxes. function grabJumpToContent(elem) { - var oXMLDoc = getXMLDocument(smf_prepareScriptUrl(smf_scripturl) + 'action=xmlhttp;sa=jumpto;xml'); - var aBoardsAndCategories = []; - ajax_indicator(true); - oXMLDoc.done(function(data, textStatus, jqXHR){ + getXMLDocument(smf_prepareScriptUrl(smf_scripturl) + 'action=xmlhttp;sa=jumpto;xml', function(oXMLDoc) + { + let aBoardsAndCategories = []; + const items = oXMLDoc.getElementsByTagName('smf')[0].getElementsByTagName('item'); - var items = $(data).find('item'); - items.each(function(i) { - aBoardsAndCategories[i] = { - id: parseInt($(this).attr('id')), - isCategory: $(this).attr('type') == 'category', - name: this.firstChild.nodeValue.removeEntities(), + for (const item of items) + { + aBoardsAndCategories.push({ + id: parseInt(item.getAttribute('id')), + isCategory: item.getAttribute('type') === 'category', + name: item.firstChild.nodeValue.removeEntities(), is_current: false, - isRedirect: parseInt($(this).attr('is_redirect')), - childLevel: parseInt($(this).attr('childlevel')) - } - }); + isRedirect: parseInt(item.getAttribute('is_redirect')), + childLevel: parseInt(item.getAttribute('childlevel')) + }); + } ajax_indicator(false); From 4371ab2ed9a5f813bb95b9d0b4eada93918b9982 Mon Sep 17 00:00:00 2001 From: John Rayes Date: Fri, 12 Sep 2025 15:51:28 -0700 Subject: [PATCH 03/10] Rewrite JumpTo object to not rely on globals --- Themes/default/Display.template.php | 2 +- Themes/default/MessageIndex.template.php | 4 +- Themes/default/Search.template.php | 2 +- Themes/default/scripts/script.js | 362 ++++++++++++++++------- 4 files changed, 265 insertions(+), 105 deletions(-) diff --git a/Themes/default/Display.template.php b/Themes/default/Display.template.php index dd08b88e7ae..627e63014b1 100644 --- a/Themes/default/Display.template.php +++ b/Themes/default/Display.template.php @@ -371,7 +371,7 @@ function template_main() sFormRemoveAccessKeys: \'postmodify\'' : '', ' }); - aJumpTo[aJumpTo.length] = new JumpTo({ + new JumpTo({ sContainerId: "display_jump_to", sJumpToTemplate: "
-
'; +
'; // Now we handle the icons echo ' From 9ece101d190410f3ed8864ac807a0ca497ac6ae5 Mon Sep 17 00:00:00 2001 From: John Rayes Date: Sat, 13 Sep 2025 15:01:15 -0700 Subject: [PATCH 08/10] Defer topic.js --- Sources/Actions/Display.php | 2 +- Sources/Actions/MessageIndex.php | 2 +- Sources/Actions/Search2.php | 2 +- Themes/default/Display.template.php | 115 ++++++++++------------- Themes/default/MessageIndex.template.php | 5 +- Themes/default/Search.template.php | 25 ++--- 6 files changed, 68 insertions(+), 83 deletions(-) diff --git a/Sources/Actions/Display.php b/Sources/Actions/Display.php index 012bce3e701..066877fd028 100644 --- a/Sources/Actions/Display.php +++ b/Sources/Actions/Display.php @@ -1087,7 +1087,7 @@ protected function setupTemplate(): void } // topic.js - Theme::loadJavaScriptFile('topic.js', ['defer' => false, 'minimize' => true], 'smf_topic'); + Theme::loadJavaScriptFile('topic.js', ['defer' => true, 'minimize' => true], 'smf_topic'); // quotedText.js Theme::loadJavaScriptFile('quotedText.js', ['defer' => true, 'minimize' => true], 'smf_quotedText'); diff --git a/Sources/Actions/MessageIndex.php b/Sources/Actions/MessageIndex.php index 100cce1623d..4374d37959a 100644 --- a/Sources/Actions/MessageIndex.php +++ b/Sources/Actions/MessageIndex.php @@ -961,7 +961,7 @@ protected function setupTemplate(): void Theme::loadTemplate('MessageIndex'); // Javascript for inline editing. - Theme::loadJavaScriptFile('topic.js', ['defer' => false, 'minimize' => true], 'smf_topic'); + Theme::loadJavaScriptFile('topic.js', ['defer' => true, 'minimize' => true], 'smf_topic'); // 'Print' the header and board info. Utils::$context['page_title'] = strip_tags(Board::$info->name); diff --git a/Sources/Actions/Search2.php b/Sources/Actions/Search2.php index a40d18dff0e..c3dd66c5a62 100644 --- a/Sources/Actions/Search2.php +++ b/Sources/Actions/Search2.php @@ -432,7 +432,7 @@ protected function setupTemplate(): void } } - Theme::loadJavaScriptFile('topic.js', ['defer' => false, 'minimize' => true], 'smf_topic'); + Theme::loadJavaScriptFile('topic.js', ['defer' => true, 'minimize' => true], 'smf_topic'); SearchApi::$loadedApi->resultsContext(); } diff --git a/Themes/default/Display.template.php b/Themes/default/Display.template.php index 59e9d912a65..1826604d270 100644 --- a/Themes/default/Display.template.php +++ b/Themes/default/Display.template.php @@ -288,63 +288,45 @@ function template_main()
'; echo ' - '; } @@ -1028,21 +1010,22 @@ function insertQuoteFast(messageid) echo ' '; } diff --git a/Themes/default/MessageIndex.template.php b/Themes/default/MessageIndex.template.php index 164f3514621..de29af0fc34 100644 --- a/Themes/default/MessageIndex.template.php +++ b/Themes/default/MessageIndex.template.php @@ -346,7 +346,7 @@ function template_main() // Show breadcrumbs at the bottom too. theme_linktree(); - echo ' + echo ' '; echo ' diff --git a/Themes/default/Search.template.php b/Themes/default/Search.template.php index 5923f28e25c..bf3e87c862e 100644 --- a/Themes/default/Search.template.php +++ b/Themes/default/Search.template.php @@ -460,18 +460,19 @@ function template_results() echo '
'; From 5be80af615755979f37eec2c9473dbd2e847fb67 Mon Sep 17 00:00:00 2001 From: John Rayes Date: Sat, 13 Sep 2025 15:23:39 -0700 Subject: [PATCH 09/10] Apply suggestions from code review --- Themes/default/Display.template.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Themes/default/Display.template.php b/Themes/default/Display.template.php index 1826604d270..c1f38ee0250 100644 --- a/Themes/default/Display.template.php +++ b/Themes/default/Display.template.php @@ -294,13 +294,13 @@ function template_main() if (!empty(Theme::$current->options['display_quick_mod']) && Theme::$current->options['display_quick_mod'] == 1 && Utils::$context['can_remove_post']) { echo ' - var strips = [ + const strips = [ { id: "moderationbuttons", display: "moderationbuttons_strip", varName: "oInTopicModeration" }, { id: "moderationbuttons_mobile", display: "moderationbuttons_strip_mobile", varName: "oInTopicModerationMobile" } ]; - for (var i = 0; i < strips.length; i++) { - var strip = strips[i]; + for (let i = 0; i < strips.length; i++) { + const strip = strips[i]; window[strip.varName] = new InTopicModeration({ sCheckboxContainerMask: "in_topic_mod_check_", aMessageIds: ["', implode('", "', Utils::$context['removableMessageIDs']), '"], From 79af76772780a93a9da73a4f8bae488d812eedb2 Mon Sep 17 00:00:00 2001 From: John Rayes Date: Sat, 13 Sep 2025 21:24:32 -0700 Subject: [PATCH 10/10] fix redirect --- Themes/default/scripts/topic.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Themes/default/scripts/topic.js b/Themes/default/scripts/topic.js index 8475a7c2c16..4fcc6ffa41b 100755 --- a/Themes/default/scripts/topic.js +++ b/Themes/default/scripts/topic.js @@ -113,7 +113,7 @@ class JumpTo { btn.textContent = this.opt.sGoButtonLabel; btn.addEventListener('click', () => { - window.location.href = smf_prepareScriptUrl(smf_scripturl) + 'board=' + this.opt.iCurBoardId + '.0'; + window.location.href = smf_prepareScriptUrl(smf_scripturl) + (this.opt.sUrlPrefix || '') + 'board=' + this.opt.iCurBoardId + '.0'; }); frag.append(' ', btn); @@ -127,14 +127,14 @@ class JumpTo { if (!this.opt.bNoRedirect) { - select.addEventListener('change', function() + select.addEventListener('change', function(self) { const val = this.options[this.selectedIndex].value; if (this.selectedIndex > 0 && val) { - window.location.href = smf_scripturl + (val.startsWith('?') ? val.substring(1) : val); + window.location.href = smf_prepareScriptUrl(smf_scripturl) + (self.opt.sUrlPrefix || '') + (val.startsWith('?') ? val.substring(1) : val); } - }); + }.bind(select, this)); } }