feat(clipboard): add clipboard utility functions for secure and insecure contexts, better text copy and read capabilities

This commit is contained in:
Ivan
2026-04-27 11:15:35 -05:00
parent b2c620eb20
commit 8fe6752c49
10 changed files with 393 additions and 31 deletions
+3 -26
View File
@@ -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) {
+1
View File
@@ -6,6 +6,7 @@ const globalState = reactive({
authEnabled: false,
authenticated: false,
detailedOutboundSendStatus: false,
messageTimestampGroupingEnabled: true,
unreadConversationsCount: 0,
activeCallTab: "phone",
blockedDestinations: [],
@@ -49,7 +49,7 @@ export default class MarkdownRenderer {
// Inline code
text = text.replace(
/`([^`]+)`/g,
'<code class="bg-black/10 dark:bg-white/10 px-1 rounded font-mono text-[0.9em]">$1</code>'
'<code class="bg-black/10 dark:bg-white/10 px-1 rounded-sm font-mono text-[0.9em]">$1</code>'
);
// Links
@@ -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<boolean>}
*/
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" };
}
}
@@ -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 };
+7 -4
View File
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<string, string | number | boolean | undefined>} [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}`);
}
@@ -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));
}