main
ts 227 lines 9.84 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 { 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)