diff --git a/meshchatx/src/frontend/js/ElectronUtils.js b/meshchatx/src/frontend/js/ElectronUtils.js index 4361e4a..4b32db8 100644 --- a/meshchatx/src/frontend/js/ElectronUtils.js +++ b/meshchatx/src/frontend/js/ElectronUtils.js @@ -1,3 +1,5 @@ +import { copyTextToClipboard as copyTextToClipboardWeb } from "./clipboardUtils.js"; + class ElectronUtils { static isElectron() { return window.electron != null; @@ -50,32 +52,7 @@ class ElectronUtils { } static async copyTextToClipboard(text) { - if (text == null || text === "") { - return false; - } - const s = String(text); - try { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(s); - return true; - } - } catch { - // fall through to execCommand - } - try { - const ta = document.createElement("textarea"); - ta.value = s; - ta.setAttribute("readonly", ""); - ta.style.position = "fixed"; - ta.style.left = "-9999px"; - document.body.appendChild(ta); - ta.select(); - const ok = document.execCommand("copy"); - document.body.removeChild(ta); - return ok; - } catch { - return false; - } + return copyTextToClipboardWeb(text); } static async revealPathInFolderOrCopy(path, onCopiedWeb) { diff --git a/meshchatx/src/frontend/js/GlobalState.js b/meshchatx/src/frontend/js/GlobalState.js index c1f66a8..34a3a35 100644 --- a/meshchatx/src/frontend/js/GlobalState.js +++ b/meshchatx/src/frontend/js/GlobalState.js @@ -6,6 +6,7 @@ const globalState = reactive({ authEnabled: false, authenticated: false, detailedOutboundSendStatus: false, + messageTimestampGroupingEnabled: true, unreadConversationsCount: 0, activeCallTab: "phone", blockedDestinations: [], diff --git a/meshchatx/src/frontend/js/MarkdownRenderer.js b/meshchatx/src/frontend/js/MarkdownRenderer.js index 236da8f..f5f9023 100644 --- a/meshchatx/src/frontend/js/MarkdownRenderer.js +++ b/meshchatx/src/frontend/js/MarkdownRenderer.js @@ -49,7 +49,7 @@ export default class MarkdownRenderer { // Inline code text = text.replace( /`([^`]+)`/g, - '$1' + '$1' ); // Links diff --git a/meshchatx/src/frontend/js/clipboardUtils.js b/meshchatx/src/frontend/js/clipboardUtils.js new file mode 100644 index 0000000..bcd3870 --- /dev/null +++ b/meshchatx/src/frontend/js/clipboardUtils.js @@ -0,0 +1,82 @@ +/** + * Clipboard helpers for browsers without a secure context (e.g. http://0.0.0.0:8000) + * where navigator.clipboard may be missing or reject. + */ + +/** + * Browsers set `false` on http://0.0.0.0 and similar; `undefined` in some test envs is treated as allowed. + * @returns {boolean} + */ +export function isWindowSecureContext() { + if (typeof window === "undefined") { + return false; + } + return window.isSecureContext !== false; +} + +/** + * Whether async clipboard read is expected to work (secure context + API present). + * @returns {boolean} + */ +export function canUseAsyncClipboardRead() { + return ( + typeof navigator !== "undefined" && + !!navigator.clipboard && + typeof navigator.clipboard.readText === "function" && + isWindowSecureContext() + ); +} + +/** + * @param {string} text + * @returns {Promise} + */ +export async function copyTextToClipboard(text) { + if (text == null || text === "") { + return false; + } + const s = String(text); + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(s); + return true; + } catch { + // fall through to execCommand + } + } + try { + const ta = document.createElement("textarea"); + ta.value = s; + ta.setAttribute("readonly", ""); + ta.setAttribute("aria-hidden", "true"); + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + ta.style.top = "0"; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + return ok; + } catch { + return false; + } +} + +/** + * @returns {Promise<{ ok: true, text: string } | { ok: false, code: string }>} + */ +export async function readTextFromClipboard() { + if (typeof navigator === "undefined" || !navigator.clipboard?.readText) { + return { ok: false, code: "unavailable" }; + } + if (!isWindowSecureContext()) { + return { ok: false, code: "insecure_context" }; + } + try { + const text = await navigator.clipboard.readText(); + return { ok: true, text: text ?? "" }; + } catch { + return { ok: false, code: "denied" }; + } +} diff --git a/meshchatx/src/frontend/js/localMessageRetention.js b/meshchatx/src/frontend/js/localMessageRetention.js new file mode 100644 index 0000000..441c3c3 --- /dev/null +++ b/meshchatx/src/frontend/js/localMessageRetention.js @@ -0,0 +1,20 @@ +const UNIT_DAYS = "days"; +const UNIT_MONTHS = "months"; + +const MAX_DAYS = 10_000; +const MAX_MONTHS = 120; + +/** + * @param {unknown} value + * @param {unknown} unit + * @returns {{ value: number, unit: string }} + */ +export function normalizeRetentionValue(value, unit) { + const u = String(unit) === UNIT_MONTHS ? UNIT_MONTHS : UNIT_DAYS; + const cap = u === UNIT_MONTHS ? MAX_MONTHS : MAX_DAYS; + const n = Number(value); + const v = Number.isFinite(n) ? Math.trunc(n) : 1; + return { value: Math.min(Math.max(1, v), cap), unit: u }; +} + +export { MAX_DAYS as MAX_RETENTION_DAYS, MAX_MONTHS as MAX_RETENTION_MONTHS, UNIT_DAYS, UNIT_MONTHS }; diff --git a/meshchatx/src/frontend/js/lxmfReactions.js b/meshchatx/src/frontend/js/lxmfReactions.js index 3de0d41..7526796 100644 --- a/meshchatx/src/frontend/js/lxmfReactions.js +++ b/meshchatx/src/frontend/js/lxmfReactions.js @@ -60,8 +60,11 @@ export function lxmfConversationListPreview(msg, { myLxmfAddressHash, peerDispla } export function mergeLxmfReactionRowsIntoMessages(messages) { - if (!Array.isArray(messages) || messages.length === 0) { - return messages; + if (!Array.isArray(messages)) { + return []; + } + if (messages.length === 0) { + return []; } const parents = []; const reactions = []; @@ -75,13 +78,13 @@ export function mergeLxmfReactionRowsIntoMessages(messages) { parents.push({ ...m, reactions: [] }); } } - const byHash = new Map(parents.map((p) => [p.hash, p])); + const byHash = new Map(parents.map((p) => [String(p.hash || "").toLowerCase(), p])); for (const r of reactions) { const targetId = r.reaction_to; if (!targetId) { continue; } - const parent = byHash.get(targetId); + const parent = byHash.get(String(targetId).toLowerCase()); if (!parent) { continue; } diff --git a/meshchatx/src/frontend/js/messageDisplayLimits.js b/meshchatx/src/frontend/js/messageDisplayLimits.js new file mode 100644 index 0000000..81b21aa --- /dev/null +++ b/meshchatx/src/frontend/js/messageDisplayLimits.js @@ -0,0 +1,13 @@ +/** + * Inline message body and raw modal use this limit so huge LXMF bodies + * do not lock the UI (markdown/DOM cost). Copy still sends full text. + */ +export const MESSAGE_BODY_MAX_DISPLAY_CHARS = 32000; + +/** + * @param {unknown} content + * @returns {boolean} + */ +export function isStringTooLargeForInlineDisplay(content) { + return typeof content === "string" && content.length > MESSAGE_BODY_MAX_DISPLAY_CHARS; +} diff --git a/meshchatx/src/frontend/js/messageTimestampGrouping.js b/meshchatx/src/frontend/js/messageTimestampGrouping.js new file mode 100644 index 0000000..07726e4 --- /dev/null +++ b/meshchatx/src/frontend/js/messageTimestampGrouping.js @@ -0,0 +1,127 @@ +/** Gap after which the next message starts a new time cluster (ms). */ +export const TIMESTAMP_CLUSTER_GAP_MS = 5 * 60 * 1000; + +/** + * @param {unknown} datetimeString + * @returns {Date | null} + */ +export function parseMessageDate(datetimeString) { + if (!datetimeString) { + return null; + } + let dateString = String(datetimeString); + if (!dateString.includes("Z") && !dateString.includes("+")) { + dateString = dateString.replace(" ", "T") + "Z"; + } + const date = new Date(dateString); + return Number.isNaN(date.getTime()) ? null : date; +} + +/** + * Local calendar day key for grouping. + * @param {Date} d + * @returns {string | null} + */ +export function calendarDayKeyFromDate(d) { + if (!d || Number.isNaN(d.getTime())) { + return null; + } + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +/** + * @param {unknown} group + * @returns {{ min: number, max: number }} + */ +export function displayGroupSortBoundsMs(group) { + if (!group || typeof group !== "object") { + return { min: 0, max: 0 }; + } + if (group.type === "single") { + const t = parseMessageDate(group.chatItem?.lxmf_message?.created_at)?.getTime() ?? 0; + return { min: t, max: t }; + } + if (group.type === "imageGroup" && Array.isArray(group.items) && group.items.length > 0) { + let minT = Infinity; + let maxT = -Infinity; + for (const it of group.items) { + const t = parseMessageDate(it?.lxmf_message?.created_at)?.getTime(); + if (t && !Number.isNaN(t)) { + minT = Math.min(minT, t); + maxT = Math.max(maxT, t); + } + } + if (minT === Infinity) { + return { min: 0, max: 0 }; + } + return { min: minT, max: maxT }; + } + return { min: 0, max: 0 }; +} + +/** + * @param {unknown} group + * @returns {boolean} + */ +export function displayGroupIsOutbound(group) { + if (group?.type === "single") { + return !!group.chatItem?.is_outbound; + } + if (group?.type === "imageGroup" && group.items?.[0]) { + return !!group.items[0].is_outbound; + } + return false; +} + +/** + * Inserts date dividers and sets `showTimestamp` on each message row (true on the + * chronologically last message of each cluster, i.e. the bubble that should show the time). + * @param {unknown[]} groupsOldestFirst + * @param {{ groupingEnabled?: boolean }} [options] + * @returns {unknown[]} + */ +export function buildTimestampGroupedOldestFirst(groupsOldestFirst, options = {}) { + const groupingEnabled = options.groupingEnabled !== false; + if (!groupsOldestFirst?.length) { + return []; + } + const onlyMsg = groupsOldestFirst.filter((g) => g && (g.type === "single" || g.type === "imageGroup")); + if (!groupingEnabled) { + return onlyMsg.map((g) => ({ ...g, showTimestamp: true })); + } + const showFlags = []; + for (let i = 0; i < onlyMsg.length; i++) { + const g = onlyMsg[i]; + const next = onlyMsg[i + 1]; + let show = true; + if (next) { + const cb = displayGroupSortBoundsMs(g); + const nb = displayGroupSortBoundsMs(next); + const sameSide = displayGroupIsOutbound(g) === displayGroupIsOutbound(next); + const gap = nb.min - cb.max; + show = !sameSide || gap >= TIMESTAMP_CLUSTER_GAP_MS || gap < 0; + } + showFlags.push(show); + } + + const out = []; + let prevDayKey = null; + for (let i = 0; i < onlyMsg.length; i++) { + const g = onlyMsg[i]; + const bounds = displayGroupSortBoundsMs(g); + const dayKey = bounds.min ? calendarDayKeyFromDate(new Date(bounds.min)) : null; + if (dayKey && dayKey !== prevDayKey) { + out.push({ + type: "dateDivider", + dayKey, + key: `date-div-${dayKey}-${out.length}`, + }); + prevDayKey = dayKey; + } + out.push({ ...g, showTimestamp: showFlags[i] }); + } + return out; +} diff --git a/meshchatx/src/frontend/js/reticulumPathfinding.js b/meshchatx/src/frontend/js/reticulumPathfinding.js new file mode 100644 index 0000000..9180655 --- /dev/null +++ b/meshchatx/src/frontend/js/reticulumPathfinding.js @@ -0,0 +1,63 @@ +const destinationPath = (hash) => `/api/v1/destination/${hash}/path`; + +/** + * @param {import("axios").AxiosInstance} api + * @param {string} hash + * @param {{ request?: "0" | "1" | boolean, timeout?: number } & Record} [params] + */ +export function getDestinationPath(api, hash, params) { + const q = { ...params }; + if (q.request === true) { + q.request = "1"; + } else if (q.request === false) { + q.request = "0"; + } + return api.get(destinationPath(hash), { params: q }); +} + +export function postRequestPath(api, hash) { + return api.post(`/api/v1/destination/${hash}/request-path`); +} + +export function postDropPath(api, hash) { + return api.post(`/api/v1/destination/${hash}/drop-path`); +} + +/** + * @typedef {"quick" | "force" | "drop_then_request"} PathFinderMode + */ + +/** + * @param {import("axios").AxiosInstance} api + * @param {string} hash + * @param {PathFinderMode} mode + * @param {{ forceTimeout?: number, onDropPathError?: (e: unknown) => void }} [options] + */ +export async function runDestinationPathFinder(api, hash, mode, options) { + const forceTimeout = options?.forceTimeout ?? 15; + if (mode === "quick") { + await postRequestPath(api, hash); + return { ok: true, path: null }; + } + if (mode === "force") { + const res = await getDestinationPath(api, hash, { + request: "1", + timeout: forceTimeout, + }); + return { ok: true, path: res.data?.path ?? null }; + } + if (mode === "drop_then_request") { + try { + await postDropPath(api, hash); + } catch (e) { + if (options?.onDropPathError) { + options.onDropPathError(e); + } else { + console.warn("drop-path failed (continuing)", e); + } + } + await postRequestPath(api, hash); + return { ok: true, path: null }; + } + throw new Error(`unknown path finder mode: ${mode}`); +} diff --git a/meshchatx/src/frontend/js/settingsSearchUtils.js b/meshchatx/src/frontend/js/settingsSearchUtils.js new file mode 100644 index 0000000..a27fbe7 --- /dev/null +++ b/meshchatx/src/frontend/js/settingsSearchUtils.js @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: 0BSD AND MIT + +const ZW_RE = /[\u200B-\u200D\uFEFF]/g; + +/** + * @param {unknown} raw + * @returns {string} + */ +export function normalizeSearchString(raw) { + if (raw == null) return ""; + const s = String(raw).replace(ZW_RE, ""); + return s.trim(); +} + +/** + * Lowercase, strip combining marks for loose matching, normalize sharp s for German keyboards. + * @param {string} str + * @returns {string} + */ +export function foldForSearch(str) { + if (!str) return ""; + let out = String(str).toLowerCase(); + try { + out = out.normalize("NFD").replace(/\p{M}/gu, ""); + } catch { + // Unicode property escapes unsupported in very old runtimes + } + return out.replace(/\u00df/g, "ss"); +} + +/** + * @param {string} normalizedTrimmed + * @returns {string[]} + */ +export function tokenizeSettingsQuery(normalizedTrimmed) { + if (!normalizedTrimmed) return []; + return normalizedTrimmed + .toLowerCase() + .split(/\s+/) + .map((t) => foldForSearch(t)) + .filter((t) => t.length > 0); +} + +/** + * @param {string} text + * @param {(key: string) => string} translateFn + * @returns {string} + */ +function resolveSnippet(text, translateFn) { + if (!text) return ""; + const s = String(text); + const content = s.includes(".") ? translateFn(s) : s; + return foldForSearch(content); +} + +/** + * Settings section search: empty query shows all; otherwise every whitespace-separated + * token must appear somewhere in the combined (translated) keyword haystack. + * + * @param {string[]} texts raw strings or i18n keys (keys contain a dot) + * @param {(key: string) => string} translateFn + * @param {string} rawQuery + * @returns {boolean} + */ +export function matchesSettingSearch(texts, translateFn, rawQuery) { + const normalized = normalizeSearchString(rawQuery); + if (!normalized) return true; + const tokens = tokenizeSettingsQuery(normalized); + if (!tokens.length) return true; + const haystack = texts + .map((t) => resolveSnippet(t, translateFn)) + .filter(Boolean) + .join(" "); + if (!haystack) return false; + return tokens.every((tok) => haystack.includes(tok)); +}