| 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 { Link } from 'wouter' |
| 4 | import { navigate } from './App' |
| 5 | import { |
| 6 | createElement as h, Fragment, memo, MouseEvent, useCallback, useEffect, useMemo, useRef, useState, useId |
| 7 | } from 'react' |
| 8 | import { useEventListener, useMediaQuery, useWindowSize } from 'usehooks-ts' |
| 9 | import { |
| 10 | domOn, ErrorMsg, hIcon, onlyTruthy, prefix, isMac, isCtrlKey, hfsEvent, formatTimestamp, restartAnimation, |
| 11 | anyDialogOpen |
| 12 | } from './misc' |
| 13 | import { Checkbox, CustomCode, Bytes, iconBtn, Spinner } from './components' |
| 14 | import { Head } from './Head' |
| 15 | import { DirEntry, state, useSnapState } from './state' |
| 16 | import { alertDialog } from './dialog' |
| 17 | import useFetchList, { usePath } from './useFetchList' |
| 18 | import { useAuthorized } from './login' |
| 19 | import _ from 'lodash' |
| 20 | import { makeOnClickOpen, openFileMenu } from './fileMenu' |
| 21 | import { ClipBar } from './clip' |
| 22 | import { fileShow, getShowComponent } from './show' |
| 23 | import i18n from './i18n' |
| 24 | import { dragFilesSource } from './dragFiles' |
| 25 | import { PAGE_SEPARATOR_CLASS, Paging, scrollIntoView } from './Paging' |
| 26 | const { t, useI18N } = i18n |
| 27 | |
| 28 | export const MISSING_PERM = "Missing permission" |
| 29 | |
| 30 | const originalTitle = document.title |
| 31 | export function BrowseFiles() { |
| 32 | useFetchList() |
| 33 | const { props, tile_size=0, error, title_with_path } = useSnapState() |
| 34 | |
| 35 | const path = usePath() |
| 36 | const title = originalTitle + (title_with_path ? ' ' + decodeURIComponent(path).slice(1, -1) : '') |
| 37 | useEffect(() => { document.title = title }, [title]) |
| 38 | useEffect(() => domOn('popstate', () => { |
| 39 | document.title = '+' // workaround to title not changing after a dialog is closed |
| 40 | document.title = title |
| 41 | }), [title]) |
| 42 | |
| 43 | const propsDropFiles = useMemo(() => ({ |
| 44 | id: 'files-dropper', |
| 45 | }), [props]) |
| 46 | if (!useAuthorized()) |
| 47 | return h(CustomCode, { name: 'unauthorized' }, h('h1', { className: 'unauthorized' }, t`Unauthorized`) ) |
| 48 | return h('div', propsDropFiles, // element dedicated to drop-files to cover full screen |
| 49 | h('div', { |
| 50 | uri: path, // used by UI tests |
| 51 | className: 'list-wrapper ' + (tile_size ? 'tiles-mode' : 'list-mode'), |
| 52 | style: { '--tile-size': tile_size }, |
| 53 | }, |
| 54 | h(CustomCode, { name: 'beforeHeader' }), |
| 55 | h(Head), |
| 56 | h(CustomCode, { name: 'afterHeader' }), |
| 57 | props?.comment && h('div', { className: 'entry-comment' }, props.comment), |
| 58 | error ? h(ErrorMsg, { err: error }) : h(FilesList), |
| 59 | h(CustomCode, { name: 'afterList' }), |
| 60 | h('div', { id: 'afterListFiller', style: { flex: 1 }}), |
| 61 | h(ClipBar), |
| 62 | h(CustomCode, { name: 'footer' }), |
| 63 | ) |
| 64 | ) |
| 65 | } |
| 66 | |
| 67 | function FilesList() { |
| 68 | const snap = useSnapState() |
| 69 | const midnight = useMidnight() // as an optimization, we calculate this only once per list and pass it down |
| 70 | const pageSize = Math.max(1, Math.floor(snap.page_size ?? 100)) |
| 71 | const [offset, setOffset] = useState(0) |
| 72 | const [extraPages, setExtraPages] = useState(0) |
| 73 | const [scrolledPages, setScrolledPages] = useState(0) |
| 74 | const [atBottom, setAtBottom] = useState(false) |
| 75 | const theList = snap.filteredList || snap.list |
| 76 | const total = theList.length |
| 77 | const nPages = Math.ceil(total / pageSize) |
| 78 | const page = Math.floor(offset / pageSize) |
| 79 | const pageEnd = offset + pageSize * (1+extraPages) - 1 |
| 80 | const thisPage = theList.slice(offset, pageEnd + 1) |
| 81 | |
| 82 | useEffect(() => setOffset(0), [theList[0]]) // reset page if the list changes |
| 83 | // reset scrolling if the page changes |
| 84 | useEffect(() => { |
| 85 | document.scrollingElement?.scrollTo(0, 0) |
| 86 | setExtraPages(0) |
| 87 | setScrolledPages(0) |
| 88 | }, [offset]) |
| 89 | |
| 90 | // continuous-scrolling |
| 91 | const calcScrolledPages = useMemo(() => |
| 92 | _.throttle(() => { |
| 93 | const i = _.findLastIndex(document.querySelectorAll('.' + PAGE_SEPARATOR_CLASS), el => |
| 94 | el.getBoundingClientRect().top <= window.innerHeight/2) |
| 95 | setScrolledPages(i + 1) |
| 96 | setAtBottom(window.innerHeight + Math.ceil(window.scrollY) >= document.body.offsetHeight) |
| 97 | }, 200), |
| 98 | []) |
| 99 | const canAddPage = pageEnd < total - 1 |
| 100 | useEffect(() => domOn('scroll', () => { |
| 101 | if (!theList.length) return |
| 102 | const timeToAdd = window.innerHeight * 1.3 + window.scrollY >= document.body.offsetHeight // 30vh before the end |
| 103 | if (timeToAdd && canAddPage) |
| 104 | setExtraPages(extraPages+1) |
| 105 | calcScrolledPages() |
| 106 | }), [page, extraPages, canAddPage]) |
| 107 | // when the list is not filling the screen, but we got more pages, introduce an artificial scrolling (via extra padding) so let the user trigger the continuous-scrolling |
| 108 | const { height: windowHeight } = useWindowSize() |
| 109 | useEffect(() => { |
| 110 | const filler = document.getElementById('afterListFiller') |
| 111 | const wrapper = filler?.closest('.list-wrapper') as HTMLElement | null |
| 112 | if (!filler || !wrapper) return |
| 113 | // when the after-list filler is still stretching, we add a tiny bottom padding so the page can overflow and trigger scrolling |
| 114 | const shouldPad = canAddPage && filler.getBoundingClientRect().height > 0 |
| 115 | wrapper.style.paddingBottom = shouldPad ? '10px' : '' |
| 116 | return () => { wrapper.style.paddingBottom = '' } |
| 117 | }, [canAddPage, windowHeight, total, page, extraPages]) |
| 118 | |
| 119 | // type to focus |
| 120 | const [focus, setFocus] = useState('') |
| 121 | const [focusSkip, setFocusSkip] = useState(0) |
| 122 | useEffect(() => setFocus(''), [theList]) // reset |
| 123 | const focusTypingId = 'focus-typing' |
| 124 | const endReached = () => restartAnimation(document.getElementById(focusTypingId), 'spin .3s') |
| 125 | const timeout = useRef() |
| 126 | useEventListener('keydown', ev => { |
| 127 | if (anyDialogOpen()) return // won't work while dialogs are open |
| 128 | if (isCtrlKey(ev as any) === 'Backspace' && location.pathname > '/') |
| 129 | return navigate(location.pathname + '..') |
| 130 | if (ev.metaKey || ev.ctrlKey || ev.altKey) return |
| 131 | const { key } = ev |
| 132 | if (key === 'Tab' && focusIndex >= 0) { // tab key while we are already focusing will cycle over matching entries |
| 133 | const go = ev.shiftKey ? -1 : 1 |
| 134 | setFocusSkip(was => { |
| 135 | if (go > 0 || was) |
| 136 | return was + go |
| 137 | endReached() |
| 138 | return was |
| 139 | }) // we always try to go forward and skip more, and if no enough matching items are found, we adjust "focusSkip" back |
| 140 | renewTimeout() |
| 141 | ev.preventDefault() |
| 142 | return |
| 143 | } |
| 144 | if (key === ' ' && !focus) return |
| 145 | if ((ev.target as any)?.tagName?.match(/INPUT|TEXTAREA|SELECT/)) return |
| 146 | if (key.length === 1 || key === 'Backspace') |
| 147 | ev.preventDefault() |
| 148 | setFocus(was => { |
| 149 | const will = key === 'Backspace' ? was.slice(0, -1) |
| 150 | : key === 'Escape' ? '' |
| 151 | : key.length === 1 ? was + key.toLocaleLowerCase() |
| 152 | : was |
| 153 | if (will !== was) |
| 154 | renewTimeout() |
| 155 | return will |
| 156 | }) |
| 157 | |
| 158 | function renewTimeout() { |
| 159 | clearTimeout(timeout.current) |
| 160 | timeout.current = setTimeout(() => setFocus(''), 5_000) as any |
| 161 | } |
| 162 | }) |
| 163 | const focusIndex = useMemo(() => { |
| 164 | if (!focus) { |
| 165 | setFocusSkip(0) |
| 166 | return -1 |
| 167 | } |
| 168 | let ret = search(offset) // first attempt within this page |
| 169 | if (offset && (ret < 0 || ret > pageEnd)) |
| 170 | ret = search(0) // search again on the whole list |
| 171 | if (ret >= 0) |
| 172 | setOffset(Math.floor(ret / pageSize) * pageSize) |
| 173 | return ret |
| 174 | |
| 175 | function search(offset: number) { |
| 176 | let prev = offset - 1 |
| 177 | let leftToSkip = focusSkip |
| 178 | while (1) { |
| 179 | const ret = _.findIndex(theList, x => x.name.toLocaleLowerCase().normalize().startsWith(focus), prev + 1) |
| 180 | if (ret < 0) { |
| 181 | if (offset || prev < offset) // this is not our last search, or prev is not a result |
| 182 | return -1 |
| 183 | if (focusSkip) |
| 184 | setFocusSkip(x => x - leftToSkip - 1) // prev is a result, but let's lessen the skip |
| 185 | endReached() |
| 186 | return prev |
| 187 | } |
| 188 | if (! leftToSkip--) |
| 189 | return ret |
| 190 | prev = ret |
| 191 | } |
| 192 | throw 'unreachable' // shut up ts |
| 193 | } |
| 194 | }, [focus, focusSkip]) |
| 195 | useEffect(() => { // wait for possible page-change before focusing |
| 196 | if (focusIndex < 0) return |
| 197 | const e = theList[focusIndex] |
| 198 | if (!e) return |
| 199 | (document.querySelector(`a[href="${e.url || e.uri}"]`) as HTMLElement)?.focus() |
| 200 | }, [focusIndex]) |
| 201 | |
| 202 | const ref = useRef<HTMLElement>() |
| 203 | |
| 204 | const [goBottom, setGoBottom] = useState(false) |
| 205 | useEffect(() => { |
| 206 | if (!goBottom) return |
| 207 | setGoBottom(false) |
| 208 | window.scrollTo(0, document.body.scrollHeight) |
| 209 | }, [goBottom]) |
| 210 | const changePage = useCallback((i: number, pleaseGoBottom?: boolean) => { |
| 211 | setFocus('') |
| 212 | if (pleaseGoBottom) |
| 213 | setGoBottom(true) |
| 214 | if (i < page || i > page + extraPages) |
| 215 | return setOffset(i * pageSize) |
| 216 | i -= page + 1 |
| 217 | const el = i < 0 ? ref.current?.querySelector('*') |
| 218 | : document.querySelectorAll('.' + PAGE_SEPARATOR_CLASS)[i] |
| 219 | scrollIntoView(el, 'center') |
| 220 | }, [page, extraPages, pageSize]) |
| 221 | const changePageToIndex = useCallback((i: number) => { |
| 222 | setFocus('') |
| 223 | // alphabetical paging is entry-anchored, so the chosen group starts at the top instead of at the numeric page boundary |
| 224 | setOffset(i) |
| 225 | }, []) |
| 226 | |
| 227 | const {t} = useI18N() |
| 228 | |
| 229 | const msgInstead = snap.list.length ? snap.filteredList && !snap.filteredList.length && t('filter_none', "No match for this filter") |
| 230 | : !snap.loading && (snap.searchManuallyInterrupted ? t('stopped_before', "Stopped before finding anything") |
| 231 | : t('empty_list', "Nothing here")) |
| 232 | |
| 233 | const focusHint = `${t('focus_hint', "By typing on your keyboard, you search and focus the items in the list.")}\n\nESC: ${t`Cancel`}` |
| 234 | return h(Fragment, {}, |
| 235 | focus && h('div', { id: focusTypingId, className: focusIndex < 0 ? 'focus-typing-mismatch' : '' }, focus, |
| 236 | hIcon('info', { style: { cursor: 'default', marginLeft: '.3em' }, title: focusHint, onClick: () => alertDialog(focusHint) }) ), |
| 237 | h('ul', { ref, className: 'dir' }, |
| 238 | msgInstead ? h('p', {}, msgInstead) |
| 239 | : thisPage.map((entry, idx) => |
| 240 | h(Entry, { |
| 241 | key: entry.key || entry.n, |
| 242 | midnight, |
| 243 | separator: idx > 0 && !(idx % pageSize) ? String(offset + idx) : undefined, |
| 244 | entry, |
| 245 | })), |
| 246 | snap.loading && h(Spinner), |
| 247 | ), |
| 248 | total > pageSize && h(Paging, { |
| 249 | nPages, |
| 250 | current: page + scrolledPages, |
| 251 | atBottom, |
| 252 | pageSize, |
| 253 | list: theList as DirEntry[], |
| 254 | changePage, |
| 255 | changePageToIndex, |
| 256 | }) |
| 257 | ) |
| 258 | } |
| 259 | |
| 260 | export function useMidnight() { |
| 261 | const [midnight, setMidnight] = useState(calcMidnight) |
| 262 | useEffect(() => { |
| 263 | setTimeout(()=> setMidnight(calcMidnight()), 10 * 60_000) // refresh every 10 minutes |
| 264 | }, []) |
| 265 | return midnight |
| 266 | |
| 267 | function calcMidnight() { |
| 268 | const recent = new Date() |
| 269 | recent.setHours(recent.getHours() - 6) |
| 270 | const midnight = new Date() |
| 271 | midnight.setHours(0,0,0,0) |
| 272 | return recent < midnight ? recent : midnight |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | interface EntryProps { entry: DirEntry, midnight: Date, separator?: string } |
| 277 | const Entry = ({ entry, midnight, separator }: EntryProps) => { |
| 278 | const { uri, isFolder, name, n } = entry |
| 279 | const { showFilter, selected, file_menu_on_link } = useSnapState() |
| 280 | const isLink = Boolean(entry.url) |
| 281 | let className = isFolder ? 'folder' : 'file' |
| 282 | if (entry.cantOpen) |
| 283 | className += ' cant-open' |
| 284 | if (separator) |
| 285 | className += ' ' + PAGE_SEPARATOR_CLASS |
| 286 | const hasHover = useMediaQuery('(hover: hover)') |
| 287 | const showingButton = !file_menu_on_link || isFolder && !hasHover |
| 288 | const ariaId = useId() |
| 289 | const commonProps = { |
| 290 | id: ariaId, |
| 291 | 'aria-label': prefix(name + ', ', isFolder ? t`Folder` : entry.web ? t`Web page` : isLink ? t`Link` : ''), |
| 292 | ...dragFilesSource(entry), |
| 293 | onClick: !isFolder && !isLink && !entry.web && file_menu_on_link && fileMenu || makeOnClickOpen(entry), |
| 294 | children: h(Fragment, {}, |
| 295 | getEntryIcon(entry), |
| 296 | h('span', { className: 'container-folder' }, n.slice(0, -name.length).replaceAll('/', '/ ')), |
| 297 | h('span', { className: 'entry-name' }, name) |
| 298 | ) |
| 299 | } |
| 300 | return h(CustomCode, { |
| 301 | name: 'entry', |
| 302 | entry, |
| 303 | render: x => x ? h('li', { className, label: separator }, x) : _.remove(state.list, { n }) && null // custom-code wants us to skip this entry |
| 304 | }, |
| 305 | showFilter && h(Checkbox, { |
| 306 | disabled: !entry.canSelect(), |
| 307 | 'aria-labelledby': ariaId, |
| 308 | value: selected[uri] || false, |
| 309 | onChange(v) { |
| 310 | if (hfsEvent('entryToggleSelection', { entry }).isDefaultPrevented()) return |
| 311 | if (v) |
| 312 | return state.selected[uri] = true |
| 313 | delete state.selected[uri] |
| 314 | }, |
| 315 | }), |
| 316 | h('span', { className: 'link-wrapper' }, // container to handle mouse over for both children |
| 317 | // we treat webpages as folders, with menu to comment |
| 318 | !isFolder ? h('a', { href: entry.url || uri, ...commonProps, target: entry.target, rel: entry.target && 'noopener noreferrer' }) |
| 319 | : h(Fragment, {}, |
| 320 | // without reloadDocument, once you enter the web page, the back button won't bring you back to the frontend |
| 321 | h(entry.web ? 'a' : Link, { href: uri, ...commonProps }), // Link = internal navigation |
| 322 | // popup button is here to be able to detect link-wrapper:hover |
| 323 | file_menu_on_link && !showingButton && h('button', { |
| 324 | className: 'popup-menu-button', |
| 325 | onClick: fileMenu |
| 326 | }, hIcon('menu'), t`Menu`) |
| 327 | ), |
| 328 | ), |
| 329 | h(CustomCode, { name: 'afterEntryName', entry }), |
| 330 | entry.comment && h('div', { className: 'entry-comment' }, entry.comment), |
| 331 | h('div', { className: 'entry-panel' }, |
| 332 | h(EntryDetails, { entry, midnight }), |
| 333 | showingButton && !isLink && iconBtn('menu', fileMenu, { className: 'file-menu-button' }), |
| 334 | ), |
| 335 | h('div'), |
| 336 | ) |
| 337 | |
| 338 | function fileMenu(ev: MouseEvent) { |
| 339 | // meta on link is standard on mac to open in new tab, while we use it on Windows where it is the only key that's not used on links |
| 340 | if (ev.altKey || ev.ctrlKey || isMac && ev.metaKey) return |
| 341 | ev.preventDefault() |
| 342 | const special = isMac ? ev.shiftKey : ev.metaKey |
| 343 | if (special && getShowComponent(entry)) |
| 344 | return fileShow(entry, { startPlaying: true }) |
| 345 | void openFileMenu(entry, ev, onlyTruthy([ |
| 346 | file_menu_on_link && 'open', |
| 347 | 'delete', |
| 348 | 'show' |
| 349 | ])) |
| 350 | } |
| 351 | |
| 352 | } |
| 353 | |
| 354 | export function getEntryIcon(entry: DirEntry) { |
| 355 | return h(CustomCode, { name: 'entryIcon', entry }, entry.getDefaultIcon()) |
| 356 | } |
| 357 | |
| 358 | export const EntryDetails = memo(({ entry, midnight }: { entry: DirEntry, midnight: Date }) => { |
| 359 | const { sort_by } = useSnapState() |
| 360 | const time = sort_by === 'creation' ? entry.c : entry.m |
| 361 | const today = time && time > midnight |
| 362 | const shortTs = useWindowSize().width < 800 |
| 363 | const {t} = useI18N() |
| 364 | const dd = '2-digit' |
| 365 | return h('div', { className: 'entry-details' }, |
| 366 | h(CustomCode, { name: 'additionalEntryDetails', entry }), |
| 367 | entry.cantOpen && hIcon(entry.cantOpen === DirEntry.FORBIDDEN ? 'lock' : 'password', { className: 'miss-perm', title: t(MISSING_PERM) }), |
| 368 | h(EntrySize, { s: entry.s }), |
| 369 | time && h('span', { |
| 370 | className: 'entry-ts', |
| 371 | 'aria-hidden': true, |
| 372 | onClick() { // mobile has no hover |
| 373 | if (shortTs) |
| 374 | void alertDialog(t`Full timestamp:` + "\n" + formatTimestamp(time)) |
| 375 | } |
| 376 | }, formatTimestamp(time, { |
| 377 | ...!shortTs || !today ? { year: shortTs ? dd : 'numeric', month: dd, day: dd } : null, |
| 378 | ...!shortTs || today ? { hour: dd, minute: dd } : null, |
| 379 | })), |
| 380 | ) |
| 381 | }) |
| 382 | |
| 383 | const EntrySize = memo(({ s }: { s: DirEntry['s'] }) => |
| 384 | s === undefined ? null : h(Bytes, { className: 'entry-size', bytes: s }) ) |