| 1 | // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt |
| 2 | |
| 3 | import React, { createElement as h } from 'react' |
| 4 | import { Btn, iconBtn, Spinner } from './components' |
| 5 | import { newDialog, toast } from './dialog' |
| 6 | import { Icon, IconProps } from './icons' |
| 7 | import { Callback, Dict, domOn, getHFS, getOrSet, Html, HTTP_MESSAGES, prefix, urlParams, useBatch } from '@hfs/shared' |
| 8 | import * as cross from '../../src/cross' |
| 9 | import * as shared from '@hfs/shared' |
| 10 | import { apiCall, getNotifications, useApi } from '@hfs/shared/api' |
| 11 | import { DirEntry, state, useSnapState } from './state' |
| 12 | import * as dialogLib from './dialog' |
| 13 | import _ from 'lodash' |
| 14 | import { reloadList } from './useFetchList' |
| 15 | import { logout } from './login' |
| 16 | import { subscribeKey } from 'valtio/utils' |
| 17 | import { uploadState } from './uploadQueue' |
| 18 | import { fileShow, Video, Audio, getShowComponent } from './show' |
| 19 | import { ANY_LANGUAGE } from '../../src/i18n' |
| 20 | import { debounceAsync } from '../../src/debounceAsync' |
| 21 | export * from '@hfs/shared' |
| 22 | import i18n from './i18n' |
| 23 | const { t, getLangs } = i18n |
| 24 | |
| 25 | export function err2msg(err: number | Error) { |
| 26 | return typeof err === 'number' ? HTTP_MESSAGES[err] |
| 27 | : (HTTP_MESSAGES[(err as any).code] || err.message || String(err)) |
| 28 | } |
| 29 | |
| 30 | export function hIcon(name: string, props?: Omit<IconProps, 'name'>) { |
| 31 | return h(Icon, { name, ...props }) |
| 32 | } |
| 33 | |
| 34 | export function ErrorMsg({ err }: { err: any }) { |
| 35 | return err ? h('div', { className:'error-msg' }, err?.message || _.isString(err) && err || `${t`Error`}:${err}`) |
| 36 | : null |
| 37 | } |
| 38 | |
| 39 | let isWorking = false // we want the 'working' thing to be singleton |
| 40 | export function working() { |
| 41 | if (isWorking) |
| 42 | return ()=>{} // noop |
| 43 | isWorking = true |
| 44 | const { close } = newDialog({ |
| 45 | closable: false, |
| 46 | noFrame: true, |
| 47 | Content: Spinner, |
| 48 | reserveClosing: true, |
| 49 | className: 'working', |
| 50 | onClose(){ |
| 51 | isWorking = false |
| 52 | } |
| 53 | }) |
| 54 | return close |
| 55 | } |
| 56 | |
| 57 | export function hfsEvent(name: string, params?:Dict) { |
| 58 | const output: any[] = [] |
| 59 | const order: number[] = [] |
| 60 | const detail = { params, output, order } |
| 61 | let ev = new CustomEvent('hfs.'+name, { detail, cancelable: true }) |
| 62 | document.dispatchEvent(ev) |
| 63 | if (!ev.defaultPrevented) { |
| 64 | ev = new CustomEvent('hfs.'+name+':after', { detail, cancelable: true }) |
| 65 | document.dispatchEvent(ev) |
| 66 | } |
| 67 | const sortedOutput = order.length && _.sortBy(output.map((x, i) => [order[i] || 0, x]), '0').map(x => x[1]) |
| 68 | return Object.assign(sortedOutput || output, { |
| 69 | isDefaultPrevented: () => ev.defaultPrevented, |
| 70 | }) |
| 71 | } |
| 72 | |
| 73 | type HfsEventCallback = (params:any, extra: { output: any[], setOrder: Callback<number>, preventDefault: Callback }) => any |
| 74 | export function onHfsEvent(pluginId: string, name: string, cb: HfsEventCallback, options?: { once?: boolean }) { |
| 75 | if (!_.isFunction(cb)) return |
| 76 | const key = 'hfs.' + name |
| 77 | document.addEventListener(key, wrapper, options) |
| 78 | return () => document.removeEventListener(key, wrapper) |
| 79 | |
| 80 | function wrapper(ev: Event) { |
| 81 | const { params, output, order } = (ev as CustomEvent).detail |
| 82 | let thisOrder |
| 83 | try { |
| 84 | const res = cb(params, { |
| 85 | output, |
| 86 | setOrder(x) { thisOrder = x }, |
| 87 | preventDefault: () => ev.preventDefault() |
| 88 | }) |
| 89 | if (res === undefined) return |
| 90 | if (Array.isArray(output)) { |
| 91 | output.push(res instanceof Promise ? res.catch(printError) : res) |
| 92 | if (thisOrder) |
| 93 | order[output.length - 1] = thisOrder |
| 94 | } |
| 95 | } |
| 96 | catch(e) { |
| 97 | printError(e) |
| 98 | } |
| 99 | |
| 100 | function printError(e: any) { |
| 101 | console.error(`plugin ${pluginId} on event ${name}: ${e}`) |
| 102 | } |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | export function formatTimestamp(x: number | string | Date, options?: Intl.DateTimeFormatOptions) { |
| 107 | const cached = getOrSet(formatTimestamp as any, 'langs', () => { |
| 108 | const ret = getLangs() |
| 109 | const def = urlParams.lang || navigator.language |
| 110 | return !ret.length || def.startsWith(ret[0]) ? def : ret // eg: if i'm ar-EG, and first translation is ar, keep ar-EG |
| 111 | }) |
| 112 | return !x ? '' : (x instanceof Date ? x : new Date(x)).toLocaleString(cached, options) |
| 113 | } |
| 114 | |
| 115 | import * as thisModule from './misc' |
| 116 | Object.assign(getHFS(), { |
| 117 | h, React, state, t, _, dialogLib, apiCall, useApi, reloadList, logout, Icon, hIcon, iconBtn, useBatch, fileShow, |
| 118 | toast, domOn, getNotifications, debounceAsync, useSnapState, DirEntry, Btn, i18n, |
| 119 | fileShowComponents: { Video, Audio }, |
| 120 | isVideoComponent, markVideoComponent, isAudioComponent, markAudioComponent, |
| 121 | isShowSupported: getShowComponent, |
| 122 | misc: { ...cross, ...shared, ...thisModule }, |
| 123 | emit: hfsEvent, |
| 124 | onEvent: (...args: Parameters<typeof onHfsEvent> extends [any, ...infer R] ? R : []) => |
| 125 | onHfsEvent(getHFS().getPluginKey(true) || '???', ...args), |
| 126 | watchState(k: string, cb: (v: any) => void, callNow=false) { |
| 127 | const up = k.split('upload.')[1] |
| 128 | const thisState = up ? uploadState : state as any |
| 129 | if (callNow) |
| 130 | cb(thisState[k]) |
| 131 | return subscribeKey(thisState, up || k, cb, true) |
| 132 | }, |
| 133 | customRestCall(name: string, ...rest: any[]) { |
| 134 | return apiCall(cross.PLUGIN_CUSTOM_REST_PREFIX + name, ...rest) |
| 135 | }, |
| 136 | html: (html: string) => h(Html, {}, html), |
| 137 | elementToEntry(el: any) { |
| 138 | if (!(el instanceof HTMLElement)) return |
| 139 | const a = el.closest('li')?.querySelector('.link-wrapper a') |
| 140 | if (!(a instanceof HTMLAnchorElement)) return |
| 141 | try { return _.find(state.list, { uri: new URL(a.href).pathname }) } |
| 142 | catch {} |
| 143 | }, |
| 144 | customizeText(mods: Dict<string>, lang=ANY_LANGUAGE) { |
| 145 | const o = i18n.state.translations |
| 146 | if (lang === ANY_LANGUAGE || lang === cross.EMBEDDED_LANGUAGE) // these are missing at the start, but we need them if we want to customize content |
| 147 | o[lang] ??= { translate: {} } |
| 148 | Object.assign(o[lang]?.translate || {}, mods) |
| 149 | } |
| 150 | }) |
| 151 | |
| 152 | markVideoComponent(Video) |
| 153 | markAudioComponent(Audio) |
| 154 | function isVideoComponent(Component: any) { |
| 155 | return Boolean(Component?.hfs_show_video) |
| 156 | } |
| 157 | function markVideoComponent(Component: any) { |
| 158 | Component.hfs_show_video = true |
| 159 | return Component |
| 160 | } |
| 161 | function isAudioComponent(Component: any) { |
| 162 | return Boolean(Component?.hfs_show_audio) |
| 163 | } |
| 164 | function markAudioComponent(Component: any) { |
| 165 | Component.hfs_show_audio = true |
| 166 | return Component |
| 167 | } |
| 168 | |
| 169 | export function operationSuccessful() { |
| 170 | return toast(t`Operation successful`, 'success') |
| 171 | } |