mirror of
https://github.com/austinried/subtracks.git
synced 2026-02-10 15:02:42 +01:00
React Query refactor (#91)
* initial react-query experiments * use queries for item screens send the data we do have over routing to prepopulate (album/playlist) use number for starred because sending Date freaks out react-navigation * add in equiv. song cover art fix * reorg, switch artistview over start mapping song cover art when any are available * refactor useStar to queries fix caching for starred items and album cover art * add hook to reset queries on server change * refactor search to use query * fix song cover art setting * use query for artistInfo * remove last bits of library state * cleanup * use query key factory already fixed one wrong key... * require coverart size * let's try no promise queues on these for now * image cache uses query * perf fix for playlist parsing also use placeholder data so we don't have to deal with staleness * drill that disabled also list controls doesn't need its own songs hook/copy * switch to react-native-blob-util for downloads slightly slower but allows us to use DownloadManager, which backgrounds downloads so they are no longer corrupted when the app suspends * add a fake "top songs" based on artist search then sorted by play count/ratings artistview should load now even if topSongs fails * try not to swap between topSongs/search on refetch set queueContext by song list so the index isn't off if the list changes * add content type validation for file fetching also try to speed up existing file return by limiting fs ops * if the HEAD fails, don't queue the download * clean up params * reimpl clear image cache * precompute contextId prevents wrong "is playing" when any mismatch between queue and list * clear images from all servers use external files dir instead of cache * fix pressable disabled flicker don't retry topsongs on failure try to optimize setqueue and fixcoverart a bit * wait for queries during clear * break out fetchExistingFile from fetchFile allows to tell if file is coming from disk or not only show placeholder/loading spinner if actually fetching image * forgot these wouldn't do anything with objects * remove query cache when switching servers * add content-disposition extension gathering add support for progress hook (needs native support still) * added custom RNBU pkg with progress changes * fully unmount tabs when server changes prevents unwanted requests, gives fresh start on switch fix fixCoverArt not re-rendering in certain cases on search * use serverId from fetch deps * fix lint * update licenses * just use the whole lodash package * make using cache buster optional
This commit is contained in:
@@ -1,81 +0,0 @@
|
||||
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
|
||||
import { Store, useStore, useStoreDeep } from '@app/state/store'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
const useFileRequest = (key: CacheItemTypeKey, id: string) => {
|
||||
const file = useStore(
|
||||
useCallback(
|
||||
(store: Store) => {
|
||||
const activeServerId = store.settings.activeServerId
|
||||
if (!activeServerId) {
|
||||
return
|
||||
}
|
||||
|
||||
return store.cacheFiles[activeServerId][key][id]
|
||||
},
|
||||
[key, id],
|
||||
),
|
||||
)
|
||||
const request = useStore(
|
||||
useCallback(
|
||||
(store: Store) => {
|
||||
const activeServerId = store.settings.activeServerId
|
||||
if (!activeServerId) {
|
||||
return
|
||||
}
|
||||
|
||||
return store.cacheRequests[activeServerId][key][id]
|
||||
},
|
||||
[key, id],
|
||||
),
|
||||
)
|
||||
|
||||
return { file, request }
|
||||
}
|
||||
|
||||
export const useCoverArtFile = (coverArt = '-1', size: CacheImageSize = 'thumbnail') => {
|
||||
const type: CacheItemTypeKey = size === 'original' ? 'coverArt' : 'coverArtThumb'
|
||||
const { file, request } = useFileRequest(type, coverArt)
|
||||
const client = useStore(store => store.client)
|
||||
const cacheItem = useStore(store => store.cacheItem)
|
||||
|
||||
useEffect(() => {
|
||||
if (!file && client) {
|
||||
cacheItem(type, coverArt, () =>
|
||||
client.getCoverArtUri({
|
||||
id: coverArt,
|
||||
size: type === 'coverArtThumb' ? '256' : undefined,
|
||||
}),
|
||||
)
|
||||
}
|
||||
// intentionally leaving file out so it doesn't re-render if the request fails
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cacheItem, client, coverArt, type])
|
||||
|
||||
return { file, request }
|
||||
}
|
||||
|
||||
export const useArtistArtFile = (artistId: string, size: CacheImageSize = 'thumbnail') => {
|
||||
const type: CacheItemTypeKey = size === 'original' ? 'artistArt' : 'artistArtThumb'
|
||||
const fetchArtistInfo = useStore(store => store.fetchArtistInfo)
|
||||
const artistInfo = useStoreDeep(store => store.library.artistInfo[artistId])
|
||||
const { file, request } = useFileRequest(type, artistId)
|
||||
const cacheItem = useStore(store => store.cacheItem)
|
||||
|
||||
useEffect(() => {
|
||||
if (!artistInfo) {
|
||||
fetchArtistInfo(artistId)
|
||||
return
|
||||
}
|
||||
|
||||
if (!file && artistInfo) {
|
||||
cacheItem(type, artistId, async () => {
|
||||
return type === 'artistArtThumb' ? artistInfo?.smallImageUrl : artistInfo?.largeImageUrl
|
||||
})
|
||||
}
|
||||
// intentionally leaving file out so it doesn't re-render if the request fails
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [artistId, cacheItem, fetchArtistInfo, type, artistInfo])
|
||||
|
||||
return { file, request }
|
||||
}
|
||||
305
app/hooks/fetch.ts
Normal file
305
app/hooks/fetch.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { CacheItemTypeKey } from '@app/models/cache'
|
||||
import { Album, AlbumCoverArt, Playlist, Song } from '@app/models/library'
|
||||
import { mapAlbum, mapArtist, mapArtistInfo, mapPlaylist, mapSong } from '@app/models/map'
|
||||
import queryClient from '@app/queryClient'
|
||||
import { useStore } from '@app/state/store'
|
||||
import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
|
||||
import { cacheDir } from '@app/util/fs'
|
||||
import { mapCollectionById } from '@app/util/state'
|
||||
import userAgent from '@app/util/userAgent'
|
||||
import cd from 'content-disposition'
|
||||
import mime from 'mime-types'
|
||||
import path from 'path'
|
||||
import ReactNativeBlobUtil, { FetchBlobResponse } from 'react-native-blob-util'
|
||||
import RNFS from 'react-native-fs'
|
||||
import qk from './queryKeys'
|
||||
|
||||
export const useClient = () => {
|
||||
const client = useStore(store => store.client)
|
||||
|
||||
return () => {
|
||||
if (!client) {
|
||||
throw new Error('no client!')
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
}
|
||||
|
||||
function cacheStarredData<T extends { id: string; starred?: undefined | any }>(item: T) {
|
||||
queryClient.setQueryData<boolean>(qk.starredItems(item.id), !!item.starred)
|
||||
}
|
||||
|
||||
function cacheAlbumCoverArtData<T extends { id: string; coverArt?: string }>(item: T) {
|
||||
queryClient.setQueryData<AlbumCoverArt>(qk.albumCoverArt(item.id), { albumId: item.id, coverArt: item.coverArt })
|
||||
}
|
||||
|
||||
export const useFetchArtists = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async () => {
|
||||
const res = await client().getArtists()
|
||||
|
||||
res.data.artists.forEach(cacheStarredData)
|
||||
|
||||
return mapCollectionById(res.data.artists, mapArtist)
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchArtist = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (id: string) => {
|
||||
const res = await client().getArtist({ id })
|
||||
|
||||
cacheStarredData(res.data.artist)
|
||||
res.data.albums.forEach(cacheStarredData)
|
||||
|
||||
res.data.albums.forEach(cacheAlbumCoverArtData)
|
||||
|
||||
return {
|
||||
artist: mapArtist(res.data.artist),
|
||||
albums: res.data.albums.map(mapAlbum),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchArtistInfo = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (id: string) => {
|
||||
const res = await client().getArtistInfo2({ id })
|
||||
return mapArtistInfo(id, res.data.artistInfo)
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchArtistTopSongs = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (artistName: string) => {
|
||||
const res = await client().getTopSongs({ artist: artistName })
|
||||
|
||||
res.data.songs.forEach(cacheStarredData)
|
||||
|
||||
return res.data.songs.map(mapSong)
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchPlaylists = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async () => {
|
||||
const res = await client().getPlaylists()
|
||||
return mapCollectionById(res.data.playlists, mapPlaylist)
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchPlaylist = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (id: string): Promise<{ playlist: Playlist; songs?: Song[] }> => {
|
||||
const res = await client().getPlaylist({ id })
|
||||
|
||||
res.data.playlist.songs.forEach(cacheStarredData)
|
||||
|
||||
return {
|
||||
playlist: mapPlaylist(res.data.playlist),
|
||||
songs: res.data.playlist.songs.map(mapSong),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchAlbum = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (id: string): Promise<{ album: Album; songs?: Song[] }> => {
|
||||
const res = await client().getAlbum({ id })
|
||||
|
||||
cacheStarredData(res.data.album)
|
||||
res.data.songs.forEach(cacheStarredData)
|
||||
|
||||
cacheAlbumCoverArtData(res.data.album)
|
||||
|
||||
return {
|
||||
album: mapAlbum(res.data.album),
|
||||
songs: res.data.songs.map(mapSong),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchAlbumList = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (size: number, offset: number, type: GetAlbumList2TypeBase) => {
|
||||
const res = await client().getAlbumList2({ size, offset, type })
|
||||
|
||||
res.data.albums.forEach(cacheStarredData)
|
||||
|
||||
res.data.albums.forEach(cacheAlbumCoverArtData)
|
||||
|
||||
return res.data.albums.map(mapAlbum)
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchSong = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (id: string) => {
|
||||
const res = await client().getSong({ id })
|
||||
|
||||
cacheStarredData(res.data.song)
|
||||
|
||||
return mapSong(res.data.song)
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchSearchResults = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (params: Search3Params) => {
|
||||
const res = await client().search3(params)
|
||||
|
||||
res.data.artists.forEach(cacheStarredData)
|
||||
res.data.albums.forEach(cacheStarredData)
|
||||
res.data.songs.forEach(cacheStarredData)
|
||||
|
||||
res.data.albums.forEach(cacheAlbumCoverArtData)
|
||||
|
||||
return {
|
||||
artists: res.data.artists.map(mapArtist),
|
||||
albums: res.data.albums.map(mapAlbum),
|
||||
songs: res.data.songs.map(mapSong),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchStar = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (params: StarParams) => {
|
||||
await client().star(params)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchUnstar = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (params: StarParams) => {
|
||||
await client().unstar(params)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export type FetchExisingFileOptions = {
|
||||
itemType: CacheItemTypeKey
|
||||
itemId: string
|
||||
}
|
||||
|
||||
export const useFetchExistingFile: () => (options: FetchExisingFileOptions) => Promise<string | undefined> = () => {
|
||||
const serverId = useStore(store => store.settings.activeServerId)
|
||||
|
||||
return async ({ itemType, itemId }) => {
|
||||
const fileDir = cacheDir(serverId, itemType, itemId)
|
||||
try {
|
||||
const dir = await RNFS.readDir(fileDir)
|
||||
console.log('existing file:', dir[0].path)
|
||||
return dir[0].path
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function assertMimeType(expected?: string, actual?: string) {
|
||||
expected = expected?.toLowerCase()
|
||||
actual = actual?.toLowerCase()
|
||||
|
||||
if (!expected || expected === actual) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!expected.includes(';')) {
|
||||
actual = actual?.split(';')[0]
|
||||
}
|
||||
|
||||
if (!expected.includes('/')) {
|
||||
actual = actual?.split('/')[0]
|
||||
}
|
||||
|
||||
if (expected !== actual) {
|
||||
throw new Error(`Request does not satisfy expected content type. Expected: ${expected} Actual: ${actual}`)
|
||||
}
|
||||
}
|
||||
|
||||
export type FetchFileOptions = FetchExisingFileOptions & {
|
||||
fromUrl: string
|
||||
useCacheBuster?: boolean
|
||||
expectedContentType?: string
|
||||
progress?: (received: number, total: number) => void
|
||||
}
|
||||
|
||||
export const useFetchFile: () => (options: FetchFileOptions) => Promise<string> = () => {
|
||||
const serverId = useStore(store => store.settings.activeServerId)
|
||||
|
||||
return async ({ itemType, itemId, fromUrl, useCacheBuster, expectedContentType, progress }) => {
|
||||
useCacheBuster = useCacheBuster === undefined ? true : useCacheBuster
|
||||
|
||||
const fileDir = cacheDir(serverId, itemType, itemId)
|
||||
const filePathNoExt = path.join(fileDir, useCacheBuster ? useStore.getState().settings.cacheBuster : itemType)
|
||||
|
||||
try {
|
||||
await RNFS.unlink(fileDir)
|
||||
} catch {}
|
||||
|
||||
const headers = { 'User-Agent': userAgent }
|
||||
|
||||
// we send a HEAD first for two reasons:
|
||||
// 1. to follow any redirects and get the actual URL (DownloadManager does not support redirects)
|
||||
// 2. to obtain the mime-type up front so we can use it for the file extension/validation
|
||||
const headRes = await fetch(fromUrl, { method: 'HEAD', headers })
|
||||
|
||||
if (headRes.status > 399) {
|
||||
throw new Error(`HTTP status error ${headRes.status}. File: ${itemType} ID: ${itemId}`)
|
||||
}
|
||||
|
||||
const contentType = headRes.headers.get('content-type') || undefined
|
||||
assertMimeType(expectedContentType, contentType)
|
||||
|
||||
const contentDisposition = headRes.headers.get('content-disposition') || undefined
|
||||
const filename = contentDisposition ? cd.parse(contentDisposition).parameters.filename : undefined
|
||||
|
||||
let extension: string | undefined
|
||||
if (filename) {
|
||||
extension = path.extname(filename) || undefined
|
||||
if (extension) {
|
||||
extension = extension.substring(1)
|
||||
}
|
||||
} else if (contentType) {
|
||||
extension = mime.extension(contentType) || undefined
|
||||
}
|
||||
|
||||
const config = ReactNativeBlobUtil.config({
|
||||
addAndroidDownloads: {
|
||||
useDownloadManager: true,
|
||||
notification: false,
|
||||
mime: contentType,
|
||||
description: 'subtracks',
|
||||
path: extension ? `${filePathNoExt}.${extension}` : filePathNoExt,
|
||||
},
|
||||
})
|
||||
|
||||
const fetchParams: Parameters<typeof config['fetch']> = ['GET', headRes.url, headers]
|
||||
|
||||
let res: FetchBlobResponse
|
||||
if (progress) {
|
||||
res = await config.fetch(...fetchParams).progress(progress)
|
||||
} else {
|
||||
res = await config.fetch(...fetchParams)
|
||||
}
|
||||
|
||||
const downloadPath = res.path()
|
||||
queryClient.setQueryData<string>(qk.existingFiles(itemType, itemId), downloadPath)
|
||||
|
||||
console.log('downloaded file:', downloadPath)
|
||||
return downloadPath
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useStore } from '@app/state/store'
|
||||
import { StarParams } from '@app/subsonic/params'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
type StarrableItem = 'album' | 'artist' | 'song'
|
||||
|
||||
function starParams(id: string, type: StarrableItem): StarParams {
|
||||
const params: StarParams = {}
|
||||
if (type === 'album') {
|
||||
params.albumId = id
|
||||
} else if (type === 'artist') {
|
||||
params.artistId = id
|
||||
} else {
|
||||
params.id = id
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
export const useStar = (id: string, type: StarrableItem) => {
|
||||
const fetchAlbum = useStore(store => store.fetchAlbum)
|
||||
const fetchArtist = useStore(store => store.fetchArtist)
|
||||
const fetchSong = useStore(store => store.fetchSong)
|
||||
|
||||
const _starred = useStore(
|
||||
useCallback(
|
||||
store => {
|
||||
if (type === 'album') {
|
||||
return store.library.albums[id] ? !!store.library.albums[id].starred : null
|
||||
} else if (type === 'artist') {
|
||||
return store.library.artists[id] ? !!store.library.artists[id].starred : null
|
||||
} else {
|
||||
return store.library.songs[id] ? !!store.library.songs[id].starred : null
|
||||
}
|
||||
},
|
||||
[id, type],
|
||||
),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (_starred === null) {
|
||||
if (type === 'album') {
|
||||
fetchAlbum(id)
|
||||
} else if (type === 'artist') {
|
||||
fetchArtist(id)
|
||||
} else {
|
||||
fetchSong(id)
|
||||
}
|
||||
}
|
||||
}, [fetchAlbum, fetchArtist, fetchSong, id, _starred, type])
|
||||
|
||||
const starred = !!_starred
|
||||
|
||||
const _star = useStore(store => store.star)
|
||||
const _unstar = useStore(store => store.unstar)
|
||||
|
||||
const star = useCallback(() => _star(starParams(id, type)), [_star, id, type])
|
||||
const unstar = useCallback(() => _unstar(starParams(id, type)), [_unstar, id, type])
|
||||
|
||||
const toggleStar = useCallback(() => (starred ? unstar() : star()), [star, starred, unstar])
|
||||
|
||||
return { star, unstar, toggleStar, starred }
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useActiveServerRefresh } from './settings'
|
||||
|
||||
export const useFetchList = <T>(fetchList: () => Promise<T[]>) => {
|
||||
const [list, setList] = useState<T[]>([])
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshing(true)
|
||||
|
||||
fetchList().then(items => {
|
||||
setList(items)
|
||||
setRefreshing(false)
|
||||
})
|
||||
}, [fetchList])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setList([])
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
useActiveServerRefresh(
|
||||
useCallback(() => {
|
||||
reset()
|
||||
}, [reset]),
|
||||
)
|
||||
|
||||
return { list, refreshing, refresh, reset }
|
||||
}
|
||||
|
||||
export const useFetchList2 = (fetchList: () => Promise<void>) => {
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await fetchList()
|
||||
setRefreshing(false)
|
||||
}, [fetchList])
|
||||
|
||||
useActiveServerRefresh(
|
||||
useCallback(async () => {
|
||||
await refresh()
|
||||
}, [refresh]),
|
||||
)
|
||||
|
||||
return { refreshing, refresh }
|
||||
}
|
||||
|
||||
export const useFetchPaginatedList = <T>(
|
||||
fetchList: (size: number, offset: number) => Promise<T[]>,
|
||||
pageSize: number,
|
||||
) => {
|
||||
const [list, setList] = useState<T[]>([])
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [offset, setOffset] = useState(0)
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setOffset(0)
|
||||
setRefreshing(true)
|
||||
|
||||
fetchList(pageSize, 0).then(firstPage => {
|
||||
setList(firstPage)
|
||||
setRefreshing(false)
|
||||
})
|
||||
}, [fetchList, pageSize])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setList([])
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
useActiveServerRefresh(
|
||||
useCallback(() => {
|
||||
refresh()
|
||||
}, [refresh]),
|
||||
)
|
||||
|
||||
const fetchNextPage = useCallback(() => {
|
||||
const newOffset = offset + pageSize
|
||||
setRefreshing(true)
|
||||
|
||||
fetchList(pageSize, newOffset).then(nextPage => {
|
||||
setRefreshing(false)
|
||||
|
||||
if (nextPage.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setList([...list, ...nextPage])
|
||||
setOffset(newOffset)
|
||||
})
|
||||
}, [offset, pageSize, fetchList, list])
|
||||
|
||||
return { list, refreshing, refresh, reset, fetchNextPage }
|
||||
}
|
||||
397
app/hooks/query.ts
Normal file
397
app/hooks/query.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
|
||||
import { Album, AlbumCoverArt, Artist, Playlist, Song, StarrableItemType } from '@app/models/library'
|
||||
import { CollectionById } from '@app/models/state'
|
||||
import queryClient from '@app/queryClient'
|
||||
import { useStore } from '@app/state/store'
|
||||
import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
|
||||
import _ from 'lodash'
|
||||
import {
|
||||
InfiniteData,
|
||||
useInfiniteQuery,
|
||||
UseInfiniteQueryResult,
|
||||
useMutation,
|
||||
useQueries,
|
||||
useQuery,
|
||||
UseQueryResult,
|
||||
} from 'react-query'
|
||||
import {
|
||||
useFetchAlbum,
|
||||
useFetchAlbumList,
|
||||
useFetchArtist,
|
||||
useFetchArtistInfo,
|
||||
useFetchArtists,
|
||||
useFetchArtistTopSongs,
|
||||
useFetchExistingFile,
|
||||
useFetchFile,
|
||||
useFetchPlaylist,
|
||||
useFetchPlaylists,
|
||||
useFetchSearchResults,
|
||||
useFetchSong,
|
||||
useFetchStar,
|
||||
useFetchUnstar,
|
||||
} from './fetch'
|
||||
import qk from './queryKeys'
|
||||
|
||||
export const useQueryArtists = () => useQuery(qk.artists, useFetchArtists())
|
||||
|
||||
export const useQueryArtist = (id: string) => {
|
||||
const fetchArtist = useFetchArtist()
|
||||
|
||||
return useQuery(qk.artist(id), () => fetchArtist(id), {
|
||||
placeholderData: () => {
|
||||
const artist = queryClient.getQueryData<CollectionById<Artist>>(qk.artists)?.byId[id]
|
||||
if (artist) {
|
||||
return { artist, albums: [] }
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useQueryArtistInfo = (id: string) => {
|
||||
const fetchArtistInfo = useFetchArtistInfo()
|
||||
return useQuery(qk.artistInfo(id), () => fetchArtistInfo(id))
|
||||
}
|
||||
|
||||
export const useQueryArtistTopSongs = (artistName?: string) => {
|
||||
const fetchArtistTopSongs = useFetchArtistTopSongs()
|
||||
const query = useQuery(qk.artistTopSongs(artistName || ''), () => fetchArtistTopSongs(artistName as string), {
|
||||
enabled: !!artistName,
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
cacheTime: Infinity,
|
||||
notifyOnChangeProps: ['data', 'isError', 'isFetched', 'isSuccess', 'isFetching'],
|
||||
})
|
||||
|
||||
const querySuccess = query.isFetched && query.isSuccess && query.data && query.data.length > 0
|
||||
|
||||
const fetchSearchResults = useFetchSearchResults()
|
||||
const backupQuery = useQuery(
|
||||
qk.search(artistName || '', 0, 0, 50),
|
||||
() => fetchSearchResults({ query: artistName as string, songCount: 50 }),
|
||||
{
|
||||
enabled: !!artistName && !query.isFetching && !querySuccess,
|
||||
select: data =>
|
||||
// sortBy is a stable sort, so that this doesn't change order arbitrarily and re-render
|
||||
_.sortBy(data.songs, [s => -(s.playCount || 0), s => -(s.averageRating || 0), s => -(s.userRating || 0)]),
|
||||
staleTime: Infinity,
|
||||
cacheTime: Infinity,
|
||||
notifyOnChangeProps: ['data', 'isError'],
|
||||
},
|
||||
)
|
||||
|
||||
return useFixCoverArt(querySuccess ? query : backupQuery)
|
||||
}
|
||||
|
||||
export const useQueryPlaylists = () => useQuery(qk.playlists, useFetchPlaylists())
|
||||
|
||||
export const useQueryPlaylist = (id: string, placeholderPlaylist?: Playlist) => {
|
||||
const fetchPlaylist = useFetchPlaylist()
|
||||
|
||||
const query = useQuery(qk.playlist(id), () => fetchPlaylist(id), {
|
||||
placeholderData: () => {
|
||||
if (placeholderPlaylist) {
|
||||
return { playlist: placeholderPlaylist }
|
||||
}
|
||||
|
||||
const playlist = queryClient.getQueryData<CollectionById<Playlist>>(qk.playlists)?.byId[id]
|
||||
if (playlist) {
|
||||
return { playlist, songs: [] }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return useFixCoverArt(query)
|
||||
}
|
||||
|
||||
export const useQueryAlbum = (id: string, placeholderAlbum?: Album) => {
|
||||
const fetchAlbum = useFetchAlbum()
|
||||
|
||||
const query = useQuery(qk.album(id), () => fetchAlbum(id), {
|
||||
placeholderData: (): { album: Album; songs?: Song[] } | undefined =>
|
||||
placeholderAlbum ? { album: placeholderAlbum } : undefined,
|
||||
})
|
||||
|
||||
return useFixCoverArt(query)
|
||||
}
|
||||
|
||||
export const useQueryAlbumList = (type: GetAlbumList2TypeBase, size: number) => {
|
||||
const fetchAlbumList = useFetchAlbumList()
|
||||
|
||||
return useInfiniteQuery(
|
||||
qk.albumList(type, size),
|
||||
async context => {
|
||||
return await fetchAlbumList(size, context.pageParam || 0, type)
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
if (lastPage.length === 0) {
|
||||
return
|
||||
}
|
||||
return allPages.length * size
|
||||
},
|
||||
cacheTime: 0,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export const useQuerySearchResults = (params: Search3Params) => {
|
||||
const fetchSearchResults = useFetchSearchResults()
|
||||
|
||||
const query = useInfiniteQuery(
|
||||
qk.search(params.query, params.artistCount, params.albumCount, params.songCount),
|
||||
async context => {
|
||||
return await fetchSearchResults({
|
||||
...params,
|
||||
artistOffset: context.pageParam?.artistOffset || 0,
|
||||
albumOffset: context.pageParam?.albumOffset || 0,
|
||||
songOffset: context.pageParam?.songOffset || 0,
|
||||
})
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
if (lastPage.albums.length + lastPage.artists.length + lastPage.songs.length === 0) {
|
||||
return
|
||||
}
|
||||
return {
|
||||
artistOffset: allPages.reduce((acc, val) => (acc += val.artists.length), 0),
|
||||
albumOffset: allPages.reduce((acc, val) => (acc += val.albums.length), 0),
|
||||
songOffset: allPages.reduce((acc, val) => (acc += val.songs.length), 0),
|
||||
}
|
||||
},
|
||||
cacheTime: 1000 * 60,
|
||||
enabled: !!params.query && params.query.length > 1,
|
||||
},
|
||||
)
|
||||
|
||||
return useFixCoverArt(query)
|
||||
}
|
||||
|
||||
export const useQueryHomeLists = (types: GetAlbumList2TypeBase[], size: number) => {
|
||||
const fetchAlbumList = useFetchAlbumList()
|
||||
|
||||
const listQueries = useQueries(
|
||||
types.map(type => {
|
||||
return {
|
||||
queryKey: qk.albumList(type, size),
|
||||
queryFn: async () => {
|
||||
const albums = await fetchAlbumList(size, 0, type as GetAlbumList2TypeBase)
|
||||
return { type, albums }
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return listQueries
|
||||
}
|
||||
|
||||
export const useStar = (id: string, type: StarrableItemType) => {
|
||||
const fetchStar = useFetchStar()
|
||||
const fetchUnstar = useFetchUnstar()
|
||||
const fetchSong = useFetchSong()
|
||||
const fetchAlbum = useFetchAlbum()
|
||||
const fetchArtist = useFetchArtist()
|
||||
|
||||
const query = useQuery(
|
||||
qk.starredItems(id),
|
||||
async () => {
|
||||
switch (type) {
|
||||
case 'album':
|
||||
console.log('fetch album starred', id)
|
||||
return !!(await fetchAlbum(id)).album.starred
|
||||
case 'artist':
|
||||
console.log('fetch artist starred', id)
|
||||
return !!(await fetchArtist(id)).artist.starred
|
||||
default:
|
||||
console.log('fetch song starred', id)
|
||||
return !!(await fetchSong(id)).starred
|
||||
}
|
||||
},
|
||||
{
|
||||
cacheTime: Infinity,
|
||||
staleTime: Infinity,
|
||||
},
|
||||
)
|
||||
|
||||
const toggle = useMutation(
|
||||
() => {
|
||||
const params: StarParams = {
|
||||
id: type === 'song' ? id : undefined,
|
||||
albumId: type === 'album' ? id : undefined,
|
||||
artistId: type === 'artist' ? id : undefined,
|
||||
}
|
||||
return !query.data ? fetchStar(params) : fetchUnstar(params)
|
||||
},
|
||||
{
|
||||
onMutate: () => {
|
||||
queryClient.setQueryData<boolean>(qk.starredItems(id), !query.data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (type === 'album') {
|
||||
queryClient.invalidateQueries(qk.albumList('starred'))
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return { query, toggle }
|
||||
}
|
||||
|
||||
export const useQueryExistingFile = (itemType: CacheItemTypeKey, itemId: string) => {
|
||||
const fetchExistingFile = useFetchExistingFile()
|
||||
|
||||
return useQuery(qk.existingFiles(itemType, itemId), () => fetchExistingFile({ itemType, itemId }), {
|
||||
staleTime: Infinity,
|
||||
cacheTime: Infinity,
|
||||
notifyOnChangeProps: ['data', 'isFetched'],
|
||||
})
|
||||
}
|
||||
|
||||
export const useQueryCoverArtPath = (coverArt = '-1', size: CacheImageSize = 'thumbnail') => {
|
||||
const fetchFile = useFetchFile()
|
||||
const client = useStore(store => store.client)
|
||||
|
||||
const itemType: CacheItemTypeKey = size === 'original' ? 'coverArt' : 'coverArtThumb'
|
||||
const existing = useQueryExistingFile(itemType, coverArt)
|
||||
|
||||
const query = useQuery(
|
||||
qk.coverArt(coverArt, size),
|
||||
async () => {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const fromUrl = client.getCoverArtUri({ id: coverArt, size: itemType === 'coverArtThumb' ? '256' : undefined })
|
||||
return await fetchFile({ itemType, itemId: coverArt, fromUrl, expectedContentType: 'image' })
|
||||
},
|
||||
{
|
||||
enabled: existing.isFetched && !existing.data && !!client,
|
||||
staleTime: Infinity,
|
||||
cacheTime: Infinity,
|
||||
},
|
||||
)
|
||||
|
||||
return { ...query, data: existing.data || query.data, isExistingFetching: existing.isFetching }
|
||||
}
|
||||
|
||||
export const useQueryArtistArtPath = (artistId: string, size: CacheImageSize = 'thumbnail') => {
|
||||
const fetchFile = useFetchFile()
|
||||
const client = useStore(store => store.client)
|
||||
const { data: artistInfo } = useQueryArtistInfo(artistId)
|
||||
|
||||
const itemType: CacheItemTypeKey = size === 'original' ? 'artistArt' : 'artistArtThumb'
|
||||
const existing = useQueryExistingFile(itemType, artistId)
|
||||
|
||||
const query = useQuery(
|
||||
qk.artistArt(artistId, size),
|
||||
async () => {
|
||||
if (!client || !artistInfo?.smallImageUrl || !artistInfo?.largeImageUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
const fromUrl = itemType === 'artistArtThumb' ? artistInfo.smallImageUrl : artistInfo.largeImageUrl
|
||||
return await fetchFile({ itemType, itemId: artistId, fromUrl, expectedContentType: 'image' })
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
existing.isFetched &&
|
||||
!existing.data &&
|
||||
!!client &&
|
||||
(!!artistInfo?.smallImageUrl || !!artistInfo?.largeImageUrl),
|
||||
staleTime: Infinity,
|
||||
cacheTime: Infinity,
|
||||
},
|
||||
)
|
||||
|
||||
return { ...query, data: existing.data || query.data, isExistingFetching: existing.isFetching }
|
||||
}
|
||||
|
||||
type WithSongs = Song[] | { songs?: Song[] }
|
||||
type InfiniteWithSongs = { songs: Song[] }
|
||||
type AnyDataWithSongs = WithSongs | InfiniteData<InfiniteWithSongs>
|
||||
type AnyQueryWithSongs = UseQueryResult<WithSongs> | UseInfiniteQueryResult<{ songs: Song[] }>
|
||||
|
||||
function getSongs<T extends AnyDataWithSongs>(data: T | undefined): Song[] {
|
||||
if (!data) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return data
|
||||
}
|
||||
|
||||
if ('pages' in data) {
|
||||
return data.pages.flatMap(p => p.songs)
|
||||
}
|
||||
|
||||
return data.songs || []
|
||||
}
|
||||
|
||||
function setSongCoverArt<T extends AnyQueryWithSongs>(query: T, coverArts: UseQueryResult<AlbumCoverArt>[]): T {
|
||||
if (!query.data) {
|
||||
return query
|
||||
}
|
||||
|
||||
const mapSongCoverArt = (song: Song) => ({
|
||||
...song,
|
||||
coverArt: coverArts.find(c => c.data?.albumId === song.albumId)?.data?.coverArt,
|
||||
})
|
||||
|
||||
if (Array.isArray(query.data)) {
|
||||
return {
|
||||
...query,
|
||||
data: query.data.map(mapSongCoverArt),
|
||||
}
|
||||
}
|
||||
|
||||
if ('pages' in query.data) {
|
||||
return {
|
||||
...query,
|
||||
data: {
|
||||
pages: query.data.pages.map(p => ({
|
||||
...p,
|
||||
songs: p.songs.map(mapSongCoverArt),
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (query.data.songs) {
|
||||
return {
|
||||
...query,
|
||||
data: {
|
||||
...query.data,
|
||||
songs: query.data.songs.map(mapSongCoverArt),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// song cover art comes back from the api as a unique id per song even if it all points to the same
|
||||
// album art, which prevents us from caching it once, so we need to use the album's cover art
|
||||
const useFixCoverArt = <T extends AnyQueryWithSongs>(query: T) => {
|
||||
const fetchAlbum = useFetchAlbum()
|
||||
|
||||
const songs = getSongs(query.data)
|
||||
const albumIds = _.uniq((songs || []).map(s => s.albumId).filter((id): id is string => id !== undefined))
|
||||
|
||||
const coverArts = useQueries(
|
||||
albumIds.map(id => ({
|
||||
queryKey: qk.albumCoverArt(id),
|
||||
queryFn: async (): Promise<AlbumCoverArt> => {
|
||||
const res = await fetchAlbum(id)
|
||||
return { albumId: res.album.id, coverArt: res.album.coverArt }
|
||||
},
|
||||
staleTime: Infinity,
|
||||
cacheTime: Infinity,
|
||||
notifyOnChangeProps: ['data', 'isFetched'] as any,
|
||||
})),
|
||||
)
|
||||
|
||||
if (coverArts.every(c => c.isFetched)) {
|
||||
return setSongCoverArt(query, coverArts)
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
52
app/hooks/queryKeys.ts
Normal file
52
app/hooks/queryKeys.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
|
||||
import { GetAlbumList2TypeBase } from '@app/subsonic/params'
|
||||
|
||||
const qk = {
|
||||
starredItems: (id: string) => ['starredItems', id],
|
||||
albumCoverArt: (id: string) => ['albumCoverArt', id],
|
||||
|
||||
artists: 'artists',
|
||||
artist: (id: string) => ['artist', id],
|
||||
artistInfo: (id: string) => ['artistInfo', id],
|
||||
artistTopSongs: (artistName: string) => ['artistTopSongs', artistName],
|
||||
|
||||
playlists: 'playlists',
|
||||
playlist: (id: string) => ['playlist', id],
|
||||
|
||||
album: (id: string) => ['album', id],
|
||||
albumList: (type: GetAlbumList2TypeBase, size?: number) => {
|
||||
const key: (string | number)[] = ['albumList', type]
|
||||
size !== undefined && key.push(size)
|
||||
return key
|
||||
},
|
||||
|
||||
search: (query: string, artistCount?: number, albumCount?: number, songCount?: number) => [
|
||||
'search',
|
||||
query,
|
||||
artistCount,
|
||||
albumCount,
|
||||
songCount,
|
||||
],
|
||||
|
||||
coverArt: (coverArt?: string, size?: CacheImageSize) => {
|
||||
const key: string[] = ['coverArt']
|
||||
coverArt !== undefined && key.push(coverArt)
|
||||
size !== undefined && key.push(size)
|
||||
return key
|
||||
},
|
||||
artistArt: (artistId?: string, size?: CacheImageSize) => {
|
||||
const key: string[] = ['artistArt']
|
||||
artistId !== undefined && key.push(artistId)
|
||||
size !== undefined && key.push(size)
|
||||
return key
|
||||
},
|
||||
|
||||
existingFiles: (type?: CacheItemTypeKey, itemId?: string) => {
|
||||
const key: string[] = ['existingFiles']
|
||||
type !== undefined && key.push(type)
|
||||
itemId !== undefined && key.push(itemId)
|
||||
return key
|
||||
},
|
||||
}
|
||||
|
||||
export default qk
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useReset } from '@app/hooks/trackplayer'
|
||||
import { useStore } from '@app/state/store'
|
||||
import { useEffect } from 'react'
|
||||
import { CacheItemTypeKey } from '@app/models/cache'
|
||||
import queryClient from '@app/queryClient'
|
||||
import { useStore, useStoreDeep } from '@app/state/store'
|
||||
import { cacheDir } from '@app/util/fs'
|
||||
import RNFS from 'react-native-fs'
|
||||
import qk from './queryKeys'
|
||||
|
||||
export const useSwitchActiveServer = () => {
|
||||
const activeServerId = useStore(store => store.settings.activeServerId)
|
||||
@@ -12,21 +16,53 @@ export const useSwitchActiveServer = () => {
|
||||
return
|
||||
}
|
||||
|
||||
await queryClient.cancelQueries(undefined, { active: true })
|
||||
await resetPlayer()
|
||||
queryClient.removeQueries()
|
||||
setActiveServer(id)
|
||||
}
|
||||
}
|
||||
|
||||
export const useActiveServerRefresh = (refresh: () => void) => {
|
||||
const activeServerId = useStore(store => store.settings.activeServerId)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeServerId) {
|
||||
refresh()
|
||||
}
|
||||
}, [activeServerId, refresh])
|
||||
}
|
||||
|
||||
export const useFirstRun = () => {
|
||||
return useStore(store => Object.keys(store.settings.servers).length === 0)
|
||||
}
|
||||
|
||||
export const useResetImageCache = () => {
|
||||
const serverIds = useStoreDeep(store => Object.keys(store.settings.servers))
|
||||
const changeCacheBuster = useStore(store => store.changeCacheBuster)
|
||||
|
||||
return async () => {
|
||||
// disable/invalidate queries
|
||||
await Promise.all([
|
||||
queryClient.cancelQueries(qk.artistArt(), { active: true }),
|
||||
queryClient.cancelQueries(qk.coverArt(), { active: true }),
|
||||
queryClient.cancelQueries(qk.existingFiles(), { active: true }),
|
||||
queryClient.invalidateQueries(qk.artistArt(), { refetchActive: false }),
|
||||
queryClient.invalidateQueries(qk.coverArt(), { refetchActive: false }),
|
||||
queryClient.invalidateQueries(qk.existingFiles(), { refetchActive: false }),
|
||||
])
|
||||
|
||||
// delete all images
|
||||
const itemTypes: CacheItemTypeKey[] = ['artistArt', 'artistArtThumb', 'coverArt', 'coverArtThumb']
|
||||
await Promise.all(
|
||||
serverIds.flatMap(id =>
|
||||
itemTypes.map(async type => {
|
||||
const dir = cacheDir(id, type)
|
||||
try {
|
||||
await RNFS.unlink(dir)
|
||||
} catch {}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// change cacheBuster
|
||||
changeCacheBuster()
|
||||
|
||||
// enable queries
|
||||
await Promise.all([
|
||||
queryClient.refetchQueries(qk.existingFiles(), { active: true }),
|
||||
queryClient.refetchQueries(qk.artistArt(), { active: true }),
|
||||
queryClient.refetchQueries(qk.coverArt(), { active: true }),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Song } from '@app/models/library'
|
||||
import { QueueContextType, TrackExt } from '@app/models/trackplayer'
|
||||
import queryClient from '@app/queryClient'
|
||||
import { useStore, useStoreDeep } from '@app/state/store'
|
||||
import { getQueue, trackPlayerCommands } from '@app/state/trackplayer'
|
||||
import { getQueue, SetQueueOptions, trackPlayerCommands } from '@app/state/trackplayer'
|
||||
import userAgent from '@app/util/userAgent'
|
||||
import _ from 'lodash'
|
||||
import TrackPlayer from 'react-native-track-player'
|
||||
import { useQueries } from 'react-query'
|
||||
import { useFetchExistingFile, useFetchFile } from './fetch'
|
||||
import qk from './queryKeys'
|
||||
|
||||
export const usePlay = () => {
|
||||
return () => trackPlayerCommands.enqueue(() => TrackPlayer.play())
|
||||
@@ -83,3 +91,88 @@ export const useIsPlaying = (contextId: string | undefined, track: number) => {
|
||||
|
||||
return contextId === queueContextId && track === currentTrackIdx
|
||||
}
|
||||
|
||||
export const useSetQueue = (type: QueueContextType, songs?: Song[]) => {
|
||||
const _setQueue = useStore(store => store.setQueue)
|
||||
const client = useStore(store => store.client)
|
||||
const buildStreamUri = useStore(store => store.buildStreamUri)
|
||||
const fetchFile = useFetchFile()
|
||||
const fetchExistingFile = useFetchExistingFile()
|
||||
|
||||
const songCoverArt = _.uniq((songs || []).map(s => s.coverArt)).filter((c): c is string => c !== undefined)
|
||||
|
||||
const coverArtPaths = useQueries(
|
||||
songCoverArt.map(coverArt => ({
|
||||
queryKey: qk.coverArt(coverArt, 'thumbnail'),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const itemType = 'coverArtThumb'
|
||||
|
||||
const existingCache = queryClient.getQueryData<string | undefined>(qk.existingFiles(itemType, coverArt))
|
||||
if (existingCache) {
|
||||
return existingCache
|
||||
}
|
||||
|
||||
const existingDisk = await fetchExistingFile({ itemId: coverArt, itemType })
|
||||
if (existingDisk) {
|
||||
return existingDisk
|
||||
}
|
||||
|
||||
const fromUrl = client.getCoverArtUri({ id: coverArt, size: '256' })
|
||||
return await fetchFile({
|
||||
itemType,
|
||||
itemId: coverArt,
|
||||
fromUrl,
|
||||
expectedContentType: 'image',
|
||||
})
|
||||
},
|
||||
enabled: !!client && !!songs,
|
||||
staleTime: Infinity,
|
||||
cacheTime: Infinity,
|
||||
notifyOnChangeProps: ['data', 'isFetched'] as any,
|
||||
})),
|
||||
)
|
||||
|
||||
const songCoverArtToPath = _.zipObject(
|
||||
songCoverArt,
|
||||
coverArtPaths.map(c => c.data),
|
||||
)
|
||||
|
||||
const mapSongToTrackExt = (s: Song): TrackExt => {
|
||||
let artwork = require('@res/fallback.png')
|
||||
if (s.coverArt) {
|
||||
const filePath = songCoverArtToPath[s.coverArt]
|
||||
if (filePath) {
|
||||
artwork = `file://${filePath}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
artist: s.artist || 'Unknown Artist',
|
||||
album: s.album || 'Unknown Album',
|
||||
url: buildStreamUri(s.id),
|
||||
userAgent,
|
||||
artwork,
|
||||
coverArt: s.coverArt,
|
||||
duration: s.duration,
|
||||
artistId: s.artistId,
|
||||
albumId: s.albumId,
|
||||
track: s.track,
|
||||
discNumber: s.discNumber,
|
||||
}
|
||||
}
|
||||
|
||||
const contextId = `${type}-${songs?.map(s => s.id).join('-')}`
|
||||
|
||||
const setQueue = async (options: SetQueueOptions) => {
|
||||
const queue = (songs || []).map(mapSongToTrackExt)
|
||||
return await _setQueue({ queue, type, contextId, ...options })
|
||||
}
|
||||
|
||||
return { setQueue, contextId, isReady: coverArtPaths.every(c => c.isFetched) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user