main
ts 384 lines 16.5 KB
Raw
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 }) )