| 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 { DirEntry, DirList, state, useSnapState } from './state' |
| 4 | import { useEffect, useRef } from 'react' |
| 5 | import { apiCall, apiEvents } from '@hfs/shared/api' |
| 6 | import _ from 'lodash' |
| 7 | import { subscribeKey } from 'valtio/utils' |
| 8 | import { useIsMounted } from 'usehooks-ts' |
| 9 | import { alertDialog } from './dialog' |
| 10 | import { |
| 11 | hfsEvent, LIST, urlParams, xlate, objFromKeys, getHFS, |
| 12 | HTTP_MESSAGES, HTTP_METHOD_NOT_ALLOWED, HTTP_UNAUTHORIZED, |
| 13 | } from './misc' |
| 14 | import { useLocation } from 'wouter' |
| 15 | import { navigate } from './App' |
| 16 | import { closeLoginDialog } from './login' |
| 17 | import { fileShow, getShowComponent } from './show' |
| 18 | import i18n from './i18n' |
| 19 | const { t } = i18n |
| 20 | |
| 21 | export function usePath() { |
| 22 | useLocation() // used just to cause render |
| 23 | return location.pathname // this is encoded, while useLocation returned decoded |
| 24 | } |
| 25 | |
| 26 | // allow links with ?search |
| 27 | let firstListRequest: any |
| 28 | setTimeout(() => {// wait, urlParams is defined at top level |
| 29 | state.remoteSearch = urlParams.search ? { search: urlParams.search } : undefined |
| 30 | firstListRequest = objFromKeys(['onlyFiles', 'onlyFolders'], x => x in urlParams || undefined) |
| 31 | }) |
| 32 | |
| 33 | let autoPlayOnce: string | undefined = urlParams.autoplay // this will be consumed, so to only act once |
| 34 | |
| 35 | export default function useFetchList() { |
| 36 | const snap = useSnapState() |
| 37 | const uri = usePath() // this api can still work removing the initial slash, but then we'll have a mixed situation that will require plugins an extra effort |
| 38 | const {remoteSearch} = snap |
| 39 | const lastUri = useRef('') |
| 40 | const lastParams = useRef<any>() |
| 41 | const lastReloader = useRef(snap.listReloader) |
| 42 | const isMounted = useIsMounted() |
| 43 | const { loginRequired=false } = snap // undefined=false |
| 44 | useEffect(()=>{ |
| 45 | const previous = lastUri.current |
| 46 | lastUri.current = uri |
| 47 | if (previous !== uri) { |
| 48 | state.uri = uri // this should be a better way than uriChanged |
| 49 | state.showFilter = false |
| 50 | state.stopSearch?.() |
| 51 | } |
| 52 | state.searchManuallyInterrupted = false |
| 53 | if (previous && previous !== uri && remoteSearch) { |
| 54 | state.remoteSearch = undefined |
| 55 | return |
| 56 | } |
| 57 | |
| 58 | const params = { uri, ...remoteSearch, ...firstListRequest } |
| 59 | if (snap.listReloader === lastReloader.current && _.isEqual(params, lastParams.current)) return |
| 60 | lastParams.current = params |
| 61 | lastReloader.current = snap.listReloader |
| 62 | |
| 63 | state.list = [] |
| 64 | state.selected = {} |
| 65 | state.loading = true |
| 66 | state.error = undefined |
| 67 | state.props = undefined |
| 68 | // while 'play' needs to wait for the whole list to be available (and sorted) to proceed in order, 'playShuffle' can start right away, and it's important it does because a ?search may take long |
| 69 | let play = false |
| 70 | let playShuffle = false |
| 71 | // buffering entries is necessary against burst of events that will hang the browser |
| 72 | const buffer: DirList = [] |
| 73 | const flush = () => { |
| 74 | const chunk = buffer.splice(0, Infinity) |
| 75 | if (!chunk.length) return |
| 76 | hfsEvent('newListEntries', { entries: chunk }) |
| 77 | state.list = sort([...state.list, ...chunk]) |
| 78 | if (playShuffle) // find first proper file, and play it |
| 79 | for (const x of chunk) |
| 80 | if (getShowComponent(x)) { |
| 81 | fileShow(x, { startPlaying: true, startShuffle: true }) |
| 82 | playShuffle = false |
| 83 | break |
| 84 | } |
| 85 | } |
| 86 | const timer = setInterval(flush, 1000) |
| 87 | const src = apiEvents('get_file_list', params, (type, data) => { |
| 88 | if (!isMounted()) return |
| 89 | switch (type) { |
| 90 | case 'connected': |
| 91 | if (autoPlayOnce === '') play = true |
| 92 | if (autoPlayOnce === 'shuffle') playShuffle = true |
| 93 | firstListRequest = undefined |
| 94 | return |
| 95 | case 'error': |
| 96 | state.stopSearch?.() |
| 97 | state.error = t`connection error` |
| 98 | lastParams.current = null |
| 99 | return |
| 100 | case 'closed': |
| 101 | flush() |
| 102 | state.stopSearch?.() |
| 103 | state.loading = false |
| 104 | if (play) |
| 105 | for (const x of state.list) |
| 106 | if (getShowComponent(x)) { |
| 107 | fileShow(x, { startPlaying: true }) |
| 108 | play = false |
| 109 | break |
| 110 | } |
| 111 | return |
| 112 | case 'msg': |
| 113 | const showLogin = location.hash === '#LOGIN' |
| 114 | if (closeLoginDialog) |
| 115 | location.hash = '' |
| 116 | state.loginRequired = showLogin |
| 117 | for (const entry of data) { |
| 118 | if (!Array.isArray(entry)) continue // unexpected |
| 119 | const [op, par] = entry |
| 120 | const error = op === LIST.error && par |
| 121 | // "method not allowed" happens when we try to directly access an unauthorized file, and we get a login prompt, and then get_file_list the file (because we didn't know it was file or folder) |
| 122 | // it also happens accessing a web-page folder, and the reload is the right solution too. |
| 123 | if (error === HTTP_METHOD_NOT_ALLOWED) { |
| 124 | state.messageOnly = t('download_starting', "Your download should now start") |
| 125 | window.location.reload() // reload will start the download, because now we got authenticated |
| 126 | continue |
| 127 | } |
| 128 | if (error) { |
| 129 | state.stopSearch?.() |
| 130 | state.error = xlate(error, HTTP_MESSAGES) |
| 131 | if (error === HTTP_UNAUTHORIZED && snap.username) |
| 132 | apiCall('refresh_session').then(x => { |
| 133 | if (x.username) // check if username was actually considered (or instead session was refused) |
| 134 | void alertDialog(t('wrong_account', { u: snap.username }, "Account {u} has no access, try another"), 'warning') |
| 135 | }) |
| 136 | state.loginRequired = error === HTTP_UNAUTHORIZED |
| 137 | lastParams.current = null |
| 138 | continue |
| 139 | } |
| 140 | if (uri && !uri.endsWith('/')) // now we know it was a folder for sure |
| 141 | return navigate(uri + '/') |
| 142 | if (op === LIST.props) { |
| 143 | autoPlayOnce = undefined |
| 144 | state.props = par |
| 145 | continue |
| 146 | } |
| 147 | if (op === LIST.add) |
| 148 | buffer.push(new DirEntry(par.n, par)) |
| 149 | } |
| 150 | if (src.readyState === src.CLOSED) |
| 151 | return state.stopSearch?.() |
| 152 | } |
| 153 | }) |
| 154 | state.stopSearch = () => { |
| 155 | state.stopSearch = undefined |
| 156 | buffer.length = 0 |
| 157 | state.loading = false |
| 158 | clearInterval(timer) |
| 159 | src.close() |
| 160 | } |
| 161 | return () => { |
| 162 | state.stopSearch?.() |
| 163 | lastParams.current = null |
| 164 | } |
| 165 | }, [uri, remoteSearch, snap.username, snap.listReloader, loginRequired]) |
| 166 | } |
| 167 | |
| 168 | export function reloadList() { |
| 169 | state.listReloader = Date.now() |
| 170 | } |
| 171 | |
| 172 | getHFS().textSortCompare = new Intl.Collator(navigator.language, { sensitivity: 'base' }).compare // expose it, so that it can be overridden |
| 173 | |
| 174 | function sort(list: DirList) { |
| 175 | const { sort_by, folders_first, sort_numerics } = state |
| 176 | // optimization: precalculate string comparisons |
| 177 | const bySize = sort_by === 'size' |
| 178 | const byExt = sort_by === 'extension' |
| 179 | const byTime = sort_by === 'time' |
| 180 | const byCreation = sort_by === 'creation' |
| 181 | const invert = state.invert_order ? -1 : 1 |
| 182 | const {textSortCompare} = getHFS() |
| 183 | return list.sort((a, b) => |
| 184 | -compareScalar(a.order||0, b.order||0) |
| 185 | || hfsEvent('sortCompare', { a, b }).find(Boolean) |
| 186 | || folders_first && -compareScalar(a.isFolder, b.isFolder) |
| 187 | || invert * (bySize ? compareScalar(a.s||0, b.s||0) |
| 188 | : byExt ? textSortCompare(a.ext, b.ext) |
| 189 | : byTime ? compareScalar(a.m, b.m) |
| 190 | : byCreation ? compareScalar(a.c, b.c) |
| 191 | : 0 |
| 192 | ) |
| 193 | || sort_numerics && (invert * compareNumerics(a.n, b.n)) |
| 194 | || invert * textSortCompare(a.n, b.n) // fallback to name/path |
| 195 | ) |
| 196 | |
| 197 | function compareNumerics(a: string, b: string) { |
| 198 | const re = /\d/g |
| 199 | if (!re.exec(a)) return 0 |
| 200 | const i = re.lastIndex |
| 201 | if (i) { // doesn't start with a number |
| 202 | if (!b.startsWith(a.slice(0, i -1))) return 0 // b is comparable only if it has same leading part |
| 203 | a = a.slice(i-1) |
| 204 | b = b.slice(i-1) |
| 205 | } |
| 206 | return compareScalar(parseFloat(a), parseFloat(b)) |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | function compareScalar(a:any, b:any) { |
| 211 | return a - b |
| 212 | } |
| 213 | |
| 214 | // update list on sorting criteria |
| 215 | const sortAgain = _.debounce(()=> state.list = sort(state.list), 100) |
| 216 | for (const k of [ 'sort_by', 'invert_order', 'folders_first', 'sort_numerics'] as const) |
| 217 | subscribeKey(state, k, sortAgain) |
| 218 | |
| 219 | const updateFilteredList = _.debounce(() => { |
| 220 | const v = state.patternFilter |
| 221 | if (!v) |
| 222 | return state.filteredList = undefined |
| 223 | const filter = new RegExp(_.escapeRegExp(v),'i') |
| 224 | state.filteredList = state.list.filter(x => filter.test(x.n)) |
| 225 | }) |
| 226 | subscribeKey(state, 'list', updateFilteredList) |
| 227 | subscribeKey(state, 'patternFilter', updateFilteredList) |