From fcbea738f1d26468a711ad0ed4677882e46ff22d Mon Sep 17 00:00:00 2001 From: Ivan Date: Fri, 24 Apr 2026 14:33:59 -0500 Subject: [PATCH] refactor(frontend): update download functionality and improve time formatting utilities --- meshchatx/src/frontend/js/DownloadUtils.js | 63 ++++++++++++++++++---- meshchatx/src/frontend/js/Utils.js | 63 +++++++++++++++++----- 2 files changed, 102 insertions(+), 24 deletions(-) diff --git a/meshchatx/src/frontend/js/DownloadUtils.js b/meshchatx/src/frontend/js/DownloadUtils.js index 02c3b28..764a28c 100644 --- a/meshchatx/src/frontend/js/DownloadUtils.js +++ b/meshchatx/src/frontend/js/DownloadUtils.js @@ -1,24 +1,65 @@ -class DownloadUtils { - static downloadFile(filename, blob) { - // create object url for blob - const objectUrl = URL.createObjectURL(blob); +function isAndroidSaveBridge() { + return ( + typeof window !== "undefined" && + window.MeshChatXAndroid != null && + typeof window.MeshChatXAndroid.saveDownload === "function" + ); +} - // create hidden link element to download blob +class DownloadUtils { + static _blobToBase64(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => { + const s = reader.result; + if (typeof s !== "string") { + reject(new Error("readAsDataURL failed")); + return; + } + const comma = s.indexOf(","); + resolve(comma >= 0 ? s.slice(comma + 1) : s); + }; + reader.onerror = () => reject(reader.error || new Error("read failed")); + reader.readAsDataURL(blob); + }); + } + + static _triggerBrowserDownload(filename, objectUrl) { const link = document.createElement("a"); link.href = objectUrl; link.download = filename; link.style.display = "none"; document.body.append(link); - - // click link to download file in browser link.click(); - - // link element is no longer needed link.remove(); - - // revoke object url to clear memory setTimeout(() => URL.revokeObjectURL(objectUrl), 10000); } + + static downloadFromBase64(filename, fileBytesBase64) { + if (isAndroidSaveBridge()) { + window.MeshChatXAndroid.saveDownload(filename, fileBytesBase64); + return; + } + const byteCharacters = atob(fileBytesBase64); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray]); + const objectUrl = URL.createObjectURL(blob); + DownloadUtils._triggerBrowserDownload(filename, objectUrl); + } + + static async downloadFile(filename, blob) { + if (isAndroidSaveBridge()) { + const b64 = await DownloadUtils._blobToBase64(blob); + window.MeshChatXAndroid.saveDownload(filename, b64); + return; + } + const objectUrl = URL.createObjectURL(blob); + DownloadUtils._triggerBrowserDownload(filename, objectUrl); + } } export default DownloadUtils; diff --git a/meshchatx/src/frontend/js/Utils.js b/meshchatx/src/frontend/js/Utils.js index 6c5823c..f14684a 100644 --- a/meshchatx/src/frontend/js/Utils.js +++ b/meshchatx/src/frontend/js/Utils.js @@ -46,38 +46,38 @@ class Utils { }; } - static formatSeconds(seconds) { + static formatSecondsWithoutAgo(seconds) { const parsedSeconds = this.parseSeconds(seconds); if (parsedSeconds.days > 0) { if (parsedSeconds.days === 1) { - return "1 day ago"; - } else { - return parsedSeconds.days + " days ago"; + return "1 day"; } + return parsedSeconds.days + " days"; } if (parsedSeconds.hours > 0) { if (parsedSeconds.hours === 1) { - return "1 hour ago"; - } else { - return parsedSeconds.hours + " hours ago"; + return "1 hour"; } + return parsedSeconds.hours + " hours"; } if (parsedSeconds.minutes > 0) { if (parsedSeconds.minutes === 1) { - return "1 min ago"; - } else { - return parsedSeconds.minutes + " mins ago"; + return "1 min"; } + return parsedSeconds.minutes + " mins"; } if (parsedSeconds.seconds <= 1) { - return "a second ago"; - } else { - return parsedSeconds.seconds + " seconds ago"; + return "a second"; } + return parsedSeconds.seconds + " seconds"; + } + + static formatSeconds(seconds) { + return this.formatSecondsWithoutAgo(seconds) + " ago"; } static formatTimeAgo(datetimeString) { @@ -108,11 +108,48 @@ class Utils { return this.formatSeconds(diffSec); } + /** + * Relative age fragment without trailing " ago" / "ago" duplication when used inside i18n + * strings that already add a locale-specific suffix (e.g. "Last synced {time} ago."). + * Sub-minute uses a neutral phrase so English templates avoid "just now ago". + */ + static formatTimeAgoForI18n(datetimeString) { + if (!datetimeString) return "unknown"; + + let dateString = datetimeString; + if (typeof dateString === "string" && !dateString.includes("Z") && !dateString.includes("+")) { + dateString = dateString.replace(" ", "T") + "Z"; + } + + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.round(diffMs / 1000); + + if (diffSec < 60) { + return "less than a minute"; + } + + if (diffSec > 86400) { + return dayjs(date).format("MMM D, h:mm A"); + } + + return this.formatSecondsWithoutAgo(diffSec); + } + static formatSecondsAgo(seconds) { const secondsAgo = Math.round(Date.now() / 1000 - seconds); return this.formatSeconds(secondsAgo); } + static formatSecondsAgoForI18n(seconds) { + const secondsAgo = Math.round(Date.now() / 1000 - seconds); + if (secondsAgo < 60) { + return "less than a minute"; + } + return this.formatSecondsWithoutAgo(secondsAgo); + } + static formatMinutesSeconds(seconds) { const parsedSeconds = this.parseSeconds(seconds); const paddedMinutes = parsedSeconds.minutes.toString().padStart(2, "0");