mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-08-29 00:08:19 +00:00
feat(clipboard): add clipboard utility functions for secure and insecure contexts, better text copy and read capabilities
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user