2026-08-10 16:16:30 +00:00
|
|
|
import {readable} from "svelte/store"
|
2026-02-16 22:45:54 +00:00
|
|
|
import {sleep, randomId} from "@welshman/lib"
|
2026-08-29 17:12:09 +00:00
|
|
|
import type {Maybe} from "@welshman/lib"
|
2026-07-08 21:53:49 +00:00
|
|
|
import {Capacitor} from "@capacitor/core"
|
2025-02-04 03:28:29 +00:00
|
|
|
export {preventDefault, stopPropagation} from "svelte/legacy"
|
2024-09-11 18:33:50 +00:00
|
|
|
|
2026-08-10 16:16:30 +00:00
|
|
|
/** Whether the user is actually looking at this tab right now. Display-only concern,
|
|
|
|
|
* kept separate from any data store — consult it wherever "is someone watching" should
|
|
|
|
|
* affect what's rendered. `document.hidden` alone misses window blur: switching to
|
|
|
|
|
* another app without switching tabs leaves visibilityState "visible", so we also track
|
|
|
|
|
* focus and require both. */
|
|
|
|
|
export const documentActive = readable(
|
|
|
|
|
typeof document === "undefined" ? true : !document.hidden && document.hasFocus(),
|
|
|
|
|
set => {
|
|
|
|
|
if (typeof document === "undefined") return
|
|
|
|
|
|
|
|
|
|
const update = () => set(!document.hidden && document.hasFocus())
|
|
|
|
|
|
|
|
|
|
document.addEventListener("visibilitychange", update)
|
|
|
|
|
window.addEventListener("blur", update)
|
|
|
|
|
window.addEventListener("focus", update)
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
document.removeEventListener("visibilitychange", update)
|
|
|
|
|
window.removeEventListener("blur", update)
|
|
|
|
|
window.removeEventListener("focus", update)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-03 21:43:00 +00:00
|
|
|
// Anchors an @svelte-plugins/datepicker popup with fixed positioning so it
|
|
|
|
|
// escapes scroll-container clipping (e.g. inside modals). Call when the picker
|
|
|
|
|
// opens; returns a cleanup function that removes the listeners.
|
|
|
|
|
export const anchorDatepicker = (wrapper: HTMLElement) => {
|
|
|
|
|
const reposition = () => {
|
|
|
|
|
const anchor = wrapper.querySelector("label")
|
|
|
|
|
const container = wrapper.querySelector(".calendars-container") as HTMLElement | null
|
|
|
|
|
|
|
|
|
|
if (!anchor || !container) return
|
|
|
|
|
|
|
|
|
|
const margin = 8
|
|
|
|
|
const rect = anchor.getBoundingClientRect()
|
|
|
|
|
const height = container.offsetHeight
|
|
|
|
|
const width = container.offsetWidth
|
|
|
|
|
|
|
|
|
|
let top = rect.bottom + 4
|
|
|
|
|
|
|
|
|
|
// flip above the input when there's not enough room below, else pin to the bottom edge
|
|
|
|
|
if (height && top + height > innerHeight - margin) {
|
|
|
|
|
top =
|
|
|
|
|
rect.top - height - 4 >= margin
|
|
|
|
|
? rect.top - height - 4
|
|
|
|
|
: Math.max(margin, innerHeight - height - margin)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const left = width
|
|
|
|
|
? Math.max(margin, Math.min(rect.left, innerWidth - width - margin))
|
|
|
|
|
: rect.left
|
|
|
|
|
|
|
|
|
|
wrapper.style.setProperty("--datepicker-container-position", "fixed")
|
|
|
|
|
wrapper.style.setProperty("--datepicker-container-top", `${top}px`)
|
|
|
|
|
wrapper.style.setProperty("--datepicker-container-left", `${left}px`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
reposition()
|
|
|
|
|
addEventListener("scroll", reposition, true)
|
|
|
|
|
addEventListener("resize", reposition)
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
removeEventListener("scroll", reposition, true)
|
|
|
|
|
removeEventListener("resize", reposition)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-08 23:32:46 +00:00
|
|
|
export const copyToClipboard = (text: string) => {
|
|
|
|
|
const {activeElement} = document
|
|
|
|
|
const input = document.createElement("textarea")
|
|
|
|
|
|
|
|
|
|
input.innerHTML = text
|
|
|
|
|
document.body.appendChild(input)
|
|
|
|
|
input.select()
|
|
|
|
|
|
|
|
|
|
const result = document.execCommand("copy")
|
|
|
|
|
|
|
|
|
|
document.body.removeChild(input)
|
|
|
|
|
;(activeElement as HTMLElement).focus()
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}
|
2024-09-11 18:33:50 +00:00
|
|
|
|
2024-11-12 22:32:36 +00:00
|
|
|
export type ScrollerOpts = {
|
2024-10-01 21:55:33 +00:00
|
|
|
onScroll: () => any
|
|
|
|
|
element: Element
|
2024-09-11 18:33:50 +00:00
|
|
|
threshold?: number
|
2025-02-06 00:26:22 +00:00
|
|
|
reverse?: boolean
|
2024-09-24 23:47:36 +00:00
|
|
|
delay?: number
|
2024-09-11 18:33:50 +00:00
|
|
|
}
|
2024-11-12 22:32:36 +00:00
|
|
|
|
|
|
|
|
export type Scroller = {
|
|
|
|
|
check: () => Promise<void>
|
|
|
|
|
stop: () => void
|
|
|
|
|
}
|
2024-09-11 18:33:50 +00:00
|
|
|
|
2024-10-01 21:55:33 +00:00
|
|
|
export const createScroller = ({
|
|
|
|
|
onScroll,
|
|
|
|
|
element,
|
|
|
|
|
delay = 1000,
|
|
|
|
|
threshold = 2000,
|
2025-02-06 00:26:22 +00:00
|
|
|
reverse = false,
|
2024-10-01 21:55:33 +00:00
|
|
|
}: ScrollerOpts) => {
|
2024-09-11 18:33:50 +00:00
|
|
|
let done = false
|
2024-10-11 00:09:01 +00:00
|
|
|
|
2024-10-24 16:57:42 +00:00
|
|
|
const container = element.classList.contains("scroll-container")
|
|
|
|
|
? element
|
|
|
|
|
: element.closest(".scroll-container")
|
2024-09-11 18:33:50 +00:00
|
|
|
|
2024-10-24 16:57:42 +00:00
|
|
|
const check = async () => {
|
2026-07-15 16:30:17 +00:00
|
|
|
const isHidden = (el: Element) => !(el as HTMLElement).offsetParent || el.clientHeight === 0
|
2026-05-28 19:17:17 +00:00
|
|
|
|
|
|
|
|
if (container && !isHidden(container)) {
|
2024-10-17 21:26:53 +00:00
|
|
|
// While we have empty space, fill it
|
|
|
|
|
const {scrollY, innerHeight} = window
|
2026-02-16 20:48:42 +00:00
|
|
|
const {scrollHeight, scrollTop, clientHeight} = container
|
|
|
|
|
const viewHeight = clientHeight || innerHeight
|
2024-10-17 21:26:53 +00:00
|
|
|
const offset = Math.abs(scrollTop || scrollY)
|
2026-02-16 20:48:42 +00:00
|
|
|
const shouldLoad = reverse
|
|
|
|
|
? offset < threshold
|
|
|
|
|
: offset + viewHeight + threshold > scrollHeight
|
2024-10-17 21:26:53 +00:00
|
|
|
|
|
|
|
|
// Only trigger loading the first time we reach the threshold
|
|
|
|
|
if (shouldLoad) {
|
|
|
|
|
await onScroll()
|
|
|
|
|
}
|
2024-09-11 18:33:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// No need to check all that often
|
|
|
|
|
await sleep(delay)
|
|
|
|
|
|
|
|
|
|
if (!done) {
|
|
|
|
|
requestAnimationFrame(check)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
requestAnimationFrame(check)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
check,
|
|
|
|
|
stop: () => {
|
|
|
|
|
done = true
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-10-24 22:48:45 +00:00
|
|
|
|
|
|
|
|
export const isMobile = "ontouchstart" in document.documentElement
|
2025-02-14 18:53:36 +00:00
|
|
|
|
2026-08-29 17:12:09 +00:00
|
|
|
// The layout's single popover host. Cached, because with a couple of popovers per chat row
|
|
|
|
|
// this runs thousands of times against a document that is itself thousands of nodes.
|
|
|
|
|
let tippyTarget: Maybe<Element>
|
|
|
|
|
|
|
|
|
|
export const getTippyTarget = () => {
|
|
|
|
|
if (!tippyTarget?.isConnected) {
|
|
|
|
|
tippyTarget = document.querySelector(".tippy-target")!
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return tippyTarget
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 21:53:49 +00:00
|
|
|
export const downloadText = async (filename: string, text: string) => {
|
|
|
|
|
// The <a download> blob trick is a no-op in native WebViews (Android in
|
|
|
|
|
// particular never triggers a download), so on device we write the file and
|
|
|
|
|
// hand it to the native share sheet, letting the user save it to Files,
|
|
|
|
|
// Drive, a password manager, etc. Cache is the directory our FileProvider
|
|
|
|
|
// (android/app/src/main/res/xml/file_paths.xml) is configured to serve.
|
|
|
|
|
if (Capacitor.isNativePlatform()) {
|
|
|
|
|
const {Filesystem, Directory, Encoding} = await import("@capacitor/filesystem")
|
|
|
|
|
const {Share} = await import("@capacitor/share")
|
|
|
|
|
|
|
|
|
|
const {uri} = await Filesystem.writeFile({
|
|
|
|
|
path: filename,
|
|
|
|
|
data: text,
|
|
|
|
|
directory: Directory.Cache,
|
|
|
|
|
encoding: Encoding.UTF8,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await Share.share({title: filename, files: [uri]})
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-14 18:53:36 +00:00
|
|
|
const blob = new Blob([text], {type: "text/plain"})
|
|
|
|
|
const url = URL.createObjectURL(blob)
|
|
|
|
|
const a = document.createElement("a")
|
|
|
|
|
|
|
|
|
|
a.href = url
|
|
|
|
|
a.download = filename
|
|
|
|
|
document.body.appendChild(a)
|
|
|
|
|
a.click()
|
|
|
|
|
document.body.removeChild(a)
|
|
|
|
|
URL.revokeObjectURL(url)
|
|
|
|
|
}
|
2025-03-20 16:38:57 +00:00
|
|
|
|
|
|
|
|
export const isIntersecting = async (element: Element) =>
|
|
|
|
|
new Promise(resolve => {
|
|
|
|
|
const observer = new IntersectionObserver(xs => {
|
|
|
|
|
resolve(xs.some(x => x.isIntersecting))
|
|
|
|
|
observer.unobserve(element)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
observer.observe(element)
|
|
|
|
|
})
|
|
|
|
|
|
2025-10-20 14:39:53 +00:00
|
|
|
export const compressFile = async (
|
|
|
|
|
file: File | Blob,
|
|
|
|
|
options: Record<string, any> = {},
|
|
|
|
|
): Promise<File> => {
|
2026-02-16 19:34:46 +00:00
|
|
|
const {default: Compressor} = await import("compressorjs-next")
|
2025-09-15 12:40:02 +00:00
|
|
|
|
2025-09-18 19:17:31 +00:00
|
|
|
return new Promise<File>((resolve, _reject) => {
|
2025-09-15 12:40:02 +00:00
|
|
|
new Compressor(file, {
|
2025-09-18 19:17:31 +00:00
|
|
|
maxWidth: 2048,
|
|
|
|
|
maxHeight: 2048,
|
2026-02-16 19:34:46 +00:00
|
|
|
convertTypes: ["image/png"],
|
2025-10-20 14:39:53 +00:00
|
|
|
...options,
|
2026-03-12 20:20:26 +00:00
|
|
|
success: result => {
|
|
|
|
|
// canvas.toBlob() returns a Blob, not a File. Capacitor's fetch interceptor
|
|
|
|
|
// checks instanceof File to handle binary uploads correctly, so we must ensure
|
|
|
|
|
// we always have a real File, not just a Blob with name/lastModified tacked on.
|
|
|
|
|
const f =
|
|
|
|
|
result instanceof File
|
|
|
|
|
? result
|
|
|
|
|
: new File([result], (result as any).name || (file as any).name || "upload", {
|
|
|
|
|
type: result.type,
|
|
|
|
|
})
|
|
|
|
|
resolve(f)
|
|
|
|
|
},
|
2025-09-15 12:40:02 +00:00
|
|
|
error: e => {
|
2025-09-18 19:17:31 +00:00
|
|
|
// Non-images break compressor, return the original file
|
2025-09-15 12:40:02 +00:00
|
|
|
if (e.toString().includes("File or Blob")) {
|
2025-09-18 19:17:31 +00:00
|
|
|
if (file instanceof Blob) {
|
|
|
|
|
file = new File([file], `${randomId()}.${file.type}`, {type: file.type})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return resolve(file as File)
|
2025-09-15 12:40:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_reject(e)
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-02-16 16:16:03 +00:00
|
|
|
|
|
|
|
|
export const escapeHtml = (html: string) => {
|
|
|
|
|
const element = document.createElement("div")
|
|
|
|
|
|
|
|
|
|
element.innerText = html
|
|
|
|
|
|
|
|
|
|
return element.innerHTML
|
|
|
|
|
}
|