From aae1fd68f0425dc256dd3b11554c6a1193de583f Mon Sep 17 00:00:00 2001 From: Jonas Snellinckx Date: Fri, 6 Mar 2020 18:19:28 +0100 Subject: [PATCH 01/13] refactor: implement electron-redux --- package.json | 2 +- src/common/configureStore.ts | 63 ++++++++++++++++----------------- types/electron-redux/index.d.ts | 22 ++++++++++++ yarn.lock | 22 ++++++++---- 4 files changed, 68 insertions(+), 41 deletions(-) create mode 100644 types/electron-redux/index.d.ts diff --git a/package.json b/package.json index 504c88a5..e9614e2b 100755 --- a/package.json +++ b/package.json @@ -226,6 +226,7 @@ "electron-dl": "^1.14.0", "electron-is": "^3.0.0", "electron-localshortcut": "^3.1.0", + "electron-redux": "file:../related/electron-redux", "electron-store": "^5.1.0", "electron-updater": "^4.2.0", "electron-window-state": "^5.0.3", @@ -264,7 +265,6 @@ "react-window-infinite-loader": "^1.0.5", "reactstrap": "^8.4.0", "redux": "^4.0.5", - "redux-electron-store": "Superjo149/redux-electron-store", "redux-modal": "^4.0.0", "redux-promise-middleware": "^6.1.1", "redux-thunk": "^2.3.0", diff --git a/src/common/configureStore.ts b/src/common/configureStore.ts index 1648a3d6..df4ee559 100755 --- a/src/common/configureStore.ts +++ b/src/common/configureStore.ts @@ -8,16 +8,22 @@ import { Logger } from '@main/utils/logger'; import { routerMiddleware } from 'connected-react-router'; // eslint-disable-next-line import/no-extraneous-dependencies import { ipcRenderer } from 'electron'; +import { + forwardToMainWithParams, + forwardToRenderer, + replayActionMain, + replayActionRenderer, + triggerAlias, + getInitialStateRenderer +} from 'electron-redux'; import { History } from 'history'; +import debounce from 'lodash/debounce'; import { applyMiddleware, compose, createStore, Middleware, Store } from 'redux'; -import { electronEnhancer } from 'redux-electron-store'; // eslint-disable-next-line import/no-extraneous-dependencies import { createLogger } from 'redux-logger'; import promiseMiddleware, { ActionType } from 'redux-promise-middleware'; import thunk from 'redux-thunk'; -import debounce from 'lodash/debounce'; - const debounceErrorMessage = debounce(store => { store.dispatch( addToast({ @@ -46,11 +52,6 @@ const handleErrorMiddleware: Middleware = (store: Store) => next => const configureStore = (history?: History): Store => { let middleware = [handleErrorMiddleware, thunk, promiseMiddleware]; - if (history) { - const router = routerMiddleware(history); - middleware = [router, ...middleware]; - } - if (process.env.NODE_ENV === 'development') { let logger: Middleware; if (history) { @@ -78,37 +79,33 @@ const configureStore = (history?: History): Store => { middleware.push(logger); } + if (history) { + const router = routerMiddleware(history); + middleware = [ + forwardToMainWithParams({ + blacklist: [/^@@(ui|router)/] + }), + router, + ...middleware + ]; + } else { + middleware.unshift(triggerAlias); + middleware.push(forwardToRenderer); + } + const composeEnhancers = typeof window === 'object' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ : compose; - const enhancer = composeEnhancers( - applyMiddleware(...middleware), - electronEnhancer( - history && { - filter: { - app: true, - config: true, - player: { - status: true, - currentPlaylistId: true, - playingTrack: true, - currentIndex: true - }, - modal: true, - auth: { - authentication: true - }, - ui: { - toasts: true - } - } - } - ) - ); + const enhancer = composeEnhancers(applyMiddleware(...middleware)); + + const initialState = history ? getInitialStateRenderer() : {}; + + const store: Store = createStore(rootReducer(history), initialState, enhancer); - const store: Store = createStore(rootReducer(history), enhancer); + const replayAction = history ? replayActionRenderer : replayActionMain; + replayAction(store); if (module.hot) { module.hot.accept('../common/store', () => { diff --git a/types/electron-redux/index.d.ts b/types/electron-redux/index.d.ts new file mode 100644 index 00000000..e4775ad5 --- /dev/null +++ b/types/electron-redux/index.d.ts @@ -0,0 +1,22 @@ +declare module 'electron-redux' { + import { Store, Middleware, ActionCreator, Action } from 'redux'; + + export const forwardToMainWithParams: (options: any) => Middleware; + export const forwardToMain: Middleware; + export const forwardToRenderer: Middleware; + export const triggerAlias: Middleware; + + interface AliasedAction { + type: 'ALIASED'; + payload: any[]; + meta: { trigger: string }; + } + + export function createAliasedAction( + name: string, + actionCreator: ActionCreator + ): ActionCreator; + export function replayActionMain(store: Store): void; + export function replayActionRenderer(store: Store): void; + export function getInitialStateRenderer(): any; +} diff --git a/yarn.lock b/yarn.lock index 9794d2f1..f49cdc57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5936,6 +5936,13 @@ electron-publish@22.3.3: lazy-val "^1.0.4" mime "^2.4.4" +"electron-redux@file:../related/electron-redux": + version "1.4.4-sync" + dependencies: + debug "^3.0.0" + flux-standard-action "^2.0.0" + redux "^3.4.0" + electron-store@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/electron-store/-/electron-store-5.1.0.tgz#0b3cb66b15d0002678fc5c13e8b0c38a8678d670" @@ -7304,6 +7311,13 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" +flux-standard-action@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/flux-standard-action/-/flux-standard-action-2.1.1.tgz#b2e204145ea8e4294d6b0f178d8e8c416802948f" + integrity sha512-W86GzmXmIiTVq/dpYVd2HtTIUX9c35Iq3ao3xR6qcKtuXgbu+BDEj72op5VnEIe/kpuSbhl+I8kT1iS2hpcusw== + dependencies: + lodash "^4.17.15" + follow-redirects@1.5.10: version "1.5.10" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a" @@ -13482,12 +13496,6 @@ redent@^1.0.0: indent-string "^2.1.0" strip-indent "^1.0.1" -redux-electron-store@Superjo149/redux-electron-store: - version "0.7.5" - resolved "https://codeload.github.com/Superjo149/redux-electron-store/tar.gz/f947dfc561c671a96e6957d2b0f19a4ceafdcd20" - dependencies: - lodash "^4.17.15" - redux-logger@^3.0.6: version "3.0.6" resolved "https://registry.yarnpkg.com/redux-logger/-/redux-logger-3.0.6.tgz#f7555966f3098f3c88604c449cf0baf5778274bf" @@ -13520,7 +13528,7 @@ redux-watcher@^1.0.1: dependencies: lodash "^4.13.1" -redux@^3.6.0: +redux@^3.4.0, redux@^3.6.0: version "3.7.2" resolved "https://registry.yarnpkg.com/redux/-/redux-3.7.2.tgz#06b73123215901d25d065be342eb026bc1c8537b" integrity sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A== From 095d70b238ac6fba78d2ae34373f577d28b22d81 Mon Sep 17 00:00:00 2001 From: Jonas Snellinckx Date: Tue, 17 Mar 2020 09:52:39 +0100 Subject: [PATCH 02/13] WIP --- .eslintrc.json | 14 +- package.json | 23 +- src/common/api/fetchPlaylist.ts | 4 +- src/common/api/helpers/axiosClient.ts | 1 + src/common/api/helpers/fetchToJsonNew.ts | 50 ++ src/common/api/index.ts | 5 + src/common/configureStore.ts | 123 ---- src/common/store/actions.ts | 6 +- src/common/store/app/actions.ts | 162 +++-- src/common/store/app/api.ts | 43 ++ src/common/store/app/epics.ts | 56 ++ src/common/store/app/reducer.ts | 256 ++++---- src/common/store/app/types.ts | 11 +- src/common/store/appAuth/actions.ts | 15 + src/common/store/appAuth/epics.ts | 77 +++ src/common/store/appAuth/index.ts | 2 + src/common/store/appAuth/reducer.ts | 36 ++ src/common/store/appAuth/selectors.ts | 3 + src/common/store/appAuth/types.ts | 16 + src/common/store/auth/actions.ts | 418 ++++++------- src/common/store/auth/api.ts | 81 +++ src/common/store/auth/epics.ts | 141 +++++ src/common/store/auth/reducer.ts | 436 ++++++++------ src/common/store/auth/selectors.ts | 43 +- src/common/store/auth/types.ts | 52 +- src/common/store/config/actions.ts | 20 +- src/common/store/config/reducer.ts | 65 +- src/common/store/config/selectors.ts | 8 +- src/common/store/config/types.ts | 9 +- src/common/store/entities/reducer.ts | 2 +- src/common/store/entities/selectors.ts | 16 +- src/common/store/index.ts | 135 +++-- src/common/store/objects/actions.ts | 72 ++- src/common/store/objects/epics.ts | 33 + .../store/objects/playlists/search/actions.ts | 181 +++--- src/common/store/objects/reducer.ts | 452 +++++++++----- src/common/store/objects/selectors.ts | 21 +- src/common/store/objects/types.ts | 28 +- src/common/store/player/actions.ts | 14 +- src/common/store/player/epics.ts | 12 + src/common/store/player/selectors.ts | 11 +- src/common/store/playlist/actions.ts | 53 +- src/common/store/playlist/api.ts | 217 +++++++ src/common/store/playlist/epics.ts | 562 ++++++++++++++++++ src/common/store/playlist/types.ts | 22 +- src/common/store/rootEpic.ts | 17 + src/common/store/rootReducer.ts | 39 ++ src/common/store/selector.ts | 4 +- src/common/store/track/actions.ts | 32 +- src/common/store/types.d.ts | 80 +++ src/common/store/ui/actions.ts | 17 +- src/common/store/ui/epics.ts | 49 ++ src/common/store/ui/reducer.ts | 86 +-- src/common/store/ui/selectors.ts | 8 +- src/common/store/ui/types.ts | 18 +- src/common/store/user/actions.ts | 11 +- src/common/utils/appUtils.ts | 4 + src/common/utils/errors/EpicError.ts | 12 + src/common/utils/ipc.ts | 2 +- src/common/utils/reduxUtils.ts | 11 + src/common/utils/soundcloudUtils.ts | 5 +- src/globals.d.ts | 8 + src/main/app.ts | 36 +- src/main/aws/awsIotService.ts | 4 +- src/main/features/core/applicationMenu.ts | 34 +- .../core/chromecast/chromecastManager.ts | 2 +- .../features/core/chromecast/deviceScanner.ts | 2 +- src/main/features/core/ipcManager.ts | 55 +- src/main/features/feature.ts | 2 +- src/main/features/win32/thumbar.ts | 2 +- src/main/index.ts | 6 +- src/renderer/App.tsx | 94 +-- src/renderer/_shared/ActionsDropdown.tsx | 8 +- src/renderer/_shared/ErrorBoundary.tsx | 2 +- .../_shared/PageHeader/PageHeader.scss | 224 +++---- .../_shared/PageHeader/PageHeader.tsx | 4 +- .../components/ToggleLikeButton.tsx | 31 + .../components/TogglePlayButton.tsx | 58 ++ .../components/ToggleRepostButton.tsx | 31 + src/renderer/_shared/TogglePlayButton.tsx | 77 --- .../TrackList/TrackListItem/TrackListItem.tsx | 4 +- .../_shared/TracksGrid/TrackGridRow.tsx | 8 +- .../TrackgridItem/TrackGridItem.tsx | 16 +- .../TrackgridUser/TrackGridUser.tsx | 2 +- .../_shared/TracksGrid/TracksGrid.tsx | 10 +- src/renderer/app/Layout.tsx | 38 +- src/renderer/app/Main.tsx | 118 ++-- src/renderer/app/components/Header/Header.tsx | 56 +- .../components/Header/Search/SearchBox.tsx | 9 +- .../app/components/Header/User/User.tsx | 25 +- src/renderer/app/components/Queue/Queue.tsx | 2 +- .../app/components/Queue/QueueItem.tsx | 2 +- .../app/components/Sidebar/Sidebar.tsx | 139 ++--- .../Sidebar/playlist/SideBarPlaylistItem.tsx | 32 +- .../modals/AboutModal/AboutModal.tsx | 2 +- src/renderer/app/components/player/Player.tsx | 2 +- .../components/player/components/Audio.tsx | 4 +- .../PlayerProgress/PlayerProgress.tsx | 6 +- src/renderer/history.ts | 3 - src/renderer/hooks/useLoadMorePromise.tsx | 28 + src/renderer/hooks/usePrevious.tsx | 9 - src/renderer/index.tsx | 56 +- src/renderer/pages/GenericPlaylist/index.tsx | 123 ++++ src/renderer/pages/artist/ArtistPage.tsx | 16 +- .../pages/charts/ChartsDetailsPage.tsx | 76 +-- src/renderer/pages/charts/ChartsPage.tsx | 116 ++-- src/renderer/pages/foryou/ForYouPage.tsx | 130 ++-- .../PersonalizedPlaylistCard.tsx | 2 +- src/renderer/pages/onboarding/OnBoarding.tsx | 68 +-- .../pages/onboarding/components/LoginStep.tsx | 2 +- .../onboarding/components/PrivacyStep.tsx | 85 +-- .../PersonalizedPlaylistPage.tsx | 226 ------- .../personalizedPlaylist/PlaylistPage.scss | 13 - src/renderer/pages/playlist/PlaylistPage.tsx | 412 +++++-------- .../pages/playlists/FeedPlaylistPage.tsx | 4 - .../pages/playlists/LikesPlaylistPage.tsx | 4 - .../pages/playlists/MyPlaylistsPage.tsx | 4 - src/renderer/pages/playlists/MyTracksPage.tsx | 4 - src/renderer/pages/playlists/Playlist.tsx | 180 ------ .../pages/playlists/playListPageWrapper.tsx | 8 - src/renderer/pages/search/SearchPage.tsx | 209 +++---- src/renderer/pages/settings/Settings.tsx | 6 +- src/renderer/pages/tags/TagsPage.tsx | 175 +++--- src/renderer/pages/track/TrackPage.tsx | 42 +- .../pages/track/components/TrackOverview.tsx | 2 +- src/types/index.ts | 33 +- src/types/normalized.ts | 2 +- src/types/soundcloud.ts | 370 ++++++------ tsconfig.json | 5 +- types/redux-electron-store/index.d.ts | 17 +- yarn.lock | 357 +++++++++-- 131 files changed, 4948 insertions(+), 3396 deletions(-) create mode 100755 src/common/api/helpers/fetchToJsonNew.ts create mode 100644 src/common/api/index.ts delete mode 100755 src/common/configureStore.ts create mode 100644 src/common/store/app/api.ts create mode 100644 src/common/store/app/epics.ts create mode 100755 src/common/store/appAuth/actions.ts create mode 100644 src/common/store/appAuth/epics.ts create mode 100644 src/common/store/appAuth/index.ts create mode 100755 src/common/store/appAuth/reducer.ts create mode 100644 src/common/store/appAuth/selectors.ts create mode 100644 src/common/store/appAuth/types.ts create mode 100644 src/common/store/auth/api.ts create mode 100644 src/common/store/auth/epics.ts create mode 100644 src/common/store/objects/epics.ts create mode 100644 src/common/store/player/epics.ts create mode 100644 src/common/store/playlist/api.ts create mode 100644 src/common/store/playlist/epics.ts create mode 100644 src/common/store/rootEpic.ts create mode 100755 src/common/store/rootReducer.ts create mode 100644 src/common/store/types.d.ts create mode 100644 src/common/store/ui/epics.ts create mode 100644 src/common/utils/errors/EpicError.ts create mode 100755 src/renderer/_shared/PageHeader/components/ToggleLikeButton.tsx create mode 100755 src/renderer/_shared/PageHeader/components/TogglePlayButton.tsx create mode 100755 src/renderer/_shared/PageHeader/components/ToggleRepostButton.tsx delete mode 100755 src/renderer/_shared/TogglePlayButton.tsx delete mode 100644 src/renderer/history.ts create mode 100644 src/renderer/hooks/useLoadMorePromise.tsx delete mode 100644 src/renderer/hooks/usePrevious.tsx create mode 100644 src/renderer/pages/GenericPlaylist/index.tsx delete mode 100644 src/renderer/pages/personalizedPlaylist/PersonalizedPlaylistPage.tsx delete mode 100644 src/renderer/pages/personalizedPlaylist/PlaylistPage.scss delete mode 100644 src/renderer/pages/playlists/FeedPlaylistPage.tsx delete mode 100644 src/renderer/pages/playlists/LikesPlaylistPage.tsx delete mode 100644 src/renderer/pages/playlists/MyPlaylistsPage.tsx delete mode 100644 src/renderer/pages/playlists/MyTracksPage.tsx delete mode 100644 src/renderer/pages/playlists/Playlist.tsx delete mode 100644 src/renderer/pages/playlists/playListPageWrapper.tsx diff --git a/.eslintrc.json b/.eslintrc.json index de329857..a38adf16 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -40,13 +40,15 @@ "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/indent": "off", "no-console": "warn", - "camelcase": "error", + "camelcase": "off", "react/display-name": "off", "react/prop-types": "off", "react/state-in-constructor": "off", "react/static-property-placement": "off", "class-methods-use-this": "off", "react/jsx-props-no-spreading": "off", + "@typescript-eslint/no-use-before-define": "off", + "react-hooks/exhaustive-deps": "warn", // TO FIX LATER, "no-script-url": "off", "jsx-a11y/anchor-is-valid": "off", @@ -58,7 +60,15 @@ "jsx-a11y/control-has-associated-label": "off", "no-underscore-dangle": "off", "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "warn" + "import/no-cycle": "warn", + "import/no-unresolved": [ + "error", + { + "ignore": [ + "AppReduxTypes" + ] + } + ] }, "settings": { "react": { diff --git a/package.json b/package.json index e9614e2b..73d3f1ab 100755 --- a/package.json +++ b/package.json @@ -79,9 +79,9 @@ "@types/react-input-autosize": "^2.0.0", "@types/react-list": "^0.8.5", "@types/react-onclickoutside": "^6.0.4", - "@types/react-redux": "^7.1.6", - "@types/react-router": "^4.4.1", - "@types/react-router-dom": "^4.3.1", + "@types/react-redux": "^7.1.7", + "@types/react-router": "^5.1.4", + "@types/react-router-dom": "^5.1.3", "@types/react-stickynode": "^1.4.0", "@types/react-virtualized": "^9.21.4", "@types/react-virtualized-auto-sizer": "^1.0.0", @@ -195,7 +195,7 @@ "ts-node": "^8.6.2", "tsconfig-paths-webpack-plugin": "^3.2.0", "tslint": "^6.0.0", - "typescript": "^3.7.5", + "typescript": "^3.8.3", "typings-for-css-modules-loader": "^1.7.0", "url-loader": "^3.0.0", "webpack": "^4.41.5", @@ -220,7 +220,7 @@ "boxicons": "^1.7.1", "classnames": "^2.2.5", "color-hash": "^1.0.3", - "connected-react-router": "^6.7.0", + "connected-react-router": "^6.5.2", "core-decorators": "^0.20.0", "electron-debug": "^3.0.1", "electron-dl": "^1.14.0", @@ -238,6 +238,7 @@ "moment": "^2.17.0", "multicast-dns": "^7.2.0", "normalizr": "^3.2.2", + "object-path-immutable": "^4.1.0", "pino": "^5.12.5", "pino-multi-stream": "^4.0.2", "pino-pretty": "^3.0.1", @@ -257,25 +258,29 @@ "react-marquee": "^1.0.0", "react-masonry-css": "^1.0.11", "react-redux": "^7.1.1", - "react-router": "^4.3.1", - "react-router-dom": "^4.3.1", + "react-router-dom": "^5.0.1", "react-stickynode": "^2.1.1", + "react-use": "^13.27.0", "react-virtualized-auto-sizer": "^1.0.2", "react-window": "^1.8.5", "react-window-infinite-loader": "^1.0.5", "reactstrap": "^8.4.0", - "redux": "^4.0.5", + "redux": "^4.0.4", + "redux-devtools-extension": "^2.13.8", "redux-modal": "^4.0.0", + "redux-observable": "^1.2.0", "redux-promise-middleware": "^6.1.1", "redux-thunk": "^2.3.0", "redux-watcher": "^1.0.1", "reselect": "^4.0.0", "retry-axios": "^2.1.1", + "rxjs": "^6.5.4", "semver": "^5.3.0", + "serialize-error": "^6.0.0", "source-map-support": "^0.5.16", "styled-components": "^2.0.1", "tslib": "^1.10.0", - "typesafe-actions": "^4.4.2", + "typesafe-actions": "^5.1.0", "universal-analytics": "^0.4.20" }, "optionalDependencies": { diff --git a/src/common/api/fetchPlaylist.ts b/src/common/api/fetchPlaylist.ts index 01d85fe0..0052cdd8 100755 --- a/src/common/api/fetchPlaylist.ts +++ b/src/common/api/fetchPlaylist.ts @@ -48,7 +48,7 @@ export default async function fetchPlaylist( let normalized = null; - if (objectId === PlaylistTypes.STREAM || objectId === PlaylistTypes.PLAYLISTS) { + if (objectId === PlaylistTypes.STREAM || objectId === PlaylistTypes.MYPLAYLISTS) { const { collection } = json as CollectionResponse; const processedColletion = collection @@ -62,7 +62,7 @@ export default async function fetchPlaylist( .map(item => { const obj: any = item.track || item.playlist; - obj.from_user = item.user; + obj.fromUser = item.user; obj.type = item.type; return obj; diff --git a/src/common/api/helpers/axiosClient.ts b/src/common/api/helpers/axiosClient.ts index 62e44e89..bf93ed9c 100644 --- a/src/common/api/helpers/axiosClient.ts +++ b/src/common/api/helpers/axiosClient.ts @@ -19,6 +19,7 @@ const replaceTokenInRequest = (request: AxiosRequestConfig, token: string) => { } }; +console.log(is.dev()); export const axiosClient = axios.create({ // eslint-disable-next-line global-require adapter: is.dev() && require('axios/lib/adapters/http') diff --git a/src/common/api/helpers/fetchToJsonNew.ts b/src/common/api/helpers/fetchToJsonNew.ts new file mode 100755 index 00000000..1892a80b --- /dev/null +++ b/src/common/api/helpers/fetchToJsonNew.ts @@ -0,0 +1,50 @@ +import { AxiosRequestConfig } from 'axios'; +import { axiosClient } from './axiosClient'; +import { CONFIG } from '../../../config'; +import { memToken } from '@common/utils/soundcloudUtils'; + +const soundCloudBaseUrl = 'https://api.soundcloud.com/'; +const soundCloudBaseUrlV2 = 'https://api-v2.soundcloud.com/'; + +export interface FetchOptions { + uri?: string; + clientId?: string | boolean; + oauthToken?: boolean; + useV2Endpoint?: boolean; + queryParams?: any; +} + +export default async function fetchToJsonNew( + fetchOptions: FetchOptions, + options: AxiosRequestConfig = {} +): Promise { + const { queryParams = {} } = fetchOptions; + + if (fetchOptions.clientId) { + if (typeof fetchOptions.clientId === 'string') { + // eslint-disable-next-line no-self-assign + queryParams.client_id = fetchOptions.clientId; + } else { + queryParams.client_id = CONFIG.CLIENT_ID; + } + } + + if (fetchOptions.oauthToken) { + queryParams.oauth_token = memToken; + } + + let baseUrl = soundCloudBaseUrl; + + if (fetchOptions.useV2Endpoint) { + baseUrl = soundCloudBaseUrlV2; + } + + // eslint-disable-next-line no-return-await + return await axiosClient + .request({ + url: `${baseUrl}${fetchOptions.uri}`, + params: queryParams, + ...options + }) + .then(res => res.data); +} diff --git a/src/common/api/index.ts b/src/common/api/index.ts new file mode 100644 index 00000000..102ca182 --- /dev/null +++ b/src/common/api/index.ts @@ -0,0 +1,5 @@ +import { searchAll } from './search'; + +export const APIService = { + searchAll +}; diff --git a/src/common/configureStore.ts b/src/common/configureStore.ts deleted file mode 100755 index df4ee559..00000000 --- a/src/common/configureStore.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Intent } from '@blueprintjs/core'; -import { rootReducer, StoreState } from '@common/store'; -// eslint-disable-next-line import/no-cycle -import { addToast } from '@common/store/actions'; -import { PlayerActionTypes } from '@common/store/player'; -import { UIActionTypes } from '@common/store/ui'; -import { Logger } from '@main/utils/logger'; -import { routerMiddleware } from 'connected-react-router'; -// eslint-disable-next-line import/no-extraneous-dependencies -import { ipcRenderer } from 'electron'; -import { - forwardToMainWithParams, - forwardToRenderer, - replayActionMain, - replayActionRenderer, - triggerAlias, - getInitialStateRenderer -} from 'electron-redux'; -import { History } from 'history'; -import debounce from 'lodash/debounce'; -import { applyMiddleware, compose, createStore, Middleware, Store } from 'redux'; -// eslint-disable-next-line import/no-extraneous-dependencies -import { createLogger } from 'redux-logger'; -import promiseMiddleware, { ActionType } from 'redux-promise-middleware'; -import thunk from 'redux-thunk'; - -const debounceErrorMessage = debounce(store => { - store.dispatch( - addToast({ - message: 'Something went wrong', - intent: Intent.DANGER - }) - ); -}, 500); - -const handleErrorMiddleware: Middleware = (store: Store) => next => action => { - if (action.type && action.type.endsWith(ActionType.Rejected)) { - const { - payload: { message } - } = action; - - if (message && message === 'Failed to fetch') { - // const { app: { offline } } = store.getState() - } else if (message) { - debounceErrorMessage(store); - } - } - - return next(action); -}; - -const configureStore = (history?: History): Store => { - let middleware = [handleErrorMiddleware, thunk, promiseMiddleware]; - - if (process.env.NODE_ENV === 'development') { - let logger: Middleware; - if (history) { - // renderer process - logger = createLogger({ - level: 'info', - collapsed: true, - predicate: (_getState: () => any, action: any) => - action.type !== UIActionTypes.SET_SCROLL_TOP && action.type !== PlayerActionTypes.SET_TIME - }); - } else { - // main process - logger = () => next => action => { - const reduxLogger = Logger.createLogger('REDUX'); - - if (action.error) { - reduxLogger.error(action.type, action.error); - } else { - reduxLogger.debug(action.type); - } - - return next(action); - }; - } - middleware.push(logger); - } - - if (history) { - const router = routerMiddleware(history); - middleware = [ - forwardToMainWithParams({ - blacklist: [/^@@(ui|router)/] - }), - router, - ...middleware - ]; - } else { - middleware.unshift(triggerAlias); - middleware.push(forwardToRenderer); - } - - const composeEnhancers = - typeof window === 'object' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ - ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ - : compose; - - const enhancer = composeEnhancers(applyMiddleware(...middleware)); - - const initialState = history ? getInitialStateRenderer() : {}; - - const store: Store = createStore(rootReducer(history), initialState, enhancer); - - const replayAction = history ? replayActionRenderer : replayActionMain; - replayAction(store); - - if (module.hot) { - module.hot.accept('../common/store', () => { - ipcRenderer.sendSync('renderer-reload'); - - // eslint-disable-next-line - const { rootReducer: newrootReducer } = require('@common/store/index'); - - store.replaceReducer(newrootReducer(history)); - }); - } - - return store; -}; -export { configureStore }; diff --git a/src/common/store/actions.ts b/src/common/store/actions.ts index f8a387ae..be0a86f7 100644 --- a/src/common/store/actions.ts +++ b/src/common/store/actions.ts @@ -1,11 +1,13 @@ export * from './app/actions'; +export * from './appAuth/actions'; export * from './auth/actions'; export * from './config/actions'; export * from './objects/actions'; // eslint-disable-next-line import/no-cycle -export * from './objects/playlists/search/actions'; export * from './player/actions'; +export * from './playlist/actions'; export * from './track/actions'; export * from './ui/actions'; export * from './user/actions'; -export * from './playlist/actions'; + +export { push, replace, goBack } from 'connected-react-router'; diff --git a/src/common/store/app/actions.ts b/src/common/store/app/actions.ts index c38b42af..79f7d502 100644 --- a/src/common/store/app/actions.ts +++ b/src/common/store/app/actions.ts @@ -1,44 +1,42 @@ -import { push, replace, goBack } from 'connected-react-router'; +import fetchToJson from '@common/api/helpers/fetchToJson'; +import { IPC } from '@common/utils/ipc'; +import { wError, wSuccess } from '@common/utils/reduxUtils'; +import { EpicFailure, SoundCloud, ThunkResult } from '@types'; +import { goBack, replace } from 'connected-react-router'; // eslint-disable-next-line import/no-extraneous-dependencies import { ipcRenderer } from 'electron'; import is from 'electron-is'; -import { action } from 'typesafe-actions'; -import { ThunkResult } from '..'; -import { fetchRemainingTracks } from '../../api/fetchRemainingTracks'; +import { Dispatch } from 'redux'; +import { action, createAction, createAsyncAction } from 'typesafe-actions'; import { EVENTS } from '../../constants/events'; -import { SC } from '../../utils'; -import { - getAuth, - getAuthFeed, - getAuthFollowings, - getAuthLikeIds, - getAuthLikesIfNeeded, - getAuthPlaylists, - getAuthReposts -} from '../auth/actions'; +import { isSoundCloudUrl, SC } from '../../utils'; import { toggleLike, toggleRepost } from '../track/actions'; -import { AppActionTypes, CanGoHistory, CastAppState, ChromeCastDevice, DevicePlayerStatus, Dimensions } from './types'; -import fetchToJson from '@common/api/helpers/fetchToJson'; -import { SoundCloud } from '@types'; -import { IPC } from '@common/utils/ipc'; - -export function getRemainingPlays(): ThunkResult { - return (dispatch, getState) => { - const { - config: { - app: { overrideClientId } - } - } = getState(); - - dispatch({ - type: AppActionTypes.SET_REMAINING_PLAYS, - payload: fetchRemainingTracks(overrideClientId || undefined) - }); - }; +import { + AppActionTypes, + CanGoHistory, + CastAppState, + ChromeCastDevice, + DevicePlayerStatus, + RemainingPlays +} from './types'; + +export const resetStore = createAction(AppActionTypes.RESET_STORE)(); +export const initApp = createAction(AppActionTypes.INIT)(); + +export const getRemainingPlays = createAsyncAction( + AppActionTypes.GET_REMAINING_PLAYS, + wSuccess(AppActionTypes.GET_REMAINING_PLAYS), + wError(AppActionTypes.GET_REMAINING_PLAYS) +)(); +export const canGoInHistory = createAction(AppActionTypes.SET_CAN_GO)(); + +// ====== +export function tryAndResolveQueryAsSoundCloudUrl(query: string, dispatch: Dispatch) { + if (isSoundCloudUrl(query)) { + dispatch(resolveUrl(query) as any); + } } -export const setDimensions = (dimensions: Dimensions) => action(AppActionTypes.SET_DIMENSIONS, dimensions); -export const canGoInHistory = (canGoHistory: CanGoHistory) => action(AppActionTypes.SET_CAN_GO, canGoHistory); export const setLastfmLoading = (loading: boolean) => action(AppActionTypes.SET_LASTFM_LOADING, loading); export const addChromeCastDevice = (device: ChromeCastDevice) => action(AppActionTypes.ADD_CHROMECAST_DEVICE, { @@ -70,18 +68,6 @@ export function initWatchers(): ThunkResult { // tslint:disable-next-line: max-func-body-length return dispatch => { if (!listeners.length) { - listeners.push({ - event: EVENTS.APP.PUSH_NAVIGATION, - handler: (_e: any, path: string, url: string) => { - dispatch( - push({ - pathname: path, - search: url - }) - ); - } - }); - listeners.push({ event: EVENTS.TRACK.LIKE, handler: (_e: any, trackId: string) => { @@ -132,48 +118,48 @@ export function stopWatchers(): void { listeners = []; } -export function initApp(): ThunkResult { - return (dispatch, getState) => { - const { - config: { - auth: { token } - } - } = getState(); - - if (!token) { - dispatch(replace('/login')); - - return Promise.resolve(); - } - - SC.initialize(token); - - dispatch(initWatchers()); - - if (process.env.NODE_ENV === 'development') { - dispatch(action(AppActionTypes.RESET_STORE)); - } - - return dispatch( - action( - AppActionTypes.SET_LOADED, - Promise.all([ - dispatch(getAuth()), - dispatch(getAuthFollowings()), - dispatch(getAuthReposts()), - - dispatch(getAuthFeed()), - dispatch(getAuthLikesIfNeeded()), - dispatch(getAuthLikeIds()), - dispatch(getAuthPlaylists()), - dispatch(getRemainingPlays()) - ]).then(() => { - setInterval(() => dispatch(getRemainingPlays()), 30000); - }) - ) - ); - }; -} +// export function initApp(): ThunkResult { +// return (dispatch, getState) => { +// const { +// config: { +// auth: { token } +// } +// } = getState(); + +// if (!token) { +// dispatch(replace('/login')); + +// return Promise.resolve(); +// } + +// SC.initialize(token); + +// dispatch(initWatchers()); + +// if (process.env.NODE_ENV === 'development') { +// dispatch(action(AppActionTypes.RESET_STORE)); +// } + +// return dispatch( +// action( +// AppActionTypes.SET_LOADED, +// Promise.all([ +// dispatch(getAuth()), +// dispatch(getAuthFollowings()), +// dispatch(getAuthReposts()), + +// dispatch(getAuthFeed()), +// dispatch(getAuthLikesIfNeeded()), +// dispatch(getAuthLikeIds()), +// dispatch(getAuthPlaylists()), +// dispatch(getRemainingPlays()) +// ]).then(() => { +// setInterval(() => dispatch(getRemainingPlays()), 30000); +// }) +// ) +// ); +// }; +// } export function resolveUrl(url: string): ThunkResult { return dispatch => { diff --git a/src/common/store/app/api.ts b/src/common/store/app/api.ts new file mode 100644 index 00000000..16bcaee6 --- /dev/null +++ b/src/common/store/app/api.ts @@ -0,0 +1,43 @@ +import fetchToJsonNew from '@common/api/helpers/fetchToJsonNew'; +import { RemainingPlays } from './types'; + +interface FetchRemainingTracksResponse { + statuses: Status[]; +} + +interface Status { + rate_limit: { + bucket: string; + max_nr_of_requests: number; + time_window: string; + name: 'plays' | 'search'; + }; + remaining_requests: number; + reset_time: string; +} + +export async function fetchRemainingTracks(overrideClientId?: string | null): Promise { + try { + const json = await fetchToJsonNew({ + uri: 'rate_limit_status', + clientId: overrideClientId ?? true + }); + + if (!json.statuses.length) { + return null; + } + + const plays = json.statuses.find(t => t.rate_limit.name === 'plays'); + + if (plays) { + return { + remaining: plays.remaining_requests, + resetTime: new Date(plays.reset_time).getTime() + }; + } + + return null; + } catch (err) { + return null; + } +} diff --git a/src/common/store/app/epics.ts b/src/common/store/app/epics.ts new file mode 100644 index 00000000..b248b432 --- /dev/null +++ b/src/common/store/app/epics.ts @@ -0,0 +1,56 @@ +import { replace } from 'connected-react-router'; +import { from, of } from 'rxjs'; +import { catchError, filter, map, switchMap, withLatestFrom } from 'rxjs/operators'; +import { isActionOf } from 'typesafe-actions'; +import { loginSuccess } from '../appAuth/actions'; +import { RootEpic } from '../types'; +import { getRemainingPlays, initApp } from './actions'; +import * as APIService from './api'; + +export const initAppEpic: RootEpic = (action$, state$) => + action$.pipe( + filter(isActionOf(initApp)), + withLatestFrom(state$), + switchMap(([, state]) => { + const { + config: { auth } + } = state; + + if (!auth.token) { + return of(replace('/login')); + } + + return of( + loginSuccess({ + access_token: auth.token + }) + ); + }) + ); + +export const getRemainingPlaysEpic: RootEpic = (action$, state$) => + action$.pipe( + filter(isActionOf(getRemainingPlays.request)), + withLatestFrom(state$), + switchMap(([, state]) => { + const { + config: { + app: { overrideClientId } + } + } = state; + + return from(APIService.fetchRemainingTracks(overrideClientId)).pipe( + map(response => { + if (response) { + return getRemainingPlays.success({ + ...response, + updatedAt: Date.now() + }); + } + + return getRemainingPlays.success(response); + }), + catchError(error => of(getRemainingPlays.failure({ error }))) + ); + }) + ); diff --git a/src/common/store/app/reducer.ts b/src/common/store/app/reducer.ts index 78260965..8d180cd7 100644 --- a/src/common/store/app/reducer.ts +++ b/src/common/store/app/reducer.ts @@ -1,6 +1,8 @@ import { Reducer } from 'redux'; import { onError, onSuccess } from '../../utils/reduxUtils'; import { AppActionTypes, AppState, ChromeCastDevice } from './types'; +import { createReducer } from 'typesafe-actions'; +import { resetStore, getRemainingPlays } from './actions'; const initialState: AppState = { history: { @@ -16,10 +18,6 @@ const initialState: AppState = { version: null }, lastChecked: 0, - dimensions: { - width: 0, - height: 0 - }, remainingPlays: null, lastfmLoading: false, chromecast: { @@ -31,134 +29,138 @@ const initialState: AppState = { } }; +export const appReducer = createReducer(initialState) + .handleAction(getRemainingPlays.success, (state, action) => { + return { + ...state, + remainingPlays: action.payload + }; + }) + .handleAction(resetStore, () => initialState); + // tslint:disable-next-line: max-func-body-length -export const appReducer: Reducer = (state = initialState, action) => { - const { payload, type } = action; +// export const appReducer: Reducer = (state = initialState, action) => { +// const { payload, type } = action; - switch (type) { - case AppActionTypes.SET_CAN_GO: - return { - ...state, - history: { - ...state.history, - next: payload.next, - back: payload.back - } - }; - case onSuccess(AppActionTypes.SET_REMAINING_PLAYS): - return { - ...state, - remainingPlays: { - ...payload, - updatedAt: Date.now() - } - }; - case AppActionTypes.TOGGLE_OFFLINE: - return { - ...state, - offline: payload.offline, - lastChecked: payload.time - }; +// switch (type) { +// case AppActionTypes.SET_CAN_GO: +// return { +// ...state, +// history: { +// ...state.history, +// next: payload.next, +// back: payload.back +// } +// }; +// case onSuccess(AppActionTypes.SET_REMAINING_PLAYS): +// return { +// ...state, +// remainingPlays: { +// ...payload, +// updatedAt: Date.now() +// } +// }; +// case AppActionTypes.TOGGLE_OFFLINE: +// return { +// ...state, +// offline: payload.offline, +// lastChecked: payload.time +// }; - case onError(AppActionTypes.SET_LOADED): - return { - ...state, - error: true, - loadingError: payload.message - }; - case onSuccess(AppActionTypes.SET_LOADED): - return { - ...state, - loaded: true, - loadingError: null - }; - case AppActionTypes.SET_DIMENSIONS: - return { - ...state, - dimensions: payload - }; - case AppActionTypes.SET_LASTFM_LOADING: - return { - ...state, - lastfmLoading: payload - }; - case AppActionTypes.SET_UPDATE_AVAILABLE: - return { - ...state, - update: { - ...state.update, - available: true, - version: payload.version - } - }; - case AppActionTypes.ADD_CHROMECAST_DEVICE: - // eslint-disable-next-line no-case-declarations - const hasDevice = state.chromecast.devices.find(d => d.id === payload.device.id); +// case onError(AppActionTypes.SET_LOADED): +// return { +// ...state, +// error: true, +// loadingError: payload.message +// }; +// case onSuccess(AppActionTypes.SET_LOADED): +// return { +// ...state, +// loaded: true, +// loadingError: null +// }; +// case AppActionTypes.SET_LASTFM_LOADING: +// return { +// ...state, +// lastfmLoading: payload +// }; +// case AppActionTypes.SET_UPDATE_AVAILABLE: +// return { +// ...state, +// update: { +// ...state.update, +// available: true, +// version: payload.version +// } +// }; +// case AppActionTypes.ADD_CHROMECAST_DEVICE: +// // eslint-disable-next-line no-case-declarations +// const hasDevice = state.chromecast.devices.find(d => d.id === payload.device.id); - // eslint-disable-next-line no-case-declarations - const newDevicesAfterAdd: ChromeCastDevice[] = [ - ...state.chromecast.devices.map(device => { - if (device.id === payload.device.id) { - return payload.device; - } +// // eslint-disable-next-line no-case-declarations +// const newDevicesAfterAdd: ChromeCastDevice[] = [ +// ...state.chromecast.devices.map(device => { +// if (device.id === payload.device.id) { +// return payload.device; +// } - return device; - }) - ]; +// return device; +// }) +// ]; - if (!hasDevice) { - newDevicesAfterAdd.push(payload.device); - } +// if (!hasDevice) { +// newDevicesAfterAdd.push(payload.device); +// } - return { - ...state, - chromecast: { - ...state.chromecast, - devices: newDevicesAfterAdd, - hasDevices: !!newDevicesAfterAdd.length - } - }; - case AppActionTypes.REMOVE_CHROMECAST_DEVICE: - // eslint-disable-next-line no-case-declarations - const newDevicesAfterRemove: ChromeCastDevice[] = [ - ...state.chromecast.devices.filter(d => d.id !== payload.deviceId) - ]; +// return { +// ...state, +// chromecast: { +// ...state.chromecast, +// devices: newDevicesAfterAdd, +// hasDevices: !!newDevicesAfterAdd.length +// } +// }; +// case AppActionTypes.REMOVE_CHROMECAST_DEVICE: +// // eslint-disable-next-line no-case-declarations +// const newDevicesAfterRemove: ChromeCastDevice[] = [ +// ...state.chromecast.devices.filter(d => d.id !== payload.deviceId) +// ]; - return { - ...state, - chromecast: { - ...state.chromecast, - devices: newDevicesAfterRemove, - hasDevices: !!newDevicesAfterRemove.length - } - }; - case AppActionTypes.SET_CHROMECAST_APP_STATE: - return { - ...state, - chromecast: { - ...state.chromecast, - castApp: payload - } - }; - case AppActionTypes.SET_CHROMECAST_DEVICE: - return { - ...state, - chromecast: { - ...state.chromecast, - selectedDeviceId: payload - } - }; - case AppActionTypes.SET_CHROMECAST_PLAYER_STATUS: - return { - ...state, - chromecast: { - ...state.chromecast, - devicePlayerStatus: payload - } - }; - case AppActionTypes.RESET_STORE: - return initialState; - default: - return state; - } -}; +// return { +// ...state, +// chromecast: { +// ...state.chromecast, +// devices: newDevicesAfterRemove, +// hasDevices: !!newDevicesAfterRemove.length +// } +// }; +// case AppActionTypes.SET_CHROMECAST_APP_STATE: +// return { +// ...state, +// chromecast: { +// ...state.chromecast, +// castApp: payload +// } +// }; +// case AppActionTypes.SET_CHROMECAST_DEVICE: +// return { +// ...state, +// chromecast: { +// ...state.chromecast, +// selectedDeviceId: payload +// } +// }; +// case AppActionTypes.SET_CHROMECAST_PLAYER_STATUS: +// return { +// ...state, +// chromecast: { +// ...state.chromecast, +// devicePlayerStatus: payload +// } +// }; +// case AppActionTypes.RESET_STORE: +// return initialState; +// default: +// return state; +// } +// }; diff --git a/src/common/store/app/types.ts b/src/common/store/app/types.ts index 64f2923c..d526c56d 100644 --- a/src/common/store/app/types.ts +++ b/src/common/store/app/types.ts @@ -10,7 +10,6 @@ export interface AppState offline: boolean; update: UpdateInfo; lastChecked: number; - dimensions: Dimensions; remainingPlays: RemainingPlays | null; lastfmLoading: boolean; chromecast: ChromeCastState; @@ -72,20 +71,18 @@ export interface UpdateInfo { available: boolean; version: string | null; } -export interface Dimensions { - width: number; - height: number; -} // ACTIONS export enum AppActionTypes { + GET_REMAINING_PLAYS = '@@app/GET_REMAINING_PLAYS', + RESET_STORE = '@@app/RESET_STORE', + INIT = '@@app/INIT', + TOGGLE_OFFLINE = '@@app/TOGGLE_OFFLINE', SET_LOADED = '@@app/SET_LOADED', - SET_DIMENSIONS = '@@app/SET_DIMENSIONS', SET_UPDATE_AVAILABLE = '@@app/SET_UPDATE_AVAILABLE', SET_CAN_GO = '@@app/SET_CAN_GO', - RESET_STORE = '@@app/RESET_STORE', SET_REMAINING_PLAYS = '@@app/SET_REMAINING_PLAYS', SET_LASTFM_LOADING = '@@app/SET_LASTFM_LOADING', ADD_CHROMECAST_DEVICE = '@@app/ADD_CHROMECAST_DEVICE', diff --git a/src/common/store/appAuth/actions.ts b/src/common/store/appAuth/actions.ts new file mode 100755 index 00000000..d3a3b52e --- /dev/null +++ b/src/common/store/appAuth/actions.ts @@ -0,0 +1,15 @@ +import { TokenResponse } from '@main/aws/awsIotService'; +import { createAction } from 'typesafe-actions'; +import { AppAuthActionTypes } from './types'; + +export const refreshToken = createAction(AppAuthActionTypes.REFRESH_TOKEN)(); +export const finishOnboarding = createAction(AppAuthActionTypes.FINISH_ONBOARDING)(); +export const login = createAction(AppAuthActionTypes.LOGIN, (loading = true) => loading)(); +export const logout = createAction(AppAuthActionTypes.LOGOUT)(); +export const loginSuccess = createAction( + AppAuthActionTypes.LOGIN_SUCCESS, + (tokenResponse?: TokenResponse) => tokenResponse +)(); + +export const loginError = createAction(AppAuthActionTypes.LOGIN_ERROR, (error?: string) => error)(); +export const loginTerminated = createAction(AppAuthActionTypes.LOGIN_TERMINATED)(); diff --git a/src/common/store/appAuth/epics.ts b/src/common/store/appAuth/epics.ts new file mode 100644 index 00000000..8c2ff80f --- /dev/null +++ b/src/common/store/appAuth/epics.ts @@ -0,0 +1,77 @@ +import { replace } from 'connected-react-router'; +import { of } from 'rxjs'; +import { filter, map, mergeMap, switchMap, withLatestFrom, tap } from 'rxjs/operators'; +import { isActionOf } from 'typesafe-actions'; +import { resetStore, getRemainingPlays } from '../app/actions'; +import { setConfigKey } from '../config/actions'; +import { RootEpic } from '../types'; +import { loginSuccess, logout, refreshToken, finishOnboarding } from './actions'; +import { configSelector } from '../config/selectors'; +import { TokenResponse } from '@main/aws/awsIotService'; +import { SC } from '@common/utils'; +import { + getCurrentUser, + getCurrentUserFollowingsIds, + getCurrentUserLikeIds, + getCurrentUserRepostIds, + getCurrentUserPlaylists +} from '../auth/actions'; +import { CONFIG } from 'src/config'; + +export const setTokenEpic: RootEpic = action$ => + action$.pipe( + filter(isActionOf([loginSuccess, refreshToken])), + map(action => action.payload), + filter((payload): payload is TokenResponse => !!payload?.access_token), + tap(payload => SC.initialize(payload.access_token)), + filter((payload): payload is TokenResponse => !!payload.refresh_token), + mergeMap(payload => + of( + setConfigKey('auth', { + expiresAt: payload.expires_at, + refreshToken: payload.refresh_token, + token: payload.access_token + }) + ) + ) + ); + +export const loginEpic: RootEpic = (action$, state$) => + action$.pipe( + filter(isActionOf(loginSuccess)), + withLatestFrom(state$), + switchMap(([, state]) => { + const config = configSelector(state); + + if (config.lastLogin) { + return of( + replace('/'), + // Fetch user + getCurrentUser.request(), + // Fetch follow Ids + getCurrentUserFollowingsIds.request(), + // Fetch like Ids + getCurrentUserLikeIds.request(), + // Fetch repost Ids + getCurrentUserRepostIds.request(), + // Fetch playlists user owns + getCurrentUserPlaylists.request(), + getRemainingPlays.request() + ); + } + + return of(replace('/login/welcome')); + }) + ); + +export const finishOnboardingEpic: RootEpic = action$ => + action$.pipe( + filter(isActionOf(finishOnboarding)), + mergeMap(() => of(setConfigKey('lastLogin', Date.now()), loginSuccess())) + ); + +export const logoutEpic: RootEpic = action$ => + action$.pipe( + filter(isActionOf(logout)), + mergeMap(() => of(setConfigKey('auth', CONFIG.DEFAULT_CONFIG.auth), resetStore(), replace('/login'))) + ); diff --git a/src/common/store/appAuth/index.ts b/src/common/store/appAuth/index.ts new file mode 100644 index 00000000..dc6ef46f --- /dev/null +++ b/src/common/store/appAuth/index.ts @@ -0,0 +1,2 @@ +export * from './reducer'; +export * from './types'; diff --git a/src/common/store/appAuth/reducer.ts b/src/common/store/appAuth/reducer.ts new file mode 100755 index 00000000..e22c922a --- /dev/null +++ b/src/common/store/appAuth/reducer.ts @@ -0,0 +1,36 @@ +import { createReducer } from 'typesafe-actions'; +import { resetStore } from '../app/actions'; +import { login, loginError, loginSuccess, loginTerminated } from './actions'; +import { AppAuthState } from './types'; + +const initialState = { + isLoading: false, + error: null +}; + +export const appAuthReducer = createReducer(initialState) + .handleAction(login, () => { + return { + isLoading: true, + error: null + }; + }) + .handleAction(loginSuccess, () => { + return { + isLoading: false, + error: null + }; + }) + .handleAction(loginError, () => { + return { + isLoading: false, + error: null + }; + }) + .handleAction(loginTerminated, () => { + return { + isLoading: false, + error: null + }; + }) + .handleAction(resetStore, () => initialState); diff --git a/src/common/store/appAuth/selectors.ts b/src/common/store/appAuth/selectors.ts new file mode 100644 index 00000000..0d19648b --- /dev/null +++ b/src/common/store/appAuth/selectors.ts @@ -0,0 +1,3 @@ +import { StoreState } from '../rootReducer'; + +export const getAppAuth = (state: StoreState) => state.appAuth; diff --git a/src/common/store/appAuth/types.ts b/src/common/store/appAuth/types.ts new file mode 100644 index 00000000..ebdc1a5c --- /dev/null +++ b/src/common/store/appAuth/types.ts @@ -0,0 +1,16 @@ +// TYPES +export type AppAuthState = Readonly<{ + isLoading: boolean; + error?: null | string; +}>; + +// ACTIONS +export enum AppAuthActionTypes { + LOGIN = '@@auth/LOGIN', + LOGIN_SUCCESS = '@@appAuth/LOGIN_SUCCESS', + LOGIN_ERROR = '@@appAuth/LOGIN_ERROR', + LOGIN_TERMINATED = '@@appAuth/LOGIN_TERMINATED', + LOGOUT = '@@appAuth/LOGOUT', + REFRESH_TOKEN = '@@appAuth/REFRESH_TOKEN', + FINISH_ONBOARDING = '@@appAuth/FINISH_ONBOARDING' +} diff --git a/src/common/store/auth/actions.ts b/src/common/store/auth/actions.ts index bac884dd..2797c35f 100755 --- a/src/common/store/auth/actions.ts +++ b/src/common/store/auth/actions.ts @@ -1,123 +1,133 @@ -import { replace } from 'connected-react-router'; -import { action } from 'typesafe-actions'; -import { ThunkResult } from '..'; -import { SoundCloud } from '../../../types'; +import { wError, wSuccess } from '@common/utils/reduxUtils'; +import { EntitiesOf, EpicFailure, ObjectMap, SoundCloud, ThunkResult } from '@types'; +import { createAsyncAction } from 'typesafe-actions'; import fetchPersonalised from '../../api/fetchPersonalised'; -import fetchPlaylists from '../../api/fetchPlaylists'; import fetchToJson from '../../api/helpers/fetchToJson'; -import fetchToObject from '../../api/helpers/fetchToObject'; import { SC } from '../../utils'; -import { setToken } from '../config/actions'; -import { ObjectTypes, PlaylistTypes } from '../objects'; -import { getPlaylist, setObject } from '../objects/actions'; -import { getPlaylistObjectSelector } from '../objects/selectors'; -import { AuthActionTypes } from './types'; -import { AppActionTypes } from '../app'; - -export function logout(): ThunkResult { - return dispatch => { - dispatch({ - type: AppActionTypes.RESET_STORE - }); - dispatch(replace('/login')); - dispatch(setToken(null)); - }; -} - -export const setLoginError = (data: string) => action(AuthActionTypes.ERROR, data); - -export const setLoginLoading = (loading = true) => action(AuthActionTypes.LOADING, loading); - -export function getAuth(): ThunkResult { - return (dispatch, getState) => { - const { - config: { - app: { analytics } - } - } = getState(); - - dispatch( - action( - AuthActionTypes.SET, - fetchToJson(SC.getMeUrl()).then(user => { - if (process.env.NODE_ENV === 'production' && analytics) { - // eslint-disable-next-line - const { ua } = require('../../utils/universalAnalytics'); - - ua.set('userId', user.id); - } - - return user; - }) - ) - ); - }; -} - -export function getAuthTracksIfNeeded(): ThunkResult { - return (dispatch, getState) => { - const state = getState(); - const { - auth: { me } - } = state; - - if (!me || !me.id) { - return; - } - - const playlistObject = getPlaylistObjectSelector(PlaylistTypes.MYTRACKS)(state); - - if (!playlistObject) { - dispatch(getPlaylist(SC.getUserTracksUrl(me.id), PlaylistTypes.MYTRACKS)); - } - }; -} - -export function getAuthAllPlaylistsIfNeeded(): ThunkResult { - return (dispatch, getState) => { - const state = getState(); - const { - auth: { me } - } = state; - - if (!me || !me.id) { - return; - } - - const playlistObject = getPlaylistObjectSelector(PlaylistTypes.PLAYLISTS)(state); - - if (!playlistObject) { - dispatch(getPlaylist(SC.getAllUserPlaylistsUrl(me.id), PlaylistTypes.PLAYLISTS)); - } - }; -} - -export function getAuthLikeIds(): ThunkResult> { - return dispatch => { - return Promise.all([ - dispatch({ - type: AuthActionTypes.SET_LIKES, - payload: fetchToObject(SC.getLikeIdsUrl()) - }), - dispatch({ - type: AuthActionTypes.SET_PLAYLIST_LIKES, - payload: fetchToObject(SC.getPlaylistLikeIdsUrl()) - }) - ]); - }; -} - -export function getAuthLikesIfNeeded(): ThunkResult { - return (dispatch, getState) => { - const playlistObject = getPlaylistObjectSelector(PlaylistTypes.LIKES)(getState()); - - if (!playlistObject) { - dispatch(getPlaylist(SC.getLikesUrl(), PlaylistTypes.LIKES)); - } - }; -} - -export const getAuthFollowings = () => action(AuthActionTypes.SET_FOLLOWINGS, fetchToObject(SC.getFollowingsUrl())); +import { ObjectTypes } from '../objects'; +import { setObject } from '../objects/actions'; +import { FetchedPlaylistItem } from './api'; +import { AuthActionTypes, AuthLikes, AuthPlaylists, AuthReposts } from './types'; + +// AUTH DATA +export const getCurrentUser = createAsyncAction( + AuthActionTypes.GET_USER, + wSuccess(AuthActionTypes.GET_USER), + wError(AuthActionTypes.GET_USER) +)(); + +export const getCurrentUserFollowingsIds = createAsyncAction( + AuthActionTypes.GET_USER_FOLLOWINGS_IDS, + wSuccess(AuthActionTypes.GET_USER_FOLLOWINGS_IDS), + wError(AuthActionTypes.GET_USER_FOLLOWINGS_IDS) +)(); + +export const getCurrentUserLikeIds = createAsyncAction( + AuthActionTypes.GET_USER_LIKE_IDS, + wSuccess(AuthActionTypes.GET_USER_LIKE_IDS), + wError(AuthActionTypes.GET_USER_LIKE_IDS) +)(); + +export const getCurrentUserRepostIds = createAsyncAction( + AuthActionTypes.GET_USER_REPOST_IDS, + wSuccess(AuthActionTypes.GET_USER_REPOST_IDS), + wError(AuthActionTypes.GET_USER_REPOST_IDS) +)(); + +export const getCurrentUserPlaylists = createAsyncAction( + AuthActionTypes.GET_USER_PLAYLISTS, + wSuccess(AuthActionTypes.GET_USER_PLAYLISTS), + wError(AuthActionTypes.GET_USER_PLAYLISTS) +) }, EpicFailure>(); + +// export function getAuth(): ThunkResult { +// return (dispatch, getState) => { +// const { +// config: { +// app: { analytics } +// } +// } = getState(); + +// dispatch( +// action( +// AuthActionTypes.SET, +// fetchToJson(SC.getMeUrl()).then(user => { +// if (process.env.NODE_ENV === 'production' && analytics) { +// // eslint-disable-next-line +// const { ua } = require('../../utils/universalAnalytics'); + +// ua.set('userId', user.id); +// } + +// return user; +// }) +// ) +// ); +// }; +// } + +// export function getAuthTracksIfNeeded(): ThunkResult { +// return (dispatch, getState) => { +// const state = getState(); + +// const currentUser = currentUserSelector(state); + +// if (!currentUser?.id) { +// return; +// } + +// const playlistObject = getPlaylistObjectSelector(PlaylistTypes.MYTRACKS)(state); + +// if (!playlistObject) { +// dispatch(getPlaylistO(SC.getUserTracksUrl(currentUser.id), PlaylistTypes.MYTRACKS)); +// } +// }; +// } + +// export function getAuthAllPlaylistsIfNeeded(): ThunkResult { +// return (dispatch, getState) => { +// const state = getState(); + +// const currentUser = currentUserSelector(state); + +// if (!currentUser?.id) { +// return; +// } + +// const playlistObject = getPlaylistObjectSelector(PlaylistTypes.PLAYLISTS)(state); + +// if (!playlistObject) { +// dispatch(getPlaylistO(SC.getAllUserPlaylistsUrl(currentUser.id), PlaylistTypes.PLAYLISTS)); +// } +// }; +// } + +// export function getAuthLikeIds(): ThunkResult> { +// return dispatch => { +// return Promise.all([ +// dispatch({ +// type: AuthActionTypes.SET_LIKES, +// payload: fetchToObject(SC.getLikeIdsUrl()) +// }), +// dispatch({ +// type: AuthActionTypes.SET_PLAYLIST_LIKES, +// payload: fetchToObject(SC.getPlaylistLikeIdsUrl()) +// }) +// ]); +// }; +// } + +// export function getAuthLikesIfNeeded(): ThunkResult { +// return (dispatch, getState) => { +// const playlistObject = getPlaylistObjectSelector(PlaylistTypes.LIKES)(getState()); + +// if (!playlistObject) { +// dispatch(getPlaylistO(SC.getLikesUrl(), PlaylistTypes.LIKES)); +// } +// }; +// } + +// export const getAuthFollowings = () => action(AuthActionTypes.SET_FOLLOWINGS, fetchToObject(SC.getFollowingsUrl())); /** * Toggle following of a specific user @@ -142,93 +152,95 @@ export function toggleFollowing(userId: number): ThunkResult { }; } -export function getAuthReposts(): ThunkResult> { - return dispatch => - Promise.all([ - dispatch({ - type: AuthActionTypes.SET_REPOSTS, - payload: fetchToObject(SC.getRepostIdsUrl()) - }), - dispatch({ - type: AuthActionTypes.SET_PLAYLIST_REPOSTS, - payload: fetchToObject(SC.getRepostIdsUrl(true)) - }) - ]); -} - -export function getAuthFeed(refresh?: boolean): ThunkResult> { - return async (dispatch, getState) => { - const { - config: { hideReposts } - } = getState(); - - return dispatch>(getPlaylist(SC.getFeedUrl(hideReposts ? 40 : 20), PlaylistTypes.STREAM, { refresh })); - }; -} +// export function getAuthReposts(): ThunkResult> { +// return dispatch => +// Promise.all([ +// dispatch({ +// type: AuthActionTypes.SET_REPOSTS, +// payload: fetchToObject(SC.getRepostIdsUrl()) +// }), +// dispatch({ +// type: AuthActionTypes.SET_PLAYLIST_REPOSTS, +// payload: fetchToObject(SC.getRepostIdsUrl(true)) +// }) +// ]); +// } + +// export function getAuthFeed(refresh?: boolean): ThunkResult> { +// return async (dispatch, getState) => { +// const { +// config: { hideReposts } +// } = getState(); + +// return dispatch>( +// getPlaylistO(SC.getFeedUrl(hideReposts ? 40 : 20), PlaylistTypes.STREAM, { refresh }) +// ); +// }; +// } /** * Get playlists from the authenticated user */ -export function getAuthPlaylists(): ThunkResult { - return dispatch => - dispatch({ - type: AuthActionTypes.SET_PLAYLISTS, - payload: { - promise: fetchPlaylists().then(({ normalized }) => { - normalized.result.forEach(playlistResult => { - if (normalized.entities.playlistEntities && normalized.entities.playlistEntities[playlistResult.id]) { - const playlist = normalized.entities.playlistEntities[playlistResult.id]; - - dispatch(setObject(playlistResult.id.toString(), ObjectTypes.PLAYLISTS, {}, playlist.tracks)); - } - }); - - return normalized; - }) - } - }); -} - -export function fetchPersonalizedPlaylistsIfNeeded(): ThunkResult { - return async (dispatch, getState) => { - const { - auth: { personalizedPlaylists } - } = getState(); - - if (!personalizedPlaylists.items && !personalizedPlaylists.loading) { - return dispatch>({ - type: AuthActionTypes.SET_PERSONALIZED_PLAYLISTS, - payload: { - promise: fetchPersonalised(SC.getPersonalizedurl()).then(({ normalized }) => { - normalized.result.forEach(playlistResult => { - (playlistResult.items.collection || []).forEach(playlistId => { - if (normalized.entities.playlistEntities && normalized.entities.playlistEntities[playlistId]) { - const playlist = normalized.entities.playlistEntities[playlistId]; - - dispatch( - setObject( - playlistId.toString(), - ObjectTypes.PLAYLISTS, - {}, - playlist.tracks, - undefined, - undefined, - 0 - ) - ); - } - }); - }); - - return { - entities: normalized.entities, - items: normalized.result - }; - }) - } - } as any); - } - - return Promise.resolve(); - }; -} +// export function getAuthPlaylists(): ThunkResult { +// return dispatch => +// dispatch({ +// type: AuthActionTypes.SET_PLAYLISTS, +// payload: { +// promise: fetchPlaylists().then(({ normalized }) => { +// normalized.result.forEach(playlistResult => { +// if (normalized.entities.playlistEntities && normalized.entities.playlistEntities[playlistResult.id]) { +// const playlist = normalized.entities.playlistEntities[playlistResult.id]; + +// dispatch(setObject(playlistResult.id.toString(), ObjectTypes.PLAYLISTS, {}, playlist.tracks)); +// } +// }); + +// return normalized; +// }) +// } +// }); +// } + +// export function fetchPersonalizedPlaylistsIfNeeded(): ThunkResult { +// return async (dispatch, getState) => { +// const { +// auth: { personalizedPlaylists } +// } = getState(); + +// if (!personalizedPlaylists.items && !personalizedPlaylists.loading) { +// return dispatch>({ +// type: AuthActionTypes.SET_PERSONALIZED_PLAYLISTS, +// payload: { +// promise: fetchPersonalised(SC.getPersonalizedurl()).then(({ normalized }) => { +// normalized.result.forEach(playlistResult => { +// (playlistResult.items.collection || []).forEach(playlistId => { +// if (normalized.entities.playlistEntities && normalized.entities.playlistEntities[playlistId]) { +// const playlist = normalized.entities.playlistEntities[playlistId]; + +// dispatch( +// setObject( +// playlistId.toString(), +// ObjectTypes.PLAYLISTS, +// {}, +// playlist.tracks || [], +// undefined, +// undefined, +// 0 +// ) +// ); +// } +// }); +// }); + +// return { +// entities: normalized.entities, +// items: normalized.result +// }; +// }) +// } +// } as any); +// } + +// return Promise.resolve(); +// }; +// } diff --git a/src/common/store/auth/api.ts b/src/common/store/auth/api.ts new file mode 100644 index 00000000..a347a581 --- /dev/null +++ b/src/common/store/auth/api.ts @@ -0,0 +1,81 @@ +import fetchToJsonNew from '@common/api/helpers/fetchToJsonNew'; +import { playlistSchema, userSchema } from '@common/schemas'; +import { Collection, EntitiesOf, SoundCloud, ResultOf } from '@types'; +import { normalize, schema } from 'normalizr'; + +export async function fetchUserFollowingIds(userId: string | number) { + return fetchToJsonNew>({ + uri: `users/${userId}/followings/ids`, + oauthToken: true, + useV2Endpoint: true, + queryParams: { + limit: 5000 + } + }); +} + +export async function fetchLikeIds(type: 'track' | 'playlist' | 'system_playlist') { + return fetchToJsonNew>({ + uri: `me/${type}_likes/${type === 'system_playlist' ? 'urns' : `ids`}`, + oauthToken: true, + useV2Endpoint: true, + queryParams: { + limit: 5000 + } + }); +} +export async function fetchRepostIds(type: 'track' | 'playlist') { + return fetchToJsonNew>({ + uri: `me/${type}_reposts/ids`, + oauthToken: true, + useV2Endpoint: true, + queryParams: { + limit: 200 + } + }); +} + +export async function fetchCurrentUser() { + return fetchToJsonNew({ + uri: 'me', + oauthToken: true + }); +} + +type FetchPlaylistsResponse = Collection; + +export interface FetchedPlaylistItem { + playlist: SoundCloud.Playlist; + created_at: string; + type: 'playlist' | 'playlist-like'; + user: SoundCloud.User; + uuid: string; +} + +export async function fetchPlaylists() { + const json = await fetchToJsonNew({ + uri: 'me/library/albums_playlists_and_system_playlists', + oauthToken: true, + useV2Endpoint: true, + queryParams: { + limit: 5000 + } + }); + + const normalized = normalize< + FetchedPlaylistItem, + EntitiesOf, + ResultOf + >( + json.collection, + new schema.Array({ + playlist: playlistSchema, + user: userSchema + }) + ); + + return { + normalized, + json + }; +} diff --git a/src/common/store/auth/epics.ts b/src/common/store/auth/epics.ts new file mode 100644 index 00000000..dc5764fb --- /dev/null +++ b/src/common/store/auth/epics.ts @@ -0,0 +1,141 @@ +import { ObjectMap, Normalized } from '@types'; +import { empty, forkJoin, from, of } from 'rxjs'; +import { catchError, filter, first, map, mergeMap, switchMap, withLatestFrom } from 'rxjs/operators'; +import { EmptyAction, isActionOf } from 'typesafe-actions'; +import { RootEpic, RootState } from '../types'; +import { + getCurrentUser, + getCurrentUserFollowingsIds, + getCurrentUserLikeIds, + getCurrentUserRepostIds, + getCurrentUserPlaylists +} from './actions'; +import * as APIService from './api'; +import { currentUserSelector } from './selectors'; +import { StateObservable } from 'redux-observable'; + +export const getCurrentUserEpic: RootEpic = action$ => + action$.pipe( + filter(isActionOf(getCurrentUser.request)), + switchMap(() => + from(APIService.fetchCurrentUser()).pipe( + map(v => getCurrentUser.success(v)), + catchError(error => of(getCurrentUser.failure({ error }))) + ) + ) + ); + +export const getCurrentUserFollowingIdsEpic: RootEpic = (action$, state$) => + action$.pipe( + filter(isActionOf(getCurrentUserFollowingsIds.request)), + withLatestFrom(state$), + switchMap(getCurrentUserFromState(state$)), + switchMap(([, userId]) => + from(APIService.fetchUserFollowingIds(userId as number)).pipe( + // Map array to object with booleans for performance + map(mapToObject), + map(v => getCurrentUserFollowingsIds.success(v)), + catchError(error => of(getCurrentUserFollowingsIds.failure({ error }))) + ) + ) + ); + +export const getCurrentUserLikeIdsEpic: RootEpic = action$ => + action$.pipe( + filter(isActionOf(getCurrentUserLikeIds.request)), + mergeMap(() => + forkJoin( + from(APIService.fetchLikeIds('track')).pipe(map(mapToObject)), + from(APIService.fetchLikeIds('playlist')).pipe(map(mapToObject)), + from(APIService.fetchLikeIds('system_playlist')).pipe(map(mapToObject)) + ).pipe( + map(([track, playlist, systemPlaylist]) => + getCurrentUserLikeIds.success({ + track, + playlist, + systemPlaylist + }) + ), + catchError(error => of(getCurrentUserLikeIds.failure({ error }))) + ) + ) + ); + +export const getCurrentUserRepostIdsEpic: RootEpic = action$ => + action$.pipe( + filter(isActionOf(getCurrentUserRepostIds.request)), + mergeMap(() => + forkJoin( + from(APIService.fetchRepostIds('track')).pipe(map(mapToObject)), + from(APIService.fetchRepostIds('playlist')).pipe(map(mapToObject)) + ).pipe( + map(([track, playlist]) => + getCurrentUserRepostIds.success({ + track, + playlist + }) + ), + catchError(error => of(getCurrentUserRepostIds.failure({ error }))) + ) + ) + ); + +export const getCurrentUserPlaylistsEpic: RootEpic = action$ => + action$.pipe( + filter(isActionOf(getCurrentUserPlaylists.request)), + switchMap(() => + from(APIService.fetchPlaylists()).pipe( + map(response => { + const likedPlaylistIds = response.normalized.result + .filter(playlist => playlist.type === 'playlist-like') + .map( + (playlist): Normalized.NormalizedResult => ({ + id: +playlist.playlist, + schema: 'playlists' + }) + ); + + const playlistIds = response.normalized.result + .filter(playlist => playlist.type === 'playlist') + .map( + (playlist): Normalized.NormalizedResult => ({ + id: +playlist.playlist, + schema: 'playlists' + }) + ); + + return getCurrentUserPlaylists.success({ + liked: likedPlaylistIds, + owned: playlistIds, + entities: response.normalized.entities + }); + }), + catchError(error => of(getCurrentUserPlaylists.failure({ error }))) + ) + ) + ); + +// Helpers + +const getCurrentUserFromState = (state$: StateObservable) => ([action, state]: [ + EmptyAction, + RootState +]) => { + const currentUser = currentUserSelector(state); + + if (currentUser?.id) { + return of([action, currentUser?.id]); + } + + return state$.pipe( + mergeMap(latestState => { + const userId = currentUserSelector(latestState)?.id; + + return userId ? of([action, userId]) : empty(); + }), + first() + ); +}; + +const mapToObject = (v: { collection: number[] }) => + v.collection.reduce((acc, value) => ({ ...acc, [value.toString()]: true }), {}); diff --git a/src/common/store/auth/reducer.ts b/src/common/store/auth/reducer.ts index 94ef3615..75c3316e 100755 --- a/src/common/store/auth/reducer.ts +++ b/src/common/store/auth/reducer.ts @@ -1,24 +1,35 @@ -import { Reducer } from 'redux'; -import { isLoading, onError, onSuccess } from '../../utils/reduxUtils'; -import { AppActionTypes } from '../app'; -import { ConfigActionTypes } from '../config'; -import { AuthActionTypes, AuthState } from './types'; +import { createReducer } from 'typesafe-actions'; +import { resetStore } from '../app/actions'; +import { + getCurrentUser, + getCurrentUserFollowingsIds, + getCurrentUserLikeIds, + getCurrentUserPlaylists, + getCurrentUserRepostIds +} from './actions'; +import { AuthState } from './types'; +import { getForYouSelection } from '../playlist/actions'; -const initialState = { - me: null, +const initialState: AuthState = { + me: { + isLoading: false + }, followings: {}, likes: { track: {}, - playlist: {} + playlist: {}, + systemPlaylist: {} }, reposts: { track: {}, playlist: {} }, - playlists: [], - authentication: { - loading: false, - error: null + playlists: { + isLoading: false, + data: { + liked: [], + owned: [] + } }, personalizedPlaylists: { loading: false, @@ -26,163 +37,252 @@ const initialState = { } }; -// tslint:disable-next-line: max-func-body-length cyclomatic-complexity -export const authReducer: Reducer = (state = initialState, action) => { - const { payload, type } = action; +export const authReducer = createReducer(initialState) + .handleAction(getCurrentUser.request, state => { + return { + ...state, + me: { + ...state.me, + isLoading: true, + error: null + } + }; + }) + .handleAction(getCurrentUser.success, (state, action) => { + return { + ...state, + me: { + ...state.me, + isLoading: false, + data: action.payload + } + }; + }) + .handleAction(getCurrentUser.failure, (state, action) => { + return { + ...state, + me: { + ...state.me, + isLoading: false, + error: action.payload + } + }; + }) + // TODO handle getCurrentUserFollowings error & loading? + .handleAction(getCurrentUserFollowingsIds.success, (state, action) => { + return { + ...state, + followings: action.payload + }; + }) + // TODO handle getCurrentUserLikeIds error & loading? + .handleAction(getCurrentUserLikeIds.success, (state, action) => { + return { + ...state, + likes: action.payload + }; + }) + // TODO handle getCurrentUserRepostIds error & loading? + .handleAction(getCurrentUserRepostIds.success, (state, action) => { + return { + ...state, + reposts: action.payload + }; + }) + .handleAction(getCurrentUserPlaylists.request, state => { + return { + ...state, + playlists: { + ...state.playlists, + isLoading: true, + error: null + } + }; + }) + .handleAction(getCurrentUserPlaylists.success, (state, action) => { + return { + ...state, + playlists: { + ...state.playlists, + isLoading: false, + data: action.payload + } + }; + }) + .handleAction(getCurrentUserPlaylists.failure, (state, action) => { + return { + ...state, + playlists: { + ...state.playlists, + isLoading: false, + error: action.payload + } + }; + }) + .handleAction(getForYouSelection.request, (state, action) => { + return { + ...state, + personalizedPlaylists: { + ...state.personalizedPlaylists, + isLoading: true, + error: null + } + }; + }) + .handleAction(getForYouSelection.success, (state, action) => { + return { + ...state, + personalizedPlaylists: { + ...state.personalizedPlaylists, + isLoading: false, + items: action.payload.result + } + }; + }) + .handleAction(getForYouSelection.failure, (state, action) => { + return { + ...state, + personalizedPlaylists: { + ...state.personalizedPlaylists, + isLoading: false, + error: action.payload.error + } + }; + }) + .handleAction(resetStore, () => initialState); - switch (type) { - case AuthActionTypes.ERROR: - return { - ...state, - authentication: { - loading: false, - error: payload || null - } - }; - case AuthActionTypes.LOADING: - return { - ...state, - authentication: { - loading: payload, - error: null - } - }; - case ConfigActionTypes.SET_TOKEN: - return { - ...state, - authentication: { - ...state.authentication, - loading: false - } - }; +// tslint:disable-next-line: max-func-body-length cyclomatic-complexity +// export const authReducer: Reducer = (state = initialState, action) => { +// const { payload, type } = action; - case onSuccess(AuthActionTypes.SET): - return { - ...state, - me: payload - }; - case onSuccess(AuthActionTypes.SET_LIKES): - return { - ...state, - likes: { - ...state.likes, - track: payload - } - }; - case onSuccess(AuthActionTypes.SET_PLAYLIST_LIKES): - return { - ...state, - likes: { - ...state.likes, - playlist: payload - } - }; - case onSuccess(AuthActionTypes.SET_FOLLOWINGS): - return { - ...state, - followings: payload - }; - case onSuccess(AuthActionTypes.SET_REPOSTS): - return { - ...state, - reposts: payload - }; - case onSuccess(AuthActionTypes.SET_PLAYLIST_REPOSTS): - return { - ...state, - reposts: { - ...state.reposts, - playlist: payload - } - }; - case onSuccess(AuthActionTypes.SET_PLAYLISTS): - return { - ...state, - playlists: payload.result - }; - case onSuccess(AuthActionTypes.SET_LIKE): - if (payload.playlist) { - return { - ...state, - likes: { - ...state.likes, - playlist: { - ...state.likes.playlist, - [payload.trackId]: payload.liked - } - } - }; - } +// switch (type) { +// case onSuccess(AuthActionTypes.SET): +// return { +// ...state, +// me: payload +// }; +// case onSuccess(AuthActionTypes.SET_LIKES): +// return { +// ...state, +// likes: { +// ...state.likes, +// track: payload +// } +// }; +// case onSuccess(AuthActionTypes.SET_PLAYLIST_LIKES): +// return { +// ...state, +// likes: { +// ...state.likes, +// playlist: payload +// } +// }; +// case onSuccess(AuthActionTypes.SET_FOLLOWINGS): +// return { +// ...state, +// followings: payload +// }; +// case onSuccess(AuthActionTypes.SET_REPOSTS): +// return { +// ...state, +// reposts: payload +// }; +// case onSuccess(AuthActionTypes.SET_PLAYLIST_REPOSTS): +// return { +// ...state, +// reposts: { +// ...state.reposts, +// playlist: payload +// } +// }; +// case onSuccess(AuthActionTypes.SET_PLAYLISTS): +// return { +// ...state, +// playlists: payload.result +// }; +// case onSuccess(AuthActionTypes.SET_LIKE): +// if (payload.playlist) { +// return { +// ...state, +// likes: { +// ...state.likes, +// playlist: { +// ...state.likes.playlist, +// [payload.trackId]: payload.liked +// } +// } +// }; +// } - return { - ...state, - likes: { - ...state.likes, - track: { - ...state.likes.track, - [payload.trackId]: payload.liked - } - } - }; - case onSuccess(AuthActionTypes.SET_REPOST): - if (payload.playlist) { - return { - ...state, - reposts: { - ...state.reposts, - playlist: { - ...state.reposts.playlist, - [payload.trackId]: payload.reposted - } - } - }; - } +// return { +// ...state, +// likes: { +// ...state.likes, +// track: { +// ...state.likes.track, +// [payload.trackId]: payload.liked +// } +// } +// }; +// case onSuccess(AuthActionTypes.SET_REPOST): +// if (payload.playlist) { +// return { +// ...state, +// reposts: { +// ...state.reposts, +// playlist: { +// ...state.reposts.playlist, +// [payload.trackId]: payload.reposted +// } +// } +// }; +// } - return { - ...state, - reposts: { - ...state.reposts, - track: { - ...state.reposts.track, - [payload.trackId]: payload.reposted - } - } - }; - case onSuccess(AuthActionTypes.SET_FOLLOWING): - return { - ...state, - followings: { - ...state.followings, - [payload.userId]: payload.following - } - }; - case isLoading(AuthActionTypes.SET_PERSONALIZED_PLAYLISTS): - return { - ...state, - personalizedPlaylists: { - ...state.personalizedPlaylists, - loading: true - } - }; - case onError(AuthActionTypes.SET_PERSONALIZED_PLAYLISTS): - return { - ...state, - personalizedPlaylists: { - ...state.personalizedPlaylists, - loading: false - } - }; - case onSuccess(AuthActionTypes.SET_PERSONALIZED_PLAYLISTS): - return { - ...state, - personalizedPlaylists: { - loading: false, - items: payload.items - } - }; - case AppActionTypes.RESET_STORE: - return initialState; - default: - return state; - } -}; +// return { +// ...state, +// reposts: { +// ...state.reposts, +// track: { +// ...state.reposts.track, +// [payload.trackId]: payload.reposted +// } +// } +// }; +// case onSuccess(AuthActionTypes.SET_FOLLOWING): +// return { +// ...state, +// followings: { +// ...state.followings, +// [payload.userId]: payload.following +// } +// }; +// case isLoading(AuthActionTypes.SET_PERSONALIZED_PLAYLISTS): +// return { +// ...state, +// personalizedPlaylists: { +// ...state.personalizedPlaylists, +// loading: true +// } +// }; +// case onError(AuthActionTypes.SET_PERSONALIZED_PLAYLISTS): +// return { +// ...state, +// personalizedPlaylists: { +// ...state.personalizedPlaylists, +// loading: false +// } +// }; +// case onSuccess(AuthActionTypes.SET_PERSONALIZED_PLAYLISTS): +// return { +// ...state, +// personalizedPlaylists: { +// loading: false, +// items: payload.items +// } +// }; +// case AppActionTypes.RESET_STORE: +// return initialState; +// default: +// return state; +// } +// }; diff --git a/src/common/store/auth/selectors.ts b/src/common/store/auth/selectors.ts index 60f717da..6e534509 100644 --- a/src/common/store/auth/selectors.ts +++ b/src/common/store/auth/selectors.ts @@ -1,39 +1,40 @@ +import { ObjectMap } from '@types'; +import { RootState } from 'AppReduxTypes'; import { createSelector } from 'reselect'; import { AuthFollowing, AuthLikes, AuthState } from '.'; -import { StoreState } from '..'; -import { Normalized } from '@types'; import { SC } from '../../utils'; import { EntitiesState } from '../entities'; import { getEntities } from '../entities/selectors'; import { ObjectGroup, ObjectState } from '../objects'; import { getPlaylistsObjects } from '../objects/selectors'; -import { AuthReposts } from './types'; +import { StoreState } from '../rootReducer'; +import { AuthPlaylists, AuthReposts } from './types'; -export const getAuth = (state: StoreState) => state.auth; +export const getAuth = (state: RootState) => state.auth; -export const getFollowings = createSelector( - getAuth, - auth => auth.followings || {} -); +export const isCurrentUserLoading = createSelector(getAuth, auth => auth.me.isLoading); +export const currentUserSelector = createSelector(getAuth, auth => auth.me.data); +export const currentUserErrorSelector = createSelector(getAuth, auth => auth.me.error); -export const getLikes = createSelector(getAuth, auth => auth.likes || {}); -export const getReposts = createSelector(getAuth, auth => auth.reposts || {}); +export const getFollowings = createSelector(getAuth, auth => auth.followings || {}); -export const getUserPlaylists = createSelector( - getAuth, - auth => auth.playlists || [] -); +export const getAuthLikesSelector = createSelector(getAuth, auth => auth.likes || {}); +export const getAuthPersonalizedPlaylistsSelector = createSelector(getAuth, auth => auth.personalizedPlaylists || {}); +export const getAuthPlaylistLikesSelector = createSelector(getAuthLikesSelector, auth => auth.playlist); +export const getAuthRepostsSelector = createSelector(getAuth, auth => auth.reposts || {}); + +export const getAuthPlaylistsSelector = createSelector(getAuth, auth => auth.playlists.data); -export type CombinedUserPlaylistState = { title: string; id: number } & ObjectState; +export type CombinedUserPlaylistState = { title: string; id: number } & ObjectState; export const getUserPlaylistsCombined = createSelector< StoreState, - Normalized.NormalizedResult[], + AuthPlaylists, ObjectGroup, EntitiesState, CombinedUserPlaylistState[] ->(getUserPlaylists, getPlaylistsObjects, getEntities, (playlists, objects, entities) => - playlists.map(p => ({ +>(getAuthPlaylistsSelector, getPlaylistsObjects, getEntities, (playlists, objects, entities) => + playlists.owned.map(p => ({ ...objects[p.id], id: p.id, title: (entities.playlistEntities[p.id] || {}).title || '' @@ -42,5 +43,7 @@ export const getUserPlaylistsCombined = createSelector< export const isFollowing = (userId: number) => createSelector(getFollowings, followings => SC.hasID(userId, followings)); -export const hasLiked = (trackId: number, type: 'playlist' | 'track' = 'track') => - createSelector(getLikes, likes => SC.hasID(trackId, likes[type])); +export const hasLiked = (trackId: number | string, type: 'playlist' | 'track' | 'systemPlaylist' = 'track') => + createSelector(getAuthLikesSelector, likes => SC.hasID(trackId, likes[type])); +export const hasReposted = (trackId: number | string, type: 'playlist' | 'track' = 'track') => + createSelector(getAuthRepostsSelector, reposts => SC.hasID(trackId, reposts[type])); diff --git a/src/common/store/auth/types.ts b/src/common/store/auth/types.ts index 1252254f..a83b4ffb 100644 --- a/src/common/store/auth/types.ts +++ b/src/common/store/auth/types.ts @@ -1,16 +1,25 @@ -import { Normalized, SoundCloud } from '@types'; +import { Normalized, SoundCloud, ObjectMap } from '@types'; +import { EpicError } from '@common/utils/errors/EpicError'; // TYPES export type AuthState = Readonly<{ - me: AuthUser | null; + me: { + isLoading: boolean; + error?: any; + data?: SoundCloud.User; + }; followings: AuthFollowing; likes: AuthLikes; reposts: AuthReposts; - playlists: Normalized.NormalizedResult[]; - authentication: AuthStatus; + playlists: { + isLoading: boolean; + error?: any; + data: AuthPlaylists; + }; personalizedPlaylists: { loading: boolean; + error?: EpicError | null; items: Normalized.NormalizedPersonalizedItem[] | null; }; }>; @@ -18,34 +27,31 @@ export type AuthState = Readonly<{ export interface AuthFollowing { [userId: string]: boolean; } +export interface AuthPlaylists { + liked: Normalized.NormalizedResult[]; + owned: Normalized.NormalizedResult[]; +} export interface AuthLikes { - track: { - [trackId: string]: boolean; - }; - playlist: { - [playlistId: string]: boolean; - }; -} -export interface AuthReposts { - track: { - [trackId: string]: boolean; - }; - playlist: { - [playlistId: string]: boolean; - }; + track: ObjectMap; + playlist: ObjectMap; + systemPlaylist: ObjectMap; } -export type AuthUser = SoundCloud.User; - -export interface AuthStatus { - loading: boolean; - error: string | null; +export interface AuthReposts { + track: ObjectMap; + playlist: ObjectMap; } // ACTIONS export enum AuthActionTypes { + GET_USER = '@@auth/GET_USER', + GET_USER_FOLLOWINGS_IDS = '@@auth/GET_USER_FOLLOWINGS_IDS', + GET_USER_LIKE_IDS = '@@auth/GET_USER_LIKE_IDS', + GET_USER_REPOST_IDS = '@@auth/GET_USER_REPOST_IDS', + GET_USER_PLAYLISTS = '@@auth/GET_USER_PLAYLISTS', + SET = '@@auth/SET', SET_PLAYLISTS = '@@auth/SET_PLAYLISTS', SET_PERSONALIZED_PLAYLISTS = '@@auth/SET_PERSONALIZED_PLAYLISTS', diff --git a/src/common/store/config/actions.ts b/src/common/store/config/actions.ts index 6d08ab93..30decde6 100644 --- a/src/common/store/config/actions.ts +++ b/src/common/store/config/actions.ts @@ -1,18 +1,10 @@ -import { TokenResponse } from '@main/aws/awsIotService'; -import { action } from 'typesafe-actions'; +import { createAction } from 'typesafe-actions'; import { ConfigActionTypes, ConfigState } from './types'; -export const setLogin = (tokenResponse: TokenResponse) => action(ConfigActionTypes.SET_LOGIN, tokenResponse); -export const setToken = (token: string | null) => action(ConfigActionTypes.SET_TOKEN, token); - -export const setConfig = (config: ConfigState) => action(ConfigActionTypes.SET_ALL, config); - -export const setConfigKey = (key: string, value: ConfigValue) => ({ - type: ConfigActionTypes.SET_KEY, - payload: { - key, - value - } -}); +export const setConfig = createAction(ConfigActionTypes.SET_CONFIG)(); +export const setConfigKey = createAction(ConfigActionTypes.SET_CONFIG_KEY, (key: string, value: ConfigValue) => ({ + key, + value +}))(); export type ConfigValue = string | number | boolean | object | null | (string | number | object)[]; diff --git a/src/common/store/config/reducer.ts b/src/common/store/config/reducer.ts index a015f2f9..c66b8e10 100644 --- a/src/common/store/config/reducer.ts +++ b/src/common/store/config/reducer.ts @@ -1,51 +1,26 @@ -import { clone, curry, setWith } from 'lodash/fp'; -import { Reducer } from 'redux'; +import immutable from 'object-path-immutable'; +import { createReducer } from 'typesafe-actions'; import { CONFIG } from '../../../config'; -import { PlayerActionTypes } from '../player'; -import { ConfigActionTypes, ConfigState } from './types'; - -export const setIn = curry((path: string, value: string, obj: object) => setWith(clone, path, value, clone(obj))); +import { setConfig, setConfigKey } from './actions'; +import { ConfigState } from './types'; const initialState = CONFIG.DEFAULT_CONFIG; -export const configReducer: Reducer = (state = initialState, action) => { - const { payload, type } = action; +export const configReducer = createReducer(initialState) + .handleAction(setConfig, (state, action) => { + const { payload } = action; - switch (type) { - case ConfigActionTypes.SET_TOKEN: - return { - ...state, - auth: { - ...state.auth, - token: payload - } - }; - case ConfigActionTypes.SET_ALL: - return { - ...state, - ...payload - }; - case ConfigActionTypes.SET_LOGIN: - return { - ...state, - auth: { - expiresAt: payload.expires_at, - refreshToken: payload.refresh_token, - token: payload.access_token - } - }; - case ConfigActionTypes.SET_KEY: - return { - ...state, - ...setIn(payload.key, payload.value, state) - }; + return { + ...state, + ...payload + }; + }) + .handleAction(setConfigKey, (state, action) => { + const { payload } = action; - case PlayerActionTypes.TOGGLE_SHUFFLE: - return { - ...state, - shuffle: payload.value - }; - default: - return state; - } -}; + console.log(payload, action.type, process.type); + return { + ...state, + ...immutable.set(state, payload.key, payload.value) + }; + }); diff --git a/src/common/store/config/selectors.ts b/src/common/store/config/selectors.ts index 3b35e768..40f7b9d3 100644 --- a/src/common/store/config/selectors.ts +++ b/src/common/store/config/selectors.ts @@ -1,11 +1,11 @@ -// eslint-disable-next-line import/no-cycle -import { StoreState } from '..'; +// eslint-disable-next-line import/no-unresolved +import { RootState } from 'AppReduxTypes'; import { createSelector } from 'reselect'; import { ConfigState } from './types'; -export const configSelector = (state: StoreState) => state.config; +export const configSelector = (state: RootState) => state.config; -export const authConfigSelector = createSelector( +export const authTokenStateSelector = createSelector( [configSelector], config => config.auth ); diff --git a/src/common/store/config/types.ts b/src/common/store/config/types.ts index f6227550..ae39f177 100644 --- a/src/common/store/config/types.ts +++ b/src/common/store/config/types.ts @@ -1,7 +1,6 @@ import { RepeatTypes } from '../player/types'; // TYPES - export interface Config extends Object { updatedAt: number; auth: { @@ -31,6 +30,7 @@ export interface AppConfig { overrideClientId: string | null; theme: string; } + export interface AudioConfig { volume: number; playbackDeviceId: null | string; @@ -50,10 +50,7 @@ export interface ProxyConfig { } // ACTIONS - export enum ConfigActionTypes { - SET_TOKEN = '@@config/SET_TOKEN', - SET_ALL = '@@config/SET_ALL', - SET_KEY = '@@config/SET_KEY', - SET_LOGIN = '@@config/SET_LOGIN' + SET_CONFIG = '@@config/SET_ALL', + SET_CONFIG_KEY = '@@config/SET_KEY' } diff --git a/src/common/store/entities/reducer.ts b/src/common/store/entities/reducer.ts index a56924d2..38ca380b 100755 --- a/src/common/store/entities/reducer.ts +++ b/src/common/store/entities/reducer.ts @@ -3,7 +3,7 @@ import { Reducer } from 'redux'; import { AppActionTypes } from '../app'; import { EntitiesState } from './types'; -const initialState = { +const initialState: EntitiesState = { playlistEntities: {}, trackEntities: {}, userEntities: {}, diff --git a/src/common/store/entities/selectors.ts b/src/common/store/entities/selectors.ts index 42a84733..5b930bf6 100644 --- a/src/common/store/entities/selectors.ts +++ b/src/common/store/entities/selectors.ts @@ -1,7 +1,7 @@ import { denormalize, schema } from 'normalizr'; import { createSelector } from 'reselect'; import { EntitiesState } from '.'; -import { StoreState } from '..'; +import { StoreState } from '../rootReducer'; import { Normalized, SoundCloud } from '@types'; import { commentSchema, playlistSchema, trackSchema, userSchema } from '../../schemas'; @@ -57,20 +57,20 @@ export const getPlaylistEntity = (id: number) => export const getCommentEntity = (id: number) => getDenormalizedEntity({ id, schema: 'comments' }); -export const getNormalizedPlaylist = (id: number) => +export const getNormalizedPlaylist = (id: string | number) => createSelector( getPlaylistEntities(), entities => entities[id] ); -export const getNormalizedUser = (id: number) => - createSelector( +export const getNormalizedUser = (id?: number) => + createSelector( getUserEntities(), - entities => entities[id] + entities => (id ? entities[id] : null) ); -export const getNormalizedTrack = (id: number) => - createSelector( +export const getNormalizedTrack = (id?: number) => + createSelector( getTrackEntities(), - entities => entities[id] + entities => (id ? entities[id] : null) ); diff --git a/src/common/store/index.ts b/src/common/store/index.ts index 09cd7d39..4b21ee05 100755 --- a/src/common/store/index.ts +++ b/src/common/store/index.ts @@ -1,39 +1,102 @@ -import { connectRouter, RouterState } from 'connected-react-router'; -import { History } from 'history'; -import { combineReducers } from 'redux'; -import { reducer as modal } from 'redux-modal'; -import { ThunkAction } from 'redux-thunk'; -import { appReducer, AppState } from './app'; -import { authReducer, AuthState } from './auth'; -import { configReducer, ConfigState } from './config'; -import { entitiesReducer, EntitiesState } from './entities'; -import { objectsReducer, ObjectsState } from './objects'; -import { playerReducer, PlayerState } from './player'; -import { uiReducer, UIState } from './ui'; - -export const rootReducer = (history?: History) => - combineReducers({ - auth: authReducer, - entities: entitiesReducer, - player: playerReducer, - objects: objectsReducer, - app: appReducer, - config: configReducer, - ui: uiReducer, - modal, - ...(history ? { router: connectRouter(history) } : {}) - }); +import { resetStore } from '@common/store/actions'; +import { PlayerActionTypes } from '@common/store/player'; +import { rootReducer } from '@common/store/rootReducer'; +import { Logger } from '@main/utils/logger'; +import { RootState } from 'AppReduxTypes'; +import { routerMiddleware } from 'connected-react-router'; +import is from 'electron-is'; +import { + forwardToMainWithParams, + forwardToRenderer, + getInitialStateRenderer, + replayActionMain, + replayActionRenderer, + triggerAlias +} from 'electron-redux'; +import { createMemoryHistory } from 'history'; +import { applyMiddleware, createStore, Middleware } from 'redux'; +import { composeWithDevTools } from 'redux-devtools-extension'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createLogger } from 'redux-logger'; +import { createEpicMiddleware } from 'redux-observable'; +import { BehaviorSubject } from 'rxjs'; +import { rootEpic } from './rootEpic'; +import { RootAction } from './types'; +import thunk from 'redux-thunk'; + +const epic$ = new BehaviorSubject(rootEpic); +export const history = createMemoryHistory(); + +history.listen(loc => console.log(process.type, loc)); + +const epicMiddleware = createEpicMiddleware(); +const connectRouterMiddleware = routerMiddleware(history); + +/** configure dev middlewares */ +const devMiddlewares: Middleware[] = [ + is.renderer() + ? createLogger({ + level: 'info', + collapsed: true, + predicate: (_getState: () => any, action: any) => action.type !== PlayerActionTypes.SET_TIME + }) + : () => next => action => { + const reduxLogger = Logger.createLogger('REDUX'); + + if (action.error) { + reduxLogger.error(action.type, action.error); + } else { + reduxLogger.debug(action.type); + } + + return next(action); + } +]; + +/** configure production middlewares */ +const middlewares: Middleware[] = [thunk, ...(is.dev() ? devMiddlewares : [])]; + +const generateMiddlewares = (): Middleware[] => + is.renderer() + ? [ + forwardToMainWithParams({ + blacklist: [] // [/^@@(ui)/] + }), + connectRouterMiddleware, + ...middlewares + ] + : [triggerAlias, ...middlewares, epicMiddleware, forwardToRenderer]; -export interface StoreState { - auth: AuthState; - entities: EntitiesState; - player: PlayerState; - objects: ObjectsState; - app: AppState; - config: ConfigState; - ui: UIState; - router: RouterState; - modal: any; +const enhancer = composeWithDevTools(applyMiddleware(...generateMiddlewares())); + +const initialState = is.renderer() ? getInitialStateRenderer() : {}; + +const store = createStore(rootReducer(history), initialState, enhancer); + +// Replay actions for redux sync +const replayAction = is.renderer() ? replayActionRenderer : replayActionMain; +replayAction(store); + +if (is.renderer()) { + window.onbeforeunload = () => { + store.dispatch(resetStore()); + }; +} else { + epicMiddleware.run(rootEpic); } -export type ThunkResult = ThunkAction; +export default store; + +if (module.hot) { + module.hot.accept('@common/store', () => { + // eslint-disable-next-line global-require + const nextReducer = require('@common/store/rootReducer').rootReducer; + store.replaceReducer(nextReducer); + }); + + module.hot.accept('@common/store/rootEpic', () => { + // eslint-disable-next-line global-require + const nextRootEpic = require('@common/store/rootEpic').rootEpic; + epic$.next(nextRootEpic); + }); +} diff --git a/src/common/store/objects/actions.ts b/src/common/store/objects/actions.ts index 2256056a..023438b2 100755 --- a/src/common/store/objects/actions.ts +++ b/src/common/store/objects/actions.ts @@ -1,26 +1,24 @@ -import { GetPlaylistOptions, Normalized, SoundCloud } from '@types'; +import { GetPlaylistOptions, Normalized, SoundCloud, ThunkResult } from '@types'; import { normalize, schema } from 'normalizr'; +import { Track } from 'src/types/soundcloud'; import { action } from 'typesafe-actions'; -import { ThunkResult } from '..'; import fetchComments from '../../api/fetchComments'; import fetchPlaylist from '../../api/fetchPlaylist'; import fetchToJson from '../../api/helpers/fetchToJson'; import { trackSchema } from '../../schemas'; import * as SC from '../../utils/soundcloudUtils'; -import { PlayerActionTypes, ProcessedQueueItems } from '../player/types'; // eslint-disable-next-line import/no-cycle import { processQueueItems } from '../player/actions'; -import { SortTypes } from '../playlist/types'; +import { PlayerActionTypes, ProcessedQueueItems } from '../player/types'; import { getPlaylistObjectSelector } from './selectors'; import { ObjectsActionTypes, ObjectState, ObjectTypes } from './types'; -import { Track } from 'src/types/soundcloud'; -const canFetch = (current: ObjectState): boolean => !current || (!!current && !current.isFetching); -const canFetchMore = (current: ObjectState): boolean => canFetch(current) && current && current.nextUrl !== null; +const canFetch = (current: ObjectState): boolean => !current || (!!current && !current.isFetching); +const canFetchMore = (current: ObjectState): boolean => canFetch(current) && current && current.nextUrl !== null; // TODO refactor, too hacky. Maybe redux-observables? // tslint:disable-next-line:max-line-length -export function getPlaylist( +export function getPlaylistO( url: string, objectId: string, options: GetPlaylistOptions = { refresh: false, appendId: null } @@ -153,7 +151,7 @@ export function fetchPlaylistIfNeeded(playlistId: number): ThunkResult item.id); // eslint-disable-next-line no-case-declarations - const result = playlist.tracks.filter(t => fetchedItemsIds.indexOf(t.id) !== -1); + const result = playlist.tracks?.filter(t => fetchedItemsIds.indexOf(t.id) !== -1); return { objectId: playlistId, @@ -267,35 +265,35 @@ export function fetchPlaylistTracks( /** * Fetch new chart if needed */ -export function fetchChartsIfNeeded(objectId: string, sortType: SortTypes = SortTypes.TOP): ThunkResult { - return (dispatch, getState) => { - const playlistObject = getPlaylistObjectSelector(objectId)(getState()); +// export function fetchChartsIfNeeded(objectId: string, sortType: SortTypes = SortTypes.TOP): ThunkResult { +// return (dispatch, getState) => { +// const playlistObject = getPlaylistObjectSelector(objectId)(getState()); - if (!playlistObject) { - dispatch(getPlaylist(SC.getChartsUrl(objectId.split('_')[0], sortType, 25), objectId)); - } - }; -} +// if (!playlistObject) { +// dispatch(getPlaylistO(SC.getChartsUrl(objectId.split('_')[0], sortType, 25), objectId)); +// } +// }; +// } -export function canFetchPlaylistTracks(playlistId: string): ThunkResult { - return (_dispatch, getState) => { - const playlistObject = getPlaylistObjectSelector(playlistId)(getState()); +// export function canFetchPlaylistTracks(playlistId: string): ThunkResult { +// return (_dispatch, getState) => { +// const playlistObject = getPlaylistObjectSelector(playlistId)(getState()); - if (!playlistObject || playlistObject.fetchedItems === playlistObject.items.length || playlistObject.isFetching) { - return false; - } +// if (!playlistObject || playlistObject.fetchedItems === playlistObject.items.length || playlistObject.isFetching) { +// return false; +// } - let newCount = playlistObject.fetchedItems + 20; +// let newCount = playlistObject.fetchedItems + 20; - if (newCount > playlistObject.items.length) { - newCount = playlistObject.items.length; - } +// if (newCount > playlistObject.items.length) { +// newCount = playlistObject.items.length; +// } - const ids = playlistObject.items.slice(playlistObject.fetchedItems, newCount); +// const ids = playlistObject.items.slice(playlistObject.fetchedItems, newCount); - return !!ids.length; - }; -} +// return !!ids.length; +// }; +// } export function fetchTracks(ids: number[]): ThunkResult { return dispatch => { @@ -339,11 +337,11 @@ export function fetchTracks(ids: number[]): ThunkResult { }; } -export const unset = (objectId: string) => - action(ObjectsActionTypes.UNSET, { - objectId, - objectType: ObjectTypes.PLAYLISTS - }); +// export const unset = (objectId: string) => +// action(ObjectsActionTypes.UNSET, { +// objectId, +// objectType: ObjectTypes.PLAYLISTS +// }); export function fetchMore(objectId: string, objectType: ObjectTypes): ThunkResult> { return async (dispatch, getState) => { @@ -356,7 +354,7 @@ export function fetchMore(objectId: string, objectType: ObjectTypes): ThunkResul if (nextUrl) { switch (objectType) { case ObjectTypes.PLAYLISTS: - return dispatch>(getPlaylist(nextUrl, objectId)); + return dispatch>(getPlaylistO(nextUrl, objectId)); case ObjectTypes.COMMENTS: return dispatch>(getCommentsByUrl(nextUrl, objectId)); default: diff --git a/src/common/store/objects/epics.ts b/src/common/store/objects/epics.ts new file mode 100644 index 00000000..8f85a624 --- /dev/null +++ b/src/common/store/objects/epics.ts @@ -0,0 +1,33 @@ +// import { APIService } from '@common/api'; +// import { from, of } from 'rxjs'; +// import { catchError, filter, map, switchMap } from 'rxjs/operators'; +// import { isActionOf } from 'typesafe-actions'; +// import { RootEpic } from '../types'; +// import { searchAsync } from './playlists/search/actions'; +// import { isSoundCloudUrl } from '@common/utils'; + +// export const searchEpic: RootEpic = action$ => +// action$.pipe( +// filter(isActionOf(searchAsync.request)), +// map(action => action.payload), +// filter(({ query }) => !isSoundCloudUrl(query)), +// switchMap(({ query }) => +// from(APIService.searchAll(query)).pipe( +// map(searchAsync.success), +// catchError(message => of(searchAsync.failure(message))) +// ) +// ) +// ); + +// export const resolveSoundCloudUrl: RootEpic = action$ => +// action$.pipe( +// filter(isActionOf(searchAsync.request)), +// map(action => action.payload), +// filter(({ query }) => isSoundCloudUrl(query)), +// switchMap(({ query }) => +// from(APIService.searchAll(query)).pipe( +// map(searchAsync.success), +// catchError(message => of(searchAsync.failure(message))) +// ) +// ) +// ); diff --git a/src/common/store/objects/playlists/search/actions.ts b/src/common/store/objects/playlists/search/actions.ts index f9bf1263..d84099c0 100644 --- a/src/common/store/objects/playlists/search/actions.ts +++ b/src/common/store/objects/playlists/search/actions.ts @@ -1,107 +1,98 @@ -import { ThunkResult } from '@common/store'; -// eslint-disable-next-line import/no-cycle -import fetchSearch from '../../../../api/fetchSearch'; -import { SC } from '../../../../utils'; -import { canFetchMoreOf, fetchMore } from '../../actions'; +import fetchSearch from '@common/api/fetchSearch'; +import { tryAndResolveQueryAsSoundCloudUrl } from '@common/store/app/actions'; +import { isSoundCloudUrl, SC } from '@common/utils'; +import { ThunkResult } from '@types'; import { getPlaylistObjectSelector, getPlaylistType } from '../../selectors'; import { ObjectsActionTypes, ObjectTypes, PlaylistTypes } from '../../types'; -import { Dispatch } from 'redux'; -import { resolveUrl } from '@common/store/app/actions'; -export function isSoundCloudUrl(query: string) { - return /https?:\/\/(www.)?soundcloud\.com\//g.exec(query) !== null; -} +// export function search( +// filter: { query?: string; tag?: string }, +// objectId: string, +// limit?: number, +// offset?: number +// ): ThunkResult> { +// return (dispatch, getState) => { +// const state = getState(); +// const { query, tag } = filter; -export function tryAndResolveQueryAsSoundCloudUrl(query: string, dispatch: Dispatch) { - if (isSoundCloudUrl(query)) { - return dispatch(resolveUrl(query) as any); - } +// const tracklistObject = getPlaylistObjectSelector(objectId)(state); - return null; -} +// if (query && isSoundCloudUrl(query)) { +// return Promise.resolve(tryAndResolveQueryAsSoundCloudUrl(query, dispatch)) as any; +// } -export function search( - filter: { query?: string; tag?: string }, - objectId: string, - limit?: number, - offset?: number -): ThunkResult> { - return (dispatch, getState) => { - const state = getState(); - const { query, tag } = filter; +// let url: string | null = null; - const tracklistObject = getPlaylistObjectSelector(objectId)(state); +// switch (getPlaylistType(objectId)) { +// case PlaylistTypes.SEARCH: +// if (query) { +// url = SC.searchAllUrl(query, limit, offset); +// } +// break; +// case PlaylistTypes.SEARCH_TRACK: +// if (query) { +// url = SC.searchTracksUrl(query, limit, offset); +// } else if (tag) { +// url = SC.searchTracksUrl(tag, limit, offset); +// } +// break; +// case PlaylistTypes.SEARCH_PLAYLIST: +// if (query) { +// url = SC.searchPlaylistsUrl(query, limit, offset); +// } else if (tag) { +// url = SC.discoverPlaylistsUrl(tag, limit, offset); +// } +// break; +// case PlaylistTypes.SEARCH_USER: +// if (query) { +// url = SC.searchUsersUrl(query, limit, offset); +// } +// break; +// default: +// } - if (query && isSoundCloudUrl(query)) { - return Promise.resolve(tryAndResolveQueryAsSoundCloudUrl(query, dispatch)) as any; - } +// if ( +// url && +// url.length && +// (!tracklistObject || (tracklistObject && !tracklistObject.isFetching && tracklistObject.nextUrl)) +// ) { +// return dispatch>({ +// type: ObjectsActionTypes.SET, +// payload: { +// promise: fetchSearch(url).then(({ normalized, json }) => { +// return { +// objectId, +// objectType: ObjectTypes.PLAYLISTS, +// entities: normalized.entities, +// result: normalized.result, +// nextUrl: json.next_href ? SC.appendToken(json.next_href) : null, +// refresh: true +// }; +// }), +// data: { +// objectId, +// objectType: ObjectTypes.PLAYLISTS +// } +// } +// } as any); +// } - let url: string | null = null; +// return Promise.resolve(); +// }; +// } - switch (getPlaylistType(objectId)) { - case PlaylistTypes.SEARCH: - if (query) { - url = SC.searchAllUrl(query, limit, offset); - } - break; - case PlaylistTypes.SEARCH_TRACK: - if (query) { - url = SC.searchTracksUrl(query, limit, offset); - } else if (tag) { - url = SC.searchTracksUrl(tag, limit, offset); - } - break; - case PlaylistTypes.SEARCH_PLAYLIST: - if (query) { - url = SC.searchPlaylistsUrl(query, limit, offset); - } else if (tag) { - url = SC.discoverPlaylistsUrl(tag, limit, offset); - } - break; - case PlaylistTypes.SEARCH_USER: - if (query) { - url = SC.searchUsersUrl(query, limit, offset); - } - break; - default: - } +// ===================================================== - let shouldFetchMore = false; +// type ObjectSet = { +// objectId: string; +// objectType: ObjectTypes; +// entities: NormalizedEntities; +// result: NormalizedResult; +// nextUrl?: string; +// }; - if ( - url && - url.length && - (!tracklistObject || (tracklistObject && !tracklistObject.isFetching && tracklistObject.nextUrl)) - ) { - return dispatch>({ - type: ObjectsActionTypes.SET, - payload: { - promise: fetchSearch(url).then(({ normalized, json }) => { - if (normalized.result.length < 15) { - shouldFetchMore = true; - } - - return { - objectId, - objectType: ObjectTypes.PLAYLISTS, - entities: normalized.entities, - result: normalized.result, - nextUrl: json.next_href ? SC.appendToken(json.next_href) : null, - refresh: true - }; - }), - data: { - objectId, - objectType: ObjectTypes.PLAYLISTS - } - } - } as any).then(() => { - if (shouldFetchMore && dispatch(canFetchMoreOf(objectId, ObjectTypes.PLAYLISTS))) { - dispatch(fetchMore(objectId, ObjectTypes.PLAYLISTS)); - } - }); - } - - return Promise.resolve(); - }; -} +// export const searchAsync = createAsyncAction('SEARCH_REQUEST', 'SEARCH_SUCCESS', 'SEARCH_FAIL')< +// { query: string }, +// any, +// string +// >(); diff --git a/src/common/store/objects/reducer.ts b/src/common/store/objects/reducer.ts index 7032d79a..e8afe7fc 100755 --- a/src/common/store/objects/reducer.ts +++ b/src/common/store/objects/reducer.ts @@ -1,179 +1,339 @@ -import { Normalized } from '@types'; -import _ from 'lodash'; -import { Reducer } from 'redux'; -import { isLoading, onError, onSuccess } from '../../utils/reduxUtils'; -import { AppActionTypes } from '../app/types'; -import { AuthActionTypes } from '../auth/types'; -import { ObjectGroup, ObjectsActionTypes, ObjectsState, ObjectState, ObjectTypes, PlaylistTypes } from './types'; - -const initialObjectsState = { +import { NormalizedResult } from 'src/types/normalized'; +import { createReducer } from 'typesafe-actions'; +import { + genericPlaylistFetchMore, + getGenericPlaylist, + getSearchPlaylist, + setPlaylistLoading, + getForYouSelection +} from '../playlist/actions'; +import { ObjectGroup, ObjectsState, ObjectState, ObjectTypes, PlaylistTypes } from './types'; +import { uniqWith, isEqual } from 'lodash'; + +const initialObjectsState: ObjectState = { isFetching: false, error: null, - meta: {}, items: [], - futureUrl: null, - nextUrl: null, - fetchedItems: 0 + fetchedItems: 0, + itemsToFetch: [], + meta: {} }; -const objectState: Reducer> = (state = initialObjectsState, action) => { - const { type, payload } = action; +const objectState = createReducer(initialObjectsState) + .handleAction([getGenericPlaylist.request, setPlaylistLoading], state => { + return { + ...state, + isFetching: true, + error: null + }; + }) + .handleAction(getSearchPlaylist, (state, action) => { + const { query, tag } = action.payload; + return { + ...state, + isFetching: (query && query.length) || !!tag, + items: [], + error: null, + meta: { query } + }; + }) + .handleAction(getGenericPlaylist.success, (state, action) => { + const { payload } = action; - let newItems; - let result; - let items; + let itemsToAdd = payload.result; + let itemsToFetch: NormalizedResult[] = []; - switch (type) { - case isLoading(ObjectsActionTypes.SET): - return { - ...state, - isFetching: true, - nextUrl: null - }; - case onError(ObjectsActionTypes.SET): - return { - ...state, - isFetching: false, - error: payload - }; - case isLoading(ObjectsActionTypes.SET_TRACKS): - return { - ...state, - isFetching: true - }; - case ObjectsActionTypes.SET: - case onSuccess(ObjectsActionTypes.SET): - result = payload.result || []; - items = state.items || []; + if (payload.fetchedItemsIds) { + itemsToAdd = itemsToAdd.filter(i => payload.fetchedItemsIds?.includes(i.id)); - newItems = _.uniqWith([...(payload.refresh ? [] : items), ...result], _.isEqual); + if (payload.result.length !== itemsToAdd.length) { + // Filter out difference between arrays + itemsToFetch = [payload.result, itemsToAdd].reduce((a, b) => + a.filter(c => !b.map(({ id }) => id).includes(c.id)) + ); + } + } - return { - ...state, - isFetching: false, - meta: payload.meta || {}, - items: newItems, - futureUrl: payload.futureUrl, - nextUrl: payload.nextUrl, - fetchedItems: payload.fetchedItems - }; - case onSuccess(ObjectsActionTypes.UPDATE_ITEMS): - return { - ...state, - items: [...payload.items] - }; - case onSuccess(ObjectsActionTypes.SET_TRACKS): - // eslint-disable-next-line no-case-declarations - const unableToFetch = _.difference( - payload.shouldFetchedIds.map((t: Normalized.NormalizedResult) => t.id), - payload.fetchedIds.map((t: Normalized.NormalizedResult) => t.id) - ); + const items = payload.refresh ? itemsToAdd : uniqWith([...state.items, ...itemsToAdd], isEqual); - // eslint-disable-next-line no-case-declarations - const filtered = state.items.filter(t => unableToFetch.indexOf(t.id) === -1); + return { + ...state, + isFetching: false, + items, + nextUrl: payload.nextUrl, + itemsToFetch, + meta: { query: payload.query, createdAt: Date.now() } + }; + }) + .handleAction(getGenericPlaylist.failure, (state, action) => { + const { payload } = action; - return { - ...state, - isFetching: false, - items: [...filtered], - fetchedItems: state.fetchedItems + payload.fetchedIds.length - }; - case onSuccess(AuthActionTypes.SET_LIKE): - if (payload.liked) { - return { - ...state, - // because of the denormalization process, every item needs a schema - items: [{ id: payload.trackId, schema: payload.playlist ? 'playlists' : 'tracks' }, ...state.items] - }; - } + return { + ...state, + isFetching: false, + error: payload.error + }; + }) + .handleAction(genericPlaylistFetchMore.success, (state, action) => { + const { payload } = action; - return { - ...state, - items: state.items.filter(item => payload.trackId !== item.id) - }; - case ObjectsActionTypes.UNSET_TRACK: - return { - ...state, - items: state.items.filter(item => payload.trackId !== item.id) - }; - case ObjectsActionTypes.UNSET: - return initialObjectsState; - default: - } + const itemsToFetch = state.itemsToFetch.filter(a => !payload.fetchedItemsIds?.includes(a.id)); - return state; -}; + return { + ...state, + isFetching: false, + items: uniqWith([...state.items, ...payload.result], isEqual), + nextUrl: payload.nextUrl, + itemsToFetch, + meta: { ...state.meta, updatedAt: Date.now() } + }; + }) + .handleAction(genericPlaylistFetchMore.failure, state => { + return { + ...state, + isFetching: false + }; + }); +// const objectState: Reducer> = (state = initialObjectsState, action) => { +// const { type, payload } = action; -const initialObjectGroupState = {}; +// let newItems; +// let result; +// let items; -const objectGroup: Reducer = (state = initialObjectGroupState, action) => { - const { type, payload } = action; +// switch (type) { +// case isLoading(ObjectsActionTypes.SET): +// return { +// ...state, +// isFetching: true, +// nextUrl: null +// }; +// case onError(ObjectsActionTypes.SET): +// return { +// ...state, +// isFetching: false, +// error: payload +// }; +// case isLoading(ObjectsActionTypes.SET_TRACKS): +// return { +// ...state, +// isFetching: true +// }; +// case ObjectsActionTypes.SET: +// case onSuccess(ObjectsActionTypes.SET): +// result = payload.result || []; +// items = state.items || []; - const playlistName = payload.playlist ? PlaylistTypes.PLAYLISTS : PlaylistTypes.LIKES; +// newItems = _.uniqWith([...(payload.refresh ? [] : items), ...result], _.isEqual); - switch (type) { - case isLoading(ObjectsActionTypes.SET): - case onSuccess(ObjectsActionTypes.SET): - case onSuccess(ObjectsActionTypes.UPDATE_ITEMS): - case onError(ObjectsActionTypes.SET): - case ObjectsActionTypes.SET: - case ObjectsActionTypes.UNSET: - case onSuccess(ObjectsActionTypes.SET_TRACKS): - case isLoading(ObjectsActionTypes.SET_TRACKS): - case ObjectsActionTypes.UNSET_TRACK: - if (!payload.objectId) { - return state; - } +// return { +// ...state, +// isFetching: false, +// meta: payload.meta || {}, +// items: newItems, +// futureUrl: payload.futureUrl, +// nextUrl: payload.nextUrl, +// fetchedItems: payload.fetchedItems +// }; +// case onSuccess(ObjectsActionTypes.UPDATE_ITEMS): +// return { +// ...state, +// items: [...payload.items] +// }; +// case onSuccess(ObjectsActionTypes.SET_TRACKS): +// // eslint-disable-next-line no-case-declarations +// const unableToFetch = _.difference( +// payload.shouldFetchedIds.map((t: Normalized.NormalizedResult) => t.id), +// payload.fetchedIds.map((t: Normalized.NormalizedResult) => t.id) +// ); - return { - ...state, - [String(payload.objectId)]: objectState(state[String(payload.objectId)], action) - }; - case onSuccess(AuthActionTypes.SET_LIKE): - if (!payload.playlist) { +// // eslint-disable-next-line no-case-declarations +// const filtered = state.items.filter(t => unableToFetch.indexOf(t.id) === -1); + +// return { +// ...state, +// isFetching: false, +// items: [...filtered], +// fetchedItems: state.fetchedItems + payload.fetchedIds.length +// }; +// case onSuccess(AuthActionTypes.SET_LIKE): +// if (payload.liked) { +// return { +// ...state, +// // because of the denormalization process, every item needs a schema +// items: [{ id: payload.trackId, schema: payload.playlist ? 'playlists' : 'tracks' }, ...state.items] +// }; +// } + +// return { +// ...state, +// items: state.items.filter(item => payload.trackId !== item.id) +// }; +// case ObjectsActionTypes.UNSET_TRACK: +// return { +// ...state, +// items: state.items.filter(item => payload.trackId !== item.id) +// }; +// case ObjectsActionTypes.UNSET: +// return initialObjectsState; +// default: +// } + +// return state; +// }; + +const initialObjectGroupState: ObjectGroup = {}; + +const objectGroup = createReducer(initialObjectGroupState) + .handleAction( + [ + getGenericPlaylist.request, + getGenericPlaylist.success, + getGenericPlaylist.failure, + setPlaylistLoading, + genericPlaylistFetchMore.request, + genericPlaylistFetchMore.success, + genericPlaylistFetchMore.failure, + getSearchPlaylist + ], + (state, action) => { + const playlistId = action.payload?.objectId || action.payload?.playlistType; + + if (!playlistId) { return state; } return { ...state, - [playlistName]: objectState(state[playlistName], action) + [playlistId]: objectState(state[playlistId], action) }; - default: - } + } + ) + .handleAction(getForYouSelection.success, (state, action) => { + const { objects } = action.payload; - return state; -}; + const addObjects = {}; + + for (let i = 0; i < objects.length; i += 1) { + const object = objects[i]; + + addObjects[object.objectId] = objectState( + state[object.objectId], + getGenericPlaylist.success({ + ...object, + entities: {} + }) + ); + } + + return { + ...state, + ...addObjects + }; + }); +// const objectGroup: Reducer = (state = initialObjectGroupState, action) => { +// const { type, payload } = action; + +// const playlistName = payload.playlist ? PlaylistTypes.PLAYLISTS : PlaylistTypes.LIKES; + +// switch (type) { +// case isLoading(ObjectsActionTypes.SET): +// case onSuccess(ObjectsActionTypes.SET): +// case onSuccess(ObjectsActionTypes.UPDATE_ITEMS): +// case onError(ObjectsActionTypes.SET): +// case ObjectsActionTypes.SET: +// case ObjectsActionTypes.UNSET: +// case onSuccess(ObjectsActionTypes.SET_TRACKS): +// case isLoading(ObjectsActionTypes.SET_TRACKS): +// case ObjectsActionTypes.UNSET_TRACK: +// if (!payload.objectId) { +// return state; +// } + +// return { +// ...state, +// [String(payload.objectId)]: objectState(state[String(payload.objectId)], action) +// }; +// case onSuccess(AuthActionTypes.SET_LIKE): +// if (!payload.playlist) { +// return state; +// } + +// return { +// ...state, +// [playlistName]: objectState(state[playlistName], action) +// }; +// default: +// } + +// return state; +// }; + +const initialState: ObjectsState = { + [PlaylistTypes.STREAM]: initialObjectsState, + [PlaylistTypes.LIKES]: initialObjectsState, + [PlaylistTypes.MYTRACKS]: initialObjectsState, + [PlaylistTypes.MYPLAYLISTS]: initialObjectsState, + [PlaylistTypes.PLAYLIST]: initialObjectsState, + [PlaylistTypes.SEARCH]: initialObjectsState, + [PlaylistTypes.SEARCH_PLAYLIST]: initialObjectsState, + [PlaylistTypes.SEARCH_TRACK]: initialObjectsState, + [PlaylistTypes.SEARCH_USER]: initialObjectsState, -const initialState = { [ObjectTypes.PLAYLISTS]: {}, [ObjectTypes.COMMENTS]: {} }; -export const objectsReducer: Reducer = (state = initialState, action) => { - const { type, payload } = action; - - switch (type) { - case isLoading(ObjectsActionTypes.SET): - case onSuccess(ObjectsActionTypes.SET): - case onSuccess(ObjectsActionTypes.UPDATE_ITEMS): - case onError(ObjectsActionTypes.SET): - case ObjectsActionTypes.SET: - case ObjectsActionTypes.UNSET: +export const objectsReducer = createReducer(initialState) + .handleAction( + [ + getGenericPlaylist.request, + getGenericPlaylist.success, + getGenericPlaylist.failure, + setPlaylistLoading, + genericPlaylistFetchMore.request, + genericPlaylistFetchMore.success, + genericPlaylistFetchMore.failure, + getSearchPlaylist + ], + (state, action) => { + const { playlistType, objectId } = action.payload; return { ...state, - [payload.objectType]: objectGroup(state[payload.objectType], action) + [playlistType]: objectId ? objectGroup(state[playlistType], action) : objectState(state[playlistType], action) }; - case onSuccess(AuthActionTypes.SET_LIKE): - case onSuccess(ObjectsActionTypes.SET_TRACKS): - case isLoading(ObjectsActionTypes.SET_TRACKS): - case ObjectsActionTypes.UNSET_TRACK: - return { - ...state, - [ObjectTypes.PLAYLISTS]: objectGroup(state[ObjectTypes.PLAYLISTS], action) - }; - case AppActionTypes.RESET_STORE: - return initialState; - default: - return state; - } -}; + } + ) + .handleAction(getForYouSelection.success, (state, action) => { + return { + ...state, + [PlaylistTypes.PLAYLIST]: objectGroup(state[PlaylistTypes.PLAYLIST], action) + }; + }); +// const { type, payload } = action; + +// switch (type) { +// case isLoading(ObjectsActionTypes.SET): +// case onSuccess(ObjectsActionTypes.SET): +// case onSuccess(ObjectsActionTypes.UPDATE_ITEMS): +// case onError(ObjectsActionTypes.SET): +// case ObjectsActionTypes.SET: +// case ObjectsActionTypes.UNSET: +// return { +// ...state, +// [payload.objectType]: objectGroup(state[payload.objectType], action) +// }; +// case onSuccess(AuthActionTypes.SET_LIKE): +// case onSuccess(ObjectsActionTypes.SET_TRACKS): +// case isLoading(ObjectsActionTypes.SET_TRACKS): +// case ObjectsActionTypes.UNSET_TRACK: +// return { +// ...state, +// [ObjectTypes.PLAYLISTS]: objectGroup(state[ObjectTypes.PLAYLISTS], action) +// }; +// case AppActionTypes.RESET_STORE: +// return initialState; +// default: +// return state; +// } +// }; diff --git a/src/common/store/objects/selectors.ts b/src/common/store/objects/selectors.ts index c1bef82c..2fb3885f 100644 --- a/src/common/store/objects/selectors.ts +++ b/src/common/store/objects/selectors.ts @@ -1,22 +1,25 @@ -import { Normalized } from '@types'; import { createSelector } from 'reselect'; // eslint-disable-next-line import/no-cycle -import { StoreState } from '..'; +import { StoreState } from '../rootReducer'; +import { RootState } from '../types'; import { ObjectGroup, ObjectState, ObjectTypes, PlaylistTypes } from './types'; +import { PlaylistIdentifier } from '../playlist/types'; export const getPlaylistsObjects = (state: StoreState) => state.objects[ObjectTypes.PLAYLISTS] || {}; +export const getPlaylistRootObject = (playlistType: PlaylistTypes | ObjectTypes) => (state: StoreState) => + state.objects[playlistType] || {}; export const getCommentsObjects = (state: StoreState) => state.objects[ObjectTypes.COMMENTS] || {}; -export const getPlaylistObjectSelector = (playlistId: string) => - createSelector | null>( - [getPlaylistsObjects], - playlists => (playlistId in playlists ? playlists[playlistId] : null) +export const getPlaylistObjectSelector = (identifier: PlaylistIdentifier) => + createSelector( + [getPlaylistRootObject(identifier.playlistType)], + playlistsOrObjectState => + identifier.objectId ? playlistsOrObjectState[identifier.objectId] : playlistsOrObjectState ); export const getCommentObject = (trackId: string) => - createSelector | null>( - [getCommentsObjects], - comments => (trackId in comments ? comments[trackId] : null) + createSelector([getCommentsObjects], comments => + trackId in comments ? comments[trackId] : null ); export const getPlaylistName = (id: string, playlistType: PlaylistTypes) => [id, playlistType].join('|'); diff --git a/src/common/store/objects/types.ts b/src/common/store/objects/types.ts index 1ac991de..dc8358cd 100644 --- a/src/common/store/objects/types.ts +++ b/src/common/store/objects/types.ts @@ -1,4 +1,5 @@ import { Normalized } from '@types'; +import { AxiosError } from 'axios'; // TYPES @@ -14,7 +15,8 @@ export enum PlaylistTypes { DISCOVER = 'DISCOVER', MYTRACKS = 'MYTRACKS', PLAYLIST = 'PLAYLIST', - PLAYLISTS = 'PLAYLISTS', + MYPLAYLISTS = 'MYPLAYLISTS', + CHART = 'CHART', // With ids RELATED = 'RELATED', @@ -27,22 +29,32 @@ export enum PlaylistTypes { } export type ObjectsState = Readonly<{ + [PlaylistTypes.STREAM]: ObjectState; + [PlaylistTypes.LIKES]: ObjectState; + [PlaylistTypes.MYTRACKS]: ObjectState; + [PlaylistTypes.MYPLAYLISTS]: ObjectState; + [PlaylistTypes.PLAYLIST]: ObjectState; + [PlaylistTypes.SEARCH]: ObjectState; + [PlaylistTypes.SEARCH_PLAYLIST]: ObjectState; + [PlaylistTypes.SEARCH_USER]: ObjectState; + [PlaylistTypes.SEARCH_TRACK]: ObjectState; + [ObjectTypes.PLAYLISTS]: ObjectGroup; [ObjectTypes.COMMENTS]: ObjectGroup; }>; export interface ObjectGroup { - [id: string]: ObjectState; + [id: string]: ObjectState; } -export interface ObjectState { +export interface ObjectState { isFetching: boolean; - error: string | null; - meta: object; - items: T[]; - futureUrl: string | null; - nextUrl: string | null; + error: AxiosError | Error | null; + items: Normalized.NormalizedResult[]; + nextUrl?: string | null; fetchedItems: number; + itemsToFetch: Normalized.NormalizedResult[]; + meta: { query?: string; createdAt?: number; updatedAt?: number }; } // ACTIONS diff --git a/src/common/store/player/actions.ts b/src/common/store/player/actions.ts index c4a1e89d..d3521b67 100755 --- a/src/common/store/player/actions.ts +++ b/src/common/store/player/actions.ts @@ -1,8 +1,8 @@ import { Intent } from '@blueprintjs/core'; +import { axiosClient } from '@common/api/helpers/axiosClient'; +import { Normalized, SoundCloud, ThunkResult } from '@types'; import _ from 'lodash'; -import { action } from 'typesafe-actions'; -import { ThunkResult } from '..'; -import { Normalized, SoundCloud } from '../../../types'; +import { action, createAction } from 'typesafe-actions'; import { getCurrentPosition } from '../../utils/playerUtils'; import * as SC from '../../utils/soundcloudUtils'; import { getPlaylistEntity, getTrackEntity } from '../entities/selectors'; @@ -21,11 +21,13 @@ import { ProcessedQueueItems, RepeatTypes } from './types'; -import { axiosClient } from '@common/api/helpers/axiosClient'; -export const setCurrentTime = (time: number) => action(PlayerActionTypes.SET_TIME, { time }); +export const toggleShuffle = createAction(PlayerActionTypes.TOGGLE_SHUFFLE)(); + +export const setCurrentTime = createAction(PlayerActionTypes.SET_TIME, (time: number) => ({ + time +}))(); export const setDuration = (time: number) => action(PlayerActionTypes.SET_DURATION, { time }); -export const toggleShuffle = (value: boolean) => action(PlayerActionTypes.TOGGLE_SHUFFLE, { value }); export const clearUpNext = () => action(PlayerActionTypes.CLEAR_UP_NEXT); export function getPlaylistObject(playlistId: string, position: number): ThunkResult> { diff --git a/src/common/store/player/epics.ts b/src/common/store/player/epics.ts new file mode 100644 index 00000000..71ac7283 --- /dev/null +++ b/src/common/store/player/epics.ts @@ -0,0 +1,12 @@ +import { Epic } from 'redux-observable'; +import { filter, map } from 'rxjs/operators'; +import { isActionOf } from 'typesafe-actions'; +import { setConfigKey } from '../config/actions'; +import { toggleShuffle } from './actions'; + +export const toggleShuffleEpic: Epic = action$ => + action$.pipe( + filter(isActionOf(toggleShuffle)), + map(action => action.payload), + map(shuffle => setConfigKey('shuffle', shuffle)) + ); diff --git a/src/common/store/player/selectors.ts b/src/common/store/player/selectors.ts index a82e7694..8fae815b 100644 --- a/src/common/store/player/selectors.ts +++ b/src/common/store/player/selectors.ts @@ -1,7 +1,7 @@ -import { createSelector } from 'reselect'; -import { StoreState } from '..'; import { Normalized } from '@types'; -import { PlayerState, PlayingTrack } from './types'; +import { createSelector } from 'reselect'; +import { StoreState } from '../rootReducer'; +import { PlayerState, PlayerStatus, PlayingTrack } from './types'; export const getPlayer = (state: StoreState) => state.player; @@ -10,6 +10,11 @@ export const getPlayingTrack = createSelector player.playingTrack ); +export const getPlayerStatusSelector = createSelector( + [getPlayer], + player => player.status +); + export const getQueue = createSelector( [getPlayer], player => player.queue || [] diff --git a/src/common/store/playlist/actions.ts b/src/common/store/playlist/actions.ts index 7b62a668..859950ef 100755 --- a/src/common/store/playlist/actions.ts +++ b/src/common/store/playlist/actions.ts @@ -1,13 +1,62 @@ import { Intent } from '@blueprintjs/core'; -import { Normalized } from '@types'; -import { ThunkResult } from '..'; +import { PersonalisedCollectionItem } from '@common/api/fetchPersonalised'; +import { wError, wSuccess } from '@common/utils/reduxUtils'; +import { Collection, EntitiesOf, EpicFailure, Normalized, SoundCloud, ThunkResult } from '@types'; +import { createAction, createAsyncAction } from 'typesafe-actions'; import fetchToJson from '../../api/helpers/fetchToJson'; import { SC } from '../../utils'; import { getPlaylistEntity } from '../entities/selectors'; import { ObjectsActionTypes, ObjectTypes } from '../objects'; import { getPlaylistObjectSelector } from '../objects/selectors'; import { addToast } from '../ui/actions'; +import { PlaylistActionTypes, PlaylistIdentifier, SortTypes } from './types'; + +interface ObjectItem extends PlaylistIdentifier { + objectType: ObjectTypes; + entities: EntitiesOf; + result: Normalized.NormalizedResult[]; + nextUrl?: string; + fetchedItemsIds?: number[]; +} +export const getGenericPlaylist = createAsyncAction( + PlaylistActionTypes.GET_GENERIC_PLAYLIST, + wSuccess(PlaylistActionTypes.GET_GENERIC_PLAYLIST), + wError(PlaylistActionTypes.GET_GENERIC_PLAYLIST) +)< + PlaylistIdentifier & { refresh: boolean; sortType?: SortTypes; searchString?: string }, + ObjectItem & { refresh?: boolean; query?: string }, + EpicFailure & PlaylistIdentifier +>(); + +export const genericPlaylistFetchMore = createAsyncAction( + PlaylistActionTypes.GENERIC_PLAYLIST_FETCH_MORE, + wSuccess(PlaylistActionTypes.GENERIC_PLAYLIST_FETCH_MORE), + wError(PlaylistActionTypes.GENERIC_PLAYLIST_FETCH_MORE) +)(); + +export const setPlaylistLoading = createAction(PlaylistActionTypes.SET_PLAYLIST_LOADING)(); + +export const getSearchPlaylist = createAction(PlaylistActionTypes.SEARCH)< + { query?: string; tag?: string; refresh: boolean } & PlaylistIdentifier +>(); +export const searchPlaylistFetchMore = createAction(PlaylistActionTypes.SEARCH_FETCH_MORE)(); + +export const getForYouSelection = createAsyncAction( + PlaylistActionTypes.GET_FORYOU_SELECTION, + wSuccess(PlaylistActionTypes.GET_FORYOU_SELECTION), + wError(PlaylistActionTypes.GET_FORYOU_SELECTION) +)< + undefined, + { + objects: ForYourObject[]; + entities: EntitiesOf & { tracks: Normalized.NormalizedResult[] }>; + result: Array; + }, + EpicFailure +>(); + +export type ForYourObject = Omit & { objectId: string }; /** * Add track to certain playlist */ diff --git a/src/common/store/playlist/api.ts b/src/common/store/playlist/api.ts new file mode 100644 index 00000000..6242ae3f --- /dev/null +++ b/src/common/store/playlist/api.ts @@ -0,0 +1,217 @@ +import fetchToJsonNew from '@common/api/helpers/fetchToJsonNew'; +import { Collection, SoundCloud } from '@types'; +import { SC } from '@common/utils'; +import { SortTypes } from './types'; + +// Stream +export interface FeedItem { + type: 'playlist' | 'track' | 'track-repost' | 'playlist-repost'; + playlist?: SoundCloud.Playlist; + track?: SoundCloud.Track; + user: SoundCloud.CompactUser; +} + +export async function fetchStream(options: { limit?: number }) { + const json = await fetchToJsonNew>({ + uri: 'stream', + oauthToken: true, + useV2Endpoint: true, + queryParams: { + limit: options.limit ?? 20 + } + }); + + return json; +} + +// Likes +export interface LikeItem { + kind: 'like'; + track: SoundCloud.Track; + created_at: string; +} + +export async function fetchLikes(options: { userId?: string | number; limit?: number }) { + const json = await fetchToJsonNew>({ + uri: `users/${options.userId}/track_likes`, + oauthToken: true, + useV2Endpoint: true, + queryParams: { + limit: options.limit ?? 20 + } + }); + + return json; +} + +// My Playlists +export interface PlaylistItem { + type: 'playlist-like' | 'playlist'; + playlist: SoundCloud.Playlist; + user: SoundCloud.User; + created_at: string; + uuid: string; +} + +export async function fetchPlaylists(options: { limit?: number }) { + const json = await fetchToJsonNew>({ + uri: `me/library/albums_playlists_and_system_playlists`, + oauthToken: true, + useV2Endpoint: true, + queryParams: { + limit: options.limit ?? 20 + } + }); + + return json; +} + +// My tracks +export async function fetchMyTracks(options: { userId?: string | number; limit?: number }) { + const json = await fetchToJsonNew>({ + uri: `users/${options.userId}/tracks`, + oauthToken: true, + useV2Endpoint: true, + queryParams: { + limit: options.limit ?? 20 + } + }); + + return json; +} + +// Charts +export interface ChartItem { + score: number; + track: SoundCloud.Track; +} + +export async function fetchCharts(options: { limit?: number; sort?: SortTypes; genre: string }) { + const json = await fetchToJsonNew>({ + uri: `charts`, + oauthToken: true, + useV2Endpoint: true, + queryParams: { + limit: options.limit ?? 20, + kind: options.sort || SortTypes.TOP, + genre: options.genre + } + }); + + return json; +} + +// Fetch playlist +export async function fetchPlaylist(options: { limit?: number; playlistId: number | string }) { + const json = await fetchToJsonNew({ + uri: `playlists/${options.playlistId}`, + oauthToken: true, + useV2Endpoint: true + }); + + return json; +} + +// Fetch seperate tracks +export async function fetchTracks(options: { ids: number[] }) { + const json = await fetchToJsonNew({ + uri: `tracks`, + oauthToken: true, + useV2Endpoint: true, + queryParams: { + ids: options.ids.join(',') + } + }); + + return json; +} + +export async function fetchFromUrl(url: string) { + const json = await fetchToJsonNew( + { + oauthToken: true, + useV2Endpoint: true + }, + { + url: SC.appendToken(url) + } + ); + + return json; +} + +interface SearchAllResponse { + collection: SearchCollectionItem[]; + next_href?: string; + query_urn: string; + total_results: number; + facets: { value: 'sound' | 'set' | 'person'; count: number; filter: string }[]; +} + +export type SearchCollectionItem = SoundCloud.Track | SoundCloud.Playlist | SoundCloud.User; + +// SearchByQuery +export async function searchAll(options: { + query?: string; + limit: number; + type?: 'users' | 'playlists_without_albums' | 'tracks'; + genre?: string; +}) { + const queryParams = { + q: options.query || '', + limit: options.limit || 20 + // facet: 'model' + }; + + if (options.genre) { + queryParams['filter.genre'] = options.genre; + } + + const json = await fetchToJsonNew({ + uri: `search${options.type ? `/${options.type}` : ''}`, + oauthToken: true, + useV2Endpoint: true, + queryParams + }); + + return json; +} + +export async function fetchPlaylistsByTag(options: { limit?: number; tag: string }) { + const json = await fetchToJsonNew>({ + uri: `playlists/discovery`, + oauthToken: true, + useV2Endpoint: true, + queryParams: { + limit: options.limit ?? 20, + tag: options.tag + } + }); + + return json; +} + +export interface PersonalisedCollectionItem { + urn: string; + last_updated: string; + kind: 'selection'; + query_urn: string; + social_proof: SoundCloud.CompactUser | null; + social_proof_users: any | null; + description: string; + style: string | null; + id: string; + title: string; + tracking_feature_name: string; + items: Collection; +} + +export async function fetchPersonalizedPlaylists() { + const json = await fetchToJsonNew>({ + uri: `mixed-selections`, + oauthToken: true, + useV2Endpoint: true + }); + + return json; +} diff --git a/src/common/store/playlist/epics.ts b/src/common/store/playlist/epics.ts new file mode 100644 index 00000000..73991285 --- /dev/null +++ b/src/common/store/playlist/epics.ts @@ -0,0 +1,562 @@ +import { playlistSchema, trackSchema, userSchema } from '@common/schemas'; +import { SC } from '@common/utils'; +import { EpicError } from '@common/utils/errors/EpicError'; +import { Collection, EntitiesOf, Normalized, SoundCloud, ResultOf } from '@types'; +import { RootState } from 'AppReduxTypes'; +import { AxiosError } from 'axios'; +import { isEqual, uniqWith } from 'lodash'; +import { normalize, schema } from 'normalizr'; +import { EMPTY, from, of, throwError } from 'rxjs'; +import { catchError, filter, map, startWith, switchMap, tap, withLatestFrom } from 'rxjs/operators'; +import { isActionOf } from 'typesafe-actions'; +import { currentUserSelector } from '../auth/selectors'; +import { ObjectTypes, PlaylistTypes } from '../objects'; +import { getPlaylistObjectSelector } from '../objects/selectors'; +import { RootEpic } from '../types'; +import { + genericPlaylistFetchMore, + getGenericPlaylist, + getSearchPlaylist, + searchPlaylistFetchMore, + setPlaylistLoading, + getForYouSelection, + ForYourObject +} from './actions'; +import * as APIService from './api'; + +const handleEpicError = (error: any) => { + if ((error as AxiosError).isAxiosError) { + console.log(error.response); + } + console.error(error?.message); + // TODO Sentry? + return error; +}; + +export const getGenericPlaylistEpic: RootEpic = (action$, state$) => + action$.pipe( + filter(isActionOf(getGenericPlaylist.request)), + tap(action => console.log(`${action.type} from ${process.type}`)), + map(action => action.payload), + withLatestFrom(state$), + switchMap(([{ playlistType, objectId, refresh, sortType }, state]) => { + const { + config: { hideReposts } + } = state; + const me = currentUserSelector(state); + + let ob$; + + switch (playlistType) { + case PlaylistTypes.STREAM: + ob$ = APIService.fetchStream({ limit: hideReposts ? 42 : 21 }); + break; + case PlaylistTypes.LIKES: + ob$ = APIService.fetchLikes({ limit: 21, userId: me?.id || '' }); + break; + case PlaylistTypes.MYTRACKS: + ob$ = APIService.fetchMyTracks({ limit: 21, userId: me?.id || '' }); + break; + case PlaylistTypes.MYPLAYLISTS: + ob$ = APIService.fetchPlaylists({ limit: 21 }); + break; + case PlaylistTypes.PLAYLIST: + if (!objectId) { + ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); + break; + } + ob$ = APIService.fetchPlaylist({ playlistId: objectId }); + break; + case PlaylistTypes.CHART: + if (!objectId) { + ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); + break; + } + ob$ = APIService.fetchCharts({ limit: 21, genre: objectId, sort: sortType }); + break; + default: + ob$ = throwError(new EpicError(`${playlistType}: ${objectId} not found`)); + } + + return from(ob$).pipe( + map(json => { + switch (playlistType) { + case PlaylistTypes.STREAM: + return processStreamItems(state)(json as Collection); + case PlaylistTypes.LIKES: + return processLikeItems(state)(json as Collection); + case PlaylistTypes.MYTRACKS: + return processMyTracks(state)(json as Collection); + case PlaylistTypes.MYPLAYLISTS: + return processStreamItems(state)(json as Collection); + case PlaylistTypes.PLAYLIST: + return processPlaylist(state)(json as SoundCloud.Playlist, objectId); + case PlaylistTypes.CHART: + return processCharts(state)(json as Collection); + default: + return { + json, + normalized: normalize(json, playlistSchema) + }; + } + }), + map(data => + getGenericPlaylist.success({ + objectId, + playlistType, + objectType: ObjectTypes.PLAYLISTS, + entities: data.normalized.entities, + result: data.normalized.result, + refresh, + nextUrl: data.json?.['next_href'], + fetchedItemsIds: data?.['fetchedItemsIds'] + }) + ), + catchError(error => + of( + getGenericPlaylist.failure({ + error: handleEpicError(error), + objectId, + playlistType + }) + ) + ) + ); + }) + ); + +export const genericPlaylistFetchMoreEpic: RootEpic = (action$, state$) => + action$.pipe( + filter(isActionOf(genericPlaylistFetchMore.request)), + withLatestFrom(state$), + map(([{ payload }, state]) => { + const { objectId, playlistType } = payload; + + const object = getPlaylistObjectSelector({ objectId, playlistType })(state); + + return { + payload, + object + }; + }), + // Don't do anything if we are already fetching this playlist + filter(({ object, payload }) => { + if (payload.playlistType !== PlaylistTypes.PLAYLIST) { + return !!object && !object.isFetching && !!object.nextUrl; + } + + return !!object && !object.isFetching && !!object.itemsToFetch.length; + }), + withLatestFrom(state$), + switchMap(([{ object, payload }, state]) => { + const { playlistType, objectId } = payload; + const urlWithToken = SC.appendToken(object?.nextUrl as string); + const itemsToFetch = (object?.itemsToFetch.map(i => i.id) || []).slice(0, 15); + + let ob$; + + if (playlistType === PlaylistTypes.PLAYLIST) { + ob$ = APIService.fetchTracks({ ids: itemsToFetch }); + } else { + ob$ = APIService.fetchFromUrl(urlWithToken); + } + + return from(ob$).pipe( + map(json => { + switch (playlistType) { + case PlaylistTypes.STREAM: + return processStreamItems(state)(json); + case PlaylistTypes.LIKES: + return processLikeItems(state)(json); + case PlaylistTypes.MYTRACKS: + return processMyTracks(state)(json); + case PlaylistTypes.MYPLAYLISTS: + return processStreamItems(state)(json); + case PlaylistTypes.PLAYLIST: + return processPlaylistTracks(state)(json, itemsToFetch); + case PlaylistTypes.CHART: + return processCharts(state)(json); + default: + return { + json, + normalized: normalize(json, playlistSchema) + }; + } + }), + map(data => + genericPlaylistFetchMore.success({ + objectId, + playlistType, + entities: data.normalized.entities, + objectType: ObjectTypes.PLAYLISTS, + result: data.normalized.result, + nextUrl: data.json?.['next_href'], + fetchedItemsIds: data?.['fetchedItemsIds'] + }) + ), + catchError(error => + of( + genericPlaylistFetchMore.failure({ + error: handleEpicError(error), + objectId, + playlistType + }) + ) + ), + startWith(setPlaylistLoading({ objectId, playlistType })) + ); + }) + ); + +export const searchEpic: RootEpic = (action$, state$) => + action$.pipe( + filter(isActionOf(getSearchPlaylist)), + tap(action => console.log(`${action.type} from ${process.type}`)), + map(action => action.payload), + switchMap(({ playlistType, objectId, query, tag, refresh }) => { + // TODO + // if (query && isSoundCloudUrl(query)) { + // return Promise.resolve(tryAndResolveQueryAsSoundCloudUrl(query, dispatch)) as any; + // } + + let ob$; + + switch (playlistType) { + case PlaylistTypes.SEARCH: + if (query && query.length) { + ob$ = APIService.searchAll({ query, limit: 21 }); + } + break; + case PlaylistTypes.SEARCH_TRACK: + if (query && query.length) { + ob$ = APIService.searchAll({ query, limit: 21, type: 'tracks' }); + } else if (tag) { + ob$ = APIService.searchAll({ genre: tag, limit: 21, type: 'tracks' }); + } + break; + case PlaylistTypes.SEARCH_PLAYLIST: + if (query && query.length) { + ob$ = APIService.searchAll({ query, limit: 21, type: 'playlists_without_albums' }); + } else if (tag) { + ob$ = APIService.fetchPlaylistsByTag({ tag, limit: 21 }); + } + break; + case PlaylistTypes.SEARCH_USER: + if (query && query.length) { + ob$ = APIService.searchAll({ query, limit: 21, type: 'users' }); + } + break; + default: + } + + return from(ob$ || EMPTY).pipe( + map(processCollection), + map(data => + getGenericPlaylist.success({ + objectId, + playlistType, + objectType: ObjectTypes.PLAYLISTS, + entities: data.normalized.entities, + result: data.normalized.result, + refresh, + nextUrl: data.json?.['next_href'], + fetchedItemsIds: data?.['fetchedItemsIds'], + query: query || tag + }) + ), + catchError(error => + of( + getGenericPlaylist.failure({ + error: handleEpicError(error), + objectId, + playlistType + }) + ) + ) + ); + }) + ); + +export const searchFetchMoreEpic: RootEpic = (action$, state$) => + action$.pipe( + filter(isActionOf(searchPlaylistFetchMore)), + withLatestFrom(state$), + map(([{ payload }, state]) => { + const { objectId, playlistType } = payload; + + const object = getPlaylistObjectSelector({ objectId, playlistType })(state); + + return { + payload, + object + }; + }), + // Don't do anything if we are already fetching this playlist + filter(({ object }) => !!object && !object.isFetching && !!object.nextUrl), + switchMap(({ object, payload }) => { + const { playlistType, objectId } = payload; + const urlWithToken = SC.appendToken(object?.nextUrl as string); + + return from(APIService.fetchFromUrl(urlWithToken)).pipe( + map(processCollection), + map(data => + genericPlaylistFetchMore.success({ + objectId, + playlistType, + entities: data.normalized.entities, + objectType: ObjectTypes.PLAYLISTS, + result: data.normalized.result, + nextUrl: data.json?.['next_href'], + fetchedItemsIds: data?.['fetchedItemsIds'] + }) + ), + catchError(error => + of( + genericPlaylistFetchMore.failure({ + error: handleEpicError(error), + objectId, + playlistType + }) + ) + ), + startWith(setPlaylistLoading({ objectId, playlistType })) + ); + }) + ); + +export const getForYouSelectionEpic: RootEpic = action$ => + // @ts-ignore + action$.pipe( + filter(isActionOf(getForYouSelection.request)), + tap(action => console.log(`${action.type} from ${process.type}`)), + // map(action => action.payload), + switchMap(() => { + return from(APIService.fetchPersonalizedPlaylists()).pipe( + map(json => { + const collection = json.collection.filter(t => t.urn.indexOf('chart') === -1); + + const normalized = normalize< + SoundCloud.Playlist, + EntitiesOf & { tracks: Normalized.NormalizedResult[] }>, + Array + >( + collection, + new schema.Array( + new schema.Object({ + items: { + collection: new schema.Array(playlistSchema) + } + }) + ) + ); + + const objects: ForYourObject[] = []; + + normalized.result.forEach(playlistResult => { + (playlistResult.items.collection || []).forEach(playlistId => { + if (normalized.entities.playlistEntities && normalized.entities.playlistEntities[playlistId]) { + const playlist = normalized.entities.playlistEntities[playlistId]; + + objects.push({ + playlistType: PlaylistTypes.PLAYLIST, + objectId: playlistId, + result: playlist.tracks || [], + fetchedItemsIds: [], + objectType: ObjectTypes.PLAYLISTS + }); + } + }); + }); + + return getForYouSelection.success({ + objects, + entities: normalized.entities, + result: normalized.result + }); + }), + catchError(error => + of( + getForYouSelection.failure({ + error: handleEpicError(error) + }) + ) + ) + ); + }) + ); + +const processStreamItems = (state: RootState) => (json: Collection) => { + const { + config: { hideReposts } + } = state; + + const processedCollection = json.collection + .filter(info => { + if (hideReposts) { + return !info.type.endsWith('repost'); + } + + // Filter out empty playlists + return (info as APIService.FeedItem).track || (info.playlist && info.playlist.track_count); + }) + .map(item => { + const obj = (item as APIService.FeedItem).track || (item.playlist as SoundCloud.Playlist); + + obj.fromUser = item.user; + obj.type = item.type; + + return obj; + }); + + const normalized = normalize, Normalized.NormalizedResult[]>( + processedCollection, + new schema.Array( + { + tracks: trackSchema, + playlists: playlistSchema, + users: userSchema + }, + input => `${input.kind}s` + ) + ); + + // Stream could have duplicate items + normalized.result = uniqWith(normalized.result, isEqual); + + return { + json, + normalized + }; +}; + +const processLikeItems = (state: RootState) => (json: Collection) => { + const processedCollection = json.collection.map(({ track }) => track); + const normalized = normalize, Normalized.NormalizedResult[]>( + processedCollection, + new schema.Array( + { + tracks: trackSchema + }, + input => `${input.kind}s` + ) + ); + + return { + json, + normalized + }; +}; + +const processMyTracks = (state: RootState) => (json: Collection) => { + const normalized = normalize, Normalized.NormalizedResult[]>( + json.collection, + new schema.Array( + { + tracks: trackSchema + }, + input => `${input.kind}s` + ) + ); + + return { + json, + normalized + }; +}; +const processCharts = (state: RootState) => (json: Collection) => { + const processedCollection = json.collection.map(item => { + const { track } = item; + track.score = item.score; + + return track; + }); + + const normalized = normalize, Normalized.NormalizedResult[]>( + processedCollection, + new schema.Array( + { + tracks: trackSchema + }, + input => `${input.kind}s` + ) + ); + + return { + json, + normalized + }; +}; + +const processPlaylist = (state: RootState) => (json: SoundCloud.Playlist, objectId?: string) => { + if (!objectId) { + throw new Error(`processPlaylist: objectId=${objectId} must be defined`); + } + + const normalized = normalize, Normalized.NormalizedResult[]>( + json, + playlistSchema + ); + + if (normalized.entities && normalized.entities.playlistEntities) { + const playlist = normalized.entities.playlistEntities[objectId]; + + let fetchedItems: Partial[] = []; + + if (json.tracks) { + fetchedItems = json.tracks.filter((t: Partial) => t.user !== undefined); + } + + const fetchedItemsIds = fetchedItems.map(item => item.id); + + return { + json, + normalized: { + ...normalized, + result: playlist.tracks + }, + fetchedItemsIds + }; + } + + return { + json, + normalized + }; +}; + +const processPlaylistTracks = (state: RootState) => (json: SoundCloud.Track[], fetchedItemsIds: number[]) => { + const normalized = normalize, Normalized.NormalizedResult[]>( + json, + new schema.Array( + { + tracks: trackSchema + }, + input => `${input.kind}s` + ) + ); + + return { + json, + normalized, + fetchedItemsIds + }; +}; + +const processCollection = (json: Collection) => { + const normalized = normalize( + json.collection, + new schema.Array( + { + playlists: playlistSchema, + tracks: trackSchema, + users: userSchema + }, + input => `${input.kind}s` + ) + ); + + return { + json, + normalized + }; +}; diff --git a/src/common/store/playlist/types.ts b/src/common/store/playlist/types.ts index e58001f9..9180c225 100644 --- a/src/common/store/playlist/types.ts +++ b/src/common/store/playlist/types.ts @@ -1,8 +1,26 @@ -// TYPES +import { ObjectTypes, PlaylistTypes } from '../objects'; -// ACTIONS +// TYPES export enum SortTypes { TOP = 'top', TRENDING = 'trending' } + +export type PlaylistIdentifier = { + objectId?: string; + playlistType: PlaylistTypes | ObjectTypes; +}; + +// ACTIONS + +export enum PlaylistActionTypes { + GET_GENERIC_PLAYLIST = '@@playlist/GET_GENERIC_PLAYLIST', + SET_PLAYLIST_LOADING = '@@playlist/SET_PLAYLIST_LOADING', + GENERIC_PLAYLIST_FETCH_MORE = '@@playlist/GENERIC_PLAYLIST_FETCH_MORE', + + SEARCH = '@@playlist/SEARCH', + SEARCH_FETCH_MORE = '@@playlist/SEARCH_FETCH_MORE', + + GET_FORYOU_SELECTION = '@@playlist/GET_FORYOU_SELECTION' +} diff --git a/src/common/store/rootEpic.ts b/src/common/store/rootEpic.ts new file mode 100644 index 00000000..1673fd0f --- /dev/null +++ b/src/common/store/rootEpic.ts @@ -0,0 +1,17 @@ +import { RootState } from 'AppReduxTypes'; +import { combineEpics } from 'redux-observable'; +import * as app from './app/epics'; +import * as appAuth from './appAuth/epics'; +import * as auth from './auth/epics'; +// import * as objects from './objects/epics'; +import * as playlist from './playlist/epics'; +import { RootAction } from './types'; +import * as ui from './ui/epics'; + +export const rootEpic = combineEpics( + ...Object.values(app), + ...Object.values(appAuth), + ...Object.values(ui), + ...Object.values(auth), + ...Object.values(playlist) +); diff --git a/src/common/store/rootReducer.ts b/src/common/store/rootReducer.ts new file mode 100755 index 00000000..a66ce3ae --- /dev/null +++ b/src/common/store/rootReducer.ts @@ -0,0 +1,39 @@ +import { connectRouter, RouterState } from 'connected-react-router'; +import { MemoryHistory } from 'history'; +import { combineReducers } from 'redux'; +import { reducer as modal } from 'redux-modal'; +import { appReducer, AppState } from './app'; +import { appAuthReducer, AppAuthState } from './appAuth'; +import { authReducer, AuthState } from './auth'; +import { configReducer, ConfigState } from './config'; +import { entitiesReducer, EntitiesState } from './entities'; +import { objectsReducer, ObjectsState } from './objects'; +import { playerReducer, PlayerState } from './player'; +import { uiReducer, UIState } from './ui'; + +export const rootReducer = (history: MemoryHistory) => + combineReducers({ + auth: authReducer, + appAuth: appAuthReducer, + entities: entitiesReducer, + player: playerReducer, + objects: objectsReducer, + app: appReducer, + config: configReducer, + ui: uiReducer, + modal, + router: connectRouter(history) + }); + +export interface StoreState { + appAuth: AppAuthState; + auth: AuthState; + entities: EntitiesState; + player: PlayerState; + objects: ObjectsState; + app: AppState; + config: ConfigState; + ui: UIState; + router: RouterState; + modal: any; +} diff --git a/src/common/store/selector.ts b/src/common/store/selector.ts index 8caa95f4..f24c7603 100644 --- a/src/common/store/selector.ts +++ b/src/common/store/selector.ts @@ -1,3 +1,3 @@ -import { StoreState } from '.'; +import { RootState } from 'AppReduxTypes'; -export const getRouter = (state: StoreState) => state.router; +export const getRouter = (state: RootState) => state.router; diff --git a/src/common/store/track/actions.ts b/src/common/store/track/actions.ts index 2bd69f04..ccc28058 100644 --- a/src/common/store/track/actions.ts +++ b/src/common/store/track/actions.ts @@ -1,31 +1,35 @@ import { Intent } from '@blueprintjs/core'; -import moment from 'moment'; +import { axiosClient } from '@common/api/helpers/axiosClient'; // eslint-disable-next-line import/no-cycle -import { ThunkResult } from '..'; +import { ThunkResult } from '@types'; +import moment from 'moment'; // eslint-disable-next-line import/no-cycle import fetchTrack from '../../api/fetchTrack'; import fetchToJson from '../../api/helpers/fetchToJson'; import { IPC } from '../../utils/ipc'; import * as SC from '../../utils/soundcloudUtils'; +import { currentUserSelector } from '../auth/selectors'; import { AuthActionTypes } from '../auth/types'; // eslint-disable-next-line import/no-cycle import { getTrackEntity } from '../entities/selectors'; // eslint-disable-next-line import/no-cycle -import { getComments, getPlaylist } from '../objects/actions'; +import { getComments, getPlaylistO } from '../objects/actions'; // eslint-disable-next-line import/no-cycle import { getCommentObject, getPlaylistName, getRelatedTracksPlaylistObject } from '../objects/selectors'; import { PlaylistTypes } from '../objects/types'; import { addToast } from '../ui/actions'; import { TrackActionTypes } from './types'; -import { axiosClient } from '@common/api/helpers/axiosClient'; -export function toggleLike(trackId: number, playlist = false): ThunkResult { +export function toggleLike(trackId: number | string, playlist = false): ThunkResult { return (dispatch, getState) => { + const state = getState(); const { - auth: { likes, me } - } = getState(); + auth: { likes } + } = state; - if (!me) { + const currentUser = currentUserSelector(state); + + if (!currentUser) { return; } @@ -33,7 +37,7 @@ export function toggleLike(trackId: number, playlist = false): ThunkResult dispatch>({ type: AuthActionTypes.SET_LIKE, - payload: fetchToJson(playlist ? SC.updatePlaylistLikeUrl(me.id, trackId) : SC.updateLikeUrl(trackId), { + payload: fetchToJson(playlist ? SC.updatePlaylistLikeUrl(currentUser.id, trackId) : SC.updateLikeUrl(trackId), { method: liked ? 'PUT' : 'DELETE' }).then(() => { if (liked) { @@ -80,17 +84,17 @@ export function toggleLike(trackId: number, playlist = false): ThunkResult * Toggle repost of a specific track */ -export function toggleRepost(trackId: number, playlist = false): ThunkResult> { +export function toggleRepost(trackOrPlaylistId: number | string, playlist = false): ThunkResult> { return async (dispatch, getState) => { const { auth: { reposts } } = getState(); - const reposted = !SC.hasID(trackId, playlist ? reposts.playlist : reposts.track); + const reposted = !SC.hasID(trackOrPlaylistId, playlist ? reposts.playlist : reposts.track); await dispatch>({ type: AuthActionTypes.SET_REPOST, - payload: axiosClient(SC.updateRepostUrl(trackId, !!playlist), { + payload: axiosClient(SC.updateRepostUrl(trackOrPlaylistId, !!playlist), { method: reposted ? 'PUT' : 'DELETE' }).then(() => { if (reposted) { @@ -103,7 +107,7 @@ export function toggleRepost(trackId: number, playlist = false): ThunkResult { } if (!getRelatedTracksPlaylistObject(trackId.toString())(state)) { - dispatch(getPlaylist(SC.getRelatedUrl(trackId), relatedTracksPlaylistId, { appendId: trackId })); + dispatch(getPlaylistO(SC.getRelatedUrl(trackId), relatedTracksPlaylistId, { appendId: trackId })); } if (!getCommentObject(trackId.toString())(state)) { diff --git a/src/common/store/types.d.ts b/src/common/store/types.d.ts new file mode 100644 index 00000000..f730234c --- /dev/null +++ b/src/common/store/types.d.ts @@ -0,0 +1,80 @@ +import { routerActions, CallHistoryMethodAction, RouterState } from 'connected-react-router'; +import { Epic } from 'redux-observable'; +import { ActionType, StateType } from 'typesafe-actions'; +import * as app from './app/actions'; +import * as appAuth from './appAuth/actions'; +import * as auth from './auth/actions'; +import * as config from './config/actions'; +import * as objects from './objects/actions'; +import * as search from './objects/playlists/search/actions'; +import * as player from './player/actions'; +import * as playlist from './playlist/actions'; +import * as track from './track/actions'; +import * as ui from './ui/actions'; +import * as user from './user/actions'; +import { LocationState } from 'history'; +import { AppAuthState } from './appAuth'; +import { AuthState } from './auth'; +import { EntitiesState } from './entities'; +import { PlayerState } from './player'; +import { ObjectsState } from './objects'; +import { AppState } from './app'; +import { ConfigState } from './config'; +import { UIState } from './ui'; +import { RootState } from 'AppReduxTypes'; + +// Hack to fix https://github.com/supasate/connected-react-router/issues/286 +type Push = (path: Path, state?: LocationState) => CallHistoryMethodAction<[Path, LocationState?]>; +// type Go, etc. + +interface RouterActions { + push: Push; + replace: Push; + // go: Go; etc. +} + +export interface StoreState { + appAuth: AppAuthState; + auth: AuthState; + entities: EntitiesState; + player: PlayerState; + objects: ObjectsState; + app: AppState; + config: ConfigState; + ui: UIState; + router: RouterState; + modal: any; +} + +type actions = { + search: typeof search; + objects: typeof objects; + player: typeof player; + track: typeof track; + user: typeof user; + ui: typeof ui; + playlist: typeof playlist; + app: typeof app; + appAuth: typeof appAuth; + auth: typeof auth; + config: typeof config; + routerActions: typeof routerActions; +}; + +type _Store = StateType; +type _RootAction = ActionType | ActionType; + +export type Store = _Store; +export type RootAction = _RootAction; + +export type RootEpic = Epic; + +declare module 'typesafe-actions' { + interface Types { + RootAction: RootAction; + } +} + +declare module 'react-redux' { + export interface DefaultRootState extends RootState {} +} diff --git a/src/common/store/ui/actions.ts b/src/common/store/ui/actions.ts index c335d878..af743007 100644 --- a/src/common/store/ui/actions.ts +++ b/src/common/store/ui/actions.ts @@ -1,7 +1,14 @@ import { IToastOptions } from '@blueprintjs/core'; -import { action } from 'typesafe-actions'; -import { UIActionTypes } from './types'; +import { createAction } from 'typesafe-actions'; +import { UIActionTypes, Dimensions } from './types'; +import { wDebounce } from '@common/utils/reduxUtils'; -export const addToast = (toast: IToastOptions) => action(UIActionTypes.ADD_TOAST, { toast }); -export const removeToast = (key: string) => action(UIActionTypes.REMOVE_TOAST, { key }); -export const clearToasts = () => action(UIActionTypes.CLEAR_TOASTS); +export const addToast = createAction(UIActionTypes.ADD_TOAST)(); +export const removeToast = createAction(UIActionTypes.REMOVE_TOAST)(); +export const clearToasts = createAction(UIActionTypes.CLEAR_TOASTS)(); + +export const setDimensions = createAction(UIActionTypes.SET_DIMENSIONS)(); +export const setDebouncedDimensions = createAction(wDebounce(UIActionTypes.SET_DIMENSIONS))(); + +export const setSearchQuery = createAction(UIActionTypes.SET_SEARCH_QUERY)<{ query: string; noNavigation?: boolean }>(); +export const setDebouncedSearchQuery = createAction(wDebounce(UIActionTypes.SET_SEARCH_QUERY))(); diff --git a/src/common/store/ui/epics.ts b/src/common/store/ui/epics.ts new file mode 100644 index 00000000..d6065a28 --- /dev/null +++ b/src/common/store/ui/epics.ts @@ -0,0 +1,49 @@ +import { routerActions } from 'connected-react-router'; +import { of } from 'rxjs'; +import { debounceTime, filter, map, switchMap, withLatestFrom } from 'rxjs/operators'; +import { isActionOf } from 'typesafe-actions'; +import { PlaylistTypes } from '../objects'; +import { getSearchPlaylist } from '../playlist/actions'; +import { RootEpic } from '../types'; +import { setDebouncedDimensions, setDebouncedSearchQuery, setDimensions, setSearchQuery } from './actions'; + +export const setDebouncedDimensionsEpic: RootEpic = action$ => + action$.pipe( + filter(isActionOf(setDebouncedDimensions)), + debounceTime(500), + map(action => setDimensions(action.payload)) + ); + +export const setDebouncedSearchQueryEpic: RootEpic = action$ => + action$.pipe( + filter(isActionOf(setDebouncedSearchQuery)), + debounceTime(250), + map(action => + setSearchQuery({ + query: action.payload + }) + ) + ); + +export const setSearchQueryEpic: RootEpic = (action$, state$) => + // @ts-ignore + action$.pipe( + filter(isActionOf(setSearchQuery)), + map(action => action.payload), + withLatestFrom(state$), + switchMap(([{ query, noNavigation }, state]) => { + const { + router: { location } + } = state; + + const navigateToSearch = []; + + if (!noNavigation && !location.pathname.startsWith('/search')) { + navigateToSearch.push(routerActions.replace('/search')); + } + + const playlistType = (location.pathname.split('/search/')?.[1] as PlaylistTypes | null) || PlaylistTypes.SEARCH; + + return of(getSearchPlaylist({ query, playlistType, refresh: true }), ...navigateToSearch); + }) + ); diff --git a/src/common/store/ui/reducer.ts b/src/common/store/ui/reducer.ts index ba4e7bf3..3ff79bba 100644 --- a/src/common/store/ui/reducer.ts +++ b/src/common/store/ui/reducer.ts @@ -1,44 +1,48 @@ -import { Reducer } from 'redux'; -// eslint-disable-next-line import/no-cycle -import { AppActionTypes } from '../app'; -import { UIActionTypes, UIState } from './types'; +import { createReducer } from 'typesafe-actions'; +import { resetStore } from '../app/actions'; +import { addToast, clearToasts, removeToast, setDimensions, setSearchQuery } from './actions'; +import { UIState } from './types'; -const initialState = { - scrollTop: 0, - scrollPosition: {}, - toasts: [] +const initialState: UIState = { + toasts: [], + dimensions: { + width: 0, + height: 0 + }, + searchQuery: undefined }; -export const uiReducer: Reducer = (state = initialState, action) => { - const { payload, type } = action; - - switch (type) { - case UIActionTypes.SET_SCROLL_TOP: - return { - ...state, - scrollPosition: { - ...state.scrollPosition, - [payload.pathname]: payload.scrollTop - } - }; - case UIActionTypes.CLEAR_TOASTS: - return { - ...state, - toasts: [] - }; - case UIActionTypes.ADD_TOAST: - return { - ...state, - toasts: [...state.toasts, payload.toast] - }; - case UIActionTypes.REMOVE_TOAST: - return { - ...state, - toasts: [...state.toasts.filter(t => t.key === payload.key)] - }; - case AppActionTypes.RESET_STORE: - return initialState; - default: - return state; - } -}; +export const uiReducer = createReducer(initialState) + .handleAction(clearToasts, state => { + return { + ...state, + toasts: [] + }; + }) + .handleAction(addToast, (state, action) => { + return { + ...state, + toasts: [...state.toasts, action.payload] + }; + }) + .handleAction(removeToast, (state, action) => { + return { + ...state, + toasts: [...state.toasts.filter(t => t.key === action.payload)] + }; + }) + .handleAction(setDimensions, (state, action) => { + return { + ...state, + dimensions: action.payload + }; + }) + .handleAction(setSearchQuery, (state, action) => { + return { + ...state, + searchQuery: action.payload.query + }; + }) + .handleAction(resetStore, () => { + return initialState; + }); diff --git a/src/common/store/ui/selectors.ts b/src/common/store/ui/selectors.ts index c56defdc..8b2d4768 100644 --- a/src/common/store/ui/selectors.ts +++ b/src/common/store/ui/selectors.ts @@ -1,3 +1,7 @@ -import { StoreState } from '..'; +import { RootState } from 'AppReduxTypes'; +import { UIState } from './types'; +import { createSelector } from 'reselect'; -export const getUi = (state: StoreState) => state.ui; +export const getUi = (state: RootState) => state.ui; + +export const getSearchQuery = createSelector(getUi, (state: UIState) => state.searchQuery); diff --git a/src/common/store/ui/types.ts b/src/common/store/ui/types.ts index 256aae54..8883b3c5 100644 --- a/src/common/store/ui/types.ts +++ b/src/common/store/ui/types.ts @@ -2,19 +2,21 @@ import { IToastOptions } from '@blueprintjs/core'; // TYPES export type UIState = Readonly<{ - scrollTop: number; - scrollPosition: { - [path: string]: number; - }; toasts: IToastOptions[]; + dimensions: Dimensions; + searchQuery?: string; }>; +export interface Dimensions { + width: number; + height: number; +} + // ACTIONS export enum UIActionTypes { - TOGGLE_QUEUE = '@@ui/TOGGLE_QUEUE', - SET_SCROLL_TOP = '@@ui/SET_SCROLL_TOP', - ADD_TOAST = '@@ui/ADD_TOAST', REMOVE_TOAST = '@@ui/REMOVE_TOAST', - CLEAR_TOASTS = '@@ui/CLEAR_TOASTS' + CLEAR_TOASTS = '@@ui/CLEAR_TOASTS', + SET_DIMENSIONS = '@@ui/SET_DIMENSIONS', + SET_SEARCH_QUERY = '@@ui/SET_SEARCH_QUERY' } diff --git a/src/common/store/user/actions.ts b/src/common/store/user/actions.ts index 361215cb..deb9c9d6 100755 --- a/src/common/store/user/actions.ts +++ b/src/common/store/user/actions.ts @@ -1,11 +1,10 @@ -import { SoundCloud } from '@types'; +import { SoundCloud, ThunkResult } from '@types'; import fetchToJson from '../../api/helpers/fetchToJson'; import { SC } from '../../utils'; import { PlaylistTypes } from '../objects'; -import { getPlaylist } from '../objects/actions'; +import { getPlaylistO } from '../objects/actions'; import { getArtistLikesPlaylistObject, getArtistTracksPlaylistObject, getPlaylistName } from '../objects/selectors'; import { UserActionTypes } from './types'; -import { ThunkResult } from '..'; /** * Get and save user @@ -110,12 +109,14 @@ export function fetchArtistIfNeeded(userId: number): ThunkResult { if (!getArtistTracksPlaylistObject(userId.toString())(state)) { dispatch( - getPlaylist(SC.getUserTracksUrl(userId), getPlaylistName(userId.toString(), PlaylistTypes.ARTIST_TRACKS)) + getPlaylistO(SC.getUserTracksUrl(userId), getPlaylistName(userId.toString(), PlaylistTypes.ARTIST_TRACKS)) ); } if (!getArtistLikesPlaylistObject(userId.toString())(state)) { - dispatch(getPlaylist(SC.getUserLikesUrl(userId), getPlaylistName(userId.toString(), PlaylistTypes.ARTIST_LIKES))); + dispatch( + getPlaylistO(SC.getUserLikesUrl(userId), getPlaylistName(userId.toString(), PlaylistTypes.ARTIST_LIKES)) + ); } }; } diff --git a/src/common/utils/appUtils.ts b/src/common/utils/appUtils.ts index 48775543..e789e1bc 100755 --- a/src/common/utils/appUtils.ts +++ b/src/common/utils/appUtils.ts @@ -84,3 +84,7 @@ export function getReadableTimeFull(sec: number, inMs?: boolean) { return str; } + +export function isSoundCloudUrl(query: string) { + return /https?:\/\/(www.)?soundcloud\.com\//g.exec(query) !== null; +} diff --git a/src/common/utils/errors/EpicError.ts b/src/common/utils/errors/EpicError.ts new file mode 100644 index 00000000..784027f1 --- /dev/null +++ b/src/common/utils/errors/EpicError.ts @@ -0,0 +1,12 @@ +import { serializeError } from 'serialize-error'; + +export class EpicError extends Error { + constructor(message: string) { + super(message); + this.name = 'EpicError'; + } + + public toJSON() { + return serializeError(this); + } +} diff --git a/src/common/utils/ipc.ts b/src/common/utils/ipc.ts index 33e4cd1b..1cfed315 100644 --- a/src/common/utils/ipc.ts +++ b/src/common/utils/ipc.ts @@ -19,7 +19,7 @@ export class IPC { ipcRenderer.send(EVENTS.TRACK.REPOSTED); } - static notifyTrackLiked(trackId: number) { + static notifyTrackLiked(trackId: number | string) { ipcRenderer.send(EVENTS.TRACK.LIKED, trackId); } } diff --git a/src/common/utils/reduxUtils.ts b/src/common/utils/reduxUtils.ts index b552dbb0..06ca8b50 100755 --- a/src/common/utils/reduxUtils.ts +++ b/src/common/utils/reduxUtils.ts @@ -11,3 +11,14 @@ export function onSuccess(actionType: string): string { export function onError(actionType: string): string { return `${actionType}_${ActionType.Rejected}`; } + +export function wSuccess(actionType: string): typeof actionType { + return `${actionType}_SUCCESS`; +} + +export function wError(actionType: string): typeof actionType { + return `${actionType}_ERROR`; +} +export function wDebounce(actionType: string) { + return `${actionType}_DEBOUNCE`; +} diff --git a/src/common/utils/soundcloudUtils.ts b/src/common/utils/soundcloudUtils.ts index c0c404be..7fc9873a 100755 --- a/src/common/utils/soundcloudUtils.ts +++ b/src/common/utils/soundcloudUtils.ts @@ -3,9 +3,12 @@ import { IMAGE_SIZES } from '../constants'; const endpoint = 'https://api.soundcloud.com/'; const v2Endpoint = 'https://api-v2.soundcloud.com/'; -let memToken: string; + +// eslint-disable-next-line import/no-mutable-exports +export let memToken: string; export function initialize(token: string) { + console.log('initialize token'); memToken = token; } diff --git a/src/globals.d.ts b/src/globals.d.ts index 88386849..8ba50335 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -38,3 +38,11 @@ declare module 'electron-window-state'; declare module 'react-dotdotdot'; declare module 'color-hash'; declare module 'react-marquee'; + +declare module 'AppReduxTypes' { + import { StateType, ActionType } from 'typesafe-actions'; + + export type Store = StateType; + export type RootAction = ActionType; + export type RootState = StateType>; +} diff --git a/src/main/app.ts b/src/main/app.ts index 0ccd3205..9d8b50f6 100755 --- a/src/main/app.ts +++ b/src/main/app.ts @@ -1,9 +1,10 @@ import { Intent } from '@blueprintjs/core'; import fetchTrack from '@common/api/fetchTrack'; import { axiosClient } from '@common/api/helpers/axiosClient'; -import { EVENTS } from '@common/constants/events'; -import { StoreState } from '@common/store'; -import { addToast, setConfigKey } from '@common/store/actions'; +import { addToast, push, setConfigKey } from '@common/store/actions'; +import { StoreState } from '@common/store/rootReducer'; +// eslint-disable-next-line import/no-unresolved +import { RootState } from 'AppReduxTypes'; // eslint-disable-next-line import/no-extraneous-dependencies import { app, BrowserWindow, BrowserWindowConstructorOptions, Event, Menu, nativeImage, shell } from 'electron'; import is from 'electron-is'; @@ -39,9 +40,7 @@ export class Auryo { public quitting = false; private readonly logger: LoggerInstance = Logger.createLogger(Auryo.name); - constructor(store: Store) { - this.store = store; - + constructor() { app.setAppUserModelId('com.auryo.core'); app.on('before-quit', () => { @@ -67,6 +66,10 @@ export class Auryo { }); } + public setStore(store: Store) { + this.store = store; + } + public async start() { if (this.quitting) { return; @@ -211,20 +214,25 @@ export class Auryo { await this.mainWindow.loadURL(winURL); - this.mainWindow.webContents.on('will-navigate', async (e, u) => { - e.preventDefault(); + this.mainWindow.webContents.on('will-navigate', async (event, url) => { + event.preventDefault(); try { - if (/^(https?:\/\/)/g.exec(u) !== null) { - if (/https?:\/\/(www.)?soundcloud\.com\//g.exec(u) !== null) { + if (/^(https?:\/\/)/g.exec(url) !== null) { + if (/https?:\/\/(www.)?soundcloud\.com\//g.exec(url) !== null) { if (this.mainWindow) { - this.mainWindow.webContents.send(EVENTS.APP.PUSH_NAVIGATION, '/resolve', u); + this.store.dispatch( + push({ + pathname: '/resolve', + search: url + }) + ); } } else { - await shell.openExternal(u); + await shell.openExternal(url); } - } else if (/^mailto:/g.exec(u) !== null) { - await shell.openExternal(u); + } else if (/^mailto:/g.exec(url) !== null) { + await shell.openExternal(url); } } catch (err) { this.logger.error(err); diff --git a/src/main/aws/awsIotService.ts b/src/main/aws/awsIotService.ts index f56f183a..6c3a95e0 100644 --- a/src/main/aws/awsIotService.ts +++ b/src/main/aws/awsIotService.ts @@ -16,8 +16,8 @@ export interface AuthMessage { export interface TokenResponse { access_token: string; - expires_at: number; - refresh_token: string; + expires_at?: number; + refresh_token?: string; } export class AWSIotService { diff --git a/src/main/features/core/applicationMenu.ts b/src/main/features/core/applicationMenu.ts index 2fde6e8d..4e931870 100755 --- a/src/main/features/core/applicationMenu.ts +++ b/src/main/features/core/applicationMenu.ts @@ -1,6 +1,6 @@ import { EVENTS } from '@common/constants/events'; import { ChangeTypes, PlayerStatus, VolumeChangeTypes } from '@common/store/player'; -import { setConfigKey, changeTrack, toggleStatus } from '@common/store/actions'; +import { setConfigKey, changeTrack, toggleStatus, push } from '@common/store/actions'; import * as SC from '@common/utils/soundcloudUtils'; import { autobind } from 'core-decorators'; // eslint-disable-next-line import/no-extraneous-dependencies @@ -57,21 +57,39 @@ export default class ApplicationMenu extends Feature { { label: 'Edit', submenu: [ + { + label: 'Undo', + accelerator: 'CmdOrCtrl+Z', + role: 'undo' + }, + { + label: 'Redo', + accelerator: 'Shift+CmdOrCtrl+Z', + role: 'redo' + }, + { + type: 'separator' + }, { label: 'Cut', accelerator: 'CmdOrCtrl+X', - selector: 'cut:' - } as any, + role: 'cut' + }, { label: 'Copy', accelerator: 'CmdOrCtrl+C', - selector: 'copy:' - } as any, + role: 'copy' + }, { label: 'Paste', accelerator: 'CmdOrCtrl+V', - selector: 'paste:' - } as any, + role: 'paste' + }, + { + label: 'Select All', + accelerator: 'CmdOrCtrl+A', + role: 'selectAll' + }, { type: 'separator' }, @@ -207,7 +225,7 @@ export default class ApplicationMenu extends Feature { label: 'Preferences', accelerator: 'CmdOrCtrl+,', click: () => { - this.sendToWebContents(EVENTS.APP.PUSH_NAVIGATION, '/settings'); + this.store.dispatch(push('/settings')); } }, { type: 'separator' }, diff --git a/src/main/features/core/chromecast/chromecastManager.ts b/src/main/features/core/chromecast/chromecastManager.ts index 9a217e55..dd05985a 100755 --- a/src/main/features/core/chromecast/chromecastManager.ts +++ b/src/main/features/core/chromecast/chromecastManager.ts @@ -2,7 +2,7 @@ import { PlatformSender } from '@amilajack/castv2-client'; import { Intent } from '@blueprintjs/core'; import { IMAGE_SIZES } from '@common/constants'; import { EVENTS } from '@common/constants/events'; -import { StoreState } from '@common/store'; +import { StoreState } from '@common/store/rootReducer'; import { DevicePlayerStatus } from '@common/store/app'; import { getTrackEntity } from '@common/store/entities/selectors'; import { PlayerStatus } from '@common/store/player'; diff --git a/src/main/features/core/chromecast/deviceScanner.ts b/src/main/features/core/chromecast/deviceScanner.ts index 16d1eb58..24d0bc15 100644 --- a/src/main/features/core/chromecast/deviceScanner.ts +++ b/src/main/features/core/chromecast/deviceScanner.ts @@ -1,4 +1,4 @@ -import { StoreState } from '@common/store'; +import { StoreState } from '@common/store/rootReducer'; import { ChromeCastDevice } from '@common/store/app'; import { addChromeCastDevice, removeChromeCastDevice } from '@common/store/actions'; import createMdnsInterface from 'multicast-dns'; diff --git a/src/main/features/core/ipcManager.ts b/src/main/features/core/ipcManager.ts index 2bf1f695..6de1aed4 100755 --- a/src/main/features/core/ipcManager.ts +++ b/src/main/features/core/ipcManager.ts @@ -1,6 +1,5 @@ import { EVENTS } from '@common/constants/events'; -import { setLoginError, setLoginLoading } from '@common/store/auth/actions'; -import { setLogin } from '@common/store/config/actions'; +import { login, loginError, loginTerminated, refreshToken, loginSuccess } from '@common/store/appAuth/actions'; import { createAuthWindow } from '@main/authWindow'; import { AWSApiGatewayService } from '@main/aws/awsApiGatewayService'; import { AWSIotService } from '@main/aws/awsIotService'; @@ -12,9 +11,11 @@ import _ from 'lodash'; import { CONFIG } from '../../../config'; import { Logger, LoggerInstance } from '../../utils/logger'; import { Feature } from '../feature'; + @autobind export default class IPCManager extends Feature { public readonly featureName = 'IPCManager'; + public authWindow: Electron.BrowserWindow | null = null; private readonly logger: LoggerInstance = Logger.createLogger(this.featureName); private readonly awsApiGateway: AWSApiGatewayService = new AWSApiGatewayService(); @@ -79,28 +80,23 @@ export default class IPCManager extends Feature { } private async showAuthWindow() { - const { - auth: { - authentication: { loading } - } - } = this.store.getState(); - let authWindow: Electron.BrowserWindow | null = null; + const { appAuth } = this.store.getState(); let awsIotWrapper: AWSIotService | undefined; - if (loading) { + if (appAuth.isLoading) { this.logger.debug('Already loading'); return; } try { - this.store.dispatch(setLoginLoading()); + this.store.dispatch(login()); this.logger.debug('Starting login'); - authWindow = createAuthWindow(); + this.authWindow = createAuthWindow(); - authWindow.on('close', () => { - this.store.dispatch(setLoginLoading(false)); + this.authWindow.on('close', () => { + this.store.dispatch(loginTerminated()); }); const getKeysResponse = await this.awsApiGateway.getKeys(); @@ -114,7 +110,7 @@ export default class IPCManager extends Feature { const path = `/auth/signin/soundcloud`; const signedRequest = this.awsApiGateway.prepareRequest(path); - await authWindow.loadURL(`${CONFIG.AWS_API_URL}${path}`, { + await this.authWindow.loadURL(`${CONFIG.AWS_API_URL}${path}`, { extraHeaders: Object.keys(signedRequest.headers).reduce( (prevString, headerName) => `${prevString}${headerName}: ${signedRequest.headers[headerName]}\n`, '' @@ -124,18 +120,16 @@ export default class IPCManager extends Feature { // tslint:disable-next-line: no-unnecessary-local-variable const tokenResponse = await awsIotWrapper.waitForMessageOrTimeOut(); - authWindow.close(); - if (tokenResponse) { this.logger.debug('Auth successfull'); - this.store.dispatch(setLogin(tokenResponse)); - this.sendToWebContents('login-success'); + this.store.dispatch(loginSuccess(tokenResponse)); await awsIotWrapper.disconnect(); } + this.authWindow.close(); } catch (err) { - if (authWindow?.isClosable()) { - authWindow.close(); + if (this.authWindow?.isClosable()) { + this.authWindow.close(); } if (awsIotWrapper) { try { @@ -145,28 +139,28 @@ export default class IPCManager extends Feature { } } - this.store.dispatch(setLoginError('Something went wrong during login')); + this.store.dispatch(loginError('Something went wrong during login')); this.logger.error(err); throw err; } + + this.authWindow = null; } private async refreshToken() { const { config: { - auth: { refreshToken } + auth: { refreshToken: token } }, - auth: { - authentication: { loading } - } + appAuth } = this.store.getState(); - if (loading) { + if (appAuth.isLoading) { return null; } - if (!refreshToken) { + if (!token) { this.logger.debug('Refreshtoken not found'); this.showAuthWindow().catch(this.logger.error); @@ -176,20 +170,19 @@ export default class IPCManager extends Feature { try { this.logger.debug('Starting refresh'); - const tokenResponse = await this.awsApiGateway.refresh(refreshToken, 'soundcloud'); + const tokenResponse = await this.awsApiGateway.refresh(token, 'soundcloud'); if (tokenResponse) { this.logger.debug('Auth successfull'); - this.store.dispatch(setLogin(tokenResponse)); - this.sendToWebContents('login-success'); + this.store.dispatch(refreshToken(tokenResponse)); return { token: tokenResponse.access_token }; } } catch (err) { - this.store.dispatch(setLoginError('Something went wrong during refresh')); + this.store.dispatch(loginError('Something went wrong during refresh')); this.logger.error(err); this.showAuthWindow().catch(this.logger.error); diff --git a/src/main/features/feature.ts b/src/main/features/feature.ts index 504ff2fd..599b5870 100755 --- a/src/main/features/feature.ts +++ b/src/main/features/feature.ts @@ -1,4 +1,4 @@ -import { StoreState } from '@common/store'; +import { StoreState } from '@common/store/rootReducer'; // eslint-disable-next-line import/no-extraneous-dependencies import { BrowserWindow, ipcMain } from 'electron'; import { isEqual } from 'lodash'; diff --git a/src/main/features/win32/thumbar.ts b/src/main/features/win32/thumbar.ts index af116172..365a2f26 100755 --- a/src/main/features/win32/thumbar.ts +++ b/src/main/features/win32/thumbar.ts @@ -1,4 +1,4 @@ -import { StoreState } from '@common/store'; +import { StoreState } from '@common/store/rootReducer'; import { ChangeTypes, PlayerStatus } from '@common/store/player'; import { changeTrack, toggleStatus } from '@common/store/actions'; // eslint-disable-next-line import/no-extraneous-dependencies diff --git a/src/main/index.ts b/src/main/index.ts index f7a19c34..543009ec 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -22,12 +22,12 @@ global.__static = staticPath.replace(/\\/g, '\\\\'); import { app, systemPreferences } from 'electron'; import { Auryo } from './app'; import { Logger } from './utils/logger'; -import { configureStore } from '@common/configureStore'; +import store from '@common/store'; import is from 'electron-is'; -const store = configureStore(); +const auryo = new Auryo(); -const auryo = new Auryo(store); +auryo.setStore(store); // Quit when all windows are closed app.on('window-all-closed', () => { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index da9de8a1..6bc7bc62 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,25 +1,42 @@ import { EVENTS } from '@common/constants/events'; -import { StoreState } from '@common/store'; +import { history } from '@common/store'; import { stopWatchers, toggleStatus } from '@common/store/actions'; -import { ConnectedRouter } from 'connected-react-router'; +import { configSelector } from '@common/store/config/selectors'; +import { ConnectedRouter, push } from 'connected-react-router'; // eslint-disable-next-line import/no-extraneous-dependencies -import { ipcRenderer } from 'electron'; -import { History } from 'history'; +import { ipcRenderer, remote } from 'electron'; +import { UnregisterCallback } from 'history'; import React, { FC, useEffect } from 'react'; // eslint-disable-next-line import/no-extraneous-dependencies import { hot } from 'react-hot-loader/root'; -import { Provider } from 'react-redux'; -import { Route, Switch } from 'react-router'; -import { Store } from 'redux'; +import { useDispatch, useSelector } from 'react-redux'; +import { Route, Switch } from 'react-router-dom'; import Main from './app/Main'; import OnBoarding from './pages/onboarding/OnBoarding'; +import { ua } from '@common/utils/universalAnalytics'; +import { useKey } from 'react-use'; -interface Props { - history: History; - store: Store; -} +export const App: FC = () => { + const analyticsEnabled = useSelector(state => configSelector(state).app.analytics); + const dispatch = useDispatch(); + + // Toggle player on Space + // TODO re-enable + // useKey(' ', () => dispatch(toggleStatus() as any), { event: 'keyup' }); + // Prevent body from scrolling when pressing Space + useKey( + ' ', + event => { + if (event.target === document.body) { + event.preventDefault(); + return false; + } + + return true; + }, + { event: 'keydown' } + ); -export const App: FC = ({ history, store }) => { useEffect(() => { ipcRenderer.send(EVENTS.APP.READY); @@ -27,45 +44,38 @@ export const App: FC = ({ history, store }) => { ipcRenderer.send(EVENTS.APP.NAVIGATE); }); - const onKeyUp = (e: KeyboardEvent) => { - // When space bar pressed - if (e.keyCode === 32) { - // Prevent from scrolling - store.dispatch(toggleStatus() as any); - } + return () => { + dispatch(stopWatchers() as any); + unregister(); }; + }, [dispatch]); - const onKeyDown = (e: KeyboardEvent) => { - // Prevent body from scrolling - if (e.keyCode === 32 && e.target === document.body) { - e.preventDefault(); - return false; - } + // Page analytics + useEffect(() => { + let unregister: UnregisterCallback; - return true; - }; + if (!process.env.TOKEN && process.env.NODE_ENV === 'production') { + ua.set('version', remote.app.getVersion()); + ua.set('anonymizeIp', true); + if (analyticsEnabled) { + ua.pv('/').send(); - window.addEventListener('keydown', onKeyDown); - window.addEventListener('keyup', onKeyUp); + unregister = history.listen(location => { + ua.pv(location.pathname).send(); + }); + } + } return () => { - store.dispatch(stopWatchers() as any); - unregister(); - window.removeEventListener('keyup', onKeyUp); - window.removeEventListener('keydown', onKeyDown); + unregister?.(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [analyticsEnabled]); return ( - - - - - - - - + + + + ); }; diff --git a/src/renderer/_shared/ActionsDropdown.tsx b/src/renderer/_shared/ActionsDropdown.tsx index a177ac2e..4fa5f45d 100644 --- a/src/renderer/_shared/ActionsDropdown.tsx +++ b/src/renderer/_shared/ActionsDropdown.tsx @@ -1,7 +1,7 @@ import { Menu, MenuDivider, MenuItem, Popover, Position } from '@blueprintjs/core'; -import { StoreState } from '@common/store'; +import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; -import { getLikes, getReposts, getUserPlaylistsCombined } from '@common/store/auth/selectors'; +import { getAuthLikesSelector, getAuthRepostsSelector, getUserPlaylistsCombined } from '@common/store/auth/selectors'; import { SC } from '@common/utils'; import { IPC } from '@common/utils/ipc'; import cn from 'classnames'; @@ -14,8 +14,8 @@ import ShareMenuItem from './ShareMenuItem'; const mapStateToProps = (state: StoreState) => ({ userPlaylists: getUserPlaylistsCombined(state), - likes: getLikes(state), - reposts: getReposts(state) + likes: getAuthLikesSelector(state), + reposts: getAuthRepostsSelector(state) }); const mapDispatchToProps = (dispatch: Dispatch) => diff --git a/src/renderer/_shared/ErrorBoundary.tsx b/src/renderer/_shared/ErrorBoundary.tsx index 93433289..56edb6f6 100644 --- a/src/renderer/_shared/ErrorBoundary.tsx +++ b/src/renderer/_shared/ErrorBoundary.tsx @@ -14,7 +14,7 @@ class ErrorBoundary extends React.PureComponent<{}, State> { public componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { // Display fallback UI - this.setState({ hasError: true, message: error.message }); + // this.setState({ hasError: true, message: error.message }); // You can also log the error to an error reporting service // eslint-disable-next-line no-console console.error(errorInfo.componentStack); diff --git a/src/renderer/_shared/PageHeader/PageHeader.scss b/src/renderer/_shared/PageHeader/PageHeader.scss index 87d02ca8..60ae0f59 100644 --- a/src/renderer/_shared/PageHeader/PageHeader.scss +++ b/src/renderer/_shared/PageHeader/PageHeader.scss @@ -1,114 +1,114 @@ -@import "../../css/bootstrap.imports.scss"; +@import '../../css/bootstrap.imports.scss'; .page-header { - padding: 20px 20px 15px 40px; - h2 { - color: var(--clr-page-header-title); - font-size: 2.1rem; - margin-bottom: 0.3rem; - } - .subtitle { - margin: 0; - color: #767c89; - font-weight: 600; - font-size: 1rem; - } - .button-group { - display: flex; - align-items: center; - margin-top: 1.8rem; - a { - margin-right: 0.5rem; - } - } - + .detailPage { - background: var(--clr-detail-page-bg); - } - &.withImage { - background-position: center center; - background-size: cover; - position: relative; - margin-top: -52px; - padding: 7rem 2rem 7rem 2.5rem; - overflow: hidden; - &:after { - content: ""; - position: absolute; - left: 0; - top: 0; - display: block; - width: 100%; - height: 100%; - background-image: var(--clr-header-pseudo); - } - &:before { - content: ""; - position: absolute; - left: 0; - top: 0; - display: block; - width: 100%; - height: 100%; - background: black; - opacity: 0.3; - } - & + .songs { - margin-top: -5rem; - } - & + .detailPage { - margin-top: -6.5rem; - position: relative; - background: var(--clr-detail-page-bg); - flex-grow: 1; - } - .gradient { - background-image: linear-gradient(120deg, rgb(132, 250, 176) 0%, rgb(143, 211, 244) 100%); - position: absolute; - top: 0; - left: 0; - width: 100%; - display: block; - height: 100%; - opacity: 0.3; - z-index: -1; - } - .bgImage { - position: absolute; - top: 0; - left: 0; - background-position: center center; - background-size: cover; - width: 120%; - height: 120%; - filter: blur(10px); - margin: -10%; - z-index: -1; - } - h2 { - color: #fff; - user-select: text; - } - .header-content { - position: relative; - z-index: 4; - } - .bp3-select { - border: 1px solid rgba(255, 255, 255, 0.2); - margin-right: 0.9rem; - border-radius: 6px; - select { - color: white; - &:focus, - &:active { - outline: none !important; - } - } - &:focus, - &:active { - outline: none !important; - } - &:after { - color: white; - } - } - } + padding: 20px 20px 15px 40px; + h2 { + color: var(--clr-page-header-title); + font-size: 2.1rem; + margin-bottom: 0.3rem; + } + .subtitle { + margin: 0; + color: #dcdcdc; + font-weight: 400; + font-size: 1rem; + } + .button-group { + display: flex; + align-items: center; + margin-top: 1.8rem; + a { + margin-right: 0.5rem; + } + } + + .detailPage { + background: var(--clr-detail-page-bg); + } + &.withImage { + background-position: center center; + background-size: cover; + position: relative; + margin-top: -52px; + padding: 7rem 2rem 7rem 2.5rem; + overflow: hidden; + &:after { + content: ''; + position: absolute; + left: 0; + top: 0; + display: block; + width: 100%; + height: 100%; + background-image: var(--clr-header-pseudo); + } + &:before { + content: ''; + position: absolute; + left: 0; + top: 0; + display: block; + width: 100%; + height: 100%; + background: black; + opacity: 0.3; + } + & + .songs { + margin-top: -5rem; + } + & + .detailPage { + margin-top: -6.5rem; + position: relative; + background: var(--clr-detail-page-bg); + flex-grow: 1; + } + .gradient { + background-image: linear-gradient(120deg, rgb(132, 250, 176) 0%, rgb(143, 211, 244) 100%); + position: absolute; + top: 0; + left: 0; + width: 100%; + display: block; + height: 100%; + opacity: 0.3; + z-index: -1; + } + .bgImage { + position: absolute; + top: 0; + left: 0; + background-position: center center; + background-size: cover; + width: 120%; + height: 120%; + filter: blur(10px); + margin: -10%; + z-index: -1; + } + h2 { + color: #fff; + user-select: text; + } + .header-content { + position: relative; + z-index: 4; + } + .bp3-select { + border: 1px solid rgba(255, 255, 255, 0.2); + margin-right: 0.9rem; + border-radius: 6px; + select { + color: white; + &:focus, + &:active { + outline: none !important; + } + } + &:focus, + &:active { + outline: none !important; + } + &:after { + color: white; + } + } + } } diff --git a/src/renderer/_shared/PageHeader/PageHeader.tsx b/src/renderer/_shared/PageHeader/PageHeader.tsx index 9c16c9b3..94a15800 100644 --- a/src/renderer/_shared/PageHeader/PageHeader.tsx +++ b/src/renderer/_shared/PageHeader/PageHeader.tsx @@ -19,8 +19,10 @@ const PageHeader = React.memo(({ image, gradient, children, title, subtit {gradient &&
}
- {title ?

{title}

: children} + {title &&

{title}

} {subtitle &&
{subtitle}
} + + {children}
)); diff --git a/src/renderer/_shared/PageHeader/components/ToggleLikeButton.tsx b/src/renderer/_shared/PageHeader/components/ToggleLikeButton.tsx new file mode 100755 index 00000000..cea7674c --- /dev/null +++ b/src/renderer/_shared/PageHeader/components/ToggleLikeButton.tsx @@ -0,0 +1,31 @@ +import * as actions from '@common/store/actions'; +import { hasLiked } from '@common/store/auth/selectors'; +import cn from 'classnames'; +import React, { FC } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +interface Props { + className?: string; + + playlistId?: string; + trackId?: number; + colored?: boolean; +} + +export const ToggleLikeButton: FC = ({ className, playlistId, trackId, colored }) => { + const playlistOrTrackId = (playlistId || trackId) as number | string; + const liked = useSelector(hasLiked(playlistOrTrackId, playlistId ? 'playlist' : 'track')); + const dispatch = useDispatch(); + + return ( +
{ + dispatch(actions.toggleLike(playlistOrTrackId, !!playlistId)); + }}> + + {liked ? 'Liked' : 'Like'} + + ); +}; diff --git a/src/renderer/_shared/PageHeader/components/TogglePlayButton.tsx b/src/renderer/_shared/PageHeader/components/TogglePlayButton.tsx new file mode 100755 index 00000000..71d16da7 --- /dev/null +++ b/src/renderer/_shared/PageHeader/components/TogglePlayButton.tsx @@ -0,0 +1,58 @@ +import * as actions from '@common/store/actions'; +import { PlayerStatus } from '@common/store/player'; +import React, { FC, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import cn from 'classnames'; + +interface Props { + className?: string; + colored?: boolean; + + playlistId?: string; + trackId?: number; + onPlay(): void; +} + +export const TogglePlayButton: FC = ({ className, playlistId, trackId, onPlay, colored }) => { + const playerStatus = useSelector(state => state.player.status); + const isPlayerPlaylist = useSelector(state => !!playlistId && state.player.currentPlaylistId === playlistId); + const isTrackPlaying = useSelector(state => !!trackId && state.player.playingTrack?.id === trackId); + + const dispatch = useDispatch(); + + const togglePlay = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.nativeEvent.stopImmediatePropagation(); + + if (isPlayerPlaylist || isTrackPlaying) { + if (playerStatus !== PlayerStatus.PLAYING) { + dispatch(actions.toggleStatus(PlayerStatus.PLAYING)); + } else if (playerStatus === PlayerStatus.PLAYING) { + dispatch(actions.toggleStatus(PlayerStatus.PAUSED)); + } + } else { + onPlay(); + } + }, + [dispatch, isPlayerPlaylist, isTrackPlaying, onPlay, playerStatus] + ); + + const getIcon = useCallback(() => { + let icon = 'play'; + + if ((isPlayerPlaylist || isTrackPlaying) && playerStatus === PlayerStatus.PLAYING) { + icon = 'pause'; + } + + return icon; + }, [isPlayerPlaylist, isTrackPlaying, playerStatus]); + + return ( + + + + ); +}; + +export default TogglePlayButton; diff --git a/src/renderer/_shared/PageHeader/components/ToggleRepostButton.tsx b/src/renderer/_shared/PageHeader/components/ToggleRepostButton.tsx new file mode 100755 index 00000000..630d062c --- /dev/null +++ b/src/renderer/_shared/PageHeader/components/ToggleRepostButton.tsx @@ -0,0 +1,31 @@ +import * as actions from '@common/store/actions'; +import { hasReposted } from '@common/store/auth/selectors'; +import cn from 'classnames'; +import React, { FC } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +interface Props { + className?: string; + + playlistId?: string; + trackId?: number; + colored?: boolean; +} + +export const ToggleRepostButton: FC = ({ className, playlistId, trackId, colored }) => { + const playlistOrTrackId = (playlistId || trackId) as number | string; + const reposted = useSelector(hasReposted(playlistOrTrackId, playlistId ? 'playlist' : 'track')); + const dispatch = useDispatch(); + + return ( + { + dispatch(actions.toggleRepost(playlistOrTrackId, !!playlistId)); + }}> + + {reposted ? 'Reposted' : 'Repost'} + + ); +}; diff --git a/src/renderer/_shared/TogglePlayButton.tsx b/src/renderer/_shared/TogglePlayButton.tsx deleted file mode 100755 index f0c3cfeb..00000000 --- a/src/renderer/_shared/TogglePlayButton.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { StoreState } from '@common/store'; -import * as actions from '@common/store/actions'; -import { PlayerStatus } from '@common/store/player'; -import React from 'react'; -import { connect } from 'react-redux'; -import { bindActionCreators, Dispatch } from 'redux'; - -interface OwnProps { - className?: string; -} - -const mapStateToProps = ({ player: { status } }: StoreState) => ({ - status -}); - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - toggleStatus: actions.toggleStatus - }, - dispatch - ); - -type PropsFromState = ReturnType; - -type PropsFromDispatch = ReturnType; - -type AllProps = OwnProps & PropsFromState & PropsFromDispatch; - -class TogglePlayButton extends React.Component { - public togglePlay = (event: React.MouseEvent) => { - const { toggleStatus, status } = this.props; - - event.preventDefault(); - event.nativeEvent.stopImmediatePropagation(); - - if (status !== PlayerStatus.PLAYING) { - toggleStatus(PlayerStatus.PLAYING); - } else if (status === PlayerStatus.PLAYING) { - toggleStatus(PlayerStatus.PAUSED); - } - }; - - public render() { - const { status, className } = this.props; - - let icon = ''; - - switch (status) { - // case PlayerStatus.ERROR: - // icon = "icon-alert-circle"; - // break; - case PlayerStatus.PLAYING: - icon = 'pause'; - break; - case PlayerStatus.PAUSED: - case PlayerStatus.STOPPED: - icon = 'play'; - break; - // case PlayerStatus.LOADING: - // icon = "more_horiz"; - // break; - default: - } - - return ( - - - - ); - } -} - -export default connect( - mapStateToProps, - mapDispatchToProps -)(TogglePlayButton); diff --git a/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.tsx b/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.tsx index 598a4cf6..611e5f80 100755 --- a/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.tsx +++ b/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.tsx @@ -1,5 +1,5 @@ import { IMAGE_SIZES } from '@common/constants'; -import { StoreState } from '@common/store'; +import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; import { getTrackEntity } from '@common/store/entities/selectors'; import { isPlaying } from '@common/store/player/selectors'; @@ -13,7 +13,7 @@ import { Normalized } from '../../../../types'; import ActionsDropdown from '../../ActionsDropdown'; import FallbackImage from '../../FallbackImage'; import { TextShortener } from '../../TextShortener'; -import TogglePlayButton from '../../TogglePlayButton'; +import TogglePlayButton from '../../PageHeader/components/TogglePlayButton'; import './TrackListItem.scss'; interface OwnProps { diff --git a/src/renderer/_shared/TracksGrid/TrackGridRow.tsx b/src/renderer/_shared/TracksGrid/TrackGridRow.tsx index 4bb2c450..2881c212 100644 --- a/src/renderer/_shared/TracksGrid/TrackGridRow.tsx +++ b/src/renderer/_shared/TracksGrid/TrackGridRow.tsx @@ -1,4 +1,5 @@ import { PlaylistTypes } from '@common/store/objects'; +import { PlaylistIdentifier } from '@common/store/playlist/types'; import cn from 'classnames'; import { autobind } from 'core-decorators'; import React from 'react'; @@ -9,9 +10,8 @@ interface Props { data: { itemsPerRow: number; items: any[]; - objectId: string; showInfo: boolean; - }; + } & PlaylistIdentifier; index: number; style: React.CSSProperties; } @@ -28,7 +28,7 @@ export class TrackGridRow extends React.PureComponent { private renderItem(index: number) { const { - data: { showInfo, objectId, items } + data: { showInfo, objectId, playlistType, items } } = this.props; const item = items[index]; @@ -44,7 +44,7 @@ export class TrackGridRow extends React.PureComponent { ); } - const showReposts = objectId === PlaylistTypes.STREAM; + const showReposts = playlistType === PlaylistTypes.STREAM; return (
diff --git a/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItem.tsx b/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItem.tsx index 892a0d9a..ee0d0161 100755 --- a/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItem.tsx +++ b/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItem.tsx @@ -1,5 +1,5 @@ import { IMAGE_SIZES } from '@common/constants'; -import { StoreState } from '@common/store'; +import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; import { getMusicEntity } from '@common/store/entities/selectors'; import { isPlaying } from '@common/store/player/selectors'; @@ -16,7 +16,7 @@ import { Normalized, SoundCloud } from '../../../../types'; import ActionsDropdown from '../../ActionsDropdown'; import FallbackImage from '../../FallbackImage'; import { TextShortener } from '../../TextShortener'; -import TogglePlayButton from '../../TogglePlayButton'; +import TogglePlayButton from '../../PageHeader/components/TogglePlayButton'; import './TrackGridItem.scss'; import { PlayingTrack } from '@common/store/player'; @@ -62,7 +62,8 @@ class TrackGridItem extends React.Component { const { track, fetchPlaylistIfNeeded, skipFetch } = this.props; if (track && track.kind === 'playlist' && track.track_count && !track.tracks && !skipFetch) { - fetchPlaylistIfNeeded(track.id); + // TODO + // fetchPlaylistIfNeeded(track.id); } } @@ -82,7 +83,8 @@ class TrackGridItem extends React.Component { (prevProps.track && track && prevProps.track.id !== track.id && !skipFetch) ) { if (track.kind === 'playlist' && track.track_count && !track.tracks) { - fetchPlaylistIfNeeded(track.id); + // TODO + // fetchPlaylistIfNeeded(track.id); } } } @@ -94,14 +96,14 @@ class TrackGridItem extends React.Component { return null; } - if (track.from_user && showReposts && track.type?.indexOf('repost') !== -1) { + if (track.fromUser && showReposts && track.type?.indexOf('repost') !== -1) { return (
{track.user.username} - - {track.from_user.username} + + {track.fromUser.username}
); diff --git a/src/renderer/_shared/TracksGrid/TrackgridUser/TrackGridUser.tsx b/src/renderer/_shared/TracksGrid/TrackgridUser/TrackGridUser.tsx index 524f5f74..604bace7 100755 --- a/src/renderer/_shared/TracksGrid/TrackgridUser/TrackGridUser.tsx +++ b/src/renderer/_shared/TracksGrid/TrackgridUser/TrackGridUser.tsx @@ -1,5 +1,5 @@ import { IMAGE_SIZES } from '@common/constants'; -import { StoreState } from '@common/store'; +import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; import { isFollowing } from '@common/store/auth/selectors'; import { getUserEntity } from '@common/store/entities/selectors'; diff --git a/src/renderer/_shared/TracksGrid/TracksGrid.tsx b/src/renderer/_shared/TracksGrid/TracksGrid.tsx index fd49b556..0273b44d 100755 --- a/src/renderer/_shared/TracksGrid/TracksGrid.tsx +++ b/src/renderer/_shared/TracksGrid/TracksGrid.tsx @@ -1,3 +1,4 @@ +import { PlaylistIdentifier } from '@common/store/playlist/types'; import { Normalized } from '@types'; import cn from 'classnames'; import React, { SFC, useContext, useEffect, useRef } from 'react'; @@ -9,10 +10,9 @@ import Spinner from '../Spinner/Spinner'; import { TrackGridRow } from './TrackGridRow'; import * as styles from './TracksGrid.module.scss'; -interface Props { +interface Props extends PlaylistIdentifier { showInfo?: boolean; items: Normalized.NormalizedResult[]; - objectId: string; hasMore?: boolean; isLoading?: boolean; @@ -26,7 +26,7 @@ function getRowsForWidth(width: number): number { } const TracksGrid: SFC = props => { - const { items, objectId, showInfo, isItemLoaded, loadMore, hasMore, isLoading } = props; + const { items, objectId, showInfo, isItemLoaded, loadMore, hasMore, isLoading, playlistType } = props; const loaderRef = useRef(null); const { setList } = useContext(ContentContext); const listRef = loaderRef?.current?._listRef; @@ -62,7 +62,8 @@ const TracksGrid: SFC = props => { return true; }} - threshold={50} + threshold={2} + minimumBatchSize={2} itemCount={itemCount} loadMoreItems={(start, end) => { if (loadMoreItems && items.length - end * itemsPerRow < 5) { @@ -83,6 +84,7 @@ const TracksGrid: SFC = props => { itemsPerRow, items, objectId, + playlistType, showInfo }} itemSize={350} diff --git a/src/renderer/app/Layout.tsx b/src/renderer/app/Layout.tsx index 10e0c956..5f202198 100644 --- a/src/renderer/app/Layout.tsx +++ b/src/renderer/app/Layout.tsx @@ -1,10 +1,7 @@ import { Intent, IResizeEntry, Position, ResizeSensor } from '@blueprintjs/core'; import { EVENTS } from '@common/constants/events'; -import { StoreState } from '@common/store'; +import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; -import { getUserPlaylists } from '@common/store/auth/selectors'; -import { PlayerStatus } from '@common/store/player'; -import { getCurrentPlaylistId } from '@common/store/player/selectors'; // eslint-disable-next-line import/no-cycle import { ContentContext, INITIAL_LAYOUT_SETTINGS, LayoutSettings } from '@renderer/_shared/context/contentContext'; import cn from 'classnames'; @@ -18,11 +15,11 @@ import React from 'react'; import Theme from 'react-custom-properties'; import Scrollbars from 'react-custom-scrollbars'; import { connect } from 'react-redux'; -import { RouteComponentProps, withRouter } from 'react-router'; +import { RouteComponentProps, withRouter } from 'react-router-dom'; import { FixedSizeList } from 'react-window'; import { bindActionCreators, compose, Dispatch } from 'redux'; +import { AudioPlayerProvider } from '../hooks/useAudioPlayer'; import ErrorBoundary from '../_shared/ErrorBoundary'; -import Spinner from '../_shared/Spinner/Spinner'; import AppError from './components/AppError/AppError'; import AboutModal from './components/modals/AboutModal/AboutModal'; import ChangelogModal from './components/modals/ChangeLogModal/ChangelogModal'; @@ -30,7 +27,6 @@ import Player from './components/player/Player'; import SideBar from './components/Sidebar/Sidebar'; import { Themes } from './components/Theme/themes'; import { Toastr } from './components/Toastr'; -import { AudioPlayerProvider } from '../hooks/useAudioPlayer'; const mapStateToProps = (state: StoreState) => { const { @@ -41,12 +37,9 @@ const mapStateToProps = (state: StoreState) => { } = state; return { - userPlayerlists: getUserPlaylists(state), playingTrack: player.playingTrack, theme: config.app.theme, toasts: ui.toasts, - currentPlaylistId: getCurrentPlaylistId(state), - isActuallyPlaying: player.status === PlayerStatus.PLAYING, offline, loaded, @@ -59,9 +52,8 @@ const mapDispatchToProps = (dispatch: Dispatch) => { addToast: actions.addToast, clearToasts: actions.clearToasts, - initApp: actions.initApp, removeToast: actions.removeToast, - setDimensions: actions.setDimensions, + setDebouncedDimensions: actions.setDebouncedDimensions, toggleOffline: actions.toggleOffline, stopWatchers: actions.stopWatchers }, @@ -96,14 +88,12 @@ class Layout extends React.Component { }; private readonly contentRef: React.RefObject = React.createRef(); - private readonly debouncedHandleResize: (entries: IResizeEntry[]) => void; private readonly debouncedSetScrollPosition: (scrollTop: number, pathname: string) => any; private unregister?: UnregisterCallback; constructor(props: AllProps) { super(props); - this.debouncedHandleResize = debounce(this.handleResize, 500, { leading: true }); this.debouncedSetScrollPosition = debounce( (scrollTop, pathname) => { this.setState(state => ({ @@ -159,9 +149,9 @@ class Layout extends React.Component { contentRect: { width, height } } ]: IResizeEntry[]) { - const { setDimensions } = this.props; + const { setDebouncedDimensions } = this.props; - setDimensions({ + setDebouncedDimensions({ height, width }); @@ -219,12 +209,10 @@ class Layout extends React.Component { children, theme, location, - currentPlaylistId, - isActuallyPlaying, + // Functions toasts, - clearToasts, - userPlayerlists + clearToasts } = this.props; const { settings, list, scrollLocations } = this.state; @@ -232,7 +220,7 @@ class Layout extends React.Component { const scrollTop = scrollLocations[location.pathname] || 0; return ( - +
{ mac: is.osx(), playing: !!playingTrack })}> - {!loaded && !offline && !loadingError ? : null} - {loadingError ? ( { className={cn({ playing: playingTrack })}> - + { - const { app, config } = state; - return { - offline: app.offline, - appHasError: app.error, - loaded: app.loaded, - token: config.auth.token - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - initApp: actions.initApp, - resolveUrl: actions.resolveUrl - }, - dispatch - ); +type Props = RouteComponentProps; -type PropsFromState = ReturnType; -type PropsFromDispatch = ReturnType; +const Main: FC = ({ location: { search } }) => { + const dispatch = useDispatch(); -type AllProps = PropsFromState & RouteComponentProps & PropsFromDispatch; - -const Main: FC = ({ loaded, offline, appHasError, location: { search }, token, initApp, resolveUrl }) => { - const handleResolve = () => { + const handleResolve = useCallback(() => { const url = search.replace('?', ''); if (!url || (url && !url.length)) { return ; } - resolveUrl(url); + dispatch(actions.resolveUrl(url)); return ; - }; - - useEffect(() => { - if (token) { - initApp(); - } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [token]); + }, [search]); + + // return ( + // + // ); + + const renderGenericPlaylist = useCallback( + (options: { title: string; playlistType: PlaylistTypes; showInfo?: boolean }) => () => ( + + ), + [] + ); + + const StreamPlaylist = renderGenericPlaylist({ + title: 'Stream', + playlistType: PlaylistTypes.STREAM, + showInfo: true + }); - if (!token) { - return ; - } + const LikesPlaylist = renderGenericPlaylist({ + title: 'Likes', + playlistType: PlaylistTypes.LIKES + }); - if (!loaded && offline) { - return ; - } + const MyTracksPlaylist = renderGenericPlaylist({ + title: 'Tracks', + playlistType: PlaylistTypes.MYTRACKS + }); - if (!loaded && !appHasError) { - return ; - } + const MyPlaylists = renderGenericPlaylist({ + title: 'Playlists', + playlistType: PlaylistTypes.MYPLAYLISTS + }); return ( @@ -85,20 +80,19 @@ const Main: FC = ({ loaded, offline, appHasError, location: { search } <>
- + - - + + - + - - - + + @@ -107,4 +101,4 @@ const Main: FC = ({ loaded, offline, appHasError, location: { search } ); }; -export default compose(withRouter, connect(mapStateToProps, mapDispatchToProps))(Main); +export default Main; diff --git a/src/renderer/app/components/Header/Header.tsx b/src/renderer/app/components/Header/Header.tsx index 7d3ee367..651679e2 100644 --- a/src/renderer/app/components/Header/Header.tsx +++ b/src/renderer/app/components/Header/Header.tsx @@ -1,29 +1,34 @@ import { Icon, Menu, MenuDivider, MenuItem, Popover, Position } from '@blueprintjs/core'; import { EVENTS } from '@common/constants/events'; -import { StoreState } from '@common/store'; +import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; +import { currentUserSelector } from '@common/store/auth/selectors'; import { InjectedContentContextProps, withContentContext } from '@renderer/_shared/context/contentContext'; import cn from 'classnames'; import * as ReactRouter from 'connected-react-router'; +import { autobind } from 'core-decorators'; // eslint-disable-next-line import/no-extraneous-dependencies import { ipcRenderer } from 'electron'; import { isEqual } from 'lodash'; import React from 'react'; import { connect } from 'react-redux'; -import { RouteComponentProps, withRouter } from 'react-router'; +import { RouteComponentProps, withRouter } from 'react-router-dom'; import Sticky from 'react-stickynode'; import { bindActionCreators, compose, Dispatch } from 'redux'; import * as ReduxModal from 'redux-modal'; import './Header.scss'; import SearchBox from './Search/SearchBox'; import User from './User/User'; -import { autobind } from 'core-decorators'; -const mapStateToProps = ({ app, auth }: StoreState) => ({ - update: app.update, - me: auth.me, - locHistory: app.history -}); +const mapStateToProps = (state: StoreState) => { + const { app } = state; + + return { + update: app.update, + currentUser: currentUserSelector(state), + locHistory: app.history + }; +}; const mapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( @@ -31,7 +36,8 @@ const mapDispatchToProps = (dispatch: Dispatch) => logout: actions.logout, show: ReduxModal.show, push: ReactRouter.push, - replace: ReactRouter.replace + replace: ReactRouter.replace, + setDebouncedSearchQuery: actions.setDebouncedSearchQuery }, dispatch ); @@ -84,13 +90,13 @@ class Header extends React.Component { } public shouldComponentUpdate(nextProps: AllProps, nextState: State) { - const { scrollTop, locHistory, me, update, location, settings } = this.props; + const { scrollTop, locHistory, currentUser, update, location, settings } = this.props; return ( !isEqual(locHistory, nextProps.locHistory) || !isEqual(location.pathname, nextProps.location.pathname) || !isEqual(settings, nextProps.settings) || - me !== nextProps.me || + currentUser !== nextProps.currentUser || (this.navBarWrapper.current && nextState.height !== this.navBarWrapper.current.clientHeight) || nextProps.update !== update || scrollTop !== nextProps.scrollTop @@ -153,34 +159,16 @@ class Header extends React.Component { } public handleSearch(prev: string, rawQuery?: string) { - const { push, replace } = this.props; - - if (!rawQuery) { - replace('/search'); + const { push, replace, setDebouncedSearchQuery } = this.props; - return; - } - - const searchQuery = rawQuery; - - if (prev) { - replace({ - pathname: `/search`, - search: searchQuery - }); - } else { - push({ - pathname: `/search`, - search: searchQuery - }); - } + setDebouncedSearchQuery(rawQuery); } // tslint:disable-next-line: max-func-body-length public render() { const { locHistory: { next, back }, - me, + currentUser, logout, scrollTop, settings, @@ -215,7 +203,7 @@ class Header extends React.Component {
- + { - + logout()} /> }> diff --git a/src/renderer/app/components/Header/Search/SearchBox.tsx b/src/renderer/app/components/Header/Search/SearchBox.tsx index d902fa21..a1f35768 100644 --- a/src/renderer/app/components/Header/Search/SearchBox.tsx +++ b/src/renderer/app/components/Header/Search/SearchBox.tsx @@ -18,7 +18,6 @@ interface State { @autobind class SearchBox extends React.Component { - private readonly handleSearchDebounced: (oldValue: string, currentValue?: string) => void; private searchInput = React.createRef(); public static readonly defaultProps: Partial = { value: '', @@ -32,8 +31,6 @@ class SearchBox extends React.Component { this.state = { query: props.initialValue || props.value || '' }; - - this.handleSearchDebounced = debounce(this.handleSearch, 250); } public componentDidMount() { @@ -54,7 +51,7 @@ class SearchBox extends React.Component { const { query } = this.state; if (this.searchInput.current) { - this.handleSearchDebounced(query, event.currentTarget.value); + this.handleSearch(query, event.currentTarget.value); } this.setState({ query: event.currentTarget.value }); @@ -64,7 +61,7 @@ class SearchBox extends React.Component { const { query } = this.state; if (event.key === 'Enter') { - this.handleSearchDebounced(query, event.currentTarget.value); + this.handleSearch(query, event.currentTarget.value); } } @@ -117,7 +114,7 @@ class SearchBox extends React.Component { href="javascript:void(0)" onClick={() => { this.setState({ query: '' }); - this.handleSearchDebounced(query); + this.handleSearch(query); }}> diff --git a/src/renderer/app/components/Header/User/User.tsx b/src/renderer/app/components/Header/User/User.tsx index 4775f400..6048facd 100644 --- a/src/renderer/app/components/Header/User/User.tsx +++ b/src/renderer/app/components/Header/User/User.tsx @@ -1,25 +1,28 @@ -import { AuthUser } from '@common/store/auth'; +import { SoundCloud } from '@types'; import React from 'react'; import { Link } from 'react-router-dom'; import './User.scss'; interface Props { - me: AuthUser | null; + currentUser?: SoundCloud.User | null; } -const User = React.memo(({ me }) => ( -
- {me ? ( - +const User = React.memo(({ currentUser }) => { + if (!currentUser) { + return null; + } + return ( +
+
-
{me.username}
+
{currentUser.username}
- user avatar + user avatar
- ) : null} -
-)); +
+ ); +}); export default User; diff --git a/src/renderer/app/components/Queue/Queue.tsx b/src/renderer/app/components/Queue/Queue.tsx index 60fa84d0..0a20fce6 100644 --- a/src/renderer/app/components/Queue/Queue.tsx +++ b/src/renderer/app/components/Queue/Queue.tsx @@ -1,5 +1,5 @@ import { Classes } from '@blueprintjs/core'; -import { StoreState } from '@common/store'; +import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; import { getQueue } from '@common/store/player/selectors'; import { autobind } from 'core-decorators'; diff --git a/src/renderer/app/components/Queue/QueueItem.tsx b/src/renderer/app/components/Queue/QueueItem.tsx index 4056fc81..2b01ab77 100644 --- a/src/renderer/app/components/Queue/QueueItem.tsx +++ b/src/renderer/app/components/Queue/QueueItem.tsx @@ -1,5 +1,5 @@ import { IMAGE_SIZES } from '@common/constants'; -import { StoreState } from '@common/store'; +import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; import { getTrackEntity } from '@common/store/entities/selectors'; import { PlayingTrack } from '@common/store/player'; diff --git a/src/renderer/app/components/Sidebar/Sidebar.tsx b/src/renderer/app/components/Sidebar/Sidebar.tsx index d363c7fd..621b4861 100755 --- a/src/renderer/app/components/Sidebar/Sidebar.tsx +++ b/src/renderer/app/components/Sidebar/Sidebar.tsx @@ -1,83 +1,84 @@ -import { Normalized } from '@types'; -import React from 'react'; +import { getAuthPlaylistsSelector } from '@common/store/auth/selectors'; +import { getCurrentPlaylistId } from '@common/store/player/selectors'; +import React, { FC } from 'react'; import Scrollbars from 'react-custom-scrollbars'; +import { useSelector } from 'react-redux'; import { NavLink, RouteComponentProps, withRouter } from 'react-router-dom'; import SideBarPlaylistItem from './playlist/SideBarPlaylistItem'; import * as styles from './Sidebar.module.scss'; -interface Props { - items: Normalized.NormalizedResult[]; - isActuallyPlaying: boolean; - currentPlaylistId: string | null; -} +type AllProps = RouteComponentProps; -type AllProps = Props & RouteComponentProps; +const SideBar: FC = () => { + const authPlaylists = useSelector(state => getAuthPlaylistsSelector(state).owned); + const currentPlaylistId = useSelector(state => getCurrentPlaylistId(state)); -const SideBar = React.memo(({ items, currentPlaylistId }) => ( - + ); +}; export default withRouter(SideBar); diff --git a/src/renderer/app/components/Sidebar/playlist/SideBarPlaylistItem.tsx b/src/renderer/app/components/Sidebar/playlist/SideBarPlaylistItem.tsx index e7c8cc31..01dec8db 100755 --- a/src/renderer/app/components/Sidebar/playlist/SideBarPlaylistItem.tsx +++ b/src/renderer/app/components/Sidebar/playlist/SideBarPlaylistItem.tsx @@ -1,35 +1,23 @@ -import { StoreState } from '@common/store'; import { getNormalizedPlaylist } from '@common/store/entities/selectors'; import { PlayerStatus } from '@common/store/player'; +import { getPlayerStatusSelector } from '@common/store/player/selectors'; import classNames from 'classnames'; -import React from 'react'; -import { connect } from 'react-redux'; +import React, { FC } from 'react'; +import { useSelector } from 'react-redux'; import { NavLink } from 'react-router-dom'; import { TextShortener } from '../../../../_shared/TextShortener'; import * as styles from '../Sidebar.module.scss'; -const mapStateToProps = (state: StoreState, props: OwnProps) => { - const { playlistId } = props; - const { - player: { status } - } = state; - - return { - playlist: getNormalizedPlaylist(playlistId)(state), - isActuallyPlaying: status === PlayerStatus.PLAYING - }; -}; - -interface OwnProps { +interface Props { playlistId: number; isPlaying: boolean; } -type PropsFromState = ReturnType; +const SideBarPlaylistItem: FC = ({ playlistId, isPlaying }) => { + const playlist = useSelector(state => getNormalizedPlaylist(playlistId)(state)); + const playerStatus = useSelector(getPlayerStatusSelector); + const isActuallyPlaying = playerStatus === PlayerStatus.PLAYING; -type AllProps = OwnProps & PropsFromState; - -const SideBarPlaylistItem = React.memo(({ playlist, isPlaying, isActuallyPlaying }) => { if (!playlist) { return null; } @@ -45,6 +33,6 @@ const SideBarPlaylistItem = React.memo(({ playlist, isPlaying, isActua
); -}); +}; -export default connect(mapStateToProps)(SideBarPlaylistItem); +export default SideBarPlaylistItem; diff --git a/src/renderer/app/components/modals/AboutModal/AboutModal.tsx b/src/renderer/app/components/modals/AboutModal/AboutModal.tsx index e29e80ac..4d9e94b8 100644 --- a/src/renderer/app/components/modals/AboutModal/AboutModal.tsx +++ b/src/renderer/app/components/modals/AboutModal/AboutModal.tsx @@ -1,5 +1,5 @@ import logo from '@assets/img/auryo-dark.png'; -import { StoreState } from '@common/store'; +import { StoreState } from '@common/store/rootReducer'; // eslint-disable-next-line import/no-extraneous-dependencies import { remote } from 'electron'; import * as os from 'os'; diff --git a/src/renderer/app/components/player/Player.tsx b/src/renderer/app/components/player/Player.tsx index 83c68a60..7d4d309e 100644 --- a/src/renderer/app/components/player/Player.tsx +++ b/src/renderer/app/components/player/Player.tsx @@ -1,6 +1,6 @@ import { Intent, Popover, PopoverInteractionKind, Slider, Tag } from '@blueprintjs/core'; import { IMAGE_SIZES } from '@common/constants'; -import { StoreState } from '@common/store'; +import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; import { hasLiked } from '@common/store/auth/selectors'; import { getNormalizedTrack, getNormalizedUser } from '@common/store/entities/selectors'; diff --git a/src/renderer/app/components/player/components/Audio.tsx b/src/renderer/app/components/player/components/Audio.tsx index ffc681ba..c90b199b 100644 --- a/src/renderer/app/components/player/components/Audio.tsx +++ b/src/renderer/app/components/player/components/Audio.tsx @@ -3,12 +3,12 @@ import { Intent } from '@blueprintjs/core'; import { EVENTS } from '@common/constants'; import * as actions from '@common/store/actions'; import { ChangeTypes, PlayerStatus } from '@common/store/player'; +import { useAudioPlayer, useAudioPosition } from '@renderer/hooks/useAudioPlayer'; // eslint-disable-next-line import/no-extraneous-dependencies import { ipcRenderer } from 'electron'; import { FC, useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; -import { useAudioPlayer, useAudioPosition } from '@renderer/hooks/useAudioPlayer'; -import { usePrevious } from '@renderer/hooks/usePrevious'; +import { usePrevious } from 'react-use'; interface Props { src?: string; diff --git a/src/renderer/app/components/player/components/PlayerProgress/PlayerProgress.tsx b/src/renderer/app/components/player/components/PlayerProgress/PlayerProgress.tsx index dee0c4b6..012fcaa0 100644 --- a/src/renderer/app/components/player/components/PlayerProgress/PlayerProgress.tsx +++ b/src/renderer/app/components/player/components/PlayerProgress/PlayerProgress.tsx @@ -14,8 +14,8 @@ export const PlayerProgress: FC = () => { const { seek } = useAudioPlayer(); const { duration, position: currentTime } = useAudioPosition(); - const [isSeeking, setIsSeeking] = useState(); - const [nextTime, setNextTime] = useState(); + const [isSeeking, setIsSeeking] = useState(false); + const [nextTime, setNextTime] = useState(); const sliderValue = isSeeking ? nextTime : currentTime; @@ -57,7 +57,7 @@ export const PlayerProgress: FC = () => { return (
-
{getReadableTime(isSeeking ? nextTime : currentTime, false, true)}
+
{getReadableTime(isSeeking && nextTime ? nextTime : currentTime, false, true)}
{ + const resolverRef = useRef(); + const previous = usePrevious(isFetching); + + useEffect(() => { + if (previous && !isFetching && resolverRef.current) { + resolverRef.current(); + resolverRef.current = undefined; + } + }, [isFetching, previous]); + + const loadMore = useCallback(() => { + return new Promise(resolve => { + resolverRef.current = resolve; + loadMoreFunction(); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loadMoreFunction, ...dependencies]); + + return { loadMore }; +}; diff --git a/src/renderer/hooks/usePrevious.tsx b/src/renderer/hooks/usePrevious.tsx deleted file mode 100644 index ff4f49df..00000000 --- a/src/renderer/hooks/usePrevious.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { useRef, useEffect } from 'react'; - -export const usePrevious = (value: T) => { - const ref = useRef(); - useEffect(() => { - ref.current = value; - }); - return ref.current; -}; diff --git a/src/renderer/index.tsx b/src/renderer/index.tsx index 361edf17..5a31e030 100755 --- a/src/renderer/index.tsx +++ b/src/renderer/index.tsx @@ -1,20 +1,16 @@ import '@blueprintjs/core/lib/css/blueprint.css'; import '@blueprintjs/icons/lib/css/blueprint-icons.css'; import '@common/sentryReporter'; -import { SC } from '@common/utils'; +import store, { history } from '@common/store'; +import { initApp } from '@common/store/actions'; import 'boxicons/css/boxicons.min.css'; -// eslint-disable-next-line import/no-extraneous-dependencies -import { remote } from 'electron'; import is from 'electron-is'; import React from 'react'; import ReactDOM from 'react-dom'; -import { configureStore } from '../common/configureStore'; -// eslint-disable-next-line import/no-named-as-default -import App from './App'; +import { App } from './App'; import './css/app.scss'; -import { history } from './history'; - -const { app } = remote; +import { Provider } from 'react-redux'; +import { ConnectedRouter } from 'connected-react-router'; let osClass = ''; @@ -34,37 +30,13 @@ if (process.env.NODE_ENV === 'development') { // whyDidYouUpdate(React); } -const store = configureStore(history); - -if (!process.env.TOKEN && process.env.NODE_ENV === 'production') { - const { - config: { - app: { analytics } - } - } = store.getState(); - - // eslint-disable-next-line - const { ua } = require('@common/utils/universalAnalytics'); - - ua.set('version', app.getVersion()); - ua.set('anonymizeIp', true); - if (analytics) { - ua.pv('/').send(); - - history.listen(location => { - ua.pv(location.pathname).send(); - }); - } -} - -const { - config: { - auth: { token } - } -} = store.getState(); - -if (token) { - SC.initialize(token); -} +store.dispatch(initApp()); -ReactDOM.render(, document.getElementById('root')); +ReactDOM.render( + + + + + , + document.getElementById('root') +); diff --git a/src/renderer/pages/GenericPlaylist/index.tsx b/src/renderer/pages/GenericPlaylist/index.tsx new file mode 100644 index 00000000..2b36627e --- /dev/null +++ b/src/renderer/pages/GenericPlaylist/index.tsx @@ -0,0 +1,123 @@ +import { getGenericPlaylist, genericPlaylistFetchMore } from '@common/store/actions'; +import { PlaylistTypes } from '@common/store/objects'; +import { getPlaylistObjectSelector } from '@common/store/objects/selectors'; +import { SortTypes } from '@common/store/playlist/types'; +import { useLoadMorePromise } from '@renderer/hooks/useLoadMorePromise'; +import { SetLayoutSettings } from '@renderer/_shared/context/contentContext'; +import React, { FC, useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import PageHeader from '../../_shared/PageHeader/PageHeader'; +import Spinner from '../../_shared/Spinner/Spinner'; +import TracksGrid from '../../_shared/TracksGrid/TracksGrid'; + +interface OwnProps { + playlistType: PlaylistTypes; + objectId?: string; + title: string; + backgroundImage?: string; + gradient?: string; + sortType?: SortTypes; + showInfo?: boolean; + onSortTypeChange?(event: React.ChangeEvent): void; +} + +type AllProps = OwnProps; + +export const GenericPlaylist: FC = ({ + onSortTypeChange, + sortType, + playlistType, + showInfo, + title, + backgroundImage, + gradient, + objectId +}) => { + const dispatch = useDispatch(); + const isChart = playlistType === PlaylistTypes.CHART; + const playlistObject = useSelector(getPlaylistObjectSelector({ objectId, playlistType })); + + useEffect(() => { + dispatch( + getGenericPlaylist.request({ + playlistType, + refresh: true, + sortType, + objectId + }) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sortType]); + + const { loadMore } = useLoadMorePromise( + playlistObject?.isFetching, + () => { + dispatch(genericPlaylistFetchMore.request({ playlistType, objectId })); + }, + [dispatch, playlistType] + ); + + const renderChartSort = useCallback(() => { + return ( +
+
+ +
+
+ ); + }, [onSortTypeChange, sortType]); + + if (!playlistObject || (playlistObject && playlistObject.isFetching && !playlistObject.items.length)) { + return ; + } + + return ( + <> + + + <> + {isChart && renderChartSort()} +

{title}

+ +
+ + {playlistObject.error && ( +
+
Something seems to have gone wrong fetching this playlist.
+
+ ⚠️ +
+
+ )} + + {!playlistObject.items.length && !playlistObject.error ? ( +
+
That's unfortunate, you don't seem to have any tracks in here
+
+ 🧐 +
+
+ ) : ( + !!playlistObject.items[index]} + loadMore={loadMore} + isLoading={playlistObject.isFetching} + hasMore={!!playlistObject.nextUrl && !playlistObject.error && !playlistObject.isFetching} + /> + )} + + ); +}; + +GenericPlaylist.defaultProps = { + showInfo: false +}; + +export default GenericPlaylist; diff --git a/src/renderer/pages/artist/ArtistPage.tsx b/src/renderer/pages/artist/ArtistPage.tsx index 94a83a3a..8ad9c97c 100644 --- a/src/renderer/pages/artist/ArtistPage.tsx +++ b/src/renderer/pages/artist/ArtistPage.tsx @@ -1,6 +1,6 @@ import { Menu, MenuItem, Popover, Position } from '@blueprintjs/core'; import { IMAGE_SIZES } from '@common/constants'; -import { StoreState } from '@common/store'; +import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; import { getNormalizedUser } from '@common/store/entities/selectors'; import { ObjectTypes, PlaylistTypes } from '@common/store/objects'; @@ -17,7 +17,7 @@ import cn from 'classnames'; import { autobind } from 'core-decorators'; import React from 'react'; import { connect } from 'react-redux'; -import { RouteComponentProps, withRouter } from 'react-router'; +import { RouteComponentProps, withRouter } from 'react-router-dom'; import { Col, Row, TabContent, TabPane } from 'reactstrap'; import { bindActionCreators, Dispatch } from 'redux'; import FallbackImage from '../../_shared/FallbackImage'; @@ -29,11 +29,12 @@ import { ToggleMore } from '../../_shared/ToggleMore'; import { TrackList } from '../../_shared/TrackList/TrackList'; import './ArtistPage.scss'; import ArtistProfiles from './components/ArtistProfiles/ArtistProfiles'; +import { currentUserSelector } from '@common/store/auth/selectors'; const mapStateToProps = (state: StoreState, props: OwnProps) => { const { auth, - app: { dimensions }, + ui: { dimensions }, player: { currentPlaylistId, status } } = state; const { @@ -54,7 +55,8 @@ const mapStateToProps = (state: StoreState, props: OwnProps) => { user: getNormalizedUser(+artistId)(state), [PlaylistTypes.ARTIST_TRACKS]: getArtistTracksPlaylistObject(artistId)(state), [PlaylistTypes.ARTIST_LIKES]: getArtistLikesPlaylistObject(artistId)(state), - artistIdParam: +artistId + artistIdParam: +artistId, + currentUser: currentUserSelector(state) }; }; @@ -266,8 +268,8 @@ class ArtistPage extends React.Component { // tslint:disable-next-line: max-func-body-length public render() { - const { user, artistIdParam, auth } = this.props; - const { followings, me } = auth; + const { user, artistIdParam, auth, currentUser } = this.props; + const { followings } = auth; const { small, activeTab } = this.state; if (!user || (user && user.loading) || user.track_count === null) { @@ -298,7 +300,7 @@ class ArtistPage extends React.Component {
{this.renderPlayButton()} - {me && artistIdParam !== me.id ? ( + {currentUser && artistIdParam !== currentUser.id ? ( ; +type Props = RouteComponentProps<{ genre: string }>; -interface State { - sort: SortTypes; -} - -export class ChartsDetailsPage extends React.PureComponent { - public readonly state: State = { - sort: SortTypes.TOP - }; - - public sortTypeChange = (event: React.ChangeEvent) => { - this.setState({ - sort: event.target.value - }); - }; - - public render() { - const { - match: { - params: { genre } - } - } = this.props; - const { sort } = this.state; - - let selectedGenre: GenreConfig | undefined = MUSIC_GENRES.find(g => g.key === genre); - - if (!selectedGenre) { - selectedGenre = AUDIO_GENRES.find(g => g.key === genre); - } - - selectedGenre = selectedGenre as GenreConfig; +export const ChartsDetailsPage: FC = ({ + match: { + params: { genre } + } +}) => { + const [sortType, setSortType] = useState(SortTypes.TOP); - const objectId = `${genre}_${sort}`; + const selectedGenre = [...MUSIC_GENRES, ...AUDIO_GENRES].find(g => g.key === genre); - return ( - - ); + if (!selectedGenre) { + return null; } -} + + return ( + setSortType(event.target.value as SortTypes)} + /> + ); +}; diff --git a/src/renderer/pages/charts/ChartsPage.tsx b/src/renderer/pages/charts/ChartsPage.tsx index 07c7c7f1..d05af996 100644 --- a/src/renderer/pages/charts/ChartsPage.tsx +++ b/src/renderer/pages/charts/ChartsPage.tsx @@ -1,7 +1,7 @@ import { AUDIO_GENRES, MUSIC_GENRES } from '@common/constants'; import cn from 'classnames'; import { autobind } from 'core-decorators'; -import React from 'react'; +import React, { FC } from 'react'; import Masonry from 'react-masonry-css'; import { NavLink, RouteComponentProps } from 'react-router-dom'; import { Nav, TabContent, TabPane } from 'reactstrap'; @@ -10,78 +10,64 @@ import './ChartsPage.scss'; import { ChartGenre } from './components/ChartGenre'; import { GENRE_IMAGES } from './genreImages'; -type OwnProps = RouteComponentProps<{ type?: string }>; +type Props = RouteComponentProps<{ type?: string }>; enum TabTypes { MUSIC = 'MUSIC', AUDIO = 'AUDIO' } -type AllProps = OwnProps; +export const ChartsPage: FC = ({ match: { params } }) => { + const type = params.type || TabTypes.MUSIC; -@autobind -export class ChartsPage extends React.Component { - public shouldComponentUpdate(_nextProps: AllProps) { - const { match } = this.props; + return ( + <> + - return match.params !== _nextProps.match.params; - } +
+ - return ( - <> - - -
- - - - -
- - {MUSIC_GENRES.map(genre => ( - - ))} - -
-
- -
- {AUDIO_GENRES.map(genre => ( -
- -
+ + +
+ + {MUSIC_GENRES.map(genre => ( + ))} -
-
-
-
- - ); - } -} + +
+ + +
+ {AUDIO_GENRES.map(genre => ( +
+ +
+ ))} +
+
+ +
+ + ); +}; diff --git a/src/renderer/pages/foryou/ForYouPage.tsx b/src/renderer/pages/foryou/ForYouPage.tsx index 566f0db5..c2e0bc1f 100644 --- a/src/renderer/pages/foryou/ForYouPage.tsx +++ b/src/renderer/pages/foryou/ForYouPage.tsx @@ -1,72 +1,44 @@ -import { StoreState } from '@common/store'; -import * as actions from '@common/store/actions'; +import { getForYouSelection } from '@common/store/actions'; +import { getAuthPersonalizedPlaylistsSelector } from '@common/store/auth/selectors'; +import { getPlaylistEntities } from '@common/store/entities/selectors'; import cn from 'classnames'; -import { autobind } from 'core-decorators'; -import React from 'react'; -import { connect } from 'react-redux'; +import React, { FC, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { RouteComponentProps } from 'react-router-dom'; -import { bindActionCreators, Dispatch } from 'redux'; import { SoundCloud } from '../../../types'; import Spinner from '../../_shared/Spinner/Spinner'; import { PersonalizedPlaylistCard } from './components/PersonalizedPlaylistCard/PersonalizedPlaylistCard'; import * as styles from './ForYouPage.module.scss'; -const mapStateToProps = (state: StoreState) => { - const { - auth: { personalizedPlaylists }, - entities: { playlistEntities } - } = state; +type Props = RouteComponentProps; - return { - ...personalizedPlaylists, - playlistEntities - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - fetchPersonalizedPlaylistsIfNeeded: actions.fetchPersonalizedPlaylistsIfNeeded - }, - dispatch - ); +export const ForYou: FC = () => { + const dispatch = useDispatch(); + const [itemsOpen, setItemsOpen] = useState<{ [key: string]: number }>({}); + const loading = useSelector(state => getAuthPersonalizedPlaylistsSelector(state).loading); + const items = useSelector(state => getAuthPersonalizedPlaylistsSelector(state).items); + const error = useSelector(state => getAuthPersonalizedPlaylistsSelector(state).error); + const playlistEntities = useSelector(getPlaylistEntities()); -type OwnProps = RouteComponentProps; - -type PropsFromState = ReturnType; - -type PropsFromDispatch = ReturnType; - -interface State { - itemsOpen: { - [key: string]: number | null; - }; -} + useEffect(() => { + dispatch(getForYouSelection.request()); + }, [dispatch]); -type AllProps = OwnProps & PropsFromState & PropsFromDispatch; - -@autobind -class ForYou extends React.Component { - public readonly state: State = { - itemsOpen: {} - }; - - public componentDidMount() { - const { fetchPersonalizedPlaylistsIfNeeded } = this.props; - - fetchPersonalizedPlaylistsIfNeeded(); + if (loading && !items?.length && !error) { + return ; } - public componentDidUpdate() { - const { fetchPersonalizedPlaylistsIfNeeded } = this.props; + const rest = items ? [...items] : []; - fetchPersonalizedPlaylistsIfNeeded(); - } + const weeklyIndex = rest.findIndex(i => i.query_urn?.indexOf('weekly') !== -1); + const weekly = rest.splice(weeklyIndex, 1)[0]; + + const uploadIndex = rest.findIndex(i => i.query_urn?.indexOf('newforyou') !== -1); + const upload = rest.splice(uploadIndex, 1)[0]; - public renderPlaylist(title: string, description: string, collection: string[] = []) { - const { itemsOpen } = this.state; - const { playlistEntities } = this.props; + const combinedCollection = [...(weekly?.items.collection || []), ...(upload?.items.collection || [])]; + const renderPlaylist = (title: string, description: string, collection: string[] = []) => { const ids = collection; const shown = itemsOpen[title] || 6; const showMore = shown < ids.length; @@ -106,11 +78,9 @@ class ForYou extends React.Component { nextPos = 6; } - this.setState({ - itemsOpen: { - ...itemsOpen, - [title]: nextPos - } + setItemsOpen({ + ...itemsOpen, + [title]: nextPos }); }}> @@ -120,37 +90,13 @@ class ForYou extends React.Component {
); - } - - public render() { - const { loading, items } = this.props; - - if (loading && !items) { - return ; - } - - const rest = items ? [...items] : []; - - const weeklyIndex = rest.findIndex(i => i.query_urn?.indexOf('weekly') !== -1); - const weekly = rest.splice(weeklyIndex, 1)[0]; - - const uploadIndex = rest.findIndex(i => i.query_urn?.indexOf('newforyou') !== -1); - const upload = rest.splice(uploadIndex, 1)[0]; - - return ( -
- {weekly && - this.renderPlaylist('Made for you', 'Playlists created by SoundCloud just for you', [ - ...(weekly.items.collection || []), - ...(upload.items.collection || []) - ])} - {rest && - rest.map(i => { - return
{this.renderPlaylist(i.title, i.description, i.items.collection)}
; - })} -
- ); - } -} + }; + return ( +
+ {weekly && renderPlaylist('Made for you', 'Playlists created by SoundCloud just for you', combinedCollection)} + {rest && rest.map(i =>
{renderPlaylist(i.title, i.description, i.items.collection)}
)} +
+ ); +}; -export default connect(mapStateToProps, mapDispatchToProps)(ForYou); +export default ForYou; diff --git a/src/renderer/pages/foryou/components/PersonalizedPlaylistCard/PersonalizedPlaylistCard.tsx b/src/renderer/pages/foryou/components/PersonalizedPlaylistCard/PersonalizedPlaylistCard.tsx index 67bdd7fc..90b8227e 100644 --- a/src/renderer/pages/foryou/components/PersonalizedPlaylistCard/PersonalizedPlaylistCard.tsx +++ b/src/renderer/pages/foryou/components/PersonalizedPlaylistCard/PersonalizedPlaylistCard.tsx @@ -18,7 +18,7 @@ export const PersonalizedPlaylistCard = React.memo(({ playlist, title, sy const imageUrl = playlist.artwork_url || playlist.calculated_artwork_url; return ( - +
diff --git a/src/renderer/pages/onboarding/OnBoarding.tsx b/src/renderer/pages/onboarding/OnBoarding.tsx index 39ba8647..5a0f8e57 100644 --- a/src/renderer/pages/onboarding/OnBoarding.tsx +++ b/src/renderer/pages/onboarding/OnBoarding.tsx @@ -1,14 +1,14 @@ import feetonmusicbox from '@assets/img/feetonmusicbox.jpg'; -import { StoreState } from '@common/store'; +import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; -import { authConfigSelector, configSelector } from '@common/store/config/selectors'; +import { authTokenStateSelector, configSelector } from '@common/store/config/selectors'; import AboutModal from '@renderer/app/components/modals/AboutModal/AboutModal'; import cn from 'classnames'; // eslint-disable-next-line import/no-extraneous-dependencies import { ipcRenderer } from 'electron'; -import React, { useEffect, useState, FC } from 'react'; +import React, { useEffect, useState, FC, useCallback } from 'react'; import { connect } from 'react-redux'; -import { RouteComponentProps } from 'react-router'; +import { RouteComponentProps } from 'react-router-dom'; import { bindActionCreators, Dispatch } from 'redux'; import * as ReduxModal from 'redux-modal'; import { LoginStep } from './components/LoginStep'; @@ -18,20 +18,21 @@ import './OnBoarding.scss'; import { Position } from '@blueprintjs/core'; import { Toastr } from '@renderer/app/components/Toastr'; import { EVENTS } from '@common/constants'; +import { getAppAuth } from '@common/store/appAuth/selectors'; const mapStateToProps = (state: StoreState) => ({ - ...state.auth.authentication, config: configSelector(state), - auth: authConfigSelector(state), - toasts: state.ui.toasts + auth: authTokenStateSelector(state), + toasts: state.ui.toasts, + appAuth: getAppAuth(state) }); const mapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( { show: ReduxModal.show, - setConfigKey: actions.setConfigKey, - clearToasts: actions.clearToasts + clearToasts: actions.clearToasts, + finishOnboarding: actions.finishOnboarding }, dispatch ); @@ -42,33 +43,33 @@ type PropsFromState = ReturnType; type PropsFromDispatch = ReturnType; -type AllProps = OwnProps & PropsFromState & PropsFromDispatch & RouteComponentProps; +type Steps = 'welcome' | 'login' | 'privacy'; -const OnBoarding: FC = ({ loading, error, show, config, setConfigKey, history, toasts, clearToasts }) => { - const [step, setStep] = useState<'welcome' | 'login' | 'privacy'>('login'); +type AllProps = OwnProps & PropsFromState & PropsFromDispatch & RouteComponentProps<{ step?: Steps }>; +const OnBoarding: FC = ({ appAuth, show, config, toasts, clearToasts, match, finishOnboarding }) => { const { - auth: { token }, - lastLogin - } = config; - - useEffect(() => { - if (token) { - if (lastLogin) { - history.replace('/'); - } else { - setStep('welcome'); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [token, lastLogin]); + params: { step: initialStep } + } = match; + const { isLoading, error } = appAuth; + const [step, setStep] = useState(initialStep ?? 'login'); const login = () => { - if (!loading) { + if (!isLoading) { ipcRenderer.send(EVENTS.APP.AUTH.LOGIN); } }; + const finish = useCallback(() => { + finishOnboarding(); + }, [finishOnboarding]); + + useEffect(() => { + if (initialStep) { + setStep(initialStep); + } + }, [initialStep]); + return (
@@ -88,7 +89,7 @@ const OnBoarding: FC = ({ loading, error, show, config, setConfigKey,
- {step === 'login' && } + {step === 'login' && } {step === 'welcome' && ( = ({ loading, error, show, config, setConfigKey, /> )} - {step === 'privacy' && ( - { - setConfigKey('lastLogin', Date.now()); - history.replace('/'); - }} - /> - )} + {step === 'privacy' && }
diff --git a/src/renderer/pages/onboarding/components/LoginStep.tsx b/src/renderer/pages/onboarding/components/LoginStep.tsx index 63192618..a17f7398 100644 --- a/src/renderer/pages/onboarding/components/LoginStep.tsx +++ b/src/renderer/pages/onboarding/components/LoginStep.tsx @@ -5,7 +5,7 @@ import React from 'react'; import * as reduxModal from 'redux-modal'; interface Props { - error: string | null; + error?: string | null; show: typeof reduxModal.show; loading: boolean; login(): void; diff --git a/src/renderer/pages/onboarding/components/PrivacyStep.tsx b/src/renderer/pages/onboarding/components/PrivacyStep.tsx index eff77eba..4c50c74e 100644 --- a/src/renderer/pages/onboarding/components/PrivacyStep.tsx +++ b/src/renderer/pages/onboarding/components/PrivacyStep.tsx @@ -2,49 +2,60 @@ import { Button } from '@blueprintjs/core'; import { ConfigState } from '@common/store/config'; import * as actions from '@common/store/actions'; import { CheckboxConfig } from '@renderer/pages/settings/components/CheckboxConfig'; -import React from 'react'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; interface Props { config: ConfigState; - setConfigKey: typeof actions.setConfigKey; onNext(): void; } -export const PrivacyStep = React.memo(({ onNext, setConfigKey, config }) => ( - <> -

🔒 Privacy

-
- - -
- I use google analytics to get an insight how large the userbase of - the app is. Your ip is being anonymized and no other data than page views and sessions are being tracked. +export const PrivacyStep = React.memo(({ onNext, config }) => { + const dispatch = useDispatch(); + + const setConfigKey = useCallback( + (key: string, value: actions.ConfigValue) => { + return dispatch(actions.setConfigKey(key, value)); + }, + [dispatch] + ); + + return ( + <> +

🔒 Privacy

+
+ + +
+ I use google analytics to get an insight how large the userbase of + the app is. Your ip is being anonymized and no other data than page views and sessions are being tracked. +
+ + + +
+ I use Sentry for error logging. No personal info from your pc is being sent. + If we don't have your crash reports, it's harder for us to fix bugs. +
- - -
- I use Sentry for error logging. No personal info from your pc is being sent. If - we don't have your crash reports, it's harder for us to fix bugs. +
+
-
- -
- -
- -)); + + ); +}); diff --git a/src/renderer/pages/personalizedPlaylist/PersonalizedPlaylistPage.tsx b/src/renderer/pages/personalizedPlaylist/PersonalizedPlaylistPage.tsx deleted file mode 100644 index 26622787..00000000 --- a/src/renderer/pages/personalizedPlaylist/PersonalizedPlaylistPage.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { Menu, MenuDivider, MenuItem, Popover, Position } from '@blueprintjs/core'; -import { IMAGE_SIZES } from '@common/constants'; -import { StoreState } from '@common/store'; -import * as actions from '@common/store/actions'; -import { getPlaylistEntity } from '@common/store/entities/selectors'; -import { getPlaylistObjectSelector } from '@common/store/objects/selectors'; -import { PlayerStatus } from '@common/store/player'; -import { SC } from '@common/utils'; -import { IPC } from '@common/utils/ipc'; -import cn from 'classnames'; -import { autobind } from 'core-decorators'; -import React from 'react'; -import { connect } from 'react-redux'; -import { RouteComponentProps } from 'react-router-dom'; -import { bindActionCreators, Dispatch } from 'redux'; -import PageHeader from '../../_shared/PageHeader/PageHeader'; -import Spinner from '../../_shared/Spinner/Spinner'; -import TracksGrid from '../../_shared/TracksGrid/TracksGrid'; -import './PlaylistPage.scss'; -import { SetLayoutSettings } from '@renderer/_shared/context/contentContext'; - -const mapStateToProps = (state: StoreState, props: OwnProps) => { - const { - player: { currentPlaylistId, status } - } = state; - const { - match: { - params: { playlistId } - } - } = props; - - const isPlayerPlaylist = currentPlaylistId === playlistId; - const isPlaylistPlaying = isPlayerPlaylist && status === PlayerStatus.PLAYING; - - return { - isPlayerPlaylist, - isPlaylistPlaying, - playlist: getPlaylistEntity(playlistId as any)(state) as any, - playlistObject: getPlaylistObjectSelector(playlistId)(state), - playlistIdParam: playlistId as any - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - playTrack: actions.playTrack, - fetchPlaylistIfNeeded: actions.fetchPlaylistIfNeeded, - fetchPlaylistTracks: actions.fetchPlaylistTracks, - addUpNext: actions.addUpNext, - toggleStatus: actions.toggleStatus - }, - dispatch - ); - -type OwnProps = RouteComponentProps<{ playlistId: string }>; - -type PropsFromState = ReturnType; - -type PropsFromDispatch = ReturnType; - -interface State { - scrollTop: number; -} - -type AllProps = OwnProps & PropsFromState & PropsFromDispatch; - -@autobind -class PersonalizedPlaylistPage extends React.Component { - public componentDidMount() { - const { fetchPlaylistTracks, playlistIdParam } = this.props; - - fetchPlaylistTracks(playlistIdParam, 30); - } - - public componentDidUpdate(nextProps: AllProps) { - const { fetchPlaylistTracks, playlistIdParam } = this.props; - - if (playlistIdParam !== nextProps.playlistIdParam) { - fetchPlaylistTracks(playlistIdParam, 30); - } - } - - public renderPlayButton = () => { - const { playlist, playlistIdParam, isPlayerPlaylist, isPlaylistPlaying, playTrack, toggleStatus } = this.props; - - if (!playlist) { - return null; - } - - const firstId = playlist.tracks[0].id; - - if (isPlaylistPlaying) { - return ( - { - toggleStatus(); - }}> - - - ); - } - - const toggle = () => { - if (isPlayerPlaylist) { - toggleStatus(); - } else { - playTrack(playlistIdParam.toString(), { id: firstId }); - } - }; - - return ( - - - - ); - }; - - // tslint:disable-next-line: max-func-body-length - public render() { - const { - // Vars - playlistObject, - playlist, - playlistIdParam, - // Functions - fetchPlaylistTracks, - addUpNext - } = this.props; - - if ( - !playlistObject || - !playlist || - (playlistObject && playlistObject.items.length === 0 && playlistObject.isFetching) - ) { - return ; - } - - const firstItem = playlist.tracks[0]; - const hasImage = playlist.artwork_url || playlist.calculated_artwork_url || (firstItem && firstItem.artwork_url); - - const permalink = `https://soundcloud.com/discover/sets/${playlist.permalink}`; - const isEmpty = !playlistObject.isFetching && playlistObject.items && playlistObject.items.length === 0; - const image = hasImage - ? SC.getImageUrl(playlist.artwork_url || playlist.calculated_artwork_url, IMAGE_SIZES.XLARGE) - : null; - - return ( - <> - - -

{playlist.title}

-
-
{playlist.description}
- -
- {firstItem && !isEmpty ? this.renderPlayButton() : null} - - {!isEmpty && ( - - {playlist.tracks.length ? ( - <> - { - addUpNext(playlist); - }} - /> - - - ) : null} - - { - IPC.openExternal(permalink); - }} - /> - - }> - - - - - )} -
-
-
- {isEmpty ? ( -
-
- This{' '} - - playlist - {' '} - is empty or not available via a third party! -
-
- 😲 -
-
- ) : ( - fetchPlaylistTracks(playlistIdParam, 30) as any} - isItemLoaded={index => !!playlistObject.items.slice(0, playlistObject.fetchedItems)[index]} - /> - )} - - ); - } -} - -export default connect(mapStateToProps, mapDispatchToProps)(PersonalizedPlaylistPage); diff --git a/src/renderer/pages/personalizedPlaylist/PlaylistPage.scss b/src/renderer/pages/personalizedPlaylist/PlaylistPage.scss deleted file mode 100644 index 27740460..00000000 --- a/src/renderer/pages/personalizedPlaylist/PlaylistPage.scss +++ /dev/null @@ -1,13 +0,0 @@ -@import "../../css/bootstrap.imports.scss"; - -.page-header { - &.withImage { - .stats { - color: #fdfdfd; - } - } - .stats { - font-size: .9rem; - opacity: .7; - } -} \ No newline at end of file diff --git a/src/renderer/pages/playlist/PlaylistPage.tsx b/src/renderer/pages/playlist/PlaylistPage.tsx index e5b29dad..f690eeeb 100644 --- a/src/renderer/pages/playlist/PlaylistPage.tsx +++ b/src/renderer/pages/playlist/PlaylistPage.tsx @@ -1,284 +1,182 @@ import { Menu, MenuDivider, MenuItem, Popover, Position } from '@blueprintjs/core'; import { IMAGE_SIZES } from '@common/constants'; -import { StoreState } from '@common/store'; -import * as actions from '@common/store/actions'; +import { addUpNext, getGenericPlaylist, genericPlaylistFetchMore, playTrack } from '@common/store/actions'; +import { getAuthPlaylistsSelector } from '@common/store/auth/selectors'; import { getNormalizedPlaylist, getNormalizedTrack, getNormalizedUser } from '@common/store/entities/selectors'; +import { PlaylistTypes } from '@common/store/objects'; import { getPlaylistObjectSelector } from '@common/store/objects/selectors'; -import { PlayerStatus } from '@common/store/player'; import { getReadableTimeFull, SC } from '@common/utils'; import { IPC } from '@common/utils/ipc'; +import { useLoadMorePromise } from '@renderer/hooks/useLoadMorePromise'; import { SetLayoutSettings } from '@renderer/_shared/context/contentContext'; +import { ToggleLikeButton } from '@renderer/_shared/PageHeader/components/ToggleLikeButton'; +import { TogglePlayButton } from '@renderer/_shared/PageHeader/components/TogglePlayButton'; +import { ToggleRepostButton } from '@renderer/_shared/PageHeader/components/ToggleRepostButton'; import cn from 'classnames'; -import { autobind } from 'core-decorators'; -import React from 'react'; -import { connect } from 'react-redux'; +import React, { FC, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { RouteComponentProps } from 'react-router-dom'; -import { bindActionCreators, Dispatch } from 'redux'; +import { usePrevious } from 'react-use'; import PageHeader from '../../_shared/PageHeader/PageHeader'; import ShareMenuItem from '../../_shared/ShareMenuItem'; import Spinner from '../../_shared/Spinner/Spinner'; import TracksGrid from '../../_shared/TracksGrid/TracksGrid'; import './PlaylistPage.scss'; -const mapStateToProps = (state: StoreState, props: OwnProps) => { - const { - player: { currentPlaylistId, status }, - auth - } = state; - const { - match: { - params: { playlistId } - } - } = props; - - const isPlayerPlaylist = currentPlaylistId === playlistId; - const isPlaylistPlaying = isPlayerPlaylist && status === PlayerStatus.PLAYING; - - const playlist = getNormalizedPlaylist(playlistId as any)(state); - - return { - auth, - isPlayerPlaylist, - isPlaylistPlaying, - playlistObject: getPlaylistObjectSelector(playlistId)(state), - playlistIdParam: playlistId as any, - - playlist, - playlistUser: playlist?.user && getNormalizedUser(playlist.user)(state), - firstItem: playlist && playlist?.tracks?.length > 1 && getNormalizedTrack(playlist.tracks[0].id)(state) - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - playTrack: actions.playTrack, - toggleLike: actions.toggleLike, - toggleRepost: actions.toggleRepost, - fetchPlaylistIfNeeded: actions.fetchPlaylistIfNeeded, - fetchPlaylistTracks: actions.fetchPlaylistTracks, - addUpNext: actions.addUpNext, - toggleStatus: actions.toggleStatus - }, - dispatch - ); - -type OwnProps = RouteComponentProps<{ playlistId: string }>; - -type PropsFromState = ReturnType; - -type PropsFromDispatch = ReturnType; +type Props = RouteComponentProps<{ playlistId: string }>; -interface State { - scrollTop: number; -} - -type AllProps = OwnProps & PropsFromState & PropsFromDispatch; - -@autobind -class PlaylistPage extends React.Component { - public componentDidMount() { - const { fetchPlaylistIfNeeded, playlistIdParam } = this.props; - - fetchPlaylistIfNeeded(playlistIdParam); +const PlaylistPage: FC = ({ + match: { + params: { playlistId: objectId } } - - public componentDidUpdate(prevProps: AllProps) { - const { fetchPlaylistIfNeeded, playlistIdParam } = this.props; - - if (playlistIdParam !== prevProps.playlistIdParam) { - fetchPlaylistIfNeeded(playlistIdParam); - } - } - - public renderPlayButton() { - const { playlist, playlistIdParam, isPlayerPlaylist, isPlaylistPlaying, playTrack, toggleStatus } = this.props; - - if (!playlist) { - return null; - } - - if (isPlaylistPlaying) { - return ( - { - toggleStatus(); - }}> - - +}) => { + const playlistType = PlaylistTypes.PLAYLIST; + const playlist = useSelector(getNormalizedPlaylist(objectId)); + const authPlaylists = useSelector(getAuthPlaylistsSelector); + const playlistObject = useSelector(getPlaylistObjectSelector({ objectId, playlistType })); + const playlistUser = useSelector(getNormalizedUser(playlist?.user)); + const firstItem = useSelector(getNormalizedTrack(playlist?.tracks?.[0]?.id)); + const isPersonalisedPlaylist = objectId.startsWith('soundcloud:'); + + const dispatch = useDispatch(); + const previousObjectId = usePrevious(objectId); + + useEffect(() => { + if (isPersonalisedPlaylist) { + dispatch(genericPlaylistFetchMore.request({ objectId, playlistType })); + } else if (objectId !== previousObjectId) { + dispatch( + getGenericPlaylist.request({ + objectId, + playlistType, + refresh: true + }) ); } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [objectId]); - const toggle = () => { - if (isPlayerPlaylist) { - toggleStatus(); - } else { - playTrack(playlistIdParam.toString()); - } - }; + const { loadMore } = useLoadMorePromise( + playlistObject?.isFetching, + () => { + dispatch(genericPlaylistFetchMore.request({ objectId, playlistType })); + }, + [dispatch, objectId] + ); - return ( - - - - ); + if ( + !playlist || + !playlistObject || + (playlistObject && playlistObject.items.length === 0 && playlistObject.isFetching) + ) { + return ; } - // tslint:disable-next-line: max-func-body-length cyclomatic-complexity - public render() { - const { - // Vars - playlistObject, - playlist, - auth, - playlistIdParam, - firstItem, - playlistUser, - // Functions - toggleLike, - toggleRepost, - fetchPlaylistTracks, - addUpNext - } = this.props; - - const { likes, playlists, reposts } = auth; - - if ( - !playlistObject || - !playlist || - (playlistObject && playlistObject.items.length === 0 && playlistObject.isFetching) - ) { - return ; - } - - const hasImage = playlist.artwork_url || (firstItem && firstItem.artwork_url); - - const liked = SC.hasID(playlistIdParam, likes.playlist); - const reposted = SC.hasID(playlistIdParam, reposts.playlist); - const playlistOwned = playlists.find(p => p.id === playlist.id); - - const isEmpty = - !playlistObject.isFetching && - ((playlist.tracks.length === 0 && playlist.duration === 0) || playlist.track_count === 0); - - const likedIcon = liked ? 'bx bxs-heart' : 'bx bx-heart'; - const image = hasImage - ? SC.getImageUrl(playlist.artwork_url || (firstItem && firstItem.artwork_url), IMAGE_SIZES.XLARGE) - : null; - - const hasMore = playlistObject.items.length > playlistObject.fetchedItems; - - return ( - <> - - - -

{playlist.title}

-
-
- {playlist.track_count} titles -{getReadableTimeFull(playlist.duration, true)} -
- -
- {firstItem && !isEmpty ? this.renderPlayButton() : null} - - {playlist.tracks.length && !playlistOwned ? ( - { - toggleLike(playlist.id, true); - }}> - - {liked ? 'Liked' : 'Like'} - - ) : null} - - {playlist.tracks.length && !playlistOwned ? ( - { - toggleRepost(playlist.id, true); - }}> - - {reposted ? 'Reposted' : 'Repost'} - - ) : null} - - {!isEmpty && ( - - {playlist.tracks.length ? ( - <> - { - addUpNext(playlist); - }} - /> - - - ) : null} - - { - IPC.openExternal(playlist.permalink_url); - }} - /> - {playlistUser && ( - p.id === playlist.id); + + const isEmpty = + !playlistObject.isFetching && + ((playlist.tracks?.length === 0 && playlist.duration === 0) || playlist.track_count === 0); + + const image = hasImage ? SC.getImageUrl(playlist.artwork_url || firstItem?.artwork_url, IMAGE_SIZES.XLARGE) : null; + + const permalink = isPersonalisedPlaylist + ? `https://soundcloud.com/discover/sets/${playlist.permalink}` + : playlist.permalink_url; + + const description = isPersonalisedPlaylist + ? playlist.description + : `${playlist.track_count} titles - ${getReadableTimeFull(playlist.duration, true)}`; + return ( + <> + + + +
+
+ {!!firstItem && !isEmpty && ( + { + dispatch(playTrack(objectId)); + }} + /> + )} + + {!isEmpty && !playlistOwned && !isPersonalisedPlaylist && } + + {!isEmpty && !playlistOwned && !isPersonalisedPlaylist && } + + {!isEmpty && ( + + {!isEmpty ? ( + <> + { + dispatch(addUpNext(playlist)); + }} /> - )} - - }> - - - - - )} -
+ + + ) : null} + + { + IPC.openExternal(permalink); + }} + /> + {playlistUser && !isPersonalisedPlaylist && ( + + )} + + }> + + + + + )}
-
- {isEmpty ? ( -
-
- This{' '} - - playlist - {' '} - is empty or not available via a third party! -
-
- 😲 -
+
+ + {isEmpty ? ( +
+
+ This{' '} + + playlist + {' '} + is empty or not available via a third party! +
+
+ 😲
- ) : ( - index < playlistObject.fetchedItems} - loadMore={() => fetchPlaylistTracks(playlistIdParam, 30) as any} - hasMore={hasMore} - /> - )} - - ); - } -} +
+ ) : ( + !!playlistObject.items[index]} + loadMore={loadMore} + hasMore={!!playlistObject.itemsToFetch.length && !playlistObject.error && !playlistObject.isFetching} + /> + )} + + ); +}; -export default connect(mapStateToProps, mapDispatchToProps)(PlaylistPage); +export default PlaylistPage; diff --git a/src/renderer/pages/playlists/FeedPlaylistPage.tsx b/src/renderer/pages/playlists/FeedPlaylistPage.tsx deleted file mode 100644 index 3d9f3995..00000000 --- a/src/renderer/pages/playlists/FeedPlaylistPage.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { PlaylistTypes } from '@common/store/objects'; -import playlistPage from './playListPageWrapper'; - -export default playlistPage('Stream', PlaylistTypes.STREAM); diff --git a/src/renderer/pages/playlists/LikesPlaylistPage.tsx b/src/renderer/pages/playlists/LikesPlaylistPage.tsx deleted file mode 100644 index b0d9065e..00000000 --- a/src/renderer/pages/playlists/LikesPlaylistPage.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { PlaylistTypes } from '@common/store/objects'; -import playlistPage from './playListPageWrapper'; - -export default playlistPage('Likes', PlaylistTypes.LIKES, false); diff --git a/src/renderer/pages/playlists/MyPlaylistsPage.tsx b/src/renderer/pages/playlists/MyPlaylistsPage.tsx deleted file mode 100644 index 854192ed..00000000 --- a/src/renderer/pages/playlists/MyPlaylistsPage.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { PlaylistTypes } from '@common/store/objects'; -import playlistPage from './playListPageWrapper'; - -export default playlistPage('Playlists', PlaylistTypes.PLAYLISTS, false); diff --git a/src/renderer/pages/playlists/MyTracksPage.tsx b/src/renderer/pages/playlists/MyTracksPage.tsx deleted file mode 100644 index 776a7769..00000000 --- a/src/renderer/pages/playlists/MyTracksPage.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { PlaylistTypes } from '@common/store/objects'; -import playlistPage from './playListPageWrapper'; - -export default playlistPage('Tracks', PlaylistTypes.MYTRACKS, false); diff --git a/src/renderer/pages/playlists/Playlist.tsx b/src/renderer/pages/playlists/Playlist.tsx deleted file mode 100644 index c7ffa7be..00000000 --- a/src/renderer/pages/playlists/Playlist.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { StoreState } from '@common/store'; -import * as actions from '@common/store/actions'; -import { ObjectTypes, PlaylistTypes } from '@common/store/objects'; -import { getPlaylistObjectSelector } from '@common/store/objects/selectors'; -import { SortTypes } from '@common/store/playlist/types'; -import { SetLayoutSettings } from '@renderer/_shared/context/contentContext'; -import { debounce } from 'lodash'; -import React from 'react'; -import isDeepEqual from 'react-fast-compare'; -import { connect } from 'react-redux'; -import { RouteComponentProps, withRouter } from 'react-router-dom'; -import { bindActionCreators, Dispatch } from 'redux'; -import PageHeader from '../../_shared/PageHeader/PageHeader'; -import Spinner from '../../_shared/Spinner/Spinner'; -import TracksGrid from '../../_shared/TracksGrid/TracksGrid'; -import { autobind } from 'core-decorators'; - -const mapStateToProps = (state: StoreState, props: OwnProps) => { - const { objectId } = props; - - return { - playlistObject: getPlaylistObjectSelector(objectId)(state) - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - fetchMore: actions.fetchMore, - fetchChartsIfNeeded: actions.fetchChartsIfNeeded, - getAuthLikesIfNeeded: actions.getAuthLikesIfNeeded, - getAuthTracksIfNeeded: actions.getAuthTracksIfNeeded, - getAuthAllPlaylistsIfNeeded: actions.getAuthAllPlaylistsIfNeeded, - canFetchMoreOf: actions.canFetchMoreOf - }, - dispatch - ); - -interface OwnProps { - objectId: string; - title: string; - backgroundImage?: string; - gradient?: string; - sortType?: SortTypes; - showInfo?: boolean; - chart?: boolean; - sortTypeChange?(event: React.ChangeEvent): void; -} - -type PropsFromState = ReturnType; - -type PropsFromDispatch = ReturnType; - -type AllProps = OwnProps & PropsFromState & PropsFromDispatch & RouteComponentProps; - -@autobind -class Playlist extends React.Component { - private readonly debouncedFetchMore: () => Promise; - - public static defaultProps: Partial = { - showInfo: false, - chart: false - }; - - constructor(props: AllProps) { - super(props); - - this.debouncedFetchMore = debounce(() => props.fetchMore(props.objectId, ObjectTypes.PLAYLISTS) as any, 500); - } - - public componentDidMount() { - this.fetchPlaylist(); - } - - public shouldComponentUpdate(nextProps: AllProps) { - if (!isDeepEqual(nextProps, this.props)) { - return true; - } - - return false; - } - - public componentDidUpdate() { - this.fetchPlaylist(); - } - - public fetchPlaylist = () => { - const { - playlistObject, - chart, - fetchChartsIfNeeded, - sortType, - fetchMore, - objectId, - getAuthLikesIfNeeded, - getAuthTracksIfNeeded, - getAuthAllPlaylistsIfNeeded - } = this.props; - - if (!playlistObject) { - if (chart) { - fetchChartsIfNeeded(objectId, sortType); - } else { - switch (objectId) { - case PlaylistTypes.LIKES: - getAuthLikesIfNeeded(); - break; - case PlaylistTypes.MYTRACKS: - getAuthTracksIfNeeded(); - break; - case PlaylistTypes.PLAYLISTS: - getAuthAllPlaylistsIfNeeded(); - break; - default: - } - } - } else if (!playlistObject || (playlistObject.items.length === 0 && playlistObject && !playlistObject.isFetching)) { - fetchMore(objectId, ObjectTypes.PLAYLISTS); - } - }; - - public renderChartSort = () => { - const { sortTypeChange, sortType } = this.props; - - return ( -
-
- -
-
- ); - }; - - public render() { - const { playlistObject, objectId, showInfo, title, chart, backgroundImage, gradient, canFetchMoreOf } = this.props; - - if (!playlistObject || (playlistObject && playlistObject.items.length === 0 && playlistObject.isFetching)) { - return ; - } - - return ( - <> - - - <> - {chart && this.renderChartSort()} -

{title}

- -
- - {!playlistObject.items.length ? ( -
-
That's unfortunate, you don't seem to have any tracks in here
-
- 🧐 -
-
- ) : ( - !!playlistObject.items[index]} - loadMore={() => this.debouncedFetchMore() as any} - isLoading={playlistObject.isFetching} - hasMore={canFetchMoreOf(objectId, ObjectTypes.PLAYLISTS) as any} - /> - )} - - ); - } -} - -export default connect( - mapStateToProps, - mapDispatchToProps -)(withRouter(Playlist)); diff --git a/src/renderer/pages/playlists/playListPageWrapper.tsx b/src/renderer/pages/playlists/playListPageWrapper.tsx deleted file mode 100644 index debc9e66..00000000 --- a/src/renderer/pages/playlists/playListPageWrapper.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; -import PlaylistPage from './Playlist'; - -const playlistPage = (name: string, objectId: string, showInfo = true) => { - return () => ; -}; - -export default playlistPage; diff --git a/src/renderer/pages/search/SearchPage.tsx b/src/renderer/pages/search/SearchPage.tsx index bf73cc3c..cba81988 100644 --- a/src/renderer/pages/search/SearchPage.tsx +++ b/src/renderer/pages/search/SearchPage.tsx @@ -1,141 +1,94 @@ -import { StoreState } from '@common/store'; import * as actions from '@common/store/actions'; -import { ObjectTypes, PlaylistTypes } from '@common/store/objects'; -import { getPlaylistName, getPlaylistObjectSelector } from '@common/store/objects/selectors'; -import React from 'react'; -import { connect } from 'react-redux'; -import { RouteComponentProps } from 'react-router'; -import { NavLink } from 'react-router-dom'; -import { bindActionCreators, Dispatch } from 'redux'; +import { searchPlaylistFetchMore } from '@common/store/actions'; +import { PlaylistTypes } from '@common/store/objects'; +import { getPlaylistObjectSelector } from '@common/store/objects/selectors'; +import { getSearchQuery } from '@common/store/ui/selectors'; +import { useLoadMorePromise } from '@renderer/hooks/useLoadMorePromise'; +import React, { FC, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { NavLink, RouteComponentProps } from 'react-router-dom'; import Spinner from '../../_shared/Spinner/Spinner'; import TracksGrid from '../../_shared/TracksGrid/TracksGrid'; -import { autobind } from 'core-decorators'; -const mapStateToProps = (state: StoreState, props: OwnProps) => { - const { - match: { - params: { category } - }, - location: { search: rawSearch } - } = props; - - const query: string = decodeURI(rawSearch.replace('?', '')); +type Props = RouteComponentProps<{ playlistType?: PlaylistTypes }>; - let objectId: string; - - switch (category) { - case 'user': - objectId = getPlaylistName(query, PlaylistTypes.SEARCH_USER); - break; - case 'playlist': - objectId = getPlaylistName(query, PlaylistTypes.SEARCH_PLAYLIST); - break; - case 'track': - objectId = getPlaylistName(query, PlaylistTypes.SEARCH_TRACK); - break; - default: - objectId = getPlaylistName(query, PlaylistTypes.SEARCH); +export const SearchPage: FC = ({ + match: { + params: { playlistType = PlaylistTypes.SEARCH } } +}) => { + const dispatch = useDispatch(); + const playlistObject = useSelector(getPlaylistObjectSelector({ playlistType })); + const query = useSelector(getSearchQuery); + + useEffect(() => { + if (playlistType !== PlaylistTypes.SEARCH && playlistObject?.meta?.query !== query) { + dispatch( + actions.getSearchPlaylist({ + playlistType, + refresh: true, + query + }) + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playlistType]); - return { - query, - playlist: getPlaylistObjectSelector(objectId)(state), - objectId - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - search: actions.search, - canFetchMoreOf: actions.canFetchMoreOf, - fetchMore: actions.fetchMore, - toggleFollowing: actions.toggleFollowing, - playTrack: actions.playTrack + const { loadMore } = useLoadMorePromise( + playlistObject?.isFetching, + () => { + dispatch(searchPlaylistFetchMore({ playlistType })); }, - dispatch + [dispatch, playlistType] ); -type OwnProps = RouteComponentProps<{ category?: string }>; - -type PropsFromState = ReturnType; - -type PropsFromDispatch = ReturnType; - -type AllProps = OwnProps & PropsFromState & PropsFromDispatch; - -@autobind -class Search extends React.Component { - public componentDidMount() { - const { query, search, playlist, objectId } = this.props; - - if (!playlist && query && query.length) { - search({ query }, objectId, 15); - } - } - - public componentDidUpdate(prevProps: AllProps) { - const { query, search, playlist, objectId } = this.props; - - if ((query !== prevProps.query || !playlist) && query && query.length) { - search({ query }, objectId, 15); - } - } - - public render() { - const { playlist, objectId, query, canFetchMoreOf, fetchMore } = this.props; - - if (!playlist || (playlist && !playlist.items.length && playlist.isFetching)) { - return ; - } - - return ( - <> -
-
- - All - - - - Users - - - Tracks - - - Playlist - -
+ return ( + <> +
+
+ + All + + + + Users + + + Tracks + + + Playlist +
- - {query === '' || (playlist && !playlist.items.length && !playlist.isFetching) ? ( -
-
- {query ? `No results for "${query}"` : 'Search for people, tracks and albums'} -
-
- {query ? '😭' : '🕵️‍'} +
+ + {!playlistObject || (!playlistObject?.items.length && playlistObject?.isFetching) ? ( + + ) : ( + <> + {!query || query === '' || (!playlistObject.items.length && !playlistObject.isFetching) ? ( +
+
+ {query ? `No results for "${query}"` : 'Search for people, tracks and albums'} +
+
+ {query ? '😭' : '🕵️‍'} +
-
- ) : ( - !!playlist.items[index]} - loadMore={() => { - return fetchMore(objectId, ObjectTypes.PLAYLISTS) as any; - }} - hasMore={canFetchMoreOf(objectId, ObjectTypes.PLAYLISTS) as any} - /> - )} - - ); - } -} + ) : ( + !!playlistObject.items[index]} + loadMore={loadMore} + hasMore={!!playlistObject.nextUrl && !playlistObject.error && !playlistObject.isFetching} + /> + )} + + )} + + ); +}; -export default connect( - mapStateToProps, - mapDispatchToProps -)(Search); +export default SearchPage; diff --git a/src/renderer/pages/settings/Settings.tsx b/src/renderer/pages/settings/Settings.tsx index 25a8bccb..b2b30aaa 100644 --- a/src/renderer/pages/settings/Settings.tsx +++ b/src/renderer/pages/settings/Settings.tsx @@ -1,7 +1,7 @@ import { Button, Collapse, Intent, Switch } from '@blueprintjs/core'; import fetchToJson from '@common/api/helpers/fetchToJson'; import { EVENTS } from '@common/constants/events'; -import { StoreState } from '@common/store'; +import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; import { SC } from '@common/utils'; import { ThemeKeys } from '@renderer/app/components/Theme/themes'; @@ -19,9 +19,9 @@ import { InputConfig } from './components/InputConfig'; import { SelectConfig } from './components/SelectConfig'; import './Settings.scss'; -const mapStateToProps = ({ config, auth, app }: StoreState) => ({ +const mapStateToProps = ({ config, app, appAuth }: StoreState) => ({ config, - authenticated: !!config.auth.token && !auth.authentication.loading, + authenticated: !!config.auth.token && !appAuth.isLoading, lastfmLoading: app.lastfmLoading }); diff --git a/src/renderer/pages/tags/TagsPage.tsx b/src/renderer/pages/tags/TagsPage.tsx index 8d0fb036..ac2d21bf 100644 --- a/src/renderer/pages/tags/TagsPage.tsx +++ b/src/renderer/pages/tags/TagsPage.tsx @@ -1,121 +1,84 @@ -import { StoreState } from '@common/store'; import * as actions from '@common/store/actions'; -import { ObjectTypes, PlaylistTypes } from '@common/store/objects'; -import { getPlaylistName, getPlaylistObjectSelector } from '@common/store/objects/selectors'; +import { searchPlaylistFetchMore } from '@common/store/actions'; +import { PlaylistTypes } from '@common/store/objects'; +import { getPlaylistObjectSelector } from '@common/store/objects/selectors'; +import { useLoadMorePromise } from '@renderer/hooks/useLoadMorePromise'; import cn from 'classnames'; -import { autobind } from 'core-decorators'; -import React from 'react'; -import { connect } from 'react-redux'; +import React, { FC, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { NavLink, RouteComponentProps } from 'react-router-dom'; import { Nav } from 'reactstrap'; -import { bindActionCreators, Dispatch } from 'redux'; import PageHeader from '../../_shared/PageHeader/PageHeader'; import Spinner from '../../_shared/Spinner/Spinner'; import TracksGrid from '../../_shared/TracksGrid/TracksGrid'; -enum TabTypes { - TRACKS = 'TRACKS', - PLAYLISTS = 'PLAYLISTS' -} +type Props = RouteComponentProps<{ tag: string; playlistType: PlaylistTypes }>; -const mapStateToProps = (state: StoreState, props: OwnProps) => { - const { - match: { - params: { tag, type } +export const TagsPage: FC = ({ + match: { + params: { tag, playlistType = PlaylistTypes.SEARCH_TRACK } + } +}) => { + const dispatch = useDispatch(); + const playlistObject = useSelector(getPlaylistObjectSelector({ playlistType })); + + useEffect(() => { + if (playlistType !== PlaylistTypes.SEARCH && playlistObject?.meta?.query !== tag) { + dispatch( + actions.getSearchPlaylist({ + playlistType, + refresh: true, + tag + }) + ); } - } = props; - - const showType = (type as TabTypes) || TabTypes.TRACKS; - - const objectId = getPlaylistName( - tag, - showType === TabTypes.TRACKS ? PlaylistTypes.SEARCH_TRACK : PlaylistTypes.SEARCH_PLAYLIST - ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playlistType]); - return { - objectId, - playlist: getPlaylistObjectSelector(objectId)(state), - tag, - showType - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - search: actions.search, - canFetchMoreOf: actions.canFetchMoreOf, - fetchMore: actions.fetchMore + const { loadMore } = useLoadMorePromise( + playlistObject?.isFetching, + () => { + dispatch(searchPlaylistFetchMore({ playlistType })); }, - dispatch + [dispatch, playlistType] ); -type OwnProps = RouteComponentProps<{ tag: string; type: string }>; - -type PropsFromState = ReturnType; - -type PropsFromDispatch = ReturnType; - -type AllProps = OwnProps & PropsFromState & PropsFromDispatch; - -@autobind -class TagsPage extends React.Component { - public componentDidMount() { - const { tag, playlist, objectId, search } = this.props; - - if (!playlist && tag && tag.length) { - search({ tag }, objectId, 25); - } - } - - public componentDidUpdate(prevProps: AllProps) { - const { tag, playlist, objectId, search, showType } = this.props; - - if (((tag !== prevProps.tag || !playlist) && tag && tag.length) || showType !== prevProps.showType) { - search({ tag }, objectId, 25); - } - } - - public render() { - const { objectId, playlist, showType, tag, fetchMore, canFetchMoreOf } = this.props; - - return ( - <> - - -
- -
- - {!playlist || (playlist && !playlist.items.length && playlist.isFetching) ? ( - - ) : ( - fetchMore(objectId, ObjectTypes.PLAYLISTS) as any} - isLoading={playlist.isFetching} - isItemLoaded={index => !!playlist.items[index]} - /> - )} - - ); - } -} + return ( + <> + + +
+ +
+ + {!playlistObject || (!playlistObject?.items.length && playlistObject?.isFetching) ? ( + + ) : ( + !!playlistObject.items[index]} + loadMore={loadMore} + hasMore={!!playlistObject.nextUrl && !playlistObject.error && !playlistObject.isFetching} + /> + )} + + ); +}; -export default connect(mapStateToProps, mapDispatchToProps)(TagsPage); +export default TagsPage; diff --git a/src/renderer/pages/track/TrackPage.tsx b/src/renderer/pages/track/TrackPage.tsx index cb1d08b2..70f1af53 100644 --- a/src/renderer/pages/track/TrackPage.tsx +++ b/src/renderer/pages/track/TrackPage.tsx @@ -1,6 +1,6 @@ import { Menu, MenuDivider, MenuItem, Popover, Position } from '@blueprintjs/core'; import { IMAGE_SIZES } from '@common/constants'; -import { StoreState } from '@common/store'; +import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; import { getUserPlaylistsCombined } from '@common/store/auth/selectors'; import { getNormalizedTrack, getNormalizedUser } from '@common/store/entities/selectors'; @@ -20,10 +20,10 @@ import { RouteComponentProps } from 'react-router-dom'; import { Col, Row, TabContent, TabPane } from 'reactstrap'; import { bindActionCreators, Dispatch } from 'redux'; import FallbackImage from '../../_shared/FallbackImage'; +import { TogglePlayButton } from '../../_shared/PageHeader/components/TogglePlayButton'; import PageHeader from '../../_shared/PageHeader/PageHeader'; import ShareMenuItem from '../../_shared/ShareMenuItem'; import Spinner from '../../_shared/Spinner/Spinner'; -import TogglePlayButton from '../../_shared/TogglePlayButton'; import { TrackList } from '../../_shared/TrackList/TrackList'; import { TrackOverview } from './components/TrackOverview'; import './TrackPage.scss'; @@ -38,7 +38,7 @@ const mapStateToProps = (state: StoreState, props: OwnProps) => { const relatedPlaylistId = getPlaylistName(songId, PlaylistTypes.RELATED); const track = getNormalizedTrack(+songId)(state); - const user = getNormalizedUser(track.user)(state); + const user = getNormalizedUser(track?.user)(state); return { playingTrack, @@ -95,13 +95,13 @@ class TrackPage extends React.PureComponent { public componentDidMount() { const { fetchTrackIfNeeded, songIdParam } = this.props; - fetchTrackIfNeeded(songIdParam); + // fetchTrackIfNeeded(songIdParam); } public componentDidUpdate() { const { fetchTrackIfNeeded, songIdParam } = this.props; - fetchTrackIfNeeded(songIdParam); + // fetchTrackIfNeeded(songIdParam); } public toggle(tab: TabTypes) { @@ -114,26 +114,6 @@ class TrackPage extends React.PureComponent { } } - public renderToggleButton() { - const { songIdParam, playTrack, relatedPlaylistId, playingTrack } = this.props; - - // TODO redundant? - - if (playingTrack && playingTrack.id !== null && playingTrack.id === songIdParam) { - return ; - } - - const playTrackFunc = () => { - playTrack(relatedPlaylistId, { id: songIdParam }); - }; - - return ( - - - - ); - } - // tslint:disable-next-line: max-func-body-length public render() { const { @@ -154,7 +134,9 @@ class TrackPage extends React.PureComponent { addUpNext, canFetchMoreOf, fetchMore, - user + user, + playTrack, + songIdParam } = this.props; const { activeTab } = this.state; @@ -191,7 +173,13 @@ class TrackPage extends React.PureComponent {
{SC.isStreamable(track) ? ( - this.renderToggleButton() + { + playTrack(relatedPlaylistId, { id: songIdParam }); + }} + /> ) : ( This track is not streamable diff --git a/src/renderer/pages/track/components/TrackOverview.tsx b/src/renderer/pages/track/components/TrackOverview.tsx index 3ec2a018..354d77dc 100644 --- a/src/renderer/pages/track/components/TrackOverview.tsx +++ b/src/renderer/pages/track/components/TrackOverview.tsx @@ -11,7 +11,7 @@ import TrackGridUser from '../../../_shared/TracksGrid/TrackgridUser/TrackGridUs interface Props { track: Normalized.Track; - comments: ObjectState | null; + comments: ObjectState | null; hasMore: boolean; loadMore(): Promise; diff --git a/src/types/index.ts b/src/types/index.ts index 21d5efb3..5cf12736 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,9 +1,32 @@ -import * as Normalized from "./normalized"; -import * as SoundCloud from "./soundcloud"; +import { AxiosError } from 'axios'; +import { ThunkAction } from 'redux-thunk'; +import * as Normalized from './normalized'; +import * as SoundCloud from './soundcloud'; +import { StoreState } from '@common/store/types'; export { SoundCloud, Normalized }; export interface GetPlaylistOptions { - refresh?: boolean; - appendId?: number | null; -} \ No newline at end of file + refresh?: boolean; + appendId?: number | null; +} + +export interface ObjectMap { + [followId: string]: boolean; +} + +export type Collection = { + collection: T[]; + next_href?: string; + query_urn?: string | null; +}; + +export type EntitiesOf = { [key: string]: { [key: string]: T } }; + +export type ResultOf = Array & { [P in K]: string }>; + +export type ThunkResult = ThunkAction; + +export interface EpicFailure { + error: AxiosError | Error; +} diff --git a/src/types/normalized.ts b/src/types/normalized.ts index 6a907a1d..3c30811d 100644 --- a/src/types/normalized.ts +++ b/src/types/normalized.ts @@ -1,7 +1,7 @@ import * as SoundCloud from './soundcloud'; export interface Playlist extends Omit { - tracks: NormalizedResult[]; + tracks?: NormalizedResult[]; user: number; } diff --git a/src/types/soundcloud.ts b/src/types/soundcloud.ts index 59604fb6..eb4596c5 100644 --- a/src/types/soundcloud.ts +++ b/src/types/soundcloud.ts @@ -1,225 +1,225 @@ export type DateString = string; export interface Asset { - id: number; - kind: T; - uri: string; + id: number; + kind: T; + uri: string; - // Local addition for checking if there's already one of these assets being loaded - loading?: boolean; + // Local addition for checking if there's already one of these assets being loaded + loading?: boolean; } export enum AssetType { - USER = "user", - COMMENT = "comment", - TRACK = "track", - PLAYLIST = "playlist", - WEBPROFILE = "web-profile", - SYSTEMPLAYLIST = "system-playlist" + USER = 'user', + COMMENT = 'comment', + TRACK = 'track', + PLAYLIST = 'playlist', + WEBPROFILE = 'web-profile', + SYSTEMPLAYLIST = 'system-playlist' } export enum ProfileService { - SOUNDCLOUD = "soundcloud", - INSTAGRAM = "instagram", - FACEBOOK = "facebook", - TWITTER = "twitter", - YOUTUBE = "youtube", - SPOTIFY = "spotify", - TUMBLR = "tumblr", - PINTEREST = "pinterest", - SNAPCHAT = "snapchat", - PERSONAL = "personal", - SONGKICK = "songkick", - BEATPORT = "beatport" + SOUNDCLOUD = 'soundcloud', + INSTAGRAM = 'instagram', + FACEBOOK = 'facebook', + TWITTER = 'twitter', + YOUTUBE = 'youtube', + SPOTIFY = 'spotify', + TUMBLR = 'tumblr', + PINTEREST = 'pinterest', + SNAPCHAT = 'snapchat', + PERSONAL = 'personal', + SONGKICK = 'songkick', + BEATPORT = 'beatport' } export interface Profile extends Asset { - created_at: DateString; - service: ProfileService; - title: string; - url: string; - username: string; + created_at: DateString; + service: ProfileService; + title: string; + url: string; + username: string; } export interface Comment extends Asset { - created_at: DateString; - user_id: number; - track_id: number; - timestamp: number; - body: string; - user: CompactUser; + created_at: DateString; + user_id: number; + track_id: number; + timestamp: number; + body: string; + user: CompactUser; } export interface CompactUser extends Asset { - permalink: string; - username: string; - last_modified: string; - permalink_url: string; - avatar_url: string; + permalink: string; + username: string; + last_modified: string; + permalink_url: string; + avatar_url: string; } export interface User extends Asset { - permalink: string; - username: string; - last_modified: string; - permalink_url: string; - avatar_url: string; - country?: any; - first_name: string; - last_name: string; - full_name: string; - description?: any; - city?: any; - discogs_name?: any; - myspace_name?: any; - website?: any; - website_title?: any; - track_count: number; - playlist_count: number; - online: boolean; - plan: string; - public_favorites_count: number; - followers_count: number; - followings_count: number; - subscriptions: any[]; - upload_seconds_left: number; - quota: Quota; - private_tracks_count: number; - private_playlists_count: number; - primary_email_confirmed: boolean; - locale: string; - reposts_count: number; - - profiles?: UserProfiles; + permalink: string; + username: string; + last_modified: string; + permalink_url: string; + avatar_url: string; + country?: any; + first_name: string; + last_name: string; + full_name: string; + description?: any; + city?: any; + discogs_name?: any; + myspace_name?: any; + website?: any; + website_title?: any; + track_count: number; + playlist_count: number; + online: boolean; + plan: string; + public_favorites_count: number; + followers_count: number; + followings_count: number; + subscriptions: any[]; + upload_seconds_left: number; + quota: Quota; + private_tracks_count: number; + private_playlists_count: number; + primary_email_confirmed: boolean; + locale: string; + reposts_count: number; + + profiles?: UserProfiles; } export interface UserProfiles { - items: Profile[]; - loading: boolean; + items: Profile[]; + loading: boolean; } export interface Track extends Asset { - created_at: string; - user_id: number; - duration: number; - commentable: boolean; - state: string; - original_content_size: number; - last_modified: string; - sharing: string; - tag_list: string; - permalink: string; - streamable: boolean; - embeddable_by: string; - purchase_url: string; - purchase_title: string; - label_id?: any; - genre: string; - title: string; - description: string; - label_name: string; - release?: any; - track_type?: any; - key_signature?: any; - isrc: string; - video_url?: any; - bpm?: any; - release_year: number; - release_month: number; - release_day: number; - original_format: string; - license: string; - user: CompactUser; - permalink_url: string; - artwork_url: string; - stream_url: string; - download_url: string; - playback_count: number; - download_count: number; - likes_count: number; - reposts_count: number; - comment_count: number; - downloadable: boolean; - waveform_url: string; - attachments_uri: string; - media?: { - transcodings: any[]; - }; - - // Will only be added to items fetched by charts - score?: number; - - policy?: any; - - // Will only be added to items fetched by stream - from_user?: CompactUser; - - error?: any; - - type?: string; + created_at: string; + user_id: number; + duration: number; + commentable: boolean; + state: string; + original_content_size: number; + last_modified: string; + sharing: string; + tag_list: string; + permalink: string; + streamable: boolean; + embeddable_by: string; + purchase_url: string; + purchase_title: string; + label_id?: any; + genre: string; + title: string; + description: string; + label_name: string; + release?: any; + track_type?: any; + key_signature?: any; + isrc: string; + video_url?: any; + bpm?: any; + release_year: number; + release_month: number; + release_day: number; + original_format: string; + license: string; + user: CompactUser; + permalink_url: string; + artwork_url: string; + stream_url: string; + download_url: string; + playback_count: number; + download_count: number; + likes_count: number; + reposts_count: number; + comment_count: number; + downloadable: boolean; + waveform_url: string; + attachments_uri: string; + media?: { + transcodings: any[]; + }; + + // Will only be added to items fetched by charts + score?: number; + + policy?: any; + + // Will only be added to items fetched by stream + fromUser?: CompactUser; + + error?: any; + + type?: string; } export interface Playlist extends Asset { - duration: number; - release_day?: any; - permalink_url: string; - genre?: any; - permalink: string; - purchase_url?: any; - release_month?: any; - description?: any; - label_name?: any; - tag_list: string; - release_year?: any; - secret_uri: string; - track_count: number; - user_id: number; - last_modified: string; - license: string; - tracks: Track[]; - playlist_type?: any; - downloadable: boolean; - sharing: string; - secret_token: string; - created_at: string; - release?: any; - title: string; - type?: any; - purchase_title?: any; - artwork_url?: any; - ean?: any; - streamable: boolean; - user: CompactUser; - embeddable_by: string; - label_id?: any; - likes_count: number; - reposts_count: number; - - // Will only be added to items fetched by stream - from_user?: CompactUser; - - policy?: any; + duration: number; + release_day?: any; + permalink_url: string; + genre?: any; + permalink: string; + purchase_url?: any; + release_month?: any; + description?: any; + label_name?: any; + tag_list: string; + release_year?: any; + secret_uri: string; + track_count: number; + user_id: number; + last_modified: string; + license: string; + tracks: Track[]; + playlist_type?: any; + downloadable: boolean; + sharing: string; + secret_token: string; + created_at: string; + release?: any; + title: string; + type?: any; + purchase_title?: any; + artwork_url?: any; + ean?: any; + streamable: boolean; + user: CompactUser; + embeddable_by: string; + label_id?: any; + likes_count: number; + reposts_count: number; + + // Will only be added to items fetched by stream + fromUser?: CompactUser; + + policy?: any; } export interface SystemPlaylist extends Asset { - urn: string; - query_urn: string; - permalink: string; - title: string; - description: string; - short_title: string; - short_description: string; - tracking_feature_name: string; - last_updated: DateString; - artwork_url: string; - calculated_artwork_url: string; - tracks: Track[]; + urn: string; + query_urn: string; + permalink: string; + title: string; + description: string; + short_title: string; + short_description: string; + tracking_feature_name: string; + last_updated: DateString; + artwork_url: string; + calculated_artwork_url: string; + tracks: Track[]; } export interface Quota { - unlimited_upload_quota: boolean; - upload_seconds_used: number; - upload_seconds_left: number; + unlimited_upload_quota: boolean; + upload_seconds_used: number; + upload_seconds_left: number; } export type Music = Playlist | Track; diff --git a/tsconfig.json b/tsconfig.json index a1e2c898..29324092 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,7 @@ { "compilerOptions": { - /* Basic Options */ - "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */, - "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, + "target": "es5", + "module": "commonjs", "lib": [ "dom", "es2015", diff --git a/types/redux-electron-store/index.d.ts b/types/redux-electron-store/index.d.ts index 650e0fd8..1013ebd7 100644 --- a/types/redux-electron-store/index.d.ts +++ b/types/redux-electron-store/index.d.ts @@ -1,11 +1,12 @@ declare module 'redux-electron-store' { - import { StoreEnhancer } from "redux"; + import { StoreEnhancer, AnyAction } from 'redux'; - namespace ReduxElectronStore { + namespace ReduxElectronStore { + function electronEnhancer(opts?: { + filter?: object | boolean; + dispatchProxy?: (action: AnyAction) => any; + }): StoreEnhancer; + } - function electronEnhancer(opts?: { filter: object }): StoreEnhancer; - - } - - export = ReduxElectronStore; -} \ No newline at end of file + export = ReduxElectronStore; +} diff --git a/yarn.lock b/yarn.lock index f49cdc57..8b2aa628 100644 --- a/yarn.lock +++ b/yarn.lock @@ -835,6 +835,13 @@ dependencies: regenerator-runtime "^0.13.2" +"@babel/runtime@^7.4.0": + version "7.8.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.7.tgz#8fefce9802db54881ba59f90bb28719b4996324d" + integrity sha512-+AATMUFppJDw6aiR5NVPHqIQBlV/Pj8wY/EZH+lmvRdUo9xBaz/rF3alAwFJQavvKfeOlPE7oaaDHVbcySbCsg== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.7.4", "@babel/template@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.3.tgz#e02ad04fe262a657809327f578056ca15fd4d1b8" @@ -1586,6 +1593,11 @@ jest-diff "^25.1.0" pretty-format "^25.1.0" +"@types/js-cookie@2.2.5": + version "2.2.5" + resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.2.5.tgz#38dfaacae8623b37cc0b0d27398e574e3fc28b1e" + integrity sha512-cpmwBRcHJmmZx0OGU7aPVwGWGbs4iKwVYchk9iuMtxNCA2zorwdaTz4GkLgs2WGxiRZRFKnV1k6tRUHX7tBMxg== + "@types/jsdom@^12.2.0": version "12.2.4" resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-12.2.4.tgz#845cd4d43f95b8406d9b724ec30c03edadcd9528" @@ -1758,7 +1770,7 @@ dependencies: "@types/react" "*" -"@types/react-redux@^7.1.6": +"@types/react-redux@^7.1.7": version "7.1.7" resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.7.tgz#12a0c529aba660696947384a059c5c6e08185c7a" integrity sha512-U+WrzeFfI83+evZE2dkZ/oF/1vjIYgqrb5dGgedkqVV8HEfDFujNgWCwHL89TDuWKb47U0nTBT6PLGq4IIogWg== @@ -1768,16 +1780,16 @@ hoist-non-react-statics "^3.3.0" redux "^4.0.0" -"@types/react-router-dom@^4.3.1": - version "4.3.5" - resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-4.3.5.tgz#72f229967690c890d00f96e6b85e9ee5780db31f" - integrity sha512-eFajSUASYbPHg2BDM1G8Btx+YqGgvROPIg6sBhl3O4kbDdYXdFdfrgQFf/pcBuQVObjfT9AL/dd15jilR5DIEA== +"@types/react-router-dom@^5.1.3": + version "5.1.3" + resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.1.3.tgz#b5d28e7850bd274d944c0fbbe5d57e6b30d71196" + integrity sha512-pCq7AkOvjE65jkGS5fQwQhvUp4+4PVD9g39gXLZViP2UqFiFzsEpB3PKf0O6mdbKsewSK8N14/eegisa/0CwnA== dependencies: "@types/history" "*" "@types/react" "*" "@types/react-router" "*" -"@types/react-router@*": +"@types/react-router@*", "@types/react-router@^5.1.4": version "5.1.4" resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.4.tgz#7d70bd905543cb6bcbdcc6bd98902332054f31a6" integrity sha512-PZtnBuyfL07sqCJvGg3z+0+kt6fobc/xmle08jBiezLS8FrmGeiGkJnuxL/8Zgy9L83ypUhniV5atZn/L8n9MQ== @@ -1785,14 +1797,6 @@ "@types/history" "*" "@types/react" "*" -"@types/react-router@^4.4.1": - version "4.4.5" - resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-4.4.5.tgz#1166997dc7eef2917b5ebce890ebecb32ee5c1b3" - integrity sha512-12+VOu1+xiC8RPc9yrgHCyLI79VswjtuqeS2gPrMcywH6tkc8rGIUhs4LaL3AJPqo5d+RPnfRpNKiJ7MK2Qhcg== - dependencies: - "@types/history" "*" - "@types/react" "*" - "@types/react-stickynode@^1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@types/react-stickynode/-/react-stickynode-1.4.0.tgz#7ba60ef8af1ab11a3371c68a4b028663996b7d50" @@ -2181,6 +2185,11 @@ "@webassemblyjs/wast-parser" "1.8.5" "@xtuc/long" "4.2.2" +"@xobotyi/scrollbar-width@1.9.4": + version "1.9.4" + resolved "https://registry.yarnpkg.com/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.4.tgz#a7dce20b7465bcad29cd6bbb557695e4ea7863cb" + integrity sha512-o12FCQt/X5n3pgKEWGpt0f/7Eg4mfv3uRwPUrctiOT8ZuxbH3cNLGWfH/8y6KxVJg4L2885ucuXQ6XECZzUiJA== + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" @@ -3418,6 +3427,11 @@ bootstrap@^4.1.3: resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.4.1.tgz#8582960eea0c5cd2bede84d8b0baf3789c3e8b01" integrity sha512-tbx5cHubwE6e2ZG7nqM3g/FZ5PQEDMWmMGNrCUBVRPHXTJaH7CBDdsLeu3eCh3B1tzAxTnAbtmrzvWEvT2NNEA== +bowser@^1.7.3: + version "1.9.4" + resolved "https://registry.yarnpkg.com/bowser/-/bowser-1.9.4.tgz#890c58a2813a9d3243704334fa81b96a5c150c9a" + integrity sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ== + boxen@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/boxen/-/boxen-4.2.0.tgz#e411b62357d6d6d36587c8ac3d5d974daa070e64" @@ -4531,7 +4545,7 @@ connect-history-api-fallback@^1.6.0: resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc" integrity sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg== -connected-react-router@^6.7.0: +connected-react-router@^6.5.2: version "6.7.0" resolved "https://registry.yarnpkg.com/connected-react-router/-/connected-react-router-6.7.0.tgz#1c37a65684f1729533264c1b1903ee91c8ca3a15" integrity sha512-RDmcmiwSfUWQ3U7J7RVkc9cwNtek26fUn0DWpA8pS7JylC97VNeosrsIxjJ/3CGDrzZPqnc0Hr/kZxjh75JGlw== @@ -4614,6 +4628,13 @@ copy-descriptor@^0.1.0: resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= +copy-to-clipboard@^3.2.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.1.tgz#115aa1a9998ffab6196f93076ad6da3b913662ae" + integrity sha512-i13qo6kIHTTpCm8/Wup+0b1mVWETvu2kIMzKoK8FpkLkFxlt0znUAHcMzox+T8sPlqtZXq3CulEjQHsYiGFJUw== + dependencies: + toggle-selection "^1.0.6" + copy-webpack-plugin@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-5.1.1.tgz#5481a03dea1123d88a988c6ff8b78247214f0b88" @@ -4842,6 +4863,14 @@ css-hot-loader@^1.3.9: lodash "^4.17.5" normalize-url "^1.9.1" +css-in-js-utils@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/css-in-js-utils/-/css-in-js-utils-2.0.1.tgz#3b472b398787291b47cfe3e44fecfdd9e914ba99" + integrity sha512-PJF0SpJT+WdbVVt0AOYp9C8GnuruRlL/UFW7932nLWmFLQTaWEzTBQEx7/hn4BuV+WON75iAViSUJLiU3PKbpA== + dependencies: + hyphenate-style-name "^1.0.2" + isobject "^3.0.1" + css-loader@^3.4.2: version "3.4.2" resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.4.2.tgz#d3fdb3358b43f233b78501c5ed7b1c6da6133202" @@ -4930,6 +4959,14 @@ css-tree@1.0.0-alpha.37: mdn-data "2.0.4" source-map "^0.6.1" +css-tree@^1.0.0-alpha.28: + version "1.0.0-alpha.39" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.39.tgz#2bff3ffe1bb3f776cf7eefd91ee5cba77a149eeb" + integrity sha512-7UvkEYgBAHRG9Nt980lYxjsTrCyHFN53ky3wVsDkiMdVqylqRt+Zc+jm5qw7/qyOvN2dHSYtX0e4MbCCExSvnA== + dependencies: + mdn-data "2.0.6" + source-map "^0.6.1" + css-value@~0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/css-value/-/css-value-0.0.1.tgz#5efd6c2eea5ea1fd6b6ac57ec0427b18452424ea" @@ -5057,7 +5094,7 @@ cssstyle@^2.0.0: dependencies: cssom "~0.3.6" -csstype@^2.2.0: +csstype@^2.2.0, csstype@^2.5.5: version "2.6.9" resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.9.tgz#05141d0cd557a56b8891394c1911c40c8a98d098" integrity sha512-xz39Sb4+OaTsULgUERcCk+TJj8ylkL4aSVDQiX/ksxbELSqwkgt4d4RD7fovIdgJGSuNYqwZEiVjYY5l0ask+Q== @@ -6182,6 +6219,13 @@ error-ex@^1.2.0, error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" +error-stack-parser@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/error-stack-parser/-/error-stack-parser-2.0.6.tgz#5a99a707bd7a4c58a797902d48d82803ede6aad8" + integrity sha512-d51brTeqC+BHlwF0BhPtcYgF5nlzf9ZZ0ZIUQNZpc9ZB9qw5IJ2diTrBY9jlCJkTLITYPjmiX6OWCwH+fuyNgQ== + dependencies: + stackframe "^1.1.1" + es-abstract@^1.13.0, es-abstract@^1.17.0, es-abstract@^1.17.0-next.1, es-abstract@^1.17.2: version "1.17.4" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.4.tgz#e3aedf19706b20e7c2594c35fc0d57605a79e184" @@ -6995,6 +7039,16 @@ fast-safe-stringify@^2.0.7: resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743" integrity sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA== +fast-shallow-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz#d4dcaf6472440dcefa6f88b98e3251e27f25628b" + integrity sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw== + +fastest-stable-stringify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/fastest-stable-stringify/-/fastest-stable-stringify-1.0.1.tgz#9122d406d4c9d98bea644a6b6853d5874b87b028" + integrity sha1-kSLUBtTJ2YvqZEpraFPVh0uHsCg= + fastparse@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.2.tgz#91728c5a5942eced8531283c79441ee4122c35a9" @@ -8121,7 +8175,7 @@ highlight.js@^9.3.0: resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.18.1.tgz#ed21aa001fe6252bb10a3d76d47573c6539fe13c" integrity sha512-OrVKYz70LHsnCgmbXctv/bfuvntIKDz177h0Co37DQ5jamGZLVmoCVMtjMtNZY3X9DrCcKfklHPNeA0uPZhSJg== -history@^4.10.1, history@^4.7.2: +history@^4.10.1, history@^4.9.0: version "4.10.1" resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== @@ -8147,12 +8201,7 @@ hoist-non-react-statics@^1.2.0: resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz#aa448cf0986d55cc40773b17174b7dd066cb7cfb" integrity sha1-qkSM8JhtVcxAdzsXF0t90GbLfPs= -hoist-non-react-statics@^2.5.0: - version "2.5.5" - resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" - integrity sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw== - -hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.3.0: +hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -8435,6 +8484,11 @@ husky@^1.1.3: run-node "^1.0.0" slash "^2.0.0" +hyphenate-style-name@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.3.tgz#097bb7fa0b8f1a9cf0bd5c734cf95899981a9b48" + integrity sha512-EcuixamT82oplpoJ2XU4pDtKGWQ7b00CD9f1ug9IaQ3p1bkHMiKCZ9ut9QDI6qsa6cpUuB+A/I+zLtdNK4n2DQ== + iconv-lite@0.4.24, iconv-lite@^0.4.17, iconv-lite@^0.4.24, iconv-lite@~0.4.13: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -8705,6 +8759,14 @@ inject-loader@^4.0.1: dependencies: babel-core "~6" +inline-style-prefixer@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/inline-style-prefixer/-/inline-style-prefixer-4.0.2.tgz#d390957d26f281255fe101da863158ac6eb60911" + integrity sha512-N8nVhwfYga9MiV9jWlwfdj1UDIaZlBFu4cJSJkIr7tZX7sHpHhGR5su1qdpW+7KPL8ISTvCIkcaFi/JdBknvPg== + dependencies: + bowser "^1.7.3" + css-in-js-utils "^2.0.0" + inquirer@^7.0.0: version "7.0.4" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.0.4.tgz#99af5bde47153abca23f5c7fc30db247f39da703" @@ -9175,6 +9237,13 @@ is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: dependencies: isobject "^3.0.1" +is-plain-object@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-3.0.0.tgz#47bfc5da1b5d50d64110806c199359482e75a928" + integrity sha512-tZIpofR+P05k8Aocp7UI/2UTa9lTJSebCXpFFoR9aibpokDj/uXBsJ8luUu0tTVYKkMU6URDUuOfJZ7koewXvg== + dependencies: + isobject "^4.0.0" + is-png@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-png/-/is-png-2.0.0.tgz#ee8cbc9e9b050425cedeeb4a6fb74a649b0a4a8d" @@ -9332,6 +9401,11 @@ isobject@^3.0.0, isobject@^3.0.1: resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= +isobject@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-4.0.0.tgz#3f1c9155e73b192022a80819bacd0343711697b0" + integrity sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA== + isomorphic-fetch@^2.1.1: version "2.2.1" resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" @@ -9783,6 +9857,11 @@ js-base64@^2.1.8: resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.5.2.tgz#313b6274dda718f714d00b3330bbae6e38e90209" integrity sha512-Vg8czh0Q7sFBSUMWWArX/miJeBWYBPpdU/3M/DKSaekLMqrqVPaedp+5mZhie/r0lgrcaYBfwXatEew6gwgiQQ== +js-cookie@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8" + integrity sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -10586,6 +10665,11 @@ mdn-data@2.0.4: resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.4.tgz#699b3c38ac6f1d728091a64650b65d388502fd5b" integrity sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA== +mdn-data@2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.6.tgz#852dc60fcaa5daa2e8cf6c9189c440ed3e042978" + integrity sha512-rQvjv71olwNHgiTbfPZFkJtjNMciWgswYeciZhtvWLO8bmX3TnhyA62I6sTWOyZssWHJJjY6/KiWwqQsWWsqOA== + media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -10773,6 +10857,15 @@ min-document@^2.19.0: dependencies: dom-walk "^0.1.0" +mini-create-react-context@^0.3.0: + version "0.3.2" + resolved "https://registry.yarnpkg.com/mini-create-react-context/-/mini-create-react-context-0.3.2.tgz#79fc598f283dd623da8e088b05db8cddab250189" + integrity sha512-2v+OeetEyliMt5VHMXsBhABoJ0/M4RCe7fatd/fBy6SMiKazUSEt3gxxypfnk2SHMkdBYvorHRoQxuGoiwbzAw== + dependencies: + "@babel/runtime" "^7.4.0" + gud "^1.0.0" + tiny-warning "^1.0.2" + mini-css-extract-plugin@^0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.9.0.tgz#47f2cf07aa165ab35733b1fc97d4c46c0564339e" @@ -11056,6 +11149,20 @@ nan@^2.12.1, nan@^2.13.2, nan@^2.9.2, nan@latest: resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== +nano-css@^5.2.1: + version "5.3.0" + resolved "https://registry.yarnpkg.com/nano-css/-/nano-css-5.3.0.tgz#9d3cd29788d48b6a07f52aa4aec7cf4da427b6b5" + integrity sha512-uM/9NGK9/E9/sTpbIZ/bQ9xOLOIHZwrrb/CRlbDHBU/GFS7Gshl24v/WJhwsVViWkpOXUmiZ66XO7fSB4Wd92Q== + dependencies: + css-tree "^1.0.0-alpha.28" + csstype "^2.5.5" + fastest-stable-stringify "^1.0.1" + inline-style-prefixer "^4.0.0" + rtl-css-js "^1.9.0" + sourcemap-codec "^1.4.1" + stacktrace-js "^2.0.0" + stylis "3.5.0" + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" @@ -11485,6 +11592,19 @@ object-keys@~0.4.0: resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-0.4.0.tgz#28a6aae7428dd2c3a92f3d95f21335dd204e0336" integrity sha1-KKaq50KN0sOpLz2V8hM13SBOAzY= +object-path-immutable@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/object-path-immutable/-/object-path-immutable-4.1.0.tgz#dc156c830c3774a8ba6b4a89ceb2639a2c9d068f" + integrity sha512-5TQ2fYe5Boyr8+9lf4EB5xK1Hb9YPwq9NNdoyiBi+QaSvBmaS7cZdf0Np9Xi3srYKP9IbPBVX76T6EgFo2LjjQ== + dependencies: + is-plain-object "^3.0.0" + object-path "^0.11.4" + +object-path@^0.11.4: + version "0.11.4" + resolved "https://registry.yarnpkg.com/object-path/-/object-path-0.11.4.tgz#370ae752fbf37de3ea70a861c23bba8915691949" + integrity sha1-NwrnUvvzfePqcKhhwju6iRVpGUk= + object-visit@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" @@ -13204,6 +13324,11 @@ react-is@^16.12.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.6, react-i resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q== +react-is@^16.6.0: + version "16.13.0" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.0.tgz#0f37c3613c34fe6b37cd7f763a0d6293ab15c527" + integrity sha512-GFMtL0vHkiBv9HluwNZTggSn/sCyEt9n02aM0dSAjGGyqyNlAyftYm4phPxdvCigG15JreC5biwxCgTAJZ7yAA== + react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" @@ -13266,30 +13391,34 @@ react-redux@^7.1.1: prop-types "^15.7.2" react-is "^16.9.0" -react-router-dom@^4.3.1: - version "4.3.1" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-4.3.1.tgz#4c2619fc24c4fa87c9fd18f4fb4a43fe63fbd5c6" - integrity sha512-c/MlywfxDdCp7EnB7YfPMOfMD3tOtIjrQlj/CKfNMBxdmpJP8xcz5P/UAFn3JbnQCNUxsHyVVqllF9LhgVyFCA== +react-router-dom@^5.0.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.1.2.tgz#06701b834352f44d37fbb6311f870f84c76b9c18" + integrity sha512-7BPHAaIwWpZS074UKaw1FjVdZBSVWEk8IuDXdB+OkLb8vd/WRQIpA4ag9WQk61aEfQs47wHyjWUoUGGZxpQXew== dependencies: - history "^4.7.2" - invariant "^2.2.4" + "@babel/runtime" "^7.1.2" + history "^4.9.0" loose-envify "^1.3.1" - prop-types "^15.6.1" - react-router "^4.3.1" - warning "^4.0.1" + prop-types "^15.6.2" + react-router "5.1.2" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" -react-router@^4.3.1: - version "4.3.1" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-4.3.1.tgz#aada4aef14c809cb2e686b05cee4742234506c4e" - integrity sha512-yrvL8AogDh2X42Dt9iknk4wF4V8bWREPirFfS9gLU1huk6qK41sg7Z/1S81jjTrGHxa3B8R3J6xIkDAA6CVarg== +react-router@5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.1.2.tgz#6ea51d789cb36a6be1ba5f7c0d48dd9e817d3418" + integrity sha512-yjEuMFy1ONK246B+rsa0cUam5OeAQ8pyclRDgpxuSCrAlJ1qN9uZ5IgyKC7gQg0w8OM50NXHEegPh/ks9YuR2A== dependencies: - history "^4.7.2" - hoist-non-react-statics "^2.5.0" - invariant "^2.2.4" + "@babel/runtime" "^7.1.2" + history "^4.9.0" + hoist-non-react-statics "^3.1.0" loose-envify "^1.3.1" + mini-create-react-context "^0.3.0" path-to-regexp "^1.7.0" - prop-types "^15.6.1" - warning "^4.0.1" + prop-types "^15.6.2" + react-is "^16.6.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" react-stickynode@^2.1.1: version "2.1.1" @@ -13332,6 +13461,25 @@ react-transition-group@^2.3.1, react-transition-group@^2.9.0: prop-types "^15.6.2" react-lifecycles-compat "^3.0.4" +react-use@^13.27.0: + version "13.27.0" + resolved "https://registry.yarnpkg.com/react-use/-/react-use-13.27.0.tgz#53a619dc9213e2cbe65d6262e8b0e76641ade4aa" + integrity sha512-2lyTyqJWyvnaP/woVtDcFS4B5pUYz0FQWI9pVHk/6TBWom2x3/ziJthkEn/LbCA9Twv39xSQU7Dn0zdIWfsNTQ== + dependencies: + "@types/js-cookie" "2.2.5" + "@xobotyi/scrollbar-width" "1.9.4" + copy-to-clipboard "^3.2.0" + fast-deep-equal "^3.1.1" + fast-shallow-equal "^1.0.0" + js-cookie "^2.2.1" + nano-css "^5.2.1" + resize-observer-polyfill "^1.5.1" + screenfull "^5.0.0" + set-harmonic-interval "^1.0.1" + throttle-debounce "^2.1.0" + ts-easing "^0.2.0" + tslib "^1.10.0" + react-virtualized-auto-sizer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz#a61dd4f756458bbf63bd895a92379f9b70f803bd" @@ -13496,6 +13644,11 @@ redent@^1.0.0: indent-string "^2.1.0" strip-indent "^1.0.1" +redux-devtools-extension@^2.13.8: + version "2.13.8" + resolved "https://registry.yarnpkg.com/redux-devtools-extension/-/redux-devtools-extension-2.13.8.tgz#37b982688626e5e4993ff87220c9bbb7cd2d96e1" + integrity sha512-8qlpooP2QqPtZHQZRhx3x3OP5skEV1py/zUdMY28WNAocbafxdG2tRD1MWE7sp8obGMNYuLWanhhQ7EQvT1FBg== + redux-logger@^3.0.6: version "3.0.6" resolved "https://registry.yarnpkg.com/redux-logger/-/redux-logger-3.0.6.tgz#f7555966f3098f3c88604c449cf0baf5778274bf" @@ -13511,6 +13664,11 @@ redux-modal@^4.0.0: hoist-non-react-statics "^3.0.0" prop-types "^15.5.10" +redux-observable@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redux-observable/-/redux-observable-1.2.0.tgz#ff51b6c6be2598e9b5e89fc36639186bb0e669c7" + integrity sha512-yeR90RP2WzZzCxxnQPlh2uFzyfFLsfXu8ROh53jGDPXVqj71uNDMmvi/YKQkd9ofiVoO4OYb1snbowO49tCEMg== + redux-promise-middleware@^6.1.1: version "6.1.2" resolved "https://registry.yarnpkg.com/redux-promise-middleware/-/redux-promise-middleware-6.1.2.tgz#1c14222686934be243cbb292e348ef7d5b20d6d2" @@ -13538,7 +13696,7 @@ redux@^3.4.0, redux@^3.6.0: loose-envify "^1.1.0" symbol-observable "^1.0.3" -redux@^4.0.0, redux@^4.0.5: +redux@^4.0.0, redux@^4.0.4: version "4.0.5" resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== @@ -13573,6 +13731,11 @@ regenerator-runtime@^0.13.2: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5" integrity sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw== +regenerator-runtime@^0.13.4: + version "0.13.5" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697" + integrity sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA== + regenerator-transform@^0.14.0: version "0.14.1" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.1.tgz#3b2fce4e1ab7732c08f665dfdb314749c7ddd2fb" @@ -14016,6 +14179,13 @@ rsvp@^4.8.4: resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" integrity sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA== +rtl-css-js@^1.9.0: + version "1.14.0" + resolved "https://registry.yarnpkg.com/rtl-css-js/-/rtl-css-js-1.14.0.tgz#daa4f192a92509e292a0519f4b255e6e3c076b7d" + integrity sha512-Dl5xDTeN3e7scU1cWX8c9b6/Nqz3u/HgR4gePc1kWXYiQWVQbKCEyK6+Hxve9LbcJ5EieHy1J9nJCN3grTtGwg== + dependencies: + "@babel/runtime" "^7.1.2" + run-async@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0" @@ -14052,7 +14222,7 @@ rx-lite@*, rx-lite@^4.0.8: resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444" integrity sha1-Cx4Rr4vESDbwSmQH6S2kJGe3lEQ= -rxjs@^6.5.2, rxjs@^6.5.3: +rxjs@^6.5.2, rxjs@^6.5.3, rxjs@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.4.tgz#e0777fe0d184cec7872df147f303572d414e211c" integrity sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q== @@ -14176,6 +14346,11 @@ schema-utils@^2.0.0, schema-utils@^2.0.1, schema-utils@^2.5.0, schema-utils@^2.6 ajv "^6.10.2" ajv-keywords "^3.4.1" +screenfull@^5.0.0: + version "5.0.2" + resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-5.0.2.tgz#b9acdcf1ec676a948674df5cd0ff66b902b0bed7" + integrity sha512-cCF2b+L/mnEiORLN5xSAz6H3t18i2oHh9BA8+CQlAh5DRw2+NFAGQJOSYbcGw8B2k04g/lVvFcfZ83b3ysH5UQ== + scss-tokenizer@^0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz#8eb06db9a9723333824d3f5530641149847ce5d1" @@ -14278,6 +14453,13 @@ serialize-error@^5.0.0: dependencies: type-fest "^0.8.0" +serialize-error@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/serialize-error/-/serialize-error-6.0.0.tgz#ccfb887a1dd1c48d6d52d7863b92544331fd752b" + integrity sha512-3vmBkMZLQO+BR4RPHcyRGdE09XCF6cvxzk2N2qn8Er3F91cy8Qt7VvEbZBOpaL53qsBbe2cFOefU6tRY6WDelA== + dependencies: + type-fest "^0.12.0" + serialize-javascript@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61" @@ -14311,6 +14493,11 @@ set-blocking@^2.0.0, set-blocking@~2.0.0: resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= +set-harmonic-interval@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/set-harmonic-interval/-/set-harmonic-interval-1.0.1.tgz#e1773705539cdfb80ce1c3d99e7f298bb3995249" + integrity sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g== + set-value@^2.0.0, set-value@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" @@ -14578,6 +14765,11 @@ source-map-url@^0.4.0: resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= +source-map@0.5.6: + version "0.5.6" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" + integrity sha1-dc449SvwczxafwwRjYEzSiu19BI= + source-map@^0.4.2, source-map@~0.4.1: version "0.4.4" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b" @@ -14600,6 +14792,11 @@ source-map@^0.7.3: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== +sourcemap-codec@^1.4.1: + version "1.4.8" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" + integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== + spawn-command@^0.0.2-1: version "0.0.2-1" resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2-1.tgz#62f5e9466981c1b796dc5929937e11c9c6921bd0" @@ -14765,11 +14962,40 @@ stable@^0.1.8: resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== +stack-generator@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/stack-generator/-/stack-generator-2.0.5.tgz#fb00e5b4ee97de603e0773ea78ce944d81596c36" + integrity sha512-/t1ebrbHkrLrDuNMdeAcsvynWgoH/i4o8EGGfX7dEYDoTXOYVAkEpFdtshlvabzc6JlJ8Kf9YdFEoz7JkzGN9Q== + dependencies: + stackframe "^1.1.1" + stack-utils@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.2.tgz#33eba3897788558bebfc2db059dc158ec36cebb8" integrity sha512-MTX+MeG5U994cazkjd/9KNAapsHnibjMLnfXodlkXw76JEea0UiNzrqidzo1emMwk7w5Qhc9jd4Bn9TBb1MFwA== +stackframe@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.1.1.tgz#ffef0a3318b1b60c3b58564989aca5660729ec71" + integrity sha512-0PlYhdKh6AfFxRyK/v+6/k+/mMfyiEBbTM5L94D0ZytQnJ166wuwoTYLHFWGbs2dpA8Rgq763KGWmN1EQEYHRQ== + +stacktrace-gps@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/stacktrace-gps/-/stacktrace-gps-3.0.4.tgz#7688dc2fc09ffb3a13165ebe0dbcaf41bcf0c69a" + integrity sha512-qIr8x41yZVSldqdqe6jciXEaSCKw1U8XTXpjDuy0ki/apyTn/r3w9hDAAQOhZdxvsC93H+WwwEu5cq5VemzYeg== + dependencies: + source-map "0.5.6" + stackframe "^1.1.1" + +stacktrace-js@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/stacktrace-js/-/stacktrace-js-2.0.2.tgz#4ca93ea9f494752d55709a081d400fdaebee897b" + integrity sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg== + dependencies: + error-stack-parser "^2.0.6" + stack-generator "^2.0.5" + stacktrace-gps "^3.0.4" + stat-mode@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/stat-mode/-/stat-mode-1.0.0.tgz#68b55cb61ea639ff57136f36b216a291800d1465" @@ -15070,6 +15296,11 @@ stylehacks@^4.0.0: postcss "^7.0.0" postcss-selector-parser "^3.0.0" +stylis@3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.0.tgz#016fa239663d77f868fef5b67cf201c4b7c701e1" + integrity sha512-pP7yXN6dwMzAR29Q0mBrabPCe0/mNO1MSr93bhay+hcZondvMMTpeGyd8nbhYJdyperNT2DRxONQuUGcJr5iPw== + stylis@^3.4.0: version "3.5.4" resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.4.tgz#f665f25f5e299cf3d64654ab949a57c768b73fbe" @@ -15356,6 +15587,11 @@ throat@^5.0.0: resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b" integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA== +throttle-debounce@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.1.0.tgz#257e648f0a56bd9e54fe0f132c4ab8611df4e1d5" + integrity sha512-AOvyNahXQuU7NN+VVvOOX+uW6FPaWdAOdRP5HfwYxAfCzXTFKRMoIMk+n+po318+ktcChx+F1Dd91G3YHeMKyg== + throttleit@0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-0.0.2.tgz#cfedf88e60c00dd9697b61fdd2a8343a9b680eaf" @@ -15430,7 +15666,7 @@ tiny-invariant@^1.0.2: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== -tiny-warning@^1.0.0: +tiny-warning@^1.0.0, tiny-warning@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== @@ -15546,6 +15782,11 @@ to-space-case@^1.0.0: dependencies: to-no-case "^1.0.0" +toggle-selection@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" + integrity sha1-bkWxJj8gF/oKzH2J14sVuL932jI= + toidentifier@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" @@ -15636,6 +15877,11 @@ tryer@^1.0.1: resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA== +ts-easing@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/ts-easing/-/ts-easing-0.2.0.tgz#c8a8a35025105566588d87dbda05dd7fbfa5a4ec" + integrity sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ== + ts-jest@^25.1.0: version "25.2.0" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-25.2.0.tgz#dfd87c2b71ef4867f5a0a44f40cb9c67e02991ac" @@ -15770,6 +16016,11 @@ type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5: resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== +type-fest@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.12.0.tgz#f57a27ab81c68d136a51fd71467eff94157fa1ee" + integrity sha512-53RyidyjvkGpnWPMF9bQgFtWp+Sl8O2Rp13VavmJgfAP9WWG6q6TkrKU8iyJdnwnfgHI6k2hTlgqH4aSdjoTbg== + type-fest@^0.5.1: version "0.5.2" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.5.2.tgz#d6ef42a0356c6cd45f49485c3b6281fc148e48a2" @@ -15820,15 +16071,15 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typesafe-actions@^4.4.2: - version "4.4.2" - resolved "https://registry.yarnpkg.com/typesafe-actions/-/typesafe-actions-4.4.2.tgz#8f817c479d12130b5ebb442032968b2a18929e1a" - integrity sha512-QW61P4cOX8dCNmrfpcUMjvU/MF/sFTC8/PlG9215W1gKDzZUBjRGdyYSO6ZcEUNsn491S2VpryJOHSIVSDqJrg== +typesafe-actions@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/typesafe-actions/-/typesafe-actions-5.1.0.tgz#9afe8b1e6a323af1fd59e6a57b11b7dd6623d2f1" + integrity sha512-bna6Yi1pRznoo6Bz1cE6btB/Yy8Xywytyfrzu/wc+NFW3ZF0I+2iCGImhBsoYYCOWuICtRO4yHcnDlzgo1AdNg== -typescript@^3.7.5: - version "3.7.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.5.tgz#0692e21f65fd4108b9330238aac11dd2e177a1ae" - integrity sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw== +typescript@^3.8.3: + version "3.8.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061" + integrity sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w== typings-for-css-modules-loader@^1.7.0: version "1.7.0" @@ -16327,7 +16578,7 @@ warning@^3.0.0: dependencies: loose-envify "^1.0.0" -warning@^4.0.1, warning@^4.0.2, warning@^4.0.3: +warning@^4.0.2, warning@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== From 63cc86f338bfae137480b4e30d61a4b9d9be4d1e Mon Sep 17 00:00:00 2001 From: Jonas Snellinckx Date: Sat, 30 May 2020 10:58:40 +0200 Subject: [PATCH 03/13] WIP2 --- .electron-react/dev-runner.js | 1 + .eslintrc.json | 1 + .vscode/settings.json | 17 +- internals/scripts/notarize.js | 2 +- package.json | 32 +- src/common/api/fetchComments.ts | 1 - src/common/api/fetchPlaylist.ts | 2 +- src/common/api/fetchRemainingTracks.ts | 5 +- src/common/api/fetchSearch.ts | 2 +- src/common/api/fetchTrack.ts | 3 +- src/common/api/helpers/axiosClient.ts | 1 - src/common/api/index.ts | 5 - src/common/schemas/index.ts | 30 + src/common/schemas/track.ts | 11 +- src/common/store/actions.ts | 2 - src/common/store/api.ts | 20 + src/common/store/app/actions.ts | 117 +- src/common/store/app/epics.ts | 5 +- src/common/store/app/reducer.ts | 6 +- src/common/store/appAuth/actions.ts | 2 +- src/common/store/appAuth/epics.ts | 33 +- src/common/store/appAuth/reducer.ts | 3 +- src/common/store/appAuth/selectors.ts | 2 +- src/common/store/auth/actions.ts | 259 +---- src/common/store/auth/api.ts | 90 ++ src/common/store/auth/epics.ts | 180 ++- src/common/store/auth/reducer.ts | 74 +- src/common/store/auth/selectors.ts | 37 +- src/common/store/auth/types.ts | 25 +- src/common/store/config/actions.ts | 4 +- src/common/store/config/selectors.ts | 12 +- src/common/store/config/types.ts | 4 +- .../store/{types.d.ts => declarations.d.ts} | 35 +- src/common/store/entities/reducer.ts | 6 +- src/common/store/entities/selectors.ts | 57 +- src/common/store/entities/types.ts | 7 +- src/common/store/index.ts | 8 +- src/common/store/objects/actions.ts | 377 ------ .../store/objects/playlists/search/actions.ts | 98 -- src/common/store/objects/reducer.ts | 256 ++++- src/common/store/objects/selectors.ts | 31 +- src/common/store/objects/types.ts | 25 +- src/common/store/player/actions.ts | 1019 +++++++---------- src/common/store/player/epics.ts | 669 ++++++++++- src/common/store/player/reducer.ts | 507 ++++---- src/common/store/player/selectors.ts | 65 +- src/common/store/player/types.ts | 46 +- src/common/store/playlist/actions.ts | 166 +-- src/common/store/playlist/api.ts | 15 - src/common/store/playlist/epics.ts | 405 +++++-- src/common/store/playlist/index.ts | 1 + src/common/store/playlist/types.ts | 7 +- src/common/store/rootEpic.ts | 15 +- src/common/store/rootReducer.ts | 40 +- src/common/store/selectors.ts | 9 + src/common/store/track/actions.ts | 208 +--- src/common/store/track/api.ts | 42 + src/common/store/track/epics.ts | 121 ++ src/common/store/track/index.ts | 1 + src/common/store/track/reducer.ts | 53 + src/common/store/track/selectors.ts | 8 + src/common/store/track/types.ts | 13 +- src/common/store/types.ts | 11 + src/common/store/ui/actions.ts | 5 +- src/common/store/ui/epics.ts | 20 +- src/common/store/ui/reducer.ts | 5 +- src/common/store/ui/selectors.ts | 6 +- src/common/store/user/actions.ts | 146 +-- src/common/store/user/api.ts | 63 + src/common/store/user/epics.ts | 76 ++ src/common/store/user/reducer.ts | 100 ++ src/common/store/user/selectors.ts | 12 + src/common/store/user/types.ts | 11 + src/common/utils/playerUtils.ts | 3 +- src/globals.d.ts | 32 +- src/main/app.ts | 17 +- src/main/features/core/applicationMenu.ts | 2 +- .../core/chromecast/chromecastManager.ts | 12 +- .../features/core/chromecast/deviceScanner.ts | 2 +- src/main/features/core/lastFm.ts | 2 +- src/main/features/core/notificationManager.ts | 2 +- src/main/features/feature.ts | 2 +- src/main/features/linux/dbusService.ts | 8 +- src/main/features/linux/mprisService.ts | 4 +- src/main/features/mac/mediaServiceManager.ts | 2 +- src/main/features/mac/touchBarManager.ts | 2 +- src/main/features/win32/thumbar.ts | 6 +- .../features/win32/win10/win10MediaService.ts | 2 +- src/main/index.ts | 2 +- src/main/utils/logger.ts | 2 +- src/renderer/App.tsx | 9 +- src/renderer/_shared/ActionsDropdown.tsx | 208 +--- .../_shared/CommentList/CommentList.tsx | 14 +- .../CommentListItem/CommentListitem.tsx | 2 +- src/renderer/_shared/ErrorBoundary.tsx | 4 +- src/renderer/_shared/InfiniteScroll.tsx | 46 + .../components/ToggleFollowButton.tsx | 33 + .../components/ToggleLikeButton.tsx | 15 +- .../components/TogglePlayButton.tsx | 38 +- .../components/ToggleRepostButton.tsx | 15 +- src/renderer/_shared/PlaylistTrackList.tsx | 45 + src/renderer/_shared/TrackList/TrackList.tsx | 40 +- .../TrackListItem/TrackListItem.scss | 16 +- .../TrackList/TrackListItem/TrackListItem.tsx | 24 +- .../_shared/TracksGrid/TrackGridRow.tsx | 98 +- .../TrackgridItem/TrackGridItem.scss | 81 +- .../TrackgridItem/TrackGridItem.tsx | 246 +--- .../TrackgridItem/TrackGridItemInfo.tsx | 37 + .../TrackgridUser/TrackGridUser.tsx | 15 +- .../_shared/TracksGrid/TracksGrid.tsx | 10 +- .../_shared/hooks/useInfiniteScroll.tsx | 36 - src/renderer/app/Layout.tsx | 6 +- src/renderer/app/Main.tsx | 4 +- src/renderer/app/components/Header/Header.tsx | 6 +- src/renderer/app/components/Queue/Queue.tsx | 204 ++-- .../app/components/Queue/QueueItem.tsx | 11 +- .../app/components/Sidebar/Sidebar.tsx | 3 +- .../Sidebar/playlist/SideBarPlaylistItem.tsx | 5 +- .../modals/AboutModal/AboutModal.tsx | 2 +- src/renderer/app/components/player/Player.tsx | 9 +- .../components/player/components/Audio.tsx | 39 +- src/renderer/css/app.scss | 22 +- src/renderer/pages/GenericPlaylist/index.tsx | 17 +- src/renderer/pages/artist/ArtistPage.tsx | 527 +++------ .../ArtistProfiles/ArtistProfiles.scss | 20 +- .../ArtistProfiles/ArtistProfiles.tsx | 100 +- src/renderer/pages/charts/ChartsPage.tsx | 1 - src/renderer/pages/foryou/ForYouPage.tsx | 4 +- src/renderer/pages/onboarding/OnBoarding.tsx | 13 +- src/renderer/pages/playlist/PlaylistPage.tsx | 42 +- src/renderer/pages/search/SearchPage.tsx | 9 +- src/renderer/pages/settings/Settings.tsx | 2 +- src/renderer/pages/tags/TagsPage.tsx | 4 +- src/renderer/pages/track/TrackPage.tsx | 454 +++----- .../pages/track/components/TrackOverview.tsx | 126 +- src/types/index.ts | 2 +- src/types/normalized.ts | 2 + src/types/soundcloud.ts | 35 +- yarn.lock | 633 ++++++---- 139 files changed, 4877 insertions(+), 4312 deletions(-) delete mode 100644 src/common/api/index.ts create mode 100644 src/common/store/api.ts rename src/common/store/{types.d.ts => declarations.d.ts} (58%) delete mode 100755 src/common/store/objects/actions.ts delete mode 100644 src/common/store/objects/playlists/search/actions.ts create mode 100644 src/common/store/playlist/index.ts create mode 100644 src/common/store/selectors.ts create mode 100644 src/common/store/track/api.ts create mode 100644 src/common/store/track/epics.ts create mode 100644 src/common/store/track/index.ts create mode 100644 src/common/store/track/reducer.ts create mode 100644 src/common/store/track/selectors.ts create mode 100644 src/common/store/types.ts create mode 100644 src/common/store/user/api.ts create mode 100644 src/common/store/user/epics.ts create mode 100644 src/common/store/user/reducer.ts create mode 100644 src/common/store/user/selectors.ts create mode 100644 src/renderer/_shared/InfiniteScroll.tsx create mode 100755 src/renderer/_shared/PageHeader/components/ToggleFollowButton.tsx create mode 100644 src/renderer/_shared/PlaylistTrackList.tsx create mode 100644 src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItemInfo.tsx delete mode 100644 src/renderer/_shared/hooks/useInfiniteScroll.tsx diff --git a/.electron-react/dev-runner.js b/.electron-react/dev-runner.js index 390785f0..b11dd760 100644 --- a/.electron-react/dev-runner.js +++ b/.electron-react/dev-runner.js @@ -180,6 +180,7 @@ function init() { startElectron(); }) .catch(err => { + console.error('Startup error'); console.error(err); }); } diff --git a/.eslintrc.json b/.eslintrc.json index a38adf16..c43d34d6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -19,6 +19,7 @@ "parserOptions": { "sourceType": "module", "ecmaVersion": 2019, + "project": "./tsconfig.json", "ecmaFeatures": { "jsx": true } diff --git a/.vscode/settings.json b/.vscode/settings.json index 5228f790..3287616b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,17 +5,22 @@ "node_modules": true }, "npm-intellisense.importQuotes": "\"", - "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact" + ], "emmet.showAbbreviationSuggestions": false, "editor.tabSize": 4, "editor.formatOnSave": true, - "editor.codeActionsOnSave": { - "source.fixAll.eslint": true - }, "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } -} + }, + "typescript.tsdk": "node_modules/typescript/lib", + "eslint.packageManager": "yarn", + "eslint.enable": true +} \ No newline at end of file diff --git a/internals/scripts/notarize.js b/internals/scripts/notarize.js index c861bf9d..c7163808 100644 --- a/internals/scripts/notarize.js +++ b/internals/scripts/notarize.js @@ -38,7 +38,7 @@ module.exports = async function(params) { ...auth }); } catch (error) { - console.error(error); + console.error('Notarize error', error); } console.log(`Done notarizing ${appId}`); diff --git a/package.json b/package.json index 73d3f1ab..4663d519 100755 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "devtron": "^1.4.0", "dotenv": "^8.1.0", "dotenv-webpack": "^1.7.0", - "electron": "^8.0.3", + "electron": "^8.3.0", "electron-builder": "^22.3.2", "electron-debug": "^3.0.1", "electron-devtools-installer": "^2.2.4", @@ -139,23 +139,23 @@ "enzyme-adapter-react-16": "^1.15.2", "enzyme-to-json": "^3.4.4", "escape-string-regexp": "^1.0.5", - "eslint": "^6.8.0", - "eslint-config-airbnb": "^18.0.1", - "eslint-config-airbnb-typescript": "^6.3.1", - "eslint-config-prettier": "^6.4.0", + "eslint": "^7.0.0", + "eslint-config-airbnb": "^18.1.0", + "eslint-config-airbnb-typescript": "^7.2.1", + "eslint-config-prettier": "^6.11.0", "eslint-config-react": "^1.1.7", - "eslint-config-standard": "^14.1.0", + "eslint-config-standard": "^14.1.1", "eslint-friendly-formatter": "^4.0.1", "eslint-import-resolver-typescript": "^2.0.0", - "eslint-loader": "^3.0.3", - "eslint-plugin-html": "^6.0.0", - "eslint-plugin-import": "^2.20.0", + "eslint-loader": "^4.0.2", + "eslint-plugin-html": "^6.0.2", + "eslint-plugin-import": "^2.20.2", "eslint-plugin-jsx-a11y": "^6.2.3", - "eslint-plugin-node": "^11.0.0", - "eslint-plugin-prettier": "^3.1.1", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-prettier": "^3.1.3", "eslint-plugin-promise": "^4.2.1", - "eslint-plugin-react": "^7.18.0", - "eslint-plugin-react-hooks": "^2.4.0", + "eslint-plugin-react": "^7.20.0", + "eslint-plugin-react-hooks": "^4.0.2", "eslint-plugin-standard": "^4.0.1", "file-loader": "^5.0.2", "fork-ts-checker-webpack-plugin": "^4.0.3", @@ -211,7 +211,7 @@ "@amilajack/castv2-client": "Superjo149/caster", "@blueprintjs/core": "^3.23.1", "@blueprintjs/icons": "^3.12.0", - "@sentry/electron": "1.2.0", + "@sentry/electron": "1.3.0", "autolinker": "^1.8.3", "aws-iot-device-sdk": "^2.2.1", "aws4": "^1.9.1", @@ -220,13 +220,13 @@ "boxicons": "^1.7.1", "classnames": "^2.2.5", "color-hash": "^1.0.3", - "connected-react-router": "^6.5.2", + "connected-react-router": "^6.8.0", "core-decorators": "^0.20.0", "electron-debug": "^3.0.1", "electron-dl": "^1.14.0", "electron-is": "^3.0.0", "electron-localshortcut": "^3.1.0", - "electron-redux": "file:../related/electron-redux", + "electron-redux": "^1.5.2", "electron-store": "^5.1.0", "electron-updater": "^4.2.0", "electron-window-state": "^5.0.3", diff --git a/src/common/api/fetchComments.ts b/src/common/api/fetchComments.ts index daabaf38..69da0a01 100755 --- a/src/common/api/fetchComments.ts +++ b/src/common/api/fetchComments.ts @@ -1,4 +1,3 @@ -/* eslint-disable camelcase */ import { Normalized, SoundCloud } from '@types'; import { normalize, schema } from 'normalizr'; import { commentSchema } from '../schemas'; diff --git a/src/common/api/fetchPlaylist.ts b/src/common/api/fetchPlaylist.ts index 0052cdd8..e57bfa92 100755 --- a/src/common/api/fetchPlaylist.ts +++ b/src/common/api/fetchPlaylist.ts @@ -4,7 +4,7 @@ import { normalize, schema } from 'normalizr'; import { SoundCloud, Normalized } from '@types'; import { playlistSchema, trackSchema, userSchema } from '../schemas'; // eslint-disable-next-line import/no-cycle -import { PlaylistTypes } from '../store/objects'; +import { PlaylistTypes } from '../store/types'; import fetchToJson from './helpers/fetchToJson'; interface CollectionItem { diff --git a/src/common/api/fetchRemainingTracks.ts b/src/common/api/fetchRemainingTracks.ts index c1971cbb..ef8fa7e2 100755 --- a/src/common/api/fetchRemainingTracks.ts +++ b/src/common/api/fetchRemainingTracks.ts @@ -1,7 +1,4 @@ -/* eslint-disable camelcase */ -// eslint-disable-next-line import/no-cycle -import { RemainingPlays } from '../store/app'; -// eslint-disable-next-line import/no-cycle +import { RemainingPlays } from '../store/types'; import { SC } from '../utils'; import fetchToJson from './helpers/fetchToJson'; diff --git a/src/common/api/fetchSearch.ts b/src/common/api/fetchSearch.ts index 6d33ec03..4f176e11 100755 --- a/src/common/api/fetchSearch.ts +++ b/src/common/api/fetchSearch.ts @@ -1,6 +1,6 @@ /* eslint-disable camelcase */ import { normalize, schema } from 'normalizr'; -import { Normalized, SoundCloud } from '../../types'; +import { Normalized, SoundCloud } from '@types'; import { playlistSchema, trackSchema, userSchema } from '../schemas'; import fetchToJson from './helpers/fetchToJson'; diff --git a/src/common/api/fetchTrack.ts b/src/common/api/fetchTrack.ts index d6e6b6d8..a64305b9 100755 --- a/src/common/api/fetchTrack.ts +++ b/src/common/api/fetchTrack.ts @@ -1,7 +1,6 @@ import { normalize } from 'normalizr'; -import { Normalized, SoundCloud } from '../../types'; +import { Normalized, SoundCloud } from '@types'; import { trackSchema } from '../schemas'; -// eslint-disable-next-line import/no-cycle import { SC } from '../utils'; import fetchToJson from './helpers/fetchToJson'; diff --git a/src/common/api/helpers/axiosClient.ts b/src/common/api/helpers/axiosClient.ts index bf93ed9c..62e44e89 100644 --- a/src/common/api/helpers/axiosClient.ts +++ b/src/common/api/helpers/axiosClient.ts @@ -19,7 +19,6 @@ const replaceTokenInRequest = (request: AxiosRequestConfig, token: string) => { } }; -console.log(is.dev()); export const axiosClient = axios.create({ // eslint-disable-next-line global-require adapter: is.dev() && require('axios/lib/adapters/http') diff --git a/src/common/api/index.ts b/src/common/api/index.ts deleted file mode 100644 index 102ca182..00000000 --- a/src/common/api/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { searchAll } from './search'; - -export const APIService = { - searchAll -}; diff --git a/src/common/schemas/index.ts b/src/common/schemas/index.ts index d483a992..8ddd6545 100755 --- a/src/common/schemas/index.ts +++ b/src/common/schemas/index.ts @@ -1,6 +1,36 @@ +import { Collection, EntitiesOf, Normalized } from '@types'; +import { normalize, schema } from 'normalizr'; import commentSchema from './comment'; import playlistSchema from './playlist'; import trackSchema from './track'; import userSchema from './user'; +export const genericSchema = new schema.Array( + { + comments: commentSchema, + playlists: playlistSchema, + tracks: trackSchema, + users: userSchema + }, + input => `${input.kind}s` +); + +export const normalizeArray = (data: T[]) => { + const normalized = normalize, Normalized.NormalizedResult[]>(data, genericSchema); + + return { + data, + normalized + }; +}; + +export const normalizeCollection = (json: Collection) => { + const normalized = normalize, Normalized.NormalizedResult[]>(json.collection, genericSchema); + + return { + json, + normalized + }; +}; + export { playlistSchema, userSchema, trackSchema, commentSchema }; diff --git a/src/common/schemas/track.ts b/src/common/schemas/track.ts index 4f473de9..ba6ae72c 100755 --- a/src/common/schemas/track.ts +++ b/src/common/schemas/track.ts @@ -7,10 +7,13 @@ const trackSchema = new schema.Entity( user: userSchema }, { - processStrategy: entity => ({ - ...entity, - likes_count: entity.likes_count || entity.favoritings_count - }) + processStrategy: entity => { + if (entity.likes_count || entity.favoritings_count) { + entity.likes_count = entity.likes_count || entity.favoritings_count; + } + + return entity; + } } ); diff --git a/src/common/store/actions.ts b/src/common/store/actions.ts index be0a86f7..b5c0572b 100644 --- a/src/common/store/actions.ts +++ b/src/common/store/actions.ts @@ -2,8 +2,6 @@ export * from './app/actions'; export * from './appAuth/actions'; export * from './auth/actions'; export * from './config/actions'; -export * from './objects/actions'; -// eslint-disable-next-line import/no-cycle export * from './player/actions'; export * from './playlist/actions'; export * from './track/actions'; diff --git a/src/common/store/api.ts b/src/common/store/api.ts new file mode 100644 index 00000000..d8cdc172 --- /dev/null +++ b/src/common/store/api.ts @@ -0,0 +1,20 @@ +import fetchToJsonNew from '@common/api/helpers/fetchToJsonNew'; +import { SC } from '@common/utils'; + +export * from './playlist/api'; +export * from './track/api'; +export * from './user/api'; + +export async function fetchFromUrl(url: string) { + const json = await fetchToJsonNew( + { + oauthToken: true, + useV2Endpoint: true + }, + { + url: SC.appendToken(url) + } + ); + + return json; +} diff --git a/src/common/store/app/actions.ts b/src/common/store/app/actions.ts index 79f7d502..7e045029 100644 --- a/src/common/store/app/actions.ts +++ b/src/common/store/app/actions.ts @@ -3,14 +3,9 @@ import { IPC } from '@common/utils/ipc'; import { wError, wSuccess } from '@common/utils/reduxUtils'; import { EpicFailure, SoundCloud, ThunkResult } from '@types'; import { goBack, replace } from 'connected-react-router'; -// eslint-disable-next-line import/no-extraneous-dependencies -import { ipcRenderer } from 'electron'; -import is from 'electron-is'; import { Dispatch } from 'redux'; import { action, createAction, createAsyncAction } from 'typesafe-actions'; -import { EVENTS } from '../../constants/events'; import { isSoundCloudUrl, SC } from '../../utils'; -import { toggleLike, toggleRepost } from '../track/actions'; import { AppActionTypes, CanGoHistory, @@ -18,13 +13,13 @@ import { ChromeCastDevice, DevicePlayerStatus, RemainingPlays -} from './types'; +} from '../types'; export const resetStore = createAction(AppActionTypes.RESET_STORE)(); export const initApp = createAction(AppActionTypes.INIT)(); export const getRemainingPlays = createAsyncAction( - AppActionTypes.GET_REMAINING_PLAYS, + String(AppActionTypes.GET_REMAINING_PLAYS), wSuccess(AppActionTypes.GET_REMAINING_PLAYS), wError(AppActionTypes.GET_REMAINING_PLAYS) )(); @@ -46,7 +41,7 @@ export const removeChromeCastDevice = (deviceId: string) => action(AppActionTypes.REMOVE_CHROMECAST_DEVICE, { deviceId }); -export const useChromeCast = (deviceId?: string) => action(AppActionTypes.SET_CHROMECAST_DEVICE, deviceId); +export const setChromecastDevice = (deviceId?: string) => action(AppActionTypes.SET_CHROMECAST_DEVICE, deviceId); export const setChromeCastPlayerStatus = (playerStatus: DevicePlayerStatus) => action(AppActionTypes.SET_CHROMECAST_PLAYER_STATUS, playerStatus); export const setChromecastAppState = (state: CastAppState | null) => @@ -62,61 +57,61 @@ export const setUpdateAvailable = (version: string) => version }); -let listeners: any[] = []; - -export function initWatchers(): ThunkResult { - // tslint:disable-next-line: max-func-body-length - return dispatch => { - if (!listeners.length) { - listeners.push({ - event: EVENTS.TRACK.LIKE, - handler: (_e: any, trackId: string) => { - if (trackId) { - dispatch(toggleLike(+trackId, false)); // TODO determine if track or playlist - } - } - }); - - listeners.push({ - event: EVENTS.TRACK.REPOST, - handler: (_e: string, trackId: string) => { - if (trackId) { - dispatch(toggleRepost(+trackId, false)); // TODO determine if track or playlist - } - } - }); - - listeners.push({ - event: EVENTS.APP.SEND_NOTIFICATION, - handler: (_e: string, contents: { title: string; message: string; image: string }) => { - const myNotification = new Notification(contents.title, { - body: contents.message, - icon: contents.image, - silent: true - }); - - myNotification.onclick = () => { - ipcRenderer.send(EVENTS.APP.RAISE); - }; - } - }); - - listeners.forEach(l => { - if (is.renderer()) { - ipcRenderer.on(l.event, l.handler); - } - }); - } - }; -} +const listeners: any[] = []; + +// export function initWatchers(): ThunkResult { +// // tslint:disable-next-line: max-func-body-length +// return dispatch => { +// if (!listeners.length) { +// listeners.push({ +// event: EVENTS.TRACK.LIKE, +// handler: (_e: any, trackId: string) => { +// if (trackId) { +// dispatch(toggleLike(+trackId, false)); // TODO determine if track or playlist +// } +// } +// }); + +// listeners.push({ +// event: EVENTS.TRACK.REPOST, +// handler: (_e: string, trackId: string) => { +// if (trackId) { +// dispatch(toggleRepost(+trackId, false)); // TODO determine if track or playlist +// } +// } +// }); + +// listeners.push({ +// event: EVENTS.APP.SEND_NOTIFICATION, +// handler: (_e: string, contents: { title: string; message: string; image: string }) => { +// const myNotification = new Notification(contents.title, { +// body: contents.message, +// icon: contents.image, +// silent: true +// }); + +// myNotification.onclick = () => { +// ipcRenderer.send(EVENTS.APP.RAISE); +// }; +// } +// }); + +// listeners.forEach(l => { +// if (is.renderer()) { +// ipcRenderer.on(l.event, l.handler); +// } +// }); +// } +// }; +// } -export function stopWatchers(): void { - listeners.forEach(l => { - ipcRenderer.removeListener(l.event, l.handler); - }); +// export function stopWatchers(): void { +// listeners.forEach(l => { +// ipcRenderer.removeListener(l.event, l.handler); +// }); - listeners = []; -} +// listeners = []; +// } // export function initApp(): ThunkResult { // return (dispatch, getState) => { diff --git a/src/common/store/app/epics.ts b/src/common/store/app/epics.ts index b248b432..7ef50b46 100644 --- a/src/common/store/app/epics.ts +++ b/src/common/store/app/epics.ts @@ -2,9 +2,8 @@ import { replace } from 'connected-react-router'; import { from, of } from 'rxjs'; import { catchError, filter, map, switchMap, withLatestFrom } from 'rxjs/operators'; import { isActionOf } from 'typesafe-actions'; -import { loginSuccess } from '../appAuth/actions'; -import { RootEpic } from '../types'; -import { getRemainingPlays, initApp } from './actions'; +import { RootEpic } from '../declarations'; +import { getRemainingPlays, initApp, loginSuccess } from '../actions'; import * as APIService from './api'; export const initAppEpic: RootEpic = (action$, state$) => diff --git a/src/common/store/app/reducer.ts b/src/common/store/app/reducer.ts index 8d180cd7..f3d3b970 100644 --- a/src/common/store/app/reducer.ts +++ b/src/common/store/app/reducer.ts @@ -1,8 +1,6 @@ -import { Reducer } from 'redux'; -import { onError, onSuccess } from '../../utils/reduxUtils'; -import { AppActionTypes, AppState, ChromeCastDevice } from './types'; import { createReducer } from 'typesafe-actions'; -import { resetStore, getRemainingPlays } from './actions'; +import { getRemainingPlays, resetStore } from '../actions'; +import { AppState } from './types'; const initialState: AppState = { history: { diff --git a/src/common/store/appAuth/actions.ts b/src/common/store/appAuth/actions.ts index d3a3b52e..fbb50fe8 100755 --- a/src/common/store/appAuth/actions.ts +++ b/src/common/store/appAuth/actions.ts @@ -1,6 +1,6 @@ import { TokenResponse } from '@main/aws/awsIotService'; import { createAction } from 'typesafe-actions'; -import { AppAuthActionTypes } from './types'; +import { AppAuthActionTypes } from '../types'; export const refreshToken = createAction(AppAuthActionTypes.REFRESH_TOKEN)(); export const finishOnboarding = createAction(AppAuthActionTypes.FINISH_ONBOARDING)(); diff --git a/src/common/store/appAuth/epics.ts b/src/common/store/appAuth/epics.ts index 8c2ff80f..44ab2837 100644 --- a/src/common/store/appAuth/epics.ts +++ b/src/common/store/appAuth/epics.ts @@ -1,28 +1,33 @@ +import { SC } from '@common/utils'; +import { TokenResponse } from '@main/aws/awsIotService'; import { replace } from 'connected-react-router'; import { of } from 'rxjs'; -import { filter, map, mergeMap, switchMap, withLatestFrom, tap } from 'rxjs/operators'; +import { filter, map, mergeMap, switchMap, tap, withLatestFrom, pluck } from 'rxjs/operators'; import { isActionOf } from 'typesafe-actions'; -import { resetStore, getRemainingPlays } from '../app/actions'; -import { setConfigKey } from '../config/actions'; -import { RootEpic } from '../types'; -import { loginSuccess, logout, refreshToken, finishOnboarding } from './actions'; -import { configSelector } from '../config/selectors'; -import { TokenResponse } from '@main/aws/awsIotService'; -import { SC } from '@common/utils'; +import { CONFIG } from '../../../config'; import { + finishOnboarding, getCurrentUser, getCurrentUserFollowingsIds, getCurrentUserLikeIds, + getCurrentUserPlaylists, getCurrentUserRepostIds, - getCurrentUserPlaylists -} from '../auth/actions'; -import { CONFIG } from 'src/config'; + getRemainingPlays, + loginSuccess, + logout, + refreshToken, + resetStore, + setConfigKey +} from '../actions'; +import { RootEpic } from '../declarations'; +import { configSelector } from '../selectors'; export const setTokenEpic: RootEpic = action$ => action$.pipe( filter(isActionOf([loginSuccess, refreshToken])), - map(action => action.payload), + pluck('payload'), filter((payload): payload is TokenResponse => !!payload?.access_token), + // TODO: should we also try to set this in the frontend? tap(payload => SC.initialize(payload.access_token)), filter((payload): payload is TokenResponse => !!payload.refresh_token), mergeMap(payload => @@ -47,7 +52,7 @@ export const loginEpic: RootEpic = (action$, state$) => return of( replace('/'), // Fetch user - getCurrentUser.request(), + getCurrentUser.request({}), // Fetch follow Ids getCurrentUserFollowingsIds.request(), // Fetch like Ids @@ -55,7 +60,7 @@ export const loginEpic: RootEpic = (action$, state$) => // Fetch repost Ids getCurrentUserRepostIds.request(), // Fetch playlists user owns - getCurrentUserPlaylists.request(), + getCurrentUserPlaylists.request({}), getRemainingPlays.request() ); } diff --git a/src/common/store/appAuth/reducer.ts b/src/common/store/appAuth/reducer.ts index e22c922a..c0734b6e 100755 --- a/src/common/store/appAuth/reducer.ts +++ b/src/common/store/appAuth/reducer.ts @@ -1,6 +1,5 @@ import { createReducer } from 'typesafe-actions'; -import { resetStore } from '../app/actions'; -import { login, loginError, loginSuccess, loginTerminated } from './actions'; +import { resetStore, login, loginError, loginSuccess, loginTerminated } from '../actions'; import { AppAuthState } from './types'; const initialState = { diff --git a/src/common/store/appAuth/selectors.ts b/src/common/store/appAuth/selectors.ts index 0d19648b..6edc4eda 100644 --- a/src/common/store/appAuth/selectors.ts +++ b/src/common/store/appAuth/selectors.ts @@ -1,3 +1,3 @@ -import { StoreState } from '../rootReducer'; +import { StoreState } from 'AppReduxTypes'; export const getAppAuth = (state: StoreState) => state.appAuth; diff --git a/src/common/store/auth/actions.ts b/src/common/store/auth/actions.ts index 2797c35f..4b632cf5 100755 --- a/src/common/store/auth/actions.ts +++ b/src/common/store/auth/actions.ts @@ -1,246 +1,65 @@ import { wError, wSuccess } from '@common/utils/reduxUtils'; -import { EntitiesOf, EpicFailure, ObjectMap, SoundCloud, ThunkResult } from '@types'; +import { EntitiesOf, EpicFailure, ObjectMap, SoundCloud } from '@types'; import { createAsyncAction } from 'typesafe-actions'; -import fetchPersonalised from '../../api/fetchPersonalised'; -import fetchToJson from '../../api/helpers/fetchToJson'; -import { SC } from '../../utils'; -import { ObjectTypes } from '../objects'; -import { setObject } from '../objects/actions'; +import { AuthActionTypes, AuthLikes, AuthPlaylists, AuthReposts, LikeType, RepostType } from '../types'; import { FetchedPlaylistItem } from './api'; -import { AuthActionTypes, AuthLikes, AuthPlaylists, AuthReposts } from './types'; -// AUTH DATA export const getCurrentUser = createAsyncAction( - AuthActionTypes.GET_USER, + String(AuthActionTypes.GET_USER), wSuccess(AuthActionTypes.GET_USER), wError(AuthActionTypes.GET_USER) -)(); +)(); export const getCurrentUserFollowingsIds = createAsyncAction( - AuthActionTypes.GET_USER_FOLLOWINGS_IDS, + String(AuthActionTypes.GET_USER_FOLLOWINGS_IDS), wSuccess(AuthActionTypes.GET_USER_FOLLOWINGS_IDS), wError(AuthActionTypes.GET_USER_FOLLOWINGS_IDS) )(); export const getCurrentUserLikeIds = createAsyncAction( - AuthActionTypes.GET_USER_LIKE_IDS, + String(AuthActionTypes.GET_USER_LIKE_IDS), wSuccess(AuthActionTypes.GET_USER_LIKE_IDS), wError(AuthActionTypes.GET_USER_LIKE_IDS) )(); export const getCurrentUserRepostIds = createAsyncAction( - AuthActionTypes.GET_USER_REPOST_IDS, + String(AuthActionTypes.GET_USER_REPOST_IDS), wSuccess(AuthActionTypes.GET_USER_REPOST_IDS), wError(AuthActionTypes.GET_USER_REPOST_IDS) )(); export const getCurrentUserPlaylists = createAsyncAction( - AuthActionTypes.GET_USER_PLAYLISTS, + String(AuthActionTypes.GET_USER_PLAYLISTS), wSuccess(AuthActionTypes.GET_USER_PLAYLISTS), wError(AuthActionTypes.GET_USER_PLAYLISTS) -) }, EpicFailure>(); - -// export function getAuth(): ThunkResult { -// return (dispatch, getState) => { -// const { -// config: { -// app: { analytics } -// } -// } = getState(); - -// dispatch( -// action( -// AuthActionTypes.SET, -// fetchToJson(SC.getMeUrl()).then(user => { -// if (process.env.NODE_ENV === 'production' && analytics) { -// // eslint-disable-next-line -// const { ua } = require('../../utils/universalAnalytics'); - -// ua.set('userId', user.id); -// } - -// return user; -// }) -// ) -// ); -// }; -// } - -// export function getAuthTracksIfNeeded(): ThunkResult { -// return (dispatch, getState) => { -// const state = getState(); - -// const currentUser = currentUserSelector(state); - -// if (!currentUser?.id) { -// return; -// } - -// const playlistObject = getPlaylistObjectSelector(PlaylistTypes.MYTRACKS)(state); - -// if (!playlistObject) { -// dispatch(getPlaylistO(SC.getUserTracksUrl(currentUser.id), PlaylistTypes.MYTRACKS)); -// } -// }; -// } - -// export function getAuthAllPlaylistsIfNeeded(): ThunkResult { -// return (dispatch, getState) => { -// const state = getState(); - -// const currentUser = currentUserSelector(state); - -// if (!currentUser?.id) { -// return; -// } - -// const playlistObject = getPlaylistObjectSelector(PlaylistTypes.PLAYLISTS)(state); - -// if (!playlistObject) { -// dispatch(getPlaylistO(SC.getAllUserPlaylistsUrl(currentUser.id), PlaylistTypes.PLAYLISTS)); -// } -// }; -// } - -// export function getAuthLikeIds(): ThunkResult> { -// return dispatch => { -// return Promise.all([ -// dispatch({ -// type: AuthActionTypes.SET_LIKES, -// payload: fetchToObject(SC.getLikeIdsUrl()) -// }), -// dispatch({ -// type: AuthActionTypes.SET_PLAYLIST_LIKES, -// payload: fetchToObject(SC.getPlaylistLikeIdsUrl()) -// }) -// ]); -// }; -// } - -// export function getAuthLikesIfNeeded(): ThunkResult { -// return (dispatch, getState) => { -// const playlistObject = getPlaylistObjectSelector(PlaylistTypes.LIKES)(getState()); - -// if (!playlistObject) { -// dispatch(getPlaylistO(SC.getLikesUrl(), PlaylistTypes.LIKES)); -// } -// }; -// } - -// export const getAuthFollowings = () => action(AuthActionTypes.SET_FOLLOWINGS, fetchToObject(SC.getFollowingsUrl())); - -/** - * Toggle following of a specific user - */ -export function toggleFollowing(userId: number): ThunkResult { - return (dispatch, getState) => { - const { - auth: { followings } - } = getState(); - - const following = SC.hasID(userId, followings); - - dispatch({ - type: AuthActionTypes.SET_FOLLOWING, - payload: fetchToJson(SC.updateFollowingUrl(userId), { - method: !following ? 'PUT' : 'DELETE' - }).then(() => ({ - userId, - following: !following - })) - }); - }; -} - -// export function getAuthReposts(): ThunkResult> { -// return dispatch => -// Promise.all([ -// dispatch({ -// type: AuthActionTypes.SET_REPOSTS, -// payload: fetchToObject(SC.getRepostIdsUrl()) -// }), -// dispatch({ -// type: AuthActionTypes.SET_PLAYLIST_REPOSTS, -// payload: fetchToObject(SC.getRepostIdsUrl(true)) -// }) -// ]); -// } - -// export function getAuthFeed(refresh?: boolean): ThunkResult> { -// return async (dispatch, getState) => { -// const { -// config: { hideReposts } -// } = getState(); - -// return dispatch>( -// getPlaylistO(SC.getFeedUrl(hideReposts ? 40 : 20), PlaylistTypes.STREAM, { refresh }) -// ); -// }; -// } - -/** - * Get playlists from the authenticated user - */ -// export function getAuthPlaylists(): ThunkResult { -// return dispatch => -// dispatch({ -// type: AuthActionTypes.SET_PLAYLISTS, -// payload: { -// promise: fetchPlaylists().then(({ normalized }) => { -// normalized.result.forEach(playlistResult => { -// if (normalized.entities.playlistEntities && normalized.entities.playlistEntities[playlistResult.id]) { -// const playlist = normalized.entities.playlistEntities[playlistResult.id]; - -// dispatch(setObject(playlistResult.id.toString(), ObjectTypes.PLAYLISTS, {}, playlist.tracks)); -// } -// }); - -// return normalized; -// }) -// } -// }); -// } - -// export function fetchPersonalizedPlaylistsIfNeeded(): ThunkResult { -// return async (dispatch, getState) => { -// const { -// auth: { personalizedPlaylists } -// } = getState(); - -// if (!personalizedPlaylists.items && !personalizedPlaylists.loading) { -// return dispatch>({ -// type: AuthActionTypes.SET_PERSONALIZED_PLAYLISTS, -// payload: { -// promise: fetchPersonalised(SC.getPersonalizedurl()).then(({ normalized }) => { -// normalized.result.forEach(playlistResult => { -// (playlistResult.items.collection || []).forEach(playlistId => { -// if (normalized.entities.playlistEntities && normalized.entities.playlistEntities[playlistId]) { -// const playlist = normalized.entities.playlistEntities[playlistId]; - -// dispatch( -// setObject( -// playlistId.toString(), -// ObjectTypes.PLAYLISTS, -// {}, -// playlist.tracks || [], -// undefined, -// undefined, -// 0 -// ) -// ); -// } -// }); -// }); - -// return { -// entities: normalized.entities, -// items: normalized.result -// }; -// }) -// } -// } as any); -// } - -// return Promise.resolve(); -// }; -// } +) }, EpicFailure>(); + +export const toggleLike = createAsyncAction( + String(AuthActionTypes.TOGGLE_LIKE), + wSuccess(AuthActionTypes.TOGGLE_LIKE), + wError(AuthActionTypes.TOGGLE_LIKE) +)< + { id: number | string; type: LikeType }, + { id: number | string; type: LikeType; liked: boolean }, + EpicFailure & { id: number | string; type: LikeType; liked: boolean } +>(); + +export const toggleRepost = createAsyncAction( + String(AuthActionTypes.TOGGLE_REPOST), + wSuccess(AuthActionTypes.TOGGLE_REPOST), + wError(AuthActionTypes.TOGGLE_REPOST) +)< + { id: number | string; type: RepostType }, + { id: number | string; type: RepostType; reposted: boolean }, + EpicFailure & { id: number | string; type: RepostType; reposted: boolean } +>(); + +export const toggleFollowing = createAsyncAction( + String(AuthActionTypes.TOGGLE_FOLLOWING), + wSuccess(AuthActionTypes.TOGGLE_FOLLOWING), + wError(AuthActionTypes.TOGGLE_FOLLOWING) +)< + { userId: number | string }, + { userId: number | string; follow: boolean }, + EpicFailure & { userId: number | string; follow: boolean } +>(); diff --git a/src/common/store/auth/api.ts b/src/common/store/auth/api.ts index a347a581..f88b105a 100644 --- a/src/common/store/auth/api.ts +++ b/src/common/store/auth/api.ts @@ -79,3 +79,93 @@ export async function fetchPlaylists() { json }; } + +// LIKES +export async function toggleTrackLike(options: { trackId: string | number; userId: string | number; like: boolean }) { + const json = await fetchToJsonNew>( + { + uri: `users/${options.userId}/track_likes/${options.trackId}`, + oauthToken: true, + useV2Endpoint: true + }, + { method: options.like ? 'PUT' : 'DELETE' } + ); + + return json; +} + +export async function togglePlaylistLike(options: { + playlistId: string | number; + userId: string | number; + like: boolean; +}) { + const json = await fetchToJsonNew>( + { + uri: `users/${options.userId}/playlist_likes/${options.playlistId}`, + oauthToken: true, + useV2Endpoint: true + }, + { method: options.like ? 'PUT' : 'DELETE' } + ); + + return json; +} + +export async function toggleSystemPlaylistLike(options: { + playlistUrn: string; + userId: string | number; + like: boolean; +}) { + const json = await fetchToJsonNew>( + { + uri: `users/${options.userId}/system_playlist_likes/${options.playlistUrn}`, + oauthToken: true, + useV2Endpoint: true + }, + { method: options.like ? 'PUT' : 'DELETE' } + ); + + return json; +} + +// REPOSTS + +export async function toggleTrackRepost(options: { trackId: string | number; repost: boolean }) { + const json = await fetchToJsonNew>( + { + uri: `me/track_reposts/${options.trackId}`, + oauthToken: true, + useV2Endpoint: true + }, + { method: options.repost ? 'PUT' : 'DELETE' } + ); + + return json; +} + +export async function togglePlaylistRepost(options: { playlistId: string | number; repost: boolean }) { + const json = await fetchToJsonNew>( + { + uri: `me/playlist_reposts/${options.playlistId}`, + oauthToken: true, + useV2Endpoint: true + }, + { method: options.repost ? 'PUT' : 'DELETE' } + ); + + return json; +} + +// Following +export async function toggleFollowing(options: { userId: string | number; follow: boolean }) { + const json = await fetchToJsonNew>( + { + uri: `me/followings/${options.userId}`, + oauthToken: true, + useV2Endpoint: true + }, + { method: options.follow ? 'POST' : 'DELETE' } + ); + + return json; +} diff --git a/src/common/store/auth/epics.ts b/src/common/store/auth/epics.ts index dc5764fb..ff724ecc 100644 --- a/src/common/store/auth/epics.ts +++ b/src/common/store/auth/epics.ts @@ -1,18 +1,36 @@ -import { ObjectMap, Normalized } from '@types'; -import { empty, forkJoin, from, of } from 'rxjs'; +import { EpicError } from '@common/utils/errors/EpicError'; +import { Normalized, ObjectMap } from '@types'; +import { StoreState } from 'AppReduxTypes'; +import { AxiosError } from 'axios'; +import { StateObservable } from 'redux-observable'; +import { empty, forkJoin, from, of, throwError } from 'rxjs'; import { catchError, filter, first, map, mergeMap, switchMap, withLatestFrom } from 'rxjs/operators'; import { EmptyAction, isActionOf } from 'typesafe-actions'; -import { RootEpic, RootState } from '../types'; import { getCurrentUser, getCurrentUserFollowingsIds, getCurrentUserLikeIds, + getCurrentUserPlaylists, getCurrentUserRepostIds, - getCurrentUserPlaylists -} from './actions'; + toggleLike, + toggleRepost +} from '../actions'; +import { RootEpic } from '../declarations'; +import { currentUserSelector, hasLiked, hasReposted } from '../selectors'; +import { LikeType, RepostType } from '../types'; import * as APIService from './api'; -import { currentUserSelector } from './selectors'; -import { StateObservable } from 'redux-observable'; +import { toggleFollowing } from './actions'; +import { isFollowing } from './selectors'; + +const handleEpicError = (error: any) => { + if ((error as AxiosError).isAxiosError) { + console.log(error.message, error.response.data); + } else { + console.error('Epic error - track', error); + } + // TODO Sentry? + return error; +}; export const getCurrentUserEpic: RootEpic = action$ => action$.pipe( @@ -115,11 +133,155 @@ export const getCurrentUserPlaylistsEpic: RootEpic = action$ => ) ); +// TODO: add toaster for success and erro +export const toggleLikeEpic: RootEpic = (action$, state$) => + action$.pipe( + filter(isActionOf(toggleLike.request)), + withLatestFrom(state$), + map(([{ payload }, state]) => { + const isLiked = hasLiked(payload.id, payload.type)(state); + const currentUser = currentUserSelector(state); + + return { + payload, + isLiked, + userId: currentUser?.id as number + }; + }), + switchMap(({ payload, isLiked, userId }) => { + const { id, type } = payload; + + let ob$; + + switch (type) { + case LikeType.Track: + ob$ = APIService.toggleTrackLike({ trackId: id, userId, like: !isLiked }); + break; + case LikeType.Playlist: + ob$ = APIService.togglePlaylistLike({ playlistId: id, userId, like: !isLiked }); + break; + case LikeType.SystemPlaylist: + ob$ = APIService.toggleSystemPlaylistLike({ playlistUrn: id.toString(), userId, like: !isLiked }); + break; + default: + ob$ = throwError(new EpicError(`${type}: Unknown type found`)); + } + + return from(ob$).pipe( + map(() => + toggleLike.success({ + id, + type, + liked: !isLiked + }) + ), + catchError(error => + of( + toggleLike.failure({ + error: handleEpicError(error), + id, + type, + liked: !isLiked + }) + ) + ) + ); + }) + ); + +// TODO: add toaster for success and error +export const toggleRepostEpic: RootEpic = (action$, state$) => + action$.pipe( + filter(isActionOf(toggleRepost.request)), + withLatestFrom(state$), + map(([{ payload }, state]) => { + const isReposted = hasReposted(payload.id, payload.type)(state); + + return { + payload, + isReposted + }; + }), + switchMap(({ payload, isReposted }) => { + const { id, type } = payload; + const repost = !isReposted; + + let ob$; + + switch (type) { + case RepostType.Track: + ob$ = APIService.toggleTrackRepost({ trackId: id, repost }); + break; + case RepostType.Playlist: + ob$ = APIService.togglePlaylistRepost({ playlistId: id, repost }); + break; + default: + ob$ = throwError(new EpicError(`${type}: Unknown type found`)); + } + + return from(ob$).pipe( + map(() => + toggleRepost.success({ + id, + type, + reposted: repost + }) + ), + catchError(error => + of( + toggleRepost.failure({ + error: handleEpicError(error), + id, + type, + reposted: repost + }) + ) + ) + ); + }) + ); + +export const toggleFollowingEpic: RootEpic = (action$, state$) => + action$.pipe( + filter(isActionOf(toggleFollowing.request)), + withLatestFrom(state$), + map(([{ payload }, state]) => { + const isFollowingUser = isFollowing(payload.userId)(state); + + return { + payload, + isFollowingUser + }; + }), + switchMap(({ payload, isFollowingUser }) => { + const { userId } = payload; + const follow = !isFollowingUser; + + return from(APIService.toggleFollowing({ userId, follow })).pipe( + map(() => + toggleFollowing.success({ + userId, + follow + }) + ), + catchError(error => + of( + toggleFollowing.failure({ + error: handleEpicError(error), + userId, + follow + }) + ) + ) + ); + }) + ); + // Helpers -const getCurrentUserFromState = (state$: StateObservable) => ([action, state]: [ +const getCurrentUserFromState = (state$: StateObservable) => ([action, state]: [ EmptyAction, - RootState + StoreState ]) => { const currentUser = currentUserSelector(state); diff --git a/src/common/store/auth/reducer.ts b/src/common/store/auth/reducer.ts index 75c3316e..dc5e13e3 100755 --- a/src/common/store/auth/reducer.ts +++ b/src/common/store/auth/reducer.ts @@ -1,14 +1,17 @@ import { createReducer } from 'typesafe-actions'; -import { resetStore } from '../app/actions'; import { getCurrentUser, getCurrentUserFollowingsIds, getCurrentUserLikeIds, getCurrentUserPlaylists, - getCurrentUserRepostIds -} from './actions'; + getCurrentUserRepostIds, + getForYouSelection, + resetStore, + toggleLike, + toggleRepost +} from '../actions'; import { AuthState } from './types'; -import { getForYouSelection } from '../playlist/actions'; +import { toggleFollowing } from './actions'; const initialState: AuthState = { me: { @@ -64,7 +67,7 @@ export const authReducer = createReducer(initialState) me: { ...state.me, isLoading: false, - error: action.payload + error: action.payload.error } }; }) @@ -75,6 +78,23 @@ export const authReducer = createReducer(initialState) followings: action.payload }; }) + .handleAction(toggleFollowing.success, (state, action) => { + const { userId, follow } = action.payload; + const { followings } = state; + + if (follow) { + followings[userId] = follow; + } else { + delete followings[userId]; + } + + return { + ...state, + followings: { + ...followings + } + }; + }) // TODO handle getCurrentUserLikeIds error & loading? .handleAction(getCurrentUserLikeIds.success, (state, action) => { return { @@ -82,6 +102,26 @@ export const authReducer = createReducer(initialState) likes: action.payload }; }) + .handleAction(toggleLike.success, (state, action) => { + const { id, type, liked } = action.payload; + const likes = state.likes[type]; + + if (liked) { + likes[id] = liked; + } else { + delete likes[id]; + } + + return { + ...state, + likes: { + ...state.likes, + [type]: { + ...likes + } + } + }; + }) // TODO handle getCurrentUserRepostIds error & loading? .handleAction(getCurrentUserRepostIds.success, (state, action) => { return { @@ -89,6 +129,26 @@ export const authReducer = createReducer(initialState) reposts: action.payload }; }) + .handleAction(toggleRepost.success, (state, action) => { + const { id, type, reposted } = action.payload; + const reposts = state.reposts[type]; + + if (reposted) { + reposts[id] = reposted; + } else { + delete reposts[id]; + } + + return { + ...state, + reposts: { + ...state.reposts, + [type]: { + ...reposts + } + } + }; + }) .handleAction(getCurrentUserPlaylists.request, state => { return { ...state, @@ -115,11 +175,11 @@ export const authReducer = createReducer(initialState) playlists: { ...state.playlists, isLoading: false, - error: action.payload + error: action.payload.error } }; }) - .handleAction(getForYouSelection.request, (state, action) => { + .handleAction(getForYouSelection.request, state => { return { ...state, personalizedPlaylists: { diff --git a/src/common/store/auth/selectors.ts b/src/common/store/auth/selectors.ts index 6e534509..01f86ad7 100644 --- a/src/common/store/auth/selectors.ts +++ b/src/common/store/auth/selectors.ts @@ -1,16 +1,11 @@ -import { ObjectMap } from '@types'; -import { RootState } from 'AppReduxTypes'; +import { StoreState } from 'AppReduxTypes'; import { createSelector } from 'reselect'; -import { AuthFollowing, AuthLikes, AuthState } from '.'; import { SC } from '../../utils'; -import { EntitiesState } from '../entities'; -import { getEntities } from '../entities/selectors'; -import { ObjectGroup, ObjectState } from '../objects'; +import { ObjectState } from '../types'; import { getPlaylistsObjects } from '../objects/selectors'; -import { StoreState } from '../rootReducer'; -import { AuthPlaylists, AuthReposts } from './types'; +import { getEntities } from '../entities/selectors'; -export const getAuth = (state: RootState) => state.auth; +export const getAuth = (state: StoreState) => state.auth; export const isCurrentUserLoading = createSelector(getAuth, auth => auth.me.isLoading); export const currentUserSelector = createSelector(getAuth, auth => auth.me.data); @@ -27,21 +22,19 @@ export const getAuthPlaylistsSelector = createSelector(getAuth, auth => auth.pla export type CombinedUserPlaylistState = { title: string; id: number } & ObjectState; -export const getUserPlaylistsCombined = createSelector< - StoreState, - AuthPlaylists, - ObjectGroup, - EntitiesState, - CombinedUserPlaylistState[] ->(getAuthPlaylistsSelector, getPlaylistsObjects, getEntities, (playlists, objects, entities) => - playlists.owned.map(p => ({ - ...objects[p.id], - id: p.id, - title: (entities.playlistEntities[p.id] || {}).title || '' - })) +export const getUserPlaylistsCombined = createSelector( + getAuthPlaylistsSelector, + getPlaylistsObjects, + getEntities, + (playlists, objects, entities) => + playlists.owned.map(p => ({ + ...objects[p.id], + id: p.id, + title: (entities.playlistEntities[p.id] || {}).title || '' + })) ); -export const isFollowing = (userId: number) => +export const isFollowing = (userId: number | string) => createSelector(getFollowings, followings => SC.hasID(userId, followings)); export const hasLiked = (trackId: number | string, type: 'playlist' | 'track' | 'systemPlaylist' = 'track') => createSelector(getAuthLikesSelector, likes => SC.hasID(trackId, likes[type])); diff --git a/src/common/store/auth/types.ts b/src/common/store/auth/types.ts index a83b4ffb..c5691c25 100644 --- a/src/common/store/auth/types.ts +++ b/src/common/store/auth/types.ts @@ -1,4 +1,5 @@ -import { Normalized, SoundCloud, ObjectMap } from '@types'; +import { Normalized, ObjectMap, SoundCloud } from '@types'; +import { AxiosError } from 'axios'; import { EpicError } from '@common/utils/errors/EpicError'; // TYPES @@ -6,7 +7,7 @@ import { EpicError } from '@common/utils/errors/EpicError'; export type AuthState = Readonly<{ me: { isLoading: boolean; - error?: any; + error?: EpicError | AxiosError | Error | null; data?: SoundCloud.User; }; followings: AuthFollowing; @@ -14,12 +15,12 @@ export type AuthState = Readonly<{ reposts: AuthReposts; playlists: { isLoading: boolean; - error?: any; + error?: EpicError | AxiosError | Error | null; data: AuthPlaylists; }; personalizedPlaylists: { loading: boolean; - error?: EpicError | null; + error?: EpicError | AxiosError | Error | null; items: Normalized.NormalizedPersonalizedItem[] | null; }; }>; @@ -43,6 +44,16 @@ export interface AuthReposts { playlist: ObjectMap; } +export enum LikeType { + Playlist = 'playlist', + Track = 'track', + SystemPlaylist = 'systemPlaylist' +} +export enum RepostType { + Playlist = 'playlist', + Track = 'track' +} + // ACTIONS export enum AuthActionTypes { @@ -52,6 +63,12 @@ export enum AuthActionTypes { GET_USER_REPOST_IDS = '@@auth/GET_USER_REPOST_IDS', GET_USER_PLAYLISTS = '@@auth/GET_USER_PLAYLISTS', + TOGGLE_LIKE = '@@track/TOGGLE_LIKE', + TOGGLE_REPOST = '@@track/TOGGLE_REPOST', + TOGGLE_FOLLOWING = '@@track/TOGGLE_FOLLOWING', + + // OLD? + SET = '@@auth/SET', SET_PLAYLISTS = '@@auth/SET_PLAYLISTS', SET_PERSONALIZED_PLAYLISTS = '@@auth/SET_PERSONALIZED_PLAYLISTS', diff --git a/src/common/store/config/actions.ts b/src/common/store/config/actions.ts index 30decde6..0d72f04a 100644 --- a/src/common/store/config/actions.ts +++ b/src/common/store/config/actions.ts @@ -1,10 +1,8 @@ import { createAction } from 'typesafe-actions'; -import { ConfigActionTypes, ConfigState } from './types'; +import { ConfigActionTypes, ConfigState, ConfigValue } from '../types'; export const setConfig = createAction(ConfigActionTypes.SET_CONFIG)(); export const setConfigKey = createAction(ConfigActionTypes.SET_CONFIG_KEY, (key: string, value: ConfigValue) => ({ key, value }))(); - -export type ConfigValue = string | number | boolean | object | null | (string | number | object)[]; diff --git a/src/common/store/config/selectors.ts b/src/common/store/config/selectors.ts index 40f7b9d3..29da793f 100644 --- a/src/common/store/config/selectors.ts +++ b/src/common/store/config/selectors.ts @@ -1,11 +1,7 @@ -// eslint-disable-next-line import/no-unresolved -import { RootState } from 'AppReduxTypes'; +import { StoreState } from 'AppReduxTypes'; import { createSelector } from 'reselect'; -import { ConfigState } from './types'; -export const configSelector = (state: RootState) => state.config; +export const configSelector = (state: StoreState) => state.config; -export const authTokenStateSelector = createSelector( - [configSelector], - config => config.auth -); +export const authTokenStateSelector = createSelector([configSelector], config => config.auth); +export const shuffleSelector = createSelector([configSelector], config => config.shuffle); diff --git a/src/common/store/config/types.ts b/src/common/store/config/types.ts index ae39f177..c0cf039f 100644 --- a/src/common/store/config/types.ts +++ b/src/common/store/config/types.ts @@ -1,4 +1,4 @@ -import { RepeatTypes } from '../player/types'; +import { RepeatTypes } from '../types'; // TYPES export interface Config extends Object { @@ -49,6 +49,8 @@ export interface ProxyConfig { password?: string; } +export type ConfigValue = string | number | boolean | object | null | (string | number | object)[]; + // ACTIONS export enum ConfigActionTypes { SET_CONFIG = '@@config/SET_ALL', diff --git a/src/common/store/types.d.ts b/src/common/store/declarations.d.ts similarity index 58% rename from src/common/store/types.d.ts rename to src/common/store/declarations.d.ts index f730234c..6a849fc0 100644 --- a/src/common/store/types.d.ts +++ b/src/common/store/declarations.d.ts @@ -1,27 +1,17 @@ -import { routerActions, CallHistoryMethodAction, RouterState } from 'connected-react-router'; +import { RootState, StoreState } from 'AppReduxTypes'; +import { CallHistoryMethodAction, routerActions } from 'connected-react-router'; +import { LocationState } from 'history'; import { Epic } from 'redux-observable'; import { ActionType, StateType } from 'typesafe-actions'; import * as app from './app/actions'; import * as appAuth from './appAuth/actions'; import * as auth from './auth/actions'; import * as config from './config/actions'; -import * as objects from './objects/actions'; -import * as search from './objects/playlists/search/actions'; import * as player from './player/actions'; import * as playlist from './playlist/actions'; import * as track from './track/actions'; import * as ui from './ui/actions'; import * as user from './user/actions'; -import { LocationState } from 'history'; -import { AppAuthState } from './appAuth'; -import { AuthState } from './auth'; -import { EntitiesState } from './entities'; -import { PlayerState } from './player'; -import { ObjectsState } from './objects'; -import { AppState } from './app'; -import { ConfigState } from './config'; -import { UIState } from './ui'; -import { RootState } from 'AppReduxTypes'; // Hack to fix https://github.com/supasate/connected-react-router/issues/286 type Push = (path: Path, state?: LocationState) => CallHistoryMethodAction<[Path, LocationState?]>; @@ -33,22 +23,7 @@ interface RouterActions { // go: Go; etc. } -export interface StoreState { - appAuth: AppAuthState; - auth: AuthState; - entities: EntitiesState; - player: PlayerState; - objects: ObjectsState; - app: AppState; - config: ConfigState; - ui: UIState; - router: RouterState; - modal: any; -} - type actions = { - search: typeof search; - objects: typeof objects; player: typeof player; track: typeof track; user: typeof user; @@ -67,7 +42,7 @@ type _RootAction = ActionType | ActionType; export type Store = _Store; export type RootAction = _RootAction; -export type RootEpic = Epic; +export type RootEpic = Epic; declare module 'typesafe-actions' { interface Types { @@ -76,5 +51,5 @@ declare module 'typesafe-actions' { } declare module 'react-redux' { - export interface DefaultRootState extends RootState {} + export interface DefaultRootState extends StoreState {} } diff --git a/src/common/store/entities/reducer.ts b/src/common/store/entities/reducer.ts index 38ca380b..c24d0bb9 100755 --- a/src/common/store/entities/reducer.ts +++ b/src/common/store/entities/reducer.ts @@ -1,13 +1,13 @@ import { merge } from 'lodash'; import { Reducer } from 'redux'; -import { AppActionTypes } from '../app'; -import { EntitiesState } from './types'; +import { EntitiesState, AppActionTypes } from '../types'; const initialState: EntitiesState = { playlistEntities: {}, trackEntities: {}, userEntities: {}, - commentEntities: {} + commentEntities: {}, + userProfileEntities: {} }; export const entitiesReducer: Reducer = (state = initialState, action) => { diff --git a/src/common/store/entities/selectors.ts b/src/common/store/entities/selectors.ts index 5b930bf6..90050ee0 100644 --- a/src/common/store/entities/selectors.ts +++ b/src/common/store/entities/selectors.ts @@ -1,9 +1,9 @@ -import { denormalize, schema } from 'normalizr'; -import { createSelector } from 'reselect'; -import { EntitiesState } from '.'; -import { StoreState } from '../rootReducer'; import { Normalized, SoundCloud } from '@types'; -import { commentSchema, playlistSchema, trackSchema, userSchema } from '../../schemas'; +import { StoreState } from 'AppReduxTypes'; +import { denormalize } from 'normalizr'; +import { createSelector } from 'reselect'; +import { EntitiesState } from '../types'; +import { genericSchema } from '@common/schemas'; export const getEntities = (state: StoreState) => state.entities; @@ -12,11 +12,8 @@ export const getPlaylistEntities = () => getEntities, entities => entities.playlistEntities ); -export const getUserEntities = () => - createSelector, EntitiesState['userEntities']>( - getEntities, - entities => entities.userEntities - ); +export const getUserEntities = () => createSelector(getEntities, entities => entities.userEntities); +export const getUserProfilesEntities = () => createSelector(getEntities, entities => entities.userProfileEntities); export const getCommentEntities = () => createSelector, EntitiesState['commentEntities']>( @@ -30,19 +27,9 @@ export const getTrackEntities = () => entities => entities.trackEntities ); -export const normalizeSchema = new schema.Array( - { - tracks: trackSchema, - playlists: playlistSchema, - users: userSchema, - comments: commentSchema - }, - input => `${input.kind}s` -); - export const getDenormalizedEntities = (result: Normalized.NormalizedResult[]) => createSelector, T[]>(getEntities, entities => - denormalize(result, normalizeSchema, entities) + denormalize(result, genericSchema, entities) ); export const getDenormalizedEntity = (result: Normalized.NormalizedResult) => @@ -63,14 +50,36 @@ export const getNormalizedPlaylist = (id: string | number) => entities => entities[id] ); -export const getNormalizedUser = (id?: number) => +export const getNormalizedUser = (id?: number | string) => createSelector( getUserEntities(), entities => (id ? entities[id] : null) ); -export const getNormalizedTrack = (id?: number) => +export const getNormalizedUserForPage = (id?: number | string) => + createSelector(getNormalizedUser(id), user => { + if (user?.followers_count !== undefined && user?.followings_count !== undefined) { + return user; + } + + return null; + }); + +export const getNormalizedTrack = (id?: number | string) => createSelector( getTrackEntities(), - entities => (id ? entities[id] : null) + entities => { + if (id) { + const track = entities[id]; + + if (track.media) { + return track; + } + } + + return null; + } ); + +export const getNormalizedUserProfiles = (userUrn?: string) => + createSelector(getUserProfilesEntities(), entities => (userUrn ? entities?.[userUrn] : null)); diff --git a/src/common/store/entities/types.ts b/src/common/store/entities/types.ts index 6bb1c97c..fe3fcf5d 100644 --- a/src/common/store/entities/types.ts +++ b/src/common/store/entities/types.ts @@ -1,6 +1,6 @@ import { Normalized, SoundCloud } from '@types'; -export type EntitiesState = Readonly<{ +export type EntitiesState = { playlistEntities: { [playlistId: number]: Normalized.Playlist; }; @@ -13,4 +13,7 @@ export type EntitiesState = Readonly<{ commentEntities: { [commentId: number]: SoundCloud.Comment; }; -}>; + userProfileEntities: { + [userUrn: string]: SoundCloud.UserProfiles; + }; +}; diff --git a/src/common/store/index.ts b/src/common/store/index.ts index 4b21ee05..178cd072 100755 --- a/src/common/store/index.ts +++ b/src/common/store/index.ts @@ -2,7 +2,7 @@ import { resetStore } from '@common/store/actions'; import { PlayerActionTypes } from '@common/store/player'; import { rootReducer } from '@common/store/rootReducer'; import { Logger } from '@main/utils/logger'; -import { RootState } from 'AppReduxTypes'; +import { StoreState } from 'AppReduxTypes'; import { routerMiddleware } from 'connected-react-router'; import is from 'electron-is'; import { @@ -19,17 +19,17 @@ import { composeWithDevTools } from 'redux-devtools-extension'; // eslint-disable-next-line import/no-extraneous-dependencies import { createLogger } from 'redux-logger'; import { createEpicMiddleware } from 'redux-observable'; +import thunk from 'redux-thunk'; import { BehaviorSubject } from 'rxjs'; +import { RootAction } from './declarations'; import { rootEpic } from './rootEpic'; -import { RootAction } from './types'; -import thunk from 'redux-thunk'; const epic$ = new BehaviorSubject(rootEpic); export const history = createMemoryHistory(); history.listen(loc => console.log(process.type, loc)); -const epicMiddleware = createEpicMiddleware(); +const epicMiddleware = createEpicMiddleware(); const connectRouterMiddleware = routerMiddleware(history); /** configure dev middlewares */ diff --git a/src/common/store/objects/actions.ts b/src/common/store/objects/actions.ts deleted file mode 100755 index 023438b2..00000000 --- a/src/common/store/objects/actions.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { GetPlaylistOptions, Normalized, SoundCloud, ThunkResult } from '@types'; -import { normalize, schema } from 'normalizr'; -import { Track } from 'src/types/soundcloud'; -import { action } from 'typesafe-actions'; -import fetchComments from '../../api/fetchComments'; -import fetchPlaylist from '../../api/fetchPlaylist'; -import fetchToJson from '../../api/helpers/fetchToJson'; -import { trackSchema } from '../../schemas'; -import * as SC from '../../utils/soundcloudUtils'; -// eslint-disable-next-line import/no-cycle -import { processQueueItems } from '../player/actions'; -import { PlayerActionTypes, ProcessedQueueItems } from '../player/types'; -import { getPlaylistObjectSelector } from './selectors'; -import { ObjectsActionTypes, ObjectState, ObjectTypes } from './types'; - -const canFetch = (current: ObjectState): boolean => !current || (!!current && !current.isFetching); -const canFetchMore = (current: ObjectState): boolean => canFetch(current) && current && current.nextUrl !== null; - -// TODO refactor, too hacky. Maybe redux-observables? -// tslint:disable-next-line:max-line-length -export function getPlaylistO( - url: string, - objectId: string, - options: GetPlaylistOptions = { refresh: false, appendId: null } -): ThunkResult> { - return async (dispatch, getState) => { - const { - config: { hideReposts } - } = getState(); - - const { value } = await dispatch>({ - type: ObjectsActionTypes.SET, - payload: { - promise: fetchPlaylist(url, objectId, hideReposts).then(({ normalized, json }) => ({ - objectId, - objectType: ObjectTypes.PLAYLISTS, - entities: normalized.entities, - result: options.appendId - ? [{ id: options.appendId, schema: 'tracks' }, ...normalized.result] - : normalized.result, - nextUrl: json.next_href ? SC.appendToken(json.next_href) : null, - futureUrl: json.future_href ? SC.appendToken(json.future_href) : null, - refresh: options.refresh - })), - data: { - objectId, - objectType: ObjectTypes.PLAYLISTS - } - } - } as any); - - const { - player: { currentPlaylistId, queue } - } = getState(); - - if (objectId === currentPlaylistId && value.result.length) { - if (value && value.result) { - const { result } = value; - - if (result.length) { - const [items, originalItems] = await dispatch>(processQueueItems(result)); - - dispatch({ - type: PlayerActionTypes.QUEUE_INSERT, - payload: { - items, - originalItems, - index: queue.length - } - }); - } - } - } - }; -} - -function getCommentsByUrl(url: string, objectId: string): ThunkResult> { - return async (dispatch, getState) => { - const { objects } = getState(); - - const objectType = ObjectTypes.COMMENTS; - const comments = objects[objectType]; - - if (!canFetch(comments[objectId])) { - return Promise.resolve(); - } - - return dispatch>({ - type: ObjectsActionTypes.SET, - payload: { - promise: fetchComments(url).then(({ normalized, json }) => ({ - objectId, - objectType, - entities: normalized.entities, - result: normalized.result, - nextUrl: json.next_href ? SC.appendToken(json.next_href) : null, - futureUrl: json.future_href ? SC.appendToken(json.future_href) : null - })), - data: { - objectId, - objectType - } - } - } as any); - }; -} - -export function getComments(trackId: number) { - return getCommentsByUrl(SC.getCommentsUrl(trackId), trackId.toString()); -} - -export const setObject = ( - objectId: string, - objectType: ObjectTypes, - entities: Normalized.NormalizedEntities, - result: Normalized.NormalizedResult[], - nextUrl?: string, - futureUrl?: string, - fetchedItems = 0 -) => { - return action(ObjectsActionTypes.SET, { - objectId, - objectType, - entities, - result, - nextUrl: nextUrl ? SC.appendToken(nextUrl) : null, - futureUrl: futureUrl ? SC.appendToken(futureUrl) : null, - fetchedItems - }); -}; - -export function fetchPlaylistIfNeeded(playlistId: number): ThunkResult> { - return async (dispatch, getState) => { - const playlistObject = getPlaylistObjectSelector(playlistId.toString())(getState()); - - if (!playlistObject || (playlistObject && playlistObject.fetchedItems === 0)) { - await dispatch>({ - type: ObjectsActionTypes.SET, - payload: { - promise: fetchPlaylist(SC.getPlaylistTracksUrl(playlistId), playlistId.toString()).then( - ({ normalized, json }) => { - if (normalized.entities && normalized.entities.playlistEntities) { - const playlist = normalized.entities.playlistEntities[playlistId]; - - let fetchedItems: Partial[] = []; - - if (json.tracks) { - fetchedItems = json.tracks.filter((t: Partial) => t.user !== undefined); - } - - const fetchedItemsIds = fetchedItems.map(item => item.id); - - // eslint-disable-next-line no-case-declarations - const result = playlist.tracks?.filter(t => fetchedItemsIds.indexOf(t.id) !== -1); - - return { - objectId: playlistId, - objectType: ObjectTypes.PLAYLISTS, - entities: normalized.entities, - result, - nextUrl: json.next_href ? SC.appendToken(json.next_href) : null, - futureUrl: json.future_href ? SC.appendToken(json.future_href) : null, - fetchedItems: fetchedItems.length - }; - } - - return {}; - } - ), - data: { - objectId: playlistId, - objectType: ObjectTypes.PLAYLISTS - } - } - } as any); - - // eslint-disable-next-line @typescript-eslint/no-use-before-define - await dispatch>(fetchPlaylistTracks(playlistId)); - } - }; -} - -export function fetchPlaylistTracks( - playlistId: number, - size = 20, - ids: Normalized.NormalizedResult[] = [] -): ThunkResult> { - return async (dispatch, getState) => { - const playlistObject = getPlaylistObjectSelector(playlistId.toString())(getState()); - - if (!playlistObject) { - await dispatch>(fetchPlaylistIfNeeded(playlistId)); - - return Promise.resolve(); - } - - let fetchIds = ids; - - if ( - (playlistObject.fetchedItems === playlistObject.items.length || playlistObject.isFetching) && - !fetchIds.length - ) { - return Promise.resolve(); - } - - if (!fetchIds.length) { - const fetched = playlistObject.fetchedItems || 0; - - let newCount = fetched + size; - - if (newCount > playlistObject.items.length) { - newCount = playlistObject.items.length; - } - - // tslint:disable-next-line: no-parameter-reassignment - fetchIds = playlistObject.items.slice(fetched, newCount); - } - - if (fetchIds && fetchIds.length) { - return dispatch>({ - type: ObjectsActionTypes.SET_TRACKS, - payload: { - promise: fetchToJson(SC.getTracks(fetchIds.map(id => id.id))).then((tracks: Track[]) => { - const normalized = normalize( - tracks, - new schema.Array( - { - tracks: trackSchema - }, - input => `${input.kind}s` - ) - ); - - let fetchedItems: Partial[] = []; - - if (tracks) { - fetchedItems = tracks.filter((t: Partial) => t.user !== undefined); - } - - const fetchedItemsIds = fetchedItems.map(item => item.id); - - // eslint-disable-next-line no-case-declarations - const result = fetchIds.filter(t => fetchedItemsIds.indexOf(t.id) !== -1); - - return { - objectId: playlistId, - objectType: ObjectTypes.PLAYLISTS, - entities: normalized.entities, - - fetchedIds: result, - shouldFetchedIds: fetchIds - }; - }), - data: { - objectId: playlistId - } - } - } as any); - } - - return Promise.resolve(); - }; -} - -/** - * Fetch new chart if needed - */ -// export function fetchChartsIfNeeded(objectId: string, sortType: SortTypes = SortTypes.TOP): ThunkResult { -// return (dispatch, getState) => { -// const playlistObject = getPlaylistObjectSelector(objectId)(getState()); - -// if (!playlistObject) { -// dispatch(getPlaylistO(SC.getChartsUrl(objectId.split('_')[0], sortType, 25), objectId)); -// } -// }; -// } - -// export function canFetchPlaylistTracks(playlistId: string): ThunkResult { -// return (_dispatch, getState) => { -// const playlistObject = getPlaylistObjectSelector(playlistId)(getState()); - -// if (!playlistObject || playlistObject.fetchedItems === playlistObject.items.length || playlistObject.isFetching) { -// return false; -// } - -// let newCount = playlistObject.fetchedItems + 20; - -// if (newCount > playlistObject.items.length) { -// newCount = playlistObject.items.length; -// } - -// const ids = playlistObject.items.slice(playlistObject.fetchedItems, newCount); - -// return !!ids.length; -// }; -// } - -export function fetchTracks(ids: number[]): ThunkResult { - return dispatch => { - if (!ids || (ids && !ids.length)) { - return null; - } - - return dispatch({ - type: ObjectsActionTypes.SET_TRACKS, - payload: { - promise: fetchToJson(SC.getTracks(ids)).then(tracks => { - const normalized = normalize( - tracks, - new schema.Array( - { - tracks: trackSchema - }, - input => `${input.kind}s` - ) - ); - - return { - entities: normalized.entities - }; - }), - data: { - entities: { - trackEntities: ids.reduce( - (obj, id) => ({ - ...obj, - [id]: { - loading: true - } - }), - {} - ) - } - } - } - }); - }; -} - -// export const unset = (objectId: string) => -// action(ObjectsActionTypes.UNSET, { -// objectId, -// objectType: ObjectTypes.PLAYLISTS -// }); - -export function fetchMore(objectId: string, objectType: ObjectTypes): ThunkResult> { - return async (dispatch, getState) => { - const { objects } = getState(); - const objectGroup = objects[objectType] || {}; - - if (canFetchMore(objectGroup[objectId])) { - const { nextUrl } = objectGroup[objectId]; - - if (nextUrl) { - switch (objectType) { - case ObjectTypes.PLAYLISTS: - return dispatch>(getPlaylistO(nextUrl, objectId)); - case ObjectTypes.COMMENTS: - return dispatch>(getCommentsByUrl(nextUrl, objectId)); - default: - } - } - } - - return Promise.resolve(); - }; -} - -export function canFetchMoreOf(objectId: string, type: ObjectTypes): ThunkResult { - return (_dispatch, getState) => { - const { objects } = getState(); - const objectGroup = objects[type] || {}; - const object = objectGroup[objectId]; - - return canFetchMore(object); - }; -} diff --git a/src/common/store/objects/playlists/search/actions.ts b/src/common/store/objects/playlists/search/actions.ts deleted file mode 100644 index d84099c0..00000000 --- a/src/common/store/objects/playlists/search/actions.ts +++ /dev/null @@ -1,98 +0,0 @@ -import fetchSearch from '@common/api/fetchSearch'; -import { tryAndResolveQueryAsSoundCloudUrl } from '@common/store/app/actions'; -import { isSoundCloudUrl, SC } from '@common/utils'; -import { ThunkResult } from '@types'; -import { getPlaylistObjectSelector, getPlaylistType } from '../../selectors'; -import { ObjectsActionTypes, ObjectTypes, PlaylistTypes } from '../../types'; - -// export function search( -// filter: { query?: string; tag?: string }, -// objectId: string, -// limit?: number, -// offset?: number -// ): ThunkResult> { -// return (dispatch, getState) => { -// const state = getState(); -// const { query, tag } = filter; - -// const tracklistObject = getPlaylistObjectSelector(objectId)(state); - -// if (query && isSoundCloudUrl(query)) { -// return Promise.resolve(tryAndResolveQueryAsSoundCloudUrl(query, dispatch)) as any; -// } - -// let url: string | null = null; - -// switch (getPlaylistType(objectId)) { -// case PlaylistTypes.SEARCH: -// if (query) { -// url = SC.searchAllUrl(query, limit, offset); -// } -// break; -// case PlaylistTypes.SEARCH_TRACK: -// if (query) { -// url = SC.searchTracksUrl(query, limit, offset); -// } else if (tag) { -// url = SC.searchTracksUrl(tag, limit, offset); -// } -// break; -// case PlaylistTypes.SEARCH_PLAYLIST: -// if (query) { -// url = SC.searchPlaylistsUrl(query, limit, offset); -// } else if (tag) { -// url = SC.discoverPlaylistsUrl(tag, limit, offset); -// } -// break; -// case PlaylistTypes.SEARCH_USER: -// if (query) { -// url = SC.searchUsersUrl(query, limit, offset); -// } -// break; -// default: -// } - -// if ( -// url && -// url.length && -// (!tracklistObject || (tracklistObject && !tracklistObject.isFetching && tracklistObject.nextUrl)) -// ) { -// return dispatch>({ -// type: ObjectsActionTypes.SET, -// payload: { -// promise: fetchSearch(url).then(({ normalized, json }) => { -// return { -// objectId, -// objectType: ObjectTypes.PLAYLISTS, -// entities: normalized.entities, -// result: normalized.result, -// nextUrl: json.next_href ? SC.appendToken(json.next_href) : null, -// refresh: true -// }; -// }), -// data: { -// objectId, -// objectType: ObjectTypes.PLAYLISTS -// } -// } -// } as any); -// } - -// return Promise.resolve(); -// }; -// } - -// ===================================================== - -// type ObjectSet = { -// objectId: string; -// objectType: ObjectTypes; -// entities: NormalizedEntities; -// result: NormalizedResult; -// nextUrl?: string; -// }; - -// export const searchAsync = createAsyncAction('SEARCH_REQUEST', 'SEARCH_SUCCESS', 'SEARCH_FAIL')< -// { query: string }, -// any, -// string -// >(); diff --git a/src/common/store/objects/reducer.ts b/src/common/store/objects/reducer.ts index e8afe7fc..bb2bab52 100755 --- a/src/common/store/objects/reducer.ts +++ b/src/common/store/objects/reducer.ts @@ -1,14 +1,21 @@ -import { NormalizedResult } from 'src/types/normalized'; +import { Normalized } from '@types'; +import _, { isEqual, pick, uniqWith } from 'lodash'; import { createReducer } from 'typesafe-actions'; import { + commentsFetchMore, genericPlaylistFetchMore, + getComments, + getForYouSelection, getGenericPlaylist, getSearchPlaylist, + queueInsert, + setCurrentPlaylist, setPlaylistLoading, - getForYouSelection -} from '../playlist/actions'; -import { ObjectGroup, ObjectsState, ObjectState, ObjectTypes, PlaylistTypes } from './types'; -import { uniqWith, isEqual } from 'lodash'; + shuffleQueue, + resolvePlaylistItems, + setCommentsLoading +} from '../actions'; +import { ObjectGroup, ObjectsState, ObjectState, ObjectStateItem, ObjectTypes, PlaylistTypes } from '../types'; const initialObjectsState: ObjectState = { isFetching: false, @@ -20,7 +27,7 @@ const initialObjectsState: ObjectState = { }; const objectState = createReducer(initialObjectsState) - .handleAction([getGenericPlaylist.request, setPlaylistLoading], state => { + .handleAction([getGenericPlaylist.request, setPlaylistLoading, getComments.request, setCommentsLoading], state => { return { ...state, isFetching: true, @@ -29,9 +36,12 @@ const objectState = createReducer(initialObjectsState) }) .handleAction(getSearchPlaylist, (state, action) => { const { query, tag } = action.payload; + + const isFetching = !!(query && query.length) || !!tag; + return { ...state, - isFetching: (query && query.length) || !!tag, + isFetching, items: [], error: null, meta: { query } @@ -41,7 +51,7 @@ const objectState = createReducer(initialObjectsState) const { payload } = action; let itemsToAdd = payload.result; - let itemsToFetch: NormalizedResult[] = []; + let itemsToFetch: Normalized.NormalizedResult[] = []; if (payload.fetchedItemsIds) { itemsToAdd = itemsToAdd.filter(i => payload.fetchedItemsIds?.includes(i.id)); @@ -65,35 +75,58 @@ const objectState = createReducer(initialObjectsState) meta: { query: payload.query, createdAt: Date.now() } }; }) - .handleAction(getGenericPlaylist.failure, (state, action) => { - const { payload } = action; + .handleAction(genericPlaylistFetchMore.success, (state, { payload }) => { + const { result = [], fetchedItemsIds = [] } = payload; + + const itemsToFetch = state.itemsToFetch.filter(a => !fetchedItemsIds.includes(a.id)); return { ...state, isFetching: false, - error: payload.error + items: uniqWith([...state.items, ...result], isEqual), + nextUrl: payload.nextUrl, + itemsToFetch, + meta: { ...state.meta, updatedAt: Date.now() } }; }) - .handleAction(genericPlaylistFetchMore.success, (state, action) => { + .handleAction(getComments.success, (state, action) => { const { payload } = action; - const itemsToFetch = state.itemsToFetch.filter(a => !payload.fetchedItemsIds?.includes(a.id)); + const itemsToAdd = payload.result; + + const items = payload.refresh ? itemsToAdd : uniqWith([...state.items, ...itemsToAdd], isEqual); return { ...state, isFetching: false, - items: uniqWith([...state.items, ...payload.result], isEqual), + items, nextUrl: payload.nextUrl, - itemsToFetch, - meta: { ...state.meta, updatedAt: Date.now() } + meta: { createdAt: Date.now() } }; }) - .handleAction(genericPlaylistFetchMore.failure, state => { + .handleAction(commentsFetchMore.success, (state, action) => { + const { payload } = action; + return { ...state, - isFetching: false + isFetching: false, + items: uniqWith([...state.items, ...payload.result], isEqual), + nextUrl: payload.nextUrl, + meta: { ...state.meta, updatedAt: Date.now() } }; - }); + }) + .handleAction( + [getGenericPlaylist.failure, genericPlaylistFetchMore.failure, getComments.failure, commentsFetchMore.failure], + (state, action) => { + const { payload } = action; + + return { + ...state, + isFetching: false, + error: payload.error + }; + } + ); // const objectState: Reducer> = (state = initialObjectsState, action) => { // const { type, payload } = action; @@ -209,6 +242,29 @@ const objectGroup = createReducer(initialObjectGroupState) }; } ) + .handleAction( + [ + getComments.request, + getComments.success, + getComments.failure, + commentsFetchMore.request, + commentsFetchMore.success, + commentsFetchMore.failure, + setCommentsLoading + ], + (state, action) => { + const trackId = action.payload?.trackId; + + if (!trackId) { + return state; + } + + return { + ...state, + [trackId]: objectState(state[trackId], action) + }; + } + ) .handleAction(getForYouSelection.success, (state, action) => { const { objects } = action.payload; @@ -274,11 +330,12 @@ const initialState: ObjectsState = { [PlaylistTypes.LIKES]: initialObjectsState, [PlaylistTypes.MYTRACKS]: initialObjectsState, [PlaylistTypes.MYPLAYLISTS]: initialObjectsState, - [PlaylistTypes.PLAYLIST]: initialObjectsState, + [PlaylistTypes.PLAYLIST]: initialObjectGroupState, [PlaylistTypes.SEARCH]: initialObjectsState, [PlaylistTypes.SEARCH_PLAYLIST]: initialObjectsState, [PlaylistTypes.SEARCH_TRACK]: initialObjectsState, [PlaylistTypes.SEARCH_USER]: initialObjectsState, + [PlaylistTypes.QUEUE]: initialObjectsState, [ObjectTypes.PLAYLISTS]: {}, [ObjectTypes.COMMENTS]: {} @@ -288,11 +345,9 @@ export const objectsReducer = createReducer(initialState) .handleAction( [ getGenericPlaylist.request, - getGenericPlaylist.success, getGenericPlaylist.failure, setPlaylistLoading, genericPlaylistFetchMore.request, - genericPlaylistFetchMore.success, genericPlaylistFetchMore.failure, getSearchPlaylist ], @@ -304,12 +359,169 @@ export const objectsReducer = createReducer(initialState) }; } ) + .handleAction( + [ + getComments.request, + getComments.success, + getComments.failure, + commentsFetchMore.request, + commentsFetchMore.success, + commentsFetchMore.failure, + setCommentsLoading + ], + (state, action) => { + return { + ...state, + [ObjectTypes.COMMENTS]: objectGroup(state[ObjectTypes.COMMENTS], action) + }; + } + ) .handleAction(getForYouSelection.success, (state, action) => { return { ...state, [PlaylistTypes.PLAYLIST]: objectGroup(state[PlaylistTypes.PLAYLIST], action) }; + }) + + // Managing QUEUE state + .handleAction([getGenericPlaylist.success], (state, action) => { + const { playlistType, objectId } = action.payload; + const queuePlaylist = state[PlaylistTypes.QUEUE]; + + const newPlaylistState = objectId + ? objectGroup(state[playlistType], action) + : objectState(state[playlistType], action); + + const newState: Partial = { + [playlistType]: newPlaylistState + }; + // const isPlaylist = objectId && playlistType === PlaylistTypes.PLAYLIST; + // const unResolvedPlaylistIndices = objectId + // ? _.keys(_.pickBy(queuePlaylist.items, { schema: 'playlists', id: +objectId })) + // : []; + + // if (isPlaylist && unResolvedPlaylistIndices.length) { + // const items = [...queuePlaylist.items]; + + // unResolvedPlaylistIndices.forEach(index => items.splice(+index, 1, ...(newPlaylistState as ObjectState).items)); + // } + + return { + ...state, + ...newState + }; + }) + .handleAction([genericPlaylistFetchMore.success], (state, action) => { + const { playlistType, objectId, shuffle, result } = action.payload; + const queuePlaylist = state[PlaylistTypes.QUEUE]; + const { originalPlaylistID } = queuePlaylist.meta; + + const newPlaylistState = objectId + ? objectGroup(state[playlistType], action) + : objectState(state[playlistType], action); + + const newState: Partial = { + [playlistType]: newPlaylistState + }; + + const isCurrentPlaylistSameAsQueue = isEqual(originalPlaylistID, { + playlistType, + objectId + }); + + if (isCurrentPlaylistSameAsQueue) { + newState[PlaylistTypes.QUEUE] = { + ...queuePlaylist, + ...pick(newPlaylistState, ['fetchedItems', 'nextUrl', 'itemsToFetch']) + }; + + // if (shuffle) { + newState[PlaylistTypes.QUEUE].items = [...newState[PlaylistTypes.QUEUE].items, ...result]; + // } + } + + return { + ...state, + ...newState + }; + }) + .handleAction(setCurrentPlaylist.success, (state, action) => { + const { + playlistId: { playlistType, objectId }, + items + } = action.payload; + + let queueObjectState: ObjectState = state[playlistType]; + + if (objectId) { + queueObjectState = state[playlistType]?.[objectId] ?? initialObjectsState; + } + + return { + ...state, + [PlaylistTypes.QUEUE]: { + ...queueObjectState, + items, + meta: { + ...queueObjectState.meta, + originalPlaylistID: { playlistType, objectId } + } + } + }; + }) + .handleAction(queueInsert, (state, action) => { + const { items, position } = action.payload; + + const queuePlaylist = state[PlaylistTypes.QUEUE]; + const newItems = [...queuePlaylist.items]; + + newItems.splice(position, 0, ...items); + + return { + ...state, + [PlaylistTypes.QUEUE]: { + ...queuePlaylist, + items: newItems + } + }; + }) + .handleAction(resolvePlaylistItems, (state, action) => { + const { items, playlistItem } = action.payload; + + const queuePlaylist = state[PlaylistTypes.QUEUE]; + const newItems = [...queuePlaylist.items]; + + const indexToReplace = _.findIndex(newItems, item => _.isEqual(item, playlistItem)); + + if (indexToReplace === -1) { + return state; + } + + newItems.splice(indexToReplace, 1, ...items); + + return { + ...state, + [PlaylistTypes.QUEUE]: { + ...queuePlaylist, + items: newItems + } + }; + }) + .handleAction(shuffleQueue, (state, { payload }) => { + const { fromIndex } = payload; + + const queuePlaylist = state[PlaylistTypes.QUEUE]; + const items = [...queuePlaylist.items]; + + return { + ...state, + [PlaylistTypes.QUEUE]: { + ...queuePlaylist, + items: items.slice(0, fromIndex).concat(_.shuffle(items.slice(fromIndex, items.length))) + } + }; }); + // const { type, payload } = action; // switch (type) { diff --git a/src/common/store/objects/selectors.ts b/src/common/store/objects/selectors.ts index 2fb3885f..5e7410f3 100644 --- a/src/common/store/objects/selectors.ts +++ b/src/common/store/objects/selectors.ts @@ -1,25 +1,27 @@ +import { StoreState } from 'AppReduxTypes'; import { createSelector } from 'reselect'; -// eslint-disable-next-line import/no-cycle -import { StoreState } from '../rootReducer'; -import { RootState } from '../types'; -import { ObjectGroup, ObjectState, ObjectTypes, PlaylistTypes } from './types'; -import { PlaylistIdentifier } from '../playlist/types'; +import { PlaylistIdentifier, ObjectGroup, ObjectState, ObjectTypes, PlaylistTypes } from '../types'; -export const getPlaylistsObjects = (state: StoreState) => state.objects[ObjectTypes.PLAYLISTS] || {}; +export const getPlaylistsObjects = (state: StoreState) => state.objects[PlaylistTypes.PLAYLIST] ?? {}; export const getPlaylistRootObject = (playlistType: PlaylistTypes | ObjectTypes) => (state: StoreState) => - state.objects[playlistType] || {}; -export const getCommentsObjects = (state: StoreState) => state.objects[ObjectTypes.COMMENTS] || {}; + state.objects[playlistType] ?? {}; export const getPlaylistObjectSelector = (identifier: PlaylistIdentifier) => - createSelector( + createSelector( [getPlaylistRootObject(identifier.playlistType)], playlistsOrObjectState => identifier.objectId ? playlistsOrObjectState[identifier.objectId] : playlistsOrObjectState ); -export const getCommentObject = (trackId: string) => +export const getQueuePlaylistSelector = (state: StoreState) => state.objects[PlaylistTypes.QUEUE] ?? {}; +export const getQueueTrackByIndexSelector = (index: number) => (state: StoreState) => + state.objects[PlaylistTypes.QUEUE].items[index]; + +export const getCommentsObjects = (state: StoreState) => state.objects[ObjectTypes.COMMENTS] || {}; + +export const getCommentObject = (trackId?: string | number) => createSelector([getCommentsObjects], comments => - trackId in comments ? comments[trackId] : null + trackId && trackId in comments ? comments[trackId] : null ); export const getPlaylistName = (id: string, playlistType: PlaylistTypes) => [id, playlistType].join('|'); @@ -41,11 +43,10 @@ export const getPlaylistType = (objectId: string): PlaylistTypes | null => { return objectId.split('|')[1] as PlaylistTypes; }; - export const getRelatedTracksPlaylistObject = (trackId: string) => - getPlaylistObjectSelector(getPlaylistName(trackId, PlaylistTypes.RELATED)); + getPlaylistObjectSelector({ objectId: trackId, playlistType: PlaylistTypes.RELATED }); export const getArtistLikesPlaylistObject = (artistId: string) => - getPlaylistObjectSelector(getPlaylistName(artistId, PlaylistTypes.ARTIST_LIKES)); + getPlaylistObjectSelector({ objectId: artistId, playlistType: PlaylistTypes.ARTIST_LIKES }); export const getArtistTracksPlaylistObject = (artistId: string) => - getPlaylistObjectSelector(getPlaylistName(artistId, PlaylistTypes.ARTIST_TRACKS)); + getPlaylistObjectSelector({ objectId: artistId, playlistType: PlaylistTypes.ARTIST_TRACKS }); diff --git a/src/common/store/objects/types.ts b/src/common/store/objects/types.ts index dc8358cd..beeafde1 100644 --- a/src/common/store/objects/types.ts +++ b/src/common/store/objects/types.ts @@ -1,8 +1,18 @@ -import { Normalized } from '@types'; +import { EntitiesOf, Normalized } from '@types'; import { AxiosError } from 'axios'; +import { PlaylistIdentifier } from '../playlist'; // TYPES +export interface ObjectItem { + objectType: ObjectTypes; + entities: EntitiesOf; + result: Normalized.NormalizedResult[]; + nextUrl?: string; + fetchedItemsIds?: number[]; + refresh?: boolean; +} + export enum ObjectTypes { PLAYLISTS = 'PLAYLISTS', COMMENTS = 'COMMENTS' @@ -22,10 +32,12 @@ export enum PlaylistTypes { RELATED = 'RELATED', ARTIST_LIKES = 'ARTIST_LIKES', ARTIST_TRACKS = 'ARTIST_TRACKS', + ARTIST_TOP_TRACKS = 'ARTIST_TOP_TRACKS', SEARCH = 'SEARCH', SEARCH_USER = 'SEARCH_USER', SEARCH_TRACK = 'SEARCH_TRACK', - SEARCH_PLAYLIST = 'SEARCH_PLAYLIST' + SEARCH_PLAYLIST = 'SEARCH_PLAYLIST', + QUEUE = 'QUEUE' } export type ObjectsState = Readonly<{ @@ -33,11 +45,12 @@ export type ObjectsState = Readonly<{ [PlaylistTypes.LIKES]: ObjectState; [PlaylistTypes.MYTRACKS]: ObjectState; [PlaylistTypes.MYPLAYLISTS]: ObjectState; - [PlaylistTypes.PLAYLIST]: ObjectState; + [PlaylistTypes.PLAYLIST]: ObjectGroup; [PlaylistTypes.SEARCH]: ObjectState; [PlaylistTypes.SEARCH_PLAYLIST]: ObjectState; [PlaylistTypes.SEARCH_USER]: ObjectState; [PlaylistTypes.SEARCH_TRACK]: ObjectState; + [PlaylistTypes.QUEUE]: ObjectState; [ObjectTypes.PLAYLISTS]: ObjectGroup; [ObjectTypes.COMMENTS]: ObjectGroup; @@ -50,13 +63,15 @@ export interface ObjectGroup { export interface ObjectState { isFetching: boolean; error: AxiosError | Error | null; - items: Normalized.NormalizedResult[]; + items: ObjectStateItem[]; nextUrl?: string | null; fetchedItems: number; itemsToFetch: Normalized.NormalizedResult[]; - meta: { query?: string; createdAt?: number; updatedAt?: number }; + meta: { query?: string; createdAt?: number; updatedAt?: number; originalPlaylistID?: PlaylistIdentifier }; } +export type ObjectStateItem = Normalized.NormalizedResult & { parentPlaylistID?: PlaylistIdentifier }; + // ACTIONS export enum ObjectsActionTypes { diff --git a/src/common/store/player/actions.ts b/src/common/store/player/actions.ts index d3521b67..09a0aeca 100755 --- a/src/common/store/player/actions.ts +++ b/src/common/store/player/actions.ts @@ -1,371 +1,314 @@ -import { Intent } from '@blueprintjs/core'; -import { axiosClient } from '@common/api/helpers/axiosClient'; -import { Normalized, SoundCloud, ThunkResult } from '@types'; -import _ from 'lodash'; -import { action, createAction } from 'typesafe-actions'; -import { getCurrentPosition } from '../../utils/playerUtils'; -import * as SC from '../../utils/soundcloudUtils'; -import { getPlaylistEntity, getTrackEntity } from '../entities/selectors'; -import { EntitiesState } from '../entities/types'; -// eslint-disable-next-line import/no-cycle -import { fetchMore, fetchPlaylistIfNeeded, fetchPlaylistTracks, fetchTracks } from '../objects/actions'; -import { getPlaylistObjectSelector, getPlaylistType } from '../objects/selectors'; -import { ObjectsActionTypes, ObjectTypes, PlaylistTypes } from '../objects/types'; -import { addToast } from '../ui/actions'; -import { - ChangeTypes, - PlayerActionTypes, - PlayerStatus, - PlayingPositionState, - PlayingTrack, - ProcessedQueueItems, - RepeatTypes -} from './types'; +import { wError, wSuccess } from '@common/utils/reduxUtils'; +import { EpicFailure, Normalized, SoundCloud, ThunkResult } from '@types'; +import { createAction, createAsyncAction } from 'typesafe-actions'; +import { PlaylistIdentifier } from '../playlist'; +import { ChangeTypes, PlayerActionTypes, PlayerStatus, PlayingTrack } from '../types'; +import { ObjectStateItem } from '../objects'; export const toggleShuffle = createAction(PlayerActionTypes.TOGGLE_SHUFFLE)(); - -export const setCurrentTime = createAction(PlayerActionTypes.SET_TIME, (time: number) => ({ - time +export const toggleStatus = createAction(PlayerActionTypes.TOGGLE_STATUS, (status?: PlayerStatus) => status)(); +export const playlistFinished = createAction(PlayerActionTypes.PLAYLIST_FINISHED)(); +export const restartTrack = createAction(PlayerActionTypes.RESTART_TRACK)(); +export const trackFinished = createAction(PlayerActionTypes.TRACK_FINISHED)(); +export const startPlayMusicIndex = createAction(PlayerActionTypes.START_PLAY_MUSIC_INDEX)<{ + index: number; + changeType?: ChangeTypes; +}>(); + +export const changeTrack = createAction(PlayerActionTypes.CHANGE_TRACK, (changeType: ChangeTypes) => ({ + changeType }))(); -export const setDuration = (time: number) => action(PlayerActionTypes.SET_DURATION, { time }); -export const clearUpNext = () => action(PlayerActionTypes.CLEAR_UP_NEXT); - -export function getPlaylistObject(playlistId: string, position: number): ThunkResult> { - return async (dispatch, getState) => { - const state = getState(); - - const { - player: { containsPlaylists } - } = state; - - const playlistObject = getPlaylistObjectSelector(playlistId)(state); - - if (!playlistObject) { - const result: any = await dispatch>(fetchPlaylistIfNeeded(+playlistId)); - - const currentPlaylistObject = getPlaylistObjectSelector(playlistId)(state); - const currentPlaylistEntity = getPlaylistEntity(+playlistId)(state); - - if (currentPlaylistObject) { - if ( - currentPlaylistEntity && - !currentPlaylistObject.isFetching && - ((currentPlaylistObject.items.length === 0 && currentPlaylistEntity.duration === 0) || - currentPlaylistEntity.track_count === 0) - ) { - throw new Error('This playlist is empty or not available via a third party!'); - } - // Fetch more tracks - if (currentPlaylistObject.fetchedItems < currentPlaylistObject.items.length) { - dispatch(fetchPlaylistTracks(+playlistId, 50)); - } - } - - return result; - } - - const playlistInQueue = containsPlaylists.find(p => position > p.start && position < p.end); - - if (playlistInQueue) { - const queuePlaylistObject = getPlaylistObjectSelector(playlistInQueue.id.toString())(state); - - if (queuePlaylistObject) { - /** - * If amount of fetched items - 25 is in the visible queue, fetch more tracks - */ - if ( - position > playlistInQueue.start + queuePlaylistObject.fetchedItems - 25 && - !queuePlaylistObject.isFetching - ) { - dispatch(fetchPlaylistTracks(playlistInQueue.id, 50)); - } - } - } - - return null; - }; +// TODO: This could be removed if no longer needed +// export const setDuration = createAction(PlayerActionTypes.SET_DURATION)(); +export const setCurrentTime = createAction(PlayerActionTypes.SET_TIME)(); + +export const startPlayMusic = createAction(PlayerActionTypes.START_PLAY_MUSIC)<{ + idResult?: ObjectStateItem; + origin?: PlaylistIdentifier; + changeType?: ChangeTypes; + nextPosition?: number; +}>(); + +interface PlayTrackProps { + idResult: ObjectStateItem; + origin: PlaylistIdentifier; + nextPosition?: number; } -export function registerPlay(): ThunkResult { - return async (_dispatch, getState) => { - const { - player: { playingTrack } - } = getState(); - - if (playingTrack) { - const { id, playlistId } = playingTrack; - - const params: any = { - track_urn: `soundcloud:tracks:${id}` - }; - - await import('@common/utils/universalAnalytics').then(({ ua }) => { - ua.event('SoundCloud', 'Play', '', id).send(); - }); - - const type = getPlaylistType(playlistId); - - if ((!type || !(type in PlaylistTypes)) && typeof playlistId !== 'string') { - params.context_urn = `soundcloud:playlists:${playlistId}`; - } - - await axiosClient.request({ - url: SC.registerPlayUrl(), - method: 'POST', - data: params - }); - } - }; +export const playTrack = createAsyncAction( + String(PlayerActionTypes.PLAY_TRACK), + wSuccess(PlayerActionTypes.PLAY_TRACK), + wError(PlayerActionTypes.PLAY_TRACK) +)< + PlayTrackProps, + PlayTrackProps & { + duration: number; + position: number; + positionInPlaylist?: number; + parentPlaylistID?: PlaylistIdentifier; + }, + EpicFailure +>(); + +interface PlayPlaylistProps { + idResult: ObjectStateItem; + origin: PlaylistIdentifier; + changeType?: ChangeTypes; + nextPosition?: number; } -/** - * Set currentrackIndex & start playing - */ -export function setPlayingTrack(nextTrack: PlayingTrack, position: number, changeType?: ChangeTypes): ThunkResult { - return (dispatch, getState) => { - const { - config: { repeat } - } = getState(); - - const track = getTrackEntity(nextTrack.id)(getState()); - - if (track && !SC.isStreamable(track)) { - if (changeType && changeType in Object.values(ChangeTypes)) { - // eslint-disable-next-line @typescript-eslint/no-use-before-define - changeTrack(changeType); - } - } - - dispatch({ - type: PlayerActionTypes.SET_TRACK, - payload: { - nextTrack, - status: PlayerStatus.PLAYING, - position, - repeat: repeat === RepeatTypes.ONE - } - }); +export const playPlaylist = createAction(PlayerActionTypes.PLAY_PLAYLIST)(); + +export const setCurrentPlaylist = createAsyncAction( + String(PlayerActionTypes.SET_CURRENT_PLAYLIST), + wSuccess(PlayerActionTypes.SET_CURRENT_PLAYLIST), + wError(PlayerActionTypes.SET_CURRENT_PLAYLIST) +)<{ playlistId: PlaylistIdentifier }, { playlistId: PlaylistIdentifier; items: ObjectStateItem[] }, EpicFailure>(); +export const setCurrentIndex = createAction(PlayerActionTypes.SET_CURRENT_INDEX)<{ + position: number; +}>(); +export const resolvePlaylistItems = createAction(PlayerActionTypes.RESOLVE_PLAYLIST_ITEMS)<{ + items: ObjectStateItem[]; + playlistItem: ObjectStateItem; +}>(); +// NEXT UP +export const addUpNext = createAsyncAction( + String(PlayerActionTypes.ADD_UP_NEXT), + wSuccess(PlayerActionTypes.ADD_UP_NEXT), + wError(PlayerActionTypes.ADD_UP_NEXT) +)(); + +export const upNextInsert = createAction(PlayerActionTypes.ADD_UP_NEXT)(); +export const queueInsert = createAction(PlayerActionTypes.QUEUE_INSERT)<{ + items: ObjectStateItem[]; + position: number; +}>(); +export const shuffleQueue = createAction(PlayerActionTypes.SHUFFLE_QUEUE)<{ + fromIndex: number; +}>(); +export const setQueue = createAction(PlayerActionTypes.SET_QUEUE)<{ + items: Normalized.NormalizedResult[]; +}>(); +export const clearUpNext = createAction(PlayerActionTypes.CLEAR_UP_NEXT)(); + +// OLD + +export function registerPlayO(): ThunkResult { + return async (_dispatch, getState) => { + // const { + // player: { playingTrack } + // } = getState(); + // if (playingTrack) { + // const { id, playlistId } = playingTrack; + // const params: any = { + // track_urn: `soundcloud:tracks:${id}` + // }; + // await import('@common/utils/universalAnalytics').then(({ ua }) => { + // ua.event('SoundCloud', 'Play', '', id).send(); + // }); + // const type = getPlaylistType(playlistId); + // if ((!type || !(type in PlaylistTypes)) && typeof playlistId !== 'string') { + // params.context_urn = `soundcloud:playlists:${playlistId}`; + // } + // await axiosClient.request({ + // url: SC.registerPlayUrl(), + // method: 'POST', + // data: params + // }); + // } }; } -export function getItemsAround(position: number): ThunkResult> { +export function getItemsAroundO(position: number): ThunkResult> { return async (dispatch, getState) => { - const { - player: { queue, currentPlaylistId } - } = getState(); - - if (currentPlaylistId) { - const currentPlaylist = getPlaylistObjectSelector(currentPlaylistId)(getState()); - - const itemsToFetch: { position: number; id: number }[] = []; - - const lowBound = position - 3; - const highBound = position + 3; - - // Get playlists - for (let i = lowBound < 0 ? 0 : position; i < (highBound > queue.length ? queue.length : highBound); i += 1) { - const queueItem = queue[i]; - - if (queueItem && queueItem.id) { - const playlist = getPlaylistEntity(+queueItem.playlistId)(getState()); - - if (playlist) { - dispatch(getPlaylistObject(queueItem.playlistId, i)); - } - - const track = getTrackEntity(queueItem.id)(getState()); - - if (!track || (track && !track.title && !track.loading)) { - itemsToFetch.push({ - position: i, - id: queueItem.id - }); - } - - if ( - currentPlaylist && - currentPlaylist.fetchedItems && - currentPlaylist.fetchedItems - 10 < i && - currentPlaylist.fetchedItems !== currentPlaylist.items.length - ) { - dispatch(fetchPlaylistTracks(+currentPlaylistId, 30)); - } - } - } - - if (itemsToFetch.length) { - const response = await dispatch>( - fetchTracks(itemsToFetch.map(i => i.id)) as any - ); - - const { - value: { - entities: { trackEntities = {} } - } - } = response; - - // SoundCloud sometimes returns 404 for some tracks, if this happens, we clear it in our app - itemsToFetch.forEach(i => { - if (!trackEntities[i.id]) { - const queueItem = queue[i.position]; - - dispatch({ - type: ObjectsActionTypes.UNSET_TRACK, - payload: { - trackId: i.id, - position: i.position, - objectId: queueItem.playlistId, - entities: { - trackEntities: { - [i.id]: undefined - } - } - } - }); - } - }); - } - } + // const { + // player: { queue, currentPlaylistId } + // } = getState(); + // if (currentPlaylistId) { + // const currentPlaylist = getPlaylistObjectSelector({ + // objectId: currentPlaylistId, + // playlistType: PlaylistTypes.MYTRACKS + // })(getState()); + // const itemsToFetch: { position: number; id: number }[] = []; + // const lowBound = position - 3; + // const highBound = position + 3; + // // Get playlists + // for (let i = lowBound < 0 ? 0 : position; i < (highBound > queue.length ? queue.length : highBound); i += 1) { + // const queueItem = queue[i]; + // if (queueItem && queueItem.id) { + // const playlist = getPlaylistEntity(+queueItem.playlistId)(getState()); + // if (playlist) { + // dispatch(getPlaylistObjectO(queueItem.playlistId, i)); + // } + // const track = getTrackEntity(queueItem.id)(getState()); + // if (!track || (track && !track.title && !track.loading)) { + // itemsToFetch.push({ + // position: i, + // id: queueItem.id + // }); + // } + // if ( + // currentPlaylist && + // currentPlaylist.fetchedItems && + // currentPlaylist.fetchedItems - 10 < i && + // currentPlaylist.fetchedItems !== currentPlaylist.items.length + // ) { + // dispatch(fetchPlaylistTracks(+currentPlaylistId, 30)); + // } + // } + // } + // if (itemsToFetch.length) { + // const response = await dispatch>( + // fetchTracks(itemsToFetch.map(i => i.id)) as any + // ); + // const { + // value: { + // entities: { trackEntities = {} } + // } + // } = response; + // // SoundCloud sometimes returns 404 for some tracks, if this happens, we clear it in our app + // itemsToFetch.forEach(i => { + // if (!trackEntities[i.id]) { + // const queueItem = queue[i.position]; + // dispatch({ + // type: ObjectsActionTypes.UNSET_TRACK, + // payload: { + // trackId: i.id, + // position: i.position, + // objectId: queueItem.playlistId, + // entities: { + // trackEntities: { + // [i.id]: undefined + // } + // } + // } + // }); + // } + // }); + // } + // } }; } /** * Update queue when scrolling through */ -export function updateQueue(range: number[]): ThunkResult { +export function updateQueueO(range: number[]): ThunkResult { return (dispatch, getState) => { - const { player } = getState(); - - const { queue, currentPlaylistId } = player; - - if (currentPlaylistId) { - if (queue.length < range[1] + 5) { - dispatch(fetchMore(currentPlaylistId, ObjectTypes.PLAYLISTS)); - } - - dispatch(getItemsAround(range[1])); - } + // const { player } = getState(); + // const { queue, currentPlaylistId } = player; + // if (currentPlaylistId) { + // if (queue.length < range[1] + 5) { + // dispatch(fetchMore(currentPlaylistId, ObjectTypes.PLAYLISTS)); + // } + // dispatch(getItemsAroundO(range[1])); + // } }; } -export function processQueueItems( - result: Normalized.NormalizedResult[], - keepFirst = false, - newPlaylistId?: string -): ThunkResult> { - return async (dispatch, getState) => { - const { - player: { currentPlaylistId }, - config: { shuffle } - } = getState(); - - if (!currentPlaylistId && !newPlaylistId) { - return [[], []]; - } - - const currentPlaylist = newPlaylistId || (currentPlaylistId as string); - - const items = await Promise.all( - result - .filter(trackIdSchema => trackIdSchema && trackIdSchema.schema !== 'users') - .map( - async (trackIdSchema): Promise => { - const { id } = trackIdSchema; - - const playlist = getPlaylistEntity(id)(getState()); - const playlistObject = getPlaylistObjectSelector(id.toString())(getState()); - - if (playlist) { - if (!playlistObject) { - dispatch(fetchPlaylistIfNeeded(id)); - } else { - return playlistObject.items.map((trackIdResult): PlayingTrack | null => { - const trackId = trackIdResult.id; - const track = getTrackEntity(id)(getState()); - - if (track && !SC.isStreamable(track)) { - return null; - } - - return { - id: trackId, - playlistId: id.toString(), - un: Date.now() - }; - }); - } - - return null; - } - - const track = getTrackEntity(id)(getState()); - - if (track && !SC.isStreamable(track)) { - return null; - } - - return { - id, - playlistId: currentPlaylist.toString(), - un: Date.now() - }; - } - ) - ); - - const flattened = _.flatten(items).filter((t): t is PlayingTrack => !!t); - - if (keepFirst) { - const [firstItem, ...rest] = flattened; - const processedRest = shuffle ? _.shuffle(rest) : rest; - - return [[firstItem, ...processedRest], flattened]; - } - - const processedItems = shuffle ? _.shuffle(flattened) : flattened; - - return [processedItems, flattened]; - }; +export function processQueueItemsO(result: Normalized.NormalizedResult[], keepFirst = false, newPlaylistId?: string) { + // return async (dispatch, getState) => { + // const { + // player: { currentPlaylistId }, + // config: { shuffle } + // } = getState(); + // if (!currentPlaylistId && !newPlaylistId) { + // return [[], []]; + // } + // const currentPlaylist = newPlaylistId || (currentPlaylistId as string); + // const items = await Promise.all( + // result + // .filter(trackIdSchema => trackIdSchema && trackIdSchema.schema !== 'users') + // .map( + // async (trackIdSchema): Promise => { + // const { id } = trackIdSchema; + // const playlist = getPlaylistEntity(id)(getState()); + // const playlistObject = getPlaylistObjectSelector({ + // objectId: id.toString(), + // playlistType: PlaylistTypes.MYTRACKS + // })(getState()); + // if (playlist) { + // if (!playlistObject) { + // dispatch(fetchPlaylistIfNeeded(id)); + // } else { + // return playlistObject.items.map((trackIdResult): PlayingTrack | null => { + // const trackId = trackIdResult.id; + // const track = getTrackEntity(id)(getState()); + // if (track && !SC.isStreamable(track)) { + // return null; + // } + // return { + // id: trackId, + // playlistId: id.toString(), + // un: Date.now() + // }; + // }); + // } + // return null; + // } + // const track = getTrackEntity(id)(getState()); + // if (track && !SC.isStreamable(track)) { + // return null; + // } + // return { + // id, + // playlistId: currentPlaylist.toString(), + // un: Date.now() + // }; + // } + // ) + // ); + // const flattened = _.flatten(items).filter((t): t is PlayingTrack => !!t); + // if (keepFirst) { + // const [firstItem, ...rest] = flattened; + // const processedRest = shuffle ? _.shuffle(rest) : rest; + // return [[firstItem, ...processedRest], flattened]; + // } + // const processedItems = shuffle ? _.shuffle(flattened) : flattened; + // return [processedItems, flattened]; + // }; } /** * Set new playlist as first or add a playlist if it doesn't exist yet */ -export function setCurrentPlaylist(playlistId: string, nextTrack: PlayingTrack | null): ThunkResult> { +export function setCurrentPlaylistO(playlistId: string, nextTrack: PlayingTrack | null): ThunkResult> { return async (dispatch, getState) => { - const state = getState(); - - const { - player: { currentPlaylistId } - } = state; - - const playlistObject = getPlaylistObjectSelector(playlistId.toString())(state); - - const containsPlaylists: PlayingPositionState[] = []; - - if (playlistObject && (nextTrack || playlistId !== currentPlaylistId)) { - const [items, originalItems] = await dispatch>( - processQueueItems(playlistObject.items, true, playlistId) - ); - - if (nextTrack && !nextTrack.id) { - await dispatch>(fetchPlaylistIfNeeded(+nextTrack.playlistId)); - } - - return dispatch>({ - type: PlayerActionTypes.SET_PLAYLIST, - payload: { - promise: Promise.resolve({ - playlistId, - items, - originalItems, - nextTrack, - containsPlaylists - }) - } - } as any); - } + // const state = getState(); + + // const { + // player: { currentPlaylistId } + // } = state; + + // const playlistObject = getPlaylistObjectSelector({ objectId: playlistId, playlistType: PlaylistTypes.MYTRACKS })( + // state + // ); + + // const containsPlaylists: PlayingPositionState[] = []; + + // if (playlistObject && (nextTrack || playlistId !== currentPlaylistId)) { + // const [items, originalItems] = await dispatch>( + // processQueueItemsO(playlistObject.items, true, playlistId) + // ); + + // if (nextTrack && !nextTrack.id) { + // await dispatch>(fetchPlaylistIfNeeded(+nextTrack.playlistId)); + // } + + // return dispatch>({ + // type: PlayerActionTypes.SET_PLAYLIST, + // payload: { + // promise: Promise.resolve({ + // playlistId, + // items, + // originalItems, + // nextTrack, + // containsPlaylists + // }) + // } + // } as any); + // } return Promise.resolve(); }; @@ -383,7 +326,7 @@ interface Next { playlistId?: string; } -export function playTrack( +export function playTrackO( playlistId: string, next?: Next, forceSetPlaylist = false, @@ -391,270 +334,154 @@ export function playTrack( ): ThunkResult { // tslint:disable-next-line: max-func-body-length cyclomatic-complexity return async (dispatch, getState) => { - const { - player: { currentPlaylistId } - } = getState(); - - let nextTrack: PlayingTrack = next as PlayingTrack; - - if (!next) { - const object = getPlaylistObjectSelector(playlistId)(getState()); - - if (object) { - // tslint:disable-next-line: no-parameter-reassignment - nextTrack = { - playlistId: playlistId.toString(), - id: object.items[0].id, - un: Date.now() - }; - } - } else if (!next.playlistId) { - nextTrack.playlistId = playlistId.toString(); - } - - /** - * If playlist isn't current, set current & add items to queue - */ - - if (currentPlaylistId !== playlistId || forceSetPlaylist) { - await dispatch>(setCurrentPlaylist(playlistId, forceSetPlaylist && nextTrack ? nextTrack : null)); - } - - const state = getState(); - - const { - player: { queue } - } = state; - - let position = getCurrentPosition({ queue, playingTrack: nextTrack }); - - if (position !== -1) { - dispatch(getItemsAround(position)); - } - - // We know the id, just set the track - if (nextTrack.id) { - const trackPlaylistObject = getPlaylistObjectSelector(playlistId)(state); - - if (trackPlaylistObject && position + 10 >= queue.length && trackPlaylistObject.nextUrl) { - await dispatch>(fetchMore(playlistId, ObjectTypes.PLAYLISTS)); - } - - dispatch(setPlayingTrack(nextTrack, position, changeType)); - - // No id is given, this means we want to play a playlist - } else if (!nextTrack.id) { - const trackPlaylistObject = getPlaylistObjectSelector(nextTrack.playlistId)(state); - const playlistEntitity = getPlaylistEntity(+nextTrack.playlistId)(state); - - if (!trackPlaylistObject) { - if (playlistEntitity && playlistEntitity.track_count > 0) { - await dispatch>(getPlaylistObject(nextTrack.playlistId, 0)); - - const { player } = getState(); - - const playlistObject = getPlaylistObjectSelector(nextTrack.playlistId)(getState()); - - if (playlistObject) { - const { - items: [firstItem] - } = playlistObject; - - nextTrack.id = firstItem.id; - - dispatch( - setPlayingTrack( - nextTrack, - getCurrentPosition({ queue: player.queue, playingTrack: nextTrack }), - changeType - ) - ); - } - } - } else { - const { - items: [firstItem] - } = trackPlaylistObject; - - if ( - playlistEntitity && - !trackPlaylistObject.isFetching && - !trackPlaylistObject.items.length && - playlistEntitity.track_count !== 0 - ) { - throw new Error('This playlist is empty or not available via a third party!'); - } else if (trackPlaylistObject.items.length) { - // If queue doesn't contain playlist yet - - if (forceSetPlaylist) { - nextTrack.id = firstItem.id; - } - - position = getCurrentPosition({ queue, playingTrack: nextTrack }); - - dispatch(setPlayingTrack(nextTrack, position, changeType)); - } - } - } + // const { + // player: { currentPlaylistId } + // } = getState(); + // let nextTrack: PlayingTrack = next as PlayingTrack; + // if (!next) { + // const object = getPlaylistObjectSelector({ objectId: playlistId, playlistType: PlaylistTypes.MYTRACKS })( + // getState() + // ); + // if (object) { + // // tslint:disable-next-line: no-parameter-reassignment + // nextTrack = { + // playlistId: playlistId.toString(), + // id: object.items[0].id, + // un: Date.now() + // }; + // } + // } else if (!next.playlistId) { + // nextTrack.playlistId = playlistId.toString(); + // } + // /** + // * If playlist isn't current, set current & add items to queue + // */ + // if (currentPlaylistId !== playlistId || forceSetPlaylist) { + // await dispatch>(setCurrentPlaylistO(playlistId, forceSetPlaylist && nextTrack ? nextTrack : null)); + // } + // const state = getState(); + // const { + // player: { queue } + // } = state; + // let position = getCurrentPosition({ queue, playingTrack: nextTrack }); + // if (position !== -1) { + // dispatch(getItemsAroundO(position)); + // } + // // We know the id, just set the track + // if (nextTrack.id) { + // const trackPlaylistObject = getPlaylistObjectSelector({ + // objectId: playlistId, + // playlistType: PlaylistTypes.MYTRACKS + // })(state); + // if (trackPlaylistObject && position + 10 >= queue.length && trackPlaylistObject.nextUrl) { + // await dispatch>(fetchMore(playlistId, ObjectTypes.PLAYLISTS)); + // } + // dispatch(setPlayingTrackO(nextTrack, position, changeType)); + // // No id is given, this means we want to play a playlist + // } else if (!nextTrack.id) { + // const trackPlaylistObject = getPlaylistObjectSelector({ + // objectId: nextTrack.playlistId, + // playlistType: PlaylistTypes.MYTRACKS + // })(state); + // const playlistEntitity = getPlaylistEntity(+nextTrack.playlistId)(state); + // if (!trackPlaylistObject) { + // if (playlistEntitity && playlistEntitity.track_count > 0) { + // await dispatch>(getPlaylistObjectO(nextTrack.playlistId, 0)); + // const { player } = getState(); + // const playlistObject = getPlaylistObjectSelector({ + // objectId: nextTrack.playlistId, + // playlistType: PlaylistTypes.MYTRACKS + // })(getState()); + // if (playlistObject) { + // const { + // items: [firstItem] + // } = playlistObject; + // nextTrack.id = firstItem.id; + // dispatch( + // setPlayingTrackO( + // nextTrack, + // getCurrentPosition({ queue: player.queue, playingTrack: nextTrack }), + // changeType + // ) + // ); + // } + // } + // } else { + // const { + // items: [firstItem] + // } = trackPlaylistObject; + // if ( + // playlistEntitity && + // !trackPlaylistObject.isFetching && + // !trackPlaylistObject.items.length && + // playlistEntitity.track_count !== 0 + // ) { + // throw new Error('This playlist is empty or not available via a third party!'); + // } else if (trackPlaylistObject.items.length) { + // // If queue doesn't contain playlist yet + // if (forceSetPlaylist) { + // nextTrack.id = firstItem.id; + // } + // position = getCurrentPosition({ queue, playingTrack: nextTrack }); + // dispatch(setPlayingTrackO(nextTrack, position, changeType)); + // } + // } + // } }; } -export function toggleStatus(newToggleStatus?: PlayerStatus): ThunkResult { - return (dispatch, getState) => { - const state = getState(); - const { - player: { status, currentPlaylistId } - } = state; - - let newStatus = newToggleStatus; - - const streamPlaylist = getPlaylistObjectSelector(PlaylistTypes.STREAM)(state); - - if (streamPlaylist && currentPlaylistId === null && newStatus === PlayerStatus.PLAYING) { - const first = streamPlaylist.items[0]; - - let next: Partial = { id: first.id }; - - if (first.schema === 'playlists') { - next = { playlistId: first.id.toString() }; - } - - dispatch(playTrack(PlaylistTypes.STREAM, next as PlayingTrack, true)); - } - - if (!newStatus) { - newStatus = PlayerStatus.PLAYING === status ? PlayerStatus.PAUSED : PlayerStatus.PLAYING; - } - - dispatch({ - type: PlayerActionTypes.TOGGLE_PLAYING, - payload: { - status: newStatus - } - }); - }; -} - -export function changeTrack(changeType: ChangeTypes, finished?: boolean): ThunkResult { - return (dispatch, getState) => { - const { - player, - config: { repeat } - } = getState(); - - const { currentPlaylistId, queue, currentIndex, currentTime } = player; - - if (!currentPlaylistId) { - return; - } - - const currentPlaylistObject = getPlaylistObjectSelector(currentPlaylistId)(getState()); - - let nextIndex = currentIndex; - - switch (changeType) { - case ChangeTypes.NEXT: - nextIndex = currentIndex + 1; - break; - case ChangeTypes.PREV: - if (currentTime < 5) { - nextIndex = currentIndex - 1; - } - break; - default: - } - - if (finished && repeat === RepeatTypes.ONE) { - nextIndex = currentIndex; - } - - // If last song - if ((nextIndex === queue.length && currentPlaylistObject && !currentPlaylistObject.nextUrl) || nextIndex === -1) { - if (repeat === null) { - dispatch(toggleStatus(PlayerStatus.PAUSED)); - - return; - } - - if (repeat === RepeatTypes.ALL) { - nextIndex = 0; - } - } - - if (nextIndex > queue.length - 1) { - return; - } - - if (nextIndex < 0) { - nextIndex = 0; - } - - const nextTrack = queue[nextIndex]; - - if (nextTrack) { - dispatch(playTrack(currentPlaylistId, nextTrack, false, changeType)); - } - }; -} /** * Add up next feature */ -export function addUpNext( +export function addUpNextO( track: SoundCloud.Track | SoundCloud.Playlist | Normalized.Playlist | Normalized.Track, remove?: number ): ThunkResult { return (dispatch, getState) => { - const { - player: { queue, currentPlaylistId, playingTrack } - } = getState(); - - const isPlaylist = track.kind === 'playlist'; - - const nextTrack = { - id: track.id, - playlistId: currentPlaylistId, - un: Date.now() - }; - - let nextList: PlayingTrack[] = []; - - if (isPlaylist) { - const playlist = track as SoundCloud.Playlist; - const { tracks = [] } = playlist; - - nextList = tracks - .map((t): PlayingTrack | null => { - if (!SC.isStreamable(t)) { - return null; - } - - return { - id: t.id, - playlistId: track.id.toString(), - un: Date.now() - }; - }) - .filter(t => t) as PlayingTrack[]; - } - - if (queue.length) { - if (remove === undefined) { - dispatch( - addToast({ - message: `Added ${isPlaylist ? 'playlist' : 'track'} to play queue`, - intent: Intent.SUCCESS - }) - ); - } - dispatch({ - type: PlayerActionTypes.ADD_UP_NEXT, - payload: { - next: isPlaylist ? nextList : [nextTrack], - remove, - position: getCurrentPosition({ queue, playingTrack }), - playlist: isPlaylist - } - }); - } + // const { + // player: { queue, currentPlaylistId, playingTrack } + // } = getState(); + // const isPlaylist = track.kind === 'playlist'; + // const nextTrack = { + // id: track.id, + // playlistId: currentPlaylistId, + // un: Date.now() + // }; + // let nextList: PlayingTrack[] = []; + // if (isPlaylist) { + // const playlist = track as SoundCloud.Playlist; + // const { tracks = [] } = playlist; + // nextList = tracks + // .map((t): PlayingTrack | null => { + // if (!SC.isStreamable(t)) { + // return null; + // } + // return { + // id: t.id, + // playlistId: track.id.toString(), + // un: Date.now() + // }; + // }) + // .filter(t => t) as PlayingTrack[]; + // } + // if (queue.length) { + // if (remove === undefined) { + // dispatch( + // addToast({ + // message: `Added ${isPlaylist ? 'playlist' : 'track'} to play queue`, + // intent: Intent.SUCCESS + // }) + // ); + // } + // dispatch({ + // type: PlayerActionTypes.ADD_UP_NEXT, + // payload: { + // next: isPlaylist ? nextList : [nextTrack], + // remove, + // position: getCurrentPosition({ queue, playingTrack }), + // playlist: isPlaylist + // } + // }); + // } }; } diff --git a/src/common/store/player/epics.ts b/src/common/store/player/epics.ts index 71ac7283..7b06093d 100644 --- a/src/common/store/player/epics.ts +++ b/src/common/store/player/epics.ts @@ -1,12 +1,667 @@ -import { Epic } from 'redux-observable'; -import { filter, map } from 'rxjs/operators'; +import { EpicError } from '@common/utils/errors/EpicError'; +import { Logger } from '@main/utils/logger'; +import { Normalized } from '@types'; +import { StoreState } from 'AppReduxTypes'; +import { AxiosError } from 'axios'; +import _ from 'lodash'; +import { StateObservable } from 'redux-observable'; +import { concat, empty, merge, of, throwError, iif } from 'rxjs'; +import { + catchError, + filter, + ignoreElements, + map, + mergeMap, + pluck, + startWith, + switchMap, + take, + takeUntil, + tap, + withLatestFrom +} from 'rxjs/operators'; import { isActionOf } from 'typesafe-actions'; -import { setConfigKey } from '../config/actions'; -import { toggleShuffle } from './actions'; +import { + changeTrack, + genericPlaylistFetchMore, + getPlaylistTracks, + getTrack, + playlistFinished, + playPlaylist, + playTrack, + queueInsert, + restartTrack, + setConfigKey, + setCurrentPlaylist, + startPlayMusic, + startPlayMusicIndex, + toggleShuffle, + toggleStatus, + trackFinished +} from '../actions'; +import { RootEpic } from '../declarations'; +import { + configSelector, + getCurrentPlaylistId, + getPlayerCurrentTime, + getPlayerUpNext, + getPlayingTrack, + getPlayingTrackIndex, + getPlaylistObjectSelector, + getQueuePlaylistSelector, + getQueueTrackByIndexSelector, + getTrackEntity, + shuffleSelector, + getPlaylistsObjects +} from '../selectors'; +import { ChangeTypes, ObjectStateItem, PlayerStatus, PlaylistTypes, RepeatTypes, PlaylistIdentifier } from '../types'; +import { setCurrentIndex, shuffleQueue, addUpNext } from './actions'; -export const toggleShuffleEpic: Epic = action$ => +const logger = Logger.createLogger('REDUX/PLAYER'); + +const handleEpicError = (error: any) => { + if ((error as AxiosError).isAxiosError) { + logger.error(error.message); + } + logger.error(error); + // TODO Sentry? + return error; +}; + +export const startPlayMusicEpic: RootEpic = (action$, state$) => + // @ts-ignore + action$.pipe( + filter(isActionOf(startPlayMusic)), + pluck('payload'), + withLatestFrom(state$), + map(([payload, state]) => ({ + payload, + currentPlaylistId: getCurrentPlaylistId(state), + shuffle: shuffleSelector(state) + })), + switchMap(({ payload, currentPlaylistId, shuffle }) => { + const origin = payload.origin ?? currentPlaylistId; + + if (!origin) { + return throwError(new EpicError('Unable to play music, no playlist found')); + } + + return concat( + // If origin is set, set playlist + of(payload).pipe( + pluck('origin'), + filter(Boolean), + mergeMap(() => + merge( + // Set current playlist + of(setCurrentPlaylist.request({ playlistId: origin })), + + action$.pipe( + // Wait for playlist to be set + filter(isActionOf(setCurrentPlaylist.success)), + take(1), + takeUntil(action$.pipe(filter(isActionOf(setCurrentPlaylist.failure)))) + ) + ) + ) + ), + + iif( + () => !!payload.idResult, + of(payload.idResult).pipe( + filter((idResult): idResult is ObjectStateItem => !!idResult), + mergeMap(idResult => + concat( + // If set, shuffle the playlist + of(shuffle).pipe( + filter(Boolean), + map(() => state$.value), + map(lastestState => ({ + idResult, + queueObject: getQueuePlaylistSelector(lastestState) + })), + map(({ queueObject, idResult: { id, un } }) => { + const currentTrackIndex = _.findIndex( + queueObject.items, + item => item.id === id && ((!!item.un && !!un && item.un === un) || (!item.un && !un)) + ); + + return shuffleQueue({ fromIndex: currentTrackIndex + 1 }); + }) + ), + + // Execute sub-action according to type of track or playlist + // If it is a playlist we do playPlaylist, to try and fetch tracks, after this, we will do playTrack. + // If it is a track, we just do playTrack. + merge( + of(payload).pipe( + filter(() => idResult.schema === 'playlists'), + map(() => + playPlaylist({ + idResult, + origin, + changeType: payload.changeType, + nextPosition: payload.nextPosition + }) + ) + ), + of(payload).pipe( + filter(() => idResult?.schema === 'tracks'), + map(() => + playTrack.request({ + idResult, + origin, + nextPosition: payload.nextPosition + }) + ) + ) + ) + ) + ) + ), + // If we did not pass idSchema, start first track + of(payload.idResult).pipe( + map(() => state$.value), + map(getQueuePlaylistSelector), + pluck('items'), + filter(items => items.length > 0), + map(items => items[0]), + map(idResult => playTrack.request({ idResult, origin })) + ) + ) + ); + }), + catchError(error => + of( + playTrack.failure({ + error: handleEpicError(error) + }) + ) + ) + ); + +export const playTrackEpic: RootEpic = (action$, state$) => + // @ts-ignore + action$.pipe( + filter(isActionOf(playTrack.request)), + pluck('payload'), + switchMap(({ idResult, origin, nextPosition }) => { + const { id, un, parentPlaylistID } = idResult; + const currentPlaylistID: PlaylistIdentifier = parentPlaylistID ?? { playlistType: PlaylistTypes.QUEUE }; + + return concat( + // Check if track is fetched, otherwise fetch it + // If the track is fetchable in the playlist, just fetch newer tracks. Otherwise just manually fetch it + of({}).pipe( + withLatestFrom(state$), + map(([, latestState]) => { + const playlistContainingTrack = getPlaylistObjectSelector(currentPlaylistID)(latestState); + const track = getTrackEntity(id)(latestState); + + const toFetch = !!playlistContainingTrack?.itemsToFetch?.some(i => i.id === id && i.schema === 'tracks'); + + return { track, toFetch, playlistContainingTrack }; + }), + // TODO: should we check if the track is fetched? + filter(({ track, toFetch }) => !track || toFetch), + tap(() => + logger.trace('playTrackEpic:: Track could not be found', { + id, + origin, + currentPlaylistIdentifier: currentPlaylistID + }) + ), + mergeMap(({ toFetch }) => { + if (toFetch) { + return action$.pipe( + filter(isActionOf(genericPlaylistFetchMore.success)), + pluck('payload'), + filter(({ playlistType, objectId }) => _.isEqual(currentPlaylistID, { playlistType, objectId })), + take(1), + takeUntil(action$.pipe(filter(isActionOf(genericPlaylistFetchMore.failure)))), + ignoreElements(), + tap(() => + logger.trace('playTrackEpic:: Using generic fetch more to fetch track', { + id, + currentPlaylistIdentifier: currentPlaylistID + }) + ), + startWith(genericPlaylistFetchMore.request(currentPlaylistID)) + ); + } + + return action$.pipe( + filter(isActionOf(getTrack.success)), + pluck('payload', 'trackId'), + filter(trackId => trackId === id), + take(1), + takeUntil(action$.pipe(filter(isActionOf(getTrack.failure)))), + ignoreElements(), + tap(() => + logger.trace('playTrackEpic:: Using getTrack to fetch track', { + id, + currentPlaylistIdentifier: currentPlaylistID + }) + ), + startWith(getTrack.request({ trackId: id, refresh: false })) + ); + }) + ), + + // If the tracks exists, start playing + // TODO: should we check if it is streamable?? + of({}).pipe( + withLatestFrom(state$), + map(([, latestState]) => ({ + track: getTrackEntity(id)(latestState), + queueObject: getQueuePlaylistSelector(latestState) + })), + mergeMap(({ track, queueObject }) => { + const position = + nextPosition != null + ? nextPosition + : _.findIndex( + queueObject.items, + item => item.id === id && ((!!item.un && !!un && item.un === un) || (!item.un && !un)) + ); + + // TODO: what if position is -1? + if (position === -1) { + // TODO: what if it does not exist? + logger.error('playTrackEpic:: Track not found in queue', { + queueObject, + id + }); + } + + return of( + playTrack.success({ + idResult, + origin, + parentPlaylistID, + duration: (track?.duration ?? 0) / 1000, + position + }) + ); + }) + ) + ); + }), + catchError(error => + of( + playTrack.failure({ + error: handleEpicError(error) + }) + ) + ) + ); + +export const playPlaylistEpic: RootEpic = (action$, state$) => + // @ts-ignore + action$.pipe( + filter(isActionOf(playPlaylist)), + pluck('payload'), + switchMap(({ idResult, origin, changeType, nextPosition }) => { + const { id } = idResult; + const currentPlaylistIdentifier = { objectId: id.toString(), playlistType: PlaylistTypes.PLAYLIST }; + + return concat( + // Get tracks and wait for it to finish + action$.pipe( + filter(isActionOf(getPlaylistTracks.success)), + pluck('payload'), + filter(payload => _.isEqual(currentPlaylistIdentifier, payload)), + take(1), + takeUntil(action$.pipe(filter(isActionOf(getPlaylistTracks.failure)))), + ignoreElements(), + startWith(getPlaylistTracks.request(currentPlaylistIdentifier)) + ), + + // Get items, start playing first or last depending on changeType + of(currentPlaylistIdentifier).pipe( + withLatestFrom(state$), + mergeMap(([, latestState]) => { + const playlist = getPlaylistObjectSelector(currentPlaylistIdentifier)(latestState); + + if (!playlist?.items?.length) { + // TODO: we cannot play this playlist, dispatch notification? + logger.trace('playPlaylistEpic:: Playlist does not have any items', { id, playlist }); + return empty(); + } + const lastIndex = playlist?.items.length - 1; + + const { 0: firstItem, [lastIndex]: lastItem } = playlist?.items; + + const nextItem = changeType === ChangeTypes.PREV ? lastItem : firstItem; + + return of( + playTrack.request({ + idResult: { ...nextItem, parentPlaylistID: currentPlaylistIdentifier }, + origin: currentPlaylistIdentifier, + nextPosition + }) + ); + }) + ) + ); + }), + catchError(error => + of( + playTrack.failure({ + error: handleEpicError(error) + }) + ) + ) + ); + +export const changeTrackEpic: RootEpic = (action$, state$) => + // @ts-ignore + action$.pipe( + filter(isActionOf(changeTrack)), + pluck('payload'), + withLatestFrom(state$), + switchMap(([{ changeType }, state]) => { + const playingTrackIndex = getPlayingTrackIndex(state); + const queue = getQueuePlaylistSelector(state); + const currentTime = getPlayerCurrentTime(state); + const upNext = getPlayerUpNext(state); + + let nextPosition = playingTrackIndex; + + if (changeType === ChangeTypes.NEXT) { + // Increase index if we are not repeating + nextPosition += 1; + } else if (changeType === ChangeTypes.PREV && currentTime < 4) { + // If currentTime lower than 4 seconds, play previous track + nextPosition -= 1; + } else { + // If PREV and more than 4 seconds have played, restart track + return of(restartTrack()); + } + + if (nextPosition < 0) nextPosition = 0; + + // If playlist is finished and not able to fetch more + if (!upNext.length && nextPosition === queue.items.length && !queue.itemsToFetch.length && !queue.nextUrl) { + return of(playlistFinished()); + } + + return concat( + // If there are items in upNext, we add the first one to our queue + of(upNext).pipe( + filter(items => items.length > 0), + map(({ 0: firstItem }) => firstItem), + map(firstItem => queueInsert({ items: [firstItem], position: nextPosition })) + ), + of(startPlayMusicIndex({ index: nextPosition, changeType })) + ); + }), + catchError(error => + of( + playTrack.failure({ + error: handleEpicError(error) + }) + ) + ) + ); + +export const playlistFinishedEpic: RootEpic = (action$, state$) => + action$.pipe( + filter(isActionOf(playlistFinished)), + withLatestFrom(state$), + switchMap(([, state]) => { + const { repeat } = configSelector(state); + + if (repeat == null) { + return of(toggleStatus(PlayerStatus.PAUSED)); + } + + if (repeat === RepeatTypes.ALL) { + return of(startPlayMusicIndex({ index: 0 })); + } + + return ignoreElements(); + }) + ); + +export const trackFinishedEpic: RootEpic = (action$, state$) => + action$.pipe( + filter(isActionOf(trackFinished)), + withLatestFrom(state$), + switchMap(([, state]) => { + const { repeat } = configSelector(state); + + const shouldRepeat = repeat === RepeatTypes.ONE; + + if (shouldRepeat) { + return of(restartTrack()); + } + + return of(changeTrack(ChangeTypes.NEXT)); + }) + ); + +export const startPlayMusicIndexEpic: RootEpic = (action$, state$) => + // @ts-ignore + action$.pipe( + filter(isActionOf(startPlayMusicIndex)), + pluck('payload'), + switchMap(({ index, changeType }) => { + return concat( + of(index).pipe( + withLatestFrom(state$), + map(([, latestState]) => getQueueTrackByIndexSelector(index)(latestState)), + tap(nextTrack => logger.trace({ nextTrack })), + filter(nextTrack => !nextTrack), + tap(() => logger.trace('startPlayMusicIndexEpic:: Track does not exist', { index })), + mergeMap(() => + action$.pipe( + filter(isActionOf(genericPlaylistFetchMore.success)), + pluck('payload'), + filter(({ playlistType }) => playlistType === PlaylistTypes.QUEUE), + take(1), + takeUntil(action$.pipe(filter(isActionOf(genericPlaylistFetchMore.failure)))), + ignoreElements(), + tap(() => logger.trace('startPlayMusicIndexEpic:: Using generic fetch more to fetch track')), + startWith(genericPlaylistFetchMore.request({ playlistType: PlaylistTypes.QUEUE })) + ) + ) + ), + of(index).pipe( + withLatestFrom(state$), + map(([, latestState]) => getQueueTrackByIndexSelector(index)(latestState)), + mergeMap(nextTrack => { + if (!nextTrack) { + // TODO: what if it still does not exist + logger.trace('startPlayMusicIndexEpic:: Track still does not exist', { index }); + + return ignoreElements(); + } + + return of( + startPlayMusic({ + idResult: nextTrack, + changeType, + nextPosition: index + }) + ); + }) + ) + ); + }), + catchError(error => + of( + playTrack.failure({ + error: handleEpicError(error) + }) + ) + ) + ); + +export const setCurrentPlaylistEpic: RootEpic = (action$, state$) => + // @ts-ignore + action$.pipe( + filter(isActionOf(setCurrentPlaylist.request)), + pluck('payload'), + withLatestFrom(state$), + map(([{ playlistId }, latestState]) => ({ + playlistId, + playlistObject: getPlaylistObjectSelector(playlistId)(latestState), + playlists: getPlaylistsObjects(latestState) + })), + filter(({ playlistObject }) => !!playlistObject), + map(({ playlistId, playlistObject, playlists }) => { + // Replace playlists with their items + const items = (playlistObject?.items ?? []).reduce((all, item) => { + if (item.schema === 'playlists') { + const playlistExists = playlists[item.id]; + + if (playlistExists) { + all.push( + ...[...playlistExists.items, ...playlistExists.itemsToFetch].map( + (i): ObjectStateItem => ({ + ...i, + parentPlaylistID: { + objectId: item.id.toString(), + playlistType: PlaylistTypes.PLAYLIST + }, + un: item.un + }) + ) + ); + } else { + all.push(item); + } + } else { + all.push(item); + } + + return all; + }, []); + + return setCurrentPlaylist.success({ items, playlistId }); + }), + catchError(error => + of( + setCurrentPlaylist.failure({ + error: handleEpicError(error) + }) + ) + ) + ); + +export const addUpNextEpic: RootEpic = (action$, state$) => + // @ts-ignore + action$.pipe( + filter(isActionOf(addUpNext.request)), + pluck('payload'), + withLatestFrom(state$), + map(([itemToAdd, latestState]) => ({ + itemToAdd, + playlists: getPlaylistsObjects(latestState) + })), + map(({ itemToAdd, playlists }) => { + // Replace playlists with their items + const items = [itemToAdd].reduce((all, item) => { + if (item.schema === 'playlists') { + const playlistExists = playlists[item.id]; + + if (playlistExists) { + all.push( + ...[...playlistExists.items, ...playlistExists.itemsToFetch].map( + (i): ObjectStateItem => ({ + ...i, + parentPlaylistID: { + objectId: item.id.toString(), + playlistType: PlaylistTypes.PLAYLIST + }, + un: item.un + }) + ) + ); + } else { + all.push(item); + } + } else { + all.push(item); + } + + return all; + }, []); + + return addUpNext.success({ items }); + }), + catchError(error => + of( + addUpNext.failure({ + error: handleEpicError(error) + }) + ) + ) + ); + +// Toggle shuffle +export const toggleShuffleEpic: RootEpic = (action$, state$) => + // @ts-ignore action$.pipe( filter(isActionOf(toggleShuffle)), - map(action => action.payload), - map(shuffle => setConfigKey('shuffle', shuffle)) + pluck('payload'), + switchMap(shuffle => + merge( + of(setConfigKey('shuffle', shuffle)), + // If shuffle, shuffle queue starting from index + of(shuffle).pipe( + filter(Boolean), + map(() => state$.value), + map(getPlayingTrackIndex), + map(fromIndex => ({ fromIndex: fromIndex + 1 })), + map(shuffleQueue) + ), + of(shuffle).pipe( + filter(isShuffling => !isShuffling), + map(() => state$.value), + map(getCurrentPlaylistId), + filter((currentPlaylistID): currentPlaylistID is PlaylistIdentifier => !!currentPlaylistID), + mergeMap(currentPlaylistID => + concat( + merge( + of(setCurrentPlaylist.request({ playlistId: currentPlaylistID })), + action$.pipe( + filter(isActionOf(setCurrentPlaylist.success)), + pluck('payload'), + take(1), + takeUntil(action$.pipe(filter(isActionOf(setCurrentPlaylist.failure)))), + ignoreElements() + ) + ), + recalculateCurrentIndex(state$) + ) + ) + ) + ) + ) + ); + +const recalculateCurrentIndex = (state$: StateObservable) => + of(state$.value).pipe( + map(state => { + const currentPlaylistID = getCurrentPlaylistId(state); + return { + currentPlaylist: currentPlaylistID ? getPlaylistObjectSelector(currentPlaylistID)(state) : null, + playingTrack: getPlayingTrack(state) + }; + }), + filter(({ playingTrack, currentPlaylist }) => !!playingTrack && !!currentPlaylist), + map(({ playingTrack, currentPlaylist }) => { + const id = playingTrack?.parentPlaylistID?.id ?? playingTrack?.id; + const un = playingTrack?.parentPlaylistID?.un ?? playingTrack?.un; + const currentTrackIndex = _.findIndex( + currentPlaylist?.items ?? [], + item => item.id === id && ((!!item.un && !!un && item.un === un) || (!item.un && !un)) + ); + + return currentTrackIndex; + }), + filter(currentTrackIndex => currentTrackIndex !== -1), + map(currentTrackIndex => setCurrentIndex({ position: currentTrackIndex })) ); diff --git a/src/common/store/player/reducer.ts b/src/common/store/player/reducer.ts index 53e9f4f1..b8c1bb0d 100755 --- a/src/common/store/player/reducer.ts +++ b/src/common/store/player/reducer.ts @@ -1,218 +1,303 @@ -import _ from 'lodash'; -import { Reducer } from 'redux'; -import { onSuccess } from '../../utils/reduxUtils'; -import { AppActionTypes } from '../app/types'; -import { ObjectsActionTypes } from '../objects/types'; -import { PlayerActionTypes, PlayerState, PlayerStatus } from './types'; - -const initialState = { +import { pick } from 'lodash'; +import { createReducer } from 'typesafe-actions'; +import { playTrack, resetStore, restartTrack, setCurrentPlaylist, setCurrentTime, toggleStatus } from '../actions'; +import { PlayerState, PlayerStatus } from '../types'; +import { addUpNext, queueInsert, setCurrentIndex } from './actions'; + +const initialState: PlayerState = { status: PlayerStatus.STOPPED, - queue: [], - originalQueue: [], - playingTrack: null, currentPlaylistId: null, - currentIndex: 0, + playingTrack: null, currentTime: 0, duration: 0, - upNext: { - start: 0, - length: 0 - }, - containsPlaylists: [] + currentIndex: 0, + upNext: [] + // upNext: { + // start: 0, + // length: 0 + // }, + // containsPlaylists: [] }; +export const playerReducer = createReducer(initialState) + .handleAction(setCurrentPlaylist.success, (state, action) => { + const { payload } = action; + return { + ...state, + currentPlaylistId: payload.playlistId + }; + }) + .handleAction(playTrack.success, (state, { payload }) => { + const { idResult, origin, parentPlaylistID, duration = 0, position, positionInPlaylist } = payload; + return { + ...state, + playingTrack: { + ...pick(idResult, ['id', 'un']), + playlistId: origin, + parentPlaylistID + }, + status: PlayerStatus.PLAYING, + duration, + currentTime: 0, + currentIndex: position, + currentIndexInPlaylist: positionInPlaylist != null ? positionInPlaylist : undefined + }; + }) + .handleAction(toggleStatus, (state, action) => { + const { payload } = action; + + let status = payload; + + if (!status) { + status = state.status === PlayerStatus.PLAYING ? PlayerStatus.PAUSED : PlayerStatus.PLAYING; + } + + return { + ...state, + status + }; + }) + // TODO: do we still need this? + // .handleAction(setDuration, (state, action) => { + // const { payload } = action; + + // return { + // ...state, + // duration: payload + // }; + // }) + .handleAction(setCurrentTime, (state, action) => { + const { payload } = action; + + return { + ...state, + currentTime: payload + }; + }) + .handleAction(restartTrack, state => { + return { + ...state, + currentTime: 0 + }; + }) + .handleAction(addUpNext.success, (state, action) => { + const { + payload: { items = [] } + } = action; + + return { + ...state, + upNext: [...state.upNext, ...items.map(item => ({ ...item, un: Date.now() }))] + }; + }) + .handleAction(queueInsert, state => { + return { + ...state, + upNext: [...state.upNext.splice(1)] + }; + }) + .handleAction(setCurrentIndex, (state, { payload }) => { + const { position } = payload; + return { + ...state, + currentIndex: position + }; + }) + .handleAction(resetStore, () => { + return initialState; + }); // tslint:disable-next-line: max-func-body-length cyclomatic-complexity -export const playerReducer: Reducer = (state = initialState, action) => { - const { payload, type } = action; - - switch (type) { - case PlayerActionTypes.SET_TRACK: - // eslint-disable-next-line no-case-declarations - const position = _.findIndex(state.queue, payload.nextTrack); - - // eslint-disable-next-line no-case-declarations - const newState = { - ...state, - playingTrack: payload.nextTrack, - status: payload.status, - currentTime: 0, - currentIndex: payload.position - }; - - if (!payload.repeat) { - newState.duration = 0; - } - - if (position === state.upNext.start) { - newState.upNext = { - start: state.upNext.length >= 1 ? state.upNext.start + 1 : 0, - length: state.upNext.length >= 1 ? state.upNext.length - 1 : 0 - }; - } - - return newState; - case PlayerActionTypes.SET_TIME: - return { - ...state, - currentTime: payload.time - }; - case PlayerActionTypes.UPDATE_TIME: - return { - ...state, - currentTime: payload.time >= 0 && payload.time < state.duration ? payload.time : state.currentTime - }; - case PlayerActionTypes.SET_DURATION: - return { - ...state, - duration: payload.time - }; - case PlayerActionTypes.TOGGLE_PLAYING: - if (payload.status === PlayerStatus.STOPPED) { - return { - ...state, - status: payload.status, - playingTrack: null, - currentTime: 0, - duration: 0, - currentPlaylistId: null - }; - } - - return { - ...state, - status: payload.status - }; - case onSuccess(PlayerActionTypes.SET_PLAYLIST): - case PlayerActionTypes.SET_PLAYLIST: - // eslint-disable-next-line no-case-declarations - const nextTrackPosition = _.findIndex(payload.items, payload.nextTrack); - - if (nextTrackPosition !== -1 && state.upNext.length > 0) { - return { - ...state, - currentPlaylistId: payload.playlistId, - queue: [ - ...payload.items.slice(0, nextTrackPosition + 1), - ...state.queue.slice(state.upNext.start, state.upNext.start + state.upNext.length), - ...payload.items.slice(nextTrackPosition + 1) - ], - originalQueue: payload.originalItems, - upNext: { - ...state.upNext, - start: nextTrackPosition + 1 - } - }; - } - - return { - ...state, - currentPlaylistId: payload.playlistId, - queue: payload.items, - originalQueue: payload.originalItems, - containsPlaylists: payload.containsPlaylists - }; - case onSuccess(PlayerActionTypes.QUEUE_INSERT): - case PlayerActionTypes.QUEUE_INSERT: - return { - ...state, - queue: [ - ...state.queue.slice(0, payload.index), - ...payload.items, - ...state.queue.slice((payload.index as number) + 1) - ], - originalQueue: [...state.originalQueue, ...payload.items] - }; - case PlayerActionTypes.ADD_UP_NEXT: - if (!_.isNil(payload.remove)) { - const removeInUpNext = - payload.remove > state.upNext.start && payload.remove < state.upNext.start + state.upNext.length; - - // eslint-disable-next-line no-shadow - const newState = { - ...state, - queue: [...state.queue.slice(0, payload.remove), ...state.queue.slice((payload.remove as number) + 1)], - upNext: { - start: payload.remove < state.upNext.start ? state.upNext.start - 1 : state.upNext.start, - length: removeInUpNext ? state.upNext.length - 1 : state.upNext.length - } - }; - - if (payload.remove === newState.upNext.start) { - newState.upNext.length = newState.upNext.length >= 1 ? newState.upNext.length - 1 : 0; - } - - return newState; - } - - if (state.upNext.length !== 0) { - return { - ...state, - queue: [ - ...state.queue.slice(0, state.upNext.start + state.upNext.length), - ...payload.next, - ...state.queue.slice(state.upNext.start + state.upNext.length) - ], - upNext: { - start: state.upNext.start, - length: state.upNext.length + (payload.next.length as number) - } - }; - } - - return { - ...state, - queue: [ - ...state.queue.slice(0, (payload.position as number) + 1), - ...payload.next, - ...state.queue.slice((payload.position as number) + 1) - ], - upNext: { - start: (payload.position as number) + 1, - length: state.upNext.length + (payload.next.length as number) - } - }; - - case PlayerActionTypes.TOGGLE_SHUFFLE: - if (payload.value) { - const before = state.queue.slice(0, state.currentIndex + 1); - const after = state.queue.slice(state.currentIndex + 1); - - const items = _.shuffle(after); - - return { - ...state, - queue: [...before, ...items] - }; - } - - // eslint-disable-next-line no-case-declarations - const after = state.originalQueue.slice(state.currentIndex + 1); - - return { - ...state, - queue: [...state.queue.slice(0, state.currentIndex + 1), ...after] - }; - - case PlayerActionTypes.CLEAR_UP_NEXT: - return { - ...state, - queue: [ - ...state.queue.slice(0, state.upNext.start), - ...state.queue.slice(state.upNext.start + state.upNext.length) - ], - upNext: { - start: 0, - length: 0 - } - }; - case ObjectsActionTypes.UNSET_TRACK: - return { - ...state, - queue: [...state.queue.slice(0, payload.position), ...state.queue.slice((payload.position as number) + 1)] - }; - case AppActionTypes.RESET_STORE: - return initialState; - default: - return state; - } -}; +// export const playerReducer: Reducer = (state = initialState, action) => { +// const { payload, type } = action; + +// switch (type) { +// case PlayerActionTypes.SET_TRACK: +// // eslint-disable-next-line no-case-declarations +// const position = _.findIndex(state.queue, payload.nextTrack); + +// // eslint-disable-next-line no-case-declarations +// const newState = { +// ...state, +// playingTrack: payload.nextTrack, +// status: payload.status, +// currentTime: 0, +// currentIndex: payload.position +// }; + +// if (!payload.repeat) { +// newState.duration = 0; +// } + +// if (position === state.upNext.start) { +// newState.upNext = { +// start: state.upNext.length >= 1 ? state.upNext.start + 1 : 0, +// length: state.upNext.length >= 1 ? state.upNext.length - 1 : 0 +// }; +// } + +// return newState; +// case PlayerActionTypes.SET_TIME: +// return { +// ...state, +// currentTime: payload.time +// }; +// case PlayerActionTypes.UPDATE_TIME: +// return { +// ...state, +// currentTime: payload.time >= 0 && payload.time < state.duration ? payload.time : state.currentTime +// }; +// case PlayerActionTypes.SET_DURATION: +// return { +// ...state, +// duration: payload.time +// }; +// case PlayerActionTypes.TOGGLE_PLAYING: +// if (payload.status === PlayerStatus.STOPPED) { +// return { +// ...state, +// status: payload.status, +// playingTrack: null, +// currentTime: 0, +// duration: 0, +// currentPlaylistId: null +// }; +// } + +// return { +// ...state, +// status: payload.status +// }; +// case onSuccess(PlayerActionTypes.SET_PLAYLIST): +// case PlayerActionTypes.SET_PLAYLIST: +// // eslint-disable-next-line no-case-declarations +// const nextTrackPosition = _.findIndex(payload.items, payload.nextTrack); + +// if (nextTrackPosition !== -1 && state.upNext.length > 0) { +// return { +// ...state, +// currentPlaylistId: payload.playlistId, +// queue: [ +// ...payload.items.slice(0, nextTrackPosition + 1), +// ...state.queue.slice(state.upNext.start, state.upNext.start + state.upNext.length), +// ...payload.items.slice(nextTrackPosition + 1) +// ], +// originalQueue: payload.originalItems, +// upNext: { +// ...state.upNext, +// start: nextTrackPosition + 1 +// } +// }; +// } + +// return { +// ...state, +// currentPlaylistId: payload.playlistId, +// queue: payload.items, +// originalQueue: payload.originalItems, +// containsPlaylists: payload.containsPlaylists +// }; +// case onSuccess(PlayerActionTypes.QUEUE_INSERT): +// case PlayerActionTypes.QUEUE_INSERT: +// return { +// ...state, +// queue: [ +// ...state.queue.slice(0, payload.index), +// ...payload.items, +// ...state.queue.slice((payload.index as number) + 1) +// ], +// originalQueue: [...state.originalQueue, ...payload.items] +// }; +// case PlayerActionTypes.ADD_UP_NEXT: +// if (!_.isNil(payload.remove)) { +// const removeInUpNext = +// payload.remove > state.upNext.start && payload.remove < state.upNext.start + state.upNext.length; + +// // eslint-disable-next-line no-shadow +// const newState = { +// ...state, +// queue: [...state.queue.slice(0, payload.remove), ...state.queue.slice((payload.remove as number) + 1)], +// upNext: { +// start: payload.remove < state.upNext.start ? state.upNext.start - 1 : state.upNext.start, +// length: removeInUpNext ? state.upNext.length - 1 : state.upNext.length +// } +// }; + +// if (payload.remove === newState.upNext.start) { +// newState.upNext.length = newState.upNext.length >= 1 ? newState.upNext.length - 1 : 0; +// } + +// return newState; +// } + +// if (state.upNext.length !== 0) { +// return { +// ...state, +// queue: [ +// ...state.queue.slice(0, state.upNext.start + state.upNext.length), +// ...payload.next, +// ...state.queue.slice(state.upNext.start + state.upNext.length) +// ], +// upNext: { +// start: state.upNext.start, +// length: state.upNext.length + (payload.next.length as number) +// } +// }; +// } + +// return { +// ...state, +// queue: [ +// ...state.queue.slice(0, (payload.position as number) + 1), +// ...payload.next, +// ...state.queue.slice((payload.position as number) + 1) +// ], +// upNext: { +// start: (payload.position as number) + 1, +// length: state.upNext.length + (payload.next.length as number) +// } +// }; + +// case PlayerActionTypes.TOGGLE_SHUFFLE: +// if (payload.value) { +// const before = state.queue.slice(0, state.currentIndex + 1); +// const after = state.queue.slice(state.currentIndex + 1); + +// const items = _.shuffle(after); + +// return { +// ...state, +// queue: [...before, ...items] +// }; +// } + +// // eslint-disable-next-line no-case-declarations +// const after = state.originalQueue.slice(state.currentIndex + 1); + +// return { +// ...state, +// queue: [...state.queue.slice(0, state.currentIndex + 1), ...after] +// }; + +// case PlayerActionTypes.CLEAR_UP_NEXT: +// return { +// ...state, +// queue: [ +// ...state.queue.slice(0, state.upNext.start), +// ...state.queue.slice(state.upNext.start + state.upNext.length) +// ], +// upNext: { +// start: 0, +// length: 0 +// } +// }; +// case ObjectsActionTypes.UNSET_TRACK: +// return { +// ...state, +// queue: [...state.queue.slice(0, payload.position), ...state.queue.slice((payload.position as number) + 1)] +// }; +// case AppActionTypes.RESET_STORE: +// return initialState; +// default: +// return state; +// } +// }; diff --git a/src/common/store/player/selectors.ts b/src/common/store/player/selectors.ts index 8fae815b..a515ac7a 100644 --- a/src/common/store/player/selectors.ts +++ b/src/common/store/player/selectors.ts @@ -1,39 +1,42 @@ -import { Normalized } from '@types'; +import { StoreState } from 'AppReduxTypes'; +import { isEqual } from 'lodash'; import { createSelector } from 'reselect'; -import { StoreState } from '../rootReducer'; -import { PlayerState, PlayerStatus, PlayingTrack } from './types'; - -export const getPlayer = (state: StoreState) => state.player; - -export const getPlayingTrack = createSelector( - [getPlayer], - player => player.playingTrack -); - -export const getPlayerStatusSelector = createSelector( - [getPlayer], - player => player.status -); - -export const getQueue = createSelector( - [getPlayer], - player => player.queue || [] -); - -export const getCurrentPlaylistId = createSelector( - [getPlayer], - player => player.currentPlaylistId || null -); - -export const isPlaying = (result: Normalized.NormalizedResult, playlistId: string) => - createSelector([getPlayingTrack], playingTrack => { +import { PlaylistIdentifier } from '../types'; +import { PlayerState, PlayingTrack } from './types'; +import { Normalized, SoundCloud } from '@types'; +import { PlaylistTypes } from '../objects'; +import { AssetType } from 'src/types/soundcloud'; + +export const getPlayerNode = (state: StoreState) => state.player; + +export const getPlayingTrack = createSelector([getPlayerNode], player => player.playingTrack); +export const getPlayerCurrentTime = createSelector([getPlayerNode], player => player.currentTime); +export const getPlayingTrackIndex = createSelector([getPlayerNode], player => player.currentIndex); +export const getPlayerUpNext = createSelector([getPlayerNode], player => player.upNext); +export const getPlayerStatus = createSelector([getPlayerNode], player => player.status); +export const getCurrentPlaylistId = createSelector([getPlayerNode], player => player.currentPlaylistId || null); + +export const getNormalizedSchemaForType = ( + trackOrPlaylist: SoundCloud.Track | SoundCloud.Playlist +): Normalized.NormalizedResult => ({ + id: trackOrPlaylist.id, + schema: trackOrPlaylist.kind === AssetType.PLAYLIST ? 'playlists' : 'tracks' +}); + +export const isPlayingSelector = (playlistId: PlaylistIdentifier, idResult?: Normalized.NormalizedResult) => + createSelector([getPlayingTrack], playingTrack => { if (!playingTrack) { return false; } - if (result.schema === 'playlists') { - return playingTrack.playlistId === result.id.toString(); + if (!idResult) return isEqual(playingTrack.playlistId, playlistId); + + if (idResult.schema === 'playlists') { + return isEqual(playingTrack.parentPlaylistID, { + playlistType: PlaylistTypes.PLAYLIST, + objectId: idResult.id.toString() + }); } - return playingTrack.id === result.id && playingTrack.playlistId === playlistId; + return playingTrack.id === idResult.id && isEqual(playingTrack.playlistId, playlistId); }); diff --git a/src/common/store/player/types.ts b/src/common/store/player/types.ts index cd1340ad..b330e813 100644 --- a/src/common/store/player/types.ts +++ b/src/common/store/player/types.ts @@ -1,22 +1,28 @@ // TYPES +import { PlaylistIdentifier } from '../playlist'; +import { ObjectStateItem } from '../types'; + export type PlayerState = Readonly<{ status: PlayerStatus; - queue: PlayingTrack[]; - originalQueue: PlayingTrack[]; + currentPlaylistId: PlaylistIdentifier | null; playingTrack: PlayingTrack | null; - currentPlaylistId: string | null; currentIndex: number; currentTime: number; duration: number; - upNext: UpNextState; - containsPlaylists: PlayingPositionState[]; + upNext: ObjectStateItem[]; + + // queue: PlayingTrack[]; + // originalQueue: PlayingTrack[]; + // upNext: UpNextState; + // containsPlaylists: PlayingPositionState[]; }>; export interface PlayingTrack { - un: number; // unique identifiable number to differentiate between the same tracks in queue id: number; - playlistId: string; + un?: number; + playlistId: PlaylistIdentifier; + parentPlaylistID?: PlaylistIdentifier; } export interface PlayingPositionState { @@ -55,14 +61,30 @@ export type ProcessedQueueItems = [PlayingTrack[], PlayingTrack[]]; // ACTIONS export enum PlayerActionTypes { + TOGGLE_STATUS = '@@player/TOGGLE_STATUS', + TOGGLE_SHUFFLE = '@@player/TOGGLE_SHUFFLE', + PLAY_TRACK = '@@player/PLAY_TRACK', + CHANGE_TRACK = '@@player/CHANGE_TRACK', + PLAY_PLAYLIST = '@@player/PLAY_PLAYLIST', + START_PLAY_MUSIC = '@@player/START_PLAY_MUSIC', + SET_CURRENT_PLAYLIST = '@@player/SET_CURRENT_PLAYLIST', + RESTART_TRACK = '@@player/RESTART_TRACK', + TRACK_FINISHED = '@@player/TRACK_FINISHED', + PLAYLIST_FINISHED = '@@player/PLAYLIST_FINISHED', + START_PLAY_MUSIC_INDEX = '@@player/START_PLAY_MUSIC_INDEX', + ADD_UP_NEXT = '@@player/ADD_UP_NEXT', + CLEAR_UP_NEXT = '@@player/CLEAR_UP_NEXT', + QUEUE_INSERT = '@@player/QUEUE_INSERT', + SET_QUEUE = '@@player/SET_QUEUE', + SHUFFLE_QUEUE = '@@player/SHUFFLE_QUEUE', + SET_CURRENT_INDEX = '@@player/SET_CURRENT_INDEX', + RESOLVE_PLAYLIST_ITEMS = '@@player/RESOLVE_PLAYLIST_ITEMS', + + // OLD SET_TIME = '@@player/SET_TIME', UPDATE_TIME = '@@player/UPDATE_TIME', SET_DURATION = '@@player/SET_DURATION', SET_TRACK = '@@player/SET_TRACK', TOGGLE_PLAYING = '@@player/TOGGLE_PLAYING', - SET_PLAYLIST = '@@player/SET_PLAYLIST', - QUEUE_INSERT = '@@player/QUEUE_INSERT', - ADD_UP_NEXT = '@@player/ADD_UP_NEXT', - CLEAR_UP_NEXT = '@@player/CLEAR_UP_NEXT', - TOGGLE_SHUFFLE = '@@player/TOGGLE_SHUFFLE' + SET_PLAYLIST = '@@player/SET_PLAYLIST' } diff --git a/src/common/store/playlist/actions.ts b/src/common/store/playlist/actions.ts index 859950ef..cab106d3 100755 --- a/src/common/store/playlist/actions.ts +++ b/src/common/store/playlist/actions.ts @@ -1,39 +1,23 @@ -import { Intent } from '@blueprintjs/core'; -import { PersonalisedCollectionItem } from '@common/api/fetchPersonalised'; import { wError, wSuccess } from '@common/utils/reduxUtils'; -import { Collection, EntitiesOf, EpicFailure, Normalized, SoundCloud, ThunkResult } from '@types'; +import { EntitiesOf, EpicFailure, Normalized, SoundCloud } from '@types'; import { createAction, createAsyncAction } from 'typesafe-actions'; -import fetchToJson from '../../api/helpers/fetchToJson'; -import { SC } from '../../utils'; -import { getPlaylistEntity } from '../entities/selectors'; -import { ObjectsActionTypes, ObjectTypes } from '../objects'; -import { getPlaylistObjectSelector } from '../objects/selectors'; -import { addToast } from '../ui/actions'; -import { PlaylistActionTypes, PlaylistIdentifier, SortTypes } from './types'; - -interface ObjectItem extends PlaylistIdentifier { - objectType: ObjectTypes; - entities: EntitiesOf; - result: Normalized.NormalizedResult[]; - nextUrl?: string; - fetchedItemsIds?: number[]; -} +import { PlaylistActionTypes, PlaylistIdentifier, PlaylistObjectItem, SortTypes } from '../types'; export const getGenericPlaylist = createAsyncAction( - PlaylistActionTypes.GET_GENERIC_PLAYLIST, + String(PlaylistActionTypes.GET_GENERIC_PLAYLIST), wSuccess(PlaylistActionTypes.GET_GENERIC_PLAYLIST), wError(PlaylistActionTypes.GET_GENERIC_PLAYLIST) )< PlaylistIdentifier & { refresh: boolean; sortType?: SortTypes; searchString?: string }, - ObjectItem & { refresh?: boolean; query?: string }, + PlaylistObjectItem & { refresh?: boolean; query?: string }, EpicFailure & PlaylistIdentifier >(); export const genericPlaylistFetchMore = createAsyncAction( - PlaylistActionTypes.GENERIC_PLAYLIST_FETCH_MORE, + String(PlaylistActionTypes.GENERIC_PLAYLIST_FETCH_MORE), wSuccess(PlaylistActionTypes.GENERIC_PLAYLIST_FETCH_MORE), wError(PlaylistActionTypes.GENERIC_PLAYLIST_FETCH_MORE) -)(); +)(); export const setPlaylistLoading = createAction(PlaylistActionTypes.SET_PLAYLIST_LOADING)(); @@ -43,11 +27,11 @@ export const getSearchPlaylist = createAction(PlaylistActionTypes.SEARCH)< export const searchPlaylistFetchMore = createAction(PlaylistActionTypes.SEARCH_FETCH_MORE)(); export const getForYouSelection = createAsyncAction( - PlaylistActionTypes.GET_FORYOU_SELECTION, + String(PlaylistActionTypes.GET_FORYOU_SELECTION), wSuccess(PlaylistActionTypes.GET_FORYOU_SELECTION), wError(PlaylistActionTypes.GET_FORYOU_SELECTION) )< - undefined, + unknown, { objects: ForYourObject[]; entities: EntitiesOf & { tracks: Normalized.NormalizedResult[] }>; @@ -56,132 +40,10 @@ export const getForYouSelection = createAsyncAction( EpicFailure >(); -export type ForYourObject = Omit & { objectId: string }; -/** - * Add track to certain playlist - */ -export function togglePlaylistTrack(trackId: number, playlistId: number): ThunkResult { - return async (dispatch, getState) => { - const state = getState(); - - const playlistObject = getPlaylistObjectSelector(playlistId.toString())(state); - const playlistEntity = getPlaylistEntity(playlistId)(state); - - if (!playlistObject || !playlistEntity) { - return; - } - - let newitems: Normalized.NormalizedResult[] = []; - - const track: Normalized.NormalizedResult = { id: trackId, schema: 'tracks' }; - - const found = !!playlistObject.items.find(t => t.id === track.id && t.schema === track.schema); - - let add = true; - - if (!found) { - newitems = [...playlistObject.items, track]; - } else { - newitems = [...playlistObject.items.filter(normalizedResult => normalizedResult.id !== track.id)]; - add = false; - } - - dispatch({ - type: ObjectsActionTypes.UPDATE_ITEMS, - payload: { - promise: fetchToJson(SC.getPlaylistupdateUrl(playlistId), { - method: 'PUT', - data: { - playlist: { - tracks: newitems.map(i => i.id) - } - } - }).then(() => { - const { - entities: { trackEntities } - } = getState(); - - const { duration } = trackEntities[trackId]; - - dispatch( - addToast({ - message: `Track ${add ? 'added to' : 'removed from'} playlist`, - intent: Intent.SUCCESS - }) - ); - - return { - objectId: playlistId, - objectType: ObjectTypes.PLAYLISTS, - items: newitems, - entities: { - playlistEntities: { - [playlistId]: { - track_count: !found ? playlistEntity.track_count + 1 : playlistEntity.track_count - 1, - duration: !found ? playlistEntity.duration + duration : playlistEntity.duration - duration - } - } - } - }; - }), - data: { - objectId: playlistId, - objectType: ObjectTypes.PLAYLISTS - } - } - }); - }; -} - -// This method is unused -export function createPlaylist(title: string, type: string, tracks: Normalized.NormalizedResult[]) { - return () => - fetchToJson(SC.getPlaylistUrl(), { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json' - }, - data: { - playlist: { - title, - sharing: type, - tracks: tracks.map(i => i.id) - } - } - }); -} - -// This method is unused because a playlist only gets deleted after a while, -// not sure if we can check if it's pending deletion. Otherwise it would be bad UX -export function deletePlaylist(playlistId: string): ThunkResult { - return (dispatch, getState) => { - const { - entities: { playlistEntities } - } = getState(); - - const playlistEntitity = playlistEntities[playlistId]; +export const getPlaylistTracks = createAsyncAction( + String(PlaylistActionTypes.GET_PLAYLIST_TRACKS), + wSuccess(PlaylistActionTypes.GET_PLAYLIST_TRACKS), + wError(PlaylistActionTypes.GET_PLAYLIST_TRACKS) +)(); - if (playlistEntitity) { - fetchToJson(SC.getPlaylistDeleteUrl(playlistId), { - method: 'DELETE' - }) - .then(() => { - dispatch( - addToast({ - message: `Playlist has been deleted`, - intent: Intent.SUCCESS - }) - ); - }) - .catch(() => { - dispatch( - addToast({ - message: `Unable to delete playlist`, - intent: Intent.DANGER - }) - ); - }); - } - }; -} +export type ForYourObject = Omit & { objectId: string }; diff --git a/src/common/store/playlist/api.ts b/src/common/store/playlist/api.ts index 6242ae3f..d9f44bc3 100644 --- a/src/common/store/playlist/api.ts +++ b/src/common/store/playlist/api.ts @@ -1,6 +1,5 @@ import fetchToJsonNew from '@common/api/helpers/fetchToJsonNew'; import { Collection, SoundCloud } from '@types'; -import { SC } from '@common/utils'; import { SortTypes } from './types'; // Stream @@ -126,20 +125,6 @@ export async function fetchTracks(options: { ids: number[] }) { return json; } -export async function fetchFromUrl(url: string) { - const json = await fetchToJsonNew( - { - oauthToken: true, - useV2Endpoint: true - }, - { - url: SC.appendToken(url) - } - ); - - return json; -} - interface SearchAllResponse { collection: SearchCollectionItem[]; next_href?: string; diff --git a/src/common/store/playlist/epics.ts b/src/common/store/playlist/epics.ts index 73991285..18a72af9 100644 --- a/src/common/store/playlist/epics.ts +++ b/src/common/store/playlist/epics.ts @@ -1,34 +1,61 @@ -import { playlistSchema, trackSchema, userSchema } from '@common/schemas'; +import { normalizeArray, normalizeCollection, playlistSchema } from '@common/schemas'; import { SC } from '@common/utils'; import { EpicError } from '@common/utils/errors/EpicError'; -import { Collection, EntitiesOf, Normalized, SoundCloud, ResultOf } from '@types'; -import { RootState } from 'AppReduxTypes'; +import { Collection, EntitiesOf, Normalized, SoundCloud } from '@types'; +import { RootAction, StoreState } from 'AppReduxTypes'; import { AxiosError } from 'axios'; -import { isEqual, uniqWith } from 'lodash'; +import _, { isEqual, uniqWith } from 'lodash'; import { normalize, schema } from 'normalizr'; -import { EMPTY, from, of, throwError } from 'rxjs'; -import { catchError, filter, map, startWith, switchMap, tap, withLatestFrom } from 'rxjs/operators'; +import { ActionsObservable, StateObservable } from 'redux-observable'; +import { concat, EMPTY, from, merge, of, throwError } from 'rxjs'; +import { + catchError, + delay, + distinctUntilChanged, + exhaustMap, + filter, + first, + flatMap, + ignoreElements, + map, + mergeMap, + pluck, + startWith, + switchMap, + take, + takeUntil, + tap, + withLatestFrom +} from 'rxjs/operators'; import { isActionOf } from 'typesafe-actions'; -import { currentUserSelector } from '../auth/selectors'; -import { ObjectTypes, PlaylistTypes } from '../objects'; -import { getPlaylistObjectSelector } from '../objects/selectors'; -import { RootEpic } from '../types'; import { + ForYourObject, genericPlaylistFetchMore, + getForYouSelection, getGenericPlaylist, + getPlaylistTracks, getSearchPlaylist, + resolvePlaylistItems, searchPlaylistFetchMore, - setPlaylistLoading, - getForYouSelection, - ForYourObject -} from './actions'; -import * as APIService from './api'; + setPlaylistLoading +} from '../actions'; +import * as APIService from '../api'; +import { RootEpic } from '../declarations'; +import { ObjectState, ObjectStateItem } from '../objects'; +import { + currentUserSelector, + getPlaylistObjectSelector, + getQueuePlaylistSelector, + shuffleSelector +} from '../selectors'; +import { ObjectTypes, PlaylistTypes } from '../types'; +import { PlaylistIdentifier } from './types'; const handleEpicError = (error: any) => { if ((error as AxiosError).isAxiosError) { - console.log(error.response); + console.log(error.message); } - console.error(error?.message); + console.error('Epic error', error); // TODO Sentry? return error; }; @@ -37,9 +64,9 @@ export const getGenericPlaylistEpic: RootEpic = (action$, state$) => action$.pipe( filter(isActionOf(getGenericPlaylist.request)), tap(action => console.log(`${action.type} from ${process.type}`)), - map(action => action.payload), + pluck('payload'), withLatestFrom(state$), - switchMap(([{ playlistType, objectId, refresh, sortType }, state]) => { + flatMap(([{ playlistType, objectId, refresh, sortType }, state]) => { const { config: { hideReposts } } = state; @@ -60,6 +87,13 @@ export const getGenericPlaylistEpic: RootEpic = (action$, state$) => case PlaylistTypes.MYPLAYLISTS: ob$ = APIService.fetchPlaylists({ limit: 21 }); break; + case PlaylistTypes.RELATED: + if (!objectId) { + ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); + break; + } + ob$ = APIService.fetchRelatedTracks({ limit: 21, trackId: objectId, userId: me?.id || '' }); + break; case PlaylistTypes.PLAYLIST: if (!objectId) { ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); @@ -74,6 +108,27 @@ export const getGenericPlaylistEpic: RootEpic = (action$, state$) => } ob$ = APIService.fetchCharts({ limit: 21, genre: objectId, sort: sortType }); break; + case PlaylistTypes.ARTIST_TRACKS: + if (!objectId) { + ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); + break; + } + ob$ = APIService.fetchUserTracks({ limit: 21, userId: objectId }); + break; + case PlaylistTypes.ARTIST_TOP_TRACKS: + if (!objectId) { + ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); + break; + } + ob$ = APIService.fetchUserTopTracks({ limit: 21, userId: objectId }); + break; + case PlaylistTypes.ARTIST_LIKES: + if (!objectId) { + ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); + break; + } + ob$ = APIService.fetchUserLikes({ limit: 21, userId: objectId }); + break; default: ob$ = throwError(new EpicError(`${playlistType}: ${objectId} not found`)); } @@ -84,8 +139,12 @@ export const getGenericPlaylistEpic: RootEpic = (action$, state$) => case PlaylistTypes.STREAM: return processStreamItems(state)(json as Collection); case PlaylistTypes.LIKES: + case PlaylistTypes.ARTIST_LIKES: return processLikeItems(state)(json as Collection); case PlaylistTypes.MYTRACKS: + case PlaylistTypes.RELATED: + case PlaylistTypes.ARTIST_TRACKS: + case PlaylistTypes.ARTIST_TOP_TRACKS: return processMyTracks(state)(json as Collection); case PlaylistTypes.MYPLAYLISTS: return processStreamItems(state)(json as Collection); @@ -96,7 +155,7 @@ export const getGenericPlaylistEpic: RootEpic = (action$, state$) => default: return { json, - normalized: normalize(json, playlistSchema) + normalized: normalize, Normalized.NormalizedResult[]>(json, playlistSchema) }; } }), @@ -136,26 +195,30 @@ export const genericPlaylistFetchMoreEpic: RootEpic = (action$, state$) => return { payload, - object + object, + + // For our queue, we keep the origin playlistId here + originalPlaylistType: object?.meta.originalPlaylistID?.playlistType ?? playlistType }; }), // Don't do anything if we are already fetching this playlist - filter(({ object, payload }) => { - if (payload.playlistType !== PlaylistTypes.PLAYLIST) { + filter(({ object, originalPlaylistType }) => { + if (originalPlaylistType !== PlaylistTypes.PLAYLIST) { return !!object && !object.isFetching && !!object.nextUrl; } return !!object && !object.isFetching && !!object.itemsToFetch.length; }), withLatestFrom(state$), - switchMap(([{ object, payload }, state]) => { + switchMap(([{ object, payload, originalPlaylistType }, state]) => { const { playlistType, objectId } = payload; + const shuffle = shuffleSelector(state); const urlWithToken = SC.appendToken(object?.nextUrl as string); const itemsToFetch = (object?.itemsToFetch.map(i => i.id) || []).slice(0, 15); let ob$; - if (playlistType === PlaylistTypes.PLAYLIST) { + if (originalPlaylistType === PlaylistTypes.PLAYLIST) { ob$ = APIService.fetchTracks({ ids: itemsToFetch }); } else { ob$ = APIService.fetchFromUrl(urlWithToken); @@ -163,23 +226,27 @@ export const genericPlaylistFetchMoreEpic: RootEpic = (action$, state$) => return from(ob$).pipe( map(json => { - switch (playlistType) { + switch (originalPlaylistType) { case PlaylistTypes.STREAM: return processStreamItems(state)(json); case PlaylistTypes.LIKES: + case PlaylistTypes.ARTIST_LIKES: return processLikeItems(state)(json); case PlaylistTypes.MYTRACKS: + case PlaylistTypes.RELATED: + case PlaylistTypes.ARTIST_TRACKS: + case PlaylistTypes.ARTIST_TOP_TRACKS: return processMyTracks(state)(json); case PlaylistTypes.MYPLAYLISTS: return processStreamItems(state)(json); case PlaylistTypes.PLAYLIST: - return processPlaylistTracks(state)(json, itemsToFetch); + return processPlaylistTracks(json, itemsToFetch); case PlaylistTypes.CHART: return processCharts(state)(json); default: return { json, - normalized: normalize(json, playlistSchema) + normalized: normalize, Normalized.NormalizedResult[]>(json, playlistSchema) }; } }), @@ -191,7 +258,8 @@ export const genericPlaylistFetchMoreEpic: RootEpic = (action$, state$) => objectType: ObjectTypes.PLAYLISTS, result: data.normalized.result, nextUrl: data.json?.['next_href'], - fetchedItemsIds: data?.['fetchedItemsIds'] + fetchedItemsIds: data?.['fetchedItemsIds'], + shuffle }) ), catchError(error => @@ -212,7 +280,7 @@ export const searchEpic: RootEpic = (action$, state$) => action$.pipe( filter(isActionOf(getSearchPlaylist)), tap(action => console.log(`${action.type} from ${process.type}`)), - map(action => action.payload), + pluck('payload'), switchMap(({ playlistType, objectId, query, tag, refresh }) => { // TODO // if (query && isSoundCloudUrl(query)) { @@ -250,7 +318,7 @@ export const searchEpic: RootEpic = (action$, state$) => } return from(ob$ || EMPTY).pipe( - map(processCollection), + map(data => normalizeCollection(data)), map(data => getGenericPlaylist.success({ objectId, @@ -284,11 +352,9 @@ export const searchFetchMoreEpic: RootEpic = (action$, state$) => map(([{ payload }, state]) => { const { objectId, playlistType } = payload; - const object = getPlaylistObjectSelector({ objectId, playlistType })(state); - return { payload, - object + object: getPlaylistObjectSelector({ objectId, playlistType })(state) }; }), // Don't do anything if we are already fetching this playlist @@ -298,7 +364,7 @@ export const searchFetchMoreEpic: RootEpic = (action$, state$) => const urlWithToken = SC.appendToken(object?.nextUrl as string); return from(APIService.fetchFromUrl(urlWithToken)).pipe( - map(processCollection), + map(normalizeCollection), map(data => genericPlaylistFetchMore.success({ objectId, @@ -325,6 +391,7 @@ export const searchFetchMoreEpic: RootEpic = (action$, state$) => ); export const getForYouSelectionEpic: RootEpic = action$ => + // eslint-disable-next-line @typescript-eslint/ban-ts-ignore // @ts-ignore action$.pipe( filter(isActionOf(getForYouSelection.request)), @@ -385,7 +452,163 @@ export const getForYouSelectionEpic: RootEpic = action$ => }) ); -const processStreamItems = (state: RootState) => (json: Collection) => { +export const getPlaylistTracksEpic: RootEpic = (action$, state$) => + // @ts-ignore + action$.pipe( + // For playlists in playlists (playlist in STREAM for ex), we need to check if an object exists for this playlist, + // Otherwise we cannot do much, so we wait for it and create it + filter(isActionOf(getPlaylistTracks.request)), + pluck('payload'), + mergeMap(payload => { + const { objectId, playlistType } = payload; + + return concat( + createPlaylistIfNotExists$(action$, state$, payload), + state$.pipe( + // Wait for the playlist to exist + map(getPlaylistObjectSelector(payload)), + distinctUntilChanged(), + filter(object => !!object), + first(), + + // Fetch all tracks + mergeMap(object => + merge( + from(_.chunk(object?.itemsToFetch.map(i => i.id) || [], 50)).pipe( + mergeMap(itemsForThisChunk => + from(APIService.fetchTracks({ ids: itemsForThisChunk })).pipe( + map(json => processPlaylistTracks(json, itemsForThisChunk)), + map(data => + genericPlaylistFetchMore.success({ + objectId, + playlistType, + entities: data.normalized.entities, + objectType: ObjectTypes.PLAYLISTS, + result: data.normalized.result, + nextUrl: data.json?.['next_href'], + fetchedItemsIds: data?.['fetchedItemsIds'] + }) + ) + ) + ) + ) + ) + ), + catchError(error => + of( + getPlaylistTracks.failure({ + error: handleEpicError(error), + objectId, + playlistType + }) + ) + ) + ), + of(getPlaylistTracks.success(payload)) + ); + }) + ); + +export const createPlaylistObjectsEpic: RootEpic = (action$, state$) => + // @ts-ignore + action$.pipe( + filter(isActionOf([getGenericPlaylist.success, genericPlaylistFetchMore.success])), + delay(250), + pluck('payload', 'result'), + mergeMap(result => + merge( + from(result).pipe( + filter(item => item.schema === 'playlists'), + mergeMap(({ id }) => + createPlaylistIfNotExists$(action$, state$, { + objectId: id.toString(), + playlistType: PlaylistTypes.PLAYLIST + }) + ) + ) + ) + ) + ); + +const createPlaylistIfNotExists$ = ( + action$: ActionsObservable, + state$: StateObservable, + payload: PlaylistIdentifier +) => + of(payload).pipe( + withLatestFrom(state$), + map(([playlistID, state]) => ({ + playlistID, + objectExists: getPlaylistObjectSelector(playlistID)(state) + })), + filter(({ objectExists }) => !objectExists), + exhaustMap(({ playlistID }) => + concat( + action$.pipe( + filter(isActionOf(getGenericPlaylist.success)), + pluck('payload'), + filterPlaylistID$(playlistID), + take(1), + takeUntil( + action$.pipe( + filter(isActionOf(getGenericPlaylist.failure)), + pluck('payload'), + filterPlaylistID$(playlistID) + ) + ), + ignoreElements(), + startWith( + getGenericPlaylist.request({ + ...playlistID, + refresh: true + }) + ) + ), + of(state$.value).pipe( + filter(() => !!playlistID.objectId), + map(getQueuePlaylistSelector), + map(queueObject => + playlistID?.objectId + ? queueObject.items.filter(i => i.schema === 'playlists' && i.id.toString() === playlistID.objectId) + : [] + ), + filter(playlistItemsToReplace => !!playlistItemsToReplace.length), + withLatestFrom(state$), + map(([playlistItemsToReplace, latestState]) => ({ + object: getPlaylistObjectSelector(playlistID)(latestState), + playlistItemsToReplace + })), + filter(({ object }) => !!object), + mergeMap(({ playlistItemsToReplace, object }) => + concat( + from(playlistItemsToReplace).pipe( + map(playlistItem => + resolvePlaylistItems({ + playlistItem, + items: [...(object as ObjectState).items, ...(object as ObjectState).itemsToFetch].map( + (i): ObjectStateItem => ({ + ...i, + parentPlaylistID: { + objectId: playlistItem.id.toString(), + playlistType: PlaylistTypes.PLAYLIST + }, + un: playlistItem.un + }) + ) + }) + ) + ) + ) + ) + ) + ) + ) + ); + +const filterPlaylistID$ = (playlistID: PlaylistIdentifier) => + filter(({ playlistType, objectId }: PlaylistIdentifier) => _.isEqual(playlistID, { playlistType, objectId })); + +const processStreamItems = (state: StoreState) => (json: Collection) => { const { config: { hideReposts } } = state; @@ -402,23 +625,13 @@ const processStreamItems = (state: RootState) => (json: Collection { const obj = (item as APIService.FeedItem).track || (item.playlist as SoundCloud.Playlist); - obj.fromUser = item.user; + obj.fromUser = item.user as any; obj.type = item.type; return obj; }); - const normalized = normalize, Normalized.NormalizedResult[]>( - processedCollection, - new schema.Array( - { - tracks: trackSchema, - playlists: playlistSchema, - users: userSchema - }, - input => `${input.kind}s` - ) - ); + const { normalized } = normalizeArray(processedCollection); // Stream could have duplicate items normalized.result = uniqWith(normalized.result, isEqual); @@ -429,41 +642,18 @@ const processStreamItems = (state: RootState) => (json: Collection (json: Collection) => { - const processedCollection = json.collection.map(({ track }) => track); - const normalized = normalize, Normalized.NormalizedResult[]>( - processedCollection, - new schema.Array( - { - tracks: trackSchema - }, - input => `${input.kind}s` - ) - ); - - return { - json, - normalized - }; +const processLikeItems = (_state: StoreState) => (json: Collection) => { + return normalizeCollection({ + ...json, + collection: json.collection.filter(({ track }) => !!track).map(({ track }) => track) + }); }; -const processMyTracks = (state: RootState) => (json: Collection) => { - const normalized = normalize, Normalized.NormalizedResult[]>( - json.collection, - new schema.Array( - { - tracks: trackSchema - }, - input => `${input.kind}s` - ) - ); - - return { - json, - normalized - }; +const processMyTracks = (_state: StoreState) => (json: Collection) => { + return normalizeCollection(json); }; -const processCharts = (state: RootState) => (json: Collection) => { + +const processCharts = (_state: StoreState) => (json: Collection) => { const processedCollection = json.collection.map(item => { const { track } = item; track.score = item.score; @@ -471,23 +661,13 @@ const processCharts = (state: RootState) => (json: Collection, Normalized.NormalizedResult[]>( - processedCollection, - new schema.Array( - { - tracks: trackSchema - }, - input => `${input.kind}s` - ) - ); - - return { - json, - normalized - }; + return normalizeCollection({ + ...json, + collection: processedCollection + }); }; -const processPlaylist = (state: RootState) => (json: SoundCloud.Playlist, objectId?: string) => { +const processPlaylist = (_state: StoreState) => (json: SoundCloud.Playlist, objectId?: string) => { if (!objectId) { throw new Error(`processPlaylist: objectId=${objectId} must be defined`); } @@ -506,13 +686,13 @@ const processPlaylist = (state: RootState) => (json: SoundCloud.Playlist, object fetchedItems = json.tracks.filter((t: Partial) => t.user !== undefined); } - const fetchedItemsIds = fetchedItems.map(item => item.id); + const fetchedItemsIds = fetchedItems.map(item => item.id as number); return { json, normalized: { ...normalized, - result: playlist.tracks + result: playlist.tracks as any }, fetchedItemsIds }; @@ -524,16 +704,8 @@ const processPlaylist = (state: RootState) => (json: SoundCloud.Playlist, object }; }; -const processPlaylistTracks = (state: RootState) => (json: SoundCloud.Track[], fetchedItemsIds: number[]) => { - const normalized = normalize, Normalized.NormalizedResult[]>( - json, - new schema.Array( - { - tracks: trackSchema - }, - input => `${input.kind}s` - ) - ); +const processPlaylistTracks = (json: SoundCloud.Track[], fetchedItemsIds: number[]) => { + const { normalized } = normalizeArray(json); return { json, @@ -541,22 +713,3 @@ const processPlaylistTracks = (state: RootState) => (json: SoundCloud.Track[], f fetchedItemsIds }; }; - -const processCollection = (json: Collection) => { - const normalized = normalize( - json.collection, - new schema.Array( - { - playlists: playlistSchema, - tracks: trackSchema, - users: userSchema - }, - input => `${input.kind}s` - ) - ); - - return { - json, - normalized - }; -}; diff --git a/src/common/store/playlist/index.ts b/src/common/store/playlist/index.ts new file mode 100644 index 00000000..fcb073fe --- /dev/null +++ b/src/common/store/playlist/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/src/common/store/playlist/types.ts b/src/common/store/playlist/types.ts index 9180c225..cbed5f18 100644 --- a/src/common/store/playlist/types.ts +++ b/src/common/store/playlist/types.ts @@ -1,4 +1,4 @@ -import { ObjectTypes, PlaylistTypes } from '../objects'; +import { ObjectItem, ObjectTypes, PlaylistTypes } from '../types'; // TYPES @@ -12,6 +12,8 @@ export type PlaylistIdentifier = { playlistType: PlaylistTypes | ObjectTypes; }; +export interface PlaylistObjectItem extends ObjectItem, PlaylistIdentifier {} + // ACTIONS export enum PlaylistActionTypes { @@ -22,5 +24,6 @@ export enum PlaylistActionTypes { SEARCH = '@@playlist/SEARCH', SEARCH_FETCH_MORE = '@@playlist/SEARCH_FETCH_MORE', - GET_FORYOU_SELECTION = '@@playlist/GET_FORYOU_SELECTION' + GET_FORYOU_SELECTION = '@@playlist/GET_FORYOU_SELECTION', + GET_PLAYLIST_TRACKS = '@@playlist/GET_PLAYLIST_TRACKS' } diff --git a/src/common/store/rootEpic.ts b/src/common/store/rootEpic.ts index 1673fd0f..c91c09b0 100644 --- a/src/common/store/rootEpic.ts +++ b/src/common/store/rootEpic.ts @@ -1,17 +1,22 @@ -import { RootState } from 'AppReduxTypes'; +import { StoreState } from 'AppReduxTypes'; import { combineEpics } from 'redux-observable'; import * as app from './app/epics'; import * as appAuth from './appAuth/epics'; import * as auth from './auth/epics'; -// import * as objects from './objects/epics'; +import { RootAction } from './declarations'; import * as playlist from './playlist/epics'; -import { RootAction } from './types'; +import * as player from './player/epics'; import * as ui from './ui/epics'; +import * as track from './track/epics'; +import * as user from './user/epics'; -export const rootEpic = combineEpics( +export const rootEpic = combineEpics( ...Object.values(app), ...Object.values(appAuth), ...Object.values(ui), ...Object.values(auth), - ...Object.values(playlist) + ...Object.values(playlist), + ...Object.values(track), + ...Object.values(user), + ...Object.values(player) ); diff --git a/src/common/store/rootReducer.ts b/src/common/store/rootReducer.ts index a66ce3ae..bf766dc4 100755 --- a/src/common/store/rootReducer.ts +++ b/src/common/store/rootReducer.ts @@ -1,18 +1,21 @@ -import { connectRouter, RouterState } from 'connected-react-router'; +import { connectRouter } from 'connected-react-router'; import { MemoryHistory } from 'history'; import { combineReducers } from 'redux'; import { reducer as modal } from 'redux-modal'; -import { appReducer, AppState } from './app'; -import { appAuthReducer, AppAuthState } from './appAuth'; -import { authReducer, AuthState } from './auth'; -import { configReducer, ConfigState } from './config'; -import { entitiesReducer, EntitiesState } from './entities'; -import { objectsReducer, ObjectsState } from './objects'; -import { playerReducer, PlayerState } from './player'; -import { uiReducer, UIState } from './ui'; +import { appReducer } from './app/reducer'; +import { appAuthReducer } from './appAuth/reducer'; +import { authReducer } from './auth/reducer'; +import { configReducer } from './config/reducer'; +import { entitiesReducer } from './entities/reducer'; +import { objectsReducer } from './objects/reducer'; +import { playerReducer } from './player/reducer'; +import { uiReducer } from './ui/reducer'; +import { StoreState } from 'AppReduxTypes'; +import { trackReducer } from './track/reducer'; +import { userReducer } from './user/reducer'; export const rootReducer = (history: MemoryHistory) => - combineReducers({ + combineReducers({ auth: authReducer, appAuth: appAuthReducer, entities: entitiesReducer, @@ -22,18 +25,7 @@ export const rootReducer = (history: MemoryHistory) => config: configReducer, ui: uiReducer, modal, - router: connectRouter(history) + router: connectRouter(history), + track: trackReducer, + user: userReducer }); - -export interface StoreState { - appAuth: AppAuthState; - auth: AuthState; - entities: EntitiesState; - player: PlayerState; - objects: ObjectsState; - app: AppState; - config: ConfigState; - ui: UIState; - router: RouterState; - modal: any; -} diff --git a/src/common/store/selectors.ts b/src/common/store/selectors.ts new file mode 100644 index 00000000..d050ccad --- /dev/null +++ b/src/common/store/selectors.ts @@ -0,0 +1,9 @@ +export * from './appAuth/selectors'; +export * from './auth/selectors'; +export * from './config/selectors'; +export * from './entities/selectors'; +export * from './objects/selectors'; +export * from './player/selectors'; +export * from './track/selectors'; +export * from './ui/selectors'; +export * from './user/selectors'; diff --git a/src/common/store/track/actions.ts b/src/common/store/track/actions.ts index ccc28058..062e58cd 100644 --- a/src/common/store/track/actions.ts +++ b/src/common/store/track/actions.ts @@ -1,180 +1,28 @@ -import { Intent } from '@blueprintjs/core'; -import { axiosClient } from '@common/api/helpers/axiosClient'; -// eslint-disable-next-line import/no-cycle -import { ThunkResult } from '@types'; -import moment from 'moment'; -// eslint-disable-next-line import/no-cycle -import fetchTrack from '../../api/fetchTrack'; -import fetchToJson from '../../api/helpers/fetchToJson'; -import { IPC } from '../../utils/ipc'; -import * as SC from '../../utils/soundcloudUtils'; -import { currentUserSelector } from '../auth/selectors'; -import { AuthActionTypes } from '../auth/types'; -// eslint-disable-next-line import/no-cycle -import { getTrackEntity } from '../entities/selectors'; -// eslint-disable-next-line import/no-cycle -import { getComments, getPlaylistO } from '../objects/actions'; -// eslint-disable-next-line import/no-cycle -import { getCommentObject, getPlaylistName, getRelatedTracksPlaylistObject } from '../objects/selectors'; -import { PlaylistTypes } from '../objects/types'; -import { addToast } from '../ui/actions'; -import { TrackActionTypes } from './types'; - -export function toggleLike(trackId: number | string, playlist = false): ThunkResult { - return (dispatch, getState) => { - const state = getState(); - const { - auth: { likes } - } = state; - - const currentUser = currentUserSelector(state); - - if (!currentUser) { - return; - } - - const liked = !SC.hasID(trackId, playlist ? likes.playlist : likes.track); - - dispatch>({ - type: AuthActionTypes.SET_LIKE, - payload: fetchToJson(playlist ? SC.updatePlaylistLikeUrl(currentUser.id, trackId) : SC.updateLikeUrl(trackId), { - method: liked ? 'PUT' : 'DELETE' - }).then(() => { - if (liked) { - dispatch( - addToast({ - message: `Liked ${playlist ? 'playlist' : 'track'}`, - intent: Intent.SUCCESS - }) - ); - } - - return { - trackId, - liked, - playlist - }; - }) - } as any) - .then(() => IPC.notifyTrackLiked(trackId)) - .catch(err => { - if (err.response && err.response.status === 429) { - return err.response.json().then((res: any) => { - if (res && res.errors) { - const error = res.errors[0]; - - if (error && error.reason_phrase === 'info: too many likes') { - dispatch( - addToast({ - message: `Please slow down your likes, you can like again ${moment(error.release_at).fromNow()}`, - intent: Intent.DANGER - }) - ); - } - } - }); - } - - return null; - }); - }; -} - -/** - * Toggle repost of a specific track - */ - -export function toggleRepost(trackOrPlaylistId: number | string, playlist = false): ThunkResult> { - return async (dispatch, getState) => { - const { - auth: { reposts } - } = getState(); - - const reposted = !SC.hasID(trackOrPlaylistId, playlist ? reposts.playlist : reposts.track); - - await dispatch>({ - type: AuthActionTypes.SET_REPOST, - payload: axiosClient(SC.updateRepostUrl(trackOrPlaylistId, !!playlist), { - method: reposted ? 'PUT' : 'DELETE' - }).then(() => { - if (reposted) { - dispatch( - addToast({ - message: `Reposted track`, - intent: Intent.SUCCESS - }) - ); - } - - return { - trackId: trackOrPlaylistId, - reposted, - playlist - }; - }) - } as any); - - IPC.notifyTrackReposted(); - }; -} - -function getTrack(trackId: number) { - return { - type: TrackActionTypes.ADD, - payload: { - promise: fetchTrack(trackId) - .then(({ normalized: { entities } }) => { - const updatedEntities = entities; - - if (updatedEntities && updatedEntities.trackEntities && updatedEntities.trackEntities[trackId]) { - updatedEntities.trackEntities[trackId].loading = false; - } - - return { - entities: updatedEntities - }; - }) - .catch(() => ({ - entities: { - trackEntities: { - [trackId]: { - loading: false - } - } - } - })), - data: { - entities: { - trackEntities: { - [trackId]: { - loading: true, - error: true - } - } - } - } - } - }; -} - -export function fetchTrackIfNeeded(trackId: number): ThunkResult { - return (dispatch, getState) => { - const state = getState(); - - const relatedTracksPlaylistId = getPlaylistName(trackId.toString(), PlaylistTypes.RELATED); - - const track = getTrackEntity(trackId)(state); - - if (!track || (track && !track.playback_count && !track.loading)) { - dispatch(getTrack(trackId)); - } - - if (!getRelatedTracksPlaylistObject(trackId.toString())(state)) { - dispatch(getPlaylistO(SC.getRelatedUrl(trackId), relatedTracksPlaylistId, { appendId: trackId })); - } - - if (!getCommentObject(trackId.toString())(state)) { - dispatch(getComments(trackId)); - } - }; -} +import { wError, wSuccess } from '@common/utils/reduxUtils'; +import { EntitiesOf, EpicFailure, SoundCloud } from '@types'; +import { createAction, createAsyncAction } from 'typesafe-actions'; +import { ObjectItem, TrackActionTypes } from '../types'; + +export const getTrack = createAsyncAction( + String(TrackActionTypes.GET_TRACK), + wSuccess(TrackActionTypes.GET_TRACK), + wError(TrackActionTypes.GET_TRACK) +)< + { refresh: boolean; trackId: number }, + { trackId: number; entities: EntitiesOf }, + EpicFailure & { trackId: number } +>(); + +export const getComments = createAsyncAction( + String(TrackActionTypes.GET_COMMENTS), + wSuccess(TrackActionTypes.GET_COMMENTS), + wError(TrackActionTypes.GET_COMMENTS) +)<{ refresh: boolean; trackId: number }, ObjectItem & { trackId: number }, EpicFailure & { trackId: number }>(); + +export const commentsFetchMore = createAsyncAction( + String(TrackActionTypes.GET_COMMENTS_FETCH_MORE), + wSuccess(TrackActionTypes.GET_COMMENTS_FETCH_MORE), + wError(TrackActionTypes.GET_COMMENTS_FETCH_MORE) +)<{ trackId: number }, ObjectItem & { trackId: number }, EpicFailure & { trackId: number }>(); + +export const setCommentsLoading = createAction(TrackActionTypes.SET_COMMENTS_LOADING)<{ trackId: number }>(); diff --git a/src/common/store/track/api.ts b/src/common/store/track/api.ts new file mode 100644 index 00000000..ef730a89 --- /dev/null +++ b/src/common/store/track/api.ts @@ -0,0 +1,42 @@ +import fetchToJsonNew from '@common/api/helpers/fetchToJsonNew'; +import { Collection, SoundCloud } from '@types'; + +export async function fetchTrack(options: { trackId: string | number }) { + const json = await fetchToJsonNew({ + uri: `tracks/${options.trackId}`, + oauthToken: true, + useV2Endpoint: true + }); + + return json; +} + +// Comments +export async function fetchComments(options: { trackId: number; limit?: number }) { + const json = await fetchToJsonNew>({ + uri: `tracks/${options.trackId}/comments`, + clientId: true, + useV2Endpoint: true, + queryParams: { + limit: options.limit ?? 20, + threaded: 1, + filter_replies: 0 + } + }); + + return json; +} + +export async function fetchRelatedTracks(options: { trackId: string; userId: string | number; limit?: number }) { + const json = await fetchToJsonNew>({ + uri: `tracks/${options.trackId}/related`, + oauthToken: true, + useV2Endpoint: true, + queryParams: { + limit: options.limit ?? 20, + user_id: options.userId + } + }); + + return json; +} diff --git a/src/common/store/track/epics.ts b/src/common/store/track/epics.ts new file mode 100644 index 00000000..1094494d --- /dev/null +++ b/src/common/store/track/epics.ts @@ -0,0 +1,121 @@ +import { normalizeArray, normalizeCollection } from '@common/schemas'; +import { SC } from '@common/utils'; +import { SoundCloud } from '@types'; +import { AxiosError } from 'axios'; +import { from, of } from 'rxjs'; +import { catchError, filter, map, startWith, switchMap, tap, withLatestFrom, pluck } from 'rxjs/operators'; +import { isActionOf } from 'typesafe-actions'; +import { commentsFetchMore, getComments, getTrack, setCommentsLoading } from '../actions'; +import { fetchFromUrl } from '../api'; +import { RootEpic } from '../declarations'; +import { getCommentObject } from '../selectors'; +import { ObjectTypes } from '../types'; +import * as APIService from './api'; + +const handleEpicError = (error: any) => { + if ((error as AxiosError).isAxiosError) { + console.log(error.message, error.response.data); + } else { + console.error('Epic error - track', error); + } + // TODO Sentry? + return error; +}; + +export const getTrackEpic: RootEpic = action$ => + action$.pipe( + filter(isActionOf(getTrack.request)), + tap(action => console.log(`${action.type} from ${process.type}`)), + pluck('payload'), + switchMap(({ trackId, refresh }) => { + return from(APIService.fetchTrack({ trackId })).pipe( + map(track => normalizeArray([track])), + map(data => + getTrack.success({ + trackId, + entities: data.normalized.entities + }) + ), + catchError(error => + of( + getTrack.failure({ + error: handleEpicError(error), + trackId + }) + ) + ) + ); + }) + ); + +export const getCommentsEpic: RootEpic = action$ => + action$.pipe( + filter(isActionOf(getComments.request)), + tap(action => console.log(`${action.type} from ${process.type}`)), + pluck('payload'), + switchMap(({ trackId, refresh }) => { + return from(APIService.fetchComments({ trackId })).pipe( + map(data => normalizeCollection(data)), + map(data => + getComments.success({ + trackId, + objectType: ObjectTypes.COMMENTS, + entities: data.normalized.entities, + result: data.normalized.result, + refresh, + nextUrl: data.json?.['next_href'] + }) + ), + catchError(error => + of( + getComments.failure({ + error: handleEpicError(error), + trackId + }) + ) + ) + ); + }) + ); + +export const commentsFetchMoreEpic: RootEpic = (action$, state$) => + action$.pipe( + filter(isActionOf(commentsFetchMore.request)), + withLatestFrom(state$), + map(([{ payload }, state]) => { + const object = getCommentObject(payload.trackId)(state); + + return { + payload, + object + }; + }), + // Don't do anything if we are already fetching this + filter(({ object }) => !!object && !object.isFetching && !!object.nextUrl), + switchMap(({ object, payload }) => { + const { trackId } = payload; + const urlWithToken = SC.appendToken(object?.nextUrl as string); + + return from(fetchFromUrl(urlWithToken)).pipe( + map(data => normalizeCollection(data)), + map(data => + commentsFetchMore.success({ + trackId, + entities: data.normalized.entities, + objectType: ObjectTypes.COMMENTS, + result: data.normalized.result, + nextUrl: data.json?.['next_href'] + }) + ), + catchError(error => + of( + commentsFetchMore.failure({ + error: handleEpicError(error), + trackId + }) + ) + ), + startWith(setCommentsLoading({ trackId })) + ); + }) + ); diff --git a/src/common/store/track/index.ts b/src/common/store/track/index.ts new file mode 100644 index 00000000..fcb073fe --- /dev/null +++ b/src/common/store/track/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/src/common/store/track/reducer.ts b/src/common/store/track/reducer.ts new file mode 100644 index 00000000..524d5cee --- /dev/null +++ b/src/common/store/track/reducer.ts @@ -0,0 +1,53 @@ +import { createReducer } from 'typesafe-actions'; +import { resetStore } from '../actions'; +import { TrackState } from '../types'; +import { getTrack } from './actions'; + +const initialState: TrackState = { + loading: [], + error: {} +}; + +export const trackReducer = createReducer(initialState) + .handleAction(getTrack.request, (state, action) => { + const { trackId } = action.payload; + + const errors = state.error; + + delete errors[trackId]; + + return { + loading: Array.from(new Set([...state.loading, trackId])), + error: { + ...errors + } + }; + }) + .handleAction(getTrack.success, (state, action) => { + const { trackId } = action.payload; + + const errors = state.error; + + delete errors[trackId]; + + return { + loading: state.loading.filter(id => id !== trackId), + error: { + ...errors + } + }; + }) + .handleAction(getTrack.failure, (state, action) => { + const { trackId, error } = action.payload; + + return { + loading: state.loading.filter(id => id !== trackId), + error: { + ...state.error, + [trackId]: error + } + }; + }) + .handleAction(resetStore, () => { + return initialState; + }); diff --git a/src/common/store/track/selectors.ts b/src/common/store/track/selectors.ts new file mode 100644 index 00000000..b3eee5cc --- /dev/null +++ b/src/common/store/track/selectors.ts @@ -0,0 +1,8 @@ +import { StoreState } from 'AppReduxTypes'; +import { createSelector } from 'reselect'; + +export const getTrackNode = (state: StoreState) => state.track; + +export const isTrackLoading = (trackId: string) => + createSelector([getTrackNode], track => track.loading.includes(+trackId)); +export const isTrackError = (trackId: number | string) => createSelector([getTrackNode], track => track.error[trackId]); diff --git a/src/common/store/track/types.ts b/src/common/store/track/types.ts index a922127f..5efbc74a 100644 --- a/src/common/store/track/types.ts +++ b/src/common/store/track/types.ts @@ -1,7 +1,16 @@ -// TYPES +import { AxiosError } from 'axios'; + +export interface TrackState { + loading: number[]; + error: { [trackId: string]: AxiosError | Error | null }; +} // ACTIONS export enum TrackActionTypes { - ADD = '@@track/ADD' + ADD = '@@track/ADD', + GET_TRACK = '@@track/GET_TRACK', + GET_COMMENTS = '@@track/GET_COMMENTS', + GET_COMMENTS_FETCH_MORE = '@@track/GET_COMMENTS_FETCH_MORE', + SET_COMMENTS_LOADING = '@@track/SET_COMMENTS_LOADING' } diff --git a/src/common/store/types.ts b/src/common/store/types.ts new file mode 100644 index 00000000..7c2d0ee6 --- /dev/null +++ b/src/common/store/types.ts @@ -0,0 +1,11 @@ +export * from './app/types'; +export * from './appAuth/types'; +export * from './auth/types'; +export * from './config/types'; +export * from './objects/types'; +export * from './player/types'; +export * from './playlist/types'; +export * from './track/types'; +export * from './ui/types'; +export * from './user/types'; +export * from './entities/types'; diff --git a/src/common/store/ui/actions.ts b/src/common/store/ui/actions.ts index af743007..792d91a3 100644 --- a/src/common/store/ui/actions.ts +++ b/src/common/store/ui/actions.ts @@ -3,12 +3,15 @@ import { createAction } from 'typesafe-actions'; import { UIActionTypes, Dimensions } from './types'; import { wDebounce } from '@common/utils/reduxUtils'; -export const addToast = createAction(UIActionTypes.ADD_TOAST)(); +// Toasts +export const addToast = createAction(UIActionTypes.ADD_TOAST)(); export const removeToast = createAction(UIActionTypes.REMOVE_TOAST)(); export const clearToasts = createAction(UIActionTypes.CLEAR_TOASTS)(); +// Dimensions export const setDimensions = createAction(UIActionTypes.SET_DIMENSIONS)(); export const setDebouncedDimensions = createAction(wDebounce(UIActionTypes.SET_DIMENSIONS))(); +// Search export const setSearchQuery = createAction(UIActionTypes.SET_SEARCH_QUERY)<{ query: string; noNavigation?: boolean }>(); export const setDebouncedSearchQuery = createAction(wDebounce(UIActionTypes.SET_SEARCH_QUERY))(); diff --git a/src/common/store/ui/epics.ts b/src/common/store/ui/epics.ts index d6065a28..1eddd364 100644 --- a/src/common/store/ui/epics.ts +++ b/src/common/store/ui/epics.ts @@ -1,11 +1,16 @@ import { routerActions } from 'connected-react-router'; import { of } from 'rxjs'; -import { debounceTime, filter, map, switchMap, withLatestFrom } from 'rxjs/operators'; +import { debounceTime, filter, map, switchMap, withLatestFrom, pluck } from 'rxjs/operators'; import { isActionOf } from 'typesafe-actions'; -import { PlaylistTypes } from '../objects'; -import { getSearchPlaylist } from '../playlist/actions'; -import { RootEpic } from '../types'; -import { setDebouncedDimensions, setDebouncedSearchQuery, setDimensions, setSearchQuery } from './actions'; +import { PlaylistTypes } from '../types'; +import { + setDebouncedDimensions, + setDebouncedSearchQuery, + setDimensions, + setSearchQuery, + getSearchPlaylist +} from '../actions'; +import { RootEpic } from '../declarations'; export const setDebouncedDimensionsEpic: RootEpic = action$ => action$.pipe( @@ -26,17 +31,18 @@ export const setDebouncedSearchQueryEpic: RootEpic = action$ => ); export const setSearchQueryEpic: RootEpic = (action$, state$) => + // eslint-disable-next-line @typescript-eslint/ban-ts-ignore // @ts-ignore action$.pipe( filter(isActionOf(setSearchQuery)), - map(action => action.payload), + pluck('payload'), withLatestFrom(state$), switchMap(([{ query, noNavigation }, state]) => { const { router: { location } } = state; - const navigateToSearch = []; + const navigateToSearch: any[] = []; if (!noNavigation && !location.pathname.startsWith('/search')) { navigateToSearch.push(routerActions.replace('/search')); diff --git a/src/common/store/ui/reducer.ts b/src/common/store/ui/reducer.ts index 3ff79bba..cb9f0db5 100644 --- a/src/common/store/ui/reducer.ts +++ b/src/common/store/ui/reducer.ts @@ -1,7 +1,6 @@ import { createReducer } from 'typesafe-actions'; -import { resetStore } from '../app/actions'; -import { addToast, clearToasts, removeToast, setDimensions, setSearchQuery } from './actions'; -import { UIState } from './types'; +import { addToast, clearToasts, removeToast, setDimensions, setSearchQuery, resetStore } from '../actions'; +import { UIState } from '../types'; const initialState: UIState = { toasts: [], diff --git a/src/common/store/ui/selectors.ts b/src/common/store/ui/selectors.ts index 8b2d4768..a43c1559 100644 --- a/src/common/store/ui/selectors.ts +++ b/src/common/store/ui/selectors.ts @@ -1,7 +1,7 @@ -import { RootState } from 'AppReduxTypes'; -import { UIState } from './types'; +import { StoreState } from 'AppReduxTypes'; +import { UIState } from '../types'; import { createSelector } from 'reselect'; -export const getUi = (state: RootState) => state.ui; +export const getUi = (state: StoreState) => state.ui; export const getSearchQuery = createSelector(getUi, (state: UIState) => state.searchQuery); diff --git a/src/common/store/user/actions.ts b/src/common/store/user/actions.ts index deb9c9d6..fe80605b 100755 --- a/src/common/store/user/actions.ts +++ b/src/common/store/user/actions.ts @@ -1,122 +1,24 @@ -import { SoundCloud, ThunkResult } from '@types'; -import fetchToJson from '../../api/helpers/fetchToJson'; -import { SC } from '../../utils'; -import { PlaylistTypes } from '../objects'; -import { getPlaylistO } from '../objects/actions'; -import { getArtistLikesPlaylistObject, getArtistTracksPlaylistObject, getPlaylistName } from '../objects/selectors'; -import { UserActionTypes } from './types'; - -/** - * Get and save user - */ -function getUser(userId: number) { - return { - type: UserActionTypes.SET, - payload: { - promise: fetchToJson(SC.getUserUrl(userId)) - .then(user => ({ - entities: { - userEntities: { - [user.id]: { - ...user, - loading: false - } - } - } - })) - .catch(() => ({ - entities: { - userEntities: { - [userId]: { - loading: false - } - } - } - })), - data: { - entities: { - userEntities: { - [userId]: { - loading: true - } - } - } - } - } - }; -} - -function getUserProfiles(userId: number) { - return { - type: UserActionTypes.SET_PROFILES, - payload: { - promise: fetchToJson(SC.getUserWebProfilesUrl(userId)) - .then(profiles => ({ - entities: { - userEntities: { - [userId]: { - profiles: { - loading: false, - items: profiles - } - } - } - } - })) - .catch(() => ({ - entities: { - userEntities: { - [userId]: { - profiles: { - loading: false, - items: [] - } - } - } - } - })), - data: { - entities: { - userEntities: { - [userId]: { - profiles: { - loading: true, - items: [] - } - } - } - } - } - } - }; -} - -export function fetchArtistIfNeeded(userId: number): ThunkResult { - return (dispatch, getState) => { - const state = getState(); - const { entities } = state; - const { userEntities } = entities; - - const user = userEntities[userId]; - - if (!user || (user && !user.followers_count && !user.loading)) { - dispatch(getUser(userId)); - } - - if (!user || (user && !user.profiles)) { - dispatch(getUserProfiles(userId)); - } - - if (!getArtistTracksPlaylistObject(userId.toString())(state)) { - dispatch( - getPlaylistO(SC.getUserTracksUrl(userId), getPlaylistName(userId.toString(), PlaylistTypes.ARTIST_TRACKS)) - ); - } - - if (!getArtistLikesPlaylistObject(userId.toString())(state)) { - dispatch( - getPlaylistO(SC.getUserLikesUrl(userId), getPlaylistName(userId.toString(), PlaylistTypes.ARTIST_LIKES)) - ); - } - }; -} +import { wError, wSuccess } from '@common/utils/reduxUtils'; +import { EntitiesOf, EpicFailure, SoundCloud } from '@types'; +import { createAsyncAction } from 'typesafe-actions'; +import { UserActionTypes } from '../types'; + +export const getUser = createAsyncAction( + String(UserActionTypes.GET_USER), + wSuccess(UserActionTypes.GET_USER), + wError(UserActionTypes.GET_USER) +)< + { refresh: boolean; userId: number }, + { userId: number; entities: EntitiesOf }, + EpicFailure & { userId: number } +>(); + +export const getUserProfiles = createAsyncAction( + String(UserActionTypes.GET_USER_PROFILES), + wSuccess(UserActionTypes.GET_USER_PROFILES), + wError(UserActionTypes.GET_USER_PROFILES) +)< + { userUrn: string }, + { userUrn: string; entities: EntitiesOf }, + EpicFailure & { userUrn: string } +>(); diff --git a/src/common/store/user/api.ts b/src/common/store/user/api.ts new file mode 100644 index 00000000..964ad0c7 --- /dev/null +++ b/src/common/store/user/api.ts @@ -0,0 +1,63 @@ +import fetchToJsonNew from '@common/api/helpers/fetchToJsonNew'; +import { Collection, SoundCloud } from '@types'; + +export async function fetchUser(options: { userId: string | number }) { + const json = await fetchToJsonNew({ + uri: `users/${options.userId}`, + oauthToken: true, + useV2Endpoint: true + }); + + return json; +} + +export async function fetchUserTopTracks(options: { userId: number | string; limit?: number }) { + const json = await fetchToJsonNew>({ + uri: `users/${options.userId}/toptracks`, + clientId: true, + useV2Endpoint: true, + queryParams: { + limit: options.limit ?? 20, + linked_partitioning: 1 + } + }); + + return json; +} +export async function fetchUserTracks(options: { userId: number | string; limit?: number }) { + const json = await fetchToJsonNew>({ + uri: `users/${options.userId}/tracks`, + clientId: true, + useV2Endpoint: true, + queryParams: { + limit: options.limit ?? 20, + linked_partitioning: 1 + } + }); + + return json; +} + +export async function fetchUserLikes(options: { userId: string | string; limit?: number }) { + const json = await fetchToJsonNew>({ + uri: `users/${options.userId}/likes`, + oauthToken: true, + useV2Endpoint: true, + queryParams: { + limit: options.limit ?? 20, + linked_partitioning: 1 + } + }); + + return json; +} + +export async function fetchUserProfiles(options: { userUrn: string }) { + const json = await fetchToJsonNew({ + uri: `users/${options.userUrn}/web-profiles`, + oauthToken: true, + useV2Endpoint: true + }); + + return json; +} diff --git a/src/common/store/user/epics.ts b/src/common/store/user/epics.ts new file mode 100644 index 00000000..b66af32d --- /dev/null +++ b/src/common/store/user/epics.ts @@ -0,0 +1,76 @@ +import { normalizeArray } from '@common/schemas'; +import { EntitiesOf, SoundCloud } from '@types'; +import { AxiosError } from 'axios'; +import { from, of } from 'rxjs'; +import { catchError, filter, map, switchMap, tap, pluck } from 'rxjs/operators'; +import { isActionOf } from 'typesafe-actions'; +import { RootEpic } from '../declarations'; +import { getUser, getUserProfiles } from './actions'; +import * as APIService from './api'; + +const handleEpicError = (error: any) => { + if ((error as AxiosError).isAxiosError) { + console.log(error.message, error.response.data); + } else { + console.error('Epic error - user', error); + } + // TODO Sentry? + return error; +}; + +export const getUserEpic: RootEpic = action$ => + action$.pipe( + filter(isActionOf(getUser.request)), + tap(action => console.log(`${action.type} from ${process.type}`)), + pluck('payload'), + switchMap(({ userId, refresh }) => { + return from(APIService.fetchUser({ userId })).pipe( + map(user => normalizeArray([user])), + map(data => + getUser.success({ + userId, + entities: data.normalized.entities + }) + ), + catchError(error => + of( + getUser.failure({ + error: handleEpicError(error), + userId + }) + ) + ) + ); + }) + ); + +export const getUserProfilesEpic: RootEpic = action$ => + action$.pipe( + filter(isActionOf(getUserProfiles.request)), + tap(action => console.log(`${action.type} from ${process.type}`)), + pluck('payload'), + switchMap(({ userUrn }) => { + return from(APIService.fetchUserProfiles({ userUrn })).pipe( + map(data => { + const entities: EntitiesOf = { + userProfileEntities: { + [userUrn]: data + } + }; + + return getUserProfiles.success({ + userUrn, + entities + }); + }), + catchError(error => + of( + getUserProfiles.failure({ + error: handleEpicError(error), + userUrn + }) + ) + ) + ); + }) + ); diff --git a/src/common/store/user/reducer.ts b/src/common/store/user/reducer.ts new file mode 100644 index 00000000..637ad6d3 --- /dev/null +++ b/src/common/store/user/reducer.ts @@ -0,0 +1,100 @@ +import { createReducer } from 'typesafe-actions'; +import { resetStore } from '../actions'; +import { UserState } from '../types'; +import { getUser, getUserProfiles } from './actions'; + +const initialState: UserState = { + loading: [], + error: {}, + userProfilesLoading: [], + userProfilesError: {} +}; + +export const userReducer = createReducer(initialState) + .handleAction(getUser.request, (state, action) => { + const { userId } = action.payload; + + const errors = state.error; + + delete errors[userId]; + + return { + ...state, + loading: Array.from(new Set([...state.loading, userId])), + error: { + ...errors + } + }; + }) + .handleAction(getUser.success, (state, action) => { + const { userId } = action.payload; + + const errors = state.error; + + delete errors[userId]; + + return { + ...state, + loading: state.loading.filter(id => id !== userId), + error: { + ...errors + } + }; + }) + .handleAction(getUser.failure, (state, action) => { + const { userId, error } = action.payload; + + return { + ...state, + loading: state.loading.filter(id => id !== userId), + error: { + ...state.error, + [userId]: error + } + }; + }) + .handleAction(getUserProfiles.request, (state, action) => { + const { userUrn } = action.payload; + + const errors = state.userProfilesError; + + delete errors[userUrn]; + + return { + ...state, + userProfilesLoading: Array.from(new Set([...state.userProfilesLoading, userUrn])), + userProfilesError: { + ...errors + } + }; + }) + .handleAction(getUserProfiles.success, (state, action) => { + const { userUrn } = action.payload; + + const errors = state.userProfilesError; + + delete errors[userUrn]; + + return { + ...state, + userProfilesLoading: state.userProfilesLoading.filter(id => id !== userUrn), + userProfilesError: { + ...errors + } + }; + }) + .handleAction(getUserProfiles.failure, (state, action) => { + const { userUrn, error } = action.payload; + + return { + ...state, + userProfilesLoading: state.userProfilesLoading.filter(id => id !== userUrn), + userProfilesError: { + ...state.error, + [userUrn]: error + } + }; + }) + .handleAction(resetStore, () => { + return initialState; + }); diff --git a/src/common/store/user/selectors.ts b/src/common/store/user/selectors.ts new file mode 100644 index 00000000..9fc7716a --- /dev/null +++ b/src/common/store/user/selectors.ts @@ -0,0 +1,12 @@ +import { StoreState } from 'AppReduxTypes'; +import { createSelector } from 'reselect'; + +export const getUser = (state: StoreState) => state.user; + +export const isUserLoading = (userId: string) => createSelector([getUser], user => user.loading.includes(+userId)); +export const isUserError = (userId: number | string) => createSelector([getUser], user => user.error[userId]); + +export const isUserProfilesLoading = (userUrn: string) => + createSelector([getUser], user => user.userProfilesLoading.includes(userUrn)); +export const isUserProfilesError = (userUrn: number | string) => + createSelector([getUser], user => user.userProfilesError[userUrn]); diff --git a/src/common/store/user/types.ts b/src/common/store/user/types.ts index 89562923..eb7b595a 100755 --- a/src/common/store/user/types.ts +++ b/src/common/store/user/types.ts @@ -1,8 +1,19 @@ +import { AxiosError } from 'axios'; + // TYPES +export interface UserState { + loading: number[]; + error: { [userId: string]: AxiosError | Error | null }; + userProfilesLoading: string[]; + userProfilesError: { [userId: string]: AxiosError | Error | null }; +} // ACTIONS export enum UserActionTypes { + GET_USER = '@@user/GET_USER', + GET_USER_PROFILES = '@@user/GET_USER_PROFILES', + SET_PROFILES = '@@user/SET_PROFILES', SET = '@@user/SET' } diff --git a/src/common/utils/playerUtils.ts b/src/common/utils/playerUtils.ts index d4a8d9e8..1f2f9090 100755 --- a/src/common/utils/playerUtils.ts +++ b/src/common/utils/playerUtils.ts @@ -1,6 +1,5 @@ import { findIndex } from 'lodash'; -// eslint-disable-next-line import/no-cycle -import { PlayerState, PlayerStatus, PlayingTrack } from '../store/player'; +import { PlayerState, PlayerStatus, PlayingTrack } from '../store/types'; export function isCurrentPlaylistPlaying(player: PlayerState, playlistId: string): boolean { return player.currentPlaylistId === playlistId && player.status === PlayerStatus.PLAYING; diff --git a/src/globals.d.ts b/src/globals.d.ts index 8ba50335..ccafd3f8 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -41,8 +41,38 @@ declare module 'react-marquee'; declare module 'AppReduxTypes' { import { StateType, ActionType } from 'typesafe-actions'; + import { AuthState } from '@common/store/auth'; + import { + AppAuthState, + EntitiesState, + PlayerState, + ObjectsState, + ConfigState, + TrackState, + UserState + } from '@common/store/types'; + import { AppState } from '@common/store/app'; + import { UIState } from '@common/store/ui'; + import { ModalState } from 'redux-modal'; + import { RouterState } from 'connected-react-router'; + + interface _StoreState { + auth: AuthState; + appAuth: AppAuthState; + entities: EntitiesState; + player: PlayerState; + objects: ObjectsState; + app: AppState; + config: ConfigState; + ui: UIState; + modal: ModalState; + router: RouterState; + track: TrackState; + user: UserState; + } export type Store = StateType; export type RootAction = ActionType; - export type RootState = StateType>; + export type RootState = StateType<_StoreState>; + export type StoreState = _StoreState; } diff --git a/src/main/app.ts b/src/main/app.ts index 9d8b50f6..814b7143 100755 --- a/src/main/app.ts +++ b/src/main/app.ts @@ -2,9 +2,8 @@ import { Intent } from '@blueprintjs/core'; import fetchTrack from '@common/api/fetchTrack'; import { axiosClient } from '@common/api/helpers/axiosClient'; import { addToast, push, setConfigKey } from '@common/store/actions'; -import { StoreState } from '@common/store/rootReducer'; // eslint-disable-next-line import/no-unresolved -import { RootState } from 'AppReduxTypes'; +import { StoreState } from 'AppReduxTypes'; // eslint-disable-next-line import/no-extraneous-dependencies import { app, BrowserWindow, BrowserWindowConstructorOptions, Event, Menu, nativeImage, shell } from 'electron'; import is from 'electron-is'; @@ -66,7 +65,7 @@ export class Auryo { }); } - public setStore(store: Store) { + public setStore(store: Store) { this.store = store; } @@ -186,7 +185,7 @@ export class Auryo { try { feature.register(); } catch (error) { - this.logger.error(error, `Error starting feature: ${feature.featureName}`); + this.logger.error(`Error starting feature: ${feature.featureName}`, error); } }; @@ -235,7 +234,7 @@ export class Auryo { await shell.openExternal(url); } } catch (err) { - this.logger.error(err); + this.logger.error('Error handling will navigate', err); } }); @@ -246,7 +245,7 @@ export class Auryo { await shell.openExternal(u); } } catch (err) { - this.logger.error(err); + this.logger.error('Error handling new window', err); } }); @@ -271,7 +270,7 @@ export class Auryo { redirectURL: mp3Url }); } catch (err) { - this.logger.error(err); + this.logger.error('Soundcloud stream hack', err); callback({ cancel: true }); } } @@ -326,11 +325,11 @@ export class Auryo { private readonly registerListeners = () => { if (this.mainWindow) { this.mainWindow.webContents.on('crashed', (event: Event) => { - this.logger.fatal(event, 'App Crashed'); + this.logger.fatal('App Crashed', event); }); this.mainWindow.on('unresponsive', (event: Event) => { - this.logger.fatal(event, 'App unresponsive'); + this.logger.fatal('App unresponsive', event); }); this.mainWindow.on('closed', () => { diff --git a/src/main/features/core/applicationMenu.ts b/src/main/features/core/applicationMenu.ts index 4e931870..b61fe676 100755 --- a/src/main/features/core/applicationMenu.ts +++ b/src/main/features/core/applicationMenu.ts @@ -1,6 +1,6 @@ import { EVENTS } from '@common/constants/events'; +import { push, setConfigKey, toggleStatus, changeTrack } from '@common/store/actions'; import { ChangeTypes, PlayerStatus, VolumeChangeTypes } from '@common/store/player'; -import { setConfigKey, changeTrack, toggleStatus, push } from '@common/store/actions'; import * as SC from '@common/utils/soundcloudUtils'; import { autobind } from 'core-decorators'; // eslint-disable-next-line import/no-extraneous-dependencies diff --git a/src/main/features/core/chromecast/chromecastManager.ts b/src/main/features/core/chromecast/chromecastManager.ts index dd05985a..83062d3f 100755 --- a/src/main/features/core/chromecast/chromecastManager.ts +++ b/src/main/features/core/chromecast/chromecastManager.ts @@ -2,9 +2,9 @@ import { PlatformSender } from '@amilajack/castv2-client'; import { Intent } from '@blueprintjs/core'; import { IMAGE_SIZES } from '@common/constants'; import { EVENTS } from '@common/constants/events'; -import { StoreState } from '@common/store/rootReducer'; +import { StoreState } from 'AppReduxTypes'; import { DevicePlayerStatus } from '@common/store/app'; -import { getTrackEntity } from '@common/store/entities/selectors'; +import { getTrackEntity } from '@common/store/selectors'; import { PlayerStatus } from '@common/store/player'; import { SC } from '@common/utils'; import { Logger, LoggerInstance } from '@main/utils/logger'; @@ -12,7 +12,7 @@ import { autobind } from 'core-decorators'; import { Feature, WatchState } from '../../feature'; import AuryoReceiver from './auryoReceiver'; import { startScanning } from './deviceScanner'; -import { addToast, setChromecastAppState, setChromeCastPlayerStatus, useChromeCast } from '@common/store/actions'; +import { addToast, setChromecastAppState, setChromeCastPlayerStatus, setChromecastDevice } from '@common/store/actions'; @autobind export default class ChromecastManager extends Feature { @@ -76,7 +76,7 @@ export default class ChromecastManager extends Feature { this.client.close(); } - this.store.dispatch(useChromeCast()); + this.store.dispatch(setChromecastDevice()); }); this.client.on('status', this.handleClientStatusChange); @@ -98,7 +98,7 @@ export default class ChromecastManager extends Feature { } catch (err) { this.logger.error(err); this.store.dispatch(setChromecastAppState(null)); - this.store.dispatch(useChromeCast()); + this.store.dispatch(setChromecastDevice()); throw err; } } @@ -217,7 +217,7 @@ export default class ChromecastManager extends Feature { ); } else { this.store.dispatch(setChromecastAppState(null)); - this.store.dispatch(useChromeCast()); + this.store.dispatch(setChromecastDevice()); } } } diff --git a/src/main/features/core/chromecast/deviceScanner.ts b/src/main/features/core/chromecast/deviceScanner.ts index 24d0bc15..f43b7783 100644 --- a/src/main/features/core/chromecast/deviceScanner.ts +++ b/src/main/features/core/chromecast/deviceScanner.ts @@ -1,4 +1,4 @@ -import { StoreState } from '@common/store/rootReducer'; +import { StoreState } from 'AppReduxTypes'; import { ChromeCastDevice } from '@common/store/app'; import { addChromeCastDevice, removeChromeCastDevice } from '@common/store/actions'; import createMdnsInterface from 'multicast-dns'; diff --git a/src/main/features/core/lastFm.ts b/src/main/features/core/lastFm.ts index b260d245..09416d70 100755 --- a/src/main/features/core/lastFm.ts +++ b/src/main/features/core/lastFm.ts @@ -1,6 +1,6 @@ import { Intent } from '@blueprintjs/core'; import { EVENTS } from '@common/constants/events'; -import { getTrackEntity } from '@common/store/entities/selectors'; +import { getTrackEntity } from '@common/store/selectors'; import { addToast, setConfigKey, setLastfmLoading } from '@common/store/actions'; import { SC } from '@common/utils'; import { Auryo } from '@main/app'; diff --git a/src/main/features/core/notificationManager.ts b/src/main/features/core/notificationManager.ts index 61c7242a..d2b1dd24 100755 --- a/src/main/features/core/notificationManager.ts +++ b/src/main/features/core/notificationManager.ts @@ -1,6 +1,6 @@ import { IMAGE_SIZES } from '@common/constants'; import { EVENTS } from '@common/constants/events'; -import { getTrackEntity } from '@common/store/entities/selectors'; +import { getTrackEntity } from '@common/store/selectors'; import { PlayingTrack } from '@common/store/player'; import { SC } from '@common/utils'; import { Auryo } from '@main/app'; diff --git a/src/main/features/feature.ts b/src/main/features/feature.ts index 599b5870..852783c3 100755 --- a/src/main/features/feature.ts +++ b/src/main/features/feature.ts @@ -1,4 +1,3 @@ -import { StoreState } from '@common/store/rootReducer'; // eslint-disable-next-line import/no-extraneous-dependencies import { BrowserWindow, ipcMain } from 'electron'; import { isEqual } from 'lodash'; @@ -6,6 +5,7 @@ import { Store } from 'redux'; import ReduxWatcher from 'redux-watcher'; // eslint-disable-next-line import/no-cycle import { Auryo } from '../app'; +import { StoreState } from 'AppReduxTypes'; export type Handler = (t: { store: Store; diff --git a/src/main/features/linux/dbusService.ts b/src/main/features/linux/dbusService.ts index dfdb5b22..46c3bda9 100755 --- a/src/main/features/linux/dbusService.ts +++ b/src/main/features/linux/dbusService.ts @@ -1,12 +1,12 @@ -import { ChangeTypes, PlayerStatus } from '@common/store/player'; import { changeTrack, toggleStatus } from '@common/store/actions'; +import { ChangeTypes, PlayerStatus } from '@common/store/player'; +import { Auryo } from '@main/app'; // eslint-disable-next-line import/no-extraneous-dependencies import * as dbus from 'dbus-next'; -import { Logger, LoggerInstance } from '../../utils/logger'; -import LinuxFeature from './linuxFeature'; // eslint-disable-next-line import/no-extraneous-dependencies import { app } from 'electron'; -import { Auryo } from '@main/app'; +import { Logger, LoggerInstance } from '../../utils/logger'; +import LinuxFeature from './linuxFeature'; export default class DbusService extends LinuxFeature { public readonly featureName = 'DbusService'; diff --git a/src/main/features/linux/mprisService.ts b/src/main/features/linux/mprisService.ts index e0311b7f..7dce30cd 100755 --- a/src/main/features/linux/mprisService.ts +++ b/src/main/features/linux/mprisService.ts @@ -1,8 +1,8 @@ import { EVENTS } from '@common/constants/events'; import { IMAGE_SIZES } from '@common/constants/Soundcloud'; -import { getTrackEntity } from '@common/store/entities/selectors'; -import { ChangeTypes, PlayerStatus } from '@common/store/player'; import { changeTrack, toggleStatus } from '@common/store/actions'; +import { ChangeTypes, PlayerStatus } from '@common/store/player'; +import { getTrackEntity } from '@common/store/selectors'; import { getCurrentPosition } from '@common/utils'; import * as SC from '@common/utils/soundcloudUtils'; import * as _ from 'lodash'; diff --git a/src/main/features/mac/mediaServiceManager.ts b/src/main/features/mac/mediaServiceManager.ts index d255c07a..bc7bf6da 100755 --- a/src/main/features/mac/mediaServiceManager.ts +++ b/src/main/features/mac/mediaServiceManager.ts @@ -1,8 +1,8 @@ import { EVENTS } from '@common/constants/events'; import { IMAGE_SIZES } from '@common/constants/Soundcloud'; import { changeTrack, toggleStatus } from '@common/store/actions'; -import { getTrackEntity } from '@common/store/entities/selectors'; import { ChangeTypes, PlayerStatus, PlayingTrack } from '@common/store/player'; +import { getTrackEntity } from '@common/store/selectors'; import * as SC from '@common/utils/soundcloudUtils'; // eslint-disable-next-line import/no-extraneous-dependencies import MediaService, { MetaData } from 'electron-media-service'; diff --git a/src/main/features/mac/touchBarManager.ts b/src/main/features/mac/touchBarManager.ts index 0d7ca4a8..a64b70b4 100755 --- a/src/main/features/mac/touchBarManager.ts +++ b/src/main/features/mac/touchBarManager.ts @@ -1,6 +1,6 @@ import { EVENTS } from '@common/constants/events'; -import { ChangeTypes, PlayerStatus } from '@common/store/player'; import { changeTrack, toggleStatus } from '@common/store/actions'; +import { ChangeTypes, PlayerStatus } from '@common/store/player'; import * as SC from '@common/utils/soundcloudUtils'; import { autobind } from 'core-decorators'; // eslint-disable-next-line import/no-extraneous-dependencies diff --git a/src/main/features/win32/thumbar.ts b/src/main/features/win32/thumbar.ts index 365a2f26..773e54a9 100755 --- a/src/main/features/win32/thumbar.ts +++ b/src/main/features/win32/thumbar.ts @@ -1,13 +1,13 @@ -import { StoreState } from '@common/store/rootReducer'; -import { ChangeTypes, PlayerStatus } from '@common/store/player'; import { changeTrack, toggleStatus } from '@common/store/actions'; +import { ChangeTypes, PlayerStatus } from '@common/store/player'; +import { StoreState } from 'AppReduxTypes'; +import { autobind } from 'core-decorators'; // eslint-disable-next-line import/no-extraneous-dependencies import { nativeImage } from 'electron'; import * as is from 'electron-is'; import * as path from 'path'; import { Auryo } from '../../app'; import { Feature } from '../feature'; -import { autobind } from 'core-decorators'; const iconsDirectory = path.resolve(global.__static, 'icons'); diff --git a/src/main/features/win32/win10/win10MediaService.ts b/src/main/features/win32/win10/win10MediaService.ts index 6a6f1e7a..63cfc042 100644 --- a/src/main/features/win32/win10/win10MediaService.ts +++ b/src/main/features/win32/win10/win10MediaService.ts @@ -1,8 +1,8 @@ import { EVENTS } from '@common/constants/events'; import { IMAGE_SIZES } from '@common/constants/Soundcloud'; import { changeTrack, toggleStatus } from '@common/store/actions'; -import { getTrackEntity } from '@common/store/entities/selectors'; import { ChangeTypes, PlayerStatus, PlayingTrack } from '@common/store/player'; +import { getTrackEntity } from '@common/store/selectors'; import * as SC from '@common/utils/soundcloudUtils'; import { Logger, LoggerInstance } from '../../../utils/logger'; import { WindowsFeature } from '../windowsFeature'; diff --git a/src/main/index.ts b/src/main/index.ts index 543009ec..e6a2f322 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -55,6 +55,6 @@ app.on('ready', async () => { try { await auryo.start(); } catch (err) { - Logger.defaultLogger().error(err); + Logger.defaultLogger().error('Error starting auryo', err); } }); diff --git a/src/main/utils/logger.ts b/src/main/utils/logger.ts index b6b2aeed..bcf41d6a 100644 --- a/src/main/utils/logger.ts +++ b/src/main/utils/logger.ts @@ -7,7 +7,7 @@ const config: pino.LoggerOptions = { colorize: !isProd }, base: null, - level: isProd ? 'info' : 'debug' + level: isProd ? 'info' : process.env.LOG_LEVEL ?? 'debug' }; export type LoggerInstance = pino.Logger; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6bc7bc62..f01e33be 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,8 +1,7 @@ import { EVENTS } from '@common/constants/events'; import { history } from '@common/store'; -import { stopWatchers, toggleStatus } from '@common/store/actions'; -import { configSelector } from '@common/store/config/selectors'; -import { ConnectedRouter, push } from 'connected-react-router'; +import { configSelector } from '@common/store/selectors'; +import { ua } from '@common/utils/universalAnalytics'; // eslint-disable-next-line import/no-extraneous-dependencies import { ipcRenderer, remote } from 'electron'; import { UnregisterCallback } from 'history'; @@ -11,10 +10,9 @@ import React, { FC, useEffect } from 'react'; import { hot } from 'react-hot-loader/root'; import { useDispatch, useSelector } from 'react-redux'; import { Route, Switch } from 'react-router-dom'; +import { useKey } from 'react-use'; import Main from './app/Main'; import OnBoarding from './pages/onboarding/OnBoarding'; -import { ua } from '@common/utils/universalAnalytics'; -import { useKey } from 'react-use'; export const App: FC = () => { const analyticsEnabled = useSelector(state => configSelector(state).app.analytics); @@ -45,7 +43,6 @@ export const App: FC = () => { }); return () => { - dispatch(stopWatchers() as any); unregister(); }; }, [dispatch]); diff --git a/src/renderer/_shared/ActionsDropdown.tsx b/src/renderer/_shared/ActionsDropdown.tsx index 4fa5f45d..36aa9e3f 100644 --- a/src/renderer/_shared/ActionsDropdown.tsx +++ b/src/renderer/_shared/ActionsDropdown.tsx @@ -1,162 +1,68 @@ import { Menu, MenuDivider, MenuItem, Popover, Position } from '@blueprintjs/core'; -import { StoreState } from '@common/store/rootReducer'; -import * as actions from '@common/store/actions'; -import { getAuthLikesSelector, getAuthRepostsSelector, getUserPlaylistsCombined } from '@common/store/auth/selectors'; -import { SC } from '@common/utils'; +import { addUpNext, toggleLike, toggleRepost } from '@common/store/actions'; +import { getNormalizedSchemaForType, hasLiked } from '@common/store/selectors'; import { IPC } from '@common/utils/ipc'; import cn from 'classnames'; -import _ from 'lodash'; -import React from 'react'; -import { connect } from 'react-redux'; -import { bindActionCreators, Dispatch } from 'redux'; +import React, { FC } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { SoundCloud } from '../../types'; import ShareMenuItem from './ShareMenuItem'; -const mapStateToProps = (state: StoreState) => ({ - userPlaylists: getUserPlaylistsCombined(state), - likes: getAuthLikesSelector(state), - reposts: getAuthRepostsSelector(state) -}); - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - addUpNext: actions.addUpNext, - toggleLike: actions.toggleLike, - toggleRepost: actions.toggleRepost, - togglePlaylistTrack: actions.togglePlaylistTrack - }, - dispatch - ); - -interface OwnProps { - track: SoundCloud.Music; +interface Props { + trackOrPlaylist: SoundCloud.Playlist | SoundCloud.Track; index?: number; playing?: boolean; currentPlaylistId?: string; } -type PropsFromState = ReturnType; - -type PropsFromDispatch = ReturnType; - -type AllProps = OwnProps & PropsFromState & PropsFromDispatch; - -class ActionsDropdown extends React.Component { - public shouldComponentUpdate(nextProps: AllProps) { - const { track, likes, index, reposts, userPlaylists } = this.props; - - return ( - track.id !== nextProps.track.id || - index !== nextProps.index || - !_.isEqual(likes, nextProps.likes) || - !_.isEqual(reposts, nextProps.reposts) || - !_.isEqual(userPlaylists, nextProps.userPlaylists) - ); - } - - // tslint:disable-next-line: max-func-body-length - public render() { - const { - toggleLike, - toggleRepost, - reposts, - likes, - track, - addUpNext, - playing, - index - // userPlaylists, - // togglePlaylistTrack, - // currentPlaylistId - } = this.props; - - const trackId = track.id; - - const liked = SC.hasID(track.id, track.kind === 'playlist' ? likes.playlist : likes.track); - const reposted = SC.hasID(track.id, track.kind === 'playlist' ? reposts.playlist : reposts.track); - - // const currentPlaylist: CombinedUserPlaylistState | null = - // currentPlaylistId ? userPlaylists.find((p) => p.id === +currentPlaylistId) || null : null; - - // const inPlaylist = currentPlaylist ? currentPlaylist.items.find((t) => t.id === trackId) : false; - - const likedText = liked ? 'Liked' : 'Like'; - const repostedText = reposted ? 'Reposted' : 'Repost'; - - return ( - - toggleLike(trackId, track.kind === 'playlist')} - /> - - toggleRepost(trackId, track.kind === 'playlist')} - /> - - {_.isNil(index) && addUpNext(track)} />} - - {track.kind !== 'playlist' ? ( - -
- I'm sorry, this feature has been disabled to preserve your playlists. Since we are unable to fetch all - tracks, we do not know for sure if we will delete tracks upon adding/removing track via Auryo. -
- - {/* { - userPlaylists.map((playlist) => { - const inPlaylist = !!playlist.items.find((t) => t.id === track.id); - - return ( - { - togglePlaylistTrack(track.id, playlist.id); - }} - text={playlist.title} - /> - ); - }) - } */} - - ) : null} - - {/* { - currentPlaylist && inPlaylist && ( - togglePlaylistTrack(track.id, currentPlaylist.id)} - /> - ) - } */} - - {index !== undefined && !playing ? ( - addUpNext(track, index)} /> - ) : null} - - - - IPC.openExternal(track.permalink_url)} /> - - - }> -
- - - - ); - } -} - -export default connect(mapStateToProps, mapDispatchToProps)(ActionsDropdown); +export const ActionsDropdown: FC = ({ trackOrPlaylist }) => { + const dispatch = useDispatch(); + const isLiked = useSelector(hasLiked(trackOrPlaylist.id, trackOrPlaylist.kind)); + const isReposted = useSelector(hasLiked(trackOrPlaylist.id, trackOrPlaylist.kind)); + const idResult = getNormalizedSchemaForType(trackOrPlaylist); + + const likedText = isLiked ? 'Liked' : 'Like'; + const repostedText = isReposted ? 'Reposted' : 'Repost'; + + return ( + + dispatch(toggleLike.request({ id: trackOrPlaylist.id, type: trackOrPlaylist.kind }))} + /> + + dispatch(toggleRepost.request({ id: trackOrPlaylist.id, type: trackOrPlaylist.kind }))} + /> + + dispatch(addUpNext.request(idResult))} /> + + {/* {index !== undefined && !playing ? ( + addUpNext(trackOrPlaylist, index)} /> + ) : null} */} + + + + IPC.openExternal(trackOrPlaylist.permalink_url)} /> + + + }> + + + + + ); +}; diff --git a/src/renderer/_shared/CommentList/CommentList.tsx b/src/renderer/_shared/CommentList/CommentList.tsx index 765a1114..f8e97410 100644 --- a/src/renderer/_shared/CommentList/CommentList.tsx +++ b/src/renderer/_shared/CommentList/CommentList.tsx @@ -1,9 +1,9 @@ import { Normalized } from '@types'; -import React from 'react'; +import React, { useRef } from 'react'; import ReactList from 'react-list'; -import { useInfiniteScroll } from '../hooks/useInfiniteScroll'; import Spinner from '../Spinner/Spinner'; import CommentListItem from './CommentListItem/CommentListitem'; +import { InfiniteScroll } from '../InfiniteScroll'; interface Props { items: Normalized.NormalizedResult[]; @@ -14,9 +14,7 @@ interface Props { loadMore?(): Promise; } -export const CommentList: React.SFC = ({ isLoading, loadMore, items = [], hasMore }) => { - useInfiniteScroll(isLoading, hasMore ? loadMore : undefined); - +export const CommentList: React.SFC = ({ isLoading = false, loadMore, items = [], hasMore = false }) => { function renderItem(index: number) { const item = items[index]; @@ -25,8 +23,10 @@ export const CommentList: React.SFC = ({ isLoading, loadMore, items = [], return (
- - {isLoading && } + + + {isLoading && } +
); }; diff --git a/src/renderer/_shared/CommentList/CommentListItem/CommentListitem.tsx b/src/renderer/_shared/CommentList/CommentListItem/CommentListitem.tsx index d94e8461..05f6f43a 100644 --- a/src/renderer/_shared/CommentList/CommentListItem/CommentListitem.tsx +++ b/src/renderer/_shared/CommentList/CommentListItem/CommentListitem.tsx @@ -1,6 +1,6 @@ import fallbackAvatar from '@assets/img/avatar_placeholder.jpg'; import { IMAGE_SIZES } from '@common/constants'; -import { getCommentEntity } from '@common/store/entities/selectors'; +import { getCommentEntity } from '@common/store/selectors'; import { SC } from '@common/utils'; import { Normalized } from '@types'; import moment from 'moment'; diff --git a/src/renderer/_shared/ErrorBoundary.tsx b/src/renderer/_shared/ErrorBoundary.tsx index 56edb6f6..b783b807 100644 --- a/src/renderer/_shared/ErrorBoundary.tsx +++ b/src/renderer/_shared/ErrorBoundary.tsx @@ -17,9 +17,9 @@ class ErrorBoundary extends React.PureComponent<{}, State> { // this.setState({ hasError: true, message: error.message }); // You can also log the error to an error reporting service // eslint-disable-next-line no-console - console.error(errorInfo.componentStack); + console.error('ErrorBoundary', errorInfo.componentStack); // eslint-disable-next-line no-console - console.error(error); + console.error('ErrorBoundary', error); } private reload() { diff --git a/src/renderer/_shared/InfiniteScroll.tsx b/src/renderer/_shared/InfiniteScroll.tsx new file mode 100644 index 00000000..ba2ccea4 --- /dev/null +++ b/src/renderer/_shared/InfiniteScroll.tsx @@ -0,0 +1,46 @@ +import React, { FC, useEffect, useRef } from 'react'; + +interface Props { + isFetching: boolean; + hasMore: boolean; + loadMore?: Function; +} + +export const InfiniteScroll: FC = ({ isFetching, hasMore, loadMore, children }) => { + const bottomRef = useRef(); + + useEffect(() => { + const currentRef = bottomRef.current; + const currentObserver = new IntersectionObserver( + entries => { + const firstEntry = entries[0]; + + if (firstEntry.isIntersecting && !isFetching) { + loadMore?.(); + } + }, + { + root: document.getElementById('scrollContainer'), + rootMargin: '100px', + threshold: 1 + } + ); + + if (currentRef && hasMore) { + currentObserver.observe(currentRef); + } + + return () => { + if (currentRef) { + currentObserver.unobserve(currentRef); + } + }; + }, [bottomRef, hasMore]); + + return ( + <> + {children} +
+ + ); +}; diff --git a/src/renderer/_shared/PageHeader/components/ToggleFollowButton.tsx b/src/renderer/_shared/PageHeader/components/ToggleFollowButton.tsx new file mode 100755 index 00000000..581dd46f --- /dev/null +++ b/src/renderer/_shared/PageHeader/components/ToggleFollowButton.tsx @@ -0,0 +1,33 @@ +import * as actions from '@common/store/actions'; +import { isFollowing } from '@common/store/selectors'; +import cn from 'classnames'; +import React, { FC } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +interface Props { + className?: string; + userId: string; + colored?: boolean; +} + +export const ToggleFollowButton: FC = ({ className, userId, colored }) => { + const isFollowingArtist = useSelector(isFollowing(userId)); + const dispatch = useDispatch(); + + return ( + { + dispatch(actions.toggleFollowing.request({ userId })); + }}> + + {isFollowingArtist ? 'Following' : 'Follow'} + + ); +}; diff --git a/src/renderer/_shared/PageHeader/components/ToggleLikeButton.tsx b/src/renderer/_shared/PageHeader/components/ToggleLikeButton.tsx index cea7674c..454906cd 100755 --- a/src/renderer/_shared/PageHeader/components/ToggleLikeButton.tsx +++ b/src/renderer/_shared/PageHeader/components/ToggleLikeButton.tsx @@ -1,20 +1,19 @@ import * as actions from '@common/store/actions'; -import { hasLiked } from '@common/store/auth/selectors'; +import { hasLiked } from '@common/store/selectors'; +import { LikeType } from '@common/store/types'; import cn from 'classnames'; import React, { FC } from 'react'; import { useDispatch, useSelector } from 'react-redux'; interface Props { className?: string; - - playlistId?: string; - trackId?: number; + type: LikeType; + id: string | number; colored?: boolean; } -export const ToggleLikeButton: FC = ({ className, playlistId, trackId, colored }) => { - const playlistOrTrackId = (playlistId || trackId) as number | string; - const liked = useSelector(hasLiked(playlistOrTrackId, playlistId ? 'playlist' : 'track')); +export const ToggleLikeButton: FC = ({ className, id, type, colored }) => { + const liked = useSelector(hasLiked(id, type)); const dispatch = useDispatch(); return ( @@ -22,7 +21,7 @@ export const ToggleLikeButton: FC = ({ className, playlistId, trackId, co href="javascript:void(0)" className={cn('c_btn', className, { active: liked, colored })} onClick={() => { - dispatch(actions.toggleLike(playlistOrTrackId, !!playlistId)); + dispatch(actions.toggleLike.request({ id, type })); }}> {liked ? 'Liked' : 'Like'} diff --git a/src/renderer/_shared/PageHeader/components/TogglePlayButton.tsx b/src/renderer/_shared/PageHeader/components/TogglePlayButton.tsx index 71d16da7..b26411ab 100755 --- a/src/renderer/_shared/PageHeader/components/TogglePlayButton.tsx +++ b/src/renderer/_shared/PageHeader/components/TogglePlayButton.tsx @@ -1,55 +1,63 @@ import * as actions from '@common/store/actions'; +import { startPlayMusic } from '@common/store/actions'; import { PlayerStatus } from '@common/store/player'; +import { getPlayerStatus, isPlayingSelector } from '@common/store/selectors'; +import { PlaylistIdentifier } from '@common/store/types'; +import { Normalized } from '@types'; +import cn from 'classnames'; import React, { FC, useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import cn from 'classnames'; interface Props { className?: string; colored?: boolean; - - playlistId?: string; - trackId?: number; - onPlay(): void; + large?: boolean; + idResult?: Normalized.NormalizedResult; + playlistID: PlaylistIdentifier; + onPlay?(): void; } -export const TogglePlayButton: FC = ({ className, playlistId, trackId, onPlay, colored }) => { - const playerStatus = useSelector(state => state.player.status); - const isPlayerPlaylist = useSelector(state => !!playlistId && state.player.currentPlaylistId === playlistId); - const isTrackPlaying = useSelector(state => !!trackId && state.player.playingTrack?.id === trackId); +export const TogglePlayButton: FC = ({ className, idResult, colored, large, playlistID }) => { + const playerStatus = useSelector(getPlayerStatus); + const isPlaying = useSelector(isPlayingSelector(playlistID, idResult)); + const isPlayerPlaylist = false; const dispatch = useDispatch(); + const onStartPlay = useCallback(() => { + dispatch(startPlayMusic({ idResult, origin: playlistID })); + }, [dispatch, idResult, playlistID]); + const togglePlay = useCallback( (event: React.MouseEvent) => { event.preventDefault(); event.nativeEvent.stopImmediatePropagation(); - if (isPlayerPlaylist || isTrackPlaying) { + if (isPlayerPlaylist || isPlaying) { if (playerStatus !== PlayerStatus.PLAYING) { dispatch(actions.toggleStatus(PlayerStatus.PLAYING)); } else if (playerStatus === PlayerStatus.PLAYING) { dispatch(actions.toggleStatus(PlayerStatus.PAUSED)); } } else { - onPlay(); + onStartPlay(); } }, - [dispatch, isPlayerPlaylist, isTrackPlaying, onPlay, playerStatus] + [dispatch, isPlayerPlaylist, isPlaying, onStartPlay, playerStatus] ); const getIcon = useCallback(() => { let icon = 'play'; - if ((isPlayerPlaylist || isTrackPlaying) && playerStatus === PlayerStatus.PLAYING) { + if ((isPlayerPlaylist || isPlaying) && playerStatus === PlayerStatus.PLAYING) { icon = 'pause'; } return icon; - }, [isPlayerPlaylist, isTrackPlaying, playerStatus]); + }, [isPlayerPlaylist, isPlaying, playerStatus]); return ( - + ); diff --git a/src/renderer/_shared/PageHeader/components/ToggleRepostButton.tsx b/src/renderer/_shared/PageHeader/components/ToggleRepostButton.tsx index 630d062c..58bad404 100755 --- a/src/renderer/_shared/PageHeader/components/ToggleRepostButton.tsx +++ b/src/renderer/_shared/PageHeader/components/ToggleRepostButton.tsx @@ -1,20 +1,19 @@ import * as actions from '@common/store/actions'; -import { hasReposted } from '@common/store/auth/selectors'; +import { hasReposted } from '@common/store/selectors'; import cn from 'classnames'; import React, { FC } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { RepostType } from '@common/store/types'; interface Props { className?: string; - - playlistId?: string; - trackId?: number; + id: string | number; + type: RepostType; colored?: boolean; } -export const ToggleRepostButton: FC = ({ className, playlistId, trackId, colored }) => { - const playlistOrTrackId = (playlistId || trackId) as number | string; - const reposted = useSelector(hasReposted(playlistOrTrackId, playlistId ? 'playlist' : 'track')); +export const ToggleRepostButton: FC = ({ className, id, type, colored }) => { + const reposted = useSelector(hasReposted(id, type)); const dispatch = useDispatch(); return ( @@ -22,7 +21,7 @@ export const ToggleRepostButton: FC = ({ className, playlistId, trackId, href="javascript:void(0)" className={cn('c_btn', className, { active: reposted, colored })} onClick={() => { - dispatch(actions.toggleRepost(playlistOrTrackId, !!playlistId)); + dispatch(actions.toggleRepost.request({ id, type })); }}> {reposted ? 'Reposted' : 'Repost'} diff --git a/src/renderer/_shared/PlaylistTrackList.tsx b/src/renderer/_shared/PlaylistTrackList.tsx new file mode 100644 index 00000000..536a4936 --- /dev/null +++ b/src/renderer/_shared/PlaylistTrackList.tsx @@ -0,0 +1,45 @@ +import { genericPlaylistFetchMore, getGenericPlaylist } from '@common/store/actions'; +import { getPlaylistObjectSelector } from '@common/store/selectors'; +import { PlaylistIdentifier } from '@common/store/types'; +import { useLoadMorePromise } from '@renderer/hooks/useLoadMorePromise'; +import Spinner from '@renderer/_shared/Spinner/Spinner'; +import { TrackList } from '@renderer/_shared/TrackList/TrackList'; +import React, { FC, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +export interface Props { + id: PlaylistIdentifier; +} + +export const PlaylistTrackList: FC = ({ id }) => { + const dispatch = useDispatch(); + const playlist = useSelector(getPlaylistObjectSelector(id)); + + useEffect(() => { + dispatch(getGenericPlaylist.request({ refresh: true, ...id })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch, id]); + + const { loadMore } = useLoadMorePromise( + playlist?.isFetching, + () => { + dispatch(genericPlaylistFetchMore.request(id)); + }, + [dispatch, id] + ); + + if (!playlist) { + return ; + } + + return ( + + ); +}; diff --git a/src/renderer/_shared/TrackList/TrackList.tsx b/src/renderer/_shared/TrackList/TrackList.tsx index 1a39a8c0..1f602496 100755 --- a/src/renderer/_shared/TrackList/TrackList.tsx +++ b/src/renderer/_shared/TrackList/TrackList.tsx @@ -1,13 +1,14 @@ import { Normalized } from '@types'; import React from 'react'; import ReactList from 'react-list'; -import { useInfiniteScroll } from '../hooks/useInfiniteScroll'; +import { InfiniteScroll } from '../InfiniteScroll'; import Spinner from '../Spinner/Spinner'; import TrackListItem from './TrackListItem/TrackListItem'; +import { PlaylistIdentifier } from '@common/store/types'; interface Props { items: Normalized.NormalizedResult[]; - objectId: string; + id: PlaylistIdentifier; hideFirstTrack?: boolean; // Infinite loading @@ -16,9 +17,14 @@ interface Props { loadMore?(): Promise; } -export const TrackList: React.SFC = ({ items, objectId, hideFirstTrack, isLoading, loadMore, hasMore }) => { - useInfiniteScroll(isLoading, hasMore ? loadMore : undefined); - +export const TrackList: React.SFC = ({ + items, + id, + hideFirstTrack, + isLoading = false, + loadMore, + hasMore = false +}) => { function renderItem(index: number) { // using a spread because we don't want to unshift the original list const showedItems = [...items]; @@ -29,7 +35,7 @@ export const TrackList: React.SFC = ({ items, objectId, hideFirstTrack, i const item = showedItems[index]; - return ; + return ; } function renderWrapper(children: JSX.Element[], ref: string) { @@ -53,17 +59,19 @@ export const TrackList: React.SFC = ({ items, objectId, hideFirstTrack, i return (
- + + - {isLoading && } + {isLoading && } +
); }; diff --git a/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.scss b/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.scss index 9c53d512..2c9b0854 100644 --- a/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.scss +++ b/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.scss @@ -14,19 +14,23 @@ width: 5%; max-width: 5%; } + .row-title { width: 50%; max-width: 50%; } + .row-artist { width: 25%; max-width: 25%; } + .row-timer { width: 7%; max-width: 7%; text-align: center; } + .row-actions { width: 13%; max-width: 13%; @@ -43,16 +47,19 @@ font-size: 0.9rem; align-items: center; border-radius: 10px; + .stats { margin-bottom: 0; font-size: 0.8rem; pointer-events: none; } + &:hover, &.isPlaying { td { background: var(--clr-track-item-bg-active); } + .toggleButton { opacity: 1; transform: scale(1, 1); @@ -81,6 +88,7 @@ .img-with-shadow { position: relative; z-index: 1; + img, picture { width: 40px; @@ -132,17 +140,22 @@ a:not(.toggleButton) { color: var(--clr-track-item-link); } + .time { text-align: right; pointer-events: none; + color: var(--clr-track-item-link) } + .trackTitle { font-weight: $font-weight-bold; } + &.isPlaying .trackTitle a, &.isPlaying .trackArtist a { color: theme-color('primary'); } + .liked i { color: theme-color('primary'); } @@ -150,9 +163,10 @@ .trackitemActions { text-align: right; padding-right: 2rem; + a { text-decoration: none !important; } } } -} +} \ No newline at end of file diff --git a/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.tsx b/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.tsx index 611e5f80..d3b661bd 100755 --- a/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.tsx +++ b/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.tsx @@ -1,8 +1,6 @@ import { IMAGE_SIZES } from '@common/constants'; -import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; -import { getTrackEntity } from '@common/store/entities/selectors'; -import { isPlaying } from '@common/store/player/selectors'; +import { getTrackEntity, isPlayingSelector } from '@common/store/selectors'; import { abbreviateNumber, getReadableTime, SC } from '@common/utils'; import cn from 'classnames'; import React from 'react'; @@ -10,22 +8,24 @@ import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; import { bindActionCreators, Dispatch } from 'redux'; import { Normalized } from '../../../../types'; -import ActionsDropdown from '../../ActionsDropdown'; +import { ActionsDropdown } from '../../ActionsDropdown'; import FallbackImage from '../../FallbackImage'; +import { TogglePlayButton } from '../../PageHeader/components/TogglePlayButton'; import { TextShortener } from '../../TextShortener'; -import TogglePlayButton from '../../PageHeader/components/TogglePlayButton'; import './TrackListItem.scss'; +import { StoreState } from 'AppReduxTypes'; +import { PlaylistIdentifier } from '@common/store/types'; interface OwnProps { idResult: Normalized.NormalizedResult; - currentPlaylistId: string; + playlistId: PlaylistIdentifier; } const mapStateToProps = (state: StoreState, props: OwnProps) => { - const { idResult, currentPlaylistId } = props; + const { idResult, playlistId } = props; return { - isTrackPlaying: isPlaying(idResult, currentPlaylistId)(state), + isTrackPlaying: isPlayingSelector(idResult, playlistId.objectId || '')(state), track: getTrackEntity(idResult.id)(state) }; }; @@ -33,7 +33,7 @@ const mapStateToProps = (state: StoreState, props: OwnProps) => { const mapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( { - playTrack: actions.playTrack + playTrack: actions.playTrackO }, dispatch ); @@ -55,10 +55,10 @@ class TrackListItem extends React.PureComponent { } public renderToggleButton = () => { - const { isTrackPlaying } = this.props; + const { isTrackPlaying, idResult } = this.props; if (isTrackPlaying) { - return ; + return ; } const icon = isTrackPlaying ? 'pause' : 'play'; @@ -116,7 +116,7 @@ class TrackListItem extends React.PureComponent { {getReadableTime(track.duration, true, true)} - + ); diff --git a/src/renderer/_shared/TracksGrid/TrackGridRow.tsx b/src/renderer/_shared/TracksGrid/TrackGridRow.tsx index 2881c212..d2243613 100644 --- a/src/renderer/_shared/TracksGrid/TrackGridRow.tsx +++ b/src/renderer/_shared/TracksGrid/TrackGridRow.tsx @@ -1,9 +1,8 @@ import { PlaylistTypes } from '@common/store/objects'; import { PlaylistIdentifier } from '@common/store/playlist/types'; import cn from 'classnames'; -import { autobind } from 'core-decorators'; -import React from 'react'; -import TrackGridItem from './TrackgridItem/TrackGridItem'; +import React, { FC, useCallback, useMemo } from 'react'; +import { TrackGridItem } from './TrackgridItem/TrackGridItem'; import TrackGridUser from './TrackgridUser/TrackGridUser'; interface Props { @@ -11,74 +10,65 @@ interface Props { itemsPerRow: number; items: any[]; showInfo: boolean; - } & PlaylistIdentifier; + playlistID: PlaylistIdentifier; + }; index: number; style: React.CSSProperties; } -@autobind -export class TrackGridRow extends React.PureComponent { - get itemWidth(): string { - const { - data: { itemsPerRow } - } = this.props; +export const TrackGridRow: FC = ({ data, index, style }) => { + const { itemsPerRow, items, showInfo, playlistID } = data; - return `${100 / itemsPerRow}%`; - } + const itemWidth = `${100 / itemsPerRow}%`; + + const renderItem = useCallback( + (itemIndex: number) => { + const item = items[itemIndex]; - private renderItem(index: number) { - const { - data: { showInfo, objectId, playlistType, items } - } = this.props; + if (item.schema === 'users') { + return ( +
+ +
+ ); + } - const item = items[index]; + const showReposts = playlistID.playlistType === PlaylistTypes.STREAM; - if (item.schema === 'users') { return ( -
- +
+
); - } - - const showReposts = playlistType === PlaylistTypes.STREAM; - - return ( -
- -
- ); - } - - public render() { - const { data, index, style } = this.props; - const { itemsPerRow, items } = data; + }, + [itemWidth, items, playlistID, showInfo] + ); - const nodes = []; + const nodes = useMemo(() => { + const itemsInRow = []; const fromIndex = index * itemsPerRow; const toIndex = Math.min(fromIndex + itemsPerRow, items.length); for (let i = fromIndex; i < toIndex; i += 1) { - nodes.push(this.renderItem(i)); + itemsInRow.push(renderItem(i)); } - if (!nodes.length) { - return null; - } + return itemsInRow; + }, [index, items.length, itemsPerRow, renderItem]); - return ( -
- {nodes} -
- ); + if (!nodes.length) { + return null; } -} + + return ( +
+ {nodes} +
+ ); +}; diff --git a/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItem.scss b/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItem.scss index 67dcb857..c90953f1 100644 --- a/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItem.scss +++ b/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItem.scss @@ -8,6 +8,7 @@ .track-grid-item { padding: 10px; max-width: 260px; + .trackCount { position: absolute; top: 0; @@ -21,28 +22,35 @@ padding: 15px; font-size: .8rem; color: darken(#fff, 15%); + span { color: #fff; display: block; + &:first-child { font-size: 1.3rem; } } } + .actions-dropdown:not(.show) { opacity: 0; } + .actions-dropdown.show+.trackTime { opacity: 0 !important; } + .trackImage:hover, &.isPlaying .trackImage { + .actions-dropdown, .trackGenre { opacity: .8; transition: .5s all; } - .toggleButton { + + .playButton { opacity: 1; transform: scale(1, 1); -webkit-transform: scale(1, 1); @@ -50,27 +58,32 @@ transition-delay: .1s; z-index: 1; } + .trackStats { transform: translateY(0); -webkit-transform: translateY(0); transition: 0.1s opacity ease-out, 0.1s transform ease-out; } + .trackTime { opacity: 0; transition: 0.2s opacity; } } + .trackImage:hover { .trackStats { transform: translateY(0); -webkit-transform: translateY(0); transition: 0.1s opacity ease-out, 0.1s transform ease-out; } + .imageWrapper:after { opacity: .05; transition: 0.5s ease; } } + .trackTime { color: white; font-size: 14px; @@ -81,64 +94,49 @@ bottom: 0; margin-bottom: 12px; pointer-events: none; + i { font-size: 16px; padding-right: 3px; } + span, i { display: inline-block; vertical-align: middle; } } - .toggleButton { - background: #fff; - border-radius: 50px; + + .playButtonWrapper { position: absolute; right: 0; bottom: 0; - width: 50px; - height: 50px; - margin-bottom: 20px; - margin-right: 10px; - text-align: center; - box-shadow: 0 10px 60px rgba(0, 0, 0, 0.03), 0 6px 50px rgba(0, 0, 0, 0.13); + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + + .playButton { transform: scale(0, 0); opacity: 0; z-index: 10; cursor: pointer; - text-decoration: none; - &.minimal { - width: 100px; - height: 100px; - margin: auto; - top: 0; - left: 0; - bottom: 0; - right: 0; - opacity: .85; - box-shadow: none; - i { - font-size: 5rem; - } - } + i { color: theme-color("primary"); - font-size: 1.9rem; - width: 100%; - height: 100%; - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; } } + .trackImage { position: relative; + & .imageWrapper { overflow: hidden; box-shadow: 0 10px 60px rgba(0, 0, 0, 0.03), 0 6px 50px rgba(0, 0, 0, 0.13); position: relative; + & img { box-shadow: 0 10px 60px rgba(0, 0, 0, 0.03), 0 6px 50px rgba(0, 0, 0, 0.13); max-width: 100%; @@ -148,11 +146,14 @@ } } } + .trackInfo { padding: 5px 0; + i { color: #6e727d; padding-right: 8px; + &.icon-favorite { width: 40px; height: 40px; @@ -168,28 +169,36 @@ padding-right: 0; } } + .trackTitle { font-weight: $font-weight-bold; + a { color: var(--clr-track-title); } } + .trackArtist { font-size: 0.96em; + a { color: #6e727d; + &.repost { opacity: 0.7; } } + i { padding: 5px; } + * { vertical-align: middle; } } } + .trackGenre { position: absolute; left: 0; @@ -205,6 +214,7 @@ text-decoration: none; opacity: 0; } + .trackFooter { position: absolute; bottom: 0; @@ -212,6 +222,7 @@ z-index: 2; padding: 0 10px 10px 10px; border-radius: 0 0 .5rem .5rem; + &:after { content: ''; border-radius: 0 0 .5rem .5rem; @@ -223,26 +234,32 @@ bottom: 0; background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, .65)); } + .actions-dropdown { .bp3-popover-target a { color: #fff; text-decoration: none; + i { font-size: 1.5rem; } } } } + .trackStats { color: #fff; font-size: 14px; + .stat { display: inline-block; padding-right: 6px; + i { padding-right: 2px; font-size: 16px; } + span, i { display: inline-block; diff --git a/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItem.tsx b/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItem.tsx index ee0d0161..8f6932d5 100755 --- a/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItem.tsx +++ b/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItem.tsx @@ -1,155 +1,33 @@ import { IMAGE_SIZES } from '@common/constants'; -import { StoreState } from '@common/store/rootReducer'; -import * as actions from '@common/store/actions'; -import { getMusicEntity } from '@common/store/entities/selectors'; -import { isPlaying } from '@common/store/player/selectors'; +import { getMusicEntity, isPlayingSelector } from '@common/store/selectors'; +import { PlaylistIdentifier } from '@common/store/types'; import { abbreviateNumber, SC } from '@common/utils'; import { getReadableTime } from '@common/utils/appUtils'; import cn from 'classnames'; -import { autobind } from 'core-decorators'; -import React from 'react'; -import isDeepEqual from 'react-fast-compare'; -import { connect } from 'react-redux'; +import React, { FC, useMemo } from 'react'; +import { useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; -import { bindActionCreators, Dispatch } from 'redux'; import { Normalized, SoundCloud } from '../../../../types'; -import ActionsDropdown from '../../ActionsDropdown'; +import { ActionsDropdown } from '../../ActionsDropdown'; import FallbackImage from '../../FallbackImage'; -import { TextShortener } from '../../TextShortener'; -import TogglePlayButton from '../../PageHeader/components/TogglePlayButton'; +import { TogglePlayButton } from '../../PageHeader/components/TogglePlayButton'; import './TrackGridItem.scss'; -import { PlayingTrack } from '@common/store/player'; +import { TrackGridItemInfo } from './TrackGridItemInfo'; -const mapStateToProps = (state: StoreState, props: OwnProps) => { - const { idResult, currentPlaylistId } = props; - - return { - isTrackPlaying: isPlaying(idResult, currentPlaylistId)(state), - track: getMusicEntity(idResult)(state) - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - playTrack: actions.playTrack, - fetchPlaylistIfNeeded: actions.fetchPlaylistIfNeeded - }, - dispatch - ); - -interface OwnProps { +interface Props { idResult: Normalized.NormalizedResult; - currentPlaylistId: string; + playlistID: PlaylistIdentifier; showInfo?: boolean; showReposts: boolean; skipFetch?: boolean; } -type PropsFromState = ReturnType; - -type PropsFromDispatch = ReturnType; - -type AllProps = OwnProps & PropsFromState & PropsFromDispatch; - -@autobind -class TrackGridItem extends React.Component { - public static defaultProps: Partial = { - showInfo: false - }; - - public componentDidMount() { - const { track, fetchPlaylistIfNeeded, skipFetch } = this.props; - - if (track && track.kind === 'playlist' && track.track_count && !track.tracks && !skipFetch) { - // TODO - // fetchPlaylistIfNeeded(track.id); - } - } - - public shouldComponentUpdate(nextProps: AllProps) { - if (!isDeepEqual(nextProps, this.props)) { - return true; - } - - return false; - } - - public componentDidUpdate(prevProps: AllProps) { - const { track, fetchPlaylistIfNeeded, skipFetch } = this.props; - - if ( - (prevProps.track === null && track != null) || - (prevProps.track && track && prevProps.track.id !== track.id && !skipFetch) - ) { - if (track.kind === 'playlist' && track.track_count && !track.tracks) { - // TODO - // fetchPlaylistIfNeeded(track.id); - } - } - } - - public renderArtist() { - const { track, showReposts } = this.props; - - if (!track || !track.user) { - return null; - } - - if (track.fromUser && showReposts && track.type?.indexOf('repost') !== -1) { - return ( -
- {track.user.username} - - - - {track.fromUser.username} - -
- ); - } - - return ( -
- {track.user.username} -
- ); - } - - public renderToggleButton() { - const { isTrackPlaying, playTrack, currentPlaylistId, track } = this.props; - - if (!track) { - return null; - } - - if (isTrackPlaying) { - return ; - } - - const icon = isTrackPlaying ? 'pause' : 'play'; - - let next: Partial = { id: track.id }; - - if (track.kind === 'playlist') { - next = { playlistId: track.id.toString() }; - } - - return ( - { - playTrack(currentPlaylistId, next as PlayingTrack, true); - }}> - - - ); - } - - public renderStats() { - const { track, showInfo, currentPlaylistId } = this.props; +// TODO: onHover fetch Tracks if it is a playlist? +export const TrackGridItem: FC = ({ idResult, playlistID, showReposts, showInfo }) => { + const isTrackPlaying = useSelector(isPlayingSelector(playlistID, idResult)); + const track = useSelector(getMusicEntity(idResult)); + const Stats = useMemo(() => { if (!track || !track.user) { return null; } @@ -190,7 +68,7 @@ class TrackGridItem extends React.Component {
- +
@@ -199,75 +77,49 @@ class TrackGridItem extends React.Component {
); - } - - public renderInfo() { - const { track } = this.props; - - if (!track) { - return null; - } + }, [playlistID, showInfo, track]); - const objectUrl = `${track.kind === 'playlist' ? '/playlist' : '/track'}/${track.id}`; + const image = SC.getImageUrl(track, IMAGE_SIZES.LARGE); - return ( -
-
- - - -
- {this.renderArtist()} -
- ); + if (!track || !track.user) { + return null; } - public render() { - const { isTrackPlaying, track } = this.props; - - const image = SC.getImageUrl(track, IMAGE_SIZES.LARGE); - - if (!track || !track.user) { - return null; - } - - return ( + return ( +
-
-
-
- {track.kind === 'playlist' ? ( -
- {track.track_count} tracks -
- ) : null} - - {SC.isStreamable(track) || track.kind === 'playlist' ? this.renderToggleButton() : null} -
- - {this.renderStats()} - {track.genre && track.genre !== '' ? ( - - {track.genre} - +
+
+ {track.kind === 'playlist' ? ( +
+ {track.track_count} tracks +
+ ) : null} + + {SC.isStreamable(track) || track.kind === 'playlist' ? ( +
+ +
) : null}
- {this.renderInfo()} + {Stats} + {track.genre && track.genre !== '' ? ( + + {track.genre} + + ) : null}
-
- ); - } -} -export default connect( - mapStateToProps, - mapDispatchToProps -)(TrackGridItem); + +
+
+ ); +}; diff --git a/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItemInfo.tsx b/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItemInfo.tsx new file mode 100644 index 00000000..81105ed7 --- /dev/null +++ b/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItemInfo.tsx @@ -0,0 +1,37 @@ +import { TextShortener } from '@renderer/_shared/TextShortener'; +import { SoundCloud } from '@types'; +import React, { FC } from 'react'; +import { Link } from 'react-router-dom'; + +export interface Props { + track: SoundCloud.Track | SoundCloud.Playlist; + showReposts: boolean; +} + +export const TrackGridItemInfo: FC = ({ track, showReposts }) => { + const objectUrl = `${track.kind === 'playlist' ? '/playlist' : '/track'}/${track.id}`; + + return ( +
+
+ + + +
+ {track.fromUser && showReposts && track.type?.indexOf('repost') !== -1 ? ( +
+ {track.user.username} + + + + {track.fromUser.username} + +
+ ) : ( +
+ {track.user.username} +
+ )} +
+ ); +}; diff --git a/src/renderer/_shared/TracksGrid/TrackgridUser/TrackGridUser.tsx b/src/renderer/_shared/TracksGrid/TrackgridUser/TrackGridUser.tsx index 604bace7..14af6d55 100755 --- a/src/renderer/_shared/TracksGrid/TrackgridUser/TrackGridUser.tsx +++ b/src/renderer/_shared/TracksGrid/TrackgridUser/TrackGridUser.tsx @@ -1,24 +1,22 @@ import { IMAGE_SIZES } from '@common/constants'; -import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; -import { isFollowing } from '@common/store/auth/selectors'; -import { getUserEntity } from '@common/store/entities/selectors'; +import { isFollowing, getUserEntity } from '@common/store/selectors'; import { abbreviateNumber, SC } from '@common/utils'; import cn from 'classnames'; import React from 'react'; import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; import { bindActionCreators, Dispatch } from 'redux'; -import { Normalized } from '../../../../types'; import FallbackImage from '../../FallbackImage'; import './TrackGridUser.scss'; +import { StoreState } from 'AppReduxTypes'; const mapStateToProps = (state: StoreState, props: OwnProps) => { - const { idResult } = props; + const { userId } = props; return { - isAuthUserFollowing: idResult ? isFollowing(idResult.id)(state) : null, - trackUser: getUserEntity(idResult.id)(state) + isAuthUserFollowing: userId ? isFollowing(userId)(state) : null, + trackUser: getUserEntity(userId)(state) }; }; @@ -31,7 +29,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => ); interface OwnProps { - idResult: Normalized.NormalizedResult; + userId: number; withStats?: boolean; } @@ -40,6 +38,7 @@ type PropsFromDispatch = ReturnType; type AllProps = OwnProps & PropsFromState & PropsFromDispatch; +// TODO: use hooks class TrackGridUser extends React.PureComponent { public static defaultProps: Partial = { withStats: false diff --git a/src/renderer/_shared/TracksGrid/TracksGrid.tsx b/src/renderer/_shared/TracksGrid/TracksGrid.tsx index 0273b44d..95505ce7 100755 --- a/src/renderer/_shared/TracksGrid/TracksGrid.tsx +++ b/src/renderer/_shared/TracksGrid/TracksGrid.tsx @@ -10,7 +10,8 @@ import Spinner from '../Spinner/Spinner'; import { TrackGridRow } from './TrackGridRow'; import * as styles from './TracksGrid.module.scss'; -interface Props extends PlaylistIdentifier { +interface Props { + playlistID: PlaylistIdentifier; showInfo?: boolean; items: Normalized.NormalizedResult[]; @@ -26,7 +27,7 @@ function getRowsForWidth(width: number): number { } const TracksGrid: SFC = props => { - const { items, objectId, showInfo, isItemLoaded, loadMore, hasMore, isLoading, playlistType } = props; + const { items, showInfo, isItemLoaded, loadMore, hasMore, isLoading, playlistID } = props; const loaderRef = useRef(null); const { setList } = useContext(ContentContext); const listRef = loaderRef?.current?._listRef; @@ -83,9 +84,8 @@ const TracksGrid: SFC = props => { itemData={{ itemsPerRow, items, - objectId, - playlistType, - showInfo + showInfo, + playlistID }} itemSize={350} width={width}> diff --git a/src/renderer/_shared/hooks/useInfiniteScroll.tsx b/src/renderer/_shared/hooks/useInfiniteScroll.tsx deleted file mode 100644 index 628e54a6..00000000 --- a/src/renderer/_shared/hooks/useInfiniteScroll.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { throttle } from 'lodash'; -import { useCallback, useEffect } from 'react'; - -export const useInfiniteScroll = (isFetching = false, loadMore?: Function, triggerFetchPos = 300) => { - const elements = document.getElementsByClassName('content'); - - const element: HTMLDivElement = elements[0] as any; - - const throttleOnScroll = useCallback( - throttle(() => { - if ( - !isFetching && - element.scrollTop + element.offsetHeight + triggerFetchPos >= element.scrollHeight && - loadMore - ) { - loadMore(); - } - }), - [isFetching] - ); - - useEffect(() => { - if (!element) return; - - element.addEventListener('scroll', throttleOnScroll); - - // eslint-disable-next-line consistent-return - return () => element.removeEventListener('scroll', throttleOnScroll); - }, [isFetching, element, throttleOnScroll]); - - if (elements.length !== 1) { - return null; - } - - return []; -}; diff --git a/src/renderer/app/Layout.tsx b/src/renderer/app/Layout.tsx index 5f202198..10c74c94 100644 --- a/src/renderer/app/Layout.tsx +++ b/src/renderer/app/Layout.tsx @@ -1,6 +1,5 @@ import { Intent, IResizeEntry, Position, ResizeSensor } from '@blueprintjs/core'; import { EVENTS } from '@common/constants/events'; -import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; // eslint-disable-next-line import/no-cycle import { ContentContext, INITIAL_LAYOUT_SETTINGS, LayoutSettings } from '@renderer/_shared/context/contentContext'; @@ -27,6 +26,7 @@ import Player from './components/player/Player'; import SideBar from './components/Sidebar/Sidebar'; import { Themes } from './components/Theme/themes'; import { Toastr } from './components/Toastr'; +import { StoreState } from 'AppReduxTypes'; const mapStateToProps = (state: StoreState) => { const { @@ -54,8 +54,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => clearToasts: actions.clearToasts, removeToast: actions.removeToast, setDebouncedDimensions: actions.setDebouncedDimensions, - toggleOffline: actions.toggleOffline, - stopWatchers: actions.stopWatchers + toggleOffline: actions.toggleOffline }, dispatch ); @@ -258,6 +257,7 @@ class Layout extends React.Component { className="content" ref={this.contentRef} onScroll={this.handleScroll as any} + renderView={props =>
} renderTrackHorizontal={() =>
} renderTrackVertical={props =>
} renderThumbHorizontal={() =>
} diff --git a/src/renderer/app/Main.tsx b/src/renderer/app/Main.tsx index 21ff2537..1a63ebf5 100644 --- a/src/renderer/app/Main.tsx +++ b/src/renderer/app/Main.tsx @@ -5,14 +5,14 @@ import Settings from '@renderer/pages/settings/Settings'; import React, { FC, useCallback } from 'react'; import { useDispatch } from 'react-redux'; import { Redirect, Route, RouteComponentProps, Switch } from 'react-router-dom'; -import ArtistPage from '../pages/artist/ArtistPage'; +import { ArtistPage } from '../pages/artist/ArtistPage'; import { ChartsDetailsPage } from '../pages/charts/ChartsDetailsPage'; import { ChartsPage } from '../pages/charts/ChartsPage'; import ForYouPage from '../pages/foryou/ForYouPage'; import PlaylistPage from '../pages/playlist/PlaylistPage'; import { SearchPage } from '../pages/search/SearchPage'; import { TagsPage } from '../pages/tags/TagsPage'; -import TrackPage from '../pages/track/TrackPage'; +import { TrackPage } from '../pages/track/TrackPage'; import Spinner from '../_shared/Spinner/Spinner'; import Header from './components/Header/Header'; import Layout from './Layout'; diff --git a/src/renderer/app/components/Header/Header.tsx b/src/renderer/app/components/Header/Header.tsx index 651679e2..3e17c3af 100644 --- a/src/renderer/app/components/Header/Header.tsx +++ b/src/renderer/app/components/Header/Header.tsx @@ -1,9 +1,9 @@ import { Icon, Menu, MenuDivider, MenuItem, Popover, Position } from '@blueprintjs/core'; import { EVENTS } from '@common/constants/events'; -import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; -import { currentUserSelector } from '@common/store/auth/selectors'; +import { currentUserSelector } from '@common/store/selectors'; import { InjectedContentContextProps, withContentContext } from '@renderer/_shared/context/contentContext'; +import { StoreState } from 'AppReduxTypes'; import cn from 'classnames'; import * as ReactRouter from 'connected-react-router'; import { autobind } from 'core-decorators'; @@ -161,7 +161,7 @@ class Header extends React.Component { public handleSearch(prev: string, rawQuery?: string) { const { push, replace, setDebouncedSearchQuery } = this.props; - setDebouncedSearchQuery(rawQuery); + setDebouncedSearchQuery(rawQuery || ''); } // tslint:disable-next-line: max-func-body-length diff --git a/src/renderer/app/components/Queue/Queue.tsx b/src/renderer/app/components/Queue/Queue.tsx index 0a20fce6..0a0433b2 100644 --- a/src/renderer/app/components/Queue/Queue.tsx +++ b/src/renderer/app/components/Queue/Queue.tsx @@ -1,130 +1,104 @@ import { Classes } from '@blueprintjs/core'; -import { StoreState } from '@common/store/rootReducer'; -import * as actions from '@common/store/actions'; -import { getQueue } from '@common/store/player/selectors'; -import { autobind } from 'core-decorators'; -import { debounce } from 'lodash'; -import React from 'react'; +import { + getPlayingTrack, + getPlayingTrackIndex, + getPlaylistsObjects, + getQueuePlaylistSelector, + getPlayerUpNext +} from '@common/store/selectors'; +import { Normalized } from '@types'; +import React, { FC, useCallback, useEffect, useMemo, useRef } from 'react'; import Scrollbars from 'react-custom-scrollbars'; import ReactList from 'react-list'; -import { connect } from 'react-redux'; -import { bindActionCreators, Dispatch } from 'redux'; +import { useSelector } from 'react-redux'; import './Queue.scss'; import QueueItem from './QueueItem'; -const mapStateToProps = (state: StoreState) => { - const { player } = state; - - return { - playingTrackId: player.playingTrack?.id, - playingTrack: player.playingTrack, - currentIndex: player.currentIndex, - upNext: player.upNext, - items: getQueue(state) - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - updateQueue: actions.updateQueue, - clearUpNext: actions.clearUpNext - }, - dispatch - ); - -type PropsFromState = ReturnType; -type PropsFromDispatch = ReturnType; - -type AllProps = PropsFromDispatch & PropsFromState; - -@autobind -class Queue extends React.PureComponent { - private readonly updateQueueDebounced: () => void; - private list = React.createRef(); - - constructor(props: AllProps) { - super(props); - - this.updateQueueDebounced = debounce(this.onScroll, 200); - } - - public componentDidMount() { - const { currentIndex, playingTrack } = this.props; - - if (playingTrack && playingTrack.id && this.list.current) { - this.list.current.scrollTo(currentIndex); +export const Queue: FC = () => { + const listRef = useRef(null); + const currentIndex = useSelector(getPlayingTrackIndex); + const playingTrack = useSelector(getPlayingTrack); + const playlists = useSelector(getPlaylistsObjects); + const queue = useSelector(getQueuePlaylistSelector); + const upNext = useSelector(getPlayerUpNext); + + useEffect(() => { + if (currentIndex != null && listRef) { + listRef.current?.scrollTo(currentIndex); + console.log('scrollTo', listRef.current); } - } - - public onScroll() { - const { updateQueue } = this.props; + }, [listRef, currentIndex, playingTrack]); - if (this.list.current) { - updateQueue(this.list.current.getVisibleRange()); - } - } + const items = useMemo(() => { + const queueItemsWithUpnext = [...queue.items]; - public renderTrack(index: number, key: number | string) { - const { items, currentIndex } = this.props; + queueItemsWithUpnext.splice(currentIndex + 1, 0, ...upNext); - const item = items[index]; + return queueItemsWithUpnext; + }, [currentIndex, queue, upNext]); - return ( - - ); - } + const onScroll = () => { + // const { updateQueue } = this.props; + // if (this.list.current) { + // updateQueue(this.list.current.getVisibleRange()); + // } + }; - public render() { - const { items, currentIndex, upNext, clearUpNext } = this.props; + const renderTrack = useCallback( + (index: number, key: number | string) => { + const item = items[index]; + + return ( + + ); + }, + [currentIndex, items] + ); - return ( - + ); +}; diff --git a/src/renderer/app/components/Queue/QueueItem.tsx b/src/renderer/app/components/Queue/QueueItem.tsx index 2b01ab77..aee4a582 100644 --- a/src/renderer/app/components/Queue/QueueItem.tsx +++ b/src/renderer/app/components/Queue/QueueItem.tsx @@ -1,18 +1,17 @@ import { IMAGE_SIZES } from '@common/constants'; -import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; -import { getTrackEntity } from '@common/store/entities/selectors'; +import { getTrackEntity, getCurrentPlaylistId } from '@common/store/selectors'; import { PlayingTrack } from '@common/store/player'; -import { getCurrentPlaylistId } from '@common/store/player/selectors'; import { SC } from '@common/utils'; import cn from 'classnames'; import React from 'react'; import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; import { bindActionCreators, Dispatch } from 'redux'; -import ActionsDropdown from '../../../_shared/ActionsDropdown'; +import { ActionsDropdown } from '../../../_shared/ActionsDropdown'; import FallbackImage from '../../../_shared/FallbackImage'; import { TextShortener } from '../../../_shared/TextShortener'; +import { StoreState } from 'AppReduxTypes'; const mapStateToProps = (state: StoreState, props: OwnProps) => { const { trackData } = props; @@ -26,7 +25,7 @@ const mapStateToProps = (state: StoreState, props: OwnProps) => { const mapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( { - playTrack: actions.playTrack + playTrack: actions.playTrackO }, dispatch ); @@ -133,7 +132,7 @@ class QueueItem extends React.PureComponent {
- +
); } diff --git a/src/renderer/app/components/Sidebar/Sidebar.tsx b/src/renderer/app/components/Sidebar/Sidebar.tsx index 621b4861..65af35b2 100755 --- a/src/renderer/app/components/Sidebar/Sidebar.tsx +++ b/src/renderer/app/components/Sidebar/Sidebar.tsx @@ -1,5 +1,4 @@ -import { getAuthPlaylistsSelector } from '@common/store/auth/selectors'; -import { getCurrentPlaylistId } from '@common/store/player/selectors'; +import { getAuthPlaylistsSelector, getCurrentPlaylistId } from '@common/store/selectors'; import React, { FC } from 'react'; import Scrollbars from 'react-custom-scrollbars'; import { useSelector } from 'react-redux'; diff --git a/src/renderer/app/components/Sidebar/playlist/SideBarPlaylistItem.tsx b/src/renderer/app/components/Sidebar/playlist/SideBarPlaylistItem.tsx index 01dec8db..211b3223 100755 --- a/src/renderer/app/components/Sidebar/playlist/SideBarPlaylistItem.tsx +++ b/src/renderer/app/components/Sidebar/playlist/SideBarPlaylistItem.tsx @@ -1,6 +1,5 @@ -import { getNormalizedPlaylist } from '@common/store/entities/selectors'; +import { getNormalizedPlaylist, getPlayerStatus } from '@common/store/selectors'; import { PlayerStatus } from '@common/store/player'; -import { getPlayerStatusSelector } from '@common/store/player/selectors'; import classNames from 'classnames'; import React, { FC } from 'react'; import { useSelector } from 'react-redux'; @@ -15,7 +14,7 @@ interface Props { const SideBarPlaylistItem: FC = ({ playlistId, isPlaying }) => { const playlist = useSelector(state => getNormalizedPlaylist(playlistId)(state)); - const playerStatus = useSelector(getPlayerStatusSelector); + const playerStatus = useSelector(getPlayerStatus); const isActuallyPlaying = playerStatus === PlayerStatus.PLAYING; if (!playlist) { diff --git a/src/renderer/app/components/modals/AboutModal/AboutModal.tsx b/src/renderer/app/components/modals/AboutModal/AboutModal.tsx index 4d9e94b8..a2ba6cd4 100644 --- a/src/renderer/app/components/modals/AboutModal/AboutModal.tsx +++ b/src/renderer/app/components/modals/AboutModal/AboutModal.tsx @@ -1,5 +1,5 @@ import logo from '@assets/img/auryo-dark.png'; -import { StoreState } from '@common/store/rootReducer'; +import { StoreState } from 'AppReduxTypes'; // eslint-disable-next-line import/no-extraneous-dependencies import { remote } from 'electron'; import * as os from 'os'; diff --git a/src/renderer/app/components/player/Player.tsx b/src/renderer/app/components/player/Player.tsx index 7d4d309e..fab6c960 100644 --- a/src/renderer/app/components/player/Player.tsx +++ b/src/renderer/app/components/player/Player.tsx @@ -1,9 +1,7 @@ import { Intent, Popover, PopoverInteractionKind, Slider, Tag } from '@blueprintjs/core'; import { IMAGE_SIZES } from '@common/constants'; -import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; -import { hasLiked } from '@common/store/auth/selectors'; -import { getNormalizedTrack, getNormalizedUser } from '@common/store/entities/selectors'; +import { hasLiked, getNormalizedTrack, getNormalizedUser } from '@common/store/selectors'; import { ChangeTypes, RepeatTypes } from '@common/store/player'; import { SC } from '@common/utils'; import cn from 'classnames'; @@ -14,12 +12,13 @@ import isDeepEqual from 'react-fast-compare'; import { connect } from 'react-redux'; import { bindActionCreators, Dispatch } from 'redux'; import FallbackImage from '../../../_shared/FallbackImage'; -import Queue from '../Queue/Queue'; +import { Queue } from '../Queue/Queue'; import { Audio } from './components/Audio'; import PlayerControls from './components/PlayerControls/PlayerControls'; import { PlayerProgress } from './components/PlayerProgress/PlayerProgress'; import { TrackInfo } from './components/TrackInfo/TrackInfo'; import * as styles from './Player.module.scss'; +import { StoreState } from 'AppReduxTypes'; const mapStateToProps = (state: StoreState) => { const { player, app, config } = state; @@ -69,7 +68,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => addToast: actions.addToast, toggleShuffle: actions.toggleShuffle, toggleLike: actions.toggleLike, - useChromeCast: actions.useChromeCast + useChromeCast: actions.setChromecastDevice }, dispatch ); diff --git a/src/renderer/app/components/player/components/Audio.tsx b/src/renderer/app/components/player/components/Audio.tsx index c90b199b..71bbf344 100644 --- a/src/renderer/app/components/player/components/Audio.tsx +++ b/src/renderer/app/components/player/components/Audio.tsx @@ -2,13 +2,14 @@ import { Intent } from '@blueprintjs/core'; import { EVENTS } from '@common/constants'; import * as actions from '@common/store/actions'; -import { ChangeTypes, PlayerStatus } from '@common/store/player'; +import { PlayerStatus } from '@common/store/player'; import { useAudioPlayer, useAudioPosition } from '@renderer/hooks/useAudioPlayer'; // eslint-disable-next-line import/no-extraneous-dependencies import { ipcRenderer } from 'electron'; -import { FC, useEffect, useState } from 'react'; -import { useDispatch } from 'react-redux'; +import { FC, useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { usePrevious } from 'react-use'; +import { getPlayerCurrentTime } from '@common/store/selectors'; interface Props { src?: string; @@ -18,9 +19,14 @@ interface Props { playbackDeviceId: string | null; } +// TODO: use webAudio? +// https://github.com/DPr00f/electron-music-player-tutorial/blob/master/app/utils/AudioController.js + export const Audio: FC = ({ src, playerStatus, playerVolume, muted, playbackDeviceId }) => { const [isSeeking, setIsSeeking] = useState(false); const { duration, position } = useAudioPosition(); + const currentTime = useSelector(getPlayerCurrentTime); + const previousSrc = usePrevious(src); const dispatch = useDispatch(); const { @@ -130,14 +136,27 @@ export const Audio: FC = ({ src, playerStatus, playerVolume, muted, playb // Onload useEffect(() => { if (wasPrevLoading && !loading && !error) { - dispatch(actions.setDuration(duration)); - dispatch(actions.registerPlay()); + // TODO: remove + // dispatch(actions.setDuration(duration)); + + // TODO: can we move this to our observables? + dispatch(actions.registerPlayO()); } }, [loading]); + // handle track restart + useEffect(() => { + if (currentTime === 0 && position > 0 && src && src === previousSrc) { + load({ + src, + volume: playerVolume + }); + } + }, [currentTime]); + // OnPlay useEffect(() => { - if (typeof position === 'number') { + if (typeof position === 'number' && playerStatus === PlayerStatus.PLAYING) { dispatch(actions.setCurrentTime(position)); } }, [position, ready]); @@ -145,24 +164,24 @@ export const Audio: FC = ({ src, playerStatus, playerVolume, muted, playb // OnEnd useEffect(() => { if (ended) { - dispatch(actions.changeTrack(ChangeTypes.NEXT, true)); + dispatch(actions.trackFinished()); } }, [ended]); - const retry = () => { + const retry = useCallback(() => { if (error && src) { load({ src, volume: playerVolume }); } - }; + }, [load, src, error, playerVolume]); // OnError useEffect(() => { if (error) { // eslint-disable-next-line no-console - console.error(error); + console.error('Audio.tsx', error); switch (error.code) { case MediaError.MEDIA_ERR_NETWORK: diff --git a/src/renderer/css/app.scss b/src/renderer/css/app.scss index 0624dd2c..397d22e0 100644 --- a/src/renderer/css/app.scss +++ b/src/renderer/css/app.scss @@ -247,7 +247,6 @@ html.macOS ::-webkit-scrollbar-thumb { transition: 0.3s box-shadow; text-decoration: none; display: inline-block; - align-self: flex-start; font-size: .9rem; display: flex; align-items: center; @@ -266,18 +265,15 @@ html.macOS ::-webkit-scrollbar-thumb { width: 36px; height: 36px; border-radius: 50px; - display: inline-block; + display: inline-flex; text-align: center; padding: 0; + justify-content: center; + align-items: center; i { font-size: 1.1rem; padding: 0; - text-align: center; - display: table-cell; - vertical-align: middle; - width: 36px; - height: 36px; } } @@ -307,11 +303,21 @@ html.macOS ::-webkit-scrollbar-thumb { i { color: theme-color("primary"); - font-size: 2rem !important; + font-size: 1.8rem !important; color: var(--clr-btn-colored-text) !important; } } + &.large { + width: 100px; + height: 100px; + + i { + + font-size: 4rem; + } + } + &.active { color: var(--clr-btn-active-text); } diff --git a/src/renderer/pages/GenericPlaylist/index.tsx b/src/renderer/pages/GenericPlaylist/index.tsx index 2b36627e..aec939cc 100644 --- a/src/renderer/pages/GenericPlaylist/index.tsx +++ b/src/renderer/pages/GenericPlaylist/index.tsx @@ -1,6 +1,6 @@ import { getGenericPlaylist, genericPlaylistFetchMore } from '@common/store/actions'; -import { PlaylistTypes } from '@common/store/objects'; -import { getPlaylistObjectSelector } from '@common/store/objects/selectors'; +import { PlaylistTypes } from '@common/store/types'; +import { getPlaylistObjectSelector } from '@common/store/selectors'; import { SortTypes } from '@common/store/playlist/types'; import { useLoadMorePromise } from '@renderer/hooks/useLoadMorePromise'; import { SetLayoutSettings } from '@renderer/_shared/context/contentContext'; @@ -10,7 +10,7 @@ import PageHeader from '../../_shared/PageHeader/PageHeader'; import Spinner from '../../_shared/Spinner/Spinner'; import TracksGrid from '../../_shared/TracksGrid/TracksGrid'; -interface OwnProps { +interface Props { playlistType: PlaylistTypes; objectId?: string; title: string; @@ -21,9 +21,7 @@ interface OwnProps { onSortTypeChange?(event: React.ChangeEvent): void; } -type AllProps = OwnProps; - -export const GenericPlaylist: FC = ({ +export const GenericPlaylist: FC = ({ onSortTypeChange, sortType, playlistType, @@ -37,11 +35,13 @@ export const GenericPlaylist: FC = ({ const isChart = playlistType === PlaylistTypes.CHART; const playlistObject = useSelector(getPlaylistObjectSelector({ objectId, playlistType })); + // Do initial fetch for playlist useEffect(() => { dispatch( getGenericPlaylist.request({ playlistType, - refresh: true, + // TODO: For the stream page, do not refresh automatically. Show a button to refresh instead + refresh: false, sortType, objectId }) @@ -103,8 +103,7 @@ export const GenericPlaylist: FC = ({ ) : ( !!playlistObject.items[index]} loadMore={loadMore} diff --git a/src/renderer/pages/artist/ArtistPage.tsx b/src/renderer/pages/artist/ArtistPage.tsx index 8ad9c97c..88ddb90a 100644 --- a/src/renderer/pages/artist/ArtistPage.tsx +++ b/src/renderer/pages/artist/ArtistPage.tsx @@ -1,393 +1,212 @@ import { Menu, MenuItem, Popover, Position } from '@blueprintjs/core'; import { IMAGE_SIZES } from '@common/constants'; -import { StoreState } from '@common/store/rootReducer'; -import * as actions from '@common/store/actions'; -import { getNormalizedUser } from '@common/store/entities/selectors'; -import { ObjectTypes, PlaylistTypes } from '@common/store/objects'; -import { - getArtistLikesPlaylistObject, - getArtistTracksPlaylistObject, - getPlaylistName -} from '@common/store/objects/selectors'; -import { PlayerStatus } from '@common/store/player'; +import { getUser } from '@common/store/actions'; +import { PlaylistTypes } from '@common/store/objects'; +import { currentUserSelector, getNormalizedUserForPage, isUserError, isUserLoading } from '@common/store/selectors'; import { abbreviateNumber, SC } from '@common/utils'; import { IPC } from '@common/utils/ipc'; import { SetLayoutSettings } from '@renderer/_shared/context/contentContext'; +import { ToggleFollowButton } from '@renderer/_shared/PageHeader/components/ToggleFollowButton'; +import { PlaylistTrackList } from '@renderer/_shared/PlaylistTrackList'; import cn from 'classnames'; -import { autobind } from 'core-decorators'; -import React from 'react'; -import { connect } from 'react-redux'; -import { RouteComponentProps, withRouter } from 'react-router-dom'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { RouteComponentProps } from 'react-router-dom'; import { Col, Row, TabContent, TabPane } from 'reactstrap'; -import { bindActionCreators, Dispatch } from 'redux'; import FallbackImage from '../../_shared/FallbackImage'; import { Linkify } from '../../_shared/Linkify'; import PageHeader from '../../_shared/PageHeader/PageHeader'; import ShareMenuItem from '../../_shared/ShareMenuItem'; import Spinner from '../../_shared/Spinner/Spinner'; import { ToggleMore } from '../../_shared/ToggleMore'; -import { TrackList } from '../../_shared/TrackList/TrackList'; import './ArtistPage.scss'; -import ArtistProfiles from './components/ArtistProfiles/ArtistProfiles'; -import { currentUserSelector } from '@common/store/auth/selectors'; +import { ArtistProfiles } from './components/ArtistProfiles/ArtistProfiles'; -const mapStateToProps = (state: StoreState, props: OwnProps) => { - const { - auth, - ui: { dimensions }, - player: { currentPlaylistId, status } - } = state; - const { - match: { - params: { artistId } - } - } = props; - - const playlistId = getPlaylistName(artistId, PlaylistTypes.ARTIST_TRACKS); - const isPlayerPlaylist = currentPlaylistId === playlistId; - const isPlaylistPlaying = isPlayerPlaylist && status === PlayerStatus.PLAYING; - - return { - dimensions, - auth, - isPlayerPlaylist, - isPlaylistPlaying, - user: getNormalizedUser(+artistId)(state), - [PlaylistTypes.ARTIST_TRACKS]: getArtistTracksPlaylistObject(artistId)(state), - [PlaylistTypes.ARTIST_LIKES]: getArtistLikesPlaylistObject(artistId)(state), - artistIdParam: +artistId, - currentUser: currentUserSelector(state) - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - fetchArtistIfNeeded: actions.fetchArtistIfNeeded, - toggleFollowing: actions.toggleFollowing, - fetchMore: actions.fetchMore, - canFetchMoreOf: actions.canFetchMoreOf, - playTrack: actions.playTrack, - toggleStatus: actions.toggleStatus - }, - dispatch - ); - -type OwnProps = RouteComponentProps<{ artistId: string }>; - -type PropsFromState = ReturnType; - -type PropsFromDispatch = ReturnType; - -interface State { - activeTab: TabTypes; - small: boolean; -} +type Props = RouteComponentProps<{ artistId: string }>; enum TabTypes { TRACKS = 'tracks', LIKES = 'likes', - INFO = 'info' + INFO = 'info', + TOP_TRACKS = 'TOP_TRACKS' } -type AllProps = OwnProps & PropsFromState & PropsFromDispatch; - -@autobind -class ArtistPage extends React.Component { - public state: State = { - activeTab: TabTypes.TRACKS, - small: false - }; - - public componentDidMount() { - const { fetchArtistIfNeeded, artistIdParam } = this.props; - - fetchArtistIfNeeded(artistIdParam); - } - - public componentDidUpdate(prevProps: AllProps) { - const { fetchArtistIfNeeded, artistIdParam, dimensions } = this.props; - const { activeTab, small } = this.state; - - if (artistIdParam !== prevProps.artistIdParam) { - fetchArtistIfNeeded(artistIdParam); - } - - if (small !== dimensions.width < 990) { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ - small: dimensions.width < 990 - }); - } - - if (dimensions.width > 768 && activeTab === TabTypes.INFO) { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ - activeTab: TabTypes.TRACKS - }); - } +export const ArtistPage: FC = ({ + match: { + params: { artistId } } +}) => { + const dispatch = useDispatch(); + const loading = useSelector(isUserLoading(artistId)); + const error = useSelector(isUserError(artistId)); - public toggle(tab: TabTypes) { - const { activeTab } = this.state; - - if (activeTab !== tab) { - this.setState({ - activeTab: tab - }); - } - } + const artist = useSelector(getNormalizedUserForPage(artistId)); + const currentUser = useSelector(currentUserSelector); - public toggleFollow() { - const { toggleFollowing, artistIdParam } = this.props; - - toggleFollowing(artistIdParam); - } - - public renderPlaylist(type: PlaylistTypes) { - const { - match: { - params: { artistId } - }, - canFetchMoreOf, - fetchMore - } = this.props; - - const objectId = getPlaylistName(artistId, type); - // eslint-disable-next-line react/destructuring-assignment - const playlist = this.props[type]; - - if (!playlist) { - return ; - } - - return ( - <> - { - return fetchMore(objectId, ObjectTypes.PLAYLISTS) as any; - }} - /> - {playlist.isFetching ? : null} - - ); - } + const [activeTab, setActiveTab] = useState(TabTypes.TRACKS); - public renderPlayButton() { - const { - playTrack, - isPlaylistPlaying, - isPlayerPlaylist, - toggleStatus, - match: { - params: { artistId } + const renderInfo = useCallback( + (toggleMore = false) => { + if (!artist) { + return null; } - } = this.props; - const playlistId = getPlaylistName(artistId, PlaylistTypes.ARTIST_TRACKS); - // eslint-disable-next-line react/destructuring-assignment - const tracksPlaylists = this.props[PlaylistTypes.ARTIST_TRACKS]; - - if (!tracksPlaylists || !tracksPlaylists.items.length) { - return null; - } - - const firstId = tracksPlaylists.items[0].id; - - if (isPlaylistPlaying) { return ( - { - toggleStatus(); - }}> - - - ); - } - - const toggle = () => { - if (isPlayerPlaylist) { - toggleStatus(); - } else { - playTrack(playlistId.toString(), { id: firstId }); - } - }; - - return ( - - - - ); - } - - public renderInfo(toggleMore = false) { - const { user } = this.props; - - if (!user) { - return null; - } - - return ( - <> -
    -
  • - {abbreviateNumber(user.followers_count)} - Followers -
  • -
  • - {abbreviateNumber(user.followings_count)} - Following -
  • -
  • - {abbreviateNumber(user.track_count)} - Tracks -
  • -
- {toggleMore ? ( - -
- + <> +
    +
  • + {abbreviateNumber(artist.followers_count)} + Followers +
  • +
  • + {abbreviateNumber(artist.followings_count)} + Following +
  • +
  • + {abbreviateNumber(artist.track_count)} + Tracks +
  • +
+ {toggleMore ? ( + +
+ +
+
+ ) : ( +
+
- - ) : ( -
- -
- )} + )} - - - ); - } + + + ); + }, + [artist] + ); - // tslint:disable-next-line: max-func-body-length - public render() { - const { user, artistIdParam, auth, currentUser } = this.props; - const { followings } = auth; - const { small, activeTab } = this.state; + const artistTracksId = useMemo(() => ({ objectId: artistId, playlistType: PlaylistTypes.ARTIST_TRACKS }), [artistId]); + const artistTopTracksId = useMemo(() => ({ objectId: artistId, playlistType: PlaylistTypes.ARTIST_TOP_TRACKS }), [ + artistId + ]); + const artistLikesId = useMemo(() => ({ objectId: artistId, playlistType: PlaylistTypes.ARTIST_LIKES }), [artistId]); - if (!user || (user && user.loading) || user.track_count === null) { - return ; + // Fetch user if it does not exist yet + useEffect(() => { + if (!artist && !loading) { + dispatch(getUser.request({ refresh: true, userId: +artistId })); } + }, [loading, artist, error, artistId, dispatch]); - const userImg = SC.getImageUrl(user.avatar_url, IMAGE_SIZES.LARGE); - const following = SC.hasID(user.id, followings); - - return ( - <> - - - - - -
- -
- - - -

{user.username}

-

- {user.city} - {user.city && user.country ? ' , ' : null} - {user.country} -

-
- {this.renderPlayButton()} - {currentUser && artistIdParam !== currentUser.id ? ( - { - this.toggleFollow(); - }}> - {following ? : } - {following ? 'Following' : 'Follow'} - - ) : null} + if (loading && !error && !artist) { + return ; + } - - { - IPC.openExternal(user.permalink_url); - }} - /> - - - }> - - - - -
- -
+ if (error || !artist) { + // TODO; how will we handle errors + return null; + } - -
-
- - - - {/* Tracks */} - {this.renderPlaylist(PlaylistTypes.ARTIST_TRACKS)} + const userImg = SC.getImageUrl(artist.avatar_url, IMAGE_SIZES.LARGE); - {/* Likes */} - {this.renderPlaylist(PlaylistTypes.ARTIST_LIKES)} + return ( + <> + - {/* Tab for info on smaller screens */} - {small ? {this.renderInfo()} : null} - - - {!small ? ( - - {this.renderInfo(true)} - - ) : null} - + + + +
+ +
+ + + +

{artist.username}

+

+ {artist.city} + {artist.city && artist.country ? ' , ' : null} + {artist.country} +

+
+ {currentUser && +artistId !== currentUser.id ? : null} + + + { + IPC.openExternal(artist.permalink_url); + }} + /> + + + }> + + + + +
+ +
+ + - - ); - } -} - -export default withRouter(connect(mapStateToProps, mapDispatchToProps)(ArtistPage)); +
+
+ + + + {/* Tracks */} + + {activeTab === TabTypes.TRACKS && } + + + {/* Tracks */} + + {activeTab === TabTypes.TOP_TRACKS && } + + + {/* Likes */} + + {activeTab === TabTypes.LIKES && } + + + + + {renderInfo(true)} + + +
+ + ); +}; diff --git a/src/renderer/pages/artist/components/ArtistProfiles/ArtistProfiles.scss b/src/renderer/pages/artist/components/ArtistProfiles/ArtistProfiles.scss index b015f720..31e7cb42 100644 --- a/src/renderer/pages/artist/components/ArtistProfiles/ArtistProfiles.scss +++ b/src/renderer/pages/artist/components/ArtistProfiles/ArtistProfiles.scss @@ -11,6 +11,7 @@ $tumblr: #35465c; $pinterest: #cb2027; $beatport: #8fc73e; $snapchat: #fffc00; + @mixin color($color) { &:hover { i { @@ -21,50 +22,65 @@ $snapchat: #fffc00; #web-profiles { padding: 0 10px 20px; + .profile { - color: #3b3e42; + color: var(--clr-body-text); display: flex; text-decoration: none; align-items: center; text-transform: capitalize; font-size: .8rem; + i { font-size: 1rem; } + @include color(#3b3e42); + &.facebook { @include color($facebook_color); } + &.twitter { @include color($twitter_color); } + &.instagram { @include color($instagram_color); } + &.youtube { @include color($youtube_color); } + &.soundcloud { @include color($soundcloud); } + &.songkick { @include color($songkick); } + &.spotify { @include color($spotify); } + &.tumblr { @include color($tumblr); } + &.pinterest { @include color($pinterest); } + &.beatport { @include color($beatport); } + &.snapchat { @include color(darken($snapchat, 3%)); } + i { width: 35px; height: 35px; @@ -76,10 +92,12 @@ $snapchat: #fffc00; margin-right: 8px; flex-shrink: 0; } + &:hover { span { text-decoration: underline; } + i { color: #50545a; transition: .5s color, .5s background; diff --git a/src/renderer/pages/artist/components/ArtistProfiles/ArtistProfiles.tsx b/src/renderer/pages/artist/components/ArtistProfiles/ArtistProfiles.tsx index 73fad8b3..2aef6ff6 100644 --- a/src/renderer/pages/artist/components/ArtistProfiles/ArtistProfiles.tsx +++ b/src/renderer/pages/artist/components/ArtistProfiles/ArtistProfiles.tsx @@ -1,34 +1,22 @@ -import React from 'react'; +import { getUserProfiles } from '@common/store/actions'; +import { getNormalizedUserProfiles, isUserProfilesError, isUserProfilesLoading } from '@common/store/selectors'; +import React, { FC, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { SoundCloud } from '../../../../../types'; import './ArtistProfiles.scss'; interface Props { - profiles?: SoundCloud.UserProfiles; + userUrn: string; className?: string; } -class ArtistProfiles extends React.Component { - public static defaultProps: Props = { - profiles: { items: [], loading: false } - }; - - /* +export const ArtistProfiles: FC = ({ userUrn, className }) => { + const dispatch = useDispatch(); + const profiles = useSelector(getNormalizedUserProfiles(userUrn)); + const loading = useSelector(isUserProfilesLoading(userUrn)); + const error = useSelector(isUserProfilesError(userUrn)); - SOUNDCLOUD = 'soundcloud', - INSTAGRAM = 'instagram', - FACEBOOK = 'facebook', - TWITTER = 'twitter', - YOUTUBE = 'youtube', - SPOTIFY = 'spotify', - TUMBLR = 'tumblr', - PINTEREST = 'pinterest', - SNAPCHAT = 'snapchat', - PERSONAL = 'personal', - SONGKICK = 'songkick', - BEATPORT = 'beatport' - - */ - public getIcon(service: string) { + const getIcon = (service: string) => { switch (service) { case SoundCloud.ProfileService.SOUNDCLOUD: return '-cloud'; @@ -44,9 +32,9 @@ class ArtistProfiles extends React.Component { return '-globe'; } - } + }; - public getTitle(title: string): string | null { + const getTitle = (title: string) => { if (!title) { return null; } @@ -62,43 +50,41 @@ class ArtistProfiles extends React.Component { default: return null; } - } - - public render() { - const { profiles, className } = this.props; + }; - if (!profiles || !profiles.items.length) { - return null; + // Fetch user if it does not exist yet + useEffect(() => { + if (!profiles && !loading) { + dispatch(getUserProfiles.request({ userUrn })); } + }, [loading, error, dispatch, profiles, userUrn]); - return ( -
- {profiles.items.map(profile => { - const title = this.getTitle(profile.title); - - const { service } = profile; + if (!profiles?.length) { + return null; + } - let iconString = service.toString(); + return ( +
+ {profiles.map(profile => { + const title = getTitle(profile.title); - if (profile.service === SoundCloud.ProfileService.PERSONAL && title) { - iconString = title; - } + const network = profile?.network; - const icon = `bx bx${this.getIcon(iconString)}`; + let iconString = network.toString(); - return ( - - - {profile.title ? profile.title : profile.service} - - ); - })} -
- ); - } -} + if (network === SoundCloud.ProfileService.PERSONAL && title) { + iconString = title; + } -export default ArtistProfiles; + const icon = `bx bx${getIcon(iconString)}`; + + return ( + + + {profile.title ? profile.title : network} + + ); + })} +
+ ); +}; diff --git a/src/renderer/pages/charts/ChartsPage.tsx b/src/renderer/pages/charts/ChartsPage.tsx index d05af996..f47efa84 100644 --- a/src/renderer/pages/charts/ChartsPage.tsx +++ b/src/renderer/pages/charts/ChartsPage.tsx @@ -1,6 +1,5 @@ import { AUDIO_GENRES, MUSIC_GENRES } from '@common/constants'; import cn from 'classnames'; -import { autobind } from 'core-decorators'; import React, { FC } from 'react'; import Masonry from 'react-masonry-css'; import { NavLink, RouteComponentProps } from 'react-router-dom'; diff --git a/src/renderer/pages/foryou/ForYouPage.tsx b/src/renderer/pages/foryou/ForYouPage.tsx index c2e0bc1f..b595907b 100644 --- a/src/renderer/pages/foryou/ForYouPage.tsx +++ b/src/renderer/pages/foryou/ForYouPage.tsx @@ -1,6 +1,5 @@ import { getForYouSelection } from '@common/store/actions'; -import { getAuthPersonalizedPlaylistsSelector } from '@common/store/auth/selectors'; -import { getPlaylistEntities } from '@common/store/entities/selectors'; +import { getAuthPersonalizedPlaylistsSelector, getPlaylistEntities } from '@common/store/selectors'; import cn from 'classnames'; import React, { FC, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; @@ -91,6 +90,7 @@ export const ForYou: FC = () => { ); }; + return (
{weekly && renderPlaylist('Made for you', 'Playlists created by SoundCloud just for you', combinedCollection)} diff --git a/src/renderer/pages/onboarding/OnBoarding.tsx b/src/renderer/pages/onboarding/OnBoarding.tsx index 5a0f8e57..cc5a9bb1 100644 --- a/src/renderer/pages/onboarding/OnBoarding.tsx +++ b/src/renderer/pages/onboarding/OnBoarding.tsx @@ -1,12 +1,15 @@ import feetonmusicbox from '@assets/img/feetonmusicbox.jpg'; -import { StoreState } from '@common/store/rootReducer'; +import { Position } from '@blueprintjs/core'; +import { EVENTS } from '@common/constants'; import * as actions from '@common/store/actions'; -import { authTokenStateSelector, configSelector } from '@common/store/config/selectors'; +import { getAppAuth, authTokenStateSelector, configSelector } from '@common/store/selectors'; import AboutModal from '@renderer/app/components/modals/AboutModal/AboutModal'; +import { Toastr } from '@renderer/app/components/Toastr'; +import { StoreState } from 'AppReduxTypes'; import cn from 'classnames'; // eslint-disable-next-line import/no-extraneous-dependencies import { ipcRenderer } from 'electron'; -import React, { useEffect, useState, FC, useCallback } from 'react'; +import React, { FC, useCallback, useEffect, useState } from 'react'; import { connect } from 'react-redux'; import { RouteComponentProps } from 'react-router-dom'; import { bindActionCreators, Dispatch } from 'redux'; @@ -15,10 +18,6 @@ import { LoginStep } from './components/LoginStep'; import { PrivacyStep } from './components/PrivacyStep'; import { WelcomeStep } from './components/WelcomeStep'; import './OnBoarding.scss'; -import { Position } from '@blueprintjs/core'; -import { Toastr } from '@renderer/app/components/Toastr'; -import { EVENTS } from '@common/constants'; -import { getAppAuth } from '@common/store/appAuth/selectors'; const mapStateToProps = (state: StoreState) => ({ config: configSelector(state), diff --git a/src/renderer/pages/playlist/PlaylistPage.tsx b/src/renderer/pages/playlist/PlaylistPage.tsx index f690eeeb..f86fffec 100644 --- a/src/renderer/pages/playlist/PlaylistPage.tsx +++ b/src/renderer/pages/playlist/PlaylistPage.tsx @@ -1,10 +1,14 @@ import { Menu, MenuDivider, MenuItem, Popover, Position } from '@blueprintjs/core'; import { IMAGE_SIZES } from '@common/constants'; -import { addUpNext, getGenericPlaylist, genericPlaylistFetchMore, playTrack } from '@common/store/actions'; -import { getAuthPlaylistsSelector } from '@common/store/auth/selectors'; -import { getNormalizedPlaylist, getNormalizedTrack, getNormalizedUser } from '@common/store/entities/selectors'; -import { PlaylistTypes } from '@common/store/objects'; -import { getPlaylistObjectSelector } from '@common/store/objects/selectors'; +import { addUpNext, genericPlaylistFetchMore, getGenericPlaylist } from '@common/store/actions'; +import { + getAuthPlaylistsSelector, + getNormalizedPlaylist, + getNormalizedTrack, + getNormalizedUser, + getPlaylistObjectSelector +} from '@common/store/selectors'; +import { LikeType, PlaylistTypes, RepostType } from '@common/store/types'; import { getReadableTimeFull, SC } from '@common/utils'; import { IPC } from '@common/utils/ipc'; import { useLoadMorePromise } from '@renderer/hooks/useLoadMorePromise'; @@ -13,7 +17,7 @@ import { ToggleLikeButton } from '@renderer/_shared/PageHeader/components/Toggle import { TogglePlayButton } from '@renderer/_shared/PageHeader/components/TogglePlayButton'; import { ToggleRepostButton } from '@renderer/_shared/PageHeader/components/ToggleRepostButton'; import cn from 'classnames'; -import React, { FC, useEffect } from 'react'; +import React, { FC, useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { RouteComponentProps } from 'react-router-dom'; import { usePrevious } from 'react-use'; @@ -64,6 +68,8 @@ const PlaylistPage: FC = ({ [dispatch, objectId] ); + const playlistID = useMemo(() => ({ objectId, playlistType: PlaylistTypes.PLAYLIST }), [objectId]); + if ( !playlist || !playlistObject || @@ -89,6 +95,7 @@ const PlaylistPage: FC = ({ const description = isPersonalisedPlaylist ? playlist.description : `${playlist.track_count} titles - ${getReadableTimeFull(playlist.duration, true)}`; + return ( <> @@ -96,19 +103,15 @@ const PlaylistPage: FC = ({
- {!!firstItem && !isEmpty && ( - { - dispatch(playTrack(objectId)); - }} - /> - )} + {!!firstItem && !isEmpty && } - {!isEmpty && !playlistOwned && !isPersonalisedPlaylist && } + {!isEmpty && !playlistOwned && !isPersonalisedPlaylist && ( + + )} - {!isEmpty && !playlistOwned && !isPersonalisedPlaylist && } + {!isEmpty && !playlistOwned && !isPersonalisedPlaylist && ( + + )} {!isEmpty && ( = ({ { - dispatch(addUpNext(playlist)); + dispatch(addUpNext.request({ id: +objectId, schema: 'playlists' })); }} /> @@ -167,8 +170,7 @@ const PlaylistPage: FC = ({ ) : ( !!playlistObject.items[index]} loadMore={loadMore} diff --git a/src/renderer/pages/search/SearchPage.tsx b/src/renderer/pages/search/SearchPage.tsx index cba81988..b7dca317 100644 --- a/src/renderer/pages/search/SearchPage.tsx +++ b/src/renderer/pages/search/SearchPage.tsx @@ -1,8 +1,7 @@ import * as actions from '@common/store/actions'; import { searchPlaylistFetchMore } from '@common/store/actions'; import { PlaylistTypes } from '@common/store/objects'; -import { getPlaylistObjectSelector } from '@common/store/objects/selectors'; -import { getSearchQuery } from '@common/store/ui/selectors'; +import { getPlaylistObjectSelector, getSearchQuery } from '@common/store/selectors'; import { useLoadMorePromise } from '@renderer/hooks/useLoadMorePromise'; import React, { FC, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; @@ -53,10 +52,10 @@ export const SearchPage: FC = ({ Users - + Tracks - + Playlist
@@ -78,7 +77,7 @@ export const SearchPage: FC = ({ ) : ( !!playlistObject.items[index]} loadMore={loadMore} diff --git a/src/renderer/pages/settings/Settings.tsx b/src/renderer/pages/settings/Settings.tsx index b2b30aaa..1e226f6c 100644 --- a/src/renderer/pages/settings/Settings.tsx +++ b/src/renderer/pages/settings/Settings.tsx @@ -1,11 +1,11 @@ import { Button, Collapse, Intent, Switch } from '@blueprintjs/core'; import fetchToJson from '@common/api/helpers/fetchToJson'; import { EVENTS } from '@common/constants/events'; -import { StoreState } from '@common/store/rootReducer'; import * as actions from '@common/store/actions'; import { SC } from '@common/utils'; import { ThemeKeys } from '@renderer/app/components/Theme/themes'; import PageHeader from '@renderer/_shared/PageHeader/PageHeader'; +import { StoreState } from 'AppReduxTypes'; import { autobind } from 'core-decorators'; // eslint-disable-next-line import/no-extraneous-dependencies import { ipcRenderer } from 'electron'; diff --git a/src/renderer/pages/tags/TagsPage.tsx b/src/renderer/pages/tags/TagsPage.tsx index ac2d21bf..315520f1 100644 --- a/src/renderer/pages/tags/TagsPage.tsx +++ b/src/renderer/pages/tags/TagsPage.tsx @@ -1,7 +1,7 @@ import * as actions from '@common/store/actions'; import { searchPlaylistFetchMore } from '@common/store/actions'; import { PlaylistTypes } from '@common/store/objects'; -import { getPlaylistObjectSelector } from '@common/store/objects/selectors'; +import { getPlaylistObjectSelector } from '@common/store/selectors'; import { useLoadMorePromise } from '@renderer/hooks/useLoadMorePromise'; import cn from 'classnames'; import React, { FC, useEffect } from 'react'; @@ -70,7 +70,7 @@ export const TagsPage: FC = ({ ) : ( !!playlistObject.items[index]} loadMore={loadMore} diff --git a/src/renderer/pages/track/TrackPage.tsx b/src/renderer/pages/track/TrackPage.tsx index 70f1af53..a88eebce 100644 --- a/src/renderer/pages/track/TrackPage.tsx +++ b/src/renderer/pages/track/TrackPage.tsx @@ -1,352 +1,196 @@ import { Menu, MenuDivider, MenuItem, Popover, Position } from '@blueprintjs/core'; import { IMAGE_SIZES } from '@common/constants'; -import { StoreState } from '@common/store/rootReducer'; -import * as actions from '@common/store/actions'; -import { getUserPlaylistsCombined } from '@common/store/auth/selectors'; -import { getNormalizedTrack, getNormalizedUser } from '@common/store/entities/selectors'; -import { ObjectTypes, PlaylistTypes } from '@common/store/objects'; -import { getCommentObject, getPlaylistName, getRelatedTracksPlaylistObject } from '@common/store/objects/selectors'; -import { PlayerStatus } from '@common/store/player'; -import { togglePlaylistTrack } from '@common/store/playlist/actions'; +import { addUpNext, getTrack } from '@common/store/actions'; +import { getNormalizedTrack, getNormalizedUser, isTrackError, isTrackLoading } from '@common/store/selectors'; +import { LikeType, PlaylistTypes, RepostType } from '@common/store/types'; import { SC } from '@common/utils'; import { IPC } from '@common/utils/ipc'; import { SetLayoutSettings } from '@renderer/_shared/context/contentContext'; +import { ToggleLikeButton } from '@renderer/_shared/PageHeader/components/ToggleLikeButton'; +import { ToggleRepostButton } from '@renderer/_shared/PageHeader/components/ToggleRepostButton'; import cn from 'classnames'; -import { autobind } from 'core-decorators'; import _ from 'lodash'; -import React from 'react'; -import { connect } from 'react-redux'; +import React, { FC, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { RouteComponentProps } from 'react-router-dom'; import { Col, Row, TabContent, TabPane } from 'reactstrap'; -import { bindActionCreators, Dispatch } from 'redux'; import FallbackImage from '../../_shared/FallbackImage'; import { TogglePlayButton } from '../../_shared/PageHeader/components/TogglePlayButton'; import PageHeader from '../../_shared/PageHeader/PageHeader'; +import { PlaylistTrackList } from '../../_shared/PlaylistTrackList'; import ShareMenuItem from '../../_shared/ShareMenuItem'; import Spinner from '../../_shared/Spinner/Spinner'; -import { TrackList } from '../../_shared/TrackList/TrackList'; import { TrackOverview } from './components/TrackOverview'; import './TrackPage.scss'; -const mapStateToProps = (state: StoreState, props: OwnProps) => { - const { songId } = props.match.params; - const { - player: { playingTrack, currentPlaylistId, status }, - auth - } = state; - - const relatedPlaylistId = getPlaylistName(songId, PlaylistTypes.RELATED); - - const track = getNormalizedTrack(+songId)(state); - const user = getNormalizedUser(track?.user)(state); - - return { - playingTrack, - isRelatedPlaylistsPlaying: currentPlaylistId === relatedPlaylistId && status === PlayerStatus.PLAYING, - relatedPlaylistId, - auth, - track, - user, - userPlaylists: getUserPlaylistsCombined(state), - songIdParam: +songId, - relatedTracks: getRelatedTracksPlaylistObject(songId)(state), - comments: getCommentObject(songId)(state) - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - fetchTrackIfNeeded: actions.fetchTrackIfNeeded, - toggleRepost: actions.toggleRepost, - fetchMore: actions.fetchMore, - canFetchMoreOf: actions.canFetchMoreOf, - playTrack: actions.playTrack, - toggleFollowing: actions.toggleFollowing, - addUpNext: actions.addUpNext, - toggleLike: actions.toggleLike, - togglePlaylistTrack - }, - dispatch - ); - -type OwnProps = RouteComponentProps<{ songId: string }>; - -type PropsFromState = ReturnType; -type PropsFromDispatch = ReturnType; - -interface State { - activeTab: TabTypes; -} - enum TabTypes { OVERVIEW = 'overview', RELATED_TRACKS = 'related' } -type AllProps = OwnProps & PropsFromState & PropsFromDispatch; - -@autobind -class TrackPage extends React.PureComponent { - public readonly state: State = { - activeTab: TabTypes.OVERVIEW - }; - - public componentDidMount() { - const { fetchTrackIfNeeded, songIdParam } = this.props; +type Props = RouteComponentProps<{ songId: string }>; - // fetchTrackIfNeeded(songIdParam); +export const TrackPage: FC = ({ + match: { + params: { songId } } +}) => { + const dispatch = useDispatch(); + const [activeTab, setActiveTab] = useState(TabTypes.OVERVIEW); - public componentDidUpdate() { - const { fetchTrackIfNeeded, songIdParam } = this.props; + const loading = useSelector(isTrackLoading(songId)); + const error = useSelector(isTrackError(songId)); + const track = useSelector(getNormalizedTrack(songId)); + const user = useSelector(getNormalizedUser(track?.user)); - // fetchTrackIfNeeded(songIdParam); - } - - public toggle(tab: TabTypes) { - const { activeTab } = this.state; + const relatedTrackId = useMemo(() => ({ objectId: songId, playlistType: PlaylistTypes.RELATED }), [songId]); - if (activeTab !== tab) { - this.setState({ - activeTab: tab - }); + // Fetch track if it does not exist yet + useEffect(() => { + if (!track && !loading) { + dispatch(getTrack.request({ refresh: true, trackId: +songId })); } - } - - // tslint:disable-next-line: max-func-body-length - public render() { - const { - // Vars - match: { - params: { songId } - }, - auth: { likes, reposts }, - relatedPlaylistId, - comments, - isRelatedPlaylistsPlaying, - relatedTracks, - track, + }, [loading, track, error, songId, dispatch]); - // Functions - toggleLike, - toggleRepost, - addUpNext, - canFetchMoreOf, - fetchMore, - user, - playTrack, - songIdParam - } = this.props; - - const { activeTab } = this.state; + if (loading && !error && !track) { + return ; + } - if (!track || (track && track.loading)) { - return ; - } + if (error || !track) { + // TODO; how will we handle errors + return null; + } - const liked = SC.hasID(track.id, likes.track); - const reposted = SC.hasID(track.id, reposts.track); + const image = SC.getImageUrl({ ...track, user }, IMAGE_SIZES.LARGE); - const image = SC.getImageUrl({ ...track, user }, IMAGE_SIZES.LARGE); + const purchaseTitle = track.purchase_title || 'Download'; - const purchaseTitle = track.purchase_title || 'Download'; + return ( + <> + - const likedIcon = liked ? 'bx bxs-heart' : 'bx bx-heart'; + + + {image && ( + +
+ +
+ + )} - return ( - <> - + +

{track.title}

- - - {image && ( - -
- -
- - )} +
+ {SC.isStreamable(track) ? ( + + ) : ( + + This track is not streamable + + )} - -

{track.title}

+ -
- {SC.isStreamable(track) ? ( - { - playTrack(relatedPlaylistId, { id: songIdParam }); - }} - /> - ) : ( - - This track is not streamable - - )} + + {!track.purchase_url && track.download_url && track.downloadable && ( { - toggleLike(track.id); + IPC.downloadFile(SC.appendClientId(track.download_url)); }}> - - {liked ? 'Liked' : 'Like'} + + )} - { - toggleRepost(track.id); - }}> - - {reposted ? 'Reposted' : 'Repost'} + + {track.purchase_url && ( + <> + {track.purchase_url && ( + { + IPC.openExternal(track.purchase_url); + }} + /> + )} + + + + )} + + { + dispatch(addUpNext.request({ id: +songId, schema: 'tracks' })); + }} + /> + + + + { + IPC.openExternal(track.permalink_url); + }} + /> + + + + }> + + - - {!track.purchase_url && track.download_url && track.downloadable && ( - { - IPC.downloadFile(SC.appendClientId(track.download_url)); - }}> - - - )} - - - {track.purchase_url && ( - <> - {track.purchase_url && ( - { - IPC.openExternal(track.purchase_url); - }} - /> - )} - - - - )} - - -
- I'm sorry, this feature has been disabled to preserve your playlists. Since we are unable to - fetch all tracks, we do not know for sure if we will delete tracks upon adding/removing track - via Auryo. -
- {/* { - userPlaylists.map((playlist) => { - const inPlaylist = !!playlist.items.find((t) => t.id === track.id); - - return ( - { - togglePlaylistTrack(track.id, playlist.id); - }} - text={playlist.title} - /> - ); - }) - } */} - - - { - addUpNext(track); - }} - /> - - - - { - IPC.openExternal(track.permalink_url); - }} - /> - - - - }> - - - -
-
- - - - - - -
- - {/* OVERVIEW */} - - fetchMore(songId, ObjectTypes.COMMENTS) as any} - /> - - - {/* RELATED TRACKS */} - {/* TODO ADD Spinner */} - - {relatedTracks && activeTab === TabTypes.RELATED_TRACKS && ( - - )} - - + +
+ + + + - - ); - } -} - -export default connect( - mapStateToProps, - mapDispatchToProps -)(TrackPage); + + +
+ + {/* OVERVIEW */} + + + + + {/* RELATED TRACKS */} + + + + +
+ + ); +}; diff --git a/src/renderer/pages/track/components/TrackOverview.tsx b/src/renderer/pages/track/components/TrackOverview.tsx index 354d77dc..32342502 100644 --- a/src/renderer/pages/track/components/TrackOverview.tsx +++ b/src/renderer/pages/track/components/TrackOverview.tsx @@ -1,7 +1,10 @@ -import { ObjectState } from '@common/store/objects'; +import { commentsFetchMore, getComments } from '@common/store/actions'; +import { getCommentObject } from '@common/store/selectors'; import { abbreviateNumber } from '@common/utils'; +import { useLoadMorePromise } from '@renderer/hooks/useLoadMorePromise'; import moment from 'moment'; -import React from 'react'; +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; import { Normalized, SoundCloud } from '../../../../types'; import { CommentList } from '../../../_shared/CommentList/CommentList'; @@ -11,10 +14,6 @@ import TrackGridUser from '../../../_shared/TracksGrid/TrackgridUser/TrackGridUs interface Props { track: Normalized.Track; - comments: ObjectState | null; - - hasMore: boolean; - loadMore(): Promise; } const getTags = (track: SoundCloud.Track | Normalized.Track) => { @@ -31,61 +30,84 @@ const getTags = (track: SoundCloud.Track | Normalized.Track) => { }, []); }; -export const TrackOverview = React.memo(({ track, comments, hasMore, loadMore }) => ( -
-
-
-
- -
+export const TrackOverview = React.memo(({ track }) => { + const dispatch = useDispatch(); + const comments = useSelector(getCommentObject(track?.id)); + + useEffect(() => { + dispatch(getComments.request({ refresh: true, trackId: track?.id })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch, track?.id]); + + const { loadMore } = useLoadMorePromise( + comments?.isFetching, + () => { + dispatch(commentsFetchMore.request({ trackId: track?.id })); + }, + [dispatch, track?.id] + ); + + return ( +
+
+
+
+ +
+ +
+
+ Created +
{moment(new Date(track.created_at)).fromNow()}
-
-
- Created -
{moment(new Date(track.created_at)).fromNow()}
- - {track.label_name && ( - <> - Label -
{track.label_name}
- - )} + {track.label_name && ( + <> + Label +
{track.label_name}
+ + )} +
-
-
-
-
- {getTags(track).map(tag => ( - - {tag} - - ))} -
-
- +
+
+
+ {getTags(track).map(tag => ( + + {tag} + + ))} +
+
+ - {abbreviateNumber(track.likes_count)} + {abbreviateNumber(track.likes_count)} - - {abbreviateNumber(track.playback_count)} + + {abbreviateNumber(track.playback_count)} - - {abbreviateNumber(track.reposts_count)} + + {abbreviateNumber(track.reposts_count)} +
-
- {track.description && ( - - - - )} + {track.description && ( + + + + )} - {comments && ( - - )} + {comments && ( + + )} +
-
-)); + ); +}); diff --git a/src/types/index.ts b/src/types/index.ts index 5cf12736..f39dc67d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,7 +2,7 @@ import { AxiosError } from 'axios'; import { ThunkAction } from 'redux-thunk'; import * as Normalized from './normalized'; import * as SoundCloud from './soundcloud'; -import { StoreState } from '@common/store/types'; +import { StoreState } from 'AppReduxTypes'; export { SoundCloud, Normalized }; diff --git a/src/types/normalized.ts b/src/types/normalized.ts index 3c30811d..90ca776a 100644 --- a/src/types/normalized.ts +++ b/src/types/normalized.ts @@ -12,6 +12,8 @@ export interface Track extends Omit { export interface NormalizedResult { schema: 'users' | 'tracks' | 'playlists' | 'comments'; id: number; + // Makes fetched items unique, easier to to upNext and queue + un?: number; } export interface NormalizedPersonalizedItem { diff --git a/src/types/soundcloud.ts b/src/types/soundcloud.ts index eb4596c5..b5469c64 100644 --- a/src/types/soundcloud.ts +++ b/src/types/soundcloud.ts @@ -4,9 +4,7 @@ export interface Asset { id: number; kind: T; uri: string; - - // Local addition for checking if there's already one of these assets being loaded - loading?: boolean; + urn: string; } export enum AssetType { @@ -29,33 +27,41 @@ export enum ProfileService { PINTEREST = 'pinterest', SNAPCHAT = 'snapchat', PERSONAL = 'personal', - SONGKICK = 'songkick', - BEATPORT = 'beatport' + SONGKICK = 'songkick' + //BEATPORT = 'beatport' } export interface Profile extends Asset { - created_at: DateString; - service: ProfileService; + network: ProfileService; title: string; url: string; username: string; } export interface Comment extends Asset { - created_at: DateString; user_id: number; track_id: number; + created_at: DateString; + self: { urn: string }; timestamp: number; body: string; user: CompactUser; } export interface CompactUser extends Asset { + avatar_url: string; + first_name: string; + full_name: string; + id: number; + last_modified: Date; + last_name: string; permalink: string; - username: string; - last_modified: string; permalink_url: string; - avatar_url: string; + urn: string; + username: string; + verified: boolean; + city?: any; + country_code?: any; } export interface User extends Asset { @@ -89,14 +95,9 @@ export interface User extends Asset { primary_email_confirmed: boolean; locale: string; reposts_count: number; - - profiles?: UserProfiles; } -export interface UserProfiles { - items: Profile[]; - loading: boolean; -} +export type UserProfiles = Profile[]; export interface Track extends Asset { created_at: string; diff --git a/yarn.lock b/yarn.lock index 8b2aa628..c5ccbb91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -828,6 +828,14 @@ "@babel/helper-plugin-utils" "^7.8.3" "@babel/plugin-transform-typescript" "^7.8.3" +"@babel/runtime-corejs3@^7.8.3": + version "7.9.6" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.9.6.tgz#67aded13fffbbc2cb93247388cf84d77a4be9a71" + integrity sha512-6toWAfaALQjt3KMZQc6fABqZwUDDuWzz+cAfPhqyEnzxvdWOAkjwPNxgF8xlmo7OWLsSjaKjsskpKHRLaMArOA== + dependencies: + core-js-pure "^3.0.0" + regenerator-runtime "^0.13.4" + "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.2.0", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5": version "7.8.4" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.4.tgz#d79f5a2040f7caa24d53e563aad49cbc05581308" @@ -1222,25 +1230,26 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA= -"@sentry/apm@5.10.2": - version "5.10.2" - resolved "https://registry.yarnpkg.com/@sentry/apm/-/apm-5.10.2.tgz#41a401b3964b68514439f8a595b12c6fd05ab21a" - integrity sha512-rPeAFsD/6ontvs7bsuHh+XAg1ohWo04ms08SNWqEvLRQJx7WfiWnjziyC0S3dXIYZDGdhruSsqQJPJN8r6Aj5g== +"@sentry/apm@5.13.2": + version "5.13.2" + resolved "https://registry.yarnpkg.com/@sentry/apm/-/apm-5.13.2.tgz#3a0809912426f52e19b1f4a603e99423a0ac8fb9" + integrity sha512-Pv6PRVkcmmYYIT422gXm968F8YQyf5uN1RSHOFBjWsxI3Ke/uRgeEdIVKPDo78GklBfETyRN6GyLEZ555jRe6g== dependencies: - "@sentry/hub" "5.10.2" - "@sentry/minimal" "5.10.2" - "@sentry/types" "5.10.0" - "@sentry/utils" "5.10.2" + "@sentry/browser" "5.13.2" + "@sentry/hub" "5.13.2" + "@sentry/minimal" "5.13.2" + "@sentry/types" "5.13.2" + "@sentry/utils" "5.13.2" tslib "^1.9.3" -"@sentry/browser@~5.10.2": - version "5.10.2" - resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-5.10.2.tgz#0bbb05505c58ea998c833cffec3f922fe4b4fa58" - integrity sha512-r3eyBu2ln7odvWtXARCZPzpuGrKsD6U9F3gKTu4xdFkA0swSLUvS7AC2FUksj/1BE23y+eB/zzPT+RYJ58tidA== +"@sentry/browser@5.13.2", "@sentry/browser@~5.13.2": + version "5.13.2" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-5.13.2.tgz#fcca630c8c80447ba8392803d4e4450fd2231b92" + integrity sha512-4MeauHs8Rf1c2FF6n84wrvA4LexEL1K/Tg3r+1vigItiqyyyYBx1sPjHGZeKeilgBi+6IEV5O8sy30QIrA/NsQ== dependencies: - "@sentry/core" "5.10.2" - "@sentry/types" "5.10.0" - "@sentry/utils" "5.10.2" + "@sentry/core" "5.13.2" + "@sentry/types" "5.13.2" + "@sentry/utils" "5.13.2" tslib "^1.9.3" "@sentry/cli@^1.47.1", "@sentry/cli@^1.49.0": @@ -1255,76 +1264,76 @@ progress "2.0.0" proxy-from-env "^1.0.0" -"@sentry/core@5.10.2", "@sentry/core@~5.10.2": - version "5.10.2" - resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.10.2.tgz#1cb64489e6f8363c3249415b49d3f1289814825f" - integrity sha512-sKVeFH3v8K8xw2vM5MKMnnyAAwih+JSE3pbNL0CcCCA+/SwX+3jeAo2BhgXev2SAR/TjWW+wmeC9TdIW7KyYbg== +"@sentry/core@5.13.2", "@sentry/core@~5.13.2": + version "5.13.2" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.13.2.tgz#d89e199beef612d0a01e5c4df4e0bb7efcb72c74" + integrity sha512-iB7CQSt9e0EJhSmcNOCjzJ/u7E7qYJ3mI3h44GO83n7VOmxBXKSvtUl9FpKFypbWrsdrDz8HihLgAZZoMLWpPA== dependencies: - "@sentry/hub" "5.10.2" - "@sentry/minimal" "5.10.2" - "@sentry/types" "5.10.0" - "@sentry/utils" "5.10.2" + "@sentry/hub" "5.13.2" + "@sentry/minimal" "5.13.2" + "@sentry/types" "5.13.2" + "@sentry/utils" "5.13.2" tslib "^1.9.3" -"@sentry/electron@1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@sentry/electron/-/electron-1.2.0.tgz#4e5498d335d31944a565a3b5ec63c6b9f451a12f" - integrity sha512-LnzCzUXI52JcSKc2JbXqQa4BtLGAVZvgN0l1aunR6nJ+DKR3Xg41NpiJ1PgvIlf1pbjz4EPcGhAewV94dMc2VA== - dependencies: - "@sentry/browser" "~5.10.2" - "@sentry/core" "~5.10.2" - "@sentry/minimal" "~5.10.2" - "@sentry/node" "~5.10.2" - "@sentry/types" "~5.10.0" - "@sentry/utils" "~5.10.2" +"@sentry/electron@1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@sentry/electron/-/electron-1.3.0.tgz#02bc5ab7d16e579cd048cdbaaed93b43584aef2d" + integrity sha512-9oNJg371A/Djk03KVBHj9BgqYCscKxzScYKlM4AYR+BxYQ3LLsZLLeD9Mkdc0hGnOszCRmO5jXRjBVYz1JkJcA== + dependencies: + "@sentry/browser" "~5.13.2" + "@sentry/core" "~5.13.2" + "@sentry/minimal" "~5.13.2" + "@sentry/node" "~5.13.2" + "@sentry/types" "~5.13.2" + "@sentry/utils" "~5.13.2" electron-fetch "^1.4.0" form-data "2.5.1" - util.promisify "1.0.0" + util.promisify "1.0.1" -"@sentry/hub@5.10.2": - version "5.10.2" - resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.10.2.tgz#25d9f36b8f7c5cb65cf486737fa61dc9bf69b7e3" - integrity sha512-hSlZIiu3hcR/I5yEhlpN9C0nip+U7hiRzRzUQaBiHO4YG4TC58NqnOPR89D/ekiuHIXzFpjW9OQmqtAMRoSUYA== +"@sentry/hub@5.13.2": + version "5.13.2" + resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.13.2.tgz#875a5ba983d6ada5caae5b6b4decd0257ef5cdb7" + integrity sha512-/U7yq3DTuRz8SRpZVKAaenW9sD2F5wbj12kDVPxPnGspyqhy0wBWKs9j0YJfBiDXMKOwp3HX964O3ygtwjnfAw== dependencies: - "@sentry/types" "5.10.0" - "@sentry/utils" "5.10.2" + "@sentry/types" "5.13.2" + "@sentry/utils" "5.13.2" tslib "^1.9.3" -"@sentry/minimal@5.10.2", "@sentry/minimal@~5.10.2": - version "5.10.2" - resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.10.2.tgz#267c2f3aa6877a0fe7a86971942e83f3ee616580" - integrity sha512-GalixiM9sckYfompH5HHTp9XT2BcjawBkcl1DMEKUBEi37+kUq0bivOBmnN1G/I4/wWOUdnAI/kagDWaWpbZPg== +"@sentry/minimal@5.13.2", "@sentry/minimal@~5.13.2": + version "5.13.2" + resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.13.2.tgz#e42e33dc74fc935f8857d1a43a528afd741640fd" + integrity sha512-VV0eA3HgrnN3mac1XVPpSCLukYsU+QxegbmpnZ8UL8eIQSZ/ZikYxagDNlZbdnmXHUpOEUeag2gxVntSCo5UcA== dependencies: - "@sentry/hub" "5.10.2" - "@sentry/types" "5.10.0" + "@sentry/hub" "5.13.2" + "@sentry/types" "5.13.2" tslib "^1.9.3" -"@sentry/node@~5.10.2": - version "5.10.2" - resolved "https://registry.yarnpkg.com/@sentry/node/-/node-5.10.2.tgz#1f5d6deefb2c1549ddb542c10952cccf5f9a4ac2" - integrity sha512-1ib1hAhVtmfXOThpcCfR4S6wFopd6lHqgOMrAUPo9saHy8zseZPRC7iTWGoSPy2RMwjrURAk54VvFnLe7G+PdQ== +"@sentry/node@~5.13.2": + version "5.13.2" + resolved "https://registry.yarnpkg.com/@sentry/node/-/node-5.13.2.tgz#3be5608e00fb3fe1b813ad8365073a465d19f5f6" + integrity sha512-LwNOUvc0+28jYfI0o4HmkDTEYdY3dWvSCnL5zggO12buon7Wc+jirXZbEQAx84HlXu7sGSjtKCTzUQOphv7sPw== dependencies: - "@sentry/apm" "5.10.2" - "@sentry/core" "5.10.2" - "@sentry/hub" "5.10.2" - "@sentry/types" "5.10.0" - "@sentry/utils" "5.10.2" + "@sentry/apm" "5.13.2" + "@sentry/core" "5.13.2" + "@sentry/hub" "5.13.2" + "@sentry/types" "5.13.2" + "@sentry/utils" "5.13.2" cookie "^0.3.1" - https-proxy-agent "^3.0.0" + https-proxy-agent "^4.0.0" lru_map "^0.3.3" tslib "^1.9.3" -"@sentry/types@5.10.0", "@sentry/types@~5.10.0": - version "5.10.0" - resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.10.0.tgz#4f0ba31b6e4d5371112c38279f11f66c73b43746" - integrity sha512-TW20GzkCWsP6uAxR2JIpIkiitCKyIOfkyDsKBeLqYj4SaZjfvBPnzgNCcYR0L0UsP1/Es6oHooZfIGSkp6GGxQ== +"@sentry/types@5.13.2", "@sentry/types@~5.13.2": + version "5.13.2" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.13.2.tgz#8e68c31f8fb99b4074374bff13ed01035b373d8c" + integrity sha512-mgAEQyc77PYBnAjnslSXUz6aKgDlunlg2c2qSK/ivKlEkTgTWWW/dE76++qVdrqM8SupnqQoiXyPDL0wUNdB3g== -"@sentry/utils@5.10.2", "@sentry/utils@~5.10.2": - version "5.10.2" - resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.10.2.tgz#261f575079d30aaf604e59f5f4de0aa21db22252" - integrity sha512-UcbbaFpYrGSV448lQ16Cr+W/MPuKUflQQUdrMCt5vgaf5+M7kpozlcji4GGGZUCXIA7oRP93ABoXj55s1OM9zw== +"@sentry/utils@5.13.2", "@sentry/utils@~5.13.2": + version "5.13.2" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.13.2.tgz#441594f4f9412bfd1690739ce986bf3a49687806" + integrity sha512-LwPQl6WRMKEnd16kg35HS3yE+VhBc8vN4+BBIlrgs7X0aoT+AbEd/sQLMisDgxNboCF44Ho3RCKtztiPb9blqg== dependencies: - "@sentry/types" "5.10.0" + "@sentry/types" "5.13.2" tslib "^1.9.3" "@sentry/webpack-plugin@^1.8.0": @@ -2016,7 +2025,17 @@ "@typescript-eslint/typescript-estree" "2.20.0" eslint-scope "^5.0.0" -"@typescript-eslint/parser@^2.18.0", "@typescript-eslint/parser@^2.3.0": +"@typescript-eslint/experimental-utils@2.33.0": + version "2.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.33.0.tgz#000f1e5f344fbea1323dc91cc174805d75f99a03" + integrity sha512-qzPM2AuxtMrRq78LwyZa8Qn6gcY8obkIrBs1ehqmQADwkYzTE1Pb4y2W+U3rE/iFkSWcWHG2LS6MJfj6SmHApg== + dependencies: + "@types/json-schema" "^7.0.3" + "@typescript-eslint/typescript-estree" "2.33.0" + eslint-scope "^5.0.0" + eslint-utils "^2.0.0" + +"@typescript-eslint/parser@^2.18.0": version "2.20.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-2.20.0.tgz#608e5bb06ba98a415b64ace994c79ab20f9772a9" integrity sha512-o8qsKaosLh2qhMZiHNtaHKTHyCHc3Triq6aMnwnWj7budm3xAY9owSZzV1uon5T9cWmJRJGzTFa90aex4m77Lw== @@ -2026,6 +2045,16 @@ "@typescript-eslint/typescript-estree" "2.20.0" eslint-visitor-keys "^1.1.0" +"@typescript-eslint/parser@^2.24.0": + version "2.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-2.33.0.tgz#395c0ef229ebef883608f8632a34f0acf02b9bdd" + integrity sha512-AUtmwUUhJoH6yrtxZMHbRUEMsC2G6z5NSxg9KsROOGqNXasM71I8P2NihtumlWTUCRld70vqIZ6Pm4E5PAziEA== + dependencies: + "@types/eslint-visitor-keys" "^1.0.0" + "@typescript-eslint/experimental-utils" "2.33.0" + "@typescript-eslint/typescript-estree" "2.33.0" + eslint-visitor-keys "^1.1.0" + "@typescript-eslint/typescript-estree@2.20.0": version "2.20.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-2.20.0.tgz#90a0f5598826b35b966ca83483b1a621b1a4d0c9" @@ -2039,6 +2068,19 @@ semver "^6.3.0" tsutils "^3.17.1" +"@typescript-eslint/typescript-estree@2.33.0": + version "2.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-2.33.0.tgz#33504c050ccafd38f397a645d4e9534d2eccbb5c" + integrity sha512-d8rY6/yUxb0+mEwTShCQF2zYQdLlqihukNfG9IUlLYz5y1CH6G/9XYbrxQLq3Z14RNvkCC6oe+OcFlyUpwUbkg== + dependencies: + debug "^4.1.1" + eslint-visitor-keys "^1.1.0" + glob "^7.1.6" + is-glob "^4.0.1" + lodash "^4.17.15" + semver "^7.3.2" + tsutils "^3.17.1" + "@webassemblyjs/ast@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359" @@ -2239,10 +2281,10 @@ acorn-globals@^4.3.2: acorn "^6.0.1" acorn-walk "^6.0.1" -acorn-jsx@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.1.0.tgz#294adb71b57398b0680015f0a38c563ee1db5384" - integrity sha512-tMUqwBWfLFbJbizRmEcWSLw6HnFzfdJs2sOJEOwwtVPMoH/0Ay+E703oZz78VSXZiiDcZrQ5XKjPIUQixhmgVw== +acorn-jsx@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.2.0.tgz#4c66069173d6fdd68ed85239fc256226182b2ebe" + integrity sha512-HiUX/+K2YpkpJ+SzBffkM/AQ2YE03S0U1kjTLVpoJdhZMOWy8qvXVN9JdLqv2QsaQ6MPYQIuNmwD8zOiYUofLQ== acorn-walk@^6.0.1, acorn-walk@^6.1.1: version "6.2.0" @@ -2264,6 +2306,11 @@ acorn@^7.1.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.0.tgz#949d36f2c292535da602283586c2477c57eb2d6c" integrity sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ== +acorn@^7.1.1: + version "7.2.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.2.0.tgz#17ea7e40d7c8640ff54a694c889c26f31704effe" + integrity sha512-apwXVmYVpQ34m/i71vrApRrRKCWQnZZF1+npOD0WV5xZFfwWOmKGQ2RWlfdy9vWITsenisM8M0Qeq8agcFHNiQ== + add-px-to-style@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/add-px-to-style/-/add-px-to-style-1.0.0.tgz#d0c135441fa8014a8137904531096f67f28f263a" @@ -2279,13 +2326,6 @@ agent-base@5: resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-5.1.1.tgz#e8fb3f242959db44d63be665db7a8e739537a32c" integrity sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g== -agent-base@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" - integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== - dependencies: - es6-promisify "^5.0.0" - aggregate-error@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.0.1.tgz#db2fe7246e536f40d9b5442a39e117d7dd6a24e0" @@ -2330,6 +2370,16 @@ ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.11.0, ajv@^6.5.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^6.12.0: + version "6.12.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.2.tgz#c629c5eced17baf314437918d2da88c99d5958cd" + integrity sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + align-text@^0.1.1, align-text@^0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117" @@ -4002,7 +4052,7 @@ chain-function@^1.0.0: resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.1.tgz#c63045e5b4b663fb86f1c6e186adaf1de402a1cc" integrity sha512-SxltgMwL9uCko5/ZCLiyG2B7R9fY4pDZUw7hJ4MhirdjBLosoDqkWABi3XMucddHdLiFJMb7PD2MZifZriuMTg== -chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.0, chalk@^2.3.1, chalk@^2.4.1, chalk@^2.4.2: +chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.3.1, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -4030,6 +4080,14 @@ chalk@^3.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.0.0.tgz#6e98081ed2d17faab615eb52ac66ec1fe6209e72" + integrity sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + character-entities-legacy@^1.0.0: version "1.1.4" resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz#94bc1845dce70a5bb9d2ecc748725661293d8fc1" @@ -4535,7 +4593,7 @@ configstore@^5.0.1: write-file-atomic "^3.0.0" xdg-basedir "^4.0.0" -confusing-browser-globals@^1.0.7: +confusing-browser-globals@^1.0.9: version "1.0.9" resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.9.tgz#72bc13b483c0276801681871d4898516f8f54fdd" integrity sha512-KbS1Y0jMtyPgIxjO7ZzMAuUpAKMt1SzCL9fsrKsX6b0zJPTaT0SiSPmewwVZg9UAO83HVIlEhZF84LIjZ0lmAw== @@ -4545,10 +4603,10 @@ connect-history-api-fallback@^1.6.0: resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc" integrity sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg== -connected-react-router@^6.5.2: - version "6.7.0" - resolved "https://registry.yarnpkg.com/connected-react-router/-/connected-react-router-6.7.0.tgz#1c37a65684f1729533264c1b1903ee91c8ca3a15" - integrity sha512-RDmcmiwSfUWQ3U7J7RVkc9cwNtek26fUn0DWpA8pS7JylC97VNeosrsIxjJ/3CGDrzZPqnc0Hr/kZxjh75JGlw== +connected-react-router@^6.8.0: + version "6.8.0" + resolved "https://registry.yarnpkg.com/connected-react-router/-/connected-react-router-6.8.0.tgz#ddc687b31d498322445d235d660798489fa56cae" + integrity sha512-E64/6krdJM3Ag3MMmh2nKPtMbH15s3JQDuaYJvOVXzu6MbHbDyIvuwLOyhQIuP4Om9zqEfZYiVyflROibSsONg== dependencies: prop-types "^15.7.2" @@ -4666,6 +4724,11 @@ core-js-compat@^3.6.2: browserslist "^4.8.3" semver "7.0.0" +core-js-pure@^3.0.0: + version "3.6.5" + resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.6.5.tgz#c79e75f5e38dbc85a662d91eea52b8256d53b813" + integrity sha512-lacdXOimsiD0QyNf9BC/mxivNJ/ybBGJXQFKzRekp1WTHoVUWsUHEn+2T8GJAzzIhyOuXA+gOxCVN3l+5PLPUA== + core-js@3, core-js@^3.6.4: version "3.6.4" resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.4.tgz#440a83536b458114b9cb2ac1580ba377dc470647" @@ -4794,6 +4857,15 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.1: shebang-command "^2.0.0" which "^2.0.1" +cross-spawn@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.2.tgz#d0d7dcfa74e89115c7619f4f721a94e1fdb716d6" + integrity sha512-PD6G8QG3S4FK/XCGFbEQrDqO2AnMMsy0meR7lerlIOHAAbkuavGU/pOqprrlvfTNjvowivTeBsjebAL0NSoMxw== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + cross-unzip@0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/cross-unzip/-/cross-unzip-0.0.2.tgz#5183bc47a09559befcf98cc4657964999359372f" @@ -5222,7 +5294,7 @@ debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: dependencies: ms "^2.1.1" -debug@^3.0.0, debug@^3.1.0, debug@^3.1.1, debug@^3.2.5: +debug@^3.0.0, debug@^3.1.1, debug@^3.2.5: version "3.2.6" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== @@ -5328,7 +5400,7 @@ deep-extend@^0.6.0: resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== -deep-is@~0.1.3: +deep-is@^0.1.3, deep-is@~0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= @@ -5973,12 +6045,14 @@ electron-publish@22.3.3: lazy-val "^1.0.4" mime "^2.4.4" -"electron-redux@file:../related/electron-redux": - version "1.4.4-sync" +electron-redux@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/electron-redux/-/electron-redux-1.5.2.tgz#a415d4873369957640ed0536fc0641c869ba64f5" + integrity sha512-LOX+CdPkkJTUU+JBaVexH6QOmENkRtuhnZjLhpsvm/LjNaMZpfuWRsHGyxxZcIMTV2CIQLH0MXqho6dcKOjc8A== dependencies: debug "^3.0.0" flux-standard-action "^2.0.0" - redux "^3.4.0" + redux "^4.0.1" electron-store@^5.1.0: version "5.1.0" @@ -6024,10 +6098,10 @@ electron@*: "@types/node" "^12.0.12" extract-zip "^1.0.3" -electron@^8.0.3: - version "8.0.3" - resolved "https://registry.yarnpkg.com/electron/-/electron-8.0.3.tgz#e0488baaa69291e26dc981ea9bc75b5da92c5c88" - integrity sha512-lr/tTr9cBzocREmL8r/P3WKnGqpKeaMFZjNVXDGd3npxwnJVUd7SHQW7LZIhZ1W2XoU3uBwTYbyH43iCIElsqw== +electron@^8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/electron/-/electron-8.3.0.tgz#c2b565a4c10d6d287d20164bcd5a478468b940a9" + integrity sha512-XRjiIJICZCgUr2vKSUI2PTkfP0gPFqCtqJUaTJSfCTuE3nTrxBKOUNeRMuCzEqspKkpFQU3SB3MdbMSHmZARlQ== dependencies: "@electron/get" "^1.0.1" "@types/node" "^12.0.12" @@ -6061,6 +6135,11 @@ emojis-list@^2.0.0: resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" integrity sha1-TapNnbAPmBmIDHn6RXrlsJof04k= +emojis-list@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" + integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== + encodeurl@^1.0.2, encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -6287,18 +6366,6 @@ es6-map@^0.1.5: es6-symbol "~3.1.1" event-emitter "~0.3.5" -es6-promise@^4.0.3: - version "4.2.8" - resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" - integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== - -es6-promisify@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203" - integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM= - dependencies: - es6-promise "^4.0.3" - es6-set@~0.1.5: version "0.1.5" resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1" @@ -6358,37 +6425,37 @@ escodegen@^1.11.1: optionalDependencies: source-map "~0.6.1" -eslint-config-airbnb-base@^14.0.0: - version "14.0.0" - resolved "https://registry.yarnpkg.com/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.0.0.tgz#8a7bcb9643d13c55df4dd7444f138bf4efa61e17" - integrity sha512-2IDHobw97upExLmsebhtfoD3NAKhV4H0CJWP3Uprd/uk+cHuWYOczPVxQ8PxLFUAw7o3Th1RAU8u1DoUpr+cMA== +eslint-config-airbnb-base@^14.1.0: + version "14.1.0" + resolved "https://registry.yarnpkg.com/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.1.0.tgz#2ba4592dd6843258221d9bff2b6831bd77c874e4" + integrity sha512-+XCcfGyCnbzOnktDVhwsCAx+9DmrzEmuwxyHUJpw+kqBVT744OUBrB09khgFKlK1lshVww6qXGsYPZpavoNjJw== dependencies: - confusing-browser-globals "^1.0.7" + confusing-browser-globals "^1.0.9" object.assign "^4.1.0" - object.entries "^1.1.0" + object.entries "^1.1.1" -eslint-config-airbnb-typescript@^6.3.1: - version "6.3.2" - resolved "https://registry.yarnpkg.com/eslint-config-airbnb-typescript/-/eslint-config-airbnb-typescript-6.3.2.tgz#d63d30f1f6b54fefdf619e0aa1e622d0f8bbccda" - integrity sha512-JdwaDl+0vX223MSKRyY+K8tQzrTcVDc3xCd3BtpQ4wsvu74Nbvvz7Ikuo/9ddFGZ2bNrYkfmXsPPrTdBJIxYug== +eslint-config-airbnb-typescript@^7.2.1: + version "7.2.1" + resolved "https://registry.yarnpkg.com/eslint-config-airbnb-typescript/-/eslint-config-airbnb-typescript-7.2.1.tgz#bce3f02fa894d1ec2f31ac527992e03761a9b7d4" + integrity sha512-D3elVKUbdsCfkOVstSyWuiu+KGCVTrYxJPoenPIqZtL6Li/R4xBeVTXjZIui8B8D17bDN3Pz5dSr7jRLY5HqIg== dependencies: - "@typescript-eslint/parser" "^2.3.0" - eslint-config-airbnb "^18.0.1" - eslint-config-airbnb-base "^14.0.0" + "@typescript-eslint/parser" "^2.24.0" + eslint-config-airbnb "^18.1.0" + eslint-config-airbnb-base "^14.1.0" -eslint-config-airbnb@^18.0.1: - version "18.0.1" - resolved "https://registry.yarnpkg.com/eslint-config-airbnb/-/eslint-config-airbnb-18.0.1.tgz#a3a74cc29b46413b6096965025381df8fb908559" - integrity sha512-hLb/ccvW4grVhvd6CT83bECacc+s4Z3/AEyWQdIT2KeTsG9dR7nx1gs7Iw4tDmGKozCNHFn4yZmRm3Tgy+XxyQ== +eslint-config-airbnb@^18.1.0: + version "18.1.0" + resolved "https://registry.yarnpkg.com/eslint-config-airbnb/-/eslint-config-airbnb-18.1.0.tgz#724d7e93dadd2169492ff5363c5aaa779e01257d" + integrity sha512-kZFuQC/MPnH7KJp6v95xsLBf63G/w7YqdPfQ0MUanxQ7zcKUNG8j+sSY860g3NwCBOa62apw16J6pRN+AOgXzw== dependencies: - eslint-config-airbnb-base "^14.0.0" + eslint-config-airbnb-base "^14.1.0" object.assign "^4.1.0" - object.entries "^1.1.0" + object.entries "^1.1.1" -eslint-config-prettier@^6.4.0: - version "6.10.0" - resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.10.0.tgz#7b15e303bf9c956875c948f6b21500e48ded6a7f" - integrity sha512-AtndijGte1rPILInUdHjvKEGbIV06NuvPrqlIEaEaWtbtvJh464mDeyGMdZEQMsGvC0ZVkiex1fSNcC4HAbRGg== +eslint-config-prettier@^6.11.0: + version "6.11.0" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.11.0.tgz#f6d2238c1290d01c859a8b5c1f7d352a0b0da8b1" + integrity sha512-oB8cpLWSAjOVFEJhhyMZh6NOEOtBVziaqdDQ86+qhDHFbZXoRTM7pNSvFRfW/W/L/LrQ38C99J5CGuRBBzBsdA== dependencies: get-stdin "^6.0.0" @@ -6397,10 +6464,10 @@ eslint-config-react@^1.1.7: resolved "https://registry.yarnpkg.com/eslint-config-react/-/eslint-config-react-1.1.7.tgz#a0918d0fc47d0e9bd161a47308021da85d2585b3" integrity sha1-oJGND8R9DpvRYaRzCAIdqF0lhbM= -eslint-config-standard@^14.1.0: - version "14.1.0" - resolved "https://registry.yarnpkg.com/eslint-config-standard/-/eslint-config-standard-14.1.0.tgz#b23da2b76fe5a2eba668374f246454e7058f15d4" - integrity sha512-EF6XkrrGVbvv8hL/kYa/m6vnvmUT+K82pJJc4JJVMM6+Qgqh0pnwprSxdduDLB9p/7bIxD+YV5O0wfb8lmcPbA== +eslint-config-standard@^14.1.1: + version "14.1.1" + resolved "https://registry.yarnpkg.com/eslint-config-standard/-/eslint-config-standard-14.1.1.tgz#830a8e44e7aef7de67464979ad06b406026c56ea" + integrity sha512-Z9B+VR+JIXRxz21udPTL9HpFMyoMUEeX1G251EQ6e05WD9aPVtVBn09XUmZ259wCMlCDmYDSZG62Hhm+ZTJcUg== eslint-friendly-formatter@^4.0.1: version "4.0.1" @@ -6433,16 +6500,16 @@ eslint-import-resolver-typescript@^2.0.0: tiny-glob "^0.2.6" tsconfig-paths "^3.9.0" -eslint-loader@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/eslint-loader/-/eslint-loader-3.0.3.tgz#e018e3d2722381d982b1201adb56819c73b480ca" - integrity sha512-+YRqB95PnNvxNp1HEjQmvf9KNvCin5HXYYseOXVC2U0KEcw4IkQ2IQEBG46j7+gW39bMzeu0GsUhVbBY3Votpw== +eslint-loader@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/eslint-loader/-/eslint-loader-4.0.2.tgz#386a1e21bcb613b3cf2d252a3b708023ccfb41ec" + integrity sha512-EDpXor6lsjtTzZpLUn7KmXs02+nIjGcgees9BYjNkWra3jVq5vVa8IoCKgzT2M7dNNeoMBtaSG83Bd40N3poLw== dependencies: + find-cache-dir "^3.3.1" fs-extra "^8.1.0" - loader-fs-cache "^1.0.2" - loader-utils "^1.2.3" - object-hash "^2.0.1" - schema-utils "^2.6.1" + loader-utils "^2.0.0" + object-hash "^2.0.3" + schema-utils "^2.6.5" eslint-module-utils@^2.4.1: version "2.5.2" @@ -6460,17 +6527,17 @@ eslint-plugin-es@^3.0.0: eslint-utils "^2.0.0" regexpp "^3.0.0" -eslint-plugin-html@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-html/-/eslint-plugin-html-6.0.0.tgz#28e5c3e71e6f612e07e73d7c215e469766628c13" - integrity sha512-PQcGippOHS+HTbQCStmH5MY1BF2MaU8qW/+Mvo/8xTa/ioeMXdSP+IiaBw2+nh0KEMfYQKuTz1Zo+vHynjwhbg== +eslint-plugin-html@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-html/-/eslint-plugin-html-6.0.2.tgz#fcbd293e218d03dd72c147fc999d185c6f5989fe" + integrity sha512-Ik/z32UteKLo8GEfwNqVKcJ/WOz/be4h8N5mbMmxxnZ+9aL9XczOXQFz/bGu+nAGVoRg8CflldxJhONFpqlrxw== dependencies: - htmlparser2 "^3.10.1" + htmlparser2 "^4.1.0" -eslint-plugin-import@^2.20.0: - version "2.20.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.20.1.tgz#802423196dcb11d9ce8435a5fc02a6d3b46939b3" - integrity sha512-qQHgFOTjguR+LnYRoToeZWT62XM55MBVXObHM6SKFd1VzDcX/vqT1kAz8ssqigh5eMj8qXcRoXXGZpPP6RfdCw== +eslint-plugin-import@^2.20.2: + version "2.20.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.20.2.tgz#91fc3807ce08be4837141272c8b99073906e588d" + integrity sha512-FObidqpXrR8OnCh4iNsxy+WACztJLXAHBO5hK79T1Hc77PgQZkyDGA5Ag9xAvRpglvLNxhH/zSmZ70/pZ31dHg== dependencies: array-includes "^3.0.3" array.prototype.flat "^1.2.1" @@ -6500,10 +6567,10 @@ eslint-plugin-jsx-a11y@^6.2.3: has "^1.0.3" jsx-ast-utils "^2.2.1" -eslint-plugin-node@^11.0.0: - version "11.0.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-node/-/eslint-plugin-node-11.0.0.tgz#365944bb0804c5d1d501182a9bc41a0ffefed726" - integrity sha512-chUs/NVID+sknFiJzxoN9lM7uKSOEta8GC8365hw1nDfwIPIjjpRSwwPvQanWv8dt/pDe9EV4anmVSwdiSndNg== +eslint-plugin-node@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz#c95544416ee4ada26740a30474eefc5402dc671d" + integrity sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g== dependencies: eslint-plugin-es "^3.0.0" eslint-utils "^2.0.0" @@ -6512,10 +6579,10 @@ eslint-plugin-node@^11.0.0: resolve "^1.10.1" semver "^6.1.0" -eslint-plugin-prettier@^3.1.1: - version "3.1.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.2.tgz#432e5a667666ab84ce72f945c72f77d996a5c9ba" - integrity sha512-GlolCC9y3XZfv3RQfwGew7NnuFDKsfI4lbvRK+PIIo23SFH+LemGs4cKwzAaRa+Mdb+lQO/STaIayno8T5sJJA== +eslint-plugin-prettier@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.3.tgz#ae116a0fc0e598fdae48743a4430903de5b4e6ca" + integrity sha512-+HG5jmu/dN3ZV3T6eCD7a4BlAySdN7mLIbJYo0z1cFQuI+r2DiTJEFeF68ots93PsnrMxbzIZ2S/ieX+mkrBeQ== dependencies: prettier-linter-helpers "^1.0.0" @@ -6524,15 +6591,15 @@ eslint-plugin-promise@^4.2.1: resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz#845fd8b2260ad8f82564c1222fce44ad71d9418a" integrity sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw== -eslint-plugin-react-hooks@^2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-2.4.0.tgz#db6ee1cc953e3a217035da3d4e9d4356d3c672a4" - integrity sha512-bH5DOCP6WpuOqNaux2BlaDCrSgv8s5BitP90bTgtZ1ZsRn2bdIfeMDY5F2RnJVnyKDy6KRQRDbipPLZ1y77QtQ== +eslint-plugin-react-hooks@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.0.2.tgz#03700ca761eacc1b6436074c456f90a8e331ff28" + integrity sha512-kAMRjNztrLW1rK+81X1NwMB2LqG+nc7Q8AibnG8/VyWhQK8SP6JotCFG+HL4u1EjziplxVz4jARdR8gGk8pLDA== -eslint-plugin-react@^7.18.0: - version "7.18.3" - resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.18.3.tgz#8be671b7f6be095098e79d27ac32f9580f599bc8" - integrity sha512-Bt56LNHAQCoou88s8ViKRjMB2+36XRejCQ1VoLj716KI1MoE99HpTVvIThJ0rvFmG4E4Gsq+UgToEjn+j044Bg== +eslint-plugin-react@^7.20.0: + version "7.20.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.20.0.tgz#f98712f0a5e57dfd3e5542ef0604b8739cd47be3" + integrity sha512-rqe1abd0vxMjmbPngo4NaYxTcR3Y4Hrmc/jg4T+sYz63yqlmJRknpEQfmWY+eDWPuMmix6iUIK+mv0zExjeLgA== dependencies: array-includes "^3.1.1" doctrine "^2.1.0" @@ -6542,8 +6609,9 @@ eslint-plugin-react@^7.18.0: object.fromentries "^2.0.2" object.values "^1.1.1" prop-types "^15.7.2" - resolve "^1.14.2" + resolve "^1.15.1" string.prototype.matchall "^4.0.2" + xregexp "^4.3.0" eslint-plugin-standard@^4.0.1: version "4.0.1" @@ -6585,22 +6653,22 @@ eslint-visitor-keys@^1.1.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2" integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A== -eslint@^6.8.0: - version "6.8.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.8.0.tgz#62262d6729739f9275723824302fb227c8c93ffb" - integrity sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig== +eslint@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.0.0.tgz#c35dfd04a4372110bd78c69a8d79864273919a08" + integrity sha512-qY1cwdOxMONHJfGqw52UOpZDeqXy8xmD0u8CT6jIstil72jkhURC704W8CFyTPDPllz4z4lu0Ql1+07PG/XdIg== dependencies: "@babel/code-frame" "^7.0.0" ajv "^6.10.0" - chalk "^2.1.0" - cross-spawn "^6.0.5" + chalk "^4.0.0" + cross-spawn "^7.0.2" debug "^4.0.1" doctrine "^3.0.0" eslint-scope "^5.0.0" - eslint-utils "^1.4.3" + eslint-utils "^2.0.0" eslint-visitor-keys "^1.1.0" - espree "^6.1.2" - esquery "^1.0.1" + espree "^7.0.0" + esquery "^1.2.0" esutils "^2.0.2" file-entry-cache "^5.0.1" functional-red-black-tree "^1.0.1" @@ -6613,28 +6681,27 @@ eslint@^6.8.0: is-glob "^4.0.0" js-yaml "^3.13.1" json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.3.0" + levn "^0.4.1" lodash "^4.17.14" minimatch "^3.0.4" - mkdirp "^0.5.1" natural-compare "^1.4.0" - optionator "^0.8.3" + optionator "^0.9.1" progress "^2.0.0" - regexpp "^2.0.1" - semver "^6.1.2" - strip-ansi "^5.2.0" - strip-json-comments "^3.0.1" + regexpp "^3.1.0" + semver "^7.2.1" + strip-ansi "^6.0.0" + strip-json-comments "^3.1.0" table "^5.2.3" text-table "^0.2.0" v8-compile-cache "^2.0.3" -espree@^6.1.2: - version "6.1.2" - resolved "https://registry.yarnpkg.com/espree/-/espree-6.1.2.tgz#6c272650932b4f91c3714e5e7b5f5e2ecf47262d" - integrity sha512-2iUPuuPP+yW1PZaMSDM9eyVf8D5P0Hi8h83YtZ5bPc/zHYjII5khoixIUTMO794NOY8F/ThF1Bo8ncZILarUTA== +espree@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-7.0.0.tgz#8a7a60f218e69f120a842dc24c5a88aa7748a74e" + integrity sha512-/r2XEx5Mw4pgKdyb7GNLQNsu++asx/dltf/CI8RFi9oGHxmQFgvLbc5Op4U6i8Oaj+kdslhJtVlEZeAqH5qOTw== dependencies: - acorn "^7.1.0" - acorn-jsx "^5.1.0" + acorn "^7.1.1" + acorn-jsx "^5.2.0" eslint-visitor-keys "^1.1.0" esprima@^4.0.0, esprima@^4.0.1: @@ -6642,12 +6709,12 @@ esprima@^4.0.0, esprima@^4.0.1: resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.1.0.tgz#c5c0b66f383e7656404f86b31334d72524eddb48" - integrity sha512-MxYW9xKmROWF672KqjO75sszsA8Mxhw06YFeS5VHlB98KDHbOSurm3ArsjO60Eaf3QmGMCP1yn+0JQkNLo/97Q== +esquery@^1.2.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.3.1.tgz#b78b5828aa8e214e29fb74c4d5b752e1c033da57" + integrity sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ== dependencies: - estraverse "^4.0.0" + estraverse "^5.1.0" esrecurse@^4.1.0: version "4.2.1" @@ -6656,11 +6723,16 @@ esrecurse@^4.1.0: dependencies: estraverse "^4.1.0" -estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: +estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: version "4.3.0" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== +estraverse@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.1.0.tgz#374309d39fd935ae500e7b92e8a6b4c720e59642" + integrity sha512-FyohXK+R0vE+y1nHLoBM7ZTyqRpqAlhdZHCWIWEviFLiGB8b04H6bQs8G+XTthacvT8VuwvteiP7RJSxMs8UEw== + esutils@^2.0.0, esutils@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" @@ -7264,15 +7336,6 @@ find-babel-config@^1.1.0: json5 "^0.5.1" path-exists "^3.0.0" -find-cache-dir@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-0.1.1.tgz#c8defae57c8a52a8a784f9e31c57c742e993a0b9" - integrity sha1-yN765XyKUqinhPnjHFfHQumToLk= - dependencies: - commondir "^1.0.1" - mkdirp "^0.5.1" - pkg-dir "^1.0.0" - find-cache-dir@^2.0.0, find-cache-dir@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-2.1.0.tgz#8d0f94cd13fe43c6c7c261a0d86115ca918c05f7" @@ -7291,6 +7354,15 @@ find-cache-dir@^3.0.0, find-cache-dir@^3.2.0: make-dir "^3.0.0" pkg-dir "^4.1.0" +find-cache-dir@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.1.tgz#89b33fad4a4670daa94f855f7fbe31d6d84fe880" + integrity sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ== + dependencies: + commondir "^1.0.1" + make-dir "^3.0.2" + pkg-dir "^4.1.0" + find-up@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" @@ -8330,7 +8402,7 @@ html-webpack-plugin@^3.2.0: toposort "^1.0.0" util.promisify "1.0.0" -htmlparser2@^3.10.1, htmlparser2@^3.3.0, htmlparser2@^3.9.1: +htmlparser2@^3.3.0, htmlparser2@^3.9.1: version "3.10.1" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== @@ -8352,6 +8424,16 @@ htmlparser2@^4.0: domutils "^2.0.0" entities "^2.0.0" +htmlparser2@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-4.1.0.tgz#9a4ef161f2e4625ebf7dfbe6c0a2f52d18a59e78" + integrity sha512-4zDq1a1zhE4gQso/c5LP1OtrhYTncXNSpvJYtWJBtXAETPlMfi3IFNjGuQbYLuVY4ZR0QMqRVvo4Pdy9KLyP8Q== + dependencies: + domelementtype "^2.0.1" + domhandler "^3.0.0" + domutils "^2.0.0" + entities "^2.0.0" + http-cache-semantics@3.8.1: version "3.8.1" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz#39b0e16add9b605bf0a9ef3d9daaf4843b4cacd2" @@ -8442,14 +8524,6 @@ https-browserify@^1.0.0: resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= -https-proxy-agent@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz#b8c286433e87602311b01c8ea34413d856a4af81" - integrity sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg== - dependencies: - agent-base "^4.3.0" - debug "^3.1.0" - https-proxy-agent@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-4.0.0.tgz#702b71fb5520a132a66de1f67541d9e62154d82b" @@ -9996,6 +10070,13 @@ json5@^1.0.1: dependencies: minimist "^1.2.0" +json5@^2.1.2: + version "2.1.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" + integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA== + dependencies: + minimist "^1.2.5" + jsonfile@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" @@ -10160,7 +10241,15 @@ levenary@^1.1.1: dependencies: leven "^3.1.0" -levn@^0.3.0, levn@~0.3.0: +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +levn@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= @@ -10189,14 +10278,6 @@ load-json-file@^2.0.0: pify "^2.0.0" strip-bom "^3.0.0" -loader-fs-cache@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/loader-fs-cache/-/loader-fs-cache-1.0.2.tgz#54cedf6b727e1779fd8f01205f05f6e88706f086" - integrity sha512-70IzT/0/L+M20jUlEqZhZyArTU6VKLRTYRDAYN26g4jfzpJqjipLL3/hgYpySqI9PwsVRHHFja0LfEmsx9X2Cw== - dependencies: - find-cache-dir "^0.1.1" - mkdirp "0.5.1" - loader-runner@^2.3.1, loader-runner@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.4.0.tgz#ed47066bfe534d7e84c4c7b9998c2a75607d9357" @@ -10231,6 +10312,15 @@ loader-utils@^0.2.11, loader-utils@^0.2.16: json5 "^0.5.0" object-assign "^4.0.1" +loader-utils@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.0.tgz#e4cace5b816d425a166b5f097e10cd12b36064b0" + integrity sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ== + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^2.1.2" + locate-path@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" @@ -10581,6 +10671,13 @@ make-dir@^3.0.0: dependencies: semver "^6.0.0" +make-dir@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" + integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + dependencies: + semver "^6.0.0" + make-error@1.x, make-error@^1.1.1: version "1.3.5" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.5.tgz#efe4e81f6db28cadd605c70f29c831b58ef776c8" @@ -10903,6 +11000,11 @@ minimist@1.2.0, minimist@^1.1.0, minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2 resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ= +minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + minimist@~0.0.1: version "0.0.10" resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" @@ -11567,7 +11669,7 @@ object-copy@^0.1.0: define-property "^0.2.5" kind-of "^3.0.3" -object-hash@^2.0.1: +object-hash@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.0.3.tgz#d12db044e03cd2ca3d77c0570d87225b02e1e6ea" integrity sha512-JPKn0GMu+Fa3zt3Bmr66JhokJU5BaNBIh4ZeTlaCBzrBsOeXzwcKKAK1tbLiPKgvwmPXsDvvLHoWh5Bm7ofIYg== @@ -11746,7 +11848,7 @@ optimize-css-assets-webpack-plugin@^5.0.3: cssnano "^4.1.10" last-call-webpack-plugin "^3.0.0" -optionator@^0.8.1, optionator@^0.8.3: +optionator@^0.8.1: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== @@ -11758,6 +11860,18 @@ optionator@^0.8.1, optionator@^0.8.3: type-check "~0.3.2" word-wrap "~1.2.3" +optionator@^0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499" + integrity sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.3" + optipng-bin@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/optipng-bin/-/optipng-bin-6.0.0.tgz#376120fa79d5e71eee2f524176efdd3a5eabd316" @@ -12361,13 +12475,6 @@ pirates@^4.0.1: dependencies: node-modules-regexp "^1.0.0" -pkg-dir@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-1.0.0.tgz#7a4b508a8d5bb2d629d447056ff4e9c9314cf3d4" - integrity sha1-ektQio1bstYp1EcFb/TpyTFM89Q= - dependencies: - find-up "^1.0.0" - pkg-dir@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b" @@ -12865,6 +12972,11 @@ prefix-style@2.0.1: resolved "https://registry.yarnpkg.com/prefix-style/-/prefix-style-2.0.1.tgz#66bba9a870cfda308a5dc20e85e9120932c95a06" integrity sha1-ZrupqHDP2jCKXcIOhekSCTLJWgY= +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" @@ -13686,7 +13798,7 @@ redux-watcher@^1.0.1: dependencies: lodash "^4.13.1" -redux@^3.4.0, redux@^3.6.0: +redux@^3.6.0: version "3.7.2" resolved "https://registry.yarnpkg.com/redux/-/redux-3.7.2.tgz#06b73123215901d25d065be342eb026bc1c8537b" integrity sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A== @@ -13696,7 +13808,7 @@ redux@^3.4.0, redux@^3.6.0: loose-envify "^1.1.0" symbol-observable "^1.0.3" -redux@^4.0.0, redux@^4.0.4: +redux@^4.0.0, redux@^4.0.1, redux@^4.0.4: version "4.0.5" resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== @@ -13766,16 +13878,16 @@ regexp.prototype.flags@^1.2.0, regexp.prototype.flags@^1.3.0: define-properties "^1.1.3" es-abstract "^1.17.0-next.1" -regexpp@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f" - integrity sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw== - regexpp@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.0.0.tgz#dd63982ee3300e67b41c1956f850aa680d9d330e" integrity sha512-Z+hNr7RAVWxznLPuA7DIh8UNX1j9CDrUQxskw9IrBE1Dxue2lyXT+shqEIeLUjrokxIP8CMy1WkjgG3rTsd5/g== +regexpp@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.1.0.tgz#206d0ad0a5648cffbdb8ae46438f3dc51c9f78e2" + integrity sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q== + regexpu-core@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-1.0.0.tgz#86a763f58ee4d7c2f6b102e4764050de7ed90c6b" @@ -14040,13 +14152,20 @@ resolve@1.1.7: resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= -resolve@1.x, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.14.2, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.8.1: +resolve@1.x, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.8.1: version "1.15.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.1.tgz#27bdcdeffeaf2d6244b95bb0f9f4b4653451f3e8" integrity sha512-84oo6ZTtoTUpjgNEr5SJyzQhzL72gaRodsSfyxC/AXRvwu0Yse9H8eF9IpGo7b8YetZhlI6v7ZQ6bKBFV/6S7w== dependencies: path-parse "^1.0.6" +resolve@^1.15.1: + version "1.17.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" + integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== + dependencies: + path-parse "^1.0.6" + responselike@1.0.2, responselike@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" @@ -14346,6 +14465,14 @@ schema-utils@^2.0.0, schema-utils@^2.0.1, schema-utils@^2.5.0, schema-utils@^2.6 ajv "^6.10.2" ajv-keywords "^3.4.1" +schema-utils@^2.6.5: + version "2.6.6" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.6.6.tgz#299fe6bd4a3365dc23d99fd446caff8f1d6c330c" + integrity sha512-wHutF/WPSbIi9x6ctjGGk2Hvl0VOz5l3EKEuKbjPlB30mKZUzb9A5k9yEXRX3pwyqVLPvpfZZEllaFq/M718hA== + dependencies: + ajv "^6.12.0" + ajv-keywords "^3.4.1" + screenfull@^5.0.0: version "5.0.2" resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-5.0.2.tgz#b9acdcf1ec676a948674df5cd0ff66b902b0bed7" @@ -14412,7 +14539,7 @@ semver@7.0.0: resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== -semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0: +semver@^6.0.0, semver@^6.1.0, semver@^6.2.0, semver@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== @@ -14422,6 +14549,11 @@ semver@^7.1.1, semver@^7.1.2, semver@^7.1.3: resolved "https://registry.yarnpkg.com/semver/-/semver-7.1.3.tgz#e4345ce73071c53f336445cfc19efb1c311df2a6" integrity sha512-ekM0zfiA9SCBlsKa2X1hxyxiI4L3B6EbVJkkdgQXnSEEaHlGdvyodMruTiulSRWMMB4NeIuYNMC9rTKTz97GxA== +semver@^7.2.1, semver@^7.3.2: + version "7.3.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938" + integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ== + semver@~5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" @@ -15253,6 +15385,11 @@ strip-json-comments@^3.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.0.1.tgz#85713975a91fb87bf1b305cca77395e40d2a64a7" integrity sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw== +strip-json-comments@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.0.tgz#7638d31422129ecf4457440009fba03f9f9ac180" + integrity sha512-e6/d0eBu7gHtdCqFt0xJr642LdToM5/cN4Qb9DbHjVx1CP5RyeM+zH7pbecEmDv/lBqb0QH+6Uqq75rxFPkM0w== + strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" @@ -16004,6 +16141,13 @@ tweetnacl@^0.14.3, tweetnacl@~0.14.0: resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + type-check@~0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" @@ -16417,7 +16561,7 @@ util.promisify@1.0.0: define-properties "^1.1.2" object.getownpropertydescriptors "^2.0.3" -util.promisify@^1.0.0, util.promisify@~1.0.0: +util.promisify@1.0.1, util.promisify@^1.0.0, util.promisify@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.0.1.tgz#6baf7774b80eeb0f7520d8b81d07982a59abbaee" integrity sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA== @@ -16957,7 +17101,7 @@ window-size@^1.1.1: define-property "^1.0.0" is-number "^3.0.0" -word-wrap@~1.2.3: +word-wrap@^1.2.3, word-wrap@~1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== @@ -17088,6 +17232,13 @@ xmlchars@^2.1.1: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== +xregexp@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-4.3.0.tgz#7e92e73d9174a99a59743f67a4ce879a04b5ae50" + integrity sha512-7jXDIFXh5yJ/orPn4SXjuVrWWoi4Cr8jfV1eHv9CixKSbU+jY4mxfrBwAuDvupPNKpMUY+FeIqsVw/JLT9+B8g== + dependencies: + "@babel/runtime-corejs3" "^7.8.3" + xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" From 7b101e0ac5c47b12f72a3aa64f7401f042a7b4ee Mon Sep 17 00:00:00 2001 From: Jonas Snellinckx Date: Wed, 6 Jan 2021 22:40:26 +0100 Subject: [PATCH 04/13] feat: WIP split up epics for main and renderer + more frontend rework --- .eslintrc.json | 6 + package.json | 65 +- patches/electron-redux+2.0.0-alpha.6.patch | 102 + src/common/api/helpers/axiosClient.ts | 82 +- src/common/api/helpers/fetchToJsonNew.ts | 28 +- src/common/constants/events.ts | 13 - src/common/store/api.ts | 18 +- src/common/store/app/actions.ts | 97 +- src/common/store/app/api.ts | 1 + src/common/store/app/epics.ts | 30 +- src/common/store/app/reducer.ts | 4 - src/common/store/app/selectors.ts | 11 + src/common/store/app/types.ts | 47 +- src/common/store/appAuth/actions.ts | 33 +- src/common/store/appAuth/epics.ts | 20 +- src/common/store/appAuth/reducer.ts | 39 +- src/common/store/appAuth/types.ts | 26 +- src/common/store/auth/actions.ts | 32 +- src/common/store/auth/api.ts | 90 +- src/common/store/auth/epics.ts | 173 +- src/common/store/auth/types.ts | 51 +- src/common/store/config/selectors.ts | 5 + src/common/store/config/types.ts | 4 +- src/common/store/declarations.d.ts | 2 +- src/common/store/entities/selectors.ts | 2 +- src/common/store/index.ts | 170 +- src/common/store/objects/reducer.ts | 29 +- src/common/store/objects/types.ts | 14 +- src/common/store/player/actions.ts | 772 +++-- src/common/store/player/epics/index.ts | 2 + .../player/{epics.ts => epics/player.ts} | 188 +- src/common/store/player/epics/queue.ts | 134 + src/common/store/player/reducer.ts | 33 +- src/common/store/player/selectors.ts | 42 +- src/common/store/player/types.ts | 52 +- src/common/store/playlist/actions.ts | 10 +- src/common/store/playlist/api.ts | 60 +- src/common/store/playlist/epics.ts | 300 +- src/common/store/playlist/types.ts | 14 +- src/common/store/track/api.ts | 18 +- src/common/store/track/epics.ts | 44 +- src/common/store/track/selectors.ts | 8 +- src/common/store/track/types.ts | 14 +- src/common/store/ui/epics.ts | 11 +- src/common/store/ui/reducer.ts | 21 +- src/common/store/ui/selectors.ts | 4 +- src/common/store/ui/types.ts | 13 +- src/common/store/user/api.ts | 30 +- src/common/store/user/epics.ts | 34 +- src/common/store/user/types.ts | 14 +- src/common/utils/errors/EpicError.ts | 41 + src/common/utils/ipc.ts | 16 - src/common/utils/playerUtils.ts | 18 +- src/common/utils/reduxUtils.ts | 17 +- src/common/utils/soundcloudUtils.ts | 2 +- src/config.ts | 3 + src/globals.d.ts | 2 + src/main/app.ts | 85 +- src/main/aws/awsApiGatewayService.ts | 84 - src/main/aws/awsIotService.ts | 112 - src/main/features/core/applicationMenu.ts | 54 +- .../core/chromecast/chromecastManager.ts | 70 +- src/main/features/core/configManager.ts | 99 +- src/main/features/core/ipcManager.ts | 142 +- src/main/features/core/lastFm.ts | 153 +- src/main/features/core/notificationManager.ts | 43 - src/main/features/core/powerMonitor.ts | 10 +- src/main/features/core/shortcutManager.ts | 8 +- src/main/features/feature.ts | 105 +- src/main/features/index.ts | 2 - src/main/features/linux/dbusService.ts | 8 +- src/main/features/linux/mprisService.ts | 136 +- src/main/features/mac/mediaServiceManager.ts | 122 +- src/main/features/mac/touchBarManager.ts | 87 +- src/main/features/win32/thumbar.ts | 96 +- .../features/win32/win10/win10MediaService.ts | 86 +- src/main/index.dev.ts | 2 +- src/main/index.ts | 106 +- src/main/store/epics/app.ts | 67 + src/main/store/epics/auth.ts | 147 + src/main/store/epics/config.ts | 20 + src/main/store/rootEpic.ts | 18 + src/main/utils/pkce.ts | 74 + src/renderer/App.tsx | 24 +- src/renderer/_shared/ActionsDropdown.tsx | 19 +- src/renderer/_shared/InfiniteScroll.tsx | 2 +- .../_shared/PageHeader/PageHeader.scss | 30 +- .../components/TogglePlayButton.tsx | 7 +- src/renderer/_shared/PlaylistTrackList.tsx | 17 +- src/renderer/_shared/ShareMenuItem.tsx | 38 +- src/renderer/_shared/TextShortener.tsx | 2 +- src/renderer/_shared/TrackList/TrackList.tsx | 34 +- .../TrackList/TrackListItem/TrackListItem.tsx | 254 +- .../_shared/TracksGrid/TrackGridRow.tsx | 2 +- .../TrackgridItem/TrackGridItem.tsx | 4 +- .../TrackgridUser/TrackGridUser.tsx | 128 +- .../_shared/TracksGrid/TracksGrid.tsx | 8 +- .../_shared/context/contentContext.tsx | 4 +- src/renderer/app/ContentWrapper.tsx | 93 + src/renderer/app/Layout.tsx | 331 +- src/renderer/app/Main.tsx | 56 +- .../app/components/Header/Header.scss | 41 +- src/renderer/app/components/Header/Header.tsx | 345 +- .../components/Header/Search/SearchBox.tsx | 128 - .../Header/components/HistoryNavigation.tsx | 35 + .../{ => components}/Search/SearchBox.scss | 7 +- .../Header/components/Search/SearchBox.tsx | 63 + .../Header/{ => components}/User/User.scss | 0 .../Header/{ => components}/User/User.tsx | 0 src/renderer/app/components/Queue/Queue.scss | 300 +- src/renderer/app/components/Queue/Queue.tsx | 100 +- .../app/components/Queue/QueueItem.tsx | 200 +- .../app/components/Sidebar/Sidebar.tsx | 5 +- .../Sidebar/playlist/SideBarPlaylistItem.tsx | 4 +- src/renderer/app/components/Toastr.tsx | 37 +- .../modals/AboutModal/AboutModal.tsx | 139 +- .../modals/SettingsModal/SettingsModal.tsx | 8 +- src/renderer/app/components/player/Player.tsx | 438 +-- .../components/player/components/Audio.tsx | 27 +- .../player/components/CastPopover.tsx | 53 + .../player/components/TrackInfo/TrackInfo.tsx | 61 +- src/renderer/css/app.scss | 10 +- src/renderer/index.tsx | 8 +- src/renderer/pages/GenericPlaylist/index.tsx | 45 +- src/renderer/pages/artist/ArtistPage.tsx | 13 +- .../ArtistProfiles/ArtistProfiles.tsx | 3 +- .../pages/charts/ChartsDetailsPage.tsx | 4 +- src/renderer/pages/charts/ChartsPage.scss | 32 +- src/renderer/pages/charts/ChartsPage.tsx | 6 +- .../pages/foryou/ForYouPage.module.scss | 11 +- src/renderer/pages/foryou/ForYouPage.tsx | 3 +- src/renderer/pages/onboarding/OnBoarding.tsx | 61 +- .../pages/onboarding/components/LoginStep.tsx | 78 +- .../onboarding/components/PrivacyStep.tsx | 23 +- src/renderer/pages/playlist/PlaylistPage.tsx | 21 +- src/renderer/pages/search/SearchPage.tsx | 8 +- src/renderer/pages/settings/Settings.tsx | 490 +-- .../settings/components/CheckboxConfig.tsx | 59 +- .../pages/settings/components/InputConfig.tsx | 117 +- .../settings/components/SelectConfig.tsx | 88 +- .../components/sections/AdvancedSettings.tsx | 62 + .../components/sections/MainSettings.tsx | 300 ++ src/renderer/pages/track/TrackPage.tsx | 10 +- .../pages/track/components/TrackOverview.tsx | 5 +- src/types/index.ts | 8 +- tsconfig.json | 1 + types/dbus-next/index.d.ts | 36 - types/electron-redux/index.d.ts | 22 - yarn.lock | 3086 ++++++++++------- 149 files changed, 6345 insertions(+), 6356 deletions(-) create mode 100644 patches/electron-redux+2.0.0-alpha.6.patch create mode 100644 src/common/store/app/selectors.ts create mode 100644 src/common/store/player/epics/index.ts rename src/common/store/player/{epics.ts => epics/player.ts} (86%) create mode 100644 src/common/store/player/epics/queue.ts delete mode 100644 src/main/aws/awsApiGatewayService.ts delete mode 100644 src/main/aws/awsIotService.ts delete mode 100755 src/main/features/core/notificationManager.ts create mode 100644 src/main/store/epics/app.ts create mode 100644 src/main/store/epics/auth.ts create mode 100644 src/main/store/epics/config.ts create mode 100644 src/main/store/rootEpic.ts create mode 100644 src/main/utils/pkce.ts create mode 100644 src/renderer/app/ContentWrapper.tsx delete mode 100644 src/renderer/app/components/Header/Search/SearchBox.tsx create mode 100644 src/renderer/app/components/Header/components/HistoryNavigation.tsx rename src/renderer/app/components/Header/{ => components}/Search/SearchBox.scss (85%) create mode 100644 src/renderer/app/components/Header/components/Search/SearchBox.tsx rename src/renderer/app/components/Header/{ => components}/User/User.scss (100%) rename src/renderer/app/components/Header/{ => components}/User/User.tsx (100%) create mode 100644 src/renderer/app/components/player/components/CastPopover.tsx create mode 100644 src/renderer/pages/settings/components/sections/AdvancedSettings.tsx create mode 100644 src/renderer/pages/settings/components/sections/MainSettings.tsx delete mode 100644 types/dbus-next/index.d.ts delete mode 100644 types/electron-redux/index.d.ts diff --git a/.eslintrc.json b/.eslintrc.json index c43d34d6..3cd92e85 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -60,6 +60,12 @@ "jsx-a11y/media-has-caption": "off", "jsx-a11y/control-has-associated-label": "off", "no-underscore-dangle": "off", + "no-param-reassign": "off", + "@typescript-eslint/camelcase": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/ban-ts-comment": "off", + "no-shadow": "off", "react-hooks/rules-of-hooks": "error", "import/no-cycle": "warn", "import/no-unresolved": [ diff --git a/package.json b/package.json index 4663d519..d3cd5f34 100755 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "pack": "yarn run pack:main && yarn run pack:renderer", "pack:main": "cross-env NODE_ENV=production webpack --progress --colors --config .electron-react/webpack.main.config.js", "pack:renderer": "cross-env NODE_ENV=production webpack --progress --colors --config .electron-react/webpack.renderer.config.js", - "postinstall": "yarn run lint:fix", + "postinstall": "patch-package && yarn run lint:fix", "test": "yarn run test:unit && yarn run test:e2e", "test:unit": "jest -c jest.unit.config.js", "test:e2e": "yarn run pack && jest -c jest.e2e.config.js", @@ -51,8 +51,6 @@ "@sentry/webpack-plugin": "^1.8.0", "@teamsupercell/typings-for-css-modules-loader": "^2.0.0", "@types/autolinker": "^0.24.28", - "@types/aws-iot-device-sdk": "^2.2.0", - "@types/aws4": "^1.5.1", "@types/classnames": "^2.2.6", "@types/electron": "^1.6.10", "@types/electron-builder": "^2.8.0", @@ -67,6 +65,7 @@ "@types/lodash-webpack-plugin": "^0.11.3", "@types/minimist": "^1.2.0", "@types/node": "^10.12.0", + "@types/node-fetch": "^2.5.7", "@types/node-sass": "^3.10.32", "@types/pino-multi-stream": "^3.1.1", "@types/react": "^16.9.19", @@ -80,9 +79,8 @@ "@types/react-list": "^0.8.5", "@types/react-onclickoutside": "^6.0.4", "@types/react-redux": "^7.1.7", - "@types/react-router": "^5.1.4", - "@types/react-router-dom": "^5.1.3", - "@types/react-stickynode": "^1.4.0", + "@types/react-router-dom": "^5.1.7", + "@types/react-sticky-el": "^1.0.2", "@types/react-virtualized": "^9.21.4", "@types/react-virtualized-auto-sizer": "^1.0.0", "@types/react-window": "^1.1.0", @@ -93,12 +91,13 @@ "@types/sinon": "^5.0.5", "@types/source-map-support": "^0.4.1", "@types/universal-analytics": "^0.4.3", + "@types/uuid": "^8.3.0", "@types/webpack": "^4.4.32", "@types/webpack-bundle-analyzer": "^2.13.1", "@types/webpack-merge": "^4.1.5", "@types/webpack-node-externals": "^1.6.3", - "@typescript-eslint/eslint-plugin": "^2.18.0", - "@typescript-eslint/parser": "^2.18.0", + "@typescript-eslint/eslint-plugin": "^4.9.0", + "@typescript-eslint/parser": "^4.9.0", "ajv": "^6.11.0", "asar": "^2.0.1", "autoprefixer": "^9.6.1", @@ -125,13 +124,12 @@ "css-loader": "^3.4.2", "del": "^5.1.0", "detect-port": "^1.3.0", - "devtron": "^1.4.0", "dotenv": "^8.1.0", "dotenv-webpack": "^1.7.0", - "electron": "^8.3.0", - "electron-builder": "^22.3.2", + "electron": "^11.0.3", + "electron-builder": "^22.9.1", "electron-debug": "^3.0.1", - "electron-devtools-installer": "^2.2.4", + "electron-devtools-installer": "^3.1.1", "electron-download": "^4.1.1", "electron-notarize": "^0.2.1", "env-cmd": "^8.0.2", @@ -173,6 +171,7 @@ "node-sass": "^4.13.1", "optimize-css-assets-webpack-plugin": "^5.0.3", "ora": "^2.1.0", + "patch-package": "^6.2.2", "pbf-loader": "^1.1.0", "postcss-import": "^11.1.0", "postcss-loader": "^2.1.5", @@ -195,7 +194,7 @@ "ts-node": "^8.6.2", "tsconfig-paths-webpack-plugin": "^3.2.0", "tslint": "^6.0.0", - "typescript": "^3.8.3", + "typescript": "^4.1.2", "typings-for-css-modules-loader": "^1.7.0", "url-loader": "^3.0.0", "webpack": "^4.41.5", @@ -211,10 +210,9 @@ "@amilajack/castv2-client": "Superjo149/caster", "@blueprintjs/core": "^3.23.1", "@blueprintjs/icons": "^3.12.0", - "@sentry/electron": "1.3.0", + "@sentry/electron": "2.0.4", + "abort-controller": "^3.0.0", "autolinker": "^1.8.3", - "aws-iot-device-sdk": "^2.2.1", - "aws4": "^1.9.1", "axios": "^0.19.2", "bootstrap": "^4.1.3", "boxicons": "^1.7.1", @@ -222,13 +220,12 @@ "color-hash": "^1.0.3", "connected-react-router": "^6.8.0", "core-decorators": "^0.20.0", - "electron-debug": "^3.0.1", - "electron-dl": "^1.14.0", + "electron-dl": "^3.0.2", "electron-is": "^3.0.0", - "electron-localshortcut": "^3.1.0", - "electron-redux": "^1.5.2", - "electron-store": "^5.1.0", - "electron-updater": "^4.2.0", + "electron-localshortcut": "^3.2.1", + "electron-redux": "file:../related/electron-redux", + "electron-store": "^6.0.1", + "electron-updater": "^4.3.5", "electron-window-state": "^5.0.3", "history": "^4.10.1", "jquery": "3.4.1", @@ -237,6 +234,7 @@ "lodash-decorators": "^6.0.1", "moment": "^2.17.0", "multicast-dns": "^7.2.0", + "node-fetch": "^2.6.1", "normalizr": "^3.2.2", "object-path-immutable": "^4.1.0", "pino": "^5.12.5", @@ -257,39 +255,37 @@ "react-markdown": "^4.0.3", "react-marquee": "^1.0.0", "react-masonry-css": "^1.0.11", - "react-redux": "^7.1.1", - "react-router-dom": "^5.0.1", - "react-stickynode": "^2.1.1", + "react-redux": "^7.2.2", + "react-router-dom": "^5.2.0", + "react-sticky-el": "^2.0.5", "react-use": "^13.27.0", "react-virtualized-auto-sizer": "^1.0.2", "react-window": "^1.8.5", "react-window-infinite-loader": "^1.0.5", "reactstrap": "^8.4.0", - "redux": "^4.0.4", + "redux": "^4.0.5", "redux-devtools-extension": "^2.13.8", "redux-modal": "^4.0.0", "redux-observable": "^1.2.0", - "redux-promise-middleware": "^6.1.1", - "redux-thunk": "^2.3.0", "redux-watcher": "^1.0.1", "reselect": "^4.0.0", - "retry-axios": "^2.1.1", - "rxjs": "^6.5.4", + "rxjs": "^6.6.3", "semver": "^5.3.0", "serialize-error": "^6.0.0", "source-map-support": "^0.5.16", "styled-components": "^2.0.1", "tslib": "^1.10.0", "typesafe-actions": "^5.1.0", - "universal-analytics": "^0.4.20" + "universal-analytics": "^0.4.20", + "uuid": "^8.3.2" }, "optionalDependencies": { "@nodert-win10-rs4/windows.foundation": "^0.4.4", "@nodert-win10-rs4/windows.media": "^0.4.4", "@nodert-win10-rs4/windows.media.playback": "^0.4.4", "@nodert-win10-rs4/windows.storage.streams": "^0.4.4", - "dbus-next": "0.8.1", - "electron-media-service": "auryo/electron-media-service", + "dbus-next": "0.9.1", + "electron-media-service": "tidal-engineering/electron-media-service", "mpris-service": "2.1.0" }, "build": { @@ -310,7 +306,6 @@ "protocols": [ { "name": "auryo", - "role": "Viewer", "schemes": [ "auryo" ] @@ -391,4 +386,4 @@ "resolutions": { "**/**/nan": "^2.9.2" } -} \ No newline at end of file +} diff --git a/patches/electron-redux+2.0.0-alpha.6.patch b/patches/electron-redux+2.0.0-alpha.6.patch new file mode 100644 index 00000000..5310fc15 --- /dev/null +++ b/patches/electron-redux+2.0.0-alpha.6.patch @@ -0,0 +1,102 @@ +diff --git a/node_modules/electron-redux/lib/electron-redux.js b/node_modules/electron-redux/lib/electron-redux.js +index 6e3210b..1605716 100644 +--- a/node_modules/electron-redux/lib/electron-redux.js ++++ b/node_modules/electron-redux/lib/electron-redux.js +@@ -93,6 +93,8 @@ const validateAction = (action, // Actions that we should never replay across st + denyList = [/^@@/, /^redux-form/]) => { + var _action$meta; + ++ console.log(isFSA(action), action); ++ + return isFSA(action) && ((_action$meta = action.meta) === null || _action$meta === void 0 ? void 0 : _action$meta.scope) !== 'local' && denyList.every(rule => !rule.test(action.type)); + }; + +@@ -158,7 +160,31 @@ const mainStateSyncEnhancer = (options = defaultMainOptions) => createStore => { + preventDoubleInitialization(); + const middleware = createMiddleware(options); + return (reducer, state) => { +- return createStore(reducer, state, redux.applyMiddleware(middleware)); ++ const store = createStore(reducer, state); ++ ++ electron.ipcMain.handle(IPCEvents.INIT_STATE_ASYNC, async () => { ++ return JSON.stringify(store.getState(), options.serializer); ++ }); ++ electron.ipcMain.on(IPCEvents.INIT_STATE, event => { ++ event.returnValue = JSON.stringify(store.getState(), options.serializer); ++ }); // When receiving an action from a renderer ++ ++ electron.ipcMain.on(IPCEvents.ACTION, (event, action) => { ++ const localAction = stopForwarding(action); ++ store.dispatch(localAction); // Forward it to all of the other renderers ++ ++ electron.webContents.getAllWebContents().forEach(contents => { ++ // Ignore the renderer that sent the action and chromium devtools ++ if (contents.id !== event.sender.id && !contents.getURL().startsWith('devtools://')) { ++ contents.send(IPCEvents.ACTION, localAction); ++ } ++ }); ++ }); ++ ++ return { ++ ...store, ++ // dispatch: middleware(store)(store.dispatch) ++ } + }; + }; + +@@ -211,7 +237,9 @@ const createMiddleware$1 = options => store => { + store.dispatch(stopForwarding(action)); + }); + return next => action => { ++ console.log(validateAction(action, options.denyList), action); + if (validateAction(action, options.denyList)) { ++ console.log("DISPATCH LOCAL ACTIO",action); + electron.ipcRenderer.send(IPCEvents.ACTION, action); + } + +@@ -231,7 +259,7 @@ const rendererStateSyncEnhancer = (options = defaultRendererOptions) => createSt + preventDoubleInitialization(); + return (reducer, state) => { + const initialState = options.lazyInit ? state : fetchInitialState(options); +- const store = createStore(options.lazyInit ? withStoreReplacer(reducer) : reducer, initialState, redux.applyMiddleware(createMiddleware$1(options))); ++ const store = createStore(options.lazyInit ? withStoreReplacer(reducer) : reducer, initialState); + + if (options.lazyInit) { + fetchInitialStateAsync(options, asyncState => { +@@ -242,11 +270,35 @@ const rendererStateSyncEnhancer = (options = defaultRendererOptions) => createSt + // immediately it's fine, but even assigning it to a constant and returning + // will make it freak out. We fix this with the line below the return. + ++ electron.ipcRenderer.on(IPCEvents.ACTION, (_, action) => { ++ store.dispatch(stopForwarding(action)); ++ }); ++ + +- return store; // TODO: this needs some ❤️ ++ return { ++ ...store, ++ // dispatch: createMiddleware$1(options)(store)(store.dispatch) ++ } // TODO: this needs some ❤️ + }; + }; + ++const forwardAction = next => action => { ++ if(validateAction(action, options.denyList)){ ++ if (process.type === "browser"){ ++ electron.webContents.getAllWebContents().forEach(contents => { ++ // Ignore chromium devtools ++ if (contents.getURL().startsWith('devtools://')) return; ++ contents.send(IPCEvents.ACTION, action); ++ }); ++ } else if(process.type === "renderer"){ ++ electron.ipcRenderer.send(IPCEvents.ACTION, action); ++ } ++ } ++ ++ return next(action); ++} ++ + exports.mainStateSyncEnhancer = mainStateSyncEnhancer; + exports.rendererStateSyncEnhancer = rendererStateSyncEnhancer; + exports.stopForwarding = stopForwarding; ++exports.forwardAction = forwardAction; diff --git a/src/common/api/helpers/axiosClient.ts b/src/common/api/helpers/axiosClient.ts index 62e44e89..d042b9d1 100644 --- a/src/common/api/helpers/axiosClient.ts +++ b/src/common/api/helpers/axiosClient.ts @@ -1,87 +1,7 @@ -import { EVENTS } from '@common/constants'; -import axios, { AxiosRequestConfig } from 'axios'; -// eslint-disable-next-line import/no-extraneous-dependencies -import { ipcRenderer } from 'electron'; +import axios from 'axios'; import is from 'electron-is'; -import * as rax from 'retry-axios'; -import { initialize } from '@common/utils/soundcloudUtils'; - -// eslint-disable-next-line no-useless-escape -const OAUTH_TOKEN_REGEX = /oauth_token=(?[A-Za-z0-9\-\._~\+\/]*)/s; - -const replaceTokenInRequest = (request: AxiosRequestConfig, token: string) => { - if (request.url) { - const reg = OAUTH_TOKEN_REGEX.exec(request.url); - - if (reg?.groups?.token) { - request.url = request.url.replace(reg.groups.token, token); - } - } -}; export const axiosClient = axios.create({ // eslint-disable-next-line global-require adapter: is.dev() && require('axios/lib/adapters/http') }); - -axiosClient.defaults.raxConfig = { - instance: axiosClient -}; - -rax.attach(axiosClient); - -let isRefreshing = false; -let subscribers: Function[] = []; - -function onRefreshed(token: string) { - subscribers.map(cb => cb(token)); -} - -function subscribeTokenRefresh(cb: Function) { - subscribers.push(cb); -} - -axiosClient.interceptors.response.use(undefined, err => { - const { config, response } = err; - const originalRequest = config as AxiosRequestConfig & { hasRetried: boolean }; - const status = response?.status; - - const tokenMatch = OAUTH_TOKEN_REGEX.exec(originalRequest?.url || ''); - - if (!tokenMatch?.groups) { - return Promise.reject(err); - } - - if (status === 401 && !originalRequest.hasRetried) { - if (!isRefreshing) { - isRefreshing = true; - let invokePromise: Promise = Promise.resolve(); - - if (is.renderer()) { - invokePromise = ipcRenderer.invoke(EVENTS.APP.AUTH.REFRESH); - } - - invokePromise.then(obj => { - isRefreshing = false; - - if (!obj || !obj?.token) { - throw new Error('no token'); - } - - initialize(obj.token); - onRefreshed(obj.token); - subscribers = []; - }); - } - - return new Promise(resolve => { - subscribeTokenRefresh((token: string) => { - replaceTokenInRequest(originalRequest, token); - originalRequest.hasRetried = true; - resolve(axios(originalRequest)); - }); - }); - } - - return Promise.reject(err); -}); diff --git a/src/common/api/helpers/fetchToJsonNew.ts b/src/common/api/helpers/fetchToJsonNew.ts index 1892a80b..e07c064b 100755 --- a/src/common/api/helpers/fetchToJsonNew.ts +++ b/src/common/api/helpers/fetchToJsonNew.ts @@ -1,7 +1,7 @@ -import { AxiosRequestConfig } from 'axios'; -import { axiosClient } from './axiosClient'; -import { CONFIG } from '../../../config'; import { memToken } from '@common/utils/soundcloudUtils'; +import * as querystring from 'querystring'; +import { fromFetch } from 'rxjs/fetch'; +import { CONFIG } from '../../../config'; const soundCloudBaseUrl = 'https://api.soundcloud.com/'; const soundCloudBaseUrlV2 = 'https://api-v2.soundcloud.com/'; @@ -12,12 +12,10 @@ export interface FetchOptions { oauthToken?: boolean; useV2Endpoint?: boolean; queryParams?: any; + url?: string; } -export default async function fetchToJsonNew( - fetchOptions: FetchOptions, - options: AxiosRequestConfig = {} -): Promise { +export default function fetchToJsonNew(fetchOptions: FetchOptions, options: RequestInit = {}) { const { queryParams = {} } = fetchOptions; if (fetchOptions.clientId) { @@ -39,12 +37,12 @@ export default async function fetchToJsonNew( baseUrl = soundCloudBaseUrlV2; } - // eslint-disable-next-line no-return-await - return await axiosClient - .request({ - url: `${baseUrl}${fetchOptions.uri}`, - params: queryParams, - ...options - }) - .then(res => res.data); + return fromFetch(fetchOptions.url ?? `${baseUrl}${fetchOptions.uri}?${querystring.stringify(queryParams)}`, { + selector: response => { + if (!response.ok) throw response; + + return response.json(); + }, + ...options + }); } diff --git a/src/common/constants/events.ts b/src/common/constants/events.ts index 253b72c1..84674cec 100644 --- a/src/common/constants/events.ts +++ b/src/common/constants/events.ts @@ -6,28 +6,15 @@ export const EVENTS = { SEEK: 'player/action/seek', SEEK_END: 'player/action/seek_end' }, - TRACK: { - LIKED: 'track/liked', - REPOSTED: 'track/reposted', - LIKE: 'track/action/like', - REPOST: 'track/action/repost' - }, APP: { - SEND_NOTIFICATION: 'app/send_notification', - NAVIGATE: 'app/navigate', - PUSH_NAVIGATION: 'app/pus-navigation', UPDATE: 'app/update', READY: 'app/ready', - RESTART: 'app/restart', RELOAD: 'app/reload', VALID_DIR: 'app/valid_dir', VALID_DIR_RESPONSE: 'app/valid_dir/response', - OPEN_EXTERNAL: 'app/open_external', - WRITE_CLIPBOARD: 'app/write_clipboard', DOWNLOAD_FILE: 'app/download_file', RAISE: 'app/raise', AUTH: { - LOGIN: 'app/auth/login', REFRESH: 'app/auth/refresh' }, LASTFM: { diff --git a/src/common/store/api.ts b/src/common/store/api.ts index d8cdc172..b8af0536 100644 --- a/src/common/store/api.ts +++ b/src/common/store/api.ts @@ -5,16 +5,10 @@ export * from './playlist/api'; export * from './track/api'; export * from './user/api'; -export async function fetchFromUrl(url: string) { - const json = await fetchToJsonNew( - { - oauthToken: true, - useV2Endpoint: true - }, - { - url: SC.appendToken(url) - } - ); - - return json; +export function fetchFromUrl(url: string) { + return fetchToJsonNew({ + oauthToken: true, + useV2Endpoint: true, + url: SC.appendToken(url) + }); } diff --git a/src/common/store/app/actions.ts b/src/common/store/app/actions.ts index 7e045029..61b37ffa 100644 --- a/src/common/store/app/actions.ts +++ b/src/common/store/app/actions.ts @@ -1,36 +1,30 @@ -import fetchToJson from '@common/api/helpers/fetchToJson'; -import { IPC } from '@common/utils/ipc'; import { wError, wSuccess } from '@common/utils/reduxUtils'; -import { EpicFailure, SoundCloud, ThunkResult } from '@types'; -import { goBack, replace } from 'connected-react-router'; -import { Dispatch } from 'redux'; +import { EpicFailure } from '@types'; import { action, createAction, createAsyncAction } from 'typesafe-actions'; -import { isSoundCloudUrl, SC } from '../../utils'; -import { - AppActionTypes, - CanGoHistory, - CastAppState, - ChromeCastDevice, - DevicePlayerStatus, - RemainingPlays -} from '../types'; +import { AppActionTypes, CastAppState, ChromeCastDevice, DevicePlayerStatus, RemainingPlays } from '../types'; export const resetStore = createAction(AppActionTypes.RESET_STORE)(); +export const copyToClipboard = createAction(AppActionTypes.COPY_TO_CLIPBOARD)(); +export const openExternalUrl = createAction(AppActionTypes.OPEN_EXTERNAL_URL)(); +export const restartApp = createAction(AppActionTypes.RESTART_APP)(); export const initApp = createAction(AppActionTypes.INIT)(); +export const receiveProtocolAction = createAction(AppActionTypes.RECEIVE_PROTOCOL_ACTION)<{ + action: string; + params: Record; +}>(); export const getRemainingPlays = createAsyncAction( String(AppActionTypes.GET_REMAINING_PLAYS), wSuccess(AppActionTypes.GET_REMAINING_PLAYS), wError(AppActionTypes.GET_REMAINING_PLAYS) )(); -export const canGoInHistory = createAction(AppActionTypes.SET_CAN_GO)(); // ====== -export function tryAndResolveQueryAsSoundCloudUrl(query: string, dispatch: Dispatch) { - if (isSoundCloudUrl(query)) { - dispatch(resolveUrl(query) as any); - } -} +// export function tryAndResolveQueryAsSoundCloudUrl(query: string, dispatch: Dispatch) { +// if (isSoundCloudUrl(query)) { +// dispatch(resolveUrl(query) as any); +// } +// } export const setLastfmLoading = (loading: boolean) => action(AppActionTypes.SET_LASTFM_LOADING, loading); export const addChromeCastDevice = (device: ChromeCastDevice) => @@ -63,23 +57,6 @@ const listeners: any[] = []; // // tslint:disable-next-line: max-func-body-length // return dispatch => { // if (!listeners.length) { -// listeners.push({ -// event: EVENTS.TRACK.LIKE, -// handler: (_e: any, trackId: string) => { -// if (trackId) { -// dispatch(toggleLike(+trackId, false)); // TODO determine if track or playlist -// } -// } -// }); - -// listeners.push({ -// event: EVENTS.TRACK.REPOST, -// handler: (_e: string, trackId: string) => { -// if (trackId) { -// dispatch(toggleRepost(+trackId, false)); // TODO determine if track or playlist -// } -// } -// }); // listeners.push({ // event: EVENTS.APP.SEND_NOTIFICATION, @@ -156,26 +133,26 @@ const listeners: any[] = []; // }; // } -export function resolveUrl(url: string): ThunkResult { - return dispatch => { - fetchToJson>(SC.resolveUrl(url)) - .then(json => { - switch (json.kind) { - case 'track': - return dispatch(replace(`/track/${json.id}`)); - case 'playlist': - return dispatch(replace(`/playlist/${json.id}`)); - case 'user': - return dispatch(replace(`/user/${json.id}`)); - default: - // eslint-disable-next-line no-console - console.error('Resolve not implemented for', json.kind); - return null; - } - }) - .catch(() => { - dispatch(goBack()); - IPC.openExternal(unescape(url)); - }); - }; -} +// export function resolveUrl(url: string): ThunkResult { +// return dispatch => { +// fetchToJson>(SC.resolveUrl(url)) +// .then(json => { +// switch (json.kind) { +// case 'track': +// return dispatch(replace(`/track/${json.id}`)); +// case 'playlist': +// return dispatch(replace(`/playlist/${json.id}`)); +// case 'user': +// return dispatch(replace(`/user/${json.id}`)); +// default: +// // eslint-disable-next-line no-console +// console.error('Resolve not implemented for', json.kind); +// return null; +// } +// }) +// .catch(() => { +// dispatch(goBack()); +// IPC.openExternal(unescape(url)); +// }); +// }; +// } diff --git a/src/common/store/app/api.ts b/src/common/store/app/api.ts index 16bcaee6..5c7aee97 100644 --- a/src/common/store/app/api.ts +++ b/src/common/store/app/api.ts @@ -16,6 +16,7 @@ interface Status { reset_time: string; } +// TODO export async function fetchRemainingTracks(overrideClientId?: string | null): Promise { try { const json = await fetchToJsonNew({ diff --git a/src/common/store/app/epics.ts b/src/common/store/app/epics.ts index 7ef50b46..ebfc5bd4 100644 --- a/src/common/store/app/epics.ts +++ b/src/common/store/app/epics.ts @@ -1,9 +1,11 @@ import { replace } from 'connected-react-router'; -import { from, of } from 'rxjs'; +import { from, fromEvent, merge, of } from 'rxjs'; import { catchError, filter, map, switchMap, withLatestFrom } from 'rxjs/operators'; import { isActionOf } from 'typesafe-actions'; +import { getRemainingPlays, initApp, login } from '../actions'; +import { configSelector } from '../config/selectors'; import { RootEpic } from '../declarations'; -import { getRemainingPlays, initApp, loginSuccess } from '../actions'; +import { toggleOffline } from './actions'; import * as APIService from './api'; export const initAppEpic: RootEpic = (action$, state$) => @@ -20,7 +22,7 @@ export const initAppEpic: RootEpic = (action$, state$) => } return of( - loginSuccess({ + login.success({ access_token: auth.token }) ); @@ -31,12 +33,17 @@ export const getRemainingPlaysEpic: RootEpic = (action$, state$) => action$.pipe( filter(isActionOf(getRemainingPlays.request)), withLatestFrom(state$), - switchMap(([, state]) => { - const { - config: { - app: { overrideClientId } - } - } = state; + map(([, state]) => configSelector(state).app.overrideClientId), + switchMap(overrideClientId => { + if (overrideClientId) { + return map(() => + getRemainingPlays.success({ + remaining: -1, + resetTime: Date.now(), + updatedAt: Date.now() + }) + ); + } return from(APIService.fetchRemainingTracks(overrideClientId)).pipe( map(response => { @@ -53,3 +60,8 @@ export const getRemainingPlaysEpic: RootEpic = (action$, state$) => ); }) ); + +export const OfflineStatusEpic: RootEpic = () => + merge(of(null), fromEvent(window, 'online'), fromEvent(window, 'offline')).pipe( + map(() => toggleOffline(!navigator.onLine)) + ); diff --git a/src/common/store/app/reducer.ts b/src/common/store/app/reducer.ts index f3d3b970..701c283c 100644 --- a/src/common/store/app/reducer.ts +++ b/src/common/store/app/reducer.ts @@ -3,10 +3,6 @@ import { getRemainingPlays, resetStore } from '../actions'; import { AppState } from './types'; const initialState: AppState = { - history: { - back: false, - next: false - }, error: false, loaded: false, loadingError: null, diff --git a/src/common/store/app/selectors.ts b/src/common/store/app/selectors.ts new file mode 100644 index 00000000..2382ae76 --- /dev/null +++ b/src/common/store/app/selectors.ts @@ -0,0 +1,11 @@ +import { StoreState } from 'AppReduxTypes'; +import { createSelector } from 'reselect'; + +export const appSelector = (state: StoreState) => state.app; + +export const isUpdateAvailableSelector = createSelector(appSelector, app => app.update.available); +export const remainingPlaysSelector = createSelector(appSelector, app => app.remainingPlays); +export const castSelector = createSelector(appSelector, app => app.chromecast); +export const isPlayingOnChromecastSelector = createSelector(castSelector, chromecast => !!chromecast.castApp); +export const loadingErrorSelector = createSelector(appSelector, app => app.loadingError); +export const lastFmLoadingSelector = createSelector(appSelector, app => app.lastfmLoading); diff --git a/src/common/store/app/types.ts b/src/common/store/app/types.ts index d526c56d..f19fca5e 100644 --- a/src/common/store/app/types.ts +++ b/src/common/store/app/types.ts @@ -3,7 +3,6 @@ // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface AppState extends Readonly<{ - history: CanGoHistory; error: boolean; loaded: boolean; loadingError: string | null; @@ -16,11 +15,11 @@ export interface AppState }> {} export const DEVICE_MODELS = { - Group: 'Google Cast Group' as 'Google Cast Group', - Home: 'Google Home' as 'Google Home', - HomeMini: 'Google Home Mini' as 'Google Home Mini', - MagniFiMini: 'MagniFi Mini' as 'MagniFi Mini', - Chromecast: 'Chromecast' as 'Chromecast' + Group: 'Google Cast Group' as const, + Home: 'Google Home' as const, + HomeMini: 'Google Home Mini' as const, + MagniFiMini: 'MagniFi Mini' as const, + Chromecast: 'Chromecast' as const }; // TODO: add more valid models as they're found @@ -62,11 +61,6 @@ export interface RemainingPlays { resetTime: number; updatedAt?: number; } - -export interface CanGoHistory { - back: boolean; - next: boolean; -} export interface UpdateInfo { available: boolean; version: string | null; @@ -75,19 +69,22 @@ export interface UpdateInfo { // ACTIONS export enum AppActionTypes { - GET_REMAINING_PLAYS = '@@app/GET_REMAINING_PLAYS', - RESET_STORE = '@@app/RESET_STORE', - INIT = '@@app/INIT', + GET_REMAINING_PLAYS = 'auryo.app.GET_REMAINING_PLAYS', + RESET_STORE = 'auryo.app.RESET_STORE', + COPY_TO_CLIPBOARD = 'auryo.app.COPY_TO_CLIPBOARD', + OPEN_EXTERNAL_URL = 'auryo.app.OPEN_EXTERNAL_URL', + RESTART_APP = 'auryo.app.RESTART_APP', + INIT = 'auryo.app.INIT', + RECEIVE_PROTOCOL_ACTION = 'auryo.app.RECEIVE_PROTOCOL_ACTION', - TOGGLE_OFFLINE = '@@app/TOGGLE_OFFLINE', - SET_LOADED = '@@app/SET_LOADED', - SET_UPDATE_AVAILABLE = '@@app/SET_UPDATE_AVAILABLE', - SET_CAN_GO = '@@app/SET_CAN_GO', - SET_REMAINING_PLAYS = '@@app/SET_REMAINING_PLAYS', - SET_LASTFM_LOADING = '@@app/SET_LASTFM_LOADING', - ADD_CHROMECAST_DEVICE = '@@app/ADD_CHROMECAST_DEVICE', - REMOVE_CHROMECAST_DEVICE = '@@app/REMOVE_CHROMECAST_DEVICE', - SET_CHROMECAST_DEVICE = '@@app/SET_CHROMECAST_DEVICE', - SET_CHROMECAST_PLAYER_STATUS = '@@app/SET_CHROMECAST_PLAYER_STATUS', - SET_CHROMECAST_APP_STATE = '@@app/SET_CHROMECAST_APP_STATE' + TOGGLE_OFFLINE = 'auryo.app.TOGGLE_OFFLINE', + SET_LOADED = 'auryo.app.SET_LOADED', + SET_UPDATE_AVAILABLE = 'auryo.app.SET_UPDATE_AVAILABLE', + SET_REMAINING_PLAYS = 'auryo.app.SET_REMAINING_PLAYS', + SET_LASTFM_LOADING = 'auryo.app.SET_LASTFM_LOADING', + ADD_CHROMECAST_DEVICE = 'auryo.app.ADD_CHROMECAST_DEVICE', + REMOVE_CHROMECAST_DEVICE = 'auryo.app.REMOVE_CHROMECAST_DEVICE', + SET_CHROMECAST_DEVICE = 'auryo.app.SET_CHROMECAST_DEVICE', + SET_CHROMECAST_PLAYER_STATUS = 'auryo.app.SET_CHROMECAST_PLAYER_STATUS', + SET_CHROMECAST_APP_STATE = 'auryo.app.SET_CHROMECAST_APP_STATE' } diff --git a/src/common/store/appAuth/actions.ts b/src/common/store/appAuth/actions.ts index fbb50fe8..5a15dc0d 100755 --- a/src/common/store/appAuth/actions.ts +++ b/src/common/store/appAuth/actions.ts @@ -1,15 +1,24 @@ -import { TokenResponse } from '@main/aws/awsIotService'; -import { createAction } from 'typesafe-actions'; -import { AppAuthActionTypes } from '../types'; +import { wCancel, wError, wSuccess } from '@common/utils/reduxUtils'; +import { createAction, createAsyncAction } from 'typesafe-actions'; +import { AppAuthActionTypes, TokenResponse } from '../types'; + +export const tokenRefresh = createAsyncAction( + String(AppAuthActionTypes.TOKEN_REFRESH), + wSuccess(AppAuthActionTypes.TOKEN_REFRESH), + wError(AppAuthActionTypes.TOKEN_REFRESH) +)(); + +export const login = createAsyncAction( + String(AppAuthActionTypes.LOGIN), + wSuccess(AppAuthActionTypes.LOGIN), + wError(AppAuthActionTypes.LOGIN), + wCancel(AppAuthActionTypes.LOGIN) +)(); -export const refreshToken = createAction(AppAuthActionTypes.REFRESH_TOKEN)(); export const finishOnboarding = createAction(AppAuthActionTypes.FINISH_ONBOARDING)(); -export const login = createAction(AppAuthActionTypes.LOGIN, (loading = true) => loading)(); +export const startLoginSession = createAction(AppAuthActionTypes.START_LOGIN_SESSION)<{ + uuid: string; + codeVerifier: string; +}>(); +export const verifyLoginSession = createAction(AppAuthActionTypes.VERIFY_LOGIN_SESSION)(); export const logout = createAction(AppAuthActionTypes.LOGOUT)(); -export const loginSuccess = createAction( - AppAuthActionTypes.LOGIN_SUCCESS, - (tokenResponse?: TokenResponse) => tokenResponse -)(); - -export const loginError = createAction(AppAuthActionTypes.LOGIN_ERROR, (error?: string) => error)(); -export const loginTerminated = createAction(AppAuthActionTypes.LOGIN_TERMINATED)(); diff --git a/src/common/store/appAuth/epics.ts b/src/common/store/appAuth/epics.ts index 44ab2837..4e54ded8 100644 --- a/src/common/store/appAuth/epics.ts +++ b/src/common/store/appAuth/epics.ts @@ -1,9 +1,9 @@ import { SC } from '@common/utils'; -import { TokenResponse } from '@main/aws/awsIotService'; import { replace } from 'connected-react-router'; import { of } from 'rxjs'; -import { filter, map, mergeMap, switchMap, tap, withLatestFrom, pluck } from 'rxjs/operators'; +import { exhaustMap, filter, mergeMap, pluck, switchMap, tap, withLatestFrom } from 'rxjs/operators'; import { isActionOf } from 'typesafe-actions'; +import { TokenResponse } from '.'; import { CONFIG } from '../../../config'; import { finishOnboarding, @@ -13,21 +13,21 @@ import { getCurrentUserPlaylists, getCurrentUserRepostIds, getRemainingPlays, - loginSuccess, + login, logout, - refreshToken, resetStore, - setConfigKey + setConfigKey, + tokenRefresh } from '../actions'; import { RootEpic } from '../declarations'; import { configSelector } from '../selectors'; export const setTokenEpic: RootEpic = action$ => action$.pipe( - filter(isActionOf([loginSuccess, refreshToken])), + filter(isActionOf([login.success, tokenRefresh.success])), pluck('payload'), + tap(payload => console.log('setTokenEpic', payload)), filter((payload): payload is TokenResponse => !!payload?.access_token), - // TODO: should we also try to set this in the frontend? tap(payload => SC.initialize(payload.access_token)), filter((payload): payload is TokenResponse => !!payload.refresh_token), mergeMap(payload => @@ -43,8 +43,9 @@ export const setTokenEpic: RootEpic = action$ => export const loginEpic: RootEpic = (action$, state$) => action$.pipe( - filter(isActionOf(loginSuccess)), + filter(isActionOf(login.success)), withLatestFrom(state$), + tap(([payload]) => console.log('loginEpic', payload)), switchMap(([, state]) => { const config = configSelector(state); @@ -72,7 +73,8 @@ export const loginEpic: RootEpic = (action$, state$) => export const finishOnboardingEpic: RootEpic = action$ => action$.pipe( filter(isActionOf(finishOnboarding)), - mergeMap(() => of(setConfigKey('lastLogin', Date.now()), loginSuccess())) + tap(payload => console.log('finishOnboardingEpic', payload)), + exhaustMap(() => of(setConfigKey('lastLogin', Date.now()), login.success())) ); export const logoutEpic: RootEpic = action$ => diff --git a/src/common/store/appAuth/reducer.ts b/src/common/store/appAuth/reducer.ts index c0734b6e..39f78130 100755 --- a/src/common/store/appAuth/reducer.ts +++ b/src/common/store/appAuth/reducer.ts @@ -1,35 +1,48 @@ import { createReducer } from 'typesafe-actions'; -import { resetStore, login, loginError, loginSuccess, loginTerminated } from '../actions'; +import { resetStore, startLoginSession } from '../actions'; +import { login, verifyLoginSession } from './actions'; import { AppAuthState } from './types'; -const initialState = { +const initialState: AppAuthState = { isLoading: false, - error: null + isError: false, + error: null, + sessionUUID: null, + codeVerifier: null }; export const appAuthReducer = createReducer(initialState) - .handleAction(login, () => { + .handleAction(login.request, () => { return { - isLoading: true, + isLoading: false, + isError: false, error: null }; }) - .handleAction(loginSuccess, () => { + .handleAction(startLoginSession, (state, { payload }) => { return { - isLoading: false, - error: null + ...state, + sessionUUID: payload.uuid, + codeVerifier: payload.codeVerifier }; }) - .handleAction(loginError, () => { + .handleAction(verifyLoginSession, state => { return { - isLoading: false, - error: null + ...state, + isLoading: true }; }) - .handleAction(loginTerminated, () => { + .handleAction([login.success, login.cancel], state => { + return { + ...state, + isLoading: false + }; + }) + .handleAction(login.failure, (_, { payload }) => { return { isLoading: false, - error: null + isError: true, + error: payload?.message }; }) .handleAction(resetStore, () => initialState); diff --git a/src/common/store/appAuth/types.ts b/src/common/store/appAuth/types.ts index ebdc1a5c..7ac9c4fd 100644 --- a/src/common/store/appAuth/types.ts +++ b/src/common/store/appAuth/types.ts @@ -1,16 +1,28 @@ // TYPES export type AppAuthState = Readonly<{ isLoading: boolean; + isError: boolean; error?: null | string; + sessionUUID?: null | string; + codeVerifier?: null | string; }>; +export interface TokenResponse { + access_token: string; + expires_at?: number; + refresh_token?: string; +} + // ACTIONS export enum AppAuthActionTypes { - LOGIN = '@@auth/LOGIN', - LOGIN_SUCCESS = '@@appAuth/LOGIN_SUCCESS', - LOGIN_ERROR = '@@appAuth/LOGIN_ERROR', - LOGIN_TERMINATED = '@@appAuth/LOGIN_TERMINATED', - LOGOUT = '@@appAuth/LOGOUT', - REFRESH_TOKEN = '@@appAuth/REFRESH_TOKEN', - FINISH_ONBOARDING = '@@appAuth/FINISH_ONBOARDING' + LOGIN = 'auryo.auth.LOGIN', + START_LOGIN_SESSION = 'auryo.auth.START_LOGIN_SESSION', + VERIFY_LOGIN_SESSION = 'auryo.auth.VERIFY_LOGIN_SESSION', + LOGIN_SUCCESS = 'auryo.appAuth.LOGIN_SUCCESS', + LOGIN_ERROR = 'auryo.appAuth.LOGIN_ERROR', + LOGIN_TERMINATED = 'auryo.appAuth.LOGIN_TERMINATED', + LOGOUT = 'auryo.appAuth.LOGOUT', + INITIATE_TOKEN_REFRESH = 'auryo.appAuth.INITIATE_TOKEN_REFRESH', + TOKEN_REFRESH = 'auryo.appAuth.TOKEN_REFRESH', + FINISH_ONBOARDING = 'auryo.appAuth.FINISH_ONBOARDING' } diff --git a/src/common/store/auth/actions.ts b/src/common/store/auth/actions.ts index 4b632cf5..c30d8deb 100755 --- a/src/common/store/auth/actions.ts +++ b/src/common/store/auth/actions.ts @@ -1,5 +1,5 @@ import { wError, wSuccess } from '@common/utils/reduxUtils'; -import { EntitiesOf, EpicFailure, ObjectMap, SoundCloud } from '@types'; +import { EntitiesOf, ObjectMap, SoundCloud } from '@types'; import { createAsyncAction } from 'typesafe-actions'; import { AuthActionTypes, AuthLikes, AuthPlaylists, AuthReposts, LikeType, RepostType } from '../types'; import { FetchedPlaylistItem } from './api'; @@ -8,50 +8,60 @@ export const getCurrentUser = createAsyncAction( String(AuthActionTypes.GET_USER), wSuccess(AuthActionTypes.GET_USER), wError(AuthActionTypes.GET_USER) -)(); +)(); export const getCurrentUserFollowingsIds = createAsyncAction( String(AuthActionTypes.GET_USER_FOLLOWINGS_IDS), wSuccess(AuthActionTypes.GET_USER_FOLLOWINGS_IDS), wError(AuthActionTypes.GET_USER_FOLLOWINGS_IDS) -)(); +)(); export const getCurrentUserLikeIds = createAsyncAction( String(AuthActionTypes.GET_USER_LIKE_IDS), wSuccess(AuthActionTypes.GET_USER_LIKE_IDS), wError(AuthActionTypes.GET_USER_LIKE_IDS) -)(); +)(); export const getCurrentUserRepostIds = createAsyncAction( String(AuthActionTypes.GET_USER_REPOST_IDS), wSuccess(AuthActionTypes.GET_USER_REPOST_IDS), wError(AuthActionTypes.GET_USER_REPOST_IDS) -)(); +)(); export const getCurrentUserPlaylists = createAsyncAction( String(AuthActionTypes.GET_USER_PLAYLISTS), wSuccess(AuthActionTypes.GET_USER_PLAYLISTS), wError(AuthActionTypes.GET_USER_PLAYLISTS) -) }, EpicFailure>(); +) }, object>(); + +export interface ToggleLikeRequestPayload { + id: number | string; + type: LikeType; +} export const toggleLike = createAsyncAction( String(AuthActionTypes.TOGGLE_LIKE), wSuccess(AuthActionTypes.TOGGLE_LIKE), wError(AuthActionTypes.TOGGLE_LIKE) )< - { id: number | string; type: LikeType }, + {} | ToggleLikeRequestPayload, { id: number | string; type: LikeType; liked: boolean }, - EpicFailure & { id: number | string; type: LikeType; liked: boolean } + { id: number | string; type: LikeType; liked: boolean } >(); +export interface ToggleRepostRequestPayload { + id: number | string; + type: RepostType; +} + export const toggleRepost = createAsyncAction( String(AuthActionTypes.TOGGLE_REPOST), wSuccess(AuthActionTypes.TOGGLE_REPOST), wError(AuthActionTypes.TOGGLE_REPOST) )< - { id: number | string; type: RepostType }, + {} | ToggleRepostRequestPayload, { id: number | string; type: RepostType; reposted: boolean }, - EpicFailure & { id: number | string; type: RepostType; reposted: boolean } + { id: number | string; type: RepostType; reposted: boolean } >(); export const toggleFollowing = createAsyncAction( @@ -61,5 +71,5 @@ export const toggleFollowing = createAsyncAction( )< { userId: number | string }, { userId: number | string; follow: boolean }, - EpicFailure & { userId: number | string; follow: boolean } + { userId: number | string; follow: boolean } >(); diff --git a/src/common/store/auth/api.ts b/src/common/store/auth/api.ts index f88b105a..fd49bd67 100644 --- a/src/common/store/auth/api.ts +++ b/src/common/store/auth/api.ts @@ -1,9 +1,11 @@ import fetchToJsonNew from '@common/api/helpers/fetchToJsonNew'; import { playlistSchema, userSchema } from '@common/schemas'; -import { Collection, EntitiesOf, SoundCloud, ResultOf } from '@types'; +import { memToken } from '@common/utils/soundcloudUtils'; +import { Collection, EntitiesOf, ResultOf, SoundCloud } from '@types'; import { normalize, schema } from 'normalizr'; +import { map } from 'rxjs/operators'; -export async function fetchUserFollowingIds(userId: string | number) { +export function fetchUserFollowingIds(userId: string | number) { return fetchToJsonNew>({ uri: `users/${userId}/followings/ids`, oauthToken: true, @@ -14,7 +16,7 @@ export async function fetchUserFollowingIds(userId: string | number) { }); } -export async function fetchLikeIds(type: 'track' | 'playlist' | 'system_playlist') { +export function fetchLikeIds(type: 'track' | 'playlist' | 'system_playlist') { return fetchToJsonNew>({ uri: `me/${type}_likes/${type === 'system_playlist' ? 'urns' : `ids`}`, oauthToken: true, @@ -24,7 +26,7 @@ export async function fetchLikeIds(type: 'track' | 'playlist' | 'system_playlist } }); } -export async function fetchRepostIds(type: 'track' | 'playlist') { +export function fetchRepostIds(type: 'track' | 'playlist') { return fetchToJsonNew>({ uri: `me/${type}_reposts/ids`, oauthToken: true, @@ -35,7 +37,7 @@ export async function fetchRepostIds(type: 'track' | 'playlist') { }); } -export async function fetchCurrentUser() { +export function fetchCurrentUser() { return fetchToJsonNew({ uri: 'me', oauthToken: true @@ -52,8 +54,8 @@ export interface FetchedPlaylistItem { uuid: string; } -export async function fetchPlaylists() { - const json = await fetchToJsonNew({ +export function fetchPlaylists() { + const json$ = fetchToJsonNew({ uri: 'me/library/albums_playlists_and_system_playlists', oauthToken: true, useV2Endpoint: true, @@ -62,27 +64,31 @@ export async function fetchPlaylists() { } }); - const normalized = normalize< - FetchedPlaylistItem, - EntitiesOf, - ResultOf - >( - json.collection, - new schema.Array({ - playlist: playlistSchema, - user: userSchema + return json$.pipe( + map(json => { + const normalized = normalize< + FetchedPlaylistItem, + EntitiesOf, + ResultOf + >( + json.collection, + new schema.Array({ + playlist: playlistSchema, + user: userSchema + }) + ); + + return { + normalized, + json + }; }) ); - - return { - normalized, - json - }; } // LIKES -export async function toggleTrackLike(options: { trackId: string | number; userId: string | number; like: boolean }) { - const json = await fetchToJsonNew>( +export function toggleTrackLike(options: { trackId: string | number; userId: string | number; like: boolean }) { + return fetchToJsonNew>( { uri: `users/${options.userId}/track_likes/${options.trackId}`, oauthToken: true, @@ -90,16 +96,10 @@ export async function toggleTrackLike(options: { trackId: string | number; userI }, { method: options.like ? 'PUT' : 'DELETE' } ); - - return json; } -export async function togglePlaylistLike(options: { - playlistId: string | number; - userId: string | number; - like: boolean; -}) { - const json = await fetchToJsonNew>( +export function togglePlaylistLike(options: { playlistId: string | number; userId: string | number; like: boolean }) { + return fetchToJsonNew>( { uri: `users/${options.userId}/playlist_likes/${options.playlistId}`, oauthToken: true, @@ -107,16 +107,10 @@ export async function togglePlaylistLike(options: { }, { method: options.like ? 'PUT' : 'DELETE' } ); - - return json; } -export async function toggleSystemPlaylistLike(options: { - playlistUrn: string; - userId: string | number; - like: boolean; -}) { - const json = await fetchToJsonNew>( +export function toggleSystemPlaylistLike(options: { playlistUrn: string; userId: string | number; like: boolean }) { + return fetchToJsonNew>( { uri: `users/${options.userId}/system_playlist_likes/${options.playlistUrn}`, oauthToken: true, @@ -124,14 +118,12 @@ export async function toggleSystemPlaylistLike(options: { }, { method: options.like ? 'PUT' : 'DELETE' } ); - - return json; } // REPOSTS -export async function toggleTrackRepost(options: { trackId: string | number; repost: boolean }) { - const json = await fetchToJsonNew>( +export function toggleTrackRepost(options: { trackId: string | number; repost: boolean }) { + return fetchToJsonNew>( { uri: `me/track_reposts/${options.trackId}`, oauthToken: true, @@ -139,12 +131,10 @@ export async function toggleTrackRepost(options: { trackId: string | number; rep }, { method: options.repost ? 'PUT' : 'DELETE' } ); - - return json; } -export async function togglePlaylistRepost(options: { playlistId: string | number; repost: boolean }) { - const json = await fetchToJsonNew>( +export function togglePlaylistRepost(options: { playlistId: string | number; repost: boolean }) { + return fetchToJsonNew>( { uri: `me/playlist_reposts/${options.playlistId}`, oauthToken: true, @@ -152,13 +142,11 @@ export async function togglePlaylistRepost(options: { playlistId: string | numbe }, { method: options.repost ? 'PUT' : 'DELETE' } ); - - return json; } // Following export async function toggleFollowing(options: { userId: string | number; follow: boolean }) { - const json = await fetchToJsonNew>( + return fetchToJsonNew>( { uri: `me/followings/${options.userId}`, oauthToken: true, @@ -166,6 +154,4 @@ export async function toggleFollowing(options: { userId: string | number; follow }, { method: options.follow ? 'POST' : 'DELETE' } ); - - return json; } diff --git a/src/common/store/auth/epics.ts b/src/common/store/auth/epics.ts index ff724ecc..06e240d9 100644 --- a/src/common/store/auth/epics.ts +++ b/src/common/store/auth/epics.ts @@ -1,10 +1,9 @@ -import { EpicError } from '@common/utils/errors/EpicError'; +import { EpicError, handleEpicError } from '@common/utils/errors/EpicError'; import { Normalized, ObjectMap } from '@types'; -import { StoreState } from 'AppReduxTypes'; -import { AxiosError } from 'axios'; +import { StoreState, _StoreState } from 'AppReduxTypes'; import { StateObservable } from 'redux-observable'; -import { empty, forkJoin, from, of, throwError } from 'rxjs'; -import { catchError, filter, first, map, mergeMap, switchMap, withLatestFrom } from 'rxjs/operators'; +import { defer, EMPTY, forkJoin, from, of, throwError } from 'rxjs'; +import { catchError, filter, first, map, mergeMap, retry, retryWhen, switchMap, withLatestFrom } from 'rxjs/operators'; import { EmptyAction, isActionOf } from 'typesafe-actions'; import { getCurrentUser, @@ -16,29 +15,21 @@ import { toggleRepost } from '../actions'; import { RootEpic } from '../declarations'; +import { getPlayingTrackSelector } from '../player/selectors'; import { currentUserSelector, hasLiked, hasReposted } from '../selectors'; import { LikeType, RepostType } from '../types'; +import { toggleFollowing, ToggleLikeRequestPayload, ToggleRepostRequestPayload } from './actions'; import * as APIService from './api'; -import { toggleFollowing } from './actions'; import { isFollowing } from './selectors'; -const handleEpicError = (error: any) => { - if ((error as AxiosError).isAxiosError) { - console.log(error.message, error.response.data); - } else { - console.error('Epic error - track', error); - } - // TODO Sentry? - return error; -}; - export const getCurrentUserEpic: RootEpic = action$ => + // @ts-expect-error action$.pipe( filter(isActionOf(getCurrentUser.request)), switchMap(() => - from(APIService.fetchCurrentUser()).pipe( + defer(() => from(APIService.fetchCurrentUser())).pipe( map(v => getCurrentUser.success(v)), - catchError(error => of(getCurrentUser.failure({ error }))) + catchError(handleEpicError(action$, getCurrentUser.failure({}))) ) ) ); @@ -49,11 +40,11 @@ export const getCurrentUserFollowingIdsEpic: RootEpic = (action$, state$) => withLatestFrom(state$), switchMap(getCurrentUserFromState(state$)), switchMap(([, userId]) => - from(APIService.fetchUserFollowingIds(userId as number)).pipe( + defer(() => from(APIService.fetchUserFollowingIds(userId as number))).pipe( // Map array to object with booleans for performance map(mapToObject), map(v => getCurrentUserFollowingsIds.success(v)), - catchError(error => of(getCurrentUserFollowingsIds.failure({ error }))) + catchError(handleEpicError(action$, getCurrentUserFollowingsIds.failure({}))) ) ) ); @@ -62,10 +53,12 @@ export const getCurrentUserLikeIdsEpic: RootEpic = action$ => action$.pipe( filter(isActionOf(getCurrentUserLikeIds.request)), mergeMap(() => - forkJoin( - from(APIService.fetchLikeIds('track')).pipe(map(mapToObject)), - from(APIService.fetchLikeIds('playlist')).pipe(map(mapToObject)), - from(APIService.fetchLikeIds('system_playlist')).pipe(map(mapToObject)) + defer(() => + forkJoin([ + from(APIService.fetchLikeIds('track')).pipe(map(mapToObject)), + from(APIService.fetchLikeIds('playlist')).pipe(map(mapToObject)), + from(APIService.fetchLikeIds('system_playlist')).pipe(map(mapToObject)) + ]) ).pipe( map(([track, playlist, systemPlaylist]) => getCurrentUserLikeIds.success({ @@ -74,7 +67,7 @@ export const getCurrentUserLikeIdsEpic: RootEpic = action$ => systemPlaylist }) ), - catchError(error => of(getCurrentUserLikeIds.failure({ error }))) + catchError(handleEpicError(action$, getCurrentUserLikeIds.failure({}))) ) ) ); @@ -83,9 +76,11 @@ export const getCurrentUserRepostIdsEpic: RootEpic = action$ => action$.pipe( filter(isActionOf(getCurrentUserRepostIds.request)), mergeMap(() => - forkJoin( - from(APIService.fetchRepostIds('track')).pipe(map(mapToObject)), - from(APIService.fetchRepostIds('playlist')).pipe(map(mapToObject)) + defer(() => + forkJoin([ + from(APIService.fetchRepostIds('track')).pipe(map(mapToObject)), + from(APIService.fetchRepostIds('playlist')).pipe(map(mapToObject)) + ]) ).pipe( map(([track, playlist]) => getCurrentUserRepostIds.success({ @@ -93,7 +88,7 @@ export const getCurrentUserRepostIdsEpic: RootEpic = action$ => playlist }) ), - catchError(error => of(getCurrentUserRepostIds.failure({ error }))) + catchError(handleEpicError(action$, getCurrentUserRepostIds.failure({}))) ) ) ); @@ -102,7 +97,7 @@ export const getCurrentUserPlaylistsEpic: RootEpic = action$ => action$.pipe( filter(isActionOf(getCurrentUserPlaylists.request)), switchMap(() => - from(APIService.fetchPlaylists()).pipe( + defer(() => from(APIService.fetchPlaylists())).pipe( map(response => { const likedPlaylistIds = response.normalized.result .filter(playlist => playlist.type === 'playlist-like') @@ -128,7 +123,7 @@ export const getCurrentUserPlaylistsEpic: RootEpic = action$ => entities: response.normalized.entities }); }), - catchError(error => of(getCurrentUserPlaylists.failure({ error }))) + catchError(handleEpicError(action$, getCurrentUserPlaylists.failure({}))) ) ) ); @@ -138,7 +133,24 @@ export const toggleLikeEpic: RootEpic = (action$, state$) => action$.pipe( filter(isActionOf(toggleLike.request)), withLatestFrom(state$), + // If no payload is set, use the current playing track map(([{ payload }, state]) => { + const playingTrack = getPlayingTrackSelector(state); + const fallbackPayload = { + id: playingTrack?.id, + type: LikeType.Track + }; + + return { + payload: !Object.keys(payload).length && playingTrack ? fallbackPayload : payload, + state + }; + }), + filter<{ + payload: ToggleLikeRequestPayload; + state: _StoreState; + }>(({ payload }) => !!(payload && payload.id && payload.type)), + map(({ payload, state }) => { const isLiked = hasLiked(payload.id, payload.type)(state); const currentUser = currentUserSelector(state); @@ -151,23 +163,25 @@ export const toggleLikeEpic: RootEpic = (action$, state$) => switchMap(({ payload, isLiked, userId }) => { const { id, type } = payload; - let ob$; + return defer(() => { + let ob$; - switch (type) { - case LikeType.Track: - ob$ = APIService.toggleTrackLike({ trackId: id, userId, like: !isLiked }); - break; - case LikeType.Playlist: - ob$ = APIService.togglePlaylistLike({ playlistId: id, userId, like: !isLiked }); - break; - case LikeType.SystemPlaylist: - ob$ = APIService.toggleSystemPlaylistLike({ playlistUrn: id.toString(), userId, like: !isLiked }); - break; - default: - ob$ = throwError(new EpicError(`${type}: Unknown type found`)); - } + switch (type) { + case LikeType.Track: + ob$ = APIService.toggleTrackLike({ trackId: id, userId, like: !isLiked }); + break; + case LikeType.Playlist: + ob$ = APIService.togglePlaylistLike({ playlistId: id, userId, like: !isLiked }); + break; + case LikeType.SystemPlaylist: + ob$ = APIService.toggleSystemPlaylistLike({ playlistUrn: id.toString(), userId, like: !isLiked }); + break; + default: + ob$ = throwError(new EpicError(`${type}: Unknown type found`)); + } - return from(ob$).pipe( + return ob$; + }).pipe( map(() => toggleLike.success({ id, @@ -175,10 +189,10 @@ export const toggleLikeEpic: RootEpic = (action$, state$) => liked: !isLiked }) ), - catchError(error => - of( + catchError( + handleEpicError( + action$, toggleLike.failure({ - error: handleEpicError(error), id, type, liked: !isLiked @@ -194,7 +208,25 @@ export const toggleRepostEpic: RootEpic = (action$, state$) => action$.pipe( filter(isActionOf(toggleRepost.request)), withLatestFrom(state$), + // If no payload is set, use the current playing track map(([{ payload }, state]) => { + const playingTrack = getPlayingTrackSelector(state); + const fallbackPayload = { + id: playingTrack?.id, + type: RepostType.Track + }; + + return { + payload: !Object.keys(payload).length && playingTrack ? fallbackPayload : payload, + state + }; + }), + filter<{ + payload: ToggleRepostRequestPayload; + state: _StoreState; + }>(({ payload }) => !!(payload && payload.id && payload.type)), + + map(({ payload, state }) => { const isReposted = hasReposted(payload.id, payload.type)(state); return { @@ -206,20 +238,21 @@ export const toggleRepostEpic: RootEpic = (action$, state$) => const { id, type } = payload; const repost = !isReposted; - let ob$; - - switch (type) { - case RepostType.Track: - ob$ = APIService.toggleTrackRepost({ trackId: id, repost }); - break; - case RepostType.Playlist: - ob$ = APIService.togglePlaylistRepost({ playlistId: id, repost }); - break; - default: - ob$ = throwError(new EpicError(`${type}: Unknown type found`)); - } + return defer(() => { + let ob$; - return from(ob$).pipe( + switch (type) { + case RepostType.Track: + ob$ = APIService.toggleTrackRepost({ trackId: id, repost }); + break; + case RepostType.Playlist: + ob$ = APIService.togglePlaylistRepost({ playlistId: id, repost }); + break; + default: + ob$ = throwError(new EpicError(`${type}: Unknown type found`)); + } + return ob$; + }).pipe( map(() => toggleRepost.success({ id, @@ -227,10 +260,10 @@ export const toggleRepostEpic: RootEpic = (action$, state$) => reposted: repost }) ), - catchError(error => - of( + catchError( + handleEpicError( + action$, toggleRepost.failure({ - error: handleEpicError(error), id, type, reposted: repost @@ -257,17 +290,17 @@ export const toggleFollowingEpic: RootEpic = (action$, state$) => const { userId } = payload; const follow = !isFollowingUser; - return from(APIService.toggleFollowing({ userId, follow })).pipe( + return defer(() => from(APIService.toggleFollowing({ userId, follow }))).pipe( map(() => toggleFollowing.success({ userId, follow }) ), - catchError(error => - of( + catchError( + handleEpicError( + action$, toggleFollowing.failure({ - error: handleEpicError(error), userId, follow }) @@ -293,7 +326,7 @@ const getCurrentUserFromState = (state$: StateObservable) => ([actio mergeMap(latestState => { const userId = currentUserSelector(latestState)?.id; - return userId ? of([action, userId]) : empty(); + return userId ? of([action, userId]) : EMPTY; }), first() ); diff --git a/src/common/store/auth/types.ts b/src/common/store/auth/types.ts index c5691c25..0f36b77f 100644 --- a/src/common/store/auth/types.ts +++ b/src/common/store/auth/types.ts @@ -1,13 +1,12 @@ -import { Normalized, ObjectMap, SoundCloud } from '@types'; -import { AxiosError } from 'axios'; import { EpicError } from '@common/utils/errors/EpicError'; +import { Normalized, ObjectMap, SoundCloud } from '@types'; // TYPES export type AuthState = Readonly<{ me: { isLoading: boolean; - error?: EpicError | AxiosError | Error | null; + error?: EpicError | Error | null; data?: SoundCloud.User; }; followings: AuthFollowing; @@ -15,12 +14,12 @@ export type AuthState = Readonly<{ reposts: AuthReposts; playlists: { isLoading: boolean; - error?: EpicError | AxiosError | Error | null; + error?: EpicError | Error | null; data: AuthPlaylists; }; personalizedPlaylists: { loading: boolean; - error?: EpicError | AxiosError | Error | null; + error?: EpicError | Error | null; items: Normalized.NormalizedPersonalizedItem[] | null; }; }>; @@ -57,29 +56,29 @@ export enum RepostType { // ACTIONS export enum AuthActionTypes { - GET_USER = '@@auth/GET_USER', - GET_USER_FOLLOWINGS_IDS = '@@auth/GET_USER_FOLLOWINGS_IDS', - GET_USER_LIKE_IDS = '@@auth/GET_USER_LIKE_IDS', - GET_USER_REPOST_IDS = '@@auth/GET_USER_REPOST_IDS', - GET_USER_PLAYLISTS = '@@auth/GET_USER_PLAYLISTS', + GET_USER = 'auryo.auth.GET_USER', + GET_USER_FOLLOWINGS_IDS = 'auryo.auth.GET_USER_FOLLOWINGS_IDS', + GET_USER_LIKE_IDS = 'auryo.auth.GET_USER_LIKE_IDS', + GET_USER_REPOST_IDS = 'auryo.auth.GET_USER_REPOST_IDS', + GET_USER_PLAYLISTS = 'auryo.auth.GET_USER_PLAYLISTS', - TOGGLE_LIKE = '@@track/TOGGLE_LIKE', - TOGGLE_REPOST = '@@track/TOGGLE_REPOST', - TOGGLE_FOLLOWING = '@@track/TOGGLE_FOLLOWING', + TOGGLE_LIKE = 'auryo.track.TOGGLE_LIKE', + TOGGLE_REPOST = 'auryo.track.TOGGLE_REPOST', + TOGGLE_FOLLOWING = 'auryo.track.TOGGLE_FOLLOWING', // OLD? - SET = '@@auth/SET', - SET_PLAYLISTS = '@@auth/SET_PLAYLISTS', - SET_PERSONALIZED_PLAYLISTS = '@@auth/SET_PERSONALIZED_PLAYLISTS', - SET_FOLLOWINGS = '@@auth/SET_FOLLOWINGS', - SET_LIKES = '@@auth/SET_LIKES', - SET_PLAYLIST_LIKES = '@@auth/SET_PLAYLIST_LIKES', - SET_REPOSTS = '@@auth/SET_REPOSTS', - SET_PLAYLIST_REPOSTS = '@@auth/SET_PLAYLIST_REPOSTS', - SET_LIKE = '@@auth/SET_LIKE', - SET_FOLLOWING = '@@auth/SET_FOLLOWING', - SET_REPOST = '@@auth/SET_REPOST', - ERROR = '@@auth/ERROR', - LOADING = '@@auth/LOADING' + SET = 'auryo.auth.SET', + SET_PLAYLISTS = 'auryo.auth.SET_PLAYLISTS', + SET_PERSONALIZED_PLAYLISTS = 'auryo.auth.SET_PERSONALIZED_PLAYLISTS', + SET_FOLLOWINGS = 'auryo.auth.SET_FOLLOWINGS', + SET_LIKES = 'auryo.auth.SET_LIKES', + SET_PLAYLIST_LIKES = 'auryo.auth.SET_PLAYLIST_LIKES', + SET_REPOSTS = 'auryo.auth.SET_REPOSTS', + SET_PLAYLIST_REPOSTS = 'auryo.auth.SET_PLAYLIST_REPOSTS', + SET_LIKE = 'auryo.auth.SET_LIKE', + SET_FOLLOWING = 'auryo.auth.SET_FOLLOWING', + SET_REPOST = 'auryo.auth.SET_REPOST', + ERROR = 'auryo.auth.ERROR', + LOADING = 'auryo.auth.LOADING' } diff --git a/src/common/store/config/selectors.ts b/src/common/store/config/selectors.ts index 29da793f..e39ba5c7 100644 --- a/src/common/store/config/selectors.ts +++ b/src/common/store/config/selectors.ts @@ -5,3 +5,8 @@ export const configSelector = (state: StoreState) => state.config; export const authTokenStateSelector = createSelector([configSelector], config => config.auth); export const shuffleSelector = createSelector([configSelector], config => config.shuffle); +export const repeatSelector = createSelector([configSelector], config => config.repeat); +export const appVersionSelector = createSelector([configSelector], config => config.version); +export const audioConfigSelector = createSelector([configSelector], config => config.audio); +export const themeSelector = createSelector([configSelector], config => config.app.theme); +export const isAuthenticatedSelector = createSelector([authTokenStateSelector], auth => !!auth.token); diff --git a/src/common/store/config/types.ts b/src/common/store/config/types.ts index c0cf039f..0bbb3470 100644 --- a/src/common/store/config/types.ts +++ b/src/common/store/config/types.ts @@ -53,6 +53,6 @@ export type ConfigValue = string | number | boolean | object | null | (string | // ACTIONS export enum ConfigActionTypes { - SET_CONFIG = '@@config/SET_ALL', - SET_CONFIG_KEY = '@@config/SET_KEY' + SET_CONFIG = 'auryo.config.SET_ALL', + SET_CONFIG_KEY = 'auryo.config.SET_KEY' } diff --git a/src/common/store/declarations.d.ts b/src/common/store/declarations.d.ts index 6a849fc0..52943e92 100644 --- a/src/common/store/declarations.d.ts +++ b/src/common/store/declarations.d.ts @@ -36,7 +36,7 @@ type actions = { routerActions: typeof routerActions; }; -type _Store = StateType; +type _Store = StateType; type _RootAction = ActionType | ActionType; export type Store = _Store; diff --git a/src/common/store/entities/selectors.ts b/src/common/store/entities/selectors.ts index 90050ee0..f512c260 100644 --- a/src/common/store/entities/selectors.ts +++ b/src/common/store/entities/selectors.ts @@ -72,7 +72,7 @@ export const getNormalizedTrack = (id?: number | string) => if (id) { const track = entities[id]; - if (track.media) { + if (track?.media) { return track; } } diff --git a/src/common/store/index.ts b/src/common/store/index.ts index 178cd072..b7b34e52 100755 --- a/src/common/store/index.ts +++ b/src/common/store/index.ts @@ -1,102 +1,106 @@ import { resetStore } from '@common/store/actions'; import { PlayerActionTypes } from '@common/store/player'; import { rootReducer } from '@common/store/rootReducer'; +import { mainRootEpic } from '@main/store/rootEpic'; import { Logger } from '@main/utils/logger'; import { StoreState } from 'AppReduxTypes'; import { routerMiddleware } from 'connected-react-router'; +// eslint-disable-next-line import/no-extraneous-dependencies +import electron, { ipcRenderer } from 'electron'; import is from 'electron-is'; -import { - forwardToMainWithParams, - forwardToRenderer, - getInitialStateRenderer, - replayActionMain, - replayActionRenderer, - triggerAlias -} from 'electron-redux'; +import { mainStateSyncEnhancer, rendererStateSyncEnhancer, stopForwarding } from 'electron-redux'; import { createMemoryHistory } from 'history'; -import { applyMiddleware, createStore, Middleware } from 'redux'; -import { composeWithDevTools } from 'redux-devtools-extension'; +import { Action, applyMiddleware, compose, createStore, Middleware, StoreEnhancer } from 'redux'; +import { devToolsEnhancer } from 'redux-devtools-extension'; // eslint-disable-next-line import/no-extraneous-dependencies import { createLogger } from 'redux-logger'; import { createEpicMiddleware } from 'redux-observable'; -import thunk from 'redux-thunk'; import { BehaviorSubject } from 'rxjs'; import { RootAction } from './declarations'; import { rootEpic } from './rootEpic'; -const epic$ = new BehaviorSubject(rootEpic); export const history = createMemoryHistory(); -history.listen(loc => console.log(process.type, loc)); - -const epicMiddleware = createEpicMiddleware(); -const connectRouterMiddleware = routerMiddleware(history); - -/** configure dev middlewares */ -const devMiddlewares: Middleware[] = [ - is.renderer() - ? createLogger({ - level: 'info', - collapsed: true, - predicate: (_getState: () => any, action: any) => action.type !== PlayerActionTypes.SET_TIME - }) - : () => next => action => { - const reduxLogger = Logger.createLogger('REDUX'); - - if (action.error) { - reduxLogger.error(action.type, action.error); - } else { - reduxLogger.debug(action.type); +export const configureStore = () => { + const epic$ = new BehaviorSubject(rootEpic); + const epicMiddleware = createEpicMiddleware(); + const connectRouterMiddleware = routerMiddleware(history); + + /** configure dev middlewares */ + const devMiddlewares: Middleware[] = [ + is.renderer() + ? createLogger({ + level: 'info', + collapsed: true, + predicate: (_getState: () => any, action: any) => action.type !== PlayerActionTypes.SET_TIME + }) + : () => next => action => { + const reduxLogger = Logger.createLogger('REDUX'); + if (action.error) { + reduxLogger.error(action.type, action.error); + } else { + reduxLogger.debug(action.type); + } + return next(action); } + ]; + + /** configure production middlewares */ + const middlewares: Middleware[] = [...(is.dev() ? devMiddlewares : []), epicMiddleware]; + + const generateMiddlewares = (): Middleware[] => + is.renderer() ? [connectRouterMiddleware, ...middlewares] : [...middlewares]; + + const enhancers: StoreEnhancer[] = [is.renderer() ? rendererStateSyncEnhancer({}) : mainStateSyncEnhancer()]; + + if (is.renderer() && is.dev()) { + enhancers.push(devToolsEnhancer({})); + } + + const middleware = applyMiddleware(...generateMiddlewares()); - return next(action); - } -]; - -/** configure production middlewares */ -const middlewares: Middleware[] = [thunk, ...(is.dev() ? devMiddlewares : [])]; - -const generateMiddlewares = (): Middleware[] => - is.renderer() - ? [ - forwardToMainWithParams({ - blacklist: [] // [/^@@(ui)/] - }), - connectRouterMiddleware, - ...middlewares - ] - : [triggerAlias, ...middlewares, epicMiddleware, forwardToRenderer]; - -const enhancer = composeWithDevTools(applyMiddleware(...generateMiddlewares())); - -const initialState = is.renderer() ? getInitialStateRenderer() : {}; - -const store = createStore(rootReducer(history), initialState, enhancer); - -// Replay actions for redux sync -const replayAction = is.renderer() ? replayActionRenderer : replayActionMain; -replayAction(store); - -if (is.renderer()) { - window.onbeforeunload = () => { - store.dispatch(resetStore()); - }; -} else { - epicMiddleware.run(rootEpic); -} - -export default store; - -if (module.hot) { - module.hot.accept('@common/store', () => { - // eslint-disable-next-line global-require - const nextReducer = require('@common/store/rootReducer').rootReducer; - store.replaceReducer(nextReducer); - }); - - module.hot.accept('@common/store/rootEpic', () => { - // eslint-disable-next-line global-require - const nextRootEpic = require('@common/store/rootEpic').rootEpic; - epic$.next(nextRootEpic); - }); -} + const enhancer: StoreEnhancer = compose(middleware, ...enhancers); + + const store = createStore(rootReducer(history), enhancer); + + if (is.renderer()) { + window.onbeforeunload = () => { + store.dispatch(resetStore()); + }; + epicMiddleware.run(rootEpic); + + // HACK: electron-redux currently only works like this https://github.com/klarna/electron-redux/issues/285 + ipcRenderer.on('electron-redux.ACTION', (_, action: Action) => { + store.dispatch(stopForwarding(action)); + }); + } else { + epicMiddleware.run(mainRootEpic); + + // HACK: electron-redux currently only works like this https://github.com/klarna/electron-redux/issues/285 + electron.ipcMain.on('electron-redux.ACTION', (event, action) => { + const localAction = stopForwarding(action); + store.dispatch(localAction); // Forward it to all of the other renderers + electron.webContents.getAllWebContents().forEach(contents => { + // Ignore the renderer that sent the action and chromium devtools + if (contents.id !== event.sender.id && !contents.getURL().startsWith('devtools://')) { + contents.send('electron-redux.ACTION', localAction); + } + }); + }); + } + // epicMiddleware.run(rootEpic); + + if (module.hot) { + module.hot.accept('@common/store', () => { + import('@common/store/rootReducer').then(({ rootReducer: nextReducer }) => + store.replaceReducer(nextReducer(history)) + ); + }); + + module.hot.accept('@common/store/rootEpic', () => { + import('@common/store/rootEpic').then(({ rootEpic: nextRootEpic }) => epic$.next(nextRootEpic)); + }); + } + + return store; +}; diff --git a/src/common/store/objects/reducer.ts b/src/common/store/objects/reducer.ts index bb2bab52..d8764485 100755 --- a/src/common/store/objects/reducer.ts +++ b/src/common/store/objects/reducer.ts @@ -9,13 +9,14 @@ import { getGenericPlaylist, getSearchPlaylist, queueInsert, + removeFromQueue, + resolvePlaylistItems, + setCommentsLoading, setCurrentPlaylist, setPlaylistLoading, - shuffleQueue, - resolvePlaylistItems, - setCommentsLoading + shuffleQueue } from '../actions'; -import { ObjectGroup, ObjectsState, ObjectState, ObjectStateItem, ObjectTypes, PlaylistTypes } from '../types'; +import { ObjectGroup, ObjectsState, ObjectState, ObjectTypes, PlaylistTypes } from '../types'; const initialObjectsState: ObjectState = { isFetching: false, @@ -64,6 +65,11 @@ const objectState = createReducer(initialObjectsState) } } + // If related playlist, also include the song which it relates to + if (payload.playlistType === PlaylistTypes.RELATED && payload.objectId) { + itemsToAdd.unshift({ id: +payload.objectId, schema: 'tracks' }); + } + const items = payload.refresh ? itemsToAdd : uniqWith([...state.items, ...itemsToAdd], isEqual); return { @@ -336,6 +342,7 @@ const initialState: ObjectsState = { [PlaylistTypes.SEARCH_TRACK]: initialObjectsState, [PlaylistTypes.SEARCH_USER]: initialObjectsState, [PlaylistTypes.QUEUE]: initialObjectsState, + [PlaylistTypes.RELATED]: initialObjectGroupState, [ObjectTypes.PLAYLISTS]: {}, [ObjectTypes.COMMENTS]: {} @@ -485,6 +492,20 @@ export const objectsReducer = createReducer(initialState) } }; }) + .handleAction(removeFromQueue, (state, { payload: indexToRemove }) => { + const queuePlaylist = state[PlaylistTypes.QUEUE]; + const newItems = [...queuePlaylist.items]; + + newItems.splice(indexToRemove, 1); + + return { + ...state, + [PlaylistTypes.QUEUE]: { + ...queuePlaylist, + items: newItems + } + }; + }) .handleAction(resolvePlaylistItems, (state, action) => { const { items, playlistItem } = action.payload; diff --git a/src/common/store/objects/types.ts b/src/common/store/objects/types.ts index beeafde1..fdc39b0d 100644 --- a/src/common/store/objects/types.ts +++ b/src/common/store/objects/types.ts @@ -1,5 +1,4 @@ import { EntitiesOf, Normalized } from '@types'; -import { AxiosError } from 'axios'; import { PlaylistIdentifier } from '../playlist'; // TYPES @@ -51,6 +50,7 @@ export type ObjectsState = Readonly<{ [PlaylistTypes.SEARCH_USER]: ObjectState; [PlaylistTypes.SEARCH_TRACK]: ObjectState; [PlaylistTypes.QUEUE]: ObjectState; + [PlaylistTypes.RELATED]: ObjectGroup; [ObjectTypes.PLAYLISTS]: ObjectGroup; [ObjectTypes.COMMENTS]: ObjectGroup; @@ -62,7 +62,7 @@ export interface ObjectGroup { export interface ObjectState { isFetching: boolean; - error: AxiosError | Error | null; + error: Error | null; items: ObjectStateItem[]; nextUrl?: string | null; fetchedItems: number; @@ -75,9 +75,9 @@ export type ObjectStateItem = Normalized.NormalizedResult & { parentPlaylistID?: // ACTIONS export enum ObjectsActionTypes { - SET = '@@objects/SET', - UNSET = '@@objects/UNSET', - UNSET_TRACK = '@@objects/UNSET_TRACK', - SET_TRACKS = '@@objects/SET_TRACKS', - UPDATE_ITEMS = '@@objects/UPDATE_ITEMS' + SET = 'auryo.objects.SET', + UNSET = 'auryo.objects.UNSET', + UNSET_TRACK = 'auryo.objects.UNSET_TRACK', + SET_TRACKS = 'auryo.objects.SET_TRACKS', + UPDATE_ITEMS = 'auryo.objects.UPDATE_ITEMS' } diff --git a/src/common/store/player/actions.ts b/src/common/store/player/actions.ts index 09a0aeca..33d51258 100755 --- a/src/common/store/player/actions.ts +++ b/src/common/store/player/actions.ts @@ -1,9 +1,9 @@ import { wError, wSuccess } from '@common/utils/reduxUtils'; -import { EpicFailure, Normalized, SoundCloud, ThunkResult } from '@types'; +import { Normalized, SoundCloud } from '@types'; import { createAction, createAsyncAction } from 'typesafe-actions'; +import { ObjectStateItem } from '../objects'; import { PlaylistIdentifier } from '../playlist'; import { ChangeTypes, PlayerActionTypes, PlayerStatus, PlayingTrack } from '../types'; -import { ObjectStateItem } from '../objects'; export const toggleShuffle = createAction(PlayerActionTypes.TOGGLE_SHUFFLE)(); export const toggleStatus = createAction(PlayerActionTypes.TOGGLE_STATUS, (status?: PlayerStatus) => status)(); @@ -30,6 +30,11 @@ export const startPlayMusic = createAction(PlayerActionTypes.START_PLAY_MUSIC)<{ nextPosition?: number; }>(); +export const playTrackFromQueue = createAction(PlayerActionTypes.PLAY_TRACK_FROM_QUEUE)<{ + idResult: ObjectStateItem; + index: number; +}>(); + interface PlayTrackProps { idResult: ObjectStateItem; origin: PlaylistIdentifier; @@ -48,7 +53,7 @@ export const playTrack = createAsyncAction( positionInPlaylist?: number; parentPlaylistID?: PlaylistIdentifier; }, - EpicFailure + object >(); interface PlayPlaylistProps { @@ -64,7 +69,7 @@ export const setCurrentPlaylist = createAsyncAction( String(PlayerActionTypes.SET_CURRENT_PLAYLIST), wSuccess(PlayerActionTypes.SET_CURRENT_PLAYLIST), wError(PlayerActionTypes.SET_CURRENT_PLAYLIST) -)<{ playlistId: PlaylistIdentifier }, { playlistId: PlaylistIdentifier; items: ObjectStateItem[] }, EpicFailure>(); +)<{ playlistId: PlaylistIdentifier }, { playlistId: PlaylistIdentifier; items: ObjectStateItem[] }, object>(); export const setCurrentIndex = createAction(PlayerActionTypes.SET_CURRENT_INDEX)<{ position: number; }>(); @@ -72,14 +77,12 @@ export const resolvePlaylistItems = createAction(PlayerActionTypes.RESOLVE_PLAYL items: ObjectStateItem[]; playlistItem: ObjectStateItem; }>(); -// NEXT UP export const addUpNext = createAsyncAction( String(PlayerActionTypes.ADD_UP_NEXT), wSuccess(PlayerActionTypes.ADD_UP_NEXT), wError(PlayerActionTypes.ADD_UP_NEXT) -)(); +)(); -export const upNextInsert = createAction(PlayerActionTypes.ADD_UP_NEXT)(); export const queueInsert = createAction(PlayerActionTypes.QUEUE_INSERT)<{ items: ObjectStateItem[]; position: number; @@ -91,397 +94,400 @@ export const setQueue = createAction(PlayerActionTypes.SET_QUEUE)<{ items: Normalized.NormalizedResult[]; }>(); export const clearUpNext = createAction(PlayerActionTypes.CLEAR_UP_NEXT)(); +export const removeFromQueue = createAction(PlayerActionTypes.REMOVE_FROM_QUEUE)(); +export const removeFromUpNext = createAction(PlayerActionTypes.REMOVE_FROM_UP_NEXT)(); +export const removeFromQueueOrUpNext = createAction(PlayerActionTypes.REMOVE_FROM_QUEUE_OR_UP_NEXT)(); // OLD -export function registerPlayO(): ThunkResult { - return async (_dispatch, getState) => { - // const { - // player: { playingTrack } - // } = getState(); - // if (playingTrack) { - // const { id, playlistId } = playingTrack; - // const params: any = { - // track_urn: `soundcloud:tracks:${id}` - // }; - // await import('@common/utils/universalAnalytics').then(({ ua }) => { - // ua.event('SoundCloud', 'Play', '', id).send(); - // }); - // const type = getPlaylistType(playlistId); - // if ((!type || !(type in PlaylistTypes)) && typeof playlistId !== 'string') { - // params.context_urn = `soundcloud:playlists:${playlistId}`; - // } - // await axiosClient.request({ - // url: SC.registerPlayUrl(), - // method: 'POST', - // data: params - // }); - // } - }; -} +// export function registerPlayO(): ThunkResult { +// return async (_dispatch, getState) => { +// // const { +// // player: { playingTrack } +// // } = getState(); +// // if (playingTrack) { +// // const { id, playlistId } = playingTrack; +// // const params: any = { +// // track_urn: `soundcloud:tracks:${id}` +// // }; +// // await import('@common/utils/universalAnalytics').then(({ ua }) => { +// // ua.event('SoundCloud', 'Play', '', id).send(); +// // }); +// // const type = getPlaylistType(playlistId); +// // if ((!type || !(type in PlaylistTypes)) && typeof playlistId !== 'string') { +// // params.context_urn = `soundcloud:playlists:${playlistId}`; +// // } +// // await axiosClient.request({ +// // url: SC.registerPlayUrl(), +// // method: 'POST', +// // data: params +// // }); +// // } +// }; +// } -export function getItemsAroundO(position: number): ThunkResult> { - return async (dispatch, getState) => { - // const { - // player: { queue, currentPlaylistId } - // } = getState(); - // if (currentPlaylistId) { - // const currentPlaylist = getPlaylistObjectSelector({ - // objectId: currentPlaylistId, - // playlistType: PlaylistTypes.MYTRACKS - // })(getState()); - // const itemsToFetch: { position: number; id: number }[] = []; - // const lowBound = position - 3; - // const highBound = position + 3; - // // Get playlists - // for (let i = lowBound < 0 ? 0 : position; i < (highBound > queue.length ? queue.length : highBound); i += 1) { - // const queueItem = queue[i]; - // if (queueItem && queueItem.id) { - // const playlist = getPlaylistEntity(+queueItem.playlistId)(getState()); - // if (playlist) { - // dispatch(getPlaylistObjectO(queueItem.playlistId, i)); - // } - // const track = getTrackEntity(queueItem.id)(getState()); - // if (!track || (track && !track.title && !track.loading)) { - // itemsToFetch.push({ - // position: i, - // id: queueItem.id - // }); - // } - // if ( - // currentPlaylist && - // currentPlaylist.fetchedItems && - // currentPlaylist.fetchedItems - 10 < i && - // currentPlaylist.fetchedItems !== currentPlaylist.items.length - // ) { - // dispatch(fetchPlaylistTracks(+currentPlaylistId, 30)); - // } - // } - // } - // if (itemsToFetch.length) { - // const response = await dispatch>( - // fetchTracks(itemsToFetch.map(i => i.id)) as any - // ); - // const { - // value: { - // entities: { trackEntities = {} } - // } - // } = response; - // // SoundCloud sometimes returns 404 for some tracks, if this happens, we clear it in our app - // itemsToFetch.forEach(i => { - // if (!trackEntities[i.id]) { - // const queueItem = queue[i.position]; - // dispatch({ - // type: ObjectsActionTypes.UNSET_TRACK, - // payload: { - // trackId: i.id, - // position: i.position, - // objectId: queueItem.playlistId, - // entities: { - // trackEntities: { - // [i.id]: undefined - // } - // } - // } - // }); - // } - // }); - // } - // } - }; -} +// export function getItemsAroundO(position: number): ThunkResult> { +// return async (dispatch, getState) => { +// // const { +// // player: { queue, currentPlaylistId } +// // } = getState(); +// // if (currentPlaylistId) { +// // const currentPlaylist = getPlaylistObjectSelector({ +// // objectId: currentPlaylistId, +// // playlistType: PlaylistTypes.MYTRACKS +// // })(getState()); +// // const itemsToFetch: { position: number; id: number }[] = []; +// // const lowBound = position - 3; +// // const highBound = position + 3; +// // // Get playlists +// // for (let i = lowBound < 0 ? 0 : position; i < (highBound > queue.length ? queue.length : highBound); i += 1) { +// // const queueItem = queue[i]; +// // if (queueItem && queueItem.id) { +// // const playlist = getPlaylistEntity(+queueItem.playlistId)(getState()); +// // if (playlist) { +// // dispatch(getPlaylistObjectO(queueItem.playlistId, i)); +// // } +// // const track = getTrackEntity(queueItem.id)(getState()); +// // if (!track || (track && !track.title && !track.loading)) { +// // itemsToFetch.push({ +// // position: i, +// // id: queueItem.id +// // }); +// // } +// // if ( +// // currentPlaylist && +// // currentPlaylist.fetchedItems && +// // currentPlaylist.fetchedItems - 10 < i && +// // currentPlaylist.fetchedItems !== currentPlaylist.items.length +// // ) { +// // dispatch(fetchPlaylistTracks(+currentPlaylistId, 30)); +// // } +// // } +// // } +// // if (itemsToFetch.length) { +// // const response = await dispatch>( +// // fetchTracks(itemsToFetch.map(i => i.id)) as any +// // ); +// // const { +// // value: { +// // entities: { trackEntities = {} } +// // } +// // } = response; +// // // SoundCloud sometimes returns 404 for some tracks, if this happens, we clear it in our app +// // itemsToFetch.forEach(i => { +// // if (!trackEntities[i.id]) { +// // const queueItem = queue[i.position]; +// // dispatch({ +// // type: ObjectsActionTypes.UNSET_TRACK, +// // payload: { +// // trackId: i.id, +// // position: i.position, +// // objectId: queueItem.playlistId, +// // entities: { +// // trackEntities: { +// // [i.id]: undefined +// // } +// // } +// // } +// // }); +// // } +// // }); +// // } +// // } +// }; +// } -/** - * Update queue when scrolling through - */ -export function updateQueueO(range: number[]): ThunkResult { - return (dispatch, getState) => { - // const { player } = getState(); - // const { queue, currentPlaylistId } = player; - // if (currentPlaylistId) { - // if (queue.length < range[1] + 5) { - // dispatch(fetchMore(currentPlaylistId, ObjectTypes.PLAYLISTS)); - // } - // dispatch(getItemsAroundO(range[1])); - // } - }; -} +// /** +// * Update queue when scrolling through +// */ +// export function updateQueueO(range: number[]): ThunkResult { +// return (dispatch, getState) => { +// // const { player } = getState(); +// // const { queue, currentPlaylistId } = player; +// // if (currentPlaylistId) { +// // if (queue.length < range[1] + 5) { +// // dispatch(fetchMore(currentPlaylistId, ObjectTypes.PLAYLISTS)); +// // } +// // dispatch(getItemsAroundO(range[1])); +// // } +// }; +// } -export function processQueueItemsO(result: Normalized.NormalizedResult[], keepFirst = false, newPlaylistId?: string) { - // return async (dispatch, getState) => { - // const { - // player: { currentPlaylistId }, - // config: { shuffle } - // } = getState(); - // if (!currentPlaylistId && !newPlaylistId) { - // return [[], []]; - // } - // const currentPlaylist = newPlaylistId || (currentPlaylistId as string); - // const items = await Promise.all( - // result - // .filter(trackIdSchema => trackIdSchema && trackIdSchema.schema !== 'users') - // .map( - // async (trackIdSchema): Promise => { - // const { id } = trackIdSchema; - // const playlist = getPlaylistEntity(id)(getState()); - // const playlistObject = getPlaylistObjectSelector({ - // objectId: id.toString(), - // playlistType: PlaylistTypes.MYTRACKS - // })(getState()); - // if (playlist) { - // if (!playlistObject) { - // dispatch(fetchPlaylistIfNeeded(id)); - // } else { - // return playlistObject.items.map((trackIdResult): PlayingTrack | null => { - // const trackId = trackIdResult.id; - // const track = getTrackEntity(id)(getState()); - // if (track && !SC.isStreamable(track)) { - // return null; - // } - // return { - // id: trackId, - // playlistId: id.toString(), - // un: Date.now() - // }; - // }); - // } - // return null; - // } - // const track = getTrackEntity(id)(getState()); - // if (track && !SC.isStreamable(track)) { - // return null; - // } - // return { - // id, - // playlistId: currentPlaylist.toString(), - // un: Date.now() - // }; - // } - // ) - // ); - // const flattened = _.flatten(items).filter((t): t is PlayingTrack => !!t); - // if (keepFirst) { - // const [firstItem, ...rest] = flattened; - // const processedRest = shuffle ? _.shuffle(rest) : rest; - // return [[firstItem, ...processedRest], flattened]; - // } - // const processedItems = shuffle ? _.shuffle(flattened) : flattened; - // return [processedItems, flattened]; - // }; -} +// export function processQueueItemsO(result: Normalized.NormalizedResult[], keepFirst = false, newPlaylistId?: string) { +// // return async (dispatch, getState) => { +// // const { +// // player: { currentPlaylistId }, +// // config: { shuffle } +// // } = getState(); +// // if (!currentPlaylistId && !newPlaylistId) { +// // return [[], []]; +// // } +// // const currentPlaylist = newPlaylistId || (currentPlaylistId as string); +// // const items = await Promise.all( +// // result +// // .filter(trackIdSchema => trackIdSchema && trackIdSchema.schema !== 'users') +// // .map( +// // async (trackIdSchema): Promise => { +// // const { id } = trackIdSchema; +// // const playlist = getPlaylistEntity(id)(getState()); +// // const playlistObject = getPlaylistObjectSelector({ +// // objectId: id.toString(), +// // playlistType: PlaylistTypes.MYTRACKS +// // })(getState()); +// // if (playlist) { +// // if (!playlistObject) { +// // dispatch(fetchPlaylistIfNeeded(id)); +// // } else { +// // return playlistObject.items.map((trackIdResult): PlayingTrack | null => { +// // const trackId = trackIdResult.id; +// // const track = getTrackEntity(id)(getState()); +// // if (track && !SC.isStreamable(track)) { +// // return null; +// // } +// // return { +// // id: trackId, +// // playlistId: id.toString(), +// // un: Date.now() +// // }; +// // }); +// // } +// // return null; +// // } +// // const track = getTrackEntity(id)(getState()); +// // if (track && !SC.isStreamable(track)) { +// // return null; +// // } +// // return { +// // id, +// // playlistId: currentPlaylist.toString(), +// // un: Date.now() +// // }; +// // } +// // ) +// // ); +// // const flattened = _.flatten(items).filter((t): t is PlayingTrack => !!t); +// // if (keepFirst) { +// // const [firstItem, ...rest] = flattened; +// // const processedRest = shuffle ? _.shuffle(rest) : rest; +// // return [[firstItem, ...processedRest], flattened]; +// // } +// // const processedItems = shuffle ? _.shuffle(flattened) : flattened; +// // return [processedItems, flattened]; +// // }; +// } -/** - * Set new playlist as first or add a playlist if it doesn't exist yet - */ -export function setCurrentPlaylistO(playlistId: string, nextTrack: PlayingTrack | null): ThunkResult> { - return async (dispatch, getState) => { - // const state = getState(); +// /** +// * Set new playlist as first or add a playlist if it doesn't exist yet +// */ +// export function setCurrentPlaylistO(playlistId: string, nextTrack: PlayingTrack | null): ThunkResult> { +// return async (dispatch, getState) => { +// // const state = getState(); - // const { - // player: { currentPlaylistId } - // } = state; +// // const { +// // player: { currentPlaylistId } +// // } = state; - // const playlistObject = getPlaylistObjectSelector({ objectId: playlistId, playlistType: PlaylistTypes.MYTRACKS })( - // state - // ); +// // const playlistObject = getPlaylistObjectSelector({ objectId: playlistId, playlistType: PlaylistTypes.MYTRACKS })( +// // state +// // ); - // const containsPlaylists: PlayingPositionState[] = []; +// // const containsPlaylists: PlayingPositionState[] = []; - // if (playlistObject && (nextTrack || playlistId !== currentPlaylistId)) { - // const [items, originalItems] = await dispatch>( - // processQueueItemsO(playlistObject.items, true, playlistId) - // ); +// // if (playlistObject && (nextTrack || playlistId !== currentPlaylistId)) { +// // const [items, originalItems] = await dispatch>( +// // processQueueItemsO(playlistObject.items, true, playlistId) +// // ); - // if (nextTrack && !nextTrack.id) { - // await dispatch>(fetchPlaylistIfNeeded(+nextTrack.playlistId)); - // } +// // if (nextTrack && !nextTrack.id) { +// // await dispatch>(fetchPlaylistIfNeeded(+nextTrack.playlistId)); +// // } - // return dispatch>({ - // type: PlayerActionTypes.SET_PLAYLIST, - // payload: { - // promise: Promise.resolve({ - // playlistId, - // items, - // originalItems, - // nextTrack, - // containsPlaylists - // }) - // } - // } as any); - // } +// // return dispatch>({ +// // type: PlayerActionTypes.SET_PLAYLIST, +// // payload: { +// // promise: Promise.resolve({ +// // playlistId, +// // items, +// // originalItems, +// // nextTrack, +// // containsPlaylists +// // }) +// // } +// // } as any); +// // } - return Promise.resolve(); - }; -} +// return Promise.resolve(); +// }; +// } -/** - * Function for playing a new track or playlist - * - * Before playing the current track, check if the track passed to the function is a playlist. If so, save the parent - * playlist and execute the function with the child playlist. If the new playlist doesn't exist, fetch it before moving on. - */ +// /** +// * Function for playing a new track or playlist +// * +// * Before playing the current track, check if the track passed to the function is a playlist. If so, save the parent +// * playlist and execute the function with the child playlist. If the new playlist doesn't exist, fetch it before moving on. +// */ -interface Next { - id: number; - playlistId?: string; -} +// interface Next { +// id: number; +// playlistId?: string; +// } -export function playTrackO( - playlistId: string, - next?: Next, - forceSetPlaylist = false, - changeType?: ChangeTypes -): ThunkResult { - // tslint:disable-next-line: max-func-body-length cyclomatic-complexity - return async (dispatch, getState) => { - // const { - // player: { currentPlaylistId } - // } = getState(); - // let nextTrack: PlayingTrack = next as PlayingTrack; - // if (!next) { - // const object = getPlaylistObjectSelector({ objectId: playlistId, playlistType: PlaylistTypes.MYTRACKS })( - // getState() - // ); - // if (object) { - // // tslint:disable-next-line: no-parameter-reassignment - // nextTrack = { - // playlistId: playlistId.toString(), - // id: object.items[0].id, - // un: Date.now() - // }; - // } - // } else if (!next.playlistId) { - // nextTrack.playlistId = playlistId.toString(); - // } - // /** - // * If playlist isn't current, set current & add items to queue - // */ - // if (currentPlaylistId !== playlistId || forceSetPlaylist) { - // await dispatch>(setCurrentPlaylistO(playlistId, forceSetPlaylist && nextTrack ? nextTrack : null)); - // } - // const state = getState(); - // const { - // player: { queue } - // } = state; - // let position = getCurrentPosition({ queue, playingTrack: nextTrack }); - // if (position !== -1) { - // dispatch(getItemsAroundO(position)); - // } - // // We know the id, just set the track - // if (nextTrack.id) { - // const trackPlaylistObject = getPlaylistObjectSelector({ - // objectId: playlistId, - // playlistType: PlaylistTypes.MYTRACKS - // })(state); - // if (trackPlaylistObject && position + 10 >= queue.length && trackPlaylistObject.nextUrl) { - // await dispatch>(fetchMore(playlistId, ObjectTypes.PLAYLISTS)); - // } - // dispatch(setPlayingTrackO(nextTrack, position, changeType)); - // // No id is given, this means we want to play a playlist - // } else if (!nextTrack.id) { - // const trackPlaylistObject = getPlaylistObjectSelector({ - // objectId: nextTrack.playlistId, - // playlistType: PlaylistTypes.MYTRACKS - // })(state); - // const playlistEntitity = getPlaylistEntity(+nextTrack.playlistId)(state); - // if (!trackPlaylistObject) { - // if (playlistEntitity && playlistEntitity.track_count > 0) { - // await dispatch>(getPlaylistObjectO(nextTrack.playlistId, 0)); - // const { player } = getState(); - // const playlistObject = getPlaylistObjectSelector({ - // objectId: nextTrack.playlistId, - // playlistType: PlaylistTypes.MYTRACKS - // })(getState()); - // if (playlistObject) { - // const { - // items: [firstItem] - // } = playlistObject; - // nextTrack.id = firstItem.id; - // dispatch( - // setPlayingTrackO( - // nextTrack, - // getCurrentPosition({ queue: player.queue, playingTrack: nextTrack }), - // changeType - // ) - // ); - // } - // } - // } else { - // const { - // items: [firstItem] - // } = trackPlaylistObject; - // if ( - // playlistEntitity && - // !trackPlaylistObject.isFetching && - // !trackPlaylistObject.items.length && - // playlistEntitity.track_count !== 0 - // ) { - // throw new Error('This playlist is empty or not available via a third party!'); - // } else if (trackPlaylistObject.items.length) { - // // If queue doesn't contain playlist yet - // if (forceSetPlaylist) { - // nextTrack.id = firstItem.id; - // } - // position = getCurrentPosition({ queue, playingTrack: nextTrack }); - // dispatch(setPlayingTrackO(nextTrack, position, changeType)); - // } - // } - // } - }; -} +// export function playTrackO( +// playlistId: string, +// next?: Next, +// forceSetPlaylist = false, +// changeType?: ChangeTypes +// ): ThunkResult { +// // tslint:disable-next-line: max-func-body-length cyclomatic-complexity +// return async (dispatch, getState) => { +// // const { +// // player: { currentPlaylistId } +// // } = getState(); +// // let nextTrack: PlayingTrack = next as PlayingTrack; +// // if (!next) { +// // const object = getPlaylistObjectSelector({ objectId: playlistId, playlistType: PlaylistTypes.MYTRACKS })( +// // getState() +// // ); +// // if (object) { +// // // tslint:disable-next-line: no-parameter-reassignment +// // nextTrack = { +// // playlistId: playlistId.toString(), +// // id: object.items[0].id, +// // un: Date.now() +// // }; +// // } +// // } else if (!next.playlistId) { +// // nextTrack.playlistId = playlistId.toString(); +// // } +// // /** +// // * If playlist isn't current, set current & add items to queue +// // */ +// // if (currentPlaylistId !== playlistId || forceSetPlaylist) { +// // await dispatch>(setCurrentPlaylistO(playlistId, forceSetPlaylist && nextTrack ? nextTrack : null)); +// // } +// // const state = getState(); +// // const { +// // player: { queue } +// // } = state; +// // let position = getCurrentPosition({ queue, playingTrack: nextTrack }); +// // if (position !== -1) { +// // dispatch(getItemsAroundO(position)); +// // } +// // // We know the id, just set the track +// // if (nextTrack.id) { +// // const trackPlaylistObject = getPlaylistObjectSelector({ +// // objectId: playlistId, +// // playlistType: PlaylistTypes.MYTRACKS +// // })(state); +// // if (trackPlaylistObject && position + 10 >= queue.length && trackPlaylistObject.nextUrl) { +// // await dispatch>(fetchMore(playlistId, ObjectTypes.PLAYLISTS)); +// // } +// // dispatch(setPlayingTrackO(nextTrack, position, changeType)); +// // // No id is given, this means we want to play a playlist +// // } else if (!nextTrack.id) { +// // const trackPlaylistObject = getPlaylistObjectSelector({ +// // objectId: nextTrack.playlistId, +// // playlistType: PlaylistTypes.MYTRACKS +// // })(state); +// // const playlistEntitity = getPlaylistEntity(+nextTrack.playlistId)(state); +// // if (!trackPlaylistObject) { +// // if (playlistEntitity && playlistEntitity.track_count > 0) { +// // await dispatch>(getPlaylistObjectO(nextTrack.playlistId, 0)); +// // const { player } = getState(); +// // const playlistObject = getPlaylistObjectSelector({ +// // objectId: nextTrack.playlistId, +// // playlistType: PlaylistTypes.MYTRACKS +// // })(getState()); +// // if (playlistObject) { +// // const { +// // items: [firstItem] +// // } = playlistObject; +// // nextTrack.id = firstItem.id; +// // dispatch( +// // setPlayingTrackO( +// // nextTrack, +// // getCurrentPosition({ queue: player.queue, playingTrack: nextTrack }), +// // changeType +// // ) +// // ); +// // } +// // } +// // } else { +// // const { +// // items: [firstItem] +// // } = trackPlaylistObject; +// // if ( +// // playlistEntitity && +// // !trackPlaylistObject.isFetching && +// // !trackPlaylistObject.items.length && +// // playlistEntitity.track_count !== 0 +// // ) { +// // throw new Error('This playlist is empty or not available via a third party!'); +// // } else if (trackPlaylistObject.items.length) { +// // // If queue doesn't contain playlist yet +// // if (forceSetPlaylist) { +// // nextTrack.id = firstItem.id; +// // } +// // position = getCurrentPosition({ queue, playingTrack: nextTrack }); +// // dispatch(setPlayingTrackO(nextTrack, position, changeType)); +// // } +// // } +// // } +// }; +// } -/** - * Add up next feature - */ -export function addUpNextO( - track: SoundCloud.Track | SoundCloud.Playlist | Normalized.Playlist | Normalized.Track, - remove?: number -): ThunkResult { - return (dispatch, getState) => { - // const { - // player: { queue, currentPlaylistId, playingTrack } - // } = getState(); - // const isPlaylist = track.kind === 'playlist'; - // const nextTrack = { - // id: track.id, - // playlistId: currentPlaylistId, - // un: Date.now() - // }; - // let nextList: PlayingTrack[] = []; - // if (isPlaylist) { - // const playlist = track as SoundCloud.Playlist; - // const { tracks = [] } = playlist; - // nextList = tracks - // .map((t): PlayingTrack | null => { - // if (!SC.isStreamable(t)) { - // return null; - // } - // return { - // id: t.id, - // playlistId: track.id.toString(), - // un: Date.now() - // }; - // }) - // .filter(t => t) as PlayingTrack[]; - // } - // if (queue.length) { - // if (remove === undefined) { - // dispatch( - // addToast({ - // message: `Added ${isPlaylist ? 'playlist' : 'track'} to play queue`, - // intent: Intent.SUCCESS - // }) - // ); - // } - // dispatch({ - // type: PlayerActionTypes.ADD_UP_NEXT, - // payload: { - // next: isPlaylist ? nextList : [nextTrack], - // remove, - // position: getCurrentPosition({ queue, playingTrack }), - // playlist: isPlaylist - // } - // }); - // } - }; -} +// /** +// * Add up next feature +// */ +// export function addUpNextO( +// track: SoundCloud.Track | SoundCloud.Playlist | Normalized.Playlist | Normalized.Track, +// remove?: number +// ): ThunkResult { +// return (dispatch, getState) => { +// // const { +// // player: { queue, currentPlaylistId, playingTrack } +// // } = getState(); +// // const isPlaylist = track.kind === 'playlist'; +// // const nextTrack = { +// // id: track.id, +// // playlistId: currentPlaylistId, +// // un: Date.now() +// // }; +// // let nextList: PlayingTrack[] = []; +// // if (isPlaylist) { +// // const playlist = track as SoundCloud.Playlist; +// // const { tracks = [] } = playlist; +// // nextList = tracks +// // .map((t): PlayingTrack | null => { +// // if (!SC.isStreamable(t)) { +// // return null; +// // } +// // return { +// // id: t.id, +// // playlistId: track.id.toString(), +// // un: Date.now() +// // }; +// // }) +// // .filter(t => t) as PlayingTrack[]; +// // } +// // if (queue.length) { +// // if (remove === undefined) { +// // dispatch( +// // addToast({ +// // message: `Added ${isPlaylist ? 'playlist' : 'track'} to play queue`, +// // intent: Intent.SUCCESS +// // }) +// // ); +// // } +// // dispatch({ +// // type: PlayerActionTypes.ADD_UP_NEXT, +// // payload: { +// // next: isPlaylist ? nextList : [nextTrack], +// // remove, +// // position: getCurrentPosition({ queue, playingTrack }), +// // playlist: isPlaylist +// // } +// // }); +// // } +// }; +// } diff --git a/src/common/store/player/epics/index.ts b/src/common/store/player/epics/index.ts new file mode 100644 index 00000000..a0225f95 --- /dev/null +++ b/src/common/store/player/epics/index.ts @@ -0,0 +1,2 @@ +export * from './player'; +export * from './queue'; diff --git a/src/common/store/player/epics.ts b/src/common/store/player/epics/player.ts similarity index 86% rename from src/common/store/player/epics.ts rename to src/common/store/player/epics/player.ts index 7b06093d..543e066a 100644 --- a/src/common/store/player/epics.ts +++ b/src/common/store/player/epics/player.ts @@ -1,11 +1,14 @@ -import { EpicError } from '@common/utils/errors/EpicError'; +import { EVENTS, IMAGE_SIZES } from '@common/constants'; +import { SC } from '@common/utils'; +import { EpicError, handleEpicError } from '@common/utils/errors/EpicError'; import { Logger } from '@main/utils/logger'; -import { Normalized } from '@types'; +import { SoundCloud } from '@types'; import { StoreState } from 'AppReduxTypes'; -import { AxiosError } from 'axios'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { ipcRenderer } from 'electron'; import _ from 'lodash'; import { StateObservable } from 'redux-observable'; -import { concat, empty, merge, of, throwError, iif } from 'rxjs'; +import { concat, EMPTY, iif, merge, of, throwError } from 'rxjs'; import { catchError, filter, @@ -38,36 +41,34 @@ import { toggleShuffle, toggleStatus, trackFinished -} from '../actions'; -import { RootEpic } from '../declarations'; +} from '../../actions'; +import { RootEpic } from '../../declarations'; import { configSelector, getCurrentPlaylistId, getPlayerCurrentTime, - getPlayerUpNext, - getPlayingTrack, getPlayingTrackIndex, + getPlayingTrackSelector, getPlaylistObjectSelector, + getPlaylistsObjects, getQueuePlaylistSelector, getQueueTrackByIndexSelector, getTrackEntity, - shuffleSelector, - getPlaylistsObjects -} from '../selectors'; -import { ChangeTypes, ObjectStateItem, PlayerStatus, PlaylistTypes, RepeatTypes, PlaylistIdentifier } from '../types'; -import { setCurrentIndex, shuffleQueue, addUpNext } from './actions'; + getUpNextSelector, + shuffleSelector +} from '../../selectors'; +import { + ChangeTypes, + ObjectStateItem, + PlayerStatus, + PlaylistIdentifier, + PlaylistTypes, + RepeatTypes +} from '../../types'; +import { setCurrentIndex, shuffleQueue } from '../actions'; const logger = Logger.createLogger('REDUX/PLAYER'); -const handleEpicError = (error: any) => { - if ((error as AxiosError).isAxiosError) { - logger.error(error.message); - } - logger.error(error); - // TODO Sentry? - return error; -}; - export const startPlayMusicEpic: RootEpic = (action$, state$) => // @ts-ignore action$.pipe( @@ -171,13 +172,7 @@ export const startPlayMusicEpic: RootEpic = (action$, state$) => ) ); }), - catchError(error => - of( - playTrack.failure({ - error: handleEpicError(error) - }) - ) - ) + catchError(handleEpicError(action$, playTrack.failure({}))) ); export const playTrackEpic: RootEpic = (action$, state$) => @@ -200,6 +195,8 @@ export const playTrackEpic: RootEpic = (action$, state$) => const toFetch = !!playlistContainingTrack?.itemsToFetch?.some(i => i.id === id && i.schema === 'tracks'); + console.log({ track }); + return { track, toFetch, playlistContainingTrack }; }), // TODO: should we check if the track is fetched? @@ -287,13 +284,7 @@ export const playTrackEpic: RootEpic = (action$, state$) => ) ); }), - catchError(error => - of( - playTrack.failure({ - error: handleEpicError(error) - }) - ) - ) + catchError(handleEpicError(action$, playTrack.failure({}))) ); export const playPlaylistEpic: RootEpic = (action$, state$) => @@ -326,7 +317,7 @@ export const playPlaylistEpic: RootEpic = (action$, state$) => if (!playlist?.items?.length) { // TODO: we cannot play this playlist, dispatch notification? logger.trace('playPlaylistEpic:: Playlist does not have any items', { id, playlist }); - return empty(); + return EMPTY; } const lastIndex = playlist?.items.length - 1; @@ -345,13 +336,7 @@ export const playPlaylistEpic: RootEpic = (action$, state$) => ) ); }), - catchError(error => - of( - playTrack.failure({ - error: handleEpicError(error) - }) - ) - ) + catchError(handleEpicError(action$, playTrack.failure({}))) ); export const changeTrackEpic: RootEpic = (action$, state$) => @@ -364,7 +349,7 @@ export const changeTrackEpic: RootEpic = (action$, state$) => const playingTrackIndex = getPlayingTrackIndex(state); const queue = getQueuePlaylistSelector(state); const currentTime = getPlayerCurrentTime(state); - const upNext = getPlayerUpNext(state); + const upNext = getUpNextSelector(state); let nextPosition = playingTrackIndex; @@ -396,13 +381,7 @@ export const changeTrackEpic: RootEpic = (action$, state$) => of(startPlayMusicIndex({ index: nextPosition, changeType })) ); }), - catchError(error => - of( - playTrack.failure({ - error: handleEpicError(error) - }) - ) - ) + catchError(handleEpicError(action$, playTrack.failure({}))) ); export const playlistFinishedEpic: RootEpic = (action$, state$) => @@ -489,17 +468,11 @@ export const startPlayMusicIndexEpic: RootEpic = (action$, state$) => ) ); }), - catchError(error => - of( - playTrack.failure({ - error: handleEpicError(error) - }) - ) - ) + catchError(handleEpicError(action$, playTrack.failure({}))) ); export const setCurrentPlaylistEpic: RootEpic = (action$, state$) => - // @ts-ignore + // @ts-expect-error action$.pipe( filter(isActionOf(setCurrentPlaylist.request)), pluck('payload'), @@ -541,68 +514,13 @@ export const setCurrentPlaylistEpic: RootEpic = (action$, state$) => return setCurrentPlaylist.success({ items, playlistId }); }), - catchError(error => - of( - setCurrentPlaylist.failure({ - error: handleEpicError(error) - }) - ) - ) - ); - -export const addUpNextEpic: RootEpic = (action$, state$) => - // @ts-ignore - action$.pipe( - filter(isActionOf(addUpNext.request)), - pluck('payload'), - withLatestFrom(state$), - map(([itemToAdd, latestState]) => ({ - itemToAdd, - playlists: getPlaylistsObjects(latestState) - })), - map(({ itemToAdd, playlists }) => { - // Replace playlists with their items - const items = [itemToAdd].reduce((all, item) => { - if (item.schema === 'playlists') { - const playlistExists = playlists[item.id]; - - if (playlistExists) { - all.push( - ...[...playlistExists.items, ...playlistExists.itemsToFetch].map( - (i): ObjectStateItem => ({ - ...i, - parentPlaylistID: { - objectId: item.id.toString(), - playlistType: PlaylistTypes.PLAYLIST - }, - un: item.un - }) - ) - ); - } else { - all.push(item); - } - } else { - all.push(item); - } - - return all; - }, []); - - return addUpNext.success({ items }); - }), - catchError(error => - of( - addUpNext.failure({ - error: handleEpicError(error) - }) - ) - ) + catchError(handleEpicError(action$, setCurrentPlaylist.failure({}))) ); // Toggle shuffle export const toggleShuffleEpic: RootEpic = (action$, state$) => - // @ts-ignore + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error action$.pipe( filter(isActionOf(toggleShuffle)), pluck('payload'), @@ -648,7 +566,7 @@ const recalculateCurrentIndex = (state$: StateObservable) => const currentPlaylistID = getCurrentPlaylistId(state); return { currentPlaylist: currentPlaylistID ? getPlaylistObjectSelector(currentPlaylistID)(state) : null, - playingTrack: getPlayingTrack(state) + playingTrack: getPlayingTrackSelector(state) }; }), filter(({ playingTrack, currentPlaylist }) => !!playingTrack && !!currentPlaylist), @@ -665,3 +583,35 @@ const recalculateCurrentIndex = (state$: StateObservable) => filter(currentTrackIndex => currentTrackIndex !== -1), map(currentTrackIndex => setCurrentIndex({ position: currentTrackIndex })) ); + +export const trackChangeNotificationEpic: RootEpic = (action$, state$) => + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + action$.pipe( + filter(isActionOf(startPlayMusic)), + pluck('payload'), + filter(({ idResult }) => !!idResult), + withLatestFrom(state$), + map(([payload, state]) => ({ + track: getTrackEntity(payload.idResult?.id as number)(state), + shouldShowNotification: state.config.app.showTrackChangeNotification + // TODO: is window focussed? then do not show + })), + tap(data => console.log('trackChangeNotificationEpic', data)), + filter(({ shouldShowNotification }) => shouldShowNotification), + pluck('track'), + filter(Boolean), + tap(track => { + console.log('fesfsewg', process.type); + const myNotification = new Notification(track.title, { + body: `${track.user && track.user.username ? track.user.username : ''}`, + icon: SC.getImageUrl(track, IMAGE_SIZES.SMALL), + silent: true + }); + + myNotification.onclick = () => { + ipcRenderer.send(EVENTS.APP.RAISE); + }; + }), + ignoreElements() + ); diff --git a/src/common/store/player/epics/queue.ts b/src/common/store/player/epics/queue.ts new file mode 100644 index 00000000..5a3dee16 --- /dev/null +++ b/src/common/store/player/epics/queue.ts @@ -0,0 +1,134 @@ +import { handleEpicError } from '@common/utils/errors/EpicError'; +import { concat, of } from 'rxjs'; +import { catchError, exhaustMap, filter, map, pluck, withLatestFrom } from 'rxjs/operators'; +import { isActionOf } from 'typesafe-actions'; +import { RootEpic } from '../../declarations'; +import { + getPlayingTrackIndex, + getPlaylistsObjects, + getUpNextSelector, + isIndexInUpNextSelector, + queuedTrackIndexSelector +} from '../../selectors'; +import { ObjectStateItem, PlaylistTypes } from '../../types'; +import { + addUpNext, + playTrackFromQueue, + queueInsert, + removeFromQueue, + removeFromQueueOrUpNext, + removeFromUpNext, + startPlayMusic +} from '../actions'; +import { upNextEndSelector } from '../selectors'; + +/** + * If a track is played from the QUEUE, and the selected track is after the upNext. + * UpNext should be inserted into the QUEUE before playing the music. + */ +export const playTrackFromQueueEpic: RootEpic = (action$, state$) => + // @ts-expect-error + action$.pipe( + filter(isActionOf(playTrackFromQueue)), + pluck('payload'), + withLatestFrom(state$), + map(([payload, latestState]) => ({ + payload, + playingTrackIndex: getPlayingTrackIndex(latestState), + upNext: getUpNextSelector(latestState), + upNextEndIndex: upNextEndSelector(latestState), + isIndexInUpNext: isIndexInUpNextSelector(payload.index)(latestState), + trackIndex: queuedTrackIndexSelector(payload.index)(latestState) + })), + exhaustMap(({ payload, upNextEndIndex, upNext, trackIndex, isIndexInUpNext, playingTrackIndex }) => { + let upNextEndToAdd = 0; + + if (isIndexInUpNext) { + upNextEndToAdd = trackIndex + 1; + } else if (upNext.length && payload.index >= upNextEndIndex) { + upNextEndToAdd = upNext.length; + } + + return concat( + // If there are items in upNext, we add the first one to our queue and remove them from upNext + of(upNext).pipe( + filter(() => !!upNextEndToAdd), + map(upNext => upNext.slice(0, upNextEndToAdd)), + map(upNextItemsToAdd => queueInsert({ items: upNextItemsToAdd, position: playingTrackIndex + 1 })) + ), + // Then start playing the track + of(startPlayMusic({ idResult: payload.idResult })) + ); + }), + catchError(handleEpicError(action$, addUpNext.failure({}))) + ); + +/** + * Add track or tracks from a playlist to the upNext + */ +export const addUpNextEpic: RootEpic = (action$, state$) => + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + action$.pipe( + filter(isActionOf(addUpNext.request)), + pluck('payload'), + withLatestFrom(state$), + map(([itemToAdd, latestState]) => ({ + itemToAdd, + playlists: getPlaylistsObjects(latestState) + })), + map(({ itemToAdd, playlists }) => { + // Replace playlists with their items + const items = [itemToAdd].reduce((all, item) => { + if (item.schema === 'playlists') { + const playlistExists = playlists[item.id]; + + if (playlistExists) { + all.push( + ...[...playlistExists.items, ...playlistExists.itemsToFetch].map( + (i): ObjectStateItem => ({ + ...i, + parentPlaylistID: { + objectId: item.id.toString(), + playlistType: PlaylistTypes.PLAYLIST + }, + un: item.un + }) + ) + ); + } else { + all.push(item); + } + } else { + all.push(item); + } + + return all; + }, []); + + return addUpNext.success({ items }); + }), + catchError(handleEpicError(action$, addUpNext.failure({}))) + ); + +/** + * Removes a track using an index from either the QUEUE playlist or upNext + */ +export const removeFromQueueEpic: RootEpic = (action$, state$) => + // @ts-expect-error + action$.pipe( + filter(isActionOf(removeFromQueueOrUpNext)), + pluck('payload'), + withLatestFrom(state$), + map(([itemIndex, state]) => ({ + isIndexInUpNext: isIndexInUpNextSelector(itemIndex)(state), + trackIndex: queuedTrackIndexSelector(itemIndex)(state) + })), + exhaustMap(({ isIndexInUpNext, trackIndex }) => { + if (isIndexInUpNext) { + return of(removeFromUpNext(trackIndex)); + } + + return of(removeFromQueue(trackIndex)); + }) + ); diff --git a/src/common/store/player/reducer.ts b/src/common/store/player/reducer.ts index b8c1bb0d..d94c2635 100755 --- a/src/common/store/player/reducer.ts +++ b/src/common/store/player/reducer.ts @@ -2,7 +2,7 @@ import { pick } from 'lodash'; import { createReducer } from 'typesafe-actions'; import { playTrack, resetStore, restartTrack, setCurrentPlaylist, setCurrentTime, toggleStatus } from '../actions'; import { PlayerState, PlayerStatus } from '../types'; -import { addUpNext, queueInsert, setCurrentIndex } from './actions'; +import { addUpNext, clearUpNext, queueInsert, removeFromUpNext, setCurrentIndex } from './actions'; const initialState: PlayerState = { status: PlayerStatus.STOPPED, @@ -12,11 +12,6 @@ const initialState: PlayerState = { duration: 0, currentIndex: 0, upNext: [] - // upNext: { - // start: 0, - // length: 0 - // }, - // containsPlaylists: [] }; export const playerReducer = createReducer(initialState) @@ -29,6 +24,7 @@ export const playerReducer = createReducer(initialState) }) .handleAction(playTrack.success, (state, { payload }) => { const { idResult, origin, parentPlaylistID, duration = 0, position, positionInPlaylist } = payload; + return { ...state, playingTrack: { @@ -90,10 +86,15 @@ export const playerReducer = createReducer(initialState) upNext: [...state.upNext, ...items.map(item => ({ ...item, un: Date.now() }))] }; }) - .handleAction(queueInsert, state => { + // Remove first item as it is inserted into the queue + .handleAction(queueInsert, (state, { payload }) => { + const { upNext: newUpNext } = state; + + newUpNext.splice(0, payload.items.length); + return { ...state, - upNext: [...state.upNext.splice(1)] + upNext: [...newUpNext] }; }) .handleAction(setCurrentIndex, (state, { payload }) => { @@ -103,6 +104,22 @@ export const playerReducer = createReducer(initialState) currentIndex: position }; }) + .handleAction(clearUpNext, state => { + return { + ...state, + upNext: [] + }; + }) + .handleAction(removeFromUpNext, (state, { payload }) => { + const { upNext: newUpNext } = state; + + newUpNext.splice(payload, 1); + + return { + ...state, + upNext: [...newUpNext] + }; + }) .handleAction(resetStore, () => { return initialState; }); diff --git a/src/common/store/player/selectors.ts b/src/common/store/player/selectors.ts index a515ac7a..7506326d 100644 --- a/src/common/store/player/selectors.ts +++ b/src/common/store/player/selectors.ts @@ -1,21 +1,47 @@ +import { Normalized, SoundCloud } from '@types'; import { StoreState } from 'AppReduxTypes'; import { isEqual } from 'lodash'; import { createSelector } from 'reselect'; -import { PlaylistIdentifier } from '../types'; -import { PlayerState, PlayingTrack } from './types'; -import { Normalized, SoundCloud } from '@types'; -import { PlaylistTypes } from '../objects'; import { AssetType } from 'src/types/soundcloud'; +import { PlaylistTypes } from '../objects'; +import { PlaylistIdentifier } from '../types'; export const getPlayerNode = (state: StoreState) => state.player; -export const getPlayingTrack = createSelector([getPlayerNode], player => player.playingTrack); +export const getPlayingTrackSelector = createSelector([getPlayerNode], player => player.playingTrack); export const getPlayerCurrentTime = createSelector([getPlayerNode], player => player.currentTime); export const getPlayingTrackIndex = createSelector([getPlayerNode], player => player.currentIndex); -export const getPlayerUpNext = createSelector([getPlayerNode], player => player.upNext); -export const getPlayerStatus = createSelector([getPlayerNode], player => player.status); +export const getUpNextSelector = createSelector([getPlayerNode], player => player.upNext); +export const getPlayerStatusSelector = createSelector([getPlayerNode], player => player.status); export const getCurrentPlaylistId = createSelector([getPlayerNode], player => player.currentPlaylistId || null); +export const upNextStartSelector = createSelector([getPlayingTrackIndex], currentIndex => currentIndex + 1); +export const upNextEndSelector = createSelector( + [upNextStartSelector, getUpNextSelector], + (upNextStart, upNext) => upNextStart + upNext.length +); + +export const isIndexInUpNextSelector = (itemIndex: number) => + createSelector([upNextStartSelector, upNextEndSelector], (upNextStart, upNextEnd) => { + return itemIndex >= upNextStart && itemIndex < upNextEnd; + }); + +export const queuedTrackIndexSelector = (itemIndex: number) => + createSelector( + [getPlayingTrackIndex, getUpNextSelector, isIndexInUpNextSelector(itemIndex), upNextEndSelector], + (currentIndex, upNext, isIndexInUpNext, upNextEnd) => { + if (isIndexInUpNext) { + return itemIndex - currentIndex - 1; + } + + if (itemIndex > upNextEnd) { + return itemIndex - upNext.length; + } + + return itemIndex; + } + ); + export const getNormalizedSchemaForType = ( trackOrPlaylist: SoundCloud.Track | SoundCloud.Playlist ): Normalized.NormalizedResult => ({ @@ -24,7 +50,7 @@ export const getNormalizedSchemaForType = ( }); export const isPlayingSelector = (playlistId: PlaylistIdentifier, idResult?: Normalized.NormalizedResult) => - createSelector([getPlayingTrack], playingTrack => { + createSelector([getPlayingTrackSelector], playingTrack => { if (!playingTrack) { return false; } diff --git a/src/common/store/player/types.ts b/src/common/store/player/types.ts index b330e813..3c5ddb6b 100644 --- a/src/common/store/player/types.ts +++ b/src/common/store/player/types.ts @@ -61,30 +61,34 @@ export type ProcessedQueueItems = [PlayingTrack[], PlayingTrack[]]; // ACTIONS export enum PlayerActionTypes { - TOGGLE_STATUS = '@@player/TOGGLE_STATUS', - TOGGLE_SHUFFLE = '@@player/TOGGLE_SHUFFLE', - PLAY_TRACK = '@@player/PLAY_TRACK', - CHANGE_TRACK = '@@player/CHANGE_TRACK', - PLAY_PLAYLIST = '@@player/PLAY_PLAYLIST', - START_PLAY_MUSIC = '@@player/START_PLAY_MUSIC', - SET_CURRENT_PLAYLIST = '@@player/SET_CURRENT_PLAYLIST', - RESTART_TRACK = '@@player/RESTART_TRACK', - TRACK_FINISHED = '@@player/TRACK_FINISHED', - PLAYLIST_FINISHED = '@@player/PLAYLIST_FINISHED', - START_PLAY_MUSIC_INDEX = '@@player/START_PLAY_MUSIC_INDEX', - ADD_UP_NEXT = '@@player/ADD_UP_NEXT', - CLEAR_UP_NEXT = '@@player/CLEAR_UP_NEXT', - QUEUE_INSERT = '@@player/QUEUE_INSERT', - SET_QUEUE = '@@player/SET_QUEUE', - SHUFFLE_QUEUE = '@@player/SHUFFLE_QUEUE', - SET_CURRENT_INDEX = '@@player/SET_CURRENT_INDEX', - RESOLVE_PLAYLIST_ITEMS = '@@player/RESOLVE_PLAYLIST_ITEMS', + TOGGLE_STATUS = 'auryo.player.TOGGLE_STATUS', + TOGGLE_SHUFFLE = 'auryo.player.TOGGLE_SHUFFLE', + PLAY_TRACK = 'auryo.player.PLAY_TRACK', + CHANGE_TRACK = 'auryo.player.CHANGE_TRACK', + PLAY_PLAYLIST = 'auryo.player.PLAY_PLAYLIST', + START_PLAY_MUSIC = 'auryo.player.START_PLAY_MUSIC', + SET_CURRENT_PLAYLIST = 'auryo.player.SET_CURRENT_PLAYLIST', + RESTART_TRACK = 'auryo.player.RESTART_TRACK', + TRACK_FINISHED = 'auryo.player.TRACK_FINISHED', + PLAYLIST_FINISHED = 'auryo.player.PLAYLIST_FINISHED', + START_PLAY_MUSIC_INDEX = 'auryo.player.START_PLAY_MUSIC_INDEX', + ADD_UP_NEXT = 'auryo.player.ADD_UP_NEXT', + CLEAR_UP_NEXT = 'auryo.player.CLEAR_UP_NEXT', + QUEUE_INSERT = 'auryo.player.QUEUE_INSERT', + SET_QUEUE = 'auryo.player.SET_QUEUE', + SHUFFLE_QUEUE = 'auryo.player.SHUFFLE_QUEUE', + SET_CURRENT_INDEX = 'auryo.player.SET_CURRENT_INDEX', + RESOLVE_PLAYLIST_ITEMS = 'auryo.player.RESOLVE_PLAYLIST_ITEMS', + REMOVE_FROM_QUEUE = 'auryo.player.REMOVE_FROM_QUEUE', + PLAY_TRACK_FROM_QUEUE = 'auryo.player.PLAY_TRACK_FROM_QUEUE', // OLD - SET_TIME = '@@player/SET_TIME', - UPDATE_TIME = '@@player/UPDATE_TIME', - SET_DURATION = '@@player/SET_DURATION', - SET_TRACK = '@@player/SET_TRACK', - TOGGLE_PLAYING = '@@player/TOGGLE_PLAYING', - SET_PLAYLIST = '@@player/SET_PLAYLIST' + SET_TIME = 'auryo.player.SET_TIME', + UPDATE_TIME = 'auryo.player.UPDATE_TIME', + SET_DURATION = 'auryo.player.SET_DURATION', + SET_TRACK = 'auryo.player.SET_TRACK', + TOGGLE_PLAYING = 'auryo.player.TOGGLE_PLAYING', + SET_PLAYLIST = 'auryo.player.SET_PLAYLIST', + REMOVE_FROM_UP_NEXT = 'REMOVE_FROM_UP_NEXT', + REMOVE_FROM_QUEUE_OR_UP_NEXT = 'REMOVE_FROM_QUEUE_OR_UP_NEXT' } diff --git a/src/common/store/playlist/actions.ts b/src/common/store/playlist/actions.ts index cab106d3..ec487495 100755 --- a/src/common/store/playlist/actions.ts +++ b/src/common/store/playlist/actions.ts @@ -10,14 +10,14 @@ export const getGenericPlaylist = createAsyncAction( )< PlaylistIdentifier & { refresh: boolean; sortType?: SortTypes; searchString?: string }, PlaylistObjectItem & { refresh?: boolean; query?: string }, - EpicFailure & PlaylistIdentifier + PlaylistIdentifier >(); export const genericPlaylistFetchMore = createAsyncAction( String(PlaylistActionTypes.GENERIC_PLAYLIST_FETCH_MORE), wSuccess(PlaylistActionTypes.GENERIC_PLAYLIST_FETCH_MORE), wError(PlaylistActionTypes.GENERIC_PLAYLIST_FETCH_MORE) -)(); +)(); export const setPlaylistLoading = createAction(PlaylistActionTypes.SET_PLAYLIST_LOADING)(); @@ -31,19 +31,19 @@ export const getForYouSelection = createAsyncAction( wSuccess(PlaylistActionTypes.GET_FORYOU_SELECTION), wError(PlaylistActionTypes.GET_FORYOU_SELECTION) )< - unknown, + undefined, { objects: ForYourObject[]; entities: EntitiesOf & { tracks: Normalized.NormalizedResult[] }>; result: Array; }, - EpicFailure + object >(); export const getPlaylistTracks = createAsyncAction( String(PlaylistActionTypes.GET_PLAYLIST_TRACKS), wSuccess(PlaylistActionTypes.GET_PLAYLIST_TRACKS), wError(PlaylistActionTypes.GET_PLAYLIST_TRACKS) -)(); +)(); export type ForYourObject = Omit & { objectId: string }; diff --git a/src/common/store/playlist/api.ts b/src/common/store/playlist/api.ts index d9f44bc3..196a3cd1 100644 --- a/src/common/store/playlist/api.ts +++ b/src/common/store/playlist/api.ts @@ -10,8 +10,8 @@ export interface FeedItem { user: SoundCloud.CompactUser; } -export async function fetchStream(options: { limit?: number }) { - const json = await fetchToJsonNew>({ +export function fetchStream(options: { limit?: number }) { + return fetchToJsonNew>({ uri: 'stream', oauthToken: true, useV2Endpoint: true, @@ -19,8 +19,6 @@ export async function fetchStream(options: { limit?: number }) { limit: options.limit ?? 20 } }); - - return json; } // Likes @@ -30,8 +28,8 @@ export interface LikeItem { created_at: string; } -export async function fetchLikes(options: { userId?: string | number; limit?: number }) { - const json = await fetchToJsonNew>({ +export function fetchLikes(options: { userId?: string | number; limit?: number }) { + return fetchToJsonNew>({ uri: `users/${options.userId}/track_likes`, oauthToken: true, useV2Endpoint: true, @@ -39,8 +37,6 @@ export async function fetchLikes(options: { userId?: string | number; limit?: nu limit: options.limit ?? 20 } }); - - return json; } // My Playlists @@ -52,8 +48,8 @@ export interface PlaylistItem { uuid: string; } -export async function fetchPlaylists(options: { limit?: number }) { - const json = await fetchToJsonNew>({ +export function fetchPlaylists(options: { limit?: number }) { + return fetchToJsonNew>({ uri: `me/library/albums_playlists_and_system_playlists`, oauthToken: true, useV2Endpoint: true, @@ -61,13 +57,11 @@ export async function fetchPlaylists(options: { limit?: number }) { limit: options.limit ?? 20 } }); - - return json; } // My tracks -export async function fetchMyTracks(options: { userId?: string | number; limit?: number }) { - const json = await fetchToJsonNew>({ +export function fetchMyTracks(options: { userId?: string | number; limit?: number }) { + return fetchToJsonNew>({ uri: `users/${options.userId}/tracks`, oauthToken: true, useV2Endpoint: true, @@ -75,8 +69,6 @@ export async function fetchMyTracks(options: { userId?: string | number; limit?: limit: options.limit ?? 20 } }); - - return json; } // Charts @@ -85,8 +77,8 @@ export interface ChartItem { track: SoundCloud.Track; } -export async function fetchCharts(options: { limit?: number; sort?: SortTypes; genre: string }) { - const json = await fetchToJsonNew>({ +export function fetchCharts(options: { limit?: number; sort?: SortTypes; genre: string }) { + return fetchToJsonNew>({ uri: `charts`, oauthToken: true, useV2Endpoint: true, @@ -96,24 +88,20 @@ export async function fetchCharts(options: { limit?: number; sort?: SortTypes; g genre: options.genre } }); - - return json; } // Fetch playlist -export async function fetchPlaylist(options: { limit?: number; playlistId: number | string }) { - const json = await fetchToJsonNew({ +export function fetchPlaylist(options: { limit?: number; playlistId: number | string }) { + return fetchToJsonNew({ uri: `playlists/${options.playlistId}`, oauthToken: true, useV2Endpoint: true }); - - return json; } // Fetch seperate tracks -export async function fetchTracks(options: { ids: number[] }) { - const json = await fetchToJsonNew({ +export function fetchTracks(options: { ids: number[] }) { + return fetchToJsonNew({ uri: `tracks`, oauthToken: true, useV2Endpoint: true, @@ -121,8 +109,6 @@ export async function fetchTracks(options: { ids: number[] }) { ids: options.ids.join(',') } }); - - return json; } interface SearchAllResponse { @@ -136,7 +122,7 @@ interface SearchAllResponse { export type SearchCollectionItem = SoundCloud.Track | SoundCloud.Playlist | SoundCloud.User; // SearchByQuery -export async function searchAll(options: { +export function searchAll(options: { query?: string; limit: number; type?: 'users' | 'playlists_without_albums' | 'tracks'; @@ -152,18 +138,16 @@ export async function searchAll(options: { queryParams['filter.genre'] = options.genre; } - const json = await fetchToJsonNew({ + return fetchToJsonNew({ uri: `search${options.type ? `/${options.type}` : ''}`, oauthToken: true, useV2Endpoint: true, queryParams }); - - return json; } -export async function fetchPlaylistsByTag(options: { limit?: number; tag: string }) { - const json = await fetchToJsonNew>({ +export function fetchPlaylistsByTag(options: { limit?: number; tag: string }) { + return fetchToJsonNew>({ uri: `playlists/discovery`, oauthToken: true, useV2Endpoint: true, @@ -172,8 +156,6 @@ export async function fetchPlaylistsByTag(options: { limit?: number; tag: string tag: options.tag } }); - - return json; } export interface PersonalisedCollectionItem { @@ -191,12 +173,10 @@ export interface PersonalisedCollectionItem { items: Collection; } -export async function fetchPersonalizedPlaylists() { - const json = await fetchToJsonNew>({ +export function fetchPersonalizedPlaylists() { + return fetchToJsonNew>({ uri: `mixed-selections`, oauthToken: true, useV2Endpoint: true }); - - return json; } diff --git a/src/common/store/playlist/epics.ts b/src/common/store/playlist/epics.ts index 18a72af9..7e579ae4 100644 --- a/src/common/store/playlist/epics.ts +++ b/src/common/store/playlist/epics.ts @@ -1,13 +1,12 @@ import { normalizeArray, normalizeCollection, playlistSchema } from '@common/schemas'; import { SC } from '@common/utils'; -import { EpicError } from '@common/utils/errors/EpicError'; +import { EpicError, handleEpicError } from '@common/utils/errors/EpicError'; import { Collection, EntitiesOf, Normalized, SoundCloud } from '@types'; import { RootAction, StoreState } from 'AppReduxTypes'; -import { AxiosError } from 'axios'; import _, { isEqual, uniqWith } from 'lodash'; import { normalize, schema } from 'normalizr'; import { ActionsObservable, StateObservable } from 'redux-observable'; -import { concat, EMPTY, from, merge, of, throwError } from 'rxjs'; +import { concat, defer, EMPTY, from, merge, of, throwError } from 'rxjs'; import { catchError, delay, @@ -15,7 +14,6 @@ import { exhaustMap, filter, first, - flatMap, ignoreElements, map, mergeMap, @@ -51,89 +49,81 @@ import { import { ObjectTypes, PlaylistTypes } from '../types'; import { PlaylistIdentifier } from './types'; -const handleEpicError = (error: any) => { - if ((error as AxiosError).isAxiosError) { - console.log(error.message); - } - console.error('Epic error', error); - // TODO Sentry? - return error; -}; - export const getGenericPlaylistEpic: RootEpic = (action$, state$) => action$.pipe( filter(isActionOf(getGenericPlaylist.request)), - tap(action => console.log(`${action.type} from ${process.type}`)), pluck('payload'), withLatestFrom(state$), - flatMap(([{ playlistType, objectId, refresh, sortType }, state]) => { + switchMap(([{ playlistType, objectId, refresh, sortType }, state]) => { const { config: { hideReposts } } = state; const me = currentUserSelector(state); - let ob$; - - switch (playlistType) { - case PlaylistTypes.STREAM: - ob$ = APIService.fetchStream({ limit: hideReposts ? 42 : 21 }); - break; - case PlaylistTypes.LIKES: - ob$ = APIService.fetchLikes({ limit: 21, userId: me?.id || '' }); - break; - case PlaylistTypes.MYTRACKS: - ob$ = APIService.fetchMyTracks({ limit: 21, userId: me?.id || '' }); - break; - case PlaylistTypes.MYPLAYLISTS: - ob$ = APIService.fetchPlaylists({ limit: 21 }); - break; - case PlaylistTypes.RELATED: - if (!objectId) { - ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); + return defer(() => { + let ob$; + + switch (playlistType) { + case PlaylistTypes.STREAM: + ob$ = APIService.fetchStream({ limit: hideReposts ? 42 : 21 }); break; - } - ob$ = APIService.fetchRelatedTracks({ limit: 21, trackId: objectId, userId: me?.id || '' }); - break; - case PlaylistTypes.PLAYLIST: - if (!objectId) { - ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); + case PlaylistTypes.LIKES: + ob$ = APIService.fetchLikes({ limit: 21, userId: me?.id || '' }); break; - } - ob$ = APIService.fetchPlaylist({ playlistId: objectId }); - break; - case PlaylistTypes.CHART: - if (!objectId) { - ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); + case PlaylistTypes.MYTRACKS: + ob$ = APIService.fetchMyTracks({ limit: 21, userId: me?.id || '' }); break; - } - ob$ = APIService.fetchCharts({ limit: 21, genre: objectId, sort: sortType }); - break; - case PlaylistTypes.ARTIST_TRACKS: - if (!objectId) { - ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); + case PlaylistTypes.MYPLAYLISTS: + ob$ = APIService.fetchPlaylists({ limit: 21 }); break; - } - ob$ = APIService.fetchUserTracks({ limit: 21, userId: objectId }); - break; - case PlaylistTypes.ARTIST_TOP_TRACKS: - if (!objectId) { - ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); + case PlaylistTypes.RELATED: + if (!objectId) { + ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); + break; + } + ob$ = APIService.fetchRelatedTracks({ limit: 21, trackId: objectId, userId: me?.id || '' }); break; - } - ob$ = APIService.fetchUserTopTracks({ limit: 21, userId: objectId }); - break; - case PlaylistTypes.ARTIST_LIKES: - if (!objectId) { - ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); + case PlaylistTypes.PLAYLIST: + if (!objectId) { + ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); + break; + } + ob$ = APIService.fetchPlaylist({ playlistId: objectId }); break; - } - ob$ = APIService.fetchUserLikes({ limit: 21, userId: objectId }); - break; - default: - ob$ = throwError(new EpicError(`${playlistType}: ${objectId} not found`)); - } + case PlaylistTypes.CHART: + if (!objectId) { + ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); + break; + } + ob$ = APIService.fetchCharts({ limit: 21, genre: objectId, sort: sortType }); + break; + case PlaylistTypes.ARTIST_TRACKS: + if (!objectId) { + ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); + break; + } + ob$ = APIService.fetchUserTracks({ limit: 21, userId: objectId }); + break; + case PlaylistTypes.ARTIST_TOP_TRACKS: + if (!objectId) { + ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); + break; + } + ob$ = APIService.fetchUserTopTracks({ limit: 21, userId: objectId }); + break; + case PlaylistTypes.ARTIST_LIKES: + if (!objectId) { + ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); + break; + } + ob$ = APIService.fetchUserLikes({ limit: 21, userId: objectId }); + break; + default: + ob$ = throwError(new EpicError(`${playlistType}: ${objectId} not found`)); + } - return from(ob$).pipe( + return ob$; + }).pipe( map(json => { switch (playlistType) { case PlaylistTypes.STREAM: @@ -145,7 +135,7 @@ export const getGenericPlaylistEpic: RootEpic = (action$, state$) => case PlaylistTypes.RELATED: case PlaylistTypes.ARTIST_TRACKS: case PlaylistTypes.ARTIST_TOP_TRACKS: - return processMyTracks(state)(json as Collection); + return processTracks(state)(json as Collection); case PlaylistTypes.MYPLAYLISTS: return processStreamItems(state)(json as Collection); case PlaylistTypes.PLAYLIST: @@ -167,14 +157,14 @@ export const getGenericPlaylistEpic: RootEpic = (action$, state$) => entities: data.normalized.entities, result: data.normalized.result, refresh, - nextUrl: data.json?.['next_href'], - fetchedItemsIds: data?.['fetchedItemsIds'] + nextUrl: data.json?.next_href, + fetchedItemsIds: data?.fetchedItemsIds }) ), - catchError(error => - of( + catchError( + handleEpicError( + action$, getGenericPlaylist.failure({ - error: handleEpicError(error), objectId, playlistType }) @@ -216,15 +206,17 @@ export const genericPlaylistFetchMoreEpic: RootEpic = (action$, state$) => const urlWithToken = SC.appendToken(object?.nextUrl as string); const itemsToFetch = (object?.itemsToFetch.map(i => i.id) || []).slice(0, 15); - let ob$; + return defer(() => { + let ob$; - if (originalPlaylistType === PlaylistTypes.PLAYLIST) { - ob$ = APIService.fetchTracks({ ids: itemsToFetch }); - } else { - ob$ = APIService.fetchFromUrl(urlWithToken); - } + if (originalPlaylistType === PlaylistTypes.PLAYLIST) { + ob$ = APIService.fetchTracks({ ids: itemsToFetch }); + } else { + ob$ = APIService.fetchFromUrl(urlWithToken); + } - return from(ob$).pipe( + return ob$; + }).pipe( map(json => { switch (originalPlaylistType) { case PlaylistTypes.STREAM: @@ -236,7 +228,7 @@ export const genericPlaylistFetchMoreEpic: RootEpic = (action$, state$) => case PlaylistTypes.RELATED: case PlaylistTypes.ARTIST_TRACKS: case PlaylistTypes.ARTIST_TOP_TRACKS: - return processMyTracks(state)(json); + return processTracks(state)(json); case PlaylistTypes.MYPLAYLISTS: return processStreamItems(state)(json); case PlaylistTypes.PLAYLIST: @@ -257,21 +249,22 @@ export const genericPlaylistFetchMoreEpic: RootEpic = (action$, state$) => entities: data.normalized.entities, objectType: ObjectTypes.PLAYLISTS, result: data.normalized.result, - nextUrl: data.json?.['next_href'], - fetchedItemsIds: data?.['fetchedItemsIds'], + nextUrl: data.json?.next_href, + fetchedItemsIds: data?.fetchedItemsIds, shuffle }) ), - catchError(error => - of( + startWith(setPlaylistLoading({ objectId, playlistType })), + + catchError( + handleEpicError( + action$, genericPlaylistFetchMore.failure({ - error: handleEpicError(error), objectId, playlistType }) ) - ), - startWith(setPlaylistLoading({ objectId, playlistType })) + ) ); }) ); @@ -287,37 +280,39 @@ export const searchEpic: RootEpic = (action$, state$) => // return Promise.resolve(tryAndResolveQueryAsSoundCloudUrl(query, dispatch)) as any; // } - let ob$; + return defer(() => { + let ob$; - switch (playlistType) { - case PlaylistTypes.SEARCH: - if (query && query.length) { - ob$ = APIService.searchAll({ query, limit: 21 }); - } - break; - case PlaylistTypes.SEARCH_TRACK: - if (query && query.length) { - ob$ = APIService.searchAll({ query, limit: 21, type: 'tracks' }); - } else if (tag) { - ob$ = APIService.searchAll({ genre: tag, limit: 21, type: 'tracks' }); - } - break; - case PlaylistTypes.SEARCH_PLAYLIST: - if (query && query.length) { - ob$ = APIService.searchAll({ query, limit: 21, type: 'playlists_without_albums' }); - } else if (tag) { - ob$ = APIService.fetchPlaylistsByTag({ tag, limit: 21 }); - } - break; - case PlaylistTypes.SEARCH_USER: - if (query && query.length) { - ob$ = APIService.searchAll({ query, limit: 21, type: 'users' }); - } - break; - default: - } + switch (playlistType) { + case PlaylistTypes.SEARCH: + if (query && query.length) { + ob$ = APIService.searchAll({ query, limit: 21 }); + } + break; + case PlaylistTypes.SEARCH_TRACK: + if (query && query.length) { + ob$ = APIService.searchAll({ query, limit: 21, type: 'tracks' }); + } else if (tag) { + ob$ = APIService.searchAll({ genre: tag, limit: 21, type: 'tracks' }); + } + break; + case PlaylistTypes.SEARCH_PLAYLIST: + if (query && query.length) { + ob$ = APIService.searchAll({ query, limit: 21, type: 'playlists_without_albums' }); + } else if (tag) { + ob$ = APIService.fetchPlaylistsByTag({ tag, limit: 21 }); + } + break; + case PlaylistTypes.SEARCH_USER: + if (query && query.length) { + ob$ = APIService.searchAll({ query, limit: 21, type: 'users' }); + } + break; + default: + } - return from(ob$ || EMPTY).pipe( + return ob$ ?? EMPTY; + }).pipe( map(data => normalizeCollection(data)), map(data => getGenericPlaylist.success({ @@ -327,15 +322,15 @@ export const searchEpic: RootEpic = (action$, state$) => entities: data.normalized.entities, result: data.normalized.result, refresh, - nextUrl: data.json?.['next_href'], - fetchedItemsIds: data?.['fetchedItemsIds'], + nextUrl: data.json?.next_href, + fetchedItemsIds: data?.fetchedItemsIds, query: query || tag }) ), - catchError(error => - of( + catchError( + handleEpicError( + action$, getGenericPlaylist.failure({ - error: handleEpicError(error), objectId, playlistType }) @@ -363,7 +358,7 @@ export const searchFetchMoreEpic: RootEpic = (action$, state$) => const { playlistType, objectId } = payload; const urlWithToken = SC.appendToken(object?.nextUrl as string); - return from(APIService.fetchFromUrl(urlWithToken)).pipe( + return defer(() => from(APIService.fetchFromUrl(urlWithToken))).pipe( map(normalizeCollection), map(data => genericPlaylistFetchMore.success({ @@ -372,33 +367,34 @@ export const searchFetchMoreEpic: RootEpic = (action$, state$) => entities: data.normalized.entities, objectType: ObjectTypes.PLAYLISTS, result: data.normalized.result, - nextUrl: data.json?.['next_href'], - fetchedItemsIds: data?.['fetchedItemsIds'] + nextUrl: data.json?.next_href, + fetchedItemsIds: data?.fetchedItemsIds }) ), - catchError(error => - of( + startWith(setPlaylistLoading({ objectId, playlistType })), + + catchError( + handleEpicError( + action$, genericPlaylistFetchMore.failure({ - error: handleEpicError(error), objectId, playlistType }) ) - ), - startWith(setPlaylistLoading({ objectId, playlistType })) + ) ); }) ); export const getForYouSelectionEpic: RootEpic = action$ => - // eslint-disable-next-line @typescript-eslint/ban-ts-ignore - // @ts-ignore + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error action$.pipe( filter(isActionOf(getForYouSelection.request)), tap(action => console.log(`${action.type} from ${process.type}`)), // map(action => action.payload), switchMap(() => { - return from(APIService.fetchPersonalizedPlaylists()).pipe( + return defer(() => from(APIService.fetchPersonalizedPlaylists())).pipe( map(json => { const collection = json.collection.filter(t => t.urn.indexOf('chart') === -1); @@ -441,19 +437,15 @@ export const getForYouSelectionEpic: RootEpic = action$ => result: normalized.result }); }), - catchError(error => - of( - getForYouSelection.failure({ - error: handleEpicError(error) - }) - ) - ) + + catchError(handleEpicError(action$, getForYouSelection.failure({}))) ); }) ); export const getPlaylistTracksEpic: RootEpic = (action$, state$) => - // @ts-ignore + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error action$.pipe( // For playlists in playlists (playlist in STREAM for ex), we need to check if an object exists for this playlist, // Otherwise we cannot do much, so we wait for it and create it @@ -476,7 +468,7 @@ export const getPlaylistTracksEpic: RootEpic = (action$, state$) => merge( from(_.chunk(object?.itemsToFetch.map(i => i.id) || [], 50)).pipe( mergeMap(itemsForThisChunk => - from(APIService.fetchTracks({ ids: itemsForThisChunk })).pipe( + defer(() => from(APIService.fetchTracks({ ids: itemsForThisChunk }))).pipe( map(json => processPlaylistTracks(json, itemsForThisChunk)), map(data => genericPlaylistFetchMore.success({ @@ -485,19 +477,20 @@ export const getPlaylistTracksEpic: RootEpic = (action$, state$) => entities: data.normalized.entities, objectType: ObjectTypes.PLAYLISTS, result: data.normalized.result, - nextUrl: data.json?.['next_href'], - fetchedItemsIds: data?.['fetchedItemsIds'] + nextUrl: data.json?.next_href, + fetchedItemsIds: data?.fetchedItemsIds }) - ) + ), + catchError(handleEpicError(action$)) ) ) ) ) ), - catchError(error => - of( + catchError( + handleEpicError( + action$, getPlaylistTracks.failure({ - error: handleEpicError(error), objectId, playlistType }) @@ -510,7 +503,8 @@ export const getPlaylistTracksEpic: RootEpic = (action$, state$) => ); export const createPlaylistObjectsEpic: RootEpic = (action$, state$) => - // @ts-ignore + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error action$.pipe( filter(isActionOf([getGenericPlaylist.success, genericPlaylistFetchMore.success])), delay(250), @@ -649,7 +643,7 @@ const processLikeItems = (_state: StoreState) => (json: Collection (json: Collection) => { +const processTracks = (_state: StoreState) => (json: Collection) => { return normalizeCollection(json); }; diff --git a/src/common/store/playlist/types.ts b/src/common/store/playlist/types.ts index cbed5f18..777ac97d 100644 --- a/src/common/store/playlist/types.ts +++ b/src/common/store/playlist/types.ts @@ -17,13 +17,13 @@ export interface PlaylistObjectItem extends ObjectItem, PlaylistIden // ACTIONS export enum PlaylistActionTypes { - GET_GENERIC_PLAYLIST = '@@playlist/GET_GENERIC_PLAYLIST', - SET_PLAYLIST_LOADING = '@@playlist/SET_PLAYLIST_LOADING', - GENERIC_PLAYLIST_FETCH_MORE = '@@playlist/GENERIC_PLAYLIST_FETCH_MORE', + GET_GENERIC_PLAYLIST = 'auryo.playlist.GET_GENERIC_PLAYLIST', + SET_PLAYLIST_LOADING = 'auryo.playlist.SET_PLAYLIST_LOADING', + GENERIC_PLAYLIST_FETCH_MORE = 'auryo.playlist.GENERIC_PLAYLIST_FETCH_MORE', - SEARCH = '@@playlist/SEARCH', - SEARCH_FETCH_MORE = '@@playlist/SEARCH_FETCH_MORE', + SEARCH = 'auryo.playlist.SEARCH', + SEARCH_FETCH_MORE = 'auryo.playlist.SEARCH_FETCH_MORE', - GET_FORYOU_SELECTION = '@@playlist/GET_FORYOU_SELECTION', - GET_PLAYLIST_TRACKS = '@@playlist/GET_PLAYLIST_TRACKS' + GET_FORYOU_SELECTION = 'auryo.playlist.GET_FORYOU_SELECTION', + GET_PLAYLIST_TRACKS = 'auryo.playlist.GET_PLAYLIST_TRACKS' } diff --git a/src/common/store/track/api.ts b/src/common/store/track/api.ts index ef730a89..78718583 100644 --- a/src/common/store/track/api.ts +++ b/src/common/store/track/api.ts @@ -1,19 +1,17 @@ import fetchToJsonNew from '@common/api/helpers/fetchToJsonNew'; import { Collection, SoundCloud } from '@types'; -export async function fetchTrack(options: { trackId: string | number }) { - const json = await fetchToJsonNew({ +export function fetchTrack(options: { trackId: string | number }) { + return fetchToJsonNew({ uri: `tracks/${options.trackId}`, oauthToken: true, useV2Endpoint: true }); - - return json; } // Comments -export async function fetchComments(options: { trackId: number; limit?: number }) { - const json = await fetchToJsonNew>({ +export function fetchComments(options: { trackId: number; limit?: number }) { + return fetchToJsonNew>({ uri: `tracks/${options.trackId}/comments`, clientId: true, useV2Endpoint: true, @@ -23,12 +21,10 @@ export async function fetchComments(options: { trackId: number; limit?: number } filter_replies: 0 } }); - - return json; } -export async function fetchRelatedTracks(options: { trackId: string; userId: string | number; limit?: number }) { - const json = await fetchToJsonNew>({ +export function fetchRelatedTracks(options: { trackId: string; userId: string | number; limit?: number }) { + return fetchToJsonNew>({ uri: `tracks/${options.trackId}/related`, oauthToken: true, useV2Endpoint: true, @@ -37,6 +33,4 @@ export async function fetchRelatedTracks(options: { trackId: string; userId: str user_id: options.userId } }); - - return json; } diff --git a/src/common/store/track/epics.ts b/src/common/store/track/epics.ts index 1094494d..84e7c0e2 100644 --- a/src/common/store/track/epics.ts +++ b/src/common/store/track/epics.ts @@ -1,9 +1,9 @@ import { normalizeArray, normalizeCollection } from '@common/schemas'; import { SC } from '@common/utils'; +import { handleEpicError } from '@common/utils/errors/EpicError'; import { SoundCloud } from '@types'; -import { AxiosError } from 'axios'; -import { from, of } from 'rxjs'; -import { catchError, filter, map, startWith, switchMap, tap, withLatestFrom, pluck } from 'rxjs/operators'; +import { defer, from } from 'rxjs'; +import { catchError, filter, map, pluck, startWith, switchMap, tap, withLatestFrom } from 'rxjs/operators'; import { isActionOf } from 'typesafe-actions'; import { commentsFetchMore, getComments, getTrack, setCommentsLoading } from '../actions'; import { fetchFromUrl } from '../api'; @@ -12,23 +12,13 @@ import { getCommentObject } from '../selectors'; import { ObjectTypes } from '../types'; import * as APIService from './api'; -const handleEpicError = (error: any) => { - if ((error as AxiosError).isAxiosError) { - console.log(error.message, error.response.data); - } else { - console.error('Epic error - track', error); - } - // TODO Sentry? - return error; -}; - export const getTrackEpic: RootEpic = action$ => action$.pipe( filter(isActionOf(getTrack.request)), tap(action => console.log(`${action.type} from ${process.type}`)), pluck('payload'), switchMap(({ trackId, refresh }) => { - return from(APIService.fetchTrack({ trackId })).pipe( + return defer(() => from(APIService.fetchTrack({ trackId }))).pipe( map(track => normalizeArray([track])), map(data => getTrack.success({ @@ -36,10 +26,10 @@ export const getTrackEpic: RootEpic = action$ => entities: data.normalized.entities }) ), - catchError(error => - of( + catchError( + handleEpicError( + action$, getTrack.failure({ - error: handleEpicError(error), trackId }) ) @@ -54,7 +44,7 @@ export const getCommentsEpic: RootEpic = action$ => tap(action => console.log(`${action.type} from ${process.type}`)), pluck('payload'), switchMap(({ trackId, refresh }) => { - return from(APIService.fetchComments({ trackId })).pipe( + return defer(() => from(APIService.fetchComments({ trackId }))).pipe( map(data => normalizeCollection(data)), map(data => getComments.success({ @@ -63,13 +53,13 @@ export const getCommentsEpic: RootEpic = action$ => entities: data.normalized.entities, result: data.normalized.result, refresh, - nextUrl: data.json?.['next_href'] + nextUrl: data.json?.next_href }) ), - catchError(error => - of( + catchError( + handleEpicError( + action$, getComments.failure({ - error: handleEpicError(error), trackId }) ) @@ -96,7 +86,7 @@ export const commentsFetchMoreEpic: RootEpic = (action$, state$) => const { trackId } = payload; const urlWithToken = SC.appendToken(object?.nextUrl as string); - return from(fetchFromUrl(urlWithToken)).pipe( + return defer(() => from(fetchFromUrl(urlWithToken))).pipe( map(data => normalizeCollection(data)), map(data => commentsFetchMore.success({ @@ -104,13 +94,13 @@ export const commentsFetchMoreEpic: RootEpic = (action$, state$) => entities: data.normalized.entities, objectType: ObjectTypes.COMMENTS, result: data.normalized.result, - nextUrl: data.json?.['next_href'] + nextUrl: data.json?.next_href }) ), - catchError(error => - of( + catchError( + handleEpicError( + action$, commentsFetchMore.failure({ - error: handleEpicError(error), trackId }) ) diff --git a/src/common/store/track/selectors.ts b/src/common/store/track/selectors.ts index b3eee5cc..49cf70ef 100644 --- a/src/common/store/track/selectors.ts +++ b/src/common/store/track/selectors.ts @@ -3,6 +3,10 @@ import { createSelector } from 'reselect'; export const getTrackNode = (state: StoreState) => state.track; -export const isTrackLoading = (trackId: string) => - createSelector([getTrackNode], track => track.loading.includes(+trackId)); +export const isTrackLoading = (trackId?: string | number) => + createSelector([getTrackNode], track => { + if (!trackId) return true; + + return track.loading.includes(+trackId); + }); export const isTrackError = (trackId: number | string) => createSelector([getTrackNode], track => track.error[trackId]); diff --git a/src/common/store/track/types.ts b/src/common/store/track/types.ts index 5efbc74a..9f04bf1a 100644 --- a/src/common/store/track/types.ts +++ b/src/common/store/track/types.ts @@ -1,16 +1,14 @@ -import { AxiosError } from 'axios'; - export interface TrackState { loading: number[]; - error: { [trackId: string]: AxiosError | Error | null }; + error: { [trackId: string]: Error | null }; } // ACTIONS export enum TrackActionTypes { - ADD = '@@track/ADD', - GET_TRACK = '@@track/GET_TRACK', - GET_COMMENTS = '@@track/GET_COMMENTS', - GET_COMMENTS_FETCH_MORE = '@@track/GET_COMMENTS_FETCH_MORE', - SET_COMMENTS_LOADING = '@@track/SET_COMMENTS_LOADING' + ADD = 'auryo.track.ADD', + GET_TRACK = 'auryo.track.GET_TRACK', + GET_COMMENTS = 'auryo.track.GET_COMMENTS', + GET_COMMENTS_FETCH_MORE = 'auryo.track.GET_COMMENTS_FETCH_MORE', + SET_COMMENTS_LOADING = 'auryo.track.SET_COMMENTS_LOADING' } diff --git a/src/common/store/ui/epics.ts b/src/common/store/ui/epics.ts index 1eddd364..5653215e 100644 --- a/src/common/store/ui/epics.ts +++ b/src/common/store/ui/epics.ts @@ -16,23 +16,24 @@ export const setDebouncedDimensionsEpic: RootEpic = action$ => action$.pipe( filter(isActionOf(setDebouncedDimensions)), debounceTime(500), - map(action => setDimensions(action.payload)) + pluck('payload'), + map(payload => setDimensions(payload)) ); export const setDebouncedSearchQueryEpic: RootEpic = action$ => action$.pipe( filter(isActionOf(setDebouncedSearchQuery)), debounceTime(250), - map(action => + pluck('payload'), + map(query => setSearchQuery({ - query: action.payload + query }) ) ); export const setSearchQueryEpic: RootEpic = (action$, state$) => - // eslint-disable-next-line @typescript-eslint/ban-ts-ignore - // @ts-ignore + // @ts-expect-error action$.pipe( filter(isActionOf(setSearchQuery)), pluck('payload'), diff --git a/src/common/store/ui/reducer.ts b/src/common/store/ui/reducer.ts index cb9f0db5..856f074f 100644 --- a/src/common/store/ui/reducer.ts +++ b/src/common/store/ui/reducer.ts @@ -4,10 +4,10 @@ import { UIState } from '../types'; const initialState: UIState = { toasts: [], - dimensions: { - width: 0, - height: 0 - }, + // dimensions: { + // width: 0, + // height: 0 + // }, searchQuery: undefined }; @@ -30,12 +30,13 @@ export const uiReducer = createReducer(initialState) toasts: [...state.toasts.filter(t => t.key === action.payload)] }; }) - .handleAction(setDimensions, (state, action) => { - return { - ...state, - dimensions: action.payload - }; - }) + // .handleAction(setDimensions, (state, action) => { + // console.log('setDimensions', action.type, process.type); + // return { + // ...state, + // dimensions: action.payload + // }; + // }) .handleAction(setSearchQuery, (state, action) => { return { ...state, diff --git a/src/common/store/ui/selectors.ts b/src/common/store/ui/selectors.ts index a43c1559..f76ee2c3 100644 --- a/src/common/store/ui/selectors.ts +++ b/src/common/store/ui/selectors.ts @@ -1,7 +1,7 @@ import { StoreState } from 'AppReduxTypes'; -import { UIState } from '../types'; import { createSelector } from 'reselect'; export const getUi = (state: StoreState) => state.ui; -export const getSearchQuery = createSelector(getUi, (state: UIState) => state.searchQuery); +export const getSearchQuerySelector = createSelector(getUi, state => state.searchQuery); +export const getToastsSelector = createSelector(getUi, state => state.toasts); diff --git a/src/common/store/ui/types.ts b/src/common/store/ui/types.ts index 8883b3c5..0ce960b9 100644 --- a/src/common/store/ui/types.ts +++ b/src/common/store/ui/types.ts @@ -3,7 +3,8 @@ import { IToastOptions } from '@blueprintjs/core'; // TYPES export type UIState = Readonly<{ toasts: IToastOptions[]; - dimensions: Dimensions; + // TODO: can this be removed? + // dimensions: Dimensions; searchQuery?: string; }>; @@ -14,9 +15,9 @@ export interface Dimensions { // ACTIONS export enum UIActionTypes { - ADD_TOAST = '@@ui/ADD_TOAST', - REMOVE_TOAST = '@@ui/REMOVE_TOAST', - CLEAR_TOASTS = '@@ui/CLEAR_TOASTS', - SET_DIMENSIONS = '@@ui/SET_DIMENSIONS', - SET_SEARCH_QUERY = '@@ui/SET_SEARCH_QUERY' + ADD_TOAST = 'auryo.ui.ADD_TOAST', + REMOVE_TOAST = '@@auryo.ui.REMOVE_TOAST', + CLEAR_TOASTS = '@@auryo.ui.CLEAR_TOASTS', + SET_DIMENSIONS = '@@auryo.ui.SET_DIMENSIONS', + SET_SEARCH_QUERY = '@@auryo.ui.SET_SEARCH_QUERY' } diff --git a/src/common/store/user/api.ts b/src/common/store/user/api.ts index 964ad0c7..00d48180 100644 --- a/src/common/store/user/api.ts +++ b/src/common/store/user/api.ts @@ -1,18 +1,16 @@ import fetchToJsonNew from '@common/api/helpers/fetchToJsonNew'; import { Collection, SoundCloud } from '@types'; -export async function fetchUser(options: { userId: string | number }) { - const json = await fetchToJsonNew({ +export function fetchUser(options: { userId: string | number }) { + return fetchToJsonNew({ uri: `users/${options.userId}`, oauthToken: true, useV2Endpoint: true }); - - return json; } -export async function fetchUserTopTracks(options: { userId: number | string; limit?: number }) { - const json = await fetchToJsonNew>({ +export function fetchUserTopTracks(options: { userId: number | string; limit?: number }) { + return fetchToJsonNew>({ uri: `users/${options.userId}/toptracks`, clientId: true, useV2Endpoint: true, @@ -21,11 +19,9 @@ export async function fetchUserTopTracks(options: { userId: number | string; lim linked_partitioning: 1 } }); - - return json; } -export async function fetchUserTracks(options: { userId: number | string; limit?: number }) { - const json = await fetchToJsonNew>({ +export function fetchUserTracks(options: { userId: number | string; limit?: number }) { + return fetchToJsonNew>({ uri: `users/${options.userId}/tracks`, clientId: true, useV2Endpoint: true, @@ -34,12 +30,10 @@ export async function fetchUserTracks(options: { userId: number | string; limit? linked_partitioning: 1 } }); - - return json; } -export async function fetchUserLikes(options: { userId: string | string; limit?: number }) { - const json = await fetchToJsonNew>({ +export function fetchUserLikes(options: { userId: string | string; limit?: number }) { + return fetchToJsonNew>({ uri: `users/${options.userId}/likes`, oauthToken: true, useV2Endpoint: true, @@ -48,16 +42,12 @@ export async function fetchUserLikes(options: { userId: string | string; limit?: linked_partitioning: 1 } }); - - return json; } -export async function fetchUserProfiles(options: { userUrn: string }) { - const json = await fetchToJsonNew({ +export function fetchUserProfiles(options: { userUrn: string }) { + return fetchToJsonNew({ uri: `users/${options.userUrn}/web-profiles`, oauthToken: true, useV2Endpoint: true }); - - return json; } diff --git a/src/common/store/user/epics.ts b/src/common/store/user/epics.ts index b66af32d..1d339286 100644 --- a/src/common/store/user/epics.ts +++ b/src/common/store/user/epics.ts @@ -1,30 +1,21 @@ import { normalizeArray } from '@common/schemas'; +import { handleEpicError } from '@common/utils/errors/EpicError'; import { EntitiesOf, SoundCloud } from '@types'; -import { AxiosError } from 'axios'; -import { from, of } from 'rxjs'; -import { catchError, filter, map, switchMap, tap, pluck } from 'rxjs/operators'; +import { defer, from, of } from 'rxjs'; +import { catchError, filter, map, pluck, switchMap, tap } from 'rxjs/operators'; import { isActionOf } from 'typesafe-actions'; import { RootEpic } from '../declarations'; import { getUser, getUserProfiles } from './actions'; import * as APIService from './api'; -const handleEpicError = (error: any) => { - if ((error as AxiosError).isAxiosError) { - console.log(error.message, error.response.data); - } else { - console.error('Epic error - user', error); - } - // TODO Sentry? - return error; -}; - export const getUserEpic: RootEpic = action$ => + // @ts-expect-error action$.pipe( filter(isActionOf(getUser.request)), tap(action => console.log(`${action.type} from ${process.type}`)), pluck('payload'), switchMap(({ userId, refresh }) => { - return from(APIService.fetchUser({ userId })).pipe( + return defer(() => from(APIService.fetchUser({ userId }))).pipe( map(user => normalizeArray([user])), map(data => getUser.success({ @@ -32,10 +23,10 @@ export const getUserEpic: RootEpic = action$ => entities: data.normalized.entities }) ), - catchError(error => - of( + catchError( + handleEpicError( + action$, getUser.failure({ - error: handleEpicError(error), userId }) ) @@ -45,12 +36,13 @@ export const getUserEpic: RootEpic = action$ => ); export const getUserProfilesEpic: RootEpic = action$ => + // @ts-expect-error action$.pipe( filter(isActionOf(getUserProfiles.request)), tap(action => console.log(`${action.type} from ${process.type}`)), pluck('payload'), switchMap(({ userUrn }) => { - return from(APIService.fetchUserProfiles({ userUrn })).pipe( + return defer(() => from(APIService.fetchUserProfiles({ userUrn }))).pipe( map(data => { const entities: EntitiesOf = { userProfileEntities: { @@ -63,10 +55,10 @@ export const getUserProfilesEpic: RootEpic = action$ => entities }); }), - catchError(error => - of( + catchError( + handleEpicError( + action$, getUserProfiles.failure({ - error: handleEpicError(error), userUrn }) ) diff --git a/src/common/store/user/types.ts b/src/common/store/user/types.ts index eb7b595a..cea8a728 100755 --- a/src/common/store/user/types.ts +++ b/src/common/store/user/types.ts @@ -1,19 +1,17 @@ -import { AxiosError } from 'axios'; - // TYPES export interface UserState { loading: number[]; - error: { [userId: string]: AxiosError | Error | null }; + error: { [userId: string]: Error | null }; userProfilesLoading: string[]; - userProfilesError: { [userId: string]: AxiosError | Error | null }; + userProfilesError: { [userId: string]: Error | null }; } // ACTIONS export enum UserActionTypes { - GET_USER = '@@user/GET_USER', - GET_USER_PROFILES = '@@user/GET_USER_PROFILES', + GET_USER = 'auryo.user.GET_USER', + GET_USER_PROFILES = 'auryo.user.GET_USER_PROFILES', - SET_PROFILES = '@@user/SET_PROFILES', - SET = '@@user/SET' + SET_PROFILES = 'auryo.user.SET_PROFILES', + SET = 'auryo.user.SET' } diff --git a/src/common/utils/errors/EpicError.ts b/src/common/utils/errors/EpicError.ts index 784027f1..d858c204 100644 --- a/src/common/utils/errors/EpicError.ts +++ b/src/common/utils/errors/EpicError.ts @@ -1,4 +1,11 @@ +import { logout, tokenRefresh } from '@common/store/actions'; +import { _RootAction } from '@common/store/declarations'; +import { Logger } from '@main/utils/logger'; +import { ActionsObservable } from 'redux-observable'; +import { EMPTY, ObservableInput, of } from 'rxjs'; +import { filter, mergeMapTo, startWith, take, takeUntil } from 'rxjs/operators'; import { serializeError } from 'serialize-error'; +import { isActionOf } from 'typesafe-actions'; export class EpicError extends Error { constructor(message: string) { @@ -10,3 +17,37 @@ export class EpicError extends Error { return serializeError(this); } } + +const logger = Logger.createLogger('EPIC'); + +export const handleEpicError = (action$: ActionsObservable<_RootAction>, actionOnFail?: A) => ( + error: any, + source: ObservableInput +) => { + // Refresh token if 401 + if (error.status === 401) { + // TODO: should have a retryCount + return action$.pipe( + filter(isActionOf(tokenRefresh.success)), + takeUntil(action$.pipe(filter(isActionOf([tokenRefresh.failure, logout])))), + take(1), + mergeMapTo(source), + startWith(tokenRefresh.request({})) + ); + } + + if (actionOnFail) { + logger.error(error); + + if ((actionOnFail as any).payload) { + (actionOnFail as any).payload.error = error; + } else { + (actionOnFail as any).payload = { error }; + } + + // TODO Sentry? + return of(actionOnFail); + } + + return of(EMPTY); +}; diff --git a/src/common/utils/ipc.ts b/src/common/utils/ipc.ts index 1cfed315..b67856f5 100644 --- a/src/common/utils/ipc.ts +++ b/src/common/utils/ipc.ts @@ -3,23 +3,7 @@ import { ipcRenderer } from 'electron'; import { EVENTS } from '../constants/events'; export class IPC { - static openExternal(url: string) { - ipcRenderer.send(EVENTS.APP.OPEN_EXTERNAL, url); - } - - static writeToClipboard(content: string) { - ipcRenderer.send(EVENTS.APP.WRITE_CLIPBOARD, content); - } - static downloadFile(url: string) { ipcRenderer.send(EVENTS.APP.DOWNLOAD_FILE, url); } - - static notifyTrackReposted() { - ipcRenderer.send(EVENTS.TRACK.REPOSTED); - } - - static notifyTrackLiked(trackId: number | string) { - ipcRenderer.send(EVENTS.TRACK.LIKED, trackId); - } } diff --git a/src/common/utils/playerUtils.ts b/src/common/utils/playerUtils.ts index 1f2f9090..d1fad002 100755 --- a/src/common/utils/playerUtils.ts +++ b/src/common/utils/playerUtils.ts @@ -1,14 +1,8 @@ -import { findIndex } from 'lodash'; -import { PlayerState, PlayerStatus, PlayingTrack } from '../store/types'; +import { isEqual } from 'lodash'; +import { ObjectStateItem } from '../store/types'; -export function isCurrentPlaylistPlaying(player: PlayerState, playlistId: string): boolean { - return player.currentPlaylistId === playlistId && player.status === PlayerStatus.PLAYING; -} +export const isMatchingObjectState = (a: ObjectStateItem, b: ObjectStateItem) => a.id === b.id && a.un === b.un; -export function getCurrentPosition(player: { playingTrack: PlayingTrack | null; queue: PlayingTrack[] }): number { - if (!player || !player.queue || !player.playingTrack) { - return -1; - } - - return findIndex(player.queue, player.playingTrack); -} +export const isMatchingObjectStateWithPlaylist = (a: ObjectStateItem, b: ObjectStateItem) => { + return isEqual(b.parentPlaylistID, a.parentPlaylistID) && isMatchingObjectState(a, b); +}; diff --git a/src/common/utils/reduxUtils.ts b/src/common/utils/reduxUtils.ts index 06ca8b50..5f6cb098 100755 --- a/src/common/utils/reduxUtils.ts +++ b/src/common/utils/reduxUtils.ts @@ -1,17 +1,3 @@ -import { ActionType } from 'redux-promise-middleware'; - -export function isLoading(actionType: string): string { - return `${actionType}_${ActionType.Pending}`; -} - -export function onSuccess(actionType: string): string { - return `${actionType}_${ActionType.Fulfilled}`; -} - -export function onError(actionType: string): string { - return `${actionType}_${ActionType.Rejected}`; -} - export function wSuccess(actionType: string): typeof actionType { return `${actionType}_SUCCESS`; } @@ -19,6 +5,9 @@ export function wSuccess(actionType: string): typeof actionType { export function wError(actionType: string): typeof actionType { return `${actionType}_ERROR`; } +export function wCancel(actionType: string): typeof actionType { + return `${actionType}_CANCEL`; +} export function wDebounce(actionType: string) { return `${actionType}_DEBOUNCE`; } diff --git a/src/common/utils/soundcloudUtils.ts b/src/common/utils/soundcloudUtils.ts index 7fc9873a..e832bb3e 100755 --- a/src/common/utils/soundcloudUtils.ts +++ b/src/common/utils/soundcloudUtils.ts @@ -417,7 +417,7 @@ export function getImageUrl(track: any, size: string) { */ export function hasID(id: any, object: any) { - return object && object[id] && object[id] === true; + return object?.[id] === true; } export function isStreamable(track: any) { diff --git a/src/config.ts b/src/config.ts index 8697625b..d6049baa 100755 --- a/src/config.ts +++ b/src/config.ts @@ -30,6 +30,9 @@ export const CONFIG = { // Auth AWS_API_URL: 'https://auth-api.auryo.com', + AURYO_API_URL: 'https://api.auryo.com', + AURYO_API_CALLBACK_URL: 'https://api.auryo.com/callback', + AURYO_API_TOKEN_URL: 'https://api.auryo.com/oauth2/token', // Config diff --git a/src/globals.d.ts b/src/globals.d.ts index ccafd3f8..b00b5147 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -5,6 +5,8 @@ declare namespace NodeJS { interface Global { __static: any; + fetch: any; + AbortController: any; } interface ProcessEnv { readonly NODE_ENV: 'development' | 'production' | 'test'; diff --git a/src/main/app.ts b/src/main/app.ts index 814b7143..ae43b21c 100755 --- a/src/main/app.ts +++ b/src/main/app.ts @@ -1,12 +1,12 @@ import { Intent } from '@blueprintjs/core'; import fetchTrack from '@common/api/fetchTrack'; -import { axiosClient } from '@common/api/helpers/axiosClient'; -import { addToast, push, setConfigKey } from '@common/store/actions'; +import { addToast, push, receiveProtocolAction } from '@common/store/actions'; // eslint-disable-next-line import/no-unresolved import { StoreState } from 'AppReduxTypes'; +import Axios from 'axios'; // eslint-disable-next-line import/no-extraneous-dependencies import { app, BrowserWindow, BrowserWindowConstructorOptions, Event, Menu, nativeImage, shell } from 'electron'; -import is from 'electron-is'; +import { stopForwarding } from 'electron-redux'; import windowStateKeeper from 'electron-window-state'; import _ from 'lodash'; import * as os from 'os'; @@ -39,49 +39,19 @@ export class Auryo { public quitting = false; private readonly logger: LoggerInstance = Logger.createLogger(Auryo.name); - constructor() { - app.setAppUserModelId('com.auryo.core'); - - app.on('before-quit', () => { - this.logger.info('Application exiting...'); - this.quitting = true; - }); - - const isPrimaryInstance = app.requestSingleInstanceLock(); - - if (!isPrimaryInstance) { - this.logger.debug(`Not the first instance - quit`); - app.quit(); - return; - } - - app.on('second-instance', () => { - // handle protocol for windows - if (is.windows()) { - process.argv.slice(1).forEach(arg => { - this.handleProtocolUrl(arg); - }); - } - }); + constructor(mainStore: Store) { + this.store = mainStore; } public setStore(store: Store) { this.store = store; } - public async start() { - if (this.quitting) { - return; - } - - app.setAsDefaultProtocolClient('auryo'); - - app.on('open-url', (event, data) => { - event.preventDefault(); - - this.handleProtocolUrl(data); - }); + public setQuitting(quitting: boolean) { + this.quitting = quitting; + } + public async start() { const mainWindowState = windowStateKeeper({ defaultWidth: 1190, defaultHeight: 728 @@ -103,7 +73,9 @@ export class Auryo { webPreferences: { nodeIntegration: true, nodeIntegrationInWorker: true, - webSecurity: process.env.NODE_ENV !== 'development' + webSecurity: process.env.NODE_ENV !== 'development', + contextIsolation: false, // We recommend enabling contextIsolation for security. + enableRemoteModule: true // Maybe wecan work this out later } }; @@ -151,28 +123,17 @@ export class Auryo { this.logger.info('App started'); } - private handleProtocolUrl(url: string) { - const action = url.replace('auryo://', '').match(/^.*(?=\?.*)/g); + public handleProtocolUrl(url: string) { + if (!url) return; - if (action && url.split('?').length) { - const result = querystring.parse(url.split('?')[1]); + const { action, search } = url.match(/auryo:\/\/(?\w*)\/?\?(?.*)/)?.groups ?? {}; + const params = search ? (querystring.parse(search) as Record) : {}; - switch (action[0]) { - case 'launch': - if (result.client_id && result.client_id.length) { - this.store.dispatch(setConfigKey('app.overrideClientId', result.client_id)); + if (!action) return; - this.store.dispatch( - addToast({ - message: `New clientId added`, - intent: Intent.SUCCESS - }) - ); - } - break; - default: - } - } + this.logger.debug('handleProtocolUrl', { action, params }); + + this.store.dispatch(stopForwarding(receiveProtocolAction({ action, params }))); } private registerTools() { @@ -316,7 +277,7 @@ export class Auryo { return null; } - const response = await axiosClient(`${streamUrl}?client_id=${clientId}`); + const response = await Axios.get(`${streamUrl}?client_id=${clientId}`); const mp3Url = response.data.url; return mp3Url; @@ -324,8 +285,8 @@ export class Auryo { private readonly registerListeners = () => { if (this.mainWindow) { - this.mainWindow.webContents.on('crashed', (event: Event) => { - this.logger.fatal('App Crashed', event); + this.mainWindow.webContents.on('render-process-gone', (event: Event) => { + this.logger.fatal('Render process gone', event); }); this.mainWindow.on('unresponsive', (event: Event) => { diff --git a/src/main/aws/awsApiGatewayService.ts b/src/main/aws/awsApiGatewayService.ts deleted file mode 100644 index d7a3fc0a..00000000 --- a/src/main/aws/awsApiGatewayService.ts +++ /dev/null @@ -1,84 +0,0 @@ -import * as aws4 from 'aws4'; -import axios, { AxiosRequestConfig, Method } from 'axios'; -import { CONFIG } from '../../config'; -// eslint-disable-next-line import/no-cycle -import { Logger, LoggerInstance } from '../utils/logger'; -// eslint-disable-next-line import/no-cycle -import { TokenResponse } from './awsIotService'; - -export interface GetKeysResponse { - iotEndpoint: string; - region: string; - accessKeyId: string; - secretAccessKey: string; - sessionToken: string; - identityId: string; -} - -export class AWSApiGatewayService { - public logger: LoggerInstance = Logger.createLogger(AWSApiGatewayService.name); - private keys: GetKeysResponse; - - public async getKeys() { - this.keys = await axios.get(`${CONFIG.AWS_API_URL}/iot/keys`).then(res => res.data); - - return this.keys; - } - - public async refresh(refreshToken: string, provider: string) { - await this.getKeys(); - - return this.performRequest(`/auth/refresh/${provider}`, 'POST', { refreshToken }).then( - res => res.data - ); - } - - public async performRequest(path: string, method: Method = 'GET', body?: object) { - const signedRequest = this.prepareRequest(path, method, body); - - return axios.request({ - ...signedRequest, - data: body - }); - } - - public prepareRequest( - path: string, - method: Method = 'GET', - body?: object - ): { host: string; path: string; headers: any } { - const host = CONFIG.AWS_API_URL.match(/[a-z0-9.-]*.com/g); - - const request: Partial = { - host: host && host.length ? host[0] : '', - method, - url: CONFIG.AWS_API_URL + path, - path: `${CONFIG.AWS_API_URL}${path}`.split('auryo.com')[1], - headers: {} - }; - - if (body) { - request.body = JSON.stringify(body); - request.data = body; - request.headers['Content-Type'] = 'application/json'; - } - - const signedRequest = aws4.sign( - { - ...request, - region: 'eu-west-1', - service: 'execute-api' - }, - { - secretAccessKey: this.keys.secretAccessKey, - accessKeyId: this.keys.accessKeyId, - sessionToken: this.keys.sessionToken - } - ); - - delete signedRequest.headers.Host; - delete signedRequest.headers['Content-Length']; - - return signedRequest; - } -} diff --git a/src/main/aws/awsIotService.ts b/src/main/aws/awsIotService.ts deleted file mode 100644 index 6c3a95e0..00000000 --- a/src/main/aws/awsIotService.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* eslint-disable camelcase */ -import * as awsIot from 'aws-iot-device-sdk'; -// eslint-disable-next-line import/no-cycle -import { Logger, LoggerInstance } from '../utils/logger'; -// eslint-disable-next-line import/no-cycle -import { GetKeysResponse } from './awsApiGatewayService'; - -export interface AuthMessage { - success: boolean; - token?: TokenResponse; - error?: { - error: string; - error_description: string; - }; -} - -export interface TokenResponse { - access_token: string; - expires_at?: number; - refresh_token?: string; -} - -export class AWSIotService { - public logger: LoggerInstance = Logger.createLogger(AWSIotService.name); - public device: awsIot.device; - private readonly identityId: string = ''; - - constructor(getKeysResponse: GetKeysResponse) { - // eslint-disable-next-line new-cap - this.device = new awsIot.device({ - region: getKeysResponse.region, - protocol: 'wss', - // debug: true, - clientId: getKeysResponse.identityId, - accessKeyId: getKeysResponse.accessKeyId, - secretKey: getKeysResponse.secretAccessKey, - sessionToken: getKeysResponse.sessionToken, - port: 443, - host: getKeysResponse.iotEndpoint - }); - - if (getKeysResponse) { - this.identityId = getKeysResponse.identityId || ''; - } - - this.device.on('error', () => { - this.logger.error('Error with mqtt'); - }); - } - - public async connect() { - return new Promise((resolve, reject) => { - this.device.on('connect', async () => { - this.logger.debug('Connected to MQTT'); - resolve(); - }); - this.device.on('close', async () => { - reject(new Error('Disconnected from mqtt')); - }); - }); - } - - public async disconnect() { - return new Promise(resolve => { - this.device.end(true, () => { - resolve(); - }); - }); - } - - public async subscribe(topic: string, options?: any) { - return new Promise((resolve, reject) => { - this.device.subscribe(this.identityId + topic, options, (err, granted) => { - if (err) { - reject(err); - - return; - } - - this.logger.debug(`Subscribed to ${this.identityId + topic}`); - - resolve(granted); - }); - this.device.on('close', async () => { - reject(new Error('Disconnected from mqtt')); - }); - }); - } - - public async waitForMessageOrTimeOut(timeoutMs = 300000) { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Login timeout')); - }, timeoutMs); - this.device.on('message', (_, payload) => { - clearTimeout(timeout); - - const message: AuthMessage = JSON.parse(payload.toString('utf8')); - - if (message.success) { - resolve(message.token); - } else { - reject( - new Error( - message.error ? `${message.error.error}: ${message.error.error_description}` : 'Error during login' - ) - ); - } - }); - }); - } -} diff --git a/src/main/features/core/applicationMenu.ts b/src/main/features/core/applicationMenu.ts index b61fe676..96a3ec2a 100755 --- a/src/main/features/core/applicationMenu.ts +++ b/src/main/features/core/applicationMenu.ts @@ -1,11 +1,12 @@ import { EVENTS } from '@common/constants/events'; -import { push, setConfigKey, toggleStatus, changeTrack } from '@common/store/actions'; +import { changeTrack, push, setConfigKey, toggleLike, toggleRepost, toggleStatus } from '@common/store/actions'; import { ChangeTypes, PlayerStatus, VolumeChangeTypes } from '@common/store/player'; +import { getPlayingTrackSelector } from '@common/store/selectors'; import * as SC from '@common/utils/soundcloudUtils'; import { autobind } from 'core-decorators'; // eslint-disable-next-line import/no-extraneous-dependencies import { app, Menu, MenuItemConstructorOptions, shell } from 'electron'; -import * as is from 'electron-is'; +import is from 'electron-is'; import { Feature } from '../feature'; @autobind @@ -16,24 +17,22 @@ export default class ApplicationMenu extends Feature { } public register() { - this.on(EVENTS.APP.READY, () => { - this.buildMenu(); + this.buildMenu(); - this.subscribe(['player', 'playingTrack'], () => { - this.buildMenu(); - }); + this.observables.trackChanged.subscribe(() => { + this.buildMenu(); + }); - this.subscribe(['player', 'status'], () => { - this.buildMenu(); - }); + this.observables.statusChanged.subscribe(() => { + this.buildMenu(); + }); - this.on(EVENTS.TRACK.LIKED, () => { - this.buildMenu(); - }); + this.observables.playingTrackLikeChanged.subscribe(() => { + this.buildMenu(); + }); - this.on(EVENTS.TRACK.REPOSTED, () => { - this.buildMenu(); - }); + this.observables.playingTrackRepostChanged.subscribe(() => { + this.buildMenu(); }); } @@ -109,9 +108,8 @@ export default class ApplicationMenu extends Feature { submenu: [ { label: !player || player.status !== PlayerStatus.PLAYING ? 'Play' : 'Pause', - accelerator: 'Space', registerAccelerator: false, - click: () => this.store.dispatch(toggleStatus() as any) + click: () => this.store.dispatch(toggleStatus()) }, { type: 'separator' @@ -119,12 +117,12 @@ export default class ApplicationMenu extends Feature { { label: 'Next', accelerator: 'CmdOrCtrl+Right', - click: () => this.store.dispatch(changeTrack(ChangeTypes.NEXT) as any) + click: () => this.store.dispatch(changeTrack(ChangeTypes.NEXT)) }, { label: 'Previous', accelerator: 'CmdOrCtrl+Left', - click: () => this.store.dispatch(changeTrack(ChangeTypes.PREV) as any) + click: () => this.store.dispatch(changeTrack(ChangeTypes.PREV)) }, { type: 'separator' @@ -148,13 +146,7 @@ export default class ApplicationMenu extends Feature { label: 'Like', accelerator: 'CmdOrCtrl+L', click: () => { - const { - player: { playingTrack } - } = this.store.getState(); - - if (playingTrack) { - this.sendToWebContents(EVENTS.TRACK.LIKE, playingTrack.id); - } + this.store.dispatch(toggleLike.request({})); }, enabled: false }, @@ -162,13 +154,7 @@ export default class ApplicationMenu extends Feature { label: 'Repost', accelerator: 'CmdOrCtrl+S', click: () => { - const { - player: { playingTrack } - } = this.store.getState(); - - if (playingTrack) { - this.sendToWebContents(EVENTS.TRACK.REPOST, playingTrack.id); - } + this.store.dispatch(toggleRepost.request({})); }, enabled: false } diff --git a/src/main/features/core/chromecast/chromecastManager.ts b/src/main/features/core/chromecast/chromecastManager.ts index 83062d3f..6235afcc 100755 --- a/src/main/features/core/chromecast/chromecastManager.ts +++ b/src/main/features/core/chromecast/chromecastManager.ts @@ -4,7 +4,7 @@ import { IMAGE_SIZES } from '@common/constants'; import { EVENTS } from '@common/constants/events'; import { StoreState } from 'AppReduxTypes'; import { DevicePlayerStatus } from '@common/store/app'; -import { getTrackEntity } from '@common/store/selectors'; +import { getQueuePlaylistSelector, getTrackEntity } from '@common/store/selectors'; import { PlayerStatus } from '@common/store/player'; import { SC } from '@common/utils'; import { Logger, LoggerInstance } from '@main/utils/logger'; @@ -104,11 +104,10 @@ export default class ChromecastManager extends Feature { } ); - this.subscribe(['player', 'playingTrack'], async ({ currentState }) => { + this.observables.trackChanged.subscribe(async ({ store }) => { + if (!(this.client && this.player)) return; try { - if (this.client && this.player) { - await this.startTrack(currentState); - } + await this.startTrack(store); } catch (err) { this.logger.error(err); throw err; @@ -117,10 +116,10 @@ export default class ChromecastManager extends Feature { // Handle volume change this.subscribe(['config', 'audio', 'volume'], async ({ currentValue }: WatchState) => { + if (!this.client) return; + try { - if (this.client) { - await this.client.setVolume({ level: currentValue }); - } + await this.client.setVolume({ level: currentValue }); } catch (err) { this.logger.error(err); throw err; @@ -129,10 +128,10 @@ export default class ChromecastManager extends Feature { // Handle mute this.subscribe(['config', 'audio', 'muted'], async ({ currentValue }: WatchState) => { + if (!this.client) return; + try { - if (this.client) { - await this.client.setVolume({ muted: currentValue }); - } + await this.client.setVolume({ muted: currentValue }); } catch (err) { this.logger.error(err); throw err; @@ -140,35 +139,35 @@ export default class ChromecastManager extends Feature { }); // Handle status change - this.subscribe(['player', 'status'], async ({ currentValue }: WatchState) => { + this.observables.statusChanged.subscribe(async ({ value: playerStatus }) => { + if (!this.player) return; + try { - if (this.player) { - const status: any = await this.player.getStatus(); + const status: any = await this.player.getStatus(); - if (status) { - const deviceStatus = status.playerState as DevicePlayerStatus; + if (status) { + const deviceStatus = status.playerState as DevicePlayerStatus; - switch (currentValue) { - case PlayerStatus.PAUSED: { - if (deviceStatus !== DevicePlayerStatus.PAUSED) { - await this.player.pause(); - } - break; + switch (playerStatus) { + case PlayerStatus.PAUSED: { + if (deviceStatus !== DevicePlayerStatus.PAUSED) { + await this.player.pause(); } - case PlayerStatus.PLAYING: { - if (deviceStatus !== DevicePlayerStatus.PLAYING) { - await this.player.play(); - } - break; + break; + } + case PlayerStatus.PLAYING: { + if (deviceStatus !== DevicePlayerStatus.PLAYING) { + await this.player.play(); } - case PlayerStatus.STOPPED: { - if (deviceStatus !== DevicePlayerStatus.IDLE) { - await this.player.stop(); - } - break; + break; + } + case PlayerStatus.STOPPED: { + if (deviceStatus !== DevicePlayerStatus.IDLE) { + await this.player.stop(); } - default: + break; } + default: } } } catch (err) { @@ -281,7 +280,7 @@ export default class ChromecastManager extends Feature { private async startTrack(state: StoreState, fromCurrentTime = false) { const { - player: { playingTrack, currentTime, status, currentIndex, queue }, + player: { playingTrack, currentTime, status, currentIndex }, config: { app: { overrideClientId } } @@ -290,7 +289,8 @@ export default class ChromecastManager extends Feature { if (playingTrack && this.player) { const trackId = playingTrack.id; const track = getTrackEntity(trackId)(state); - const nextTrackId = queue[currentIndex + 1]; + const queue = getQueuePlaylistSelector(state); + const nextTrackId = queue.items[currentIndex + 1]; const nextTrack = nextTrackId && nextTrackId.id ? getTrackEntity(nextTrackId.id)(state) : null; if (track) { diff --git a/src/main/features/core/configManager.ts b/src/main/features/core/configManager.ts index 896b6c10..a96a89ac 100755 --- a/src/main/features/core/configManager.ts +++ b/src/main/features/core/configManager.ts @@ -1,45 +1,18 @@ -import { EVENTS } from '@common/constants/events'; -import { canGoInHistory } from '@common/store/app/actions'; import { Config } from '@common/store/config'; import { setConfig } from '@common/store/config/actions'; // eslint-disable-next-line import/no-extraneous-dependencies -import { app, session } from 'electron'; +import { app } from 'electron'; import _ from 'lodash'; -import isDeepEqual from 'react-fast-compare'; -import { show } from 'redux-modal'; import * as semver from 'semver'; import { CONFIG } from '../../../config'; -import { Auryo } from '../../app'; import { settings } from '../../settings'; -import { Logger, LoggerInstance } from '../../utils/logger'; -import { Utils } from '../../utils/utils'; -import { Feature, WatchState } from '../feature'; +import { Feature } from '../feature'; export default class ConfigManager extends Feature { public readonly featureName = 'ConfigManager'; - private readonly logger: LoggerInstance = Logger.createLogger(this.featureName); - private isNewVersion = false; - private isNewUser = false; - private readonly writetoConfig: (config: Config) => void; private config: Config = CONFIG.DEFAULT_CONFIG; - constructor(auryo: Auryo) { - super(auryo); - - this.writetoConfig = _.debounce( - (config: Config) => { - if (!isDeepEqual(settings.store, config)) { - settings.set(config as any); - } - }, - 250, - { - leading: true - } - ); - } - public async register() { try { this.config = settings.store as any; @@ -49,81 +22,13 @@ export default class ConfigManager extends Feature { if (this.config.version === undefined) { this.config.version = app.getVersion(); - this.isNewUser = true; } else if (semver.lt(this.config.version, app.getVersion())) { this.config.version = app.getVersion(); - this.isNewVersion = true; } // fill out default values if config is incomplete this.config = _.defaultsDeep(this.config, CONFIG.DEFAULT_CONFIG); - if (this.config.enableProxy && this.config.proxy.host) { - this.logger.info('Enabling proxy'); - - if (session.defaultSession) { - await session.defaultSession.setProxy({ - proxyRules: Utils.getProxyUrlFromConfig(this.config.proxy), - pacScript: '', - proxyBypassRules: '' - }); - - if (session.defaultSession) { - const proxy = await session.defaultSession.resolveProxy('https://api.soundcloud.com'); - - this.logger.info(`Proxy status: ${proxy}`); - - if (!proxy && session.defaultSession) { - await session.defaultSession.setProxy({ - proxyRules: '', - pacScript: '', - proxyBypassRules: '' - }); - - this.logger.error('Failed to initialize proxy'); - } - } - } - } - - this.writetoConfig(this.config); this.store.dispatch(setConfig(this.config)); - - this.on(EVENTS.APP.READY, () => { - this.notifyNewVersion(); - this.on(EVENTS.APP.NAVIGATE, this.checkCanGo); - this.subscribe(['config'], this.updateConfig); - }); } - - /** - * Write new values to the config file - */ - public updateConfig = ({ currentValue }: WatchState) => { - this.writetoConfig(currentValue); - }; - - /** - * On route change, check if can Go from browser webcontents - */ - public checkCanGo = () => { - if (this.win && this.win.webContents) { - const back = this.win.webContents.canGoBack(); - const next = this.win.webContents.canGoForward(); - - this.store.dispatch(canGoInHistory({ back, next })); - } - }; - - /** - * If version doesn't match config version, send update to frontend on app loaded - */ - public notifyNewVersion = () => { - if (this.isNewVersion && !this.isNewUser && !process.env.TOKEN) { - setTimeout(() => { - this.store.dispatch(show('changelog', { version: app.getVersion() })); - super.unregister(['app', 'loaded']); - }, 5000); - } - }; } diff --git a/src/main/features/core/ipcManager.ts b/src/main/features/core/ipcManager.ts index 6de1aed4..c04a71fb 100755 --- a/src/main/features/core/ipcManager.ts +++ b/src/main/features/core/ipcManager.ts @@ -1,14 +1,9 @@ import { EVENTS } from '@common/constants/events'; -import { login, loginError, loginTerminated, refreshToken, loginSuccess } from '@common/store/appAuth/actions'; -import { createAuthWindow } from '@main/authWindow'; -import { AWSApiGatewayService } from '@main/aws/awsApiGatewayService'; -import { AWSIotService } from '@main/aws/awsIotService'; import { autobind } from 'core-decorators'; // eslint-disable-next-line import/no-extraneous-dependencies -import { app, clipboard, dialog, ipcMain, shell } from 'electron'; +import { app, dialog, ipcMain } from 'electron'; import { download } from 'electron-dl'; import _ from 'lodash'; -import { CONFIG } from '../../../config'; import { Logger, LoggerInstance } from '../../utils/logger'; import { Feature } from '../feature'; @@ -18,8 +13,6 @@ export default class IPCManager extends Feature { public authWindow: Electron.BrowserWindow | null = null; private readonly logger: LoggerInstance = Logger.createLogger(this.featureName); - private readonly awsApiGateway: AWSApiGatewayService = new AWSApiGatewayService(); - // tslint:disable-next-line: max-func-body-length public register() { ipcMain.on(EVENTS.APP.VALID_DIR, async () => { @@ -30,11 +23,6 @@ export default class IPCManager extends Feature { } }); - ipcMain.on(EVENTS.APP.RESTART, () => { - app.relaunch({ args: process.argv.slice(1).concat(['--relaunch']) }); - app.exit(0); - }); - ipcMain.on(EVENTS.APP.RAISE, () => { if (this.win) { this.win.focus(); @@ -46,18 +34,6 @@ export default class IPCManager extends Feature { } }); - ipcMain.on(EVENTS.APP.OPEN_EXTERNAL, async (_e, arg) => { - try { - await shell.openExternal(arg); - } catch (err) { - this.logger.error(err); - } - }); - - ipcMain.on(EVENTS.APP.WRITE_CLIPBOARD, (_e, arg: string) => { - clipboard.writeText(arg); - }); - ipcMain.on(EVENTS.APP.DOWNLOAD_FILE, (_e, url: string) => { const { config } = this.store.getState(); @@ -73,121 +49,5 @@ export default class IPCManager extends Feature { .catch(this.logger.error); } }); - - ipcMain.on(EVENTS.APP.AUTH.LOGIN, this.showAuthWindow); - - ipcMain.handle(EVENTS.APP.AUTH.REFRESH, this.refreshToken); - } - - private async showAuthWindow() { - const { appAuth } = this.store.getState(); - let awsIotWrapper: AWSIotService | undefined; - - if (appAuth.isLoading) { - this.logger.debug('Already loading'); - return; - } - - try { - this.store.dispatch(login()); - - this.logger.debug('Starting login'); - - this.authWindow = createAuthWindow(); - - this.authWindow.on('close', () => { - this.store.dispatch(loginTerminated()); - }); - - const getKeysResponse = await this.awsApiGateway.getKeys(); - - awsIotWrapper = new AWSIotService(getKeysResponse); - - await awsIotWrapper.connect(); - - await awsIotWrapper.subscribe('/oauth/token'); - - const path = `/auth/signin/soundcloud`; - const signedRequest = this.awsApiGateway.prepareRequest(path); - - await this.authWindow.loadURL(`${CONFIG.AWS_API_URL}${path}`, { - extraHeaders: Object.keys(signedRequest.headers).reduce( - (prevString, headerName) => `${prevString}${headerName}: ${signedRequest.headers[headerName]}\n`, - '' - ) - }); - - // tslint:disable-next-line: no-unnecessary-local-variable - const tokenResponse = await awsIotWrapper.waitForMessageOrTimeOut(); - - if (tokenResponse) { - this.logger.debug('Auth successfull'); - - this.store.dispatch(loginSuccess(tokenResponse)); - await awsIotWrapper.disconnect(); - } - this.authWindow.close(); - } catch (err) { - if (this.authWindow?.isClosable()) { - this.authWindow.close(); - } - if (awsIotWrapper) { - try { - await awsIotWrapper.disconnect(); - } catch (_e) { - // don't handle - } - } - - this.store.dispatch(loginError('Something went wrong during login')); - this.logger.error(err); - - throw err; - } - - this.authWindow = null; - } - - private async refreshToken() { - const { - config: { - auth: { refreshToken: token } - }, - appAuth - } = this.store.getState(); - - if (appAuth.isLoading) { - return null; - } - - if (!token) { - this.logger.debug('Refreshtoken not found'); - this.showAuthWindow().catch(this.logger.error); - - return null; - } - - try { - this.logger.debug('Starting refresh'); - - const tokenResponse = await this.awsApiGateway.refresh(token, 'soundcloud'); - - if (tokenResponse) { - this.logger.debug('Auth successfull'); - - this.store.dispatch(refreshToken(tokenResponse)); - - return { - token: tokenResponse.access_token - }; - } - } catch (err) { - this.store.dispatch(loginError('Something went wrong during refresh')); - this.logger.error(err); - - this.showAuthWindow().catch(this.logger.error); - } - - return null; } } diff --git a/src/main/features/core/lastFm.ts b/src/main/features/core/lastFm.ts index 09416d70..f41400cc 100755 --- a/src/main/features/core/lastFm.ts +++ b/src/main/features/core/lastFm.ts @@ -1,8 +1,7 @@ import { Intent } from '@blueprintjs/core'; import { EVENTS } from '@common/constants/events'; -import { getTrackEntity } from '@common/store/selectors'; import { addToast, setConfigKey, setLastfmLoading } from '@common/store/actions'; -import { SC } from '@common/utils'; +import { getTrackEntity } from '@common/store/selectors'; import { Auryo } from '@main/app'; import { Logger, LoggerInstance } from '@main/utils/logger'; import { autobind } from 'core-decorators'; @@ -12,8 +11,9 @@ import * as Lastfm from 'lastfm'; import { debounce } from 'lodash'; import { CONFIG } from '../../../config'; import { SoundCloud } from '../../../types'; -import { Feature, WatchState } from '../feature'; +import { Feature } from '../feature'; +// TODO: Can we rewrite this in epics? @autobind export default class LastFm extends Feature { public readonly featureName = 'LastFm'; @@ -39,96 +39,89 @@ export default class LastFm extends Feature { this.logger.error(err); } - // tslint:disable-next-line: max-func-body-length - this.on(EVENTS.APP.READY, () => { - // Authorize - this.on(EVENTS.APP.LASTFM.AUTH, async () => { - try { - await this.getLastFMSession(true); - } catch (err) { - this.logger.error(err); - } - }); + this.observables.trackChanged.subscribe(async ({ value: track, store }) => { + try { + const config = store.config.lastfm; - // track change - this.subscribe(['player', 'playingTrack'], async ({ currentState }) => { - try { - const { - player: { playingTrack }, - config: { lastfm } - } = currentState; - - if (playingTrack && lastfm && lastfm.key) { - const trackId = playingTrack.id; - const track = getTrackEntity(trackId)(currentState); - - if (track) { - const [artist, title] = this.cleanInfo(track); - await this.updateNowPlaying(title, artist); - } - } - } catch (err) { - this.logger.error(err); - throw err; - } - }); + if (!config?.key) return; - // like - this.on(EVENTS.TRACK.LIKED, async (args: any[]) => { - try { - const currentState = this.store.getState(); + if (track) { + const [artist, title] = this.cleanInfo(track); + await this.updateNowPlaying(title, artist); + } + } catch (err) { + this.logger.error(err); + throw err; + } + }); - const { - config: { lastfm }, - auth: { likes } - } = currentState; + // Scrobble + this.observables.playerCurrentTimeChanged.subscribe(async ({ store }) => { + try { + const { + player: { playingTrack, duration, currentTime }, + config: { lastfm } + } = store; - const trackId = args[0]; + if (!playingTrack || !lastfm?.key) return; - if (trackId && lastfm && lastfm.key) { - const track = getTrackEntity(trackId)(currentState); + const trackId = playingTrack.id; + const track = getTrackEntity(trackId)(store); - if (track) { - const liked = SC.hasID(track.id, likes.track); + const shouldScrobble = + duration > 30 && // should be longer than 30s according to lastfm + (currentTime / duration > 0.5 || // should have exceeded 1/2 of the song + currentTime > 60 * 4); // or passed 4 minutes, whichever comes first - const [artist, title] = this.cleanInfo(track); - await this.updateLiked(liked, title, artist); - } - } - } catch (err) { - this.logger.error(err); - throw err; + if (track && shouldScrobble) { + const [artist, title] = this.cleanInfo(track); + await this.scrobbleDebounced(title, artist, Math.round(Date.now() / 1000 - currentTime)); } - }); + } catch (err) { + this.logger.error(err); + throw err; + } + }); - // scrobble - this.subscribe(['player', 'currentTime'], async ({ currentState }: WatchState) => { + // tslint:disable-next-line: max-func-body-length + this.on(EVENTS.APP.READY, () => { + // Authorize + this.on(EVENTS.APP.LASTFM.AUTH, async () => { try { - const { - player: { playingTrack, duration, currentTime }, - config: { lastfm } - } = currentState; - - if (playingTrack && lastfm && lastfm.key) { - const trackId = playingTrack.id; - const track = getTrackEntity(trackId)(currentState); - - if (track) { - if ( - duration > 30 && // should be longer than 30s according to lastfm - (currentTime / duration > 0.5 || // should have exceeded 1/2 of the song - currentTime > 60 * 4) // or passed 4 minutes, whichever comes first - ) { - const [artist, title] = this.cleanInfo(track); - await this.scrobbleDebounced(title, artist, Math.round(Date.now() / 1000 - currentTime)); - } - } - } + await this.getLastFMSession(true); } catch (err) { this.logger.error(err); - throw err; } }); + + // like + // TODO: can be done in an epic? + // this.on(EVENTS.TRACK.LIKED, async (args: any[]) => { + // try { + // const currentState = this.store.getState(); + + // const { + // config: { lastfm }, + // auth: { likes } + // } = currentState; + + // const trackId = args[0]; + + // if (trackId && lastfm && lastfm.key) { + // const track = getTrackEntity(trackId)(currentState); + + // if (track) { + // const liked = SC.hasID(track.id, likes.track); + + // const [artist, title] = this.cleanInfo(track); + // await this.updateLiked(liked, title, artist); + // } + // } + // } catch (err) { + // this.logger.error(err); + // throw err; + // } + // }); }); } @@ -257,7 +250,7 @@ export default class LastFm extends Feature { config: { lastfm: lastfmConfig } } = this.store.getState(); - if (lastfmConfig && lastfmConfig.user && lastfmConfig.key) { + if (lastfmConfig?.user && lastfmConfig?.key) { return this.lastfm.session(lastfmConfig.user, lastfmConfig.key); } diff --git a/src/main/features/core/notificationManager.ts b/src/main/features/core/notificationManager.ts deleted file mode 100755 index d2b1dd24..00000000 --- a/src/main/features/core/notificationManager.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { IMAGE_SIZES } from '@common/constants'; -import { EVENTS } from '@common/constants/events'; -import { getTrackEntity } from '@common/store/selectors'; -import { PlayingTrack } from '@common/store/player'; -import { SC } from '@common/utils'; -import { Auryo } from '@main/app'; -import { Feature } from '../feature'; - -export default class NotificationManager extends Feature { - public readonly featureName = 'NotificationManager'; - constructor(auryo: Auryo) { - super(auryo, 'ready-to-show'); - } - - public register() { - // Track changed - this.subscribe(['player', 'playingTrack'], ({ currentState }) => { - if (!this.win || (this.win && this.win.isFocused())) { - return; - } - - const { - player: { playingTrack }, - config: { - app: { showTrackChangeNotification } - } - } = currentState; - - if (playingTrack && showTrackChangeNotification) { - const trackId = playingTrack.id; - const track = getTrackEntity(trackId)(currentState); - - if (track) { - this.sendToWebContents(EVENTS.APP.SEND_NOTIFICATION, { - title: track.title, - message: `${track.user && track.user.username ? track.user.username : ''}`, - image: SC.getImageUrl(track, IMAGE_SIZES.SMALL) - }); - } - } - }); - } -} diff --git a/src/main/features/core/powerMonitor.ts b/src/main/features/core/powerMonitor.ts index 4d4ecc44..4c6b9b5a 100755 --- a/src/main/features/core/powerMonitor.ts +++ b/src/main/features/core/powerMonitor.ts @@ -1,5 +1,6 @@ -import { PlayerStatus } from '@common/store/player'; import { toggleStatus } from '@common/store/actions'; +import { PlayerStatus } from '@common/store/player'; +import { autobind } from 'core-decorators'; // eslint-disable-next-line import/no-extraneous-dependencies import { powerMonitor } from 'electron'; import { Feature } from '../feature'; @@ -7,15 +8,16 @@ import { Feature } from '../feature'; /** * Pause music on power down or sleep */ +@autobind export default class PowerMonitor extends Feature { public readonly featureName = 'PowerMonitor'; public register() { powerMonitor.on('suspend', this.pause); } - public pause = () => { - this.store.dispatch(toggleStatus(PlayerStatus.PAUSED) as any); - }; + public pause() { + this.store.dispatch(toggleStatus(PlayerStatus.PAUSED)); + } public unregister() { powerMonitor.removeListener('suspend', this.pause); diff --git a/src/main/features/core/shortcutManager.ts b/src/main/features/core/shortcutManager.ts index a1e1c776..2a2333b6 100755 --- a/src/main/features/core/shortcutManager.ts +++ b/src/main/features/core/shortcutManager.ts @@ -21,16 +21,16 @@ export default class Shortcut extends Feature { public register() { globalShortcut.register('MediaPlayPause', () => { - this.store.dispatch(toggleStatus() as any); + this.store.dispatch(toggleStatus()); }); globalShortcut.register('MediaPreviousTrack', () => { - this.store.dispatch(changeTrack(ChangeTypes.PREV) as any); + this.store.dispatch(changeTrack(ChangeTypes.PREV)); }); globalShortcut.register('MediaNextTrack', () => { - this.store.dispatch(changeTrack(ChangeTypes.NEXT) as any); + this.store.dispatch(changeTrack(ChangeTypes.NEXT)); }); globalShortcut.register('MediaStop', () => { - this.store.dispatch(toggleStatus(PlayerStatus.STOPPED) as any); + this.store.dispatch(toggleStatus(PlayerStatus.STOPPED)); }); } diff --git a/src/main/features/feature.ts b/src/main/features/feature.ts index 852783c3..492813aa 100755 --- a/src/main/features/feature.ts +++ b/src/main/features/feature.ts @@ -5,7 +5,13 @@ import { Store } from 'redux'; import ReduxWatcher from 'redux-watcher'; // eslint-disable-next-line import/no-cycle import { Auryo } from '../app'; -import { StoreState } from 'AppReduxTypes'; +import { StoreState, _StoreState } from 'AppReduxTypes'; +import { from, Observable, of, OperatorFunction } from 'rxjs'; +import { distinctUntilChanged, distinctUntilKeyChanged, filter, map, pluck, withLatestFrom } from 'rxjs/operators'; +import { SoundCloud } from '@types'; +import { AuthLikes, AuthReposts, PlayerStatus, PlayingTrack } from '@common/store/types'; +import { getTrackEntity, hasLiked, hasReposted } from '@common/store/selectors'; +import { SC } from '@common/utils'; export type Handler = (t: { store: Store; @@ -25,12 +31,28 @@ export interface WatchState { currentValue: T; } +type ObjectWithState = { store: _StoreState; value: T }; +type ObservableWithState = Observable>; + +interface AuryoObservables { + trackChanged: ObservableWithState; + statusChanged: ObservableWithState; + + playingTrackLikeChanged: ObservableWithState; + playingTrackRepostChanged: ObservableWithState; + + playerCurrentTimeChanged: ObservableWithState; + playerDurationChanged: ObservableWithState; +} + export class Feature { public readonly featureName: string = 'Feature'; public timers: any[] = []; public win: BrowserWindow | null = null; public store: Store; public watcher: any; + public store$: Observable; + public observables: AuryoObservables; private readonly listeners: { path: string[]; handler: Function }[] = []; private readonly ipclisteners: { name: string; handler: Function }[] = []; @@ -41,6 +63,87 @@ export class Feature { this.store = app.store; this.watcher = new ReduxWatcher(app.store); + + this.store$ = new Observable(observer => { + // emit the current state as first value: + observer.next(app.store.getState()); + const unsubscribe = app.store.subscribe(() => { + // emit on every new state changes + observer.next(app.store.getState()); + }); + // let's return the function that will be called + // when the Observable is unsubscribed + return unsubscribe; + }); + + this.observables = this.registerObservables(); + } + + private registerObservables(): AuryoObservables { + return { + trackChanged: this.store$.pipe( + pluck('player', 'playingTrack'), + filter(Boolean), + distinctUntilChanged(), + withLatestFrom(this.store$), + map(([playingTrack, store]) => ({ + value: getTrackEntity(playingTrack.id)(store), + store + })), + filter>(({ value }) => !!value) + ), + statusChanged: this.store$.pipe( + pluck('player', 'status'), + filter(Boolean), + distinctUntilChanged(), + withLatestFrom(this.store$), + map(([value, store]) => ({ + value, + store + })) + ), + playingTrackLikeChanged: this.store$.pipe( + distinctUntilChanged( + ({ auth: authA, player: playerA }, { auth: authB, player: playerB }) => + authA.likes === authB.likes && playerA.playingTrack === playerB.playingTrack + ), + map(store => ({ + value: store.player.playingTrack ? hasLiked(store.player.playingTrack.id, 'track')(store) : false, + store + })), + distinctUntilKeyChanged('value') + ), + playingTrackRepostChanged: this.store$.pipe( + distinctUntilChanged( + ({ auth: authA, player: playerA }, { auth: authB, player: playerB }) => + authA.reposts === authB.reposts && playerA.playingTrack === playerB.playingTrack + ), + filter(store => !!store.player.playingTrack), + map(store => ({ + value: hasReposted((store.player.playingTrack as PlayingTrack).id, 'track')(store), + store + })), + distinctUntilKeyChanged('value') + ), + playerCurrentTimeChanged: this.store$.pipe( + pluck('player', 'currentTime'), + distinctUntilChanged(), + withLatestFrom(this.store$), + map(([value, store]) => ({ + value, + store + })) + ), + playerDurationChanged: this.store$.pipe( + pluck('player', 'duration'), + distinctUntilChanged(), + withLatestFrom(this.store$), + map(([value, store]) => ({ + value, + store + })) + ) + }; } public subscribe(path: string[], handler: Handler) { diff --git a/src/main/features/index.ts b/src/main/features/index.ts index 83be6def..cff3dbbe 100644 --- a/src/main/features/index.ts +++ b/src/main/features/index.ts @@ -5,7 +5,6 @@ import ChromecastManager from './core/chromecast/chromecastManager'; import ConfigManager from './core/configManager'; import IPCManager from './core/ipcManager'; import LastFm from './core/lastFm'; -import NotificationManager from './core/notificationManager'; import PowerMonitor from './core/powerMonitor'; import ShortcutManager from './core/shortcutManager'; import { Feature } from './feature'; @@ -24,7 +23,6 @@ export const tools: typeof Feature[] = [ PowerMonitor, ShortcutManager, ApplicationMenu, - NotificationManager, ChromecastManager, // Mac TouchBarManager, diff --git a/src/main/features/linux/dbusService.ts b/src/main/features/linux/dbusService.ts index 46c3bda9..d08abe06 100755 --- a/src/main/features/linux/dbusService.ts +++ b/src/main/features/linux/dbusService.ts @@ -53,16 +53,16 @@ export default class DbusService extends LinuxFeature { private onMediaPlayerKeyPressed(_: number, keyName: string) { switch (keyName) { case 'Next': - this.store.dispatch(changeTrack(ChangeTypes.NEXT) as any); + this.store.dispatch(changeTrack(ChangeTypes.NEXT)); break; case 'Previous': - this.store.dispatch(changeTrack(ChangeTypes.PREV) as any); + this.store.dispatch(changeTrack(ChangeTypes.PREV)); break; case 'Play': - this.store.dispatch(toggleStatus() as any); + this.store.dispatch(toggleStatus()); break; case 'Stop': - this.store.dispatch(toggleStatus(PlayerStatus.STOPPED) as any); + this.store.dispatch(toggleStatus(PlayerStatus.STOPPED)); break; default: } diff --git a/src/main/features/linux/mprisService.ts b/src/main/features/linux/mprisService.ts index 7dce30cd..7ae3204a 100755 --- a/src/main/features/linux/mprisService.ts +++ b/src/main/features/linux/mprisService.ts @@ -1,19 +1,19 @@ -import { EVENTS } from '@common/constants/events'; import { IMAGE_SIZES } from '@common/constants/Soundcloud'; import { changeTrack, toggleStatus } from '@common/store/actions'; import { ChangeTypes, PlayerStatus } from '@common/store/player'; -import { getTrackEntity } from '@common/store/selectors'; -import { getCurrentPosition } from '@common/utils'; +import { getQueuePlaylistSelector } from '@common/store/selectors'; import * as SC from '@common/utils/soundcloudUtils'; +import { _StoreState } from 'AppReduxTypes'; +import { autobind } from 'core-decorators'; import * as _ from 'lodash'; import * as path from 'path'; import { Logger, LoggerInstance } from '../../utils/logger'; -import { WatchState } from '../feature'; import { MprisServiceClient } from './interfaces/mpris-service.interface'; import LinuxFeature from './linuxFeature'; const logosPath = path.resolve(global.__static, 'logos'); +@autobind export default class MprisService extends LinuxFeature { public readonly featureName = 'MprisService'; private readonly logger: LoggerInstance = Logger.createLogger(MprisService.featureName); @@ -69,106 +69,92 @@ export default class MprisService extends LinuxFeature { }); this.player.on('play', () => { - this.store.dispatch(toggleStatus(PlayerStatus.PLAYING) as any); + this.store.dispatch(toggleStatus(PlayerStatus.PLAYING)); }); this.player.on('pause', () => { - this.store.dispatch(toggleStatus(PlayerStatus.PAUSED) as any); + this.store.dispatch(toggleStatus(PlayerStatus.PAUSED)); }); this.player.on('playpause', () => { - this.store.dispatch(toggleStatus() as any); + this.store.dispatch(toggleStatus()); }); this.player.on('stop', () => { - this.store.dispatch(toggleStatus(PlayerStatus.STOPPED) as any); + this.store.dispatch(toggleStatus(PlayerStatus.STOPPED)); }); this.player.on('next', () => { - this.store.dispatch(changeTrack(ChangeTypes.NEXT) as any); + this.store.dispatch(changeTrack(ChangeTypes.NEXT)); }); this.player.on('previous', () => { - this.store.dispatch(changeTrack(ChangeTypes.PREV) as any); + this.store.dispatch(changeTrack(ChangeTypes.PREV)); }); // // WATCHERS // - this.on(EVENTS.APP.READY, () => { - /** - * Update track information - */ - this.subscribe(['player', 'playingTrack'], ({ currentState }) => { - const { - player: { playingTrack, queue } - } = currentState; - - if (playingTrack && this.player) { - const trackId = playingTrack.id; - const track = getTrackEntity(trackId)(currentState); - - const position = getCurrentPosition({ queue, playingTrack }); - - this.player.canGoPrevious = queue.length > 0 && position > 0; - this.player.canGoNext = queue.length > 0 && position + 1 <= queue.length; - - this.meta = { - ...this.meta, - ...this.player.metadata - }; - - if (track) { - this.meta['mpris:trackId'] = this.player.objectPath(track.id.toString()); - this.meta['mpris:length'] = this.parseTime(track.duration); // int - this.meta['mpris:artUrl'] = SC.getImageUrl(track, IMAGE_SIZES.XLARGE); - this.meta['xesam:genre'] = [track.genre || '']; - this.meta['xesam:title'] = track.title || ''; - this.meta['xesam:artist'] = [track.user && track.user.username ? track.user.username : 'Unknown artist']; - this.meta['xesam:url'] = track.uri || ''; - this.meta['xesam:useCount'] = track.playback_count || 0; - } else { - this.meta['xesam:title'] = 'Auryo'; - this.meta['xesam:artist'] = ['']; - this.meta['mpris:length'] = 0; - this.meta['xesam:url'] = ''; - this.meta['mpris:artUrl'] = `file://${path.join(logosPath, 'auryo-128.png')}`; - } - - if (!_.isEqual(this.meta, this.player.metadata)) { - this.player.metadata = this.meta; - } + // Update track information + this.observables.trackChanged.subscribe(({ value: track, store }) => { + const { + player: { playingTrack, currentIndex } + } = store; + + if (track && this.player && playingTrack) { + const queue = getQueuePlaylistSelector(store); + const queueLength = queue.items.length; + + this.player.canGoPrevious = queueLength > 0 && currentIndex > 0; + this.player.canGoNext = queueLength > 0 && currentIndex + 1 <= queueLength; + + this.meta = { + ...this.meta, + ...this.player.metadata + }; + + if (track) { + this.meta['mpris:trackId'] = this.player.objectPath(track.id.toString()); + this.meta['mpris:length'] = this.parseTime(track.duration); // int + this.meta['mpris:artUrl'] = SC.getImageUrl(track, IMAGE_SIZES.XLARGE); + this.meta['xesam:genre'] = [track.genre || '']; + this.meta['xesam:title'] = track.title || ''; + this.meta['xesam:artist'] = [track.user && track.user.username ? track.user.username : 'Unknown artist']; + this.meta['xesam:url'] = track.uri || ''; + this.meta['xesam:useCount'] = track.playback_count || 0; + } else { + this.meta['xesam:title'] = 'Auryo'; + this.meta['xesam:artist'] = ['']; + this.meta['mpris:length'] = 0; + this.meta['xesam:url'] = ''; + this.meta['mpris:artUrl'] = `file://${path.join(logosPath, 'auryo-128.png')}`; } - }); - - /** - * Update time - */ - this.subscribe(['player', 'status'], this.updateStatus.bind(this)); - this.subscribe(['player', 'currentTime'], this.updateTime.bind(this)); - this.subscribe(['player', 'duration'], this.updateTime.bind(this)); + + if (!_.isEqual(this.meta, this.player.metadata)) { + this.player.metadata = this.meta; + } + } + }); + + this.observables.statusChanged.subscribe(({ value: status }) => { + if (status && this.player) { + this.player.playbackStatus = (status + .toLowerCase() + .charAt(0) + .toUpperCase() + status.toLowerCase().slice(1)) as any; + } }); + + this.observables.playerDurationChanged.subscribe(({ store }) => this.updateTime(store)); + this.observables.playerCurrentTimeChanged.subscribe(({ store }) => this.updateTime(store)); } catch (e) { this.logger.warn('Mpris not supported'); this.logger.warn(e); } } - public updateStatus({ currentValue }: WatchState) { - if (currentValue && this.player) { - this.player.playbackStatus = (currentValue - .toLowerCase() - .charAt(0) - .toUpperCase() + currentValue.toLowerCase().slice(1)) as any; - } - } - - public updateTime = ({ - currentState: { - player: { currentTime, duration } - } - }: WatchState) => { + public updateTime = ({ player: { currentTime, duration } }: _StoreState) => { if (this.player) { this.meta = { ...this.meta, diff --git a/src/main/features/mac/mediaServiceManager.ts b/src/main/features/mac/mediaServiceManager.ts index bc7bf6da..40938b24 100755 --- a/src/main/features/mac/mediaServiceManager.ts +++ b/src/main/features/mac/mediaServiceManager.ts @@ -1,12 +1,10 @@ import { EVENTS } from '@common/constants/events'; import { IMAGE_SIZES } from '@common/constants/Soundcloud'; import { changeTrack, toggleStatus } from '@common/store/actions'; -import { ChangeTypes, PlayerStatus, PlayingTrack } from '@common/store/player'; -import { getTrackEntity } from '@common/store/selectors'; +import { ChangeTypes, PlayerStatus } from '@common/store/player'; import * as SC from '@common/utils/soundcloudUtils'; // eslint-disable-next-line import/no-extraneous-dependencies import MediaService, { MetaData } from 'electron-media-service'; -import { WatchState } from '../feature'; import MacFeature from './macFeature'; type milliseconds = number; @@ -39,104 +37,80 @@ export default class MediaServiceManager extends MacFeature { this.myService.setMetaData(this.meta); this.myService.on('play', () => { - const { - player: { status } - } = this.store.getState(); - - if (status !== PlayerStatus.PLAYING) { - this.store.dispatch(toggleStatus(PlayerStatus.PLAYING) as any); - } + this.store.dispatch(toggleStatus(PlayerStatus.PLAYING)); }); this.myService.on('pause', () => { - const { - player: { status } - } = this.store.getState(); - - if (status !== PlayerStatus.PAUSED) { - this.store.dispatch(toggleStatus(PlayerStatus.PAUSED) as any); - } + this.store.dispatch(toggleStatus(PlayerStatus.PAUSED)); }); this.myService.on('stop', () => { - this.store.dispatch(toggleStatus(PlayerStatus.STOPPED) as any); + this.store.dispatch(toggleStatus(PlayerStatus.STOPPED)); }); this.myService.on('playPause', () => { - this.store.dispatch(toggleStatus() as any); + this.store.dispatch(toggleStatus()); }); this.myService.on('next', () => { - this.store.dispatch(changeTrack(ChangeTypes.NEXT) as any); + this.store.dispatch(changeTrack(ChangeTypes.NEXT)); }); this.myService.on('previous', () => { - this.store.dispatch(changeTrack(ChangeTypes.PREV) as any); + this.store.dispatch(changeTrack(ChangeTypes.PREV)); }); this.myService.on('seek', (to: milliseconds) => { this.sendToWebContents(EVENTS.PLAYER.SEEK, to / 1000); }); - // - // WATCHERS - // - /** * Update track information */ - this.on(EVENTS.APP.READY, () => { - this.subscribe(['player', 'playingTrack'], ({ currentState }) => { - const { - player: { playingTrack } - } = currentState; - - if (playingTrack && this.myService) { - const trackId = playingTrack.id; - const track = getTrackEntity(trackId)(this.store.getState()); - - if (track) { - this.meta.id = track.id; - this.meta.title = track.title; - - this.meta.artist = track.user && track.user.username ? track.user.username : 'Unknown artist'; - this.meta.albumArt = SC.getImageUrl(track, IMAGE_SIZES.LARGE); - this.myService.setMetaData(this.meta); - } - } - }); - - /** - * Update playback status - */ - this.subscribe(['player', 'status'], ({ currentValue: status }: any) => { - this.meta.state = status.toLowerCase(); - - if (this.myService) { - this.myService.setMetaData(this.meta); - } - }); - - /** - * Update time - */ - this.subscribe(['player', 'currentTime'], this.updateTime); - this.subscribe(['player', 'duration'], this.updateTime); + this.observables.trackChanged.subscribe(({ value: track }) => { + if (this.myService) { + this.meta.id = track.id; + this.meta.title = track.title; + + this.meta.artist = track.user && track.user.username ? track.user.username : 'Unknown artist'; + this.meta.albumArt = SC.getImageUrl(track, IMAGE_SIZES.LARGE); + this.myService.setMetaData(this.meta); + } }); - } - public updateTime = ({ - currentState: { - player: { currentTime, duration } - } - }: WatchState) => { - this.meta.currentTime = Math.round(currentTime * 1e3); - this.meta.duration = Math.round(duration * 1e3); + /** + * Sync status + */ + this.observables.statusChanged.subscribe(({ value: status }) => { + this.meta.state = MediaStates[status]; - if (this.myService) { - this.myService.setMetaData(this.meta); - } - }; + if (this.myService) { + this.myService.setMetaData(this.meta); + } + }); + + /** + * Sync currentTime + */ + this.observables.playerCurrentTimeChanged.subscribe(({ value: currentTime }) => { + this.meta.currentTime = Math.round(currentTime * 1e3); + + if (this.myService) { + this.myService.setMetaData(this.meta); + } + }); + + /** + * Sync duration + */ + this.observables.playerDurationChanged.subscribe(({ value: duration }) => { + this.meta.duration = Math.round(duration * 1e3); + + if (this.myService) { + this.myService.setMetaData(this.meta); + } + }); + } public unregister() { super.unregister(); diff --git a/src/main/features/mac/touchBarManager.ts b/src/main/features/mac/touchBarManager.ts index a64b70b4..b359e339 100755 --- a/src/main/features/mac/touchBarManager.ts +++ b/src/main/features/mac/touchBarManager.ts @@ -1,12 +1,9 @@ -import { EVENTS } from '@common/constants/events'; -import { changeTrack, toggleStatus } from '@common/store/actions'; -import { ChangeTypes, PlayerStatus } from '@common/store/player'; -import * as SC from '@common/utils/soundcloudUtils'; +import { changeTrack, toggleLike, toggleRepost, toggleStatus } from '@common/store/actions'; +import { ChangeTypes } from '@common/store/player'; import { autobind } from 'core-decorators'; // eslint-disable-next-line import/no-extraneous-dependencies import { nativeImage, TouchBar } from 'electron'; import * as path from 'path'; -import { WatchState } from '../feature'; import MacFeature from './macFeature'; const { TouchBarButton, TouchBarSpacer } = TouchBar; @@ -53,44 +50,32 @@ export default class TouchBarManager extends MacFeature { icon: nativeImage.createFromPath(path.join(iconsDirectory, 'previous.png')).resize({ width: 20 }), - click: () => this.store.dispatch(changeTrack(ChangeTypes.PREV) as any) + click: () => this.store.dispatch(changeTrack(ChangeTypes.PREV)) }); public playPauseBtn: Electron.TouchBarButton = new TouchBarButton({ icon: this.playstates.PAUSED, - click: () => this.store.dispatch(toggleStatus() as any) + click: () => this.store.dispatch(toggleStatus()) }); public nextBtn: Electron.TouchBarButton = new TouchBarButton({ icon: nativeImage.createFromPath(path.join(iconsDirectory, 'next.png')).resize({ width: 20 }), - click: () => this.store.dispatch(changeTrack(ChangeTypes.NEXT) as any) + click: () => this.store.dispatch(changeTrack(ChangeTypes.NEXT)) }); public likeBtn: Electron.TouchBarButton = new TouchBarButton({ icon: this.likestates.unliked, click: () => { - const { - player: { playingTrack } - } = this.store.getState(); - - if (playingTrack) { - this.sendToWebContents(EVENTS.TRACK.LIKE, playingTrack.id); - } + this.store.dispatch(toggleLike.request({})); } }); public repostBtn: Electron.TouchBarButton = new TouchBarButton({ icon: this.repoststates.notReposted, click: () => { - const { - player: { playingTrack } - } = this.store.getState(); - - if (playingTrack) { - this.sendToWebContents(EVENTS.TRACK.REPOST, playingTrack.id); - } + this.store.dispatch(toggleRepost.request({})); } }); @@ -115,58 +100,16 @@ export default class TouchBarManager extends MacFeature { this.win.setTouchBar(touchBar); } - this.on(EVENTS.APP.READY, () => { - this.subscribe(['player', 'status'], this.updateStatus); - this.subscribe(['player', 'playingTrack'], this.checkIfLiked); - - this.on(EVENTS.TRACK.LIKED, this.checkIfLiked); - this.on(EVENTS.TRACK.REPOSTED, this.checkIfReposted); + this.observables.statusChanged.subscribe(({ value: status }) => { + this.playPauseBtn.icon = this.playstates[status]; }); - } - - public checkIfLiked() { - const { - entities: { trackEntities }, - player: { playingTrack }, - auth: { likes } - } = this.store.getState(); - if (playingTrack) { - const trackId = playingTrack.id; - const track = trackEntities[trackId]; - - if (track) { - const liked = SC.hasID(track.id, likes.track); - - this.likeBtn.icon = liked ? this.likestates.liked : this.likestates.unliked; - } - } else { - this.likeBtn.icon = this.likestates.unliked; - } - } - - public checkIfReposted() { - const { - entities: { trackEntities }, - player: { playingTrack }, - auth: { reposts } - } = this.store.getState(); - - if (playingTrack) { - const trackId = playingTrack.id; - const track = trackEntities[trackId]; - - if (track) { - const reposted = SC.hasID(track.id, reposts.track); - - this.repostBtn.icon = reposted ? this.repoststates.reposted : this.repoststates.notReposted; - } - } else { - this.repostBtn.icon = this.repoststates.notReposted; - } - } + this.observables.playingTrackLikeChanged.subscribe(({ value: liked }) => { + this.likeBtn.icon = liked ? this.likestates.liked : this.likestates.unliked; + }); - public updateStatus({ currentValue }: WatchState) { - this.playPauseBtn.icon = this.playstates[currentValue]; + this.observables.playingTrackRepostChanged.subscribe(({ value: reposted }) => { + this.repostBtn.icon = reposted ? this.repoststates.reposted : this.repoststates.notReposted; + }); } } diff --git a/src/main/features/win32/thumbar.ts b/src/main/features/win32/thumbar.ts index 773e54a9..e7474981 100755 --- a/src/main/features/win32/thumbar.ts +++ b/src/main/features/win32/thumbar.ts @@ -1,10 +1,11 @@ import { changeTrack, toggleStatus } from '@common/store/actions'; import { ChangeTypes, PlayerStatus } from '@common/store/player'; +import { getQueuePlaylistSelector } from '@common/store/selectors'; import { StoreState } from 'AppReduxTypes'; import { autobind } from 'core-decorators'; // eslint-disable-next-line import/no-extraneous-dependencies import { nativeImage } from 'electron'; -import * as is from 'electron-is'; +import is from 'electron-is'; import * as path from 'path'; import { Auryo } from '../../app'; import { Feature } from '../feature'; @@ -41,7 +42,7 @@ export default class Thumbar extends Feature { tooltip: 'Play', icon: nativeImage.createFromPath(path.join(iconsDirectory, 'play.png')), click: () => { - this.togglePlay(PlayerStatus.PLAYING); + this.store.dispatch(toggleStatus(PlayerStatus.PLAYING)); } }, playDisabled: { @@ -55,7 +56,7 @@ export default class Thumbar extends Feature { tooltip: 'Pause', icon: nativeImage.createFromPath(path.join(iconsDirectory, 'pause.png')), click: () => { - this.togglePlay(PlayerStatus.PAUSED); + this.store.dispatch(toggleStatus(PlayerStatus.PAUSED)); } }, pauseDisabled: { @@ -69,7 +70,7 @@ export default class Thumbar extends Feature { tooltip: 'Prev', icon: nativeImage.createFromPath(path.join(iconsDirectory, 'previous.png')), click: () => { - this.changeTrack(ChangeTypes.PREV); + this.store.dispatch(changeTrack(ChangeTypes.PREV)); } }, prevDisabled: { @@ -83,7 +84,7 @@ export default class Thumbar extends Feature { tooltip: 'Next', icon: nativeImage.createFromPath(path.join(iconsDirectory, 'next.png')), click: () => { - this.changeTrack(ChangeTypes.NEXT); + this.store.dispatch(changeTrack(ChangeTypes.NEXT)); } }, nextDisabled: { @@ -97,63 +98,52 @@ export default class Thumbar extends Feature { this.setThumbarButtons(this.store.getState()); - this.subscribe(['player', 'status'], ({ currentState }) => { - this.setThumbarButtons(currentState); + this.observables.statusChanged.subscribe(({ store }) => { + this.setThumbarButtons(store); }); - this.subscribe(['player', 'playingTrack'], ({ currentState }) => { - this.setThumbarButtons(currentState); + this.observables.trackChanged.subscribe(({ store }) => { + this.setThumbarButtons(store); }); } - public setThumbarButtons(state: StoreState) { + public setThumbarButtons(store: StoreState) { const { - player: { status, queue, currentIndex } - } = state; + player: { status, currentIndex } + } = store; - if (this.win && this.thumbarButtons) { - switch (status) { - case PlayerStatus.PLAYING: - this.win.setThumbarButtons([ - queue.length > 0 || currentIndex > 0 ? this.thumbarButtons.prev : this.thumbarButtons.prevDisabled, - this.thumbarButtons.pause, - queue.length > 0 && currentIndex + 1 <= queue.length - ? this.thumbarButtons.next - : this.thumbarButtons.nextDisabled - ]); - break; - case PlayerStatus.PAUSED: - this.win.setThumbarButtons([ - queue.length > 0 || currentIndex > 0 ? this.thumbarButtons.prev : this.thumbarButtons.prevDisabled, - this.thumbarButtons.play, - queue.length > 0 && currentIndex + 1 <= queue.length - ? this.thumbarButtons.next - : this.thumbarButtons.nextDisabled - ]); - break; - case PlayerStatus.STOPPED: - this.win.setThumbarButtons([ - this.thumbarButtons.prevDisabled, - this.thumbarButtons.playDisabled, - this.thumbarButtons.nextDisabled - ]); - break; - default: - } - } - } + if (!(this.win && this.thumbarButtons)) return; - public togglePlay(newStatus: PlayerStatus) { - const { - player: { status } - } = this.store.getState(); + const queue = getQueuePlaylistSelector(store); + const queueLength = queue.items.length; - if (status !== newStatus) { - this.store.dispatch(toggleStatus(newStatus) as any); + switch (status) { + case PlayerStatus.PLAYING: + this.win.setThumbarButtons([ + queueLength > 0 || currentIndex > 0 ? this.thumbarButtons.prev : this.thumbarButtons.prevDisabled, + this.thumbarButtons.pause, + queueLength > 0 && currentIndex + 1 <= queueLength + ? this.thumbarButtons.next + : this.thumbarButtons.nextDisabled + ]); + break; + case PlayerStatus.PAUSED: + this.win.setThumbarButtons([ + queueLength > 0 || currentIndex > 0 ? this.thumbarButtons.prev : this.thumbarButtons.prevDisabled, + this.thumbarButtons.play, + queueLength > 0 && currentIndex + 1 <= queueLength + ? this.thumbarButtons.next + : this.thumbarButtons.nextDisabled + ]); + break; + case PlayerStatus.STOPPED: + this.win.setThumbarButtons([ + this.thumbarButtons.prevDisabled, + this.thumbarButtons.playDisabled, + this.thumbarButtons.nextDisabled + ]); + break; + default: } } - - public changeTrack(changeType: ChangeTypes) { - this.store.dispatch(changeTrack(changeType) as any); - } } diff --git a/src/main/features/win32/win10/win10MediaService.ts b/src/main/features/win32/win10/win10MediaService.ts index 63cfc042..94bb6893 100644 --- a/src/main/features/win32/win10/win10MediaService.ts +++ b/src/main/features/win32/win10/win10MediaService.ts @@ -1,8 +1,6 @@ -import { EVENTS } from '@common/constants/events'; import { IMAGE_SIZES } from '@common/constants/Soundcloud'; import { changeTrack, toggleStatus } from '@common/store/actions'; -import { ChangeTypes, PlayerStatus, PlayingTrack } from '@common/store/player'; -import { getTrackEntity } from '@common/store/selectors'; +import { ChangeTypes, PlayerStatus } from '@common/store/player'; import * as SC from '@common/utils/soundcloudUtils'; import { Logger, LoggerInstance } from '../../../utils/logger'; import { WindowsFeature } from '../windowsFeature'; @@ -52,84 +50,52 @@ export default class Win10MediaService extends WindowsFeature { Controls.on('buttonpressed', (_sender: any, eventArgs: any) => { switch (eventArgs.button) { case SystemMediaTransportControlsButton.play: - this.togglePlay(PlayerStatus.PLAYING); + this.store.dispatch(toggleStatus(PlayerStatus.PLAYING)); break; case SystemMediaTransportControlsButton.pause: - this.togglePlay(PlayerStatus.PAUSED); + this.store.dispatch(toggleStatus(PlayerStatus.PAUSED)); break; case SystemMediaTransportControlsButton.stop: - this.togglePlay(PlayerStatus.STOPPED); + this.store.dispatch(toggleStatus(PlayerStatus.STOPPED)); break; case SystemMediaTransportControlsButton.next: - this.changeTrack(ChangeTypes.NEXT); + this.store.dispatch(changeTrack(ChangeTypes.NEXT)); break; case SystemMediaTransportControlsButton.previous: - this.changeTrack(ChangeTypes.PREV); + this.store.dispatch(changeTrack(ChangeTypes.PREV)); break; default: } }); - this.on(EVENTS.APP.READY, () => { - // Status changed - this.subscribe(['player', 'status'], ({ currentValue: status }: any) => { - const mapping = { - [PlayerStatus.STOPPED]: MediaPlaybackStatus.stopped, - [PlayerStatus.PAUSED]: MediaPlaybackStatus.paused, - [PlayerStatus.PLAYING]: MediaPlaybackStatus.playing - }; + this.observables.statusChanged.subscribe(({ value: status }) => { + const mapping = { + [PlayerStatus.STOPPED]: MediaPlaybackStatus.stopped, + [PlayerStatus.PAUSED]: MediaPlaybackStatus.paused, + [PlayerStatus.PLAYING]: MediaPlaybackStatus.playing + }; - Controls.playbackStatus = mapping[status]; - }); - - // Track changed - this.subscribe(['player', 'playingTrack'], ({ currentState }) => { - const { - player: { playingTrack } - } = currentState; - - if (playingTrack) { - const trackId = playingTrack.id; - const track = getTrackEntity(trackId)(this.store.getState()); - - if (track) { - const image = SC.getImageUrl(track, IMAGE_SIZES.SMALL); - Controls.displayUpdater.musicProperties.title = track.title || ''; - Controls.displayUpdater.musicProperties.artist = - track.user && track.user.username ? track.user.username : 'Unknown artist'; - Controls.displayUpdater.musicProperties.albumTitle = track.genre || ''; - Controls.displayUpdater.thumbnail = image - ? RandomAccessStreamReference.createFromUri(new Uri(image)) - : ''; - - Controls.displayUpdater.update(); - - return; - } - } + Controls.playbackStatus = mapping[status]; + }); + // Track changed + this.observables.trackChanged.subscribe(({ value: track }) => { + if (track) { + const image = SC.getImageUrl(track, IMAGE_SIZES.SMALL); + Controls.displayUpdater.musicProperties.title = track.title || ''; + Controls.displayUpdater.musicProperties.artist = + track.user && track.user.username ? track.user.username : 'Unknown artist'; + Controls.displayUpdater.musicProperties.albumTitle = track.genre || ''; + Controls.displayUpdater.thumbnail = image ? RandomAccessStreamReference.createFromUri(new Uri(image)) : ''; + } else { Controls.displayUpdater.musicProperties.title = 'Auryo'; Controls.displayUpdater.musicProperties.artist = 'No track is playing'; + } - Controls.displayUpdater.update(); - }); + Controls.displayUpdater.update(); }); } catch (e) { this.logger.error(e); } } - - public togglePlay = (newStatus: PlayerStatus) => { - const { - player: { status } - } = this.store.getState(); - - if (status !== newStatus) { - this.store.dispatch(toggleStatus(newStatus) as any); - } - }; - - public changeTrack = (changeType: ChangeTypes) => { - this.store.dispatch(changeTrack(changeType) as any); - }; } diff --git a/src/main/index.dev.ts b/src/main/index.dev.ts index ec9c3613..cb0354ba 100644 --- a/src/main/index.dev.ts +++ b/src/main/index.dev.ts @@ -12,7 +12,7 @@ require('electron-debug')({ showDevTools: true }); // Install `react-devtools` require('electron').app.on('ready', () => { - require('devtron').install(); + // require('devtron').install(); let installExtension = require('electron-devtools-installer'); diff --git a/src/main/index.ts b/src/main/index.ts index e6a2f322..21de0a21 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -18,43 +18,93 @@ if (process.env.NODE_ENV !== 'development') { global.__static = staticPath.replace(/\\/g, '\\\\'); +import { configureStore } from '@common/store'; // eslint-disable-next-line import/no-extraneous-dependencies import { app, systemPreferences } from 'electron'; +import is from 'electron-is'; import { Auryo } from './app'; import { Logger } from './utils/logger'; -import store from '@common/store'; -import is from 'electron-is'; -const auryo = new Auryo(); +const mainStore = configureStore(); -auryo.setStore(store); +const auryo = new Auryo(mainStore); -// Quit when all windows are closed -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } -}); +const logger = Logger.defaultLogger(); + +app.setAppUserModelId('com.auryo.core'); +app.setAsDefaultProtocolClient('auryo'); + +registerListeners(); + +const isSingleInstance = requestInstanceLock(); + +if (isSingleInstance) { + app.on('second-instance', (_e, argv) => { + // Handle protocol url for Windows + if (is.windows()) { + auryo.handleProtocolUrl(argv[1]); + } + }); + + // This method will be called when Electron has done everything + // initialization and ready for creating browser windows. + app.whenReady().then(async () => { + if (is.osx()) systemPreferences.isTrustedAccessibilityClient(true); + + // Handle protocol url for Windows + if (is.windows()) { + auryo.handleProtocolUrl(process.argv[1]); + } + + try { + await auryo.start(); + } catch (err) { + logger.error('Error starting auryo', err); + } + }); +} + +// Helpers + +function requestInstanceLock() { + const gotTheLock = app.requestSingleInstanceLock(); -app.on('activate', () => { - if (auryo.mainWindow) { - auryo.mainWindow.show(); - } else { - // Something went wrong + if (!gotTheLock) { + logger.debug('Not the first instance, gonna quit.'); app.quit(); } -}); -// This method will be called when Electron has done everything -// initialization and ready for creating browser windows. -app.on('ready', async () => { - if (is.osx()) { - systemPreferences.isTrustedAccessibilityClient(true); - } + return gotTheLock; +} - try { - await auryo.start(); - } catch (err) { - Logger.defaultLogger().error('Error starting auryo', err); - } -}); +function registerListeners() { + app.on('before-quit', () => { + logger.info('Application exiting...'); + auryo.setQuitting(true); + }); + + // Quit when all windows are closed + app.on('window-all-closed', () => { + // On macOS it is common for applications and their menu bar + // to stay active until the user quits explicitly with Cmd + Q + if (process.platform !== 'darwin') { + app.quit(); + } + }); + + app.on('activate', () => { + if (auryo.mainWindow) { + auryo.mainWindow.show(); + } else { + // Something went wrong + app.quit(); + } + }); + + // Handle protocol url for MacOS + app.on('open-url', (event, data) => { + event.preventDefault(); + + auryo.handleProtocolUrl(data); + }); +} diff --git a/src/main/store/epics/app.ts b/src/main/store/epics/app.ts new file mode 100644 index 00000000..ab6f6778 --- /dev/null +++ b/src/main/store/epics/app.ts @@ -0,0 +1,67 @@ +import { Intent } from '@blueprintjs/core'; +import { + addToast, + copyToClipboard, + openExternalUrl, + receiveProtocolAction, + restartApp, + setConfigKey +} from '@common/store/actions'; +import { RootEpic } from '@common/store/declarations'; +import { Logger } from '@main/utils/logger'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { app, clipboard, shell } from 'electron'; +import { concat, of } from 'rxjs'; +import { concatMap, filter, ignoreElements, pluck, tap } from 'rxjs/operators'; +import { isActionOf } from 'typesafe-actions'; + +const logger = Logger.createLogger('EPIC/main/app'); + +export const handleReceiveClientIdEpic: RootEpic = action$ => + // @ts-expect-error + action$.pipe( + filter(isActionOf(receiveProtocolAction)), + pluck('payload'), + filter(({ action, params }) => action === 'launch' && !!params.client_id), + concatMap(({ params }) => { + return concat( + of(setConfigKey('app.overrideClientId', params.client_id as string)), + of( + addToast({ + message: `New clientId added`, + intent: Intent.SUCCESS + }) + ) + ); + }) + ); + +export const copyToClipboardEpic: RootEpic = action$ => + // @ts-expect-error + action$.pipe( + filter(isActionOf(copyToClipboard)), + pluck('payload'), + tap(text => clipboard.writeText(text)), + ignoreElements() + ); + +export const openExternalEpic: RootEpic = action$ => + // @ts-expect-error + action$.pipe( + filter(isActionOf(openExternalUrl)), + pluck('payload'), + tap(url => shell.openExternal(url).catch(logger.error)), + ignoreElements() + ); + +export const restartAppEpic: RootEpic = action$ => + // @ts-expect-error + action$.pipe( + filter(isActionOf(restartApp)), + pluck('payload'), + tap(() => { + app.relaunch({ args: process.argv.slice(1).concat(['--relaunch']) }); + app.exit(0); + }), + ignoreElements() + ); diff --git a/src/main/store/epics/auth.ts b/src/main/store/epics/auth.ts new file mode 100644 index 00000000..853717aa --- /dev/null +++ b/src/main/store/epics/auth.ts @@ -0,0 +1,147 @@ +import { + addToast, + login, + logout, + receiveProtocolAction, + startLoginSession, + tokenRefresh, + verifyLoginSession +} from '@common/store/actions'; +import { RootEpic } from '@common/store/declarations'; +import { configSelector } from '@common/store/selectors'; +import { TokenResponse } from '@common/store/types'; +import { Logger } from '@main/utils/logger'; +import { pkceChallenge } from '@main/utils/pkce'; +import Axios from 'axios'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { shell } from 'electron'; +import { stopForwarding } from 'electron-redux'; +import * as querystring from 'querystring'; +import { concat, from, iif, merge, of, TimeoutError } from 'rxjs'; +import { fromFetch } from 'rxjs/fetch'; +import { + catchError, + filter, + map, + pluck, + startWith, + switchMap, + takeUntil, + tap, + timeout, + withLatestFrom +} from 'rxjs/operators'; +import { isActionOf } from 'typesafe-actions'; +import { v4 } from 'uuid'; +import { CONFIG } from '../../../config'; + +const logger = Logger.createLogger('EPIC/main/auth'); + +export const loginEpic: RootEpic = action$ => + // @ts-expect-error + action$.pipe( + filter(isActionOf(login.request)), + + // Initialize flow + map(() => ({ + uuid: v4(), + challenge: pkceChallenge() + })), + tap(({ uuid, challenge }) => { + const queryParams = querystring.stringify({ + response_type: 'code', + state: uuid, + code_challenge: challenge.codeChallenge, + code_challenge_method: 'S256' + }); + shell.openExternal(`${CONFIG.AURYO_API_URL}/authorize?${queryParams}`); + }), + switchMap(({ uuid, challenge }) => + action$.pipe( + startWith(stopForwarding(startLoginSession({ uuid, codeVerifier: challenge.codeVerifier }))), + filter(isActionOf(receiveProtocolAction)), + takeUntil(action$.pipe(filter(isActionOf([login.request, login.failure, login.success, login.cancel])))), + pluck('payload'), + filter(({ action }) => action === 'auth'), + + // 5 minute timeout + timeout(60000 * 5), + + switchMap(({ params }) => + iif( + // If session uuid matched the current one + () => !!params.code && !!params.state && params.state === uuid, + // continue as normal + concat( + of(stopForwarding(verifyLoginSession())), + from( + Axios.post( + CONFIG.AURYO_API_TOKEN_URL, + querystring.stringify({ + grant_type: 'authorization_code', + code: params.code, + code_verifier: challenge.codeVerifier, + redirect_uri: CONFIG.AURYO_API_CALLBACK_URL + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ) + ).pipe( + pluck('data'), + map(login.success), + catchError(err => { + logger.error('Error during login', err); + return of(logout(), login.failure({})); + }) + ) + ), + // Otherwise throw error and + of(login.failure({ message: 'The session may have expired, please try logging in again.' })) + ) + ), + catchError(err => { + if (err.name === 'TimeoutError') { + return of(login.cancel({})); + } + + return of(login.failure({ message: 'Something went wrong during login. Please try again.' })); + }) + ) + ) + ); + +export const tokenRefreshEpic: RootEpic = (action$, state$) => + // @ts-expect-error + action$.pipe( + filter(isActionOf(tokenRefresh.request)), + withLatestFrom(state$), + map(([, state]) => ({ + refreshToken: configSelector(state).auth.refreshToken + })), + switchMap(({ refreshToken }) => { + return fromFetch(CONFIG.AURYO_API_TOKEN_URL, { + method: 'POST', + body: querystring.stringify({ + grant_type: 'refresh_token', + refresh_token: refreshToken + }), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + selector: res => { + if (!res.ok) throw res as any; + + return res.json(); + } + }).pipe( + map(tokenRefresh.success), + catchError(err => { + logger.error('Error refreshing token', err); + return of(logout(), tokenRefresh.failure({})); + }) + ); + }) + ); diff --git a/src/main/store/epics/config.ts b/src/main/store/epics/config.ts new file mode 100644 index 00000000..e0cbdc25 --- /dev/null +++ b/src/main/store/epics/config.ts @@ -0,0 +1,20 @@ +import { setConfig, setConfigKey } from '@common/store/actions'; +import { RootEpic } from '@common/store/declarations'; +import { settings } from '@main/settings'; +import { Logger } from '@main/utils/logger'; +import { debounceTime, filter, ignoreElements, map, tap, withLatestFrom } from 'rxjs/operators'; +import { isActionOf } from 'typesafe-actions'; + +const logger = Logger.createLogger('EPIC/main/config'); + +export const saveSettingsEpic: RootEpic = (action$, state$) => + // @ts-expect-error + action$.pipe( + filter(isActionOf([setConfigKey, setConfig])), + withLatestFrom(state$), + debounceTime(500), + map(([, state]) => state.config), + tap(latestConfig => settings.set(latestConfig)), + tap(() => logger.trace('Settings saved')), + ignoreElements() + ); diff --git a/src/main/store/rootEpic.ts b/src/main/store/rootEpic.ts new file mode 100644 index 00000000..7b9c47de --- /dev/null +++ b/src/main/store/rootEpic.ts @@ -0,0 +1,18 @@ +import { RootAction } from '@common/store/declarations'; +import AbortController from 'abort-controller'; +import { StoreState } from 'AppReduxTypes'; +import fetch from 'node-fetch'; +import { combineEpics } from 'redux-observable'; +import * as app from './epics/app'; +import * as auth from './epics/auth'; +import * as config from './epics/config'; + +// This is a polyfill for rxjs fetch +global.fetch = fetch; +global.AbortController = AbortController; + +export const mainRootEpic = combineEpics( + ...Object.values(config), + ...Object.values(auth), + ...Object.values(app) +); diff --git a/src/main/utils/pkce.ts b/src/main/utils/pkce.ts new file mode 100644 index 00000000..9bc5383f --- /dev/null +++ b/src/main/utils/pkce.ts @@ -0,0 +1,74 @@ +import { createHash, randomBytes } from 'crypto'; + +// Taken from https://github.com/crouchcd/pkce-challenge#readme + +/** Generate cryptographically secure random string + * @param {number} size The desired length of the string + * @param {string} mask A mask of characters (no more than 256) to choose from + * @returns {string} The random string + */ +function random(size: number, mask: string | any[]) { + let result = ''; + const randomIndices = randomBytes(size); + const byteLength = 2 ** 8; // 256 + const maskLength = Math.min(mask.length, byteLength); + // the scaling factor breaks down the possible values of bytes (0x00-0xFF) + // into the range of mask indices + const scalingFactor = byteLength / maskLength; + for (let i = 0; i < size; i += 1) { + const randomIndex = Math.floor(randomIndices[i] / scalingFactor); + result += mask[randomIndex]; + } + return result; +} + +/** Base64 url encode a string + * @param {string} base64 The base64 string to url encode + * @returns {string} The base64 url encoded string + */ +function base64UrlEncode(base64: string) { + return base64 + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); +} + +/** Generate a PKCE challenge verifier + * @param {number} length Length of the verifier + * @returns {string} A random verifier `length` characters long + */ +function generateVerifier(length: number) { + const mask = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'; + return random(length, mask); +} + +/** Generate a PKCE challenge code from a verifier + * @param {string} code_verifier + * @returns {string} The base64 url encoded code challenge + */ +function generateChallenge(code_verifier: string) { + const hash = createHash('sha256') + .update(code_verifier) + .digest('base64'); + return base64UrlEncode(hash); +} + +/** Generate a PKCE challenge pair + * @param {number} [length=43] Length of the verifer (between 43-128) + * @returns {{code_challenge:string,code_verifier:string}} PKCE challenge pair + */ +export function pkceChallenge(length?: number) { + if (!length) length = 43; + + if (length < 43 || length > 128) { + throw new Error(`Expected a length between 43 and 128. Received ${length}.`); + } + + const verifier = generateVerifier(length); + const challenge = generateChallenge(verifier); + + return { + codeChallenge: challenge, + codeVerifier: verifier + }; +} diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f01e33be..d08de3c4 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,5 +1,6 @@ import { EVENTS } from '@common/constants/events'; import { history } from '@common/store'; +import { toggleStatus } from '@common/store/actions'; import { configSelector } from '@common/store/selectors'; import { ua } from '@common/utils/universalAnalytics'; // eslint-disable-next-line import/no-extraneous-dependencies @@ -12,19 +13,22 @@ import { useDispatch, useSelector } from 'react-redux'; import { Route, Switch } from 'react-router-dom'; import { useKey } from 'react-use'; import Main from './app/Main'; -import OnBoarding from './pages/onboarding/OnBoarding'; +import { OnBoarding } from './pages/onboarding/OnBoarding'; export const App: FC = () => { - const analyticsEnabled = useSelector(state => configSelector(state).app.analytics); const dispatch = useDispatch(); + const analyticsEnabled = useSelector(state => configSelector(state).app.analytics); // Toggle player on Space - // TODO re-enable - // useKey(' ', () => dispatch(toggleStatus() as any), { event: 'keyup' }); - // Prevent body from scrolling when pressing Space useKey( ' ', event => { + // Only toggle status when not in input field + if (!(event?.target instanceof HTMLInputElement)) { + dispatch(toggleStatus()); + } + + // Prevent body from scrolling when pressing Space if (event.target === document.body) { event.preventDefault(); return false; @@ -37,15 +41,7 @@ export const App: FC = () => { useEffect(() => { ipcRenderer.send(EVENTS.APP.READY); - - const unregister = history.listen(() => { - ipcRenderer.send(EVENTS.APP.NAVIGATE); - }); - - return () => { - unregister(); - }; - }, [dispatch]); + }, []); // Page analytics useEffect(() => { diff --git a/src/renderer/_shared/ActionsDropdown.tsx b/src/renderer/_shared/ActionsDropdown.tsx index 36aa9e3f..a847c5db 100644 --- a/src/renderer/_shared/ActionsDropdown.tsx +++ b/src/renderer/_shared/ActionsDropdown.tsx @@ -1,7 +1,6 @@ import { Menu, MenuDivider, MenuItem, Popover, Position } from '@blueprintjs/core'; -import { addUpNext, toggleLike, toggleRepost } from '@common/store/actions'; -import { getNormalizedSchemaForType, hasLiked } from '@common/store/selectors'; -import { IPC } from '@common/utils/ipc'; +import { addUpNext, openExternalUrl, toggleLike, toggleRepost } from '@common/store/actions'; +import { getNormalizedSchemaForType, hasLiked, hasReposted } from '@common/store/selectors'; import cn from 'classnames'; import React, { FC } from 'react'; import { useDispatch, useSelector } from 'react-redux'; @@ -10,15 +9,13 @@ import ShareMenuItem from './ShareMenuItem'; interface Props { trackOrPlaylist: SoundCloud.Playlist | SoundCloud.Track; - index?: number; - playing?: boolean; - currentPlaylistId?: string; + removeFromQueue?(): void; } -export const ActionsDropdown: FC = ({ trackOrPlaylist }) => { +export const ActionsDropdown: FC = ({ trackOrPlaylist, removeFromQueue }) => { const dispatch = useDispatch(); const isLiked = useSelector(hasLiked(trackOrPlaylist.id, trackOrPlaylist.kind)); - const isReposted = useSelector(hasLiked(trackOrPlaylist.id, trackOrPlaylist.kind)); + const isReposted = useSelector(hasReposted(trackOrPlaylist.id, trackOrPlaylist.kind)); const idResult = getNormalizedSchemaForType(trackOrPlaylist); const likedText = isLiked ? 'Liked' : 'Like'; @@ -46,13 +43,11 @@ export const ActionsDropdown: FC = ({ trackOrPlaylist }) => { dispatch(addUpNext.request(idResult))} /> - {/* {index !== undefined && !playing ? ( - addUpNext(trackOrPlaylist, index)} /> - ) : null} */} + {removeFromQueue && } - IPC.openExternal(trackOrPlaylist.permalink_url)} /> + dispatch(openExternalUrl(trackOrPlaylist.permalink_url))} /> = ({ isFetching, hasMore, loadMore, children }) => { - const bottomRef = useRef(); + const bottomRef = useRef(null); useEffect(() => { const currentRef = bottomRef.current; diff --git a/src/renderer/_shared/PageHeader/PageHeader.scss b/src/renderer/_shared/PageHeader/PageHeader.scss index 60ae0f59..e15ac17e 100644 --- a/src/renderer/_shared/PageHeader/PageHeader.scss +++ b/src/renderer/_shared/PageHeader/PageHeader.scss @@ -1,28 +1,35 @@ @import '../../css/bootstrap.imports.scss'; + .page-header { - padding: 20px 20px 15px 40px; + padding: 20px 30px 30px 30px; + h2 { color: var(--clr-page-header-title); font-size: 2.1rem; margin-bottom: 0.3rem; } + .subtitle { margin: 0; color: #dcdcdc; font-weight: 400; font-size: 1rem; } + .button-group { display: flex; align-items: center; margin-top: 1.8rem; + a { margin-right: 0.5rem; } } - + .detailPage { + + +.detailPage { background: var(--clr-detail-page-bg); } + &.withImage { background-position: center center; background-size: cover; @@ -30,6 +37,7 @@ margin-top: -52px; padding: 7rem 2rem 7rem 2.5rem; overflow: hidden; + &:after { content: ''; position: absolute; @@ -40,6 +48,7 @@ height: 100%; background-image: var(--clr-header-pseudo); } + &:before { content: ''; position: absolute; @@ -51,15 +60,18 @@ background: black; opacity: 0.3; } - & + .songs { + + &+.songs { margin-top: -5rem; } - & + .detailPage { + + &+.detailPage { margin-top: -6.5rem; position: relative; background: var(--clr-detail-page-bg); flex-grow: 1; } + .gradient { background-image: linear-gradient(120deg, rgb(132, 250, 176) 0%, rgb(143, 211, 244) 100%); position: absolute; @@ -71,6 +83,7 @@ opacity: 0.3; z-index: -1; } + .bgImage { position: absolute; top: 0; @@ -83,32 +96,39 @@ margin: -10%; z-index: -1; } + h2 { color: #fff; user-select: text; } + .header-content { position: relative; z-index: 4; } + .bp3-select { border: 1px solid rgba(255, 255, 255, 0.2); margin-right: 0.9rem; border-radius: 6px; + select { color: white; + &:focus, &:active { outline: none !important; } } + &:focus, &:active { outline: none !important; } + &:after { color: white; } } } -} +} \ No newline at end of file diff --git a/src/renderer/_shared/PageHeader/components/TogglePlayButton.tsx b/src/renderer/_shared/PageHeader/components/TogglePlayButton.tsx index b26411ab..47e1175f 100755 --- a/src/renderer/_shared/PageHeader/components/TogglePlayButton.tsx +++ b/src/renderer/_shared/PageHeader/components/TogglePlayButton.tsx @@ -1,10 +1,11 @@ import * as actions from '@common/store/actions'; import { startPlayMusic } from '@common/store/actions'; import { PlayerStatus } from '@common/store/player'; -import { getPlayerStatus, isPlayingSelector } from '@common/store/selectors'; +import { getPlayerStatusSelector, isPlayingSelector } from '@common/store/selectors'; import { PlaylistIdentifier } from '@common/store/types'; import { Normalized } from '@types'; import cn from 'classnames'; +import { stopForwarding } from 'electron-redux'; import React, { FC, useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; @@ -18,14 +19,14 @@ interface Props { } export const TogglePlayButton: FC = ({ className, idResult, colored, large, playlistID }) => { - const playerStatus = useSelector(getPlayerStatus); + const playerStatus = useSelector(getPlayerStatusSelector); const isPlaying = useSelector(isPlayingSelector(playlistID, idResult)); const isPlayerPlaylist = false; const dispatch = useDispatch(); const onStartPlay = useCallback(() => { - dispatch(startPlayMusic({ idResult, origin: playlistID })); + dispatch(stopForwarding(startPlayMusic({ idResult, origin: playlistID }))); }, [dispatch, idResult, playlistID]); const togglePlay = useCallback( diff --git a/src/renderer/_shared/PlaylistTrackList.tsx b/src/renderer/_shared/PlaylistTrackList.tsx index 536a4936..4783b53c 100644 --- a/src/renderer/_shared/PlaylistTrackList.tsx +++ b/src/renderer/_shared/PlaylistTrackList.tsx @@ -4,28 +4,29 @@ import { PlaylistIdentifier } from '@common/store/types'; import { useLoadMorePromise } from '@renderer/hooks/useLoadMorePromise'; import Spinner from '@renderer/_shared/Spinner/Spinner'; import { TrackList } from '@renderer/_shared/TrackList/TrackList'; +import { stopForwarding } from 'electron-redux'; import React, { FC, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; export interface Props { - id: PlaylistIdentifier; + playlistID: PlaylistIdentifier; } -export const PlaylistTrackList: FC = ({ id }) => { +export const PlaylistTrackList: FC = ({ playlistID }) => { const dispatch = useDispatch(); - const playlist = useSelector(getPlaylistObjectSelector(id)); + const playlist = useSelector(getPlaylistObjectSelector(playlistID)); useEffect(() => { - dispatch(getGenericPlaylist.request({ refresh: true, ...id })); + dispatch(stopForwarding(getGenericPlaylist.request({ refresh: true, ...playlistID }))); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dispatch, id]); + }, [dispatch, playlistID]); const { loadMore } = useLoadMorePromise( playlist?.isFetching, () => { - dispatch(genericPlaylistFetchMore.request(id)); + dispatch(stopForwarding(genericPlaylistFetchMore.request(playlistID))); }, - [dispatch, id] + [dispatch, playlistID] ); if (!playlist) { @@ -34,7 +35,7 @@ export const PlaylistTrackList: FC = ({ id }) => { return ( (({ title, username, permalink: rawPermalink }) => { + const dispatch = useDispatch(); let text = `Listen to "${title || username}"`; if (title) { @@ -29,38 +31,42 @@ const ShareMenuItem = React.memo(({ title, username, permalink: rawPermal { - // tslint:disable-next-line:max-line-length - IPC.openExternal( - `https://twitter.com/intent/tweet?hashtags=SoundCloudForDesktop,Auryo&related=Auryoapp&via=Auryoapp&text=${text}&url=${permalink}` + dispatch( + openExternalUrl( + `https://twitter.com/intent/tweet?hashtags=SoundCloudForDesktop,Auryo&related=Auryoapp&via=Auryoapp&text=${text}&url=${permalink}` + ) ); }} /> { - // tslint:disable-next-line:max-line-length - IPC.openExternal( - `https://www.facebook.com/dialog/share?quote=${text}%20via%20Auryo&hashtag=%23SoundCloud&app_id=${CONFIG.FB_APP_ID}&display=popup&href=${permalink}&redirect_uri=http://auryo.com` + dispatch( + openExternalUrl( + `https://www.facebook.com/dialog/share?quote=${text}%20via%20Auryo&hashtag=%23SoundCloud&app_id=${CONFIG.FB_APP_ID}&display=popup&href=${permalink}&redirect_uri=http://auryo.com` + ) ); }} /> { - // tslint:disable-next-line:max-line-length - IPC.openExternal( - `https://www.facebook.com/dialog/send?app_id=${CONFIG.FB_APP_ID}&link=${permalink}&redirect_uri=http://auryo.com` + dispatch( + openExternalUrl( + `https://www.facebook.com/dialog/send?app_id=${CONFIG.FB_APP_ID}&link=${permalink}&redirect_uri=http://auryo.com` + ) ); }} /> { - // tslint:disable-next-line:max-line-length - IPC.openExternal( - `mailto:?&subject=Checkout this ${ - title ? 'track' : 'artist' - } on Soundcloud&body=${text}%20${permalink}%20via%20http%3A//auryo.com` + dispatch( + openExternalUrl( + `mailto:?&subject=Checkout this ${ + title ? 'track' : 'artist' + } on Soundcloud&body=${text}%20${permalink}%20via%20http%3A//auryo.com` + ) ); }} /> @@ -68,7 +74,7 @@ const ShareMenuItem = React.memo(({ title, username, permalink: rawPermal { - IPC.writeToClipboard(permalink); + dispatch(copyToClipboard(permalink)); }} /> diff --git a/src/renderer/_shared/TextShortener.tsx b/src/renderer/_shared/TextShortener.tsx index b6ca93ca..a35304e9 100644 --- a/src/renderer/_shared/TextShortener.tsx +++ b/src/renderer/_shared/TextShortener.tsx @@ -12,5 +12,5 @@ export const TextShortener = React.memo(({ text, clamp }) => { return {text}; } - return ; + return ; }); diff --git a/src/renderer/_shared/TrackList/TrackList.tsx b/src/renderer/_shared/TrackList/TrackList.tsx index 1f602496..50e546ba 100755 --- a/src/renderer/_shared/TrackList/TrackList.tsx +++ b/src/renderer/_shared/TrackList/TrackList.tsx @@ -1,9 +1,9 @@ import { Normalized } from '@types'; -import React from 'react'; +import React, { FC, useCallback } from 'react'; import ReactList from 'react-list'; import { InfiniteScroll } from '../InfiniteScroll'; import Spinner from '../Spinner/Spinner'; -import TrackListItem from './TrackListItem/TrackListItem'; +import { TrackListItem } from './TrackListItem/TrackListItem'; import { PlaylistIdentifier } from '@common/store/types'; interface Props { @@ -17,26 +17,22 @@ interface Props { loadMore?(): Promise; } -export const TrackList: React.SFC = ({ - items, - id, - hideFirstTrack, - isLoading = false, - loadMore, - hasMore = false -}) => { - function renderItem(index: number) { - // using a spread because we don't want to unshift the original list - const showedItems = [...items]; +export const TrackList: FC = ({ items, id, hideFirstTrack, isLoading = false, loadMore, hasMore = false }) => { + const renderItem = useCallback( + (index: number) => { + // using a spread because we don't want to unshift the original list + const showedItems = [...items]; - if (hideFirstTrack) { - showedItems.shift(); - } + if (hideFirstTrack) { + showedItems.shift(); + } - const item = showedItems[index]; + const item = showedItems[index]; - return ; - } + return ; + }, + [hideFirstTrack, id, items] + ); function renderWrapper(children: JSX.Element[], ref: string) { return ( diff --git a/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.tsx b/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.tsx index d3b661bd..91084866 100755 --- a/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.tsx +++ b/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.tsx @@ -1,129 +1,161 @@ import { IMAGE_SIZES } from '@common/constants'; -import * as actions from '@common/store/actions'; -import { getTrackEntity, isPlayingSelector } from '@common/store/selectors'; +import { startPlayMusic } from '@common/store/actions'; +import { getMusicEntity, isPlayingSelector } from '@common/store/selectors'; +import { PlaylistIdentifier } from '@common/store/types'; import { abbreviateNumber, getReadableTime, SC } from '@common/utils'; import cn from 'classnames'; -import React from 'react'; -import { connect } from 'react-redux'; +import { stopForwarding } from 'electron-redux'; +import React, { FC, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; -import { bindActionCreators, Dispatch } from 'redux'; -import { Normalized } from '../../../../types'; +import { Normalized, SoundCloud } from '../../../../types'; import { ActionsDropdown } from '../../ActionsDropdown'; import FallbackImage from '../../FallbackImage'; import { TogglePlayButton } from '../../PageHeader/components/TogglePlayButton'; import { TextShortener } from '../../TextShortener'; import './TrackListItem.scss'; -import { StoreState } from 'AppReduxTypes'; -import { PlaylistIdentifier } from '@common/store/types'; -interface OwnProps { +interface Props { idResult: Normalized.NormalizedResult; - playlistId: PlaylistIdentifier; + playlistID: PlaylistIdentifier; } -const mapStateToProps = (state: StoreState, props: OwnProps) => { - const { idResult, playlistId } = props; - - return { - isTrackPlaying: isPlayingSelector(idResult, playlistId.objectId || '')(state), - track: getTrackEntity(idResult.id)(state) - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - playTrack: actions.playTrackO - }, - dispatch - ); - -type PropsFromState = ReturnType; -type PropsFromDispatch = ReturnType; +export const TrackListItem: FC = ({ playlistID, idResult }) => { + const isTrackPlaying = useSelector(isPlayingSelector(playlistID, idResult)); + const track = useSelector(getMusicEntity(idResult)); + const dispatch = useDispatch(); -type AllProps = OwnProps & PropsFromState & PropsFromDispatch; + const playTrack = useCallback(() => { + dispatch(stopForwarding(startPlayMusic({ idResult, origin: playlistID }))); + }, [dispatch, idResult, playlistID]); -class TrackListItem extends React.PureComponent { - public playTrack(doubleClick: boolean, e: React.MouseEvent) { - const { playTrack, currentPlaylistId, idResult } = this.props; - - if (doubleClick) { - e.preventDefault(); - } - - playTrack(currentPlaylistId, { id: idResult.id }, true); + if (!track || !track.title) { + return null; } - public renderToggleButton = () => { - const { isTrackPlaying, idResult } = this.props; - - if (isTrackPlaying) { - return ; - } - - const icon = isTrackPlaying ? 'pause' : 'play'; - - return ( - { - this.playTrack(true, e); - }}> - - - ); - }; - - public render() { - const { track, isTrackPlaying } = this.props; - - if (!track || !track.title) { - return null; - } - - return ( - { - this.playTrack(false, e); - }}> - -
- - - {SC.isStreamable(track) ? this.renderToggleButton() : null} -
- - -
- - - -
-
- - - {abbreviateNumber(track.likes_count)} - - - {abbreviateNumber(track.reposts_count)} -
- - - - {track.user.username} - - {getReadableTime(track.duration, true, true)} - - - - - ); - } -} + return ( + { + playTrack(); + }}> + +
+ + + {SC.isStreamable(track) ? ( + + ) : null} +
+ + +
+ + + +
+
+ + + {abbreviateNumber(track.likes_count)} + + + {abbreviateNumber(track.reposts_count)} +
+ + + + {track.user.username} + + {getReadableTime(track.duration, true, true)} + + + + + ); +}; -export default connect( - mapStateToProps, - mapDispatchToProps -)(TrackListItem); +// class TrackListItem extends React.PureComponent { +// public playTrack(doubleClick: boolean, e: React.MouseEvent) { +// const { playTrack, currentPlaylistId, idResult } = this.props; + +// if (doubleClick) { +// e.preventDefault(); +// } + +// playTrack(currentPlaylistId, { id: idResult.id }, true); +// } + +// public renderToggleButton = () => { +// const { isTrackPlaying, idResult } = this.props; + +// if (isTrackPlaying) { +// return ; +// } + +// const icon = isTrackPlaying ? 'pause' : 'play'; + +// return ( +// { +// this.playTrack(true, e); +// }}> +// +// +// ); +// }; + +// public render() { +// const { track, isTrackPlaying } = this.props; + +// if (!track || !track.title) { +// return null; +// } + +// return ( +// { +// this.playTrack(false, e); +// }}> +// +//
+// +// +// {SC.isStreamable(track) ? this.renderToggleButton() : null} +//
+// +// +//
+// +// +// +//
+//
+// + +// {abbreviateNumber(track.likes_count)} + +// +// {abbreviateNumber(track.reposts_count)} +//
+// + +// +// {track.user.username} +// +// {getReadableTime(track.duration, true, true)} +// +// +// +// +// ); +// } +// } + +// export default connect( +// mapStateToProps, +// mapDispatchToProps +// )(TrackListItem); diff --git a/src/renderer/_shared/TracksGrid/TrackGridRow.tsx b/src/renderer/_shared/TracksGrid/TrackGridRow.tsx index d2243613..98ef6a54 100644 --- a/src/renderer/_shared/TracksGrid/TrackGridRow.tsx +++ b/src/renderer/_shared/TracksGrid/TrackGridRow.tsx @@ -3,7 +3,7 @@ import { PlaylistIdentifier } from '@common/store/playlist/types'; import cn from 'classnames'; import React, { FC, useCallback, useMemo } from 'react'; import { TrackGridItem } from './TrackgridItem/TrackGridItem'; -import TrackGridUser from './TrackgridUser/TrackGridUser'; +import { TrackGridUser } from './TrackgridUser/TrackGridUser'; interface Props { data: { diff --git a/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItem.tsx b/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItem.tsx index 8f6932d5..a4005014 100755 --- a/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItem.tsx +++ b/src/renderer/_shared/TracksGrid/TrackgridItem/TrackGridItem.tsx @@ -68,7 +68,7 @@ export const TrackGridItem: FC = ({ idResult, playlistID, showReposts, sh
- +
@@ -77,7 +77,7 @@ export const TrackGridItem: FC = ({ idResult, playlistID, showReposts, sh
); - }, [playlistID, showInfo, track]); + }, [showInfo, track]); const image = SC.getImageUrl(track, IMAGE_SIZES.LARGE); diff --git a/src/renderer/_shared/TracksGrid/TrackgridUser/TrackGridUser.tsx b/src/renderer/_shared/TracksGrid/TrackgridUser/TrackGridUser.tsx index 14af6d55..147205f2 100755 --- a/src/renderer/_shared/TracksGrid/TrackgridUser/TrackGridUser.tsx +++ b/src/renderer/_shared/TracksGrid/TrackgridUser/TrackGridUser.tsx @@ -1,103 +1,67 @@ import { IMAGE_SIZES } from '@common/constants'; import * as actions from '@common/store/actions'; -import { isFollowing, getUserEntity } from '@common/store/selectors'; +import { getUserEntity, isFollowing } from '@common/store/selectors'; import { abbreviateNumber, SC } from '@common/utils'; import cn from 'classnames'; -import React from 'react'; -import { connect } from 'react-redux'; +import React, { FC } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; -import { bindActionCreators, Dispatch } from 'redux'; import FallbackImage from '../../FallbackImage'; import './TrackGridUser.scss'; -import { StoreState } from 'AppReduxTypes'; -const mapStateToProps = (state: StoreState, props: OwnProps) => { - const { userId } = props; - - return { - isAuthUserFollowing: userId ? isFollowing(userId)(state) : null, - trackUser: getUserEntity(userId)(state) - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - toggleFollowing: actions.toggleFollowing - }, - dispatch - ); - -interface OwnProps { +interface Props { userId: number; withStats?: boolean; } -type PropsFromState = ReturnType; -type PropsFromDispatch = ReturnType; - -type AllProps = OwnProps & PropsFromState & PropsFromDispatch; - -// TODO: use hooks -class TrackGridUser extends React.PureComponent { - public static defaultProps: Partial = { - withStats: false - }; +export const TrackGridUser: FC = ({ userId, withStats }) => { + const dispatch = useDispatch(); + const trackUser = useSelector(getUserEntity(userId)); + const isAuthUserFollowing = useSelector(isFollowing(userId)); - public render() { - const { trackUser, isAuthUserFollowing, toggleFollowing, withStats } = this.props; + if (!trackUser) return null; - if (!trackUser) { - return null; - } + // eslint-disable-next-line, camelcase + const { id, username, avatar_url, followers_count, track_count } = trackUser; - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - const { id, username, avatar_url, followers_count, track_count } = trackUser; + const imgUrl = SC.getImageUrl(avatar_url, IMAGE_SIZES.SMALL); - const imgUrl = SC.getImageUrl(avatar_url, IMAGE_SIZES.SMALL); - - return ( -
-
-
-
- -
-
- - {username} - + return ( +
+
+
+
+ +
+
+ + {username} + - {withStats && ( -
-
- - {abbreviateNumber(followers_count)} -
-
- - {abbreviateNumber(track_count)} -
+ {withStats && ( + +
+ + {abbreviateNumber(track_count)} +
+
+ )} + { + dispatch(actions.toggleFollowing.request({ userId })); + }}> + {isAuthUserFollowing ? : } + {isAuthUserFollowing ? 'Following' : 'Follow'} +
- ); - } -} - -export default connect( - mapStateToProps, - mapDispatchToProps -)(TrackGridUser); +
+ ); +}; diff --git a/src/renderer/_shared/TracksGrid/TracksGrid.tsx b/src/renderer/_shared/TracksGrid/TracksGrid.tsx index 95505ce7..af226eb6 100755 --- a/src/renderer/_shared/TracksGrid/TracksGrid.tsx +++ b/src/renderer/_shared/TracksGrid/TracksGrid.tsx @@ -1,11 +1,11 @@ import { PlaylistIdentifier } from '@common/store/playlist/types'; import { Normalized } from '@types'; import cn from 'classnames'; -import React, { SFC, useContext, useEffect, useRef } from 'react'; +import React, { FC, useEffect, useRef } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeList as List } from 'react-window'; import InfiniteLoader from 'react-window-infinite-loader'; -import { ContentContext } from '../context/contentContext'; +import { useContentContext } from '../context/contentContext'; import Spinner from '../Spinner/Spinner'; import { TrackGridRow } from './TrackGridRow'; import * as styles from './TracksGrid.module.scss'; @@ -26,10 +26,10 @@ function getRowsForWidth(width: number): number { return Math.floor(width / 255); } -const TracksGrid: SFC = props => { +const TracksGrid: FC = props => { const { items, showInfo, isItemLoaded, loadMore, hasMore, isLoading, playlistID } = props; const loaderRef = useRef(null); - const { setList } = useContext(ContentContext); + const { setList } = useContentContext(); const listRef = loaderRef?.current?._listRef; useEffect(() => { diff --git a/src/renderer/_shared/context/contentContext.tsx b/src/renderer/_shared/context/contentContext.tsx index c65d7795..59b1b48a 100644 --- a/src/renderer/_shared/context/contentContext.tsx +++ b/src/renderer/_shared/context/contentContext.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useContext, useEffect } from 'react'; import { FixedSizeList } from 'react-window'; export const INITIAL_LAYOUT_SETTINGS: LayoutSettings = { @@ -74,3 +74,5 @@ const SetLayoutSettingsComponent: React.SFC = ( }; export const SetLayoutSettings = withContentContext(SetLayoutSettingsComponent); + +export const useContentContext = () => useContext(ContentContext); diff --git a/src/renderer/app/ContentWrapper.tsx b/src/renderer/app/ContentWrapper.tsx new file mode 100644 index 00000000..24cf01f8 --- /dev/null +++ b/src/renderer/app/ContentWrapper.tsx @@ -0,0 +1,93 @@ +import { Position } from '@blueprintjs/core'; +// eslint-disable-next-line import/no-cycle +import { ContentContext, INITIAL_LAYOUT_SETTINGS } from '@renderer/_shared/context/contentContext'; +import { debounce } from 'lodash'; +import React, { FC, useCallback, useLayoutEffect, useRef, useState } from 'react'; +import Scrollbars from 'react-custom-scrollbars'; +import { useHistory, useLocation } from 'react-router-dom'; +import { FixedSizeList } from 'react-window'; +import ErrorBoundary from '../_shared/ErrorBoundary'; +import { Toastr } from './components/Toastr'; + +export const ContentWrapper: FC = ({ children }) => { + const contentRef = useRef(null); + const history = useHistory(); + const location = useLocation(); + + const [settings, setSettings] = useState(INITIAL_LAYOUT_SETTINGS); + const [isScrolling, setIsScrolling] = useState(false); + const [scrollLocations, setScrollLocations] = useState({}); + const [list, setList] = useState(null); + + // If we go back and know the scrollLocation of the previous page, scroll to it + useLayoutEffect(() => { + const unregister = history.listen((_location, action) => { + const previousScrollTop = scrollLocations[_location.pathname] || 0; + + if (!isScrolling) { + const scrollTo = action === 'POP' ? previousScrollTop : 0; + + setIsScrolling(true); + + requestAnimationFrame(() => { + // Scroll content to correct place + contentRef.current?.scrollTop(scrollTo); + + setIsScrolling(false); + }); + } + }); + + return () => unregister(); + }, [history, isScrolling, scrollLocations]); + + const debouncedSetScrollPosition = useRef( + debounce( + (scrollTop: number, pathname: string) => { + setScrollLocations(scrollLocations => ({ + ...scrollLocations, + [pathname]: scrollTop + })); + }, + 100, + { maxWait: 200 } + ) + ); + + const handleScroll = useCallback( + (e: React.ChangeEvent) => { + const { scrollTop } = e.target; + + if (list) { + list.scrollTo(scrollTop); + } + + debouncedSetScrollPosition.current(scrollTop, location.pathname); + }, + [list, location.pathname] + ); + + return ( + setList(newList), + applySettings: newSettings => setSettings(oldSettings => ({ ...oldSettings, ...newSettings })) + }}> +
} + renderTrackHorizontal={() =>
} + renderTrackVertical={props =>
} + renderThumbHorizontal={() =>
} + renderThumbVertical={props =>
}> + + + {children} + + + ); +}; diff --git a/src/renderer/app/Layout.tsx b/src/renderer/app/Layout.tsx index 10c74c94..ce8d75b6 100644 --- a/src/renderer/app/Layout.tsx +++ b/src/renderer/app/Layout.tsx @@ -1,287 +1,80 @@ -import { Intent, IResizeEntry, Position, ResizeSensor } from '@blueprintjs/core'; +import { IResizeEntry, ResizeSensor } from '@blueprintjs/core'; import { EVENTS } from '@common/constants/events'; import * as actions from '@common/store/actions'; -// eslint-disable-next-line import/no-cycle -import { ContentContext, INITIAL_LAYOUT_SETTINGS, LayoutSettings } from '@renderer/_shared/context/contentContext'; +import { loadingErrorSelector } from '@common/store/app/selectors'; +import { themeSelector } from '@common/store/selectors'; import cn from 'classnames'; -import { autobind } from 'core-decorators'; // eslint-disable-next-line import/no-extraneous-dependencies import { ipcRenderer } from 'electron'; -import * as is from 'electron-is'; -import { UnregisterCallback } from 'history'; -import { debounce } from 'lodash'; -import React from 'react'; +import is from 'electron-is'; +import React, { FC, useCallback } from 'react'; import Theme from 'react-custom-properties'; -import Scrollbars from 'react-custom-scrollbars'; -import { connect } from 'react-redux'; -import { RouteComponentProps, withRouter } from 'react-router-dom'; -import { FixedSizeList } from 'react-window'; -import { bindActionCreators, compose, Dispatch } from 'redux'; +import { useDispatch, useSelector } from 'react-redux'; import { AudioPlayerProvider } from '../hooks/useAudioPlayer'; -import ErrorBoundary from '../_shared/ErrorBoundary'; import AppError from './components/AppError/AppError'; import AboutModal from './components/modals/AboutModal/AboutModal'; import ChangelogModal from './components/modals/ChangeLogModal/ChangelogModal'; -import Player from './components/player/Player'; +import { Player } from './components/player/Player'; import SideBar from './components/Sidebar/Sidebar'; import { Themes } from './components/Theme/themes'; -import { Toastr } from './components/Toastr'; -import { StoreState } from 'AppReduxTypes'; +import { ContentWrapper } from './ContentWrapper'; -const mapStateToProps = (state: StoreState) => { - const { - app: { offline, loaded, loadingError }, - config, - player, - ui - } = state; +export const Layout: FC = ({ children }) => { + const dispatch = useDispatch(); - return { - playingTrack: player.playingTrack, - theme: config.app.theme, - toasts: ui.toasts, + const theme = useSelector(themeSelector); + const loadingError = useSelector(loadingErrorSelector); - offline, - loaded, - loadingError - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - addToast: actions.addToast, - clearToasts: actions.clearToasts, - removeToast: actions.removeToast, - setDebouncedDimensions: actions.setDebouncedDimensions, - toggleOffline: actions.toggleOffline + // TODO: can this be removed? + const onResize = useCallback( + ([ + { + contentRect: { width, height } + } + ]: IResizeEntry[]) => { + // dispatch( + // actions.setDebouncedDimensions({ + // height, + // width + // }) + // ); }, - dispatch + [dispatch] ); -interface State { - isScrolling: boolean; - settings: LayoutSettings; - list?: FixedSizeList | null; - scrollLocations: { - [path: string]: number; - }; -} - -type PropsFromState = ReturnType; - -type PropsFromDispatch = ReturnType; - -type AllProps = PropsFromState & - PropsFromDispatch & - RouteComponentProps & { - children(props: { scrollTop: number }): React.ReactNode; - }; - -@autobind -class Layout extends React.Component { - public state: State = { - settings: INITIAL_LAYOUT_SETTINGS, - isScrolling: false, - scrollLocations: {} - }; - - private readonly contentRef: React.RefObject = React.createRef(); - private readonly debouncedSetScrollPosition: (scrollTop: number, pathname: string) => any; - private unregister?: UnregisterCallback; - - constructor(props: AllProps) { - super(props); - - this.debouncedSetScrollPosition = debounce( - (scrollTop, pathname) => { - this.setState(state => ({ - scrollLocations: { - ...state.scrollLocations, - [pathname]: scrollTop - } - })); - }, - 100, - { maxWait: 200 } - ); - } - - public componentDidMount() { - window.addEventListener('online', this.setOnlineStatus); - window.addEventListener('offline', this.setOnlineStatus); - - this.handlePreviousScrollPositionOnBack(); - } - - public componentDidUpdate(prevProps: AllProps) { - const { offline, addToast, removeToast } = this.props; - - if (offline !== prevProps.offline && offline === true) { - addToast({ - key: 'offline', - intent: Intent.PRIMARY, - message: 'You are currently offline.' - }); - } else if (offline !== prevProps.offline && offline === false) { - removeToast('offline'); - } - } - - public componentWillUnmount() { - window.removeEventListener('online', this.setOnlineStatus); - window.removeEventListener('offline', this.setOnlineStatus); - - if (this.unregister) { - this.unregister(); - } - } - - private setOnlineStatus() { - const { toggleOffline } = this.props; - - toggleOffline(!navigator.onLine); - } - - private handleResize([ - { - contentRect: { width, height } - } - ]: IResizeEntry[]) { - const { setDebouncedDimensions } = this.props; - - setDebouncedDimensions({ - height, - width - }); - } - - private handleScroll(e: React.ChangeEvent) { - const { scrollTop } = e.target; - const { location } = this.props; - const { list } = this.state; - - if (list) { - list.scrollTo(scrollTop); - } - this.debouncedSetScrollPosition(scrollTop, location.pathname); - } - - private handlePreviousScrollPositionOnBack() { - const { history } = this.props; - this.unregister = history.listen((_location, action) => { - const { isScrolling, scrollLocations } = this.state; - const previousScrollTop = scrollLocations[_location.pathname] || 0; - - if (!isScrolling) { - const scrollTo = action === 'POP' ? previousScrollTop : 0; - - this.setState( - { - isScrolling: true - }, - () => { - requestAnimationFrame(() => { - // Scroll content to correct place - if (this.contentRef.current) { - this.contentRef.current.scrollTop(scrollTo); - } - - this.setState({ - isScrolling: false - }); - }); - } - ); - } - }); - } - - // tslint:disable-next-line: max-func-body-length - public render() { - const { - // Vars - offline, - loaded, - loadingError, - playingTrack, - children, - theme, - location, - - // Functions - toasts, - clearToasts - } = this.props; - - const { settings, list, scrollLocations } = this.state; - - const scrollTop = scrollLocations[location.pathname] || 0; - - return ( - - -
- {loadingError ? ( - { - ipcRenderer.send(EVENTS.APP.RELOAD); - }} - /> - ) : null} - -
- - - this.setState({ list: newList }), - applySettings: newSettings => { - this.setState(({ settings: oldSettings }) => ({ - settings: { ...oldSettings, ...newSettings } - })); - } - }}> -
} - renderTrackHorizontal={() =>
} - renderTrackVertical={props =>
} - renderThumbHorizontal={() =>
} - renderThumbVertical={props =>
}> - - - {children({ scrollTop })} - - - - - - -
- - {/* Register Modals */} - - - -
-
-
- ); - } -} - -export default compose(withRouter, connect(mapStateToProps, mapDispatchToProps))(Layout); + return ( + + +
+ {loadingError ? ( + { + ipcRenderer.send(EVENTS.APP.RELOAD); + }} + /> + ) : null} + +
+ + + {children} + + + + +
+ + {/* Register Modals */} + + + +
+
+
+ ); +}; diff --git a/src/renderer/app/Main.tsx b/src/renderer/app/Main.tsx index 1a63ebf5..6108bcc7 100644 --- a/src/renderer/app/Main.tsx +++ b/src/renderer/app/Main.tsx @@ -1,7 +1,6 @@ -import * as actions from '@common/store/actions'; import { PlaylistTypes } from '@common/store/objects'; import { GenericPlaylist } from '@renderer/pages/GenericPlaylist'; -import Settings from '@renderer/pages/settings/Settings'; +import { Settings } from '@renderer/pages/settings/Settings'; import React, { FC, useCallback } from 'react'; import { useDispatch } from 'react-redux'; import { Redirect, Route, RouteComponentProps, Switch } from 'react-router-dom'; @@ -14,8 +13,8 @@ import { SearchPage } from '../pages/search/SearchPage'; import { TagsPage } from '../pages/tags/TagsPage'; import { TrackPage } from '../pages/track/TrackPage'; import Spinner from '../_shared/Spinner/Spinner'; -import Header from './components/Header/Header'; -import Layout from './Layout'; +import { Header } from './components/Header/Header'; +import { Layout } from './Layout'; type Props = RouteComponentProps; @@ -29,18 +28,13 @@ const Main: FC = ({ location: { search } }) => { return ; } - dispatch(actions.resolveUrl(url)); + // TODO + // dispatch(actions.resolveUrl(url)); return ; // eslint-disable-next-line react-hooks/exhaustive-deps }, [search]); - // return ( - // - // ); - const renderGenericPlaylist = useCallback( (options: { title: string; playlistType: PlaylistTypes; showInfo?: boolean }) => () => ( = ({ location: { search } }) => { return ( - {({ scrollTop }: any) => ( - <> -
- - - - - - - - - - - - - - - - - - )} + <> +
+ + + + + + + + + + + + + + + + + ); }; diff --git a/src/renderer/app/components/Header/Header.scss b/src/renderer/app/components/Header/Header.scss index 945061df..d439d1ec 100644 --- a/src/renderer/app/components/Header/Header.scss +++ b/src/renderer/app/components/Header/Header.scss @@ -1,22 +1,28 @@ @import "../../../css/bootstrap.imports.scss"; -.sticky-outer-wrapper.sticky { +.sticky { position: relative; z-index: 9; } +.navbar-wrapper { + + transition: .25s background ease-in-out; +} + .header-wrapper { + .bp3-popover-target { transform: rotate(90deg) } &.withImage { - background: rgba(255, 255, 255, 0.15); + background: rgba(65, 56, 56, 0.15); position: relative; z-index: 5; - .sticky-outer-wrapper:not(.sticky) { + &>div>div:not(.sticky) { .bp3-popover-target svg { color: white; } @@ -68,37 +74,8 @@ .navbar-wrapper { background: var(--clr-navbar-bg-sticky); - transition: .5s tranform; } } - - - .sticky-1 { - .navbar-wrapper { - box-shadow: 3px 3px 15px rgba(0, 0, 0, 0.03), 3px 3px 30px rgba(0, 0, 0, 0.01); - } - } - - .sticky-2 { - .navbar-wrapper { - box-shadow: 3px 3px 15px rgba(0, 0, 0, 0.06), 3px 3px 30px rgba(0, 0, 0, 0.03); - } - } - - .sticky-3 { - .navbar-wrapper { - box-shadow: 3px 3px 15px rgba(0, 0, 0, 0.11), 3px 3px 30px rgba(0, 0, 0, 0.05); - } - } -} - -.sticky-appear { - transform: translateY(-52px); -} - -.sticky-appear.sticky-appear-active { - transform: translateY(0); - transition: all 300ms ease-in-out; } .navbar { diff --git a/src/renderer/app/components/Header/Header.tsx b/src/renderer/app/components/Header/Header.tsx index 3e17c3af..9ff4f033 100644 --- a/src/renderer/app/components/Header/Header.tsx +++ b/src/renderer/app/components/Header/Header.tsx @@ -1,265 +1,100 @@ import { Icon, Menu, MenuDivider, MenuItem, Popover, Position } from '@blueprintjs/core'; import { EVENTS } from '@common/constants/events'; -import * as actions from '@common/store/actions'; +import { logout } from '@common/store/actions'; +import { isUpdateAvailableSelector } from '@common/store/app/selectors'; import { currentUserSelector } from '@common/store/selectors'; -import { InjectedContentContextProps, withContentContext } from '@renderer/_shared/context/contentContext'; -import { StoreState } from 'AppReduxTypes'; +import { useContentContext } from '@renderer/_shared/context/contentContext'; import cn from 'classnames'; -import * as ReactRouter from 'connected-react-router'; -import { autobind } from 'core-decorators'; // eslint-disable-next-line import/no-extraneous-dependencies import { ipcRenderer } from 'electron'; -import { isEqual } from 'lodash'; -import React from 'react'; -import { connect } from 'react-redux'; -import { RouteComponentProps, withRouter } from 'react-router-dom'; -import Sticky from 'react-stickynode'; -import { bindActionCreators, compose, Dispatch } from 'redux'; -import * as ReduxModal from 'redux-modal'; +import React, { FC } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import Sticky from 'react-sticky-el'; +// import Sticky from 'react-stickynode'; +import { show } from 'redux-modal'; +import { HistoryNavigation } from './components/HistoryNavigation'; +import { SearchBox } from './components/Search/SearchBox'; +import User from './components/User/User'; import './Header.scss'; -import SearchBox from './Search/SearchBox'; -import User from './User/User'; -const mapStateToProps = (state: StoreState) => { - const { app } = state; - - return { - update: app.update, - currentUser: currentUserSelector(state), - locHistory: app.history - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - logout: actions.logout, - show: ReduxModal.show, - push: ReactRouter.push, - replace: ReactRouter.replace, - setDebouncedSearchQuery: actions.setDebouncedSearchQuery - }, - dispatch - ); - -interface OwnProps { - children?: React.ReactNode; - className?: string; - scrollTop: number; - query?: string; - focus?: boolean; -} - -type PropsFromState = ReturnType; - -type PropsFromDispatch = ReturnType; - -interface State { - height: number; -} - -type AllProps = OwnProps & PropsFromState & PropsFromDispatch & RouteComponentProps & InjectedContentContextProps; - -@autobind -class Header extends React.Component { - public readonly state: State = { - height: 0 - }; - - private navBarWrapper = React.createRef(); - private search = React.createRef(); - public static readonly defaultProps: Partial = { - className: '', - query: '', - focus: false, - children: null - }; - - public componentDidMount() { - const { focus } = this.props; - - if (this.navBarWrapper.current) { - this.setState({ - height: this.navBarWrapper.current.clientHeight - }); - } - - if (focus && this.search.current) { - this.search.current.focus(); - } - } - - public shouldComponentUpdate(nextProps: AllProps, nextState: State) { - const { scrollTop, locHistory, currentUser, update, location, settings } = this.props; - - return ( - !isEqual(locHistory, nextProps.locHistory) || - !isEqual(location.pathname, nextProps.location.pathname) || - !isEqual(settings, nextProps.settings) || - currentUser !== nextProps.currentUser || - (this.navBarWrapper.current && nextState.height !== this.navBarWrapper.current.clientHeight) || - nextProps.update !== update || - scrollTop !== nextProps.scrollTop - ); - } - - public componentDidUpdate(prevProps: AllProps) { - const { height } = this.state; - const { location } = this.props; - - if (this.navBarWrapper.current && height !== this.navBarWrapper.current.clientHeight) { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ - height: this.navBarWrapper.current.clientHeight - }); - } - - if (location.pathname !== prevProps.location.pathname) { - if (this.navBarWrapper.current) { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ - height: this.navBarWrapper.current.clientHeight - }); - } - } - } - - public goBack() { - const { - locHistory: { back }, - history - } = this.props; - - if (back) { - history.goBack(); - } - } - - public goForward() { - const { - locHistory: { next }, - history - } = this.props; - - if (next) { - history.goForward(); - } - } - - public showUtilitiesModal(activeTab: string) { - const { show } = this.props; - - show('utilities', { - activeTab - }); - } - - public doUpdate() { - ipcRenderer.send(EVENTS.APP.UPDATE); - } - - public handleSearch(prev: string, rawQuery?: string) { - const { push, replace, setDebouncedSearchQuery } = this.props; - - setDebouncedSearchQuery(rawQuery || ''); - } - - // tslint:disable-next-line: max-func-body-length - public render() { - const { - locHistory: { next, back }, - currentUser, - logout, - scrollTop, - settings, - query, - children, - update, - push - } = this.props; - - const { height } = this.state; - - const isSticky = scrollTop - 52 > 0; - - return ( -
- -
- +
{children}
+
+
+
+ ); +}; diff --git a/src/renderer/app/components/Header/Search/SearchBox.tsx b/src/renderer/app/components/Header/Search/SearchBox.tsx deleted file mode 100644 index a1f35768..00000000 --- a/src/renderer/app/components/Header/Search/SearchBox.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { autobind } from 'core-decorators'; -// eslint-disable-next-line import/no-extraneous-dependencies -import { ipcRenderer } from 'electron'; -import { debounce } from 'lodash'; -import React from 'react'; -import './SearchBox.scss'; - -interface Props { - className?: string; - initialValue?: string; - value?: string; - handleSearch?(previousQuery: string, currentValue?: string): void; -} - -interface State { - query: string; -} - -@autobind -class SearchBox extends React.Component { - private searchInput = React.createRef(); - public static readonly defaultProps: Partial = { - value: '', - className: 'globalSearch', - initialValue: '' - }; - - constructor(props: Props) { - super(props); - - this.state = { - query: props.initialValue || props.value || '' - }; - } - - public componentDidMount() { - ipcRenderer.on('keydown:search', this.focus); - } - - public shouldComponentUpdate(_nextProps: Props, nextState: State) { - const { query } = this.state; - - return nextState.query !== query; - } - - public componentWillUnmount() { - ipcRenderer.removeListener('keydown:search', this.focus); - } - - public onChange(event: React.FormEvent) { - const { query } = this.state; - - if (this.searchInput.current) { - this.handleSearch(query, event.currentTarget.value); - } - - this.setState({ query: event.currentTarget.value }); - } - - public onKeyPress(event: React.KeyboardEvent) { - const { query } = this.state; - - if (event.key === 'Enter') { - this.handleSearch(query, event.currentTarget.value); - } - } - - public setValue(query: string) { - this.setState({ query }); - } - - public handleSearch(oldValue: string, currentValue?: string) { - const { handleSearch } = this.props; - - if (handleSearch) { - handleSearch(oldValue, currentValue); - } - } - - public focus() { - if (this.searchInput.current) { - this.searchInput.current.focus(); - } - } - - public render() { - const { className } = this.props; - const { query } = this.state; - - return ( -
-
- - - -
- e.stopPropagation()} - onChange={this.onChange} - /> - - -
- ); - } -} - -export default SearchBox; diff --git a/src/renderer/app/components/Header/components/HistoryNavigation.tsx b/src/renderer/app/components/Header/components/HistoryNavigation.tsx new file mode 100644 index 00000000..46573b4c --- /dev/null +++ b/src/renderer/app/components/Header/components/HistoryNavigation.tsx @@ -0,0 +1,35 @@ +import cn from 'classnames'; +import React, { FC, useCallback } from 'react'; +import { useHistory } from 'react-router-dom'; + +export const HistoryNavigation: FC = () => { + const history = useHistory(); + + const canGoBack = (history as any).canGo(-1); + const canGoForward = (history as any).canGo(1); + + const goBack = useCallback(() => { + if (canGoBack) { + history.goBack(); + } + }, [canGoBack, history]); + + const goForward = useCallback(() => { + if (canGoForward) { + history.goForward(); + } + }, [canGoForward, history]); + + return ( +
+ +
+ ); +}; diff --git a/src/renderer/app/components/Header/Search/SearchBox.scss b/src/renderer/app/components/Header/components/Search/SearchBox.scss similarity index 85% rename from src/renderer/app/components/Header/Search/SearchBox.scss rename to src/renderer/app/components/Header/components/Search/SearchBox.scss index 50ae8f37..de7da1b7 100644 --- a/src/renderer/app/components/Header/Search/SearchBox.scss +++ b/src/renderer/app/components/Header/components/Search/SearchBox.scss @@ -1,7 +1,8 @@ -@import "../../../../css/bootstrap.imports.scss"; +@import "../../../../../css/bootstrap.imports.scss"; .input-group.search-box { + i { background: transparent; border: none; @@ -18,6 +19,7 @@ transition: .25s ease-out transform; font-size: 1.1rem; } + .input-group-text { background: transparent; border: 0; @@ -31,13 +33,14 @@ transition: .1s ease-in transform; font-size: .95rem; outline: none !important; + color: var(--clr-body-text); &::-webkit-input-placeholder { color: #ADADAD; } &:placeholder-shown { - & + .input-group-append #clear { + &+.input-group-append #clear { pointer-events: none; transform: scale(0); } diff --git a/src/renderer/app/components/Header/components/Search/SearchBox.tsx b/src/renderer/app/components/Header/components/Search/SearchBox.tsx new file mode 100644 index 00000000..b09e53d3 --- /dev/null +++ b/src/renderer/app/components/Header/components/Search/SearchBox.tsx @@ -0,0 +1,63 @@ +import { setDebouncedSearchQuery } from '@common/store/actions'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { ipcRenderer } from 'electron'; +import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import './SearchBox.scss'; + +export const SearchBox: FC = () => { + const [query, setQuery] = useState(''); + const searchInput = useRef(null); + const dispatch = useDispatch(); + + const focus = useCallback(() => { + if (searchInput?.current) searchInput.current?.focus(); + }, []); + + const updateQuery = useCallback( + (query: string) => { + setQuery(query); + dispatch(setDebouncedSearchQuery(query)); + }, + [dispatch] + ); + + useEffect(() => { + ipcRenderer.on('keydown:search', focus); + return () => { + ipcRenderer.removeListener('keydown:search', focus); + }; + }, [focus]); + + return ( +
+
+ + + +
+ { + if (event.key === 'Enter') { + dispatch(setDebouncedSearchQuery(query)); + } + }} + onKeyUp={e => e.preventDefault()} + onChange={event => updateQuery(event.target.value)} + /> + + +
+ ); +}; diff --git a/src/renderer/app/components/Header/User/User.scss b/src/renderer/app/components/Header/components/User/User.scss similarity index 100% rename from src/renderer/app/components/Header/User/User.scss rename to src/renderer/app/components/Header/components/User/User.scss diff --git a/src/renderer/app/components/Header/User/User.tsx b/src/renderer/app/components/Header/components/User/User.tsx similarity index 100% rename from src/renderer/app/components/Header/User/User.tsx rename to src/renderer/app/components/Header/components/User/User.tsx diff --git a/src/renderer/app/components/Queue/Queue.scss b/src/renderer/app/components/Queue/Queue.scss index 1df4f30c..150c821a 100644 --- a/src/renderer/app/components/Queue/Queue.scss +++ b/src/renderer/app/components/Queue/Queue.scss @@ -1,150 +1,168 @@ @import "../../../css/bootstrap.imports.scss"; -.playing { - .playQueue { - height: calc(100% - 130px); - margin-bottom: 86px; - } -} .playQueue { - will-change: transform; - width: 300px; - flex-shrink: 0; - background: $black; - color: #fff; - z-index: 100009; - box-shadow: 3px 3px 15px rgba(0, 0, 0, 0.11), 3px 3px 30px rgba(0, 0, 0, 0.05); - border-radius: 5px; - overflow: hidden; - max-height: 500px; - height: calc(100vh - 120px); - &:after { - width: 0; - height: 0; - border-style: solid; - border-width: 12px 10px 0 10px; - border-color: #222326 transparent transparent transparent; - content: ""; - position: absolute; - right: 0; - margin-right: 10px; - } - .playqueue-title { - padding: 10px 10px; - border-bottom: 1px solid grey; - font-size: 0.9rem; - height: 44px; - a { - color: #fff; - text-decoration: none; - transition: 0.2s ease-in; - &:active { - transform: rotate(90deg); - transition: 0.5s all; - } - } - } - .tracks { - height: calc(100% - 44px); - min-height: 60px; - position: relative; - overflow: hidden; - } - .track { - height: 60px; - border-left: 4px solid transparent; - cursor: pointer; - color: #f9f9f9; - padding: 0 3rem 0 0.6rem; - font-size: 0.9rem; - text-decoration: none !important; - .stats { - color: #e4e4e4; - opacity: 0.8; - font-size: 0.75rem; - margin-bottom: 0; - a { - color: #e4e4e4; - } - .stat { - span { - padding-right: 3px; - } - &:not(:first-child) { - span:before { - content: ""; - width: 5px; - height: 5px; - background: white; - display: inline-block; - vertical-align: middle; - margin: 0 5px; - border-radius: 100%; - } - } - } - i { - font-size: 0.8rem; - } - } - img { - flex-shrink: 0; - } - .item-info { - padding-left: 7px; - width: 100%; - max-height: 100%; - .title { - width: 100%; - color: #f1f1f1; - } - } - .image-wrap { - position: relative; - } - .title a { - color: #f1f1f1; - } - &:hover { - background: lighten($black, 4%); - } - &.played { - .item-info, - img { - opacity: 0.6; - } - } - &.playing { - border-left: 4px solid theme-color("primary"); - .title { - color: theme-color("primary"); - } - } - } + will-change: transform; + width: 300px; + flex-shrink: 0; + background: $black; + color: #fff; + z-index: 100009; + box-shadow: 3px 3px 15px rgba(0, 0, 0, 0.11), 3px 3px 30px rgba(0, 0, 0, 0.05); + border-radius: 5px; + overflow: hidden; + max-height: 500px; + height: calc(100vh - 120px); + + &:after { + width: 0; + height: 0; + border-style: solid; + border-width: 12px 10px 0 10px; + border-color: #222326 transparent transparent transparent; + content: ""; + position: absolute; + right: 0; + margin-right: 10px; + } + + .playqueue-title { + padding: 10px 10px; + border-bottom: 1px solid grey; + font-size: 0.9rem; + height: 44px; + + a { + color: #fff; + text-decoration: none; + transition: 0.2s ease-in; + + &:active { + transform: rotate(90deg); + transition: 0.5s all; + } + } + } + + .tracks { + height: calc(100% - 44px); + min-height: 60px; + position: relative; + overflow: hidden; + } + + .track { + height: 60px; + border-left: 4px solid transparent; + cursor: pointer; + color: #f9f9f9; + padding: 0 3rem 0 0.6rem; + font-size: 0.9rem; + text-decoration: none !important; + + .stats { + color: #e4e4e4; + opacity: 0.8; + font-size: 0.75rem; + margin-bottom: 0; + + a { + color: #e4e4e4; + } + + .stat { + span { + padding-right: 3px; + } + + &:not(:first-child) { + span:before { + content: ""; + width: 5px; + height: 5px; + background: white; + display: inline-block; + vertical-align: middle; + margin: 0 5px; + border-radius: 100%; + } + } + } + + i { + font-size: 0.8rem; + } + } + + img { + flex-shrink: 0; + } + + .item-info { + padding-left: 7px; + width: 100%; + max-height: 100%; + + .title { + width: 100%; + color: #f1f1f1; + } + } + + .image-wrap { + position: relative; + } + + .title a { + color: #f1f1f1; + } + + &:hover { + background: lighten($black, 4%); + } + + &.played { + + .item-info, + img { + opacity: 0.6; + } + } + + &.playing { + border-left: 4px solid theme-color("primary"); + + .title { + color: theme-color("primary"); + } + } + } } .queueItem { - position: relative; - .actions-dropdown { - position: absolute; - right: 0; - top: 0; - padding: 1rem; - a { - color: #fff; - text-decoration: none; - i { - font-size: 1.2rem; - } - } - } + position: relative; + + .actions-dropdown { + position: absolute; + right: 0; + top: 0; + padding: 1rem; + + a { + color: #fff; + text-decoration: none; + + i { + font-size: 1.2rem; + } + } + } } .clearQueue { - border: 1px solid; - padding: 3px 8px; - border-radius: 5px; - margin-right: 1rem; - font-size: 0.7rem; - text-transform: uppercase; -} + border: 1px solid; + padding: 3px 8px; + border-radius: 5px; + margin-right: 1rem; + font-size: 0.7rem; + text-transform: uppercase; +} \ No newline at end of file diff --git a/src/renderer/app/components/Queue/Queue.tsx b/src/renderer/app/components/Queue/Queue.tsx index 0a0433b2..a26a8281 100644 --- a/src/renderer/app/components/Queue/Queue.tsx +++ b/src/renderer/app/components/Queue/Queue.tsx @@ -1,31 +1,41 @@ import { Classes } from '@blueprintjs/core'; +import { clearUpNext, genericPlaylistFetchMore } from '@common/store/actions'; import { - getPlayingTrack, + getUpNextSelector, + getPlayingTrackSelector, getPlayingTrackIndex, - getPlaylistsObjects, getQueuePlaylistSelector, - getPlayerUpNext + getPlaylistsObjects } from '@common/store/selectors'; -import { Normalized } from '@types'; +import { PlaylistIdentifier, PlaylistTypes } from '@common/store/types'; +import { useLoadMorePromise } from '@renderer/hooks/useLoadMorePromise'; +import Spinner from '@renderer/_shared/Spinner/Spinner'; +import { stopForwarding } from 'electron-redux'; +import { debounce } from 'lodash'; import React, { FC, useCallback, useEffect, useMemo, useRef } from 'react'; import Scrollbars from 'react-custom-scrollbars'; import ReactList from 'react-list'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; +import { useDebounce } from 'react-use'; import './Queue.scss'; -import QueueItem from './QueueItem'; +import { QueueItem } from './QueueItem'; export const Queue: FC = () => { const listRef = useRef(null); const currentIndex = useSelector(getPlayingTrackIndex); - const playingTrack = useSelector(getPlayingTrack); - const playlists = useSelector(getPlaylistsObjects); + const playingTrack = useSelector(getPlayingTrackSelector); const queue = useSelector(getQueuePlaylistSelector); - const upNext = useSelector(getPlayerUpNext); + const playlists = useSelector(getPlaylistsObjects); + const upNext = useSelector(getUpNextSelector); + const dispatch = useDispatch(); useEffect(() => { - if (currentIndex != null && listRef) { - listRef.current?.scrollTo(currentIndex); - console.log('scrollTo', listRef.current); + if (currentIndex != null && listRef.current) { + const visibleRanges = listRef.current.getVisibleRange(); + + if (currentIndex < visibleRanges[0] || currentIndex > visibleRanges[1]) { + listRef.current.scrollTo(currentIndex); + } } }, [listRef, currentIndex, playingTrack]); @@ -37,25 +47,56 @@ export const Queue: FC = () => { return queueItemsWithUpnext; }, [currentIndex, queue, upNext]); - const onScroll = () => { - // const { updateQueue } = this.props; - // if (this.list.current) { - // updateQueue(this.list.current.getVisibleRange()); - // } - }; + const { loadMore } = useLoadMorePromise( + queue?.isFetching, + () => { + if (!listRef.current) return; + + // Check and fetch remaining tracks from subplaylist when scrolled to in Queue + const visibleRanges = listRef.current.getVisibleRange(); + const length = visibleRanges[1] + 10 - visibleRanges[0]; + + const playlistIDsToFetch = Array.from(Array(length).keys()).reduce( + (playlistsToFetch, _, index) => { + const itemIndex = visibleRanges[0] + index; + const item = items[itemIndex]; + + if (item?.parentPlaylistID?.playlistType === PlaylistTypes.PLAYLIST && item.parentPlaylistID.objectId) { + const playlist = playlists[item.parentPlaylistID.objectId]; + if ( + !playlist.isFetching && + !!playlist.itemsToFetch.length && + !playlistsToFetch.find(p => p.objectId === item.parentPlaylistID?.objectId) + ) { + playlistsToFetch.push(item.parentPlaylistID); + } + } + + return playlistsToFetch; + }, + [] + ); + + playlistIDsToFetch.forEach(playlistID => { + dispatch(stopForwarding(genericPlaylistFetchMore.request(playlistID))); + }); + + // Fetch more items from Queue when almost out of tracks + if (items.length - 10 < visibleRanges[0]) { + dispatch(stopForwarding(genericPlaylistFetchMore.request({ playlistType: PlaylistTypes.QUEUE }))); + } + }, + [dispatch] + ); + + const loadMoreDebounced = useRef(debounce(loadMore, 200, { maxWait: 300 })); const renderTrack = useCallback( (index: number, key: number | string) => { const item = items[index]; return ( - + ); }, [currentIndex, items] @@ -66,16 +107,16 @@ export const Queue: FC = () => {
Play Queue
- {/* {upNext.length > 0 && ( + {upNext.length > 0 && ( { - clearUpNext(); + dispatch(clearUpNext()); }}> Clear - )} */} + )} @@ -83,7 +124,7 @@ export const Queue: FC = () => {
} renderTrackVertical={props =>
} renderThumbHorizontal={() =>
} @@ -97,6 +138,7 @@ export const Queue: FC = () => { useTranslate3d itemRenderer={renderTrack} /> + {queue?.isFetching && }
diff --git a/src/renderer/app/components/Queue/QueueItem.tsx b/src/renderer/app/components/Queue/QueueItem.tsx index aee4a582..74bd21e0 100644 --- a/src/renderer/app/components/Queue/QueueItem.tsx +++ b/src/renderer/app/components/Queue/QueueItem.tsx @@ -1,144 +1,108 @@ import { IMAGE_SIZES } from '@common/constants'; -import * as actions from '@common/store/actions'; -import { getTrackEntity, getCurrentPlaylistId } from '@common/store/selectors'; -import { PlayingTrack } from '@common/store/player'; +import { playTrack, playTrackFromQueue, removeFromQueueOrUpNext, startPlayMusic } from '@common/store/actions'; +import { getCurrentPlaylistId, getTrackEntity, isTrackLoading } from '@common/store/selectors'; +import { ObjectStateItem, PlaylistTypes } from '@common/store/types'; import { SC } from '@common/utils'; import cn from 'classnames'; -import React from 'react'; -import { connect } from 'react-redux'; +import { stopForwarding } from 'electron-redux'; +import React, { FC, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; -import { bindActionCreators, Dispatch } from 'redux'; import { ActionsDropdown } from '../../../_shared/ActionsDropdown'; import FallbackImage from '../../../_shared/FallbackImage'; import { TextShortener } from '../../../_shared/TextShortener'; -import { StoreState } from 'AppReduxTypes'; - -const mapStateToProps = (state: StoreState, props: OwnProps) => { - const { trackData } = props; - - return { - track: trackData ? getTrackEntity(trackData.id)(state) : null, - currentPlaylistId: getCurrentPlaylistId(state) - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - playTrack: actions.playTrackO - }, - dispatch - ); - -interface OwnProps { - trackData: PlayingTrack; - index: number; +interface Props { + item: ObjectStateItem; played: boolean; playing: boolean; + index: number; } -type PropsFromState = ReturnType; - -type PropsFromDispatch = ReturnType; - -type AllProps = OwnProps & PropsFromState & PropsFromDispatch; - -class QueueItem extends React.PureComponent { - // tslint:disable-next-line: max-func-body-length - public render() { - const { - // Vars - track, - index, - currentPlaylistId, - playing, - played, - trackData, +export const QueueItem: FC = ({ playing, played, item, index }) => { + const dispatch = useDispatch(); + const currentPlaylistId = useSelector(getCurrentPlaylistId); + const trackLoading = useSelector(isTrackLoading(item.id)); + const track = useSelector(getTrackEntity(item.id)); - // Functions - playTrack - } = this.props; + const removeFromQueue = useCallback(() => { + if (playing) return; + dispatch(removeFromQueueOrUpNext(index)); + }, [dispatch, index, playing]); - if (!currentPlaylistId) { - return null; - } + if (!currentPlaylistId) { + return null; + } - if (!track || !track.user || (track && track.loading && !track.title)) { - return ( -
-
- - + if (!track || !track.user || trackLoading) { + return ( +
+
+ + + Sorry, your browser does not support inline SVG. + +
+
+
+ + Sorry, your browser does not support inline SVG.
-
-
- - - Sorry, your browser does not support inline SVG. - -
-
- - - Sorry, your browser does not support inline SVG. - -
+
+ + + Sorry, your browser does not support inline SVG. +
- ); - } +
+ ); + } - return ( -
-
{ - if ((e.target as any).className !== 'bx bx-dots-horizontal-rounded') { - playTrack(currentPlaylistId, trackData); - } - }}> -
- + return ( +
+
{ + if ((e.target as any).className !== 'bx bx-dots-horizontal-rounded') { + dispatch(stopForwarding(playTrackFromQueue({ idResult: item, index }))); + } + }}> +
+ +
+
+
+ { + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + }} + to={`/track/${track.id}`}> + +
-
-
- { - e.stopPropagation(); - e.nativeEvent.stopImmediatePropagation(); - }} - to={`/track/${track.id}`}> - - -
-
- { - e.stopPropagation(); - e.nativeEvent.stopImmediatePropagation(); - }} - to={`/user/${track.user.id}`}> - {track.user.username} - -
+
+ { + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + }} + to={`/user/${track.user.id}`}> + {track.user.username} +
- -
- ); - } -} -export default connect( - mapStateToProps, - mapDispatchToProps -)(QueueItem); + +
+ ); +}; diff --git a/src/renderer/app/components/Sidebar/Sidebar.tsx b/src/renderer/app/components/Sidebar/Sidebar.tsx index 65af35b2..f594ea75 100755 --- a/src/renderer/app/components/Sidebar/Sidebar.tsx +++ b/src/renderer/app/components/Sidebar/Sidebar.tsx @@ -1,4 +1,5 @@ import { getAuthPlaylistsSelector, getCurrentPlaylistId } from '@common/store/selectors'; +import { PlaylistTypes } from '@common/store/types'; import React, { FC } from 'react'; import Scrollbars from 'react-custom-scrollbars'; import { useSelector } from 'react-redux'; @@ -63,7 +64,9 @@ const SideBar: FC = () => {

Playlists

{authPlaylists.map(normalizedResult => { - const isPlaying = !!currentPlaylistId && normalizedResult.id.toString() === currentPlaylistId; + const isPlaying = + currentPlaylistId?.playlistType === PlaylistTypes.PLAYLIST && + normalizedResult.id.toString() === currentPlaylistId.objectId; return ( = ({ playlistId, isPlaying }) => { const playlist = useSelector(state => getNormalizedPlaylist(playlistId)(state)); - const playerStatus = useSelector(getPlayerStatus); + const playerStatus = useSelector(getPlayerStatusSelector); const isActuallyPlaying = playerStatus === PlayerStatus.PLAYING; if (!playlist) { diff --git a/src/renderer/app/components/Toastr.tsx b/src/renderer/app/components/Toastr.tsx index 962ade2a..79f6f75e 100644 --- a/src/renderer/app/components/Toastr.tsx +++ b/src/renderer/app/components/Toastr.tsx @@ -1,32 +1,25 @@ -import { IToasterProps, IToastOptions, Toaster } from '@blueprintjs/core'; +import { IToasterProps, Toaster } from '@blueprintjs/core'; import * as actions from '@common/store/actions'; -import React from 'react'; +import { getToastsSelector } from '@common/store/selectors'; +import React, { FC, useEffect, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; -interface Props extends IToasterProps { - toasts: IToastOptions[]; - clearToasts: typeof actions.clearToasts; -} - -export class Toastr extends React.PureComponent { - private toaster = React.createRef(); - - public componentDidUpdate() { - const { toasts, clearToasts } = this.props; +export const Toastr: FC = props => { + const toasterRef = useRef(null); + const toasts = useSelector(getToastsSelector); + const dispatch = useDispatch(); + useEffect(() => { if (toasts.length) { toasts.forEach(toast => { - if (this.toaster.current) { - this.toaster.current.show(toast); + if (toasterRef.current) { + toasterRef.current.show(toast); } }); - clearToasts(); + dispatch(actions.clearToasts()); } - } - - public render() { - const { toasts, clearToasts, ...props } = this.props; + }, [dispatch, toasts]); - return ; - } -} + return ; +}; diff --git a/src/renderer/app/components/modals/AboutModal/AboutModal.tsx b/src/renderer/app/components/modals/AboutModal/AboutModal.tsx index a2ba6cd4..43507481 100644 --- a/src/renderer/app/components/modals/AboutModal/AboutModal.tsx +++ b/src/renderer/app/components/modals/AboutModal/AboutModal.tsx @@ -1,93 +1,84 @@ import logo from '@assets/img/auryo-dark.png'; -import { StoreState } from 'AppReduxTypes'; -// eslint-disable-next-line import/no-extraneous-dependencies -import { remote } from 'electron'; +import { remainingPlaysSelector } from '@common/store/app/selectors'; +import { appVersionSelector } from '@common/store/selectors'; import * as os from 'os'; -import React from 'react'; -import { connect } from 'react-redux'; +import React, { FC } from 'react'; +import { useSelector } from 'react-redux'; import { Modal, ModalBody } from 'reactstrap'; -import { compose } from 'redux'; import { connectModal, InjectedProps } from 'redux-modal'; import './AboutModal.scss'; -const mapStateToProps = ({ app }: StoreState) => ({ - remainingPlays: app.remainingPlays -}); - interface PassedProps { activeTab?: TabType; } -type PropsFromState = ReturnType; - enum TabType { ABOUT = 'about', SETTINGS = 'settings' } -type Props = PropsFromState & PassedProps & InjectedProps; +type Props = PassedProps & InjectedProps; -class UtilitiesModal extends React.PureComponent { - public render() { - const { show, handleHide, remainingPlays } = this.props; +const UtilitiesModal: FC = ({ show, handleHide }) => { + const remainingPlays = useSelector(remainingPlaysSelector); + const version = useSelector(appVersionSelector); - return ( - - -
- - - -
-
-
- logo -
-
- - - - - - - - - - - - - - - - - - - - - - - -
Version{remote.app.getVersion()}
Platform{os.platform()}
Platform version{os.release()}
Arch{os.arch()}
Remaining plays - - {remainingPlays ? remainingPlays.remaining || 'Unlimited' : 'Unknown'} - -
-
+ return ( + + +
+ + + +
+
+
+ logo +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
Version{version}
Platform{os.platform()}
Platform version{os.release()}
Arch{os.arch()}
Remaining plays + + {remainingPlays ? remainingPlays.remaining || 'Unlimited' : 'Unknown'} + +
+
-
-
- Created by Jonas Snellinckx -
-
- - @Auryoapp -
-
-
-
-
- ); - } -} +
+
+ Created by Jonas Snellinckx +
+
+ + @Auryoapp +
+
+
+
+
+ ); +}; -export default compose(connectModal({ name: 'utilities' }), connect(mapStateToProps))(UtilitiesModal); +export default connectModal({ name: 'utilities' })(UtilitiesModal); diff --git a/src/renderer/app/components/modals/SettingsModal/SettingsModal.tsx b/src/renderer/app/components/modals/SettingsModal/SettingsModal.tsx index 630409cd..01721d74 100644 --- a/src/renderer/app/components/modals/SettingsModal/SettingsModal.tsx +++ b/src/renderer/app/components/modals/SettingsModal/SettingsModal.tsx @@ -1,12 +1,12 @@ -import Settings from '@renderer/pages/settings/Settings'; -import React from 'react'; +import { Settings } from '@renderer/pages/settings/Settings'; +import React, { FC } from 'react'; import { Modal, ModalBody } from 'reactstrap'; import { connectModal, InjectedProps } from 'redux-modal'; import './SettingsModal.scss'; type Props = InjectedProps; -const SettingsModal: React.SFC = ({ show, handleHide }) => { +const SettingsModal: FC = ({ show, handleHide }) => { return (
@@ -15,7 +15,7 @@ const SettingsModal: React.SFC = ({ show, handleHide }) => {
- +
); diff --git a/src/renderer/app/components/player/Player.tsx b/src/renderer/app/components/player/Player.tsx index fab6c960..39cc9c64 100644 --- a/src/renderer/app/components/player/Player.tsx +++ b/src/renderer/app/components/player/Player.tsx @@ -1,116 +1,73 @@ import { Intent, Popover, PopoverInteractionKind, Slider, Tag } from '@blueprintjs/core'; import { IMAGE_SIZES } from '@common/constants'; import * as actions from '@common/store/actions'; -import { hasLiked, getNormalizedTrack, getNormalizedUser } from '@common/store/selectors'; +import { isPlayingOnChromecastSelector, remainingPlaysSelector } from '@common/store/app/selectors'; import { ChangeTypes, RepeatTypes } from '@common/store/player'; +import { + audioConfigSelector, + getNormalizedTrack, + getNormalizedUser, + getPlayerStatusSelector, + getPlayingTrackSelector, + isTrackLoading, + repeatSelector, + shuffleSelector +} from '@common/store/selectors'; import { SC } from '@common/utils'; -import cn from 'classnames'; -import { autobind } from 'core-decorators'; import moment from 'moment'; -import React from 'react'; -import isDeepEqual from 'react-fast-compare'; -import { connect } from 'react-redux'; -import { bindActionCreators, Dispatch } from 'redux'; +import React, { FC, useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import FallbackImage from '../../../_shared/FallbackImage'; import { Queue } from '../Queue/Queue'; import { Audio } from './components/Audio'; +import { CastPopover } from './components/CastPopover'; import PlayerControls from './components/PlayerControls/PlayerControls'; import { PlayerProgress } from './components/PlayerProgress/PlayerProgress'; import { TrackInfo } from './components/TrackInfo/TrackInfo'; import * as styles from './Player.module.scss'; -import { StoreState } from 'AppReduxTypes'; -const mapStateToProps = (state: StoreState) => { - const { player, app, config } = state; - - let track = null; - let trackUser = null; - let liked = false; - - if (player.playingTrack && player.playingTrack.id) { - track = getNormalizedTrack(player.playingTrack.id)(state); - - if (track) { - trackUser = getNormalizedUser(track.user)(state); - } - - liked = hasLiked(player.playingTrack.id)(state); - - if (!track || (track && !track.title && track.loading)) { - track = null; - } - } - - return { - track, - trackUser, - status: player.status, - playingTrack: player.playingTrack, - volume: config.audio.volume, - muted: config.audio.muted, - shuffle: config.shuffle, - repeat: config.repeat, - playbackDeviceId: config.audio.playbackDeviceId, - overrideClientId: config.app.overrideClientId, - remainingPlays: app.remainingPlays, - liked, - chromecast: app.chromecast - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - changeTrack: actions.changeTrack, - toggleStatus: actions.toggleStatus, - setConfigKey: actions.setConfigKey, - setCurrentTime: actions.setCurrentTime, - addToast: actions.addToast, - toggleShuffle: actions.toggleShuffle, - toggleLike: actions.toggleLike, - useChromeCast: actions.setChromecastDevice +export const Player: FC = () => { + const dispatch = useDispatch(); + const [isVolumeSeeking, setIsVolumeSeeking] = useState(false); + const [volume, setVolume] = useState(0); + + const playingTrack = useSelector(getPlayingTrackSelector); + const playerStatus = useSelector(getPlayerStatusSelector); + const track = useSelector(getNormalizedTrack(playingTrack?.id)); + const user = useSelector(getNormalizedUser(track?.user)); + const trackLoading = useSelector(isTrackLoading(playingTrack?.id)); + const audioConfig = useSelector(audioConfigSelector); + const shuffle = useSelector(shuffleSelector); + const repeat = useSelector(repeatSelector); + const remainingPlays = useSelector(remainingPlaysSelector); + const isPlayingOnChromecast = useSelector(isPlayingOnChromecastSelector); + + const volumeChange = useCallback( + (value: number) => { + if (audioConfig.muted) { + dispatch(actions.setConfigKey('audio.muted', false)); + } + + setIsVolumeSeeking(true); + setVolume(value); }, - dispatch + [audioConfig.muted, dispatch] ); -type PropsFromState = ReturnType; -type PropsFromDispatch = ReturnType; - -interface State { - isVolumeSeeking: boolean; - volume: number; - volumeBeforeMute: number; -} + const onVolumeRelease = useCallback( + (value: number) => { + setIsVolumeSeeking(false); -type AllProps = PropsFromState & PropsFromDispatch; - -@autobind -class Player extends React.Component { - public state: State = { - isVolumeSeeking: false, - volume: 0, - volumeBeforeMute: 0.5 - }; - - public shouldComponentUpdate(nextProps: AllProps, nextState: State) { - return nextState !== this.state || !isDeepEqual(nextProps, this.props); - } - - public volumeChange(volume: number) { - const { muted, setConfigKey } = this.props; - - if (muted) { - setConfigKey('audio.muted', false); - } - this.setState({ - volume, - isVolumeSeeking: true - }); - } + dispatch(actions.setConfigKey('audio.volume', value)); + }, + [dispatch] + ); - public toggleRepeat() { - const { setConfigKey, repeat } = this.props; + const toggleShuffle = useCallback(() => { + dispatch(actions.toggleShuffle(!shuffle)); + }, [dispatch, shuffle]); + const toggleRepeat = useCallback(() => { let newRepeatType: RepeatTypes | null = null; if (!repeat) { @@ -119,63 +76,34 @@ class Player extends React.Component { newRepeatType = RepeatTypes.ONE; } - setConfigKey('repeat', newRepeatType); - } - - public toggleMute() { - const { muted, setConfigKey, volume } = this.props; - const { volumeBeforeMute } = this.state; + dispatch(actions.setConfigKey('repeat', newRepeatType)); + }, [dispatch, repeat]); - if (muted) { - this.volumeChange(volumeBeforeMute); + const toggleMute = useCallback(() => { + if (audioConfig.muted) { + dispatch(actions.setConfigKey('audio.muted', false)); + dispatch(actions.setConfigKey('audio.volume', volume)); } else { - this.setState({ - volumeBeforeMute: volume - }); - this.volumeChange(0); + setVolume(volume); } - setConfigKey('audio.muted', !muted); - } - - public changeSong(changeType: ChangeTypes) { - const { changeTrack } = this.props; - - changeTrack(changeType); - } - - public toggleShuffle() { - const { shuffle, toggleShuffle } = this.props; - - toggleShuffle(!shuffle); - } - - public renderAudio() { - const { - playingTrack, - status, - volume: configVolume, - track, - remainingPlays, - // overrideClientId, - chromecast, - muted, - playbackDeviceId - } = this.props; - const { isVolumeSeeking, volume } = this.state; + dispatch(actions.setConfigKey('audio.muted', !audioConfig.muted)); + }, [audioConfig.muted, dispatch, volume]); + const renderAudio = useCallback(() => { if (!track || !playingTrack) { return null; } - const audioVolume = isVolumeSeeking ? volume : configVolume; + const audioVolume = isVolumeSeeking ? volume : audioConfig.volume; const limitReached = remainingPlays && remainingPlays.remaining === 0; if (remainingPlays && limitReached) { return (
- Stream limit reached! Unfortunately the API enforces a 15K plays/day limit. This limit will expire in{' '} + Stream limit reached! Unfortunately the SoundCloud API enforces a 15K plays/day limit. This limit will expire + in{' '} {moment(remainingPlays.resetTime).fromNow()} @@ -183,179 +111,101 @@ class Player extends React.Component { ); } - const playingOnChromecast = !!chromecast.castApp; - return (
+ return ( +
+
+
- ); - } -} -export default connect(mapStateToProps, mapDispatchToProps)(Player); + {renderAudio()} + +
+ + + { + dispatch(actions.changeTrack(ChangeTypes.PREV)); + }} + onNextClick={() => { + dispatch(actions.changeTrack(ChangeTypes.NEXT)); + }} + onToggleClick={() => { + dispatch(actions.toggleStatus()); + }} + /> + + + + + +
+ }> + + + + + + + + } position="bottom-right"> + + + + +
+
+ ); +}; diff --git a/src/renderer/app/components/player/components/Audio.tsx b/src/renderer/app/components/player/components/Audio.tsx index 71bbf344..9b1a6b52 100644 --- a/src/renderer/app/components/player/components/Audio.tsx +++ b/src/renderer/app/components/player/components/Audio.tsx @@ -2,11 +2,11 @@ import { Intent } from '@blueprintjs/core'; import { EVENTS } from '@common/constants'; import * as actions from '@common/store/actions'; -import { PlayerStatus } from '@common/store/player'; +import { ChangeTypes, PlayerStatus } from '@common/store/player'; import { useAudioPlayer, useAudioPosition } from '@renderer/hooks/useAudioPlayer'; // eslint-disable-next-line import/no-extraneous-dependencies import { ipcRenderer } from 'electron'; -import { FC, useCallback, useEffect, useState } from 'react'; +import { FC, memo, useCallback, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { usePrevious } from 'react-use'; import { getPlayerCurrentTime } from '@common/store/selectors'; @@ -22,7 +22,7 @@ interface Props { // TODO: use webAudio? // https://github.com/DPr00f/electron-music-player-tutorial/blob/master/app/utils/AudioController.js -export const Audio: FC = ({ src, playerStatus, playerVolume, muted, playbackDeviceId }) => { +export const Audio: FC = memo(({ src, playerStatus, playerVolume, muted, playbackDeviceId }) => { const [isSeeking, setIsSeeking] = useState(false); const { duration, position } = useAudioPosition(); const currentTime = useSelector(getPlayerCurrentTime); @@ -138,9 +138,8 @@ export const Audio: FC = ({ src, playerStatus, playerVolume, muted, playb if (wasPrevLoading && !loading && !error) { // TODO: remove // dispatch(actions.setDuration(duration)); - // TODO: can we move this to our observables? - dispatch(actions.registerPlayO()); + // dispatch(actions.registerPlayO()); } }, [loading]); @@ -188,18 +187,30 @@ export const Audio: FC = ({ src, playerStatus, playerVolume, muted, playb retry(); setTimeout(retry, 500); break; - case MediaError.MEDIA_ERR_DECODE: case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED: + dispatch( + actions.addToast({ + message: + 'We are unable to play this track. It may be that this song is not available via third party applications.', + intent: Intent.DANGER + }) + ); + + dispatch(actions.changeTrack(ChangeTypes.NEXT)); + break; + case MediaError.MEDIA_ERR_DECODE: default: dispatch( actions.addToast({ - message: 'An error occurred during playback', + message: 'Something went wrong while playing this track', intent: Intent.DANGER }) ); + + dispatch(actions.changeTrack(ChangeTypes.NEXT)); } } }, [error]); return null; -}; +}); diff --git a/src/renderer/app/components/player/components/CastPopover.tsx b/src/renderer/app/components/player/components/CastPopover.tsx new file mode 100644 index 00000000..95c209a6 --- /dev/null +++ b/src/renderer/app/components/player/components/CastPopover.tsx @@ -0,0 +1,53 @@ +import { Popover } from '@blueprintjs/core'; +import { castSelector } from '@common/store/app/selectors'; +import cn from 'classnames'; +import React, { FC } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import * as styles from '../Player.module.scss'; +import * as actions from '@common/store/actions'; + +export const CastPopover: FC = () => { + const dispatch = useDispatch(); + const chromecast = useSelector(castSelector); + + if (!chromecast.devices.length) return null; + + return ( + +
Nearby devices
+ {chromecast.devices.map(d => { + return ( +
{ + dispatch(actions.setChromecastDevice(chromecast.selectedDeviceId === d.id ? undefined : d.id)); + }}> + {chromecast.selectedDeviceId === d.id && } +
+ {d.name} +
+ {chromecast.selectedDeviceId === d.id && !chromecast.castApp && 'Connecting...'} + {chromecast.selectedDeviceId === d.id && chromecast.castApp ? 'Casting' : null} +
+
+
+ ); + })} +
+ }> + + + + + ); +}; diff --git a/src/renderer/app/components/player/components/TrackInfo/TrackInfo.tsx b/src/renderer/app/components/player/components/TrackInfo/TrackInfo.tsx index 8dbd0aec..72befb6c 100644 --- a/src/renderer/app/components/player/components/TrackInfo/TrackInfo.tsx +++ b/src/renderer/app/components/player/components/TrackInfo/TrackInfo.tsx @@ -1,5 +1,9 @@ +import * as actions from '@common/store/actions'; +import { hasLiked } from '@common/store/selectors'; +import { SoundCloud } from '@types'; import cn from 'classnames'; -import React from 'react'; +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; import FallbackImage from '../../../../../_shared/FallbackImage'; import { TextShortener } from '../../../../../_shared/TextShortener'; @@ -9,31 +13,36 @@ interface Props { img: string; title: string; id: string; - userId: string; - username: string; - - liked: boolean; - toggleLike(): void; + user: SoundCloud.User; } -export const TrackInfo = React.memo(({ img, title, id, userId, username, liked, toggleLike }) => ( -
-
- - - - -
-
- - - - - - +export const TrackInfo = React.memo(({ img, title, id, user }) => { + const isLiked = useSelector(hasLiked(id)); + const dispatch = useDispatch(); + + const toggleLike = useCallback(() => { + dispatch(actions.toggleLike.request(id)); + }, [dispatch, id]); + + return ( +
+
+ + + + +
+
+ + + + + + +
-
-)); + ); +}); diff --git a/src/renderer/css/app.scss b/src/renderer/css/app.scss index 397d22e0..e661a297 100644 --- a/src/renderer/css/app.scss +++ b/src/renderer/css/app.scss @@ -36,9 +36,6 @@ body { color: #3e3e3e; } -.playing .custom-scroll { - height: calc(100% - 56px); -} .ReactVirtualized__Grid { outline: none !important; @@ -401,6 +398,7 @@ i { } } + .trackHeader { position: relative; color: #fff; @@ -588,12 +586,6 @@ main { height: 100%; } -.playing { - .f-height { - height: calc(100% - 56px); - } -} - .grad-blue:after { content: ''; position: absolute; diff --git a/src/renderer/index.tsx b/src/renderer/index.tsx index 5a31e030..8eedf985 100755 --- a/src/renderer/index.tsx +++ b/src/renderer/index.tsx @@ -1,16 +1,16 @@ import '@blueprintjs/core/lib/css/blueprint.css'; import '@blueprintjs/icons/lib/css/blueprint-icons.css'; import '@common/sentryReporter'; -import store, { history } from '@common/store'; +import { history, configureStore } from '@common/store'; import { initApp } from '@common/store/actions'; import 'boxicons/css/boxicons.min.css'; +import { ConnectedRouter } from 'connected-react-router'; import is from 'electron-is'; import React from 'react'; import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; import { App } from './App'; import './css/app.scss'; -import { Provider } from 'react-redux'; -import { ConnectedRouter } from 'connected-react-router'; let osClass = ''; @@ -30,6 +30,8 @@ if (process.env.NODE_ENV === 'development') { // whyDidYouUpdate(React); } +const store = configureStore(); + store.dispatch(initApp()); ReactDOM.render( diff --git a/src/renderer/pages/GenericPlaylist/index.tsx b/src/renderer/pages/GenericPlaylist/index.tsx index aec939cc..b2f06280 100644 --- a/src/renderer/pages/GenericPlaylist/index.tsx +++ b/src/renderer/pages/GenericPlaylist/index.tsx @@ -1,10 +1,12 @@ -import { getGenericPlaylist, genericPlaylistFetchMore } from '@common/store/actions'; -import { PlaylistTypes } from '@common/store/types'; -import { getPlaylistObjectSelector } from '@common/store/selectors'; +import { genericPlaylistFetchMore, getGenericPlaylist } from '@common/store/actions'; import { SortTypes } from '@common/store/playlist/types'; +import { getPlaylistObjectSelector } from '@common/store/selectors'; +import { PlaylistTypes } from '@common/store/types'; import { useLoadMorePromise } from '@renderer/hooks/useLoadMorePromise'; import { SetLayoutSettings } from '@renderer/_shared/context/contentContext'; -import React, { FC, useCallback, useEffect } from 'react'; +import { TogglePlayButton } from '@renderer/_shared/PageHeader/components/TogglePlayButton'; +import { stopForwarding } from 'electron-redux'; +import React, { FC, useCallback, useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import PageHeader from '../../_shared/PageHeader/PageHeader'; import Spinner from '../../_shared/Spinner/Spinner'; @@ -33,18 +35,21 @@ export const GenericPlaylist: FC = ({ }) => { const dispatch = useDispatch(); const isChart = playlistType === PlaylistTypes.CHART; - const playlistObject = useSelector(getPlaylistObjectSelector({ objectId, playlistType })); + const playlistID = useMemo(() => ({ objectId, playlistType }), [objectId, playlistType]); + const playlistObject = useSelector(getPlaylistObjectSelector(playlistID)); // Do initial fetch for playlist useEffect(() => { dispatch( - getGenericPlaylist.request({ - playlistType, - // TODO: For the stream page, do not refresh automatically. Show a button to refresh instead - refresh: false, - sortType, - objectId - }) + stopForwarding( + getGenericPlaylist.request({ + playlistType, + // TODO: For the stream page, do not refresh automatically. Show a button to refresh instead + refresh: playlistType !== PlaylistTypes.STREAM, + sortType, + objectId + }) + ) ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [sortType]); @@ -52,7 +57,7 @@ export const GenericPlaylist: FC = ({ const { loadMore } = useLoadMorePromise( playlistObject?.isFetching, () => { - dispatch(genericPlaylistFetchMore.request({ playlistType, objectId })); + dispatch(stopForwarding(genericPlaylistFetchMore.request({ playlistType, objectId }))); }, [dispatch, playlistType] ); @@ -70,6 +75,9 @@ export const GenericPlaylist: FC = ({ ); }, [onSortTypeChange, sortType]); + const isEmpty = !playlistObject?.isFetching && playlistObject?.items?.length === 0; + const hasItems = playlistObject?.items?.length; + if (!playlistObject || (playlistObject && playlistObject.isFetching && !playlistObject.items.length)) { return ; } @@ -77,10 +85,15 @@ export const GenericPlaylist: FC = ({ return ( <> - + <> - {isChart && renderChartSort()} -

{title}

+ {isChart ? ( + renderChartSort() + ) : ( +
+ {!!hasItems && !isEmpty && } +
+ )}
diff --git a/src/renderer/pages/artist/ArtistPage.tsx b/src/renderer/pages/artist/ArtistPage.tsx index 88ddb90a..023354d3 100644 --- a/src/renderer/pages/artist/ArtistPage.tsx +++ b/src/renderer/pages/artist/ArtistPage.tsx @@ -1,6 +1,6 @@ import { Menu, MenuItem, Popover, Position } from '@blueprintjs/core'; import { IMAGE_SIZES } from '@common/constants'; -import { getUser } from '@common/store/actions'; +import { getUser, openExternalUrl } from '@common/store/actions'; import { PlaylistTypes } from '@common/store/objects'; import { currentUserSelector, getNormalizedUserForPage, isUserError, isUserLoading } from '@common/store/selectors'; import { abbreviateNumber, SC } from '@common/utils'; @@ -9,6 +9,7 @@ import { SetLayoutSettings } from '@renderer/_shared/context/contentContext'; import { ToggleFollowButton } from '@renderer/_shared/PageHeader/components/ToggleFollowButton'; import { PlaylistTrackList } from '@renderer/_shared/PlaylistTrackList'; import cn from 'classnames'; +import { stopForwarding } from 'electron-redux'; import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { RouteComponentProps } from 'react-router-dom'; @@ -95,7 +96,7 @@ export const ArtistPage: FC = ({ // Fetch user if it does not exist yet useEffect(() => { if (!artist && !loading) { - dispatch(getUser.request({ refresh: true, userId: +artistId })); + dispatch(stopForwarding(getUser.request({ refresh: true, userId: +artistId }))); } }, [loading, artist, error, artistId, dispatch]); @@ -141,7 +142,7 @@ export const ArtistPage: FC = ({ { - IPC.openExternal(artist.permalink_url); + dispatch(openExternalUrl(artist.permalink_url)); }} /> @@ -188,17 +189,17 @@ export const ArtistPage: FC = ({ {/* Tracks */} - {activeTab === TabTypes.TRACKS && } + {activeTab === TabTypes.TRACKS && } {/* Tracks */} - {activeTab === TabTypes.TOP_TRACKS && } + {activeTab === TabTypes.TOP_TRACKS && } {/* Likes */} - {activeTab === TabTypes.LIKES && } + {activeTab === TabTypes.LIKES && } diff --git a/src/renderer/pages/artist/components/ArtistProfiles/ArtistProfiles.tsx b/src/renderer/pages/artist/components/ArtistProfiles/ArtistProfiles.tsx index 2aef6ff6..58035031 100644 --- a/src/renderer/pages/artist/components/ArtistProfiles/ArtistProfiles.tsx +++ b/src/renderer/pages/artist/components/ArtistProfiles/ArtistProfiles.tsx @@ -1,5 +1,6 @@ import { getUserProfiles } from '@common/store/actions'; import { getNormalizedUserProfiles, isUserProfilesError, isUserProfilesLoading } from '@common/store/selectors'; +import { stopForwarding } from 'electron-redux'; import React, { FC, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { SoundCloud } from '../../../../../types'; @@ -55,7 +56,7 @@ export const ArtistProfiles: FC = ({ userUrn, className }) => { // Fetch user if it does not exist yet useEffect(() => { if (!profiles && !loading) { - dispatch(getUserProfiles.request({ userUrn })); + dispatch(stopForwarding(getUserProfiles.request({ userUrn }))); } }, [loading, error, dispatch, profiles, userUrn]); diff --git a/src/renderer/pages/charts/ChartsDetailsPage.tsx b/src/renderer/pages/charts/ChartsDetailsPage.tsx index d120d421..eee53b7a 100644 --- a/src/renderer/pages/charts/ChartsDetailsPage.tsx +++ b/src/renderer/pages/charts/ChartsDetailsPage.tsx @@ -1,10 +1,10 @@ -import { AUDIO_GENRES, GenreConfig, MUSIC_GENRES } from '@common/constants'; +import { AUDIO_GENRES, MUSIC_GENRES } from '@common/constants'; +import { PlaylistTypes } from '@common/store/objects'; import { SortTypes } from '@common/store/playlist/types'; import React, { FC, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { GenericPlaylist } from '../GenericPlaylist'; import { GENRE_IMAGES } from './genreImages'; -import { PlaylistTypes } from '@common/store/objects'; type Props = RouteComponentProps<{ genre: string }>; diff --git a/src/renderer/pages/charts/ChartsPage.scss b/src/renderer/pages/charts/ChartsPage.scss index e9962ba8..007a7860 100644 --- a/src/renderer/pages/charts/ChartsPage.scss +++ b/src/renderer/pages/charts/ChartsPage.scss @@ -1,8 +1,11 @@ @import "../../css/bootstrap.imports.scss"; .tabs { + margin-top: 1.8rem; + &.nav-tabs { border: none; + .nav-link { font-size: .8rem; background: var(--clr-tabs-bg); @@ -17,17 +20,19 @@ &.active { border-color: var(--clr-tabs-border-active); - color: theme-color("primary") !important; font-weight: $font-weight-bold; + + background: var(--clr-transparent-tabs-active-bg); + color: var(--clr-transparent-tabs-active-text); } } } - padding-bottom: 2rem; } + .charts { - padding: 0 2.5rem !important; + padding: 0 30px !important; .chart { position: relative; @@ -35,6 +40,7 @@ border-radius: 8px; overflow: hidden; margin-bottom: 2.5rem; + &.withImage { min-height: 100px; } @@ -43,10 +49,12 @@ padding: 1.5rem 0; width: 100%; margin-bottom: 1.5rem; + h1 { font-size: 1rem; } } + &:after { content: ''; position: absolute; @@ -58,6 +66,7 @@ z-index: 1; opacity: .3; } + i { position: absolute; font-size: 5rem; @@ -80,6 +89,7 @@ z-index: 1; opacity: .5; } + h1 { position: absolute; left: 0; @@ -92,6 +102,7 @@ font-size: 1.5rem; z-index: 3; } + img { max-width: 100%; } @@ -99,20 +110,25 @@ } .my-masonry-grid { - display: -webkit-box; /* Not needed if autoprefixing */ - display: -ms-flexbox; /* Not needed if autoprefixing */ + display: -webkit-box; + /* Not needed if autoprefixing */ + display: -ms-flexbox; + /* Not needed if autoprefixing */ display: flex; - margin-left: -30px; /* gutter size offset */ + margin-left: -30px; + /* gutter size offset */ width: auto; } .my-masonry-grid_column { - padding-left: 30px; /* gutter size */ + padding-left: 30px; + /* gutter size */ background-clip: padding-box; } // Style your items -.my-masonry-grid_column > div { /* change div to reference your elements you put in */ +.my-masonry-grid_column>div { + /* change div to reference your elements you put in */ background: grey; margin-bottom: 30px; } \ No newline at end of file diff --git a/src/renderer/pages/charts/ChartsPage.tsx b/src/renderer/pages/charts/ChartsPage.tsx index f47efa84..17774c70 100644 --- a/src/renderer/pages/charts/ChartsPage.tsx +++ b/src/renderer/pages/charts/ChartsPage.tsx @@ -21,9 +21,7 @@ export const ChartsPage: FC = ({ match: { params } }) => { return ( <> - - -
+ + +
diff --git a/src/renderer/pages/foryou/ForYouPage.module.scss b/src/renderer/pages/foryou/ForYouPage.module.scss index 3f1dba3a..2022b686 100644 --- a/src/renderer/pages/foryou/ForYouPage.module.scss +++ b/src/renderer/pages/foryou/ForYouPage.module.scss @@ -1,4 +1,5 @@ @import '../../css/bootstrap.imports.scss'; + .subtitle { color: rgb(118, 124, 137); font-weight: 600; @@ -7,12 +8,14 @@ } .container { - padding: 20px 2.5rem 2.5rem; + padding: 20px 30px 30px; + &>.playlists { .showMore { display: none; } } + :global(div:not(:first-of-type)) { .header { margin-top: 3rem; @@ -30,8 +33,6 @@ } .showMore { - background: white; - color: #1a97ea !important; border-radius: 100%; box-shadow: 0 10px 60px rgba(0, 0, 0, 0.03), 0 6px 50px rgba(0, 0, 0, 0.1); transition: .5s box-shadow; @@ -44,11 +45,13 @@ left: 0; right: 0; margin: 0 auto; - border: 1px solid #e4e4e4; + background: var(--clr-transparent-tabs-active-bg); + color: var(--clr-transparent-tabs-active-text); i { font-size: 1.5rem; } + &:hover { box-shadow: 0 10px 60px rgba(0, 0, 0, 0.08), 0 6px 50px rgba(0, 0, 0, 0.25); transition: .5s box-shadow; diff --git a/src/renderer/pages/foryou/ForYouPage.tsx b/src/renderer/pages/foryou/ForYouPage.tsx index b595907b..8821b221 100644 --- a/src/renderer/pages/foryou/ForYouPage.tsx +++ b/src/renderer/pages/foryou/ForYouPage.tsx @@ -1,6 +1,7 @@ import { getForYouSelection } from '@common/store/actions'; import { getAuthPersonalizedPlaylistsSelector, getPlaylistEntities } from '@common/store/selectors'; import cn from 'classnames'; +import { stopForwarding } from 'electron-redux'; import React, { FC, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { RouteComponentProps } from 'react-router-dom'; @@ -20,7 +21,7 @@ export const ForYou: FC = () => { const playlistEntities = useSelector(getPlaylistEntities()); useEffect(() => { - dispatch(getForYouSelection.request()); + dispatch(stopForwarding(getForYouSelection.request())); }, [dispatch]); if (loading && !items?.length && !error) { diff --git a/src/renderer/pages/onboarding/OnBoarding.tsx b/src/renderer/pages/onboarding/OnBoarding.tsx index cc5a9bb1..2710a4ed 100644 --- a/src/renderer/pages/onboarding/OnBoarding.tsx +++ b/src/renderer/pages/onboarding/OnBoarding.tsx @@ -1,67 +1,32 @@ import feetonmusicbox from '@assets/img/feetonmusicbox.jpg'; import { Position } from '@blueprintjs/core'; -import { EVENTS } from '@common/constants'; import * as actions from '@common/store/actions'; -import { getAppAuth, authTokenStateSelector, configSelector } from '@common/store/selectors'; import AboutModal from '@renderer/app/components/modals/AboutModal/AboutModal'; import { Toastr } from '@renderer/app/components/Toastr'; -import { StoreState } from 'AppReduxTypes'; import cn from 'classnames'; -// eslint-disable-next-line import/no-extraneous-dependencies -import { ipcRenderer } from 'electron'; import React, { FC, useCallback, useEffect, useState } from 'react'; -import { connect } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { RouteComponentProps } from 'react-router-dom'; -import { bindActionCreators, Dispatch } from 'redux'; -import * as ReduxModal from 'redux-modal'; import { LoginStep } from './components/LoginStep'; import { PrivacyStep } from './components/PrivacyStep'; import { WelcomeStep } from './components/WelcomeStep'; import './OnBoarding.scss'; -const mapStateToProps = (state: StoreState) => ({ - config: configSelector(state), - auth: authTokenStateSelector(state), - toasts: state.ui.toasts, - appAuth: getAppAuth(state) -}); - -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - show: ReduxModal.show, - clearToasts: actions.clearToasts, - finishOnboarding: actions.finishOnboarding - }, - dispatch - ); - -type OwnProps = RouteComponentProps; - -type PropsFromState = ReturnType; - -type PropsFromDispatch = ReturnType; - type Steps = 'welcome' | 'login' | 'privacy'; -type AllProps = OwnProps & PropsFromState & PropsFromDispatch & RouteComponentProps<{ step?: Steps }>; +type Props = RouteComponentProps<{ step?: Steps }>; -const OnBoarding: FC = ({ appAuth, show, config, toasts, clearToasts, match, finishOnboarding }) => { - const { +export const OnBoarding: FC = ({ + match: { params: { step: initialStep } - } = match; - const { isLoading, error } = appAuth; + } +}) => { + const dispatch = useDispatch(); const [step, setStep] = useState(initialStep ?? 'login'); - const login = () => { - if (!isLoading) { - ipcRenderer.send(EVENTS.APP.AUTH.LOGIN); - } - }; - const finish = useCallback(() => { - finishOnboarding(); - }, [finishOnboarding]); + dispatch(actions.finishOnboarding()); + }, [dispatch]); useEffect(() => { if (initialStep) { @@ -88,7 +53,7 @@ const OnBoarding: FC = ({ appAuth, show, config, toasts, clearToasts,
- {step === 'login' && } + {step === 'login' && } {step === 'welcome' && ( = ({ appAuth, show, config, toasts, clearToasts, /> )} - {step === 'privacy' && } + {step === 'privacy' && }
- +
); }; - -export default connect(mapStateToProps, mapDispatchToProps)(OnBoarding); diff --git a/src/renderer/pages/onboarding/components/LoginStep.tsx b/src/renderer/pages/onboarding/components/LoginStep.tsx index a17f7398..6b190a46 100644 --- a/src/renderer/pages/onboarding/components/LoginStep.tsx +++ b/src/renderer/pages/onboarding/components/LoginStep.tsx @@ -1,46 +1,54 @@ import logo from '@assets/img/auryo-dark.png'; import { Button } from '@blueprintjs/core'; +import { getAppAuth } from '@common/store/selectors'; +import * as actions from '@common/store/actions'; import SettingsModal from '@renderer/app/components/modals/SettingsModal/SettingsModal'; -import React from 'react'; +import React, { FC, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import * as reduxModal from 'redux-modal'; -interface Props { - error?: string | null; - show: typeof reduxModal.show; - loading: boolean; - login(): void; -} +export const LoginStep: FC = () => { + const dispatch = useDispatch(); + const { isLoading, error, isError } = useSelector(getAppAuth); + const login = useCallback(() => { + if (!isLoading) { + dispatch(actions.login.request({})); + } + }, [dispatch, isLoading]); -export const LoginStep = React.memo(({ error, login, show, loading }) => ( - <> -
- login -
-
- A SoundCloud client for your desktop. This project is open-source, so consider{' '} - contributing or becoming{' '} - a financial backer. But most of all, enjoy the music. 🎉 -
+ return ( + <> +
+ login +
+
+ A SoundCloud client for your desktop. This project is open-source, so consider{' '} + contributing or becoming{' '} + a financial backer. But most of all, enjoy the music. 🎉 +
-
- {error ?
{error}
: null} +
+ {isError ? ( +
{error ?? 'Something went wrong during login, please try again'}
+ ) : null} - Login using SoundCloud + Login using SoundCloud - + - { - show('settings', {}); - }}> - Settings - -
+ { + dispatch(reduxModal.show('settings', {})); + }}> + Settings + +
- - -)); + + + ); +}; diff --git a/src/renderer/pages/onboarding/components/PrivacyStep.tsx b/src/renderer/pages/onboarding/components/PrivacyStep.tsx index 4c50c74e..3ac5926b 100644 --- a/src/renderer/pages/onboarding/components/PrivacyStep.tsx +++ b/src/renderer/pages/onboarding/components/PrivacyStep.tsx @@ -1,20 +1,19 @@ import { Button } from '@blueprintjs/core'; -import { ConfigState } from '@common/store/config'; import * as actions from '@common/store/actions'; +import { ConfigValue } from '@common/store/config'; import { CheckboxConfig } from '@renderer/pages/settings/components/CheckboxConfig'; import React, { useCallback } from 'react'; import { useDispatch } from 'react-redux'; interface Props { - config: ConfigState; onNext(): void; } -export const PrivacyStep = React.memo(({ onNext, config }) => { +export const PrivacyStep = React.memo(({ onNext }) => { const dispatch = useDispatch(); const setConfigKey = useCallback( - (key: string, value: actions.ConfigValue) => { + (key: string, value: ConfigValue) => { return dispatch(actions.setConfigKey(key, value)); }, [dispatch] @@ -24,26 +23,14 @@ export const PrivacyStep = React.memo(({ onNext, config }) => { <>

🔒 Privacy

- +
I use google analytics to get an insight how large the userbase of the app is. Your ip is being anonymized and no other data than page views and sessions are being tracked.
- +
I use Sentry for error logging. No personal info from your pc is being sent. diff --git a/src/renderer/pages/playlist/PlaylistPage.tsx b/src/renderer/pages/playlist/PlaylistPage.tsx index f86fffec..1e3a3f43 100644 --- a/src/renderer/pages/playlist/PlaylistPage.tsx +++ b/src/renderer/pages/playlist/PlaylistPage.tsx @@ -1,6 +1,6 @@ import { Menu, MenuDivider, MenuItem, Popover, Position } from '@blueprintjs/core'; import { IMAGE_SIZES } from '@common/constants'; -import { addUpNext, genericPlaylistFetchMore, getGenericPlaylist } from '@common/store/actions'; +import { addUpNext, genericPlaylistFetchMore, getGenericPlaylist, openExternalUrl } from '@common/store/actions'; import { getAuthPlaylistsSelector, getNormalizedPlaylist, @@ -17,6 +17,7 @@ import { ToggleLikeButton } from '@renderer/_shared/PageHeader/components/Toggle import { TogglePlayButton } from '@renderer/_shared/PageHeader/components/TogglePlayButton'; import { ToggleRepostButton } from '@renderer/_shared/PageHeader/components/ToggleRepostButton'; import cn from 'classnames'; +import { stopForwarding } from 'electron-redux'; import React, { FC, useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { RouteComponentProps } from 'react-router-dom'; @@ -47,14 +48,16 @@ const PlaylistPage: FC = ({ useEffect(() => { if (isPersonalisedPlaylist) { - dispatch(genericPlaylistFetchMore.request({ objectId, playlistType })); + dispatch(stopForwarding(genericPlaylistFetchMore.request({ objectId, playlistType }))); } else if (objectId !== previousObjectId) { dispatch( - getGenericPlaylist.request({ - objectId, - playlistType, - refresh: true - }) + stopForwarding( + getGenericPlaylist.request({ + objectId, + playlistType, + refresh: true + }) + ) ); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -63,7 +66,7 @@ const PlaylistPage: FC = ({ const { loadMore } = useLoadMorePromise( playlistObject?.isFetching, () => { - dispatch(genericPlaylistFetchMore.request({ objectId, playlistType })); + dispatch(stopForwarding(genericPlaylistFetchMore.request({ objectId, playlistType }))); }, [dispatch, objectId] ); @@ -135,7 +138,7 @@ const PlaylistPage: FC = ({ { - IPC.openExternal(permalink); + dispatch(openExternalUrl(permalink)); }} /> {playlistUser && !isPersonalisedPlaylist && ( diff --git a/src/renderer/pages/search/SearchPage.tsx b/src/renderer/pages/search/SearchPage.tsx index b7dca317..66d8e1a5 100644 --- a/src/renderer/pages/search/SearchPage.tsx +++ b/src/renderer/pages/search/SearchPage.tsx @@ -1,7 +1,7 @@ import * as actions from '@common/store/actions'; import { searchPlaylistFetchMore } from '@common/store/actions'; import { PlaylistTypes } from '@common/store/objects'; -import { getPlaylistObjectSelector, getSearchQuery } from '@common/store/selectors'; +import { getPlaylistObjectSelector, getSearchQuerySelector } from '@common/store/selectors'; import { useLoadMorePromise } from '@renderer/hooks/useLoadMorePromise'; import React, { FC, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; @@ -18,7 +18,7 @@ export const SearchPage: FC = ({ }) => { const dispatch = useDispatch(); const playlistObject = useSelector(getPlaylistObjectSelector({ playlistType })); - const query = useSelector(getSearchQuery); + const query = useSelector(getSearchQuerySelector); useEffect(() => { if (playlistType !== PlaylistTypes.SEARCH && playlistObject?.meta?.query !== query) { @@ -43,8 +43,8 @@ export const SearchPage: FC = ({ return ( <> -
-
+
+
All diff --git a/src/renderer/pages/settings/Settings.tsx b/src/renderer/pages/settings/Settings.tsx index 1e226f6c..e2872605 100644 --- a/src/renderer/pages/settings/Settings.tsx +++ b/src/renderer/pages/settings/Settings.tsx @@ -1,463 +1,57 @@ -import { Button, Collapse, Intent, Switch } from '@blueprintjs/core'; -import fetchToJson from '@common/api/helpers/fetchToJson'; -import { EVENTS } from '@common/constants/events'; -import * as actions from '@common/store/actions'; -import { SC } from '@common/utils'; -import { ThemeKeys } from '@renderer/app/components/Theme/themes'; +import { restartApp } from '@common/store/actions'; +import { isAuthenticatedSelector } from '@common/store/selectors'; import PageHeader from '@renderer/_shared/PageHeader/PageHeader'; -import { StoreState } from 'AppReduxTypes'; -import { autobind } from 'core-decorators'; -// eslint-disable-next-line import/no-extraneous-dependencies -import { ipcRenderer } from 'electron'; -import { debounce } from 'lodash-decorators'; -import React from 'react'; -import { connect } from 'react-redux'; -import { bindActionCreators, Dispatch } from 'redux'; -import { CONFIG } from '../../../config'; -import { CheckboxConfig } from './components/CheckboxConfig'; -import { InputConfig } from './components/InputConfig'; -import { SelectConfig } from './components/SelectConfig'; +import React, { FC, useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { AdvancedSettings } from './components/sections/AdvancedSettings'; +import { MainSettings } from './components/sections/MainSettings'; import './Settings.scss'; -const mapStateToProps = ({ config, app, appAuth }: StoreState) => ({ - config, - authenticated: !!config.auth.token && !appAuth.isLoading, - lastfmLoading: app.lastfmLoading -}); +export const Settings: FC = () => { + const dispatch = useDispatch(); + const authenticated = useSelector(isAuthenticatedSelector); + const [shouldRestart, setShouldRestart] = useState(false); -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - setConfigKey: actions.setConfigKey, - setConfig: actions.setConfig, - logout: actions.logout, - addToast: actions.addToast - }, - dispatch - ); - -interface Props { - noHeader?: boolean; -} - -interface State { - restartMsg: boolean; - advancedOpen: boolean; - audioDevices: MediaDeviceInfo[]; -} - -type PropsFromState = ReturnType; - -type PropsFromDispatch = ReturnType; - -type AllProps = Props & PropsFromState & PropsFromDispatch; - -interface SettingGroup { - name: string; - settings: Setting[]; -} + const restart = useCallback(() => dispatch(restartApp()), [dispatch]); -interface Setting { - authenticated: boolean; - setting: React.ReactNode; -} + const showRestart = useCallback(() => setShouldRestart(true), []); -@autobind -class Settings extends React.PureComponent { - public static defaultProps: Partial = { - authenticated: false - }; - - public readonly state: State = { - restartMsg: false, - advancedOpen: false, - audioDevices: [] - }; - - public async componentDidMount() { - const devices = await navigator.mediaDevices.enumerateDevices(); - const audioDevices = devices.filter(device => device.kind === 'audiooutput'); - - this.setState({ - audioDevices - }); - } - - get settings(): SettingGroup[] { - const { config, setConfigKey, lastfmLoading } = this.props; - - return [ - { - name: 'General', - settings: [ - { - authenticated: true, - setting: ( - - ) - }, - { - authenticated: true, - setting: ( - - ) - }, - { - authenticated: true, - setting: ( - - ) - }, - { - authenticated: true, - setting: ( - ({ k: theme, v: theme }))} - /> - ) - }, - { - authenticated: true, - setting: ( -
-
- Download path -
{'app.downloadPath'.split('.').reduce((o, i) => o[i], config)}
-
- -
- ) - }, - { - authenticated: false, - setting: ( - - Read here why and how. -
- } - /> - ) - } - ] - }, - { - name: 'Stream', - settings: [ - { - authenticated: true, - setting: ( - { - this.setState({ - restartMsg: true - }); - setKey(); - }} - /> - ) - } - ] - }, - { - name: 'Integrations', - settings: [ - { - authenticated: true, - setting: ( -
-
- LastFm integration -
- {!!config.lastfm && ( - <> - {config.lastfm && config.lastfm.key ? ( -
- Authorized as {config.lastfm.user} - setConfigKey('lastfm', null)} className="text-danger"> - - -
- ) : ( - - )} - - )} -
-
-
- -
-
- ) - } - ] - }, - { - name: 'Proxy (Experimental)', - settings: [ - { - authenticated: false, - setting: ( -
- { - this.setState({ - restartMsg: true - }); - setKey(); - }} - /> + return ( + <> + - {config.enableProxy ? ( -
-
- - -
-
- - -
-
- ) : null} +
+ {authenticated && ( +
+
+ +
+
+ Are you enjoying this app as much as I am? Or even more? +
+ I would love to spend more time on this, and other open-source projects. I do not earn anything off this + project, so I would highly appreciate any financial contribution towards this goal. + Contribute now
- ) - } - ] - } - ]; - } - - public restart() { - ipcRenderer.send(EVENTS.APP.RESTART); - } - - @debounce(800) - public tryClientId(addToast: typeof actions.addToast, saveValue: Function, clientId: string) { - fetchToJson(SC.getRemainingTracks(clientId)) - .then(remaining => { - if (remaining) { - saveValue(); - addToast({ - message: 'Your clientId has been set', - intent: Intent.SUCCESS - }); - } - }) - .catch(() => { - addToast({ - message: 'This clientId might not be correct', - intent: Intent.DANGER - }); - }); - } - - public checkAndSaveClientId(clientId: string, saveValue: () => void) { - const { addToast } = this.props; - - if (clientId && clientId.length) { - this.tryClientId(addToast, saveValue, clientId); - } else { - saveValue(); - } - } - - public isValidDirectory() { - const { setConfigKey } = this.props; - - ipcRenderer.send(EVENTS.APP.VALID_DIR); - ipcRenderer.once(EVENTS.APP.VALID_DIR_RESPONSE, (_event, dir: string) => { - setConfigKey('app.downloadPath', dir); - }); - } - - private handleClick() { - const { advancedOpen } = this.state; - - this.setState({ advancedOpen: !advancedOpen }); - } - - private toggleLastFm(event: React.ChangeEvent) { - const { setConfigKey } = this.props; - - setConfigKey('lastfm', event.target.checked ? {} : null); - } - - private authorizeLastFm() { - ipcRenderer.send(EVENTS.APP.LASTFM.AUTH); - } - - private renderSettings() { - const { authenticated } = this.props; - const { restartMsg, advancedOpen } = this.state; - - return ( - <> - {restartMsg && ( -
- A{' '} - - restart - {' '} - is required to enable/disable this feature. -
- )} - - {this.settings.map(settingGroup => { - const settings = settingGroup.settings - .filter(setting => setting.authenticated === authenticated || setting.authenticated === false) - .map(setting => setting.setting); - - if (!settings.length) { - return null; - } - - return ( -
-
{settingGroup.name}
-
{settings}
- ); - })} - - {authenticated && ( -
- - {this.renderAdvancedSettings()}
)} - - ); - } - - private renderAdvancedSettings() { - const { setConfig, logout, config, setConfigKey } = this.props; - - const { audioDevices } = this.state; - return ( -
-
Advanced settings
- - ({ k: d.label, v: d.deviceId }))]} - configKey="audio.playbackDeviceId" - /> - - -
- ); - } - - public render() { - const { authenticated } = this.props; - - return ( - <> - - -
- {authenticated && ( -
-
- -
-
- Are you enjoying this app as much as I am? Or even more? -
- I would love to spend more time on this, and other open-source projects. I do not earn anything off - this project, so I would highly appreciate any financial contribution towards this goal. - Contribute now -
-
+ <> + {shouldRestart && ( +
+ A{' '} + + restart + {' '} + is required to enable/disable this feature.
)} - {this.renderSettings()} -
- - ); - } -} - -export default connect(mapStateToProps, mapDispatchToProps)(Settings); + + + +
+ + ); +}; diff --git a/src/renderer/pages/settings/components/CheckboxConfig.tsx b/src/renderer/pages/settings/components/CheckboxConfig.tsx index 97f1f672..a5038838 100644 --- a/src/renderer/pages/settings/components/CheckboxConfig.tsx +++ b/src/renderer/pages/settings/components/CheckboxConfig.tsx @@ -1,12 +1,10 @@ import { Switch } from '@blueprintjs/core'; import * as actions from '@common/store/actions'; -import { ConfigState } from '@common/store/config'; -import React from 'react'; -import { autobind } from 'core-decorators'; +import { configSelector } from '@common/store/selectors'; +import React, { FC, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; interface Props { - config: ConfigState; - setConfigKey: typeof actions.setConfigKey; configKey: string; name?: string; alignIndicator?: 'right' | 'left' | 'center'; @@ -14,35 +12,28 @@ interface Props { onChange?(value: boolean, setKey: () => void): void; } -@autobind -export class CheckboxConfig extends React.Component { - public handleChange(e: React.ChangeEvent) { - const { configKey, setConfigKey, onChange } = this.props; +export const CheckboxConfig: FC = ({ configKey, name, alignIndicator, onChange: propagateOnChange }) => { + const dispatch = useDispatch(); + const config = useSelector(configSelector); - if (onChange) { - onChange(e.target.checked, () => { - setConfigKey(configKey, e.target.checked); - }); - } else { - setConfigKey(configKey, e.target.checked); - } - } + const value = configKey.split('.').reduce((o, i) => o[i], config); - public render() { - const { configKey, name, config, alignIndicator } = this.props; + const onChange = useCallback( + (e: React.ChangeEvent) => { + if (propagateOnChange) { + propagateOnChange(e.target.checked, () => { + dispatch(actions.setConfigKey(configKey, e.target.checked)); + }); + } else { + dispatch(actions.setConfigKey(configKey, e.target.checked)); + } + }, + [configKey, dispatch, propagateOnChange] + ); - const value = configKey.split('.').reduce((o, i) => o[i], config); - - return ( -
- -
- ); - } -} + return ( +
+ +
+ ); +}; diff --git a/src/renderer/pages/settings/components/InputConfig.tsx b/src/renderer/pages/settings/components/InputConfig.tsx index 0442ebc1..287aab69 100644 --- a/src/renderer/pages/settings/components/InputConfig.tsx +++ b/src/renderer/pages/settings/components/InputConfig.tsx @@ -1,83 +1,76 @@ import * as actions from '@common/store/actions'; -import { ConfigState } from '@common/store/config'; +import { configSelector } from '@common/store/selectors'; import cn from 'classnames'; import { debounce } from 'lodash'; -import React from 'react'; -import { autobind } from 'core-decorators'; +import React, { FC, useCallback, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; interface Props { - config: ConfigState; - setConfigKey: typeof actions.setConfigKey; configKey: string; name: string; className?: string; description?: React.ReactNode; - usePlaceholder: boolean; + usePlaceholder?: boolean; placeholder: string; - invalid: boolean; + invalid?: boolean; type: string; onChange?(value: string | null, setKey: () => void): void; } -@autobind -export class InputConfig extends React.PureComponent { - private readonly saveDebounced: (value: string) => void; +export const InputConfig: FC = ({ + configKey, + name, + type = 'text', + className = '', + usePlaceholder = false, + placeholder = '', + invalid, + description, + onChange: propagateOnChange +}) => { + const dispatch = useDispatch(); + const config = useSelector(configSelector); - public static readonly defaultProps: Partial = { - type: 'text', - usePlaceholder: false, - placeholder: '', - className: '', - invalid: false - }; + const value = configKey.split('.').reduce((o, i) => o[i], config); + const defaultValue = value || ''; + const placeholderText = usePlaceholder ? name : placeholder; - constructor(props: Props) { - super(props); + const onChange = useCallback( + (value: string) => { + const val = value.length ? value : null; - this.saveDebounced = debounce(this.handleChange.bind(this), 50); - } + if (propagateOnChange) { + propagateOnChange(val, () => { + dispatch(actions.setConfigKey(configKey, val)); + }); + } else { + dispatch(actions.setConfigKey(configKey, val)); + } + }, + [configKey, dispatch, propagateOnChange] + ); - public handleChange(value: string) { - const { configKey, onChange, setConfigKey } = this.props; + const saveDebounced = useRef(debounce(onChange, 50)); - const val = value.length ? value : null; + return ( +
+
+ {!usePlaceholder && } - if (onChange) { - onChange(val, () => { - setConfigKey(configKey, val); - }); - } else { - setConfigKey(configKey, val); - } - } - - public render() { - const { configKey, name, config, type, className, usePlaceholder, placeholder, invalid, description } = this.props; - - const value = configKey.split('.').reduce((o, i) => o[i], config); - const defaultValue = value || ''; - const placeholderText = usePlaceholder ? name : placeholder; - - return ( -
-
- {!usePlaceholder && } - - {!!description &&
{description}
} -
- - ) => this.saveDebounced(event.target.value)} - placeholder={placeholderText} - onKeyUp={e => e.stopPropagation()} - defaultValue={defaultValue} - /> + {!!description &&
{description}
}
- ); - } -} + + ) => saveDebounced.current(event.target.value)} + placeholder={placeholderText} + onKeyUp={e => e.stopPropagation()} + defaultValue={defaultValue} + /> +
+ ); +}; diff --git a/src/renderer/pages/settings/components/SelectConfig.tsx b/src/renderer/pages/settings/components/SelectConfig.tsx index 93cf1275..546b0fd8 100644 --- a/src/renderer/pages/settings/components/SelectConfig.tsx +++ b/src/renderer/pages/settings/components/SelectConfig.tsx @@ -1,57 +1,51 @@ import * as actions from '@common/store/actions'; -import { ConfigState } from '@common/store/config'; -import React from 'react'; -import { autobind } from 'core-decorators'; +import { configSelector } from '@common/store/selectors'; +import React, { FC, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; interface Props { - config: ConfigState; - setConfigKey: typeof actions.setConfigKey; configKey: string; name: string; data: { k: string; v: any }[]; className?: string; - usePlaceholder: boolean; + usePlaceholder?: boolean; onChange?(value: string): void; } -@autobind -export class SelectConfig extends React.PureComponent { - public static readonly defaultProps: Partial = { - usePlaceholder: false, - className: '', - data: [] - }; - - public handleChange(e: React.FormEvent) { - const { configKey, setConfigKey, onChange } = this.props; - - setConfigKey(configKey, e.currentTarget.value); - - if (onChange) { - onChange(e.currentTarget.value); - } - } - - public render() { - const { configKey, name, config, className, usePlaceholder, data } = this.props; - - const value = configKey.split('.').reduce((o, i) => o[i], config); - - return ( -
- {!usePlaceholder && {name}} - -
- ); - } -} +export const SelectConfig: FC = ({ + configKey, + name, + className = '', + usePlaceholder, + data = [], + onChange: propagateOnChange +}) => { + const dispatch = useDispatch(); + const config = useSelector(configSelector); + + const value = configKey.split('.').reduce((o, i) => o[i], config); + + const onChange = useCallback( + (e: React.ChangeEvent) => { + dispatch(actions.setConfigKey(configKey, e.currentTarget.value)); + + if (propagateOnChange) { + propagateOnChange(e.currentTarget.value); + } + }, + [configKey, dispatch, propagateOnChange] + ); + + return ( +
+ {!usePlaceholder && {name}} + +
+ ); +}; diff --git a/src/renderer/pages/settings/components/sections/AdvancedSettings.tsx b/src/renderer/pages/settings/components/sections/AdvancedSettings.tsx new file mode 100644 index 00000000..456087fd --- /dev/null +++ b/src/renderer/pages/settings/components/sections/AdvancedSettings.tsx @@ -0,0 +1,62 @@ +import { Button, Collapse } from '@blueprintjs/core'; +import * as actions from '@common/store/actions'; +import { isAuthenticatedSelector } from '@common/store/selectors'; +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { CONFIG } from 'src/config'; +import { SelectConfig } from '../SelectConfig'; + +export const AdvancedSettings: FC = () => { + const dispatch = useDispatch(); + const authenticated = useSelector(isAuthenticatedSelector); + + const [audioDevices, setAudioDevices] = useState([]); + const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); + + useEffect(() => { + const initAudioDevices = async () => { + const devices = await navigator.mediaDevices.enumerateDevices(); + const audioDevices = devices.filter(device => device.kind === 'audiooutput'); + + setAudioDevices(audioDevices); + }; + + initAudioDevices(); + }, []); + + const toggleAdvanced = useCallback(() => { + setIsAdvancedOpen(!isAdvancedOpen); + }, [isAdvancedOpen]); + + if (!authenticated) return null; + + return ( +
+ + + + +
+ ); +}; diff --git a/src/renderer/pages/settings/components/sections/MainSettings.tsx b/src/renderer/pages/settings/components/sections/MainSettings.tsx new file mode 100644 index 00000000..d41dc713 --- /dev/null +++ b/src/renderer/pages/settings/components/sections/MainSettings.tsx @@ -0,0 +1,300 @@ +import { Button, Intent, Switch } from '@blueprintjs/core'; +import fetchToJson from '@common/api/helpers/fetchToJson'; +import { EVENTS } from '@common/constants/events'; +import * as actions from '@common/store/actions'; +import { lastFmLoadingSelector } from '@common/store/app/selectors'; +import { configSelector, isAuthenticatedSelector } from '@common/store/selectors'; +import { SC } from '@common/utils'; +import { ThemeKeys } from '@renderer/app/components/Theme/themes'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { ipcRenderer } from 'electron'; +import { debounce } from 'lodash'; +import React, { FC, useCallback, useMemo, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { CheckboxConfig } from '../CheckboxConfig'; +import { InputConfig } from '../InputConfig'; +import { SelectConfig } from '../SelectConfig'; + +export interface SettingGroup { + name: string; + settings: Setting[]; +} + +interface Setting { + authenticated: boolean; + setting: React.ReactNode; +} + +interface Props { + onShouldRestart(): void; +} + +export const MainSettings: FC = ({ onShouldRestart }) => { + const dispatch = useDispatch(); + const authenticated = useSelector(isAuthenticatedSelector); + const lastfmLoading = useSelector(lastFmLoadingSelector); + const config = useSelector(configSelector); + + // TODO refactor to redux-observables + const authorizeLastFm = useCallback(() => { + ipcRenderer.send(EVENTS.APP.LASTFM.AUTH); + }, []); + + const isValidDirectory = useCallback(() => { + ipcRenderer.send(EVENTS.APP.VALID_DIR); + ipcRenderer.once(EVENTS.APP.VALID_DIR_RESPONSE, (_event, dir: string) => { + dispatch(actions.setConfigKey('app.downloadPath', dir)); + }); + }, [dispatch]); + + const tryClientId = useCallback( + (saveValue: Function, clientId: string) => { + fetchToJson(SC.getRemainingTracks(clientId)) + .then(remaining => { + if (remaining) { + saveValue(); + dispatch( + actions.addToast({ + message: 'Your clientId has been set', + intent: Intent.SUCCESS + }) + ); + } + }) + .catch(() => { + dispatch( + actions.addToast({ + message: 'This clientId might not be correct', + intent: Intent.DANGER + }) + ); + }); + }, + [dispatch] + ); + + const debounceTryClientId = useRef(debounce(tryClientId, 800)); + + const checkAndSaveClientId = useCallback((clientId: string, saveValue: () => void) => { + if (clientId?.length === 32) { + debounceTryClientId.current(saveValue, clientId); + } else { + saveValue(); + } + }, []); + + const settings = useMemo((): SettingGroup[] => { + return [ + { + name: 'General', + settings: [ + { + authenticated: true, + setting: + }, + { + authenticated: true, + setting: + }, + { + authenticated: true, + setting: ( + + ) + }, + { + authenticated: true, + setting: ( + ({ k: theme, v: theme }))} + /> + ) + }, + { + authenticated: true, + setting: ( +
+
+ Download path +
{'app.downloadPath'.split('.').reduce((o, i) => o[i], config)}
+
+ +
+ ) + }, + { + authenticated: false, + setting: ( + + Read here why and how. +
+ } + /> + ) + } + ] + }, + { + name: 'Stream', + settings: [ + { + authenticated: true, + setting: ( + { + onShouldRestart(); + setKey(); + }} + /> + ) + } + ] + }, + { + name: 'Integrations', + settings: [ + { + authenticated: true, + setting: ( +
+
+ LastFm integration +
+ {!!config.lastfm && ( + <> + {config.lastfm && config.lastfm.key ? ( + + ) : ( + + )} + + )} +
+
+
+ { + dispatch(actions.setConfigKey('lastfm', (event.target as any).checked ? {} : null)); + }} + /> +
+
+ ) + } + ] + } + // { + // name: 'Proxy (Experimental)', + // settings: [ + // { + // authenticated: false, + // setting: ( + //
+ // { + // this.setState({ + // restartMsg: true + // }); + // setKey(); + // }} + // /> + + // {config.enableProxy ? ( + //
+ //
+ // + // + //
+ //
+ // + // + //
+ //
+ // ) : null} + //
+ // ) + // } + // ] + // } + ]; + }, [authorizeLastFm, checkAndSaveClientId, config, dispatch, isValidDirectory, lastfmLoading]); + + return ( +
+ {settings.map(settingGroup => { + const settings = settingGroup.settings + .filter(setting => setting.authenticated === authenticated || setting.authenticated === false) + .map(setting => setting.setting); + + if (!settings.length) { + return null; + } + + return ( +
+
{settingGroup.name}
+
{settings}
+
+ ); + })} +
+ ); +}; diff --git a/src/renderer/pages/track/TrackPage.tsx b/src/renderer/pages/track/TrackPage.tsx index a88eebce..c3f8689b 100644 --- a/src/renderer/pages/track/TrackPage.tsx +++ b/src/renderer/pages/track/TrackPage.tsx @@ -1,6 +1,6 @@ import { Menu, MenuDivider, MenuItem, Popover, Position } from '@blueprintjs/core'; import { IMAGE_SIZES } from '@common/constants'; -import { addUpNext, getTrack } from '@common/store/actions'; +import { addUpNext, getTrack, openExternalUrl } from '@common/store/actions'; import { getNormalizedTrack, getNormalizedUser, isTrackError, isTrackLoading } from '@common/store/selectors'; import { LikeType, PlaylistTypes, RepostType } from '@common/store/types'; import { SC } from '@common/utils'; @@ -84,7 +84,7 @@ export const TrackPage: FC = ({ diff --git a/src/renderer/pages/track/components/TrackOverview.tsx b/src/renderer/pages/track/components/TrackOverview.tsx index 32342502..795d711c 100644 --- a/src/renderer/pages/track/components/TrackOverview.tsx +++ b/src/renderer/pages/track/components/TrackOverview.tsx @@ -2,6 +2,7 @@ import { commentsFetchMore, getComments } from '@common/store/actions'; import { getCommentObject } from '@common/store/selectors'; import { abbreviateNumber } from '@common/utils'; import { useLoadMorePromise } from '@renderer/hooks/useLoadMorePromise'; +import { stopForwarding } from 'electron-redux'; import moment from 'moment'; import React, { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; @@ -10,7 +11,7 @@ import { Normalized, SoundCloud } from '../../../../types'; import { CommentList } from '../../../_shared/CommentList/CommentList'; import { Linkify } from '../../../_shared/Linkify'; import { ToggleMore } from '../../../_shared/ToggleMore'; -import TrackGridUser from '../../../_shared/TracksGrid/TrackgridUser/TrackGridUser'; +import { TrackGridUser } from '../../../_shared/TracksGrid/TrackgridUser/TrackGridUser'; interface Props { track: Normalized.Track; @@ -35,7 +36,7 @@ export const TrackOverview = React.memo(({ track }) => { const comments = useSelector(getCommentObject(track?.id)); useEffect(() => { - dispatch(getComments.request({ refresh: true, trackId: track?.id })); + dispatch(stopForwarding(getComments.request({ refresh: true, trackId: track?.id }))); // eslint-disable-next-line react-hooks/exhaustive-deps }, [dispatch, track?.id]); diff --git a/src/types/index.ts b/src/types/index.ts index f39dc67d..146741e1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,8 +1,5 @@ -import { AxiosError } from 'axios'; -import { ThunkAction } from 'redux-thunk'; import * as Normalized from './normalized'; import * as SoundCloud from './soundcloud'; -import { StoreState } from 'AppReduxTypes'; export { SoundCloud, Normalized }; @@ -24,9 +21,6 @@ export type Collection = { export type EntitiesOf = { [key: string]: { [key: string]: T } }; export type ResultOf = Array & { [P in K]: string }>; - -export type ThunkResult = ThunkAction; - export interface EpicFailure { - error: AxiosError | Error; + error?: Error; } diff --git a/tsconfig.json b/tsconfig.json index 29324092..56046f84 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,7 @@ "moduleResolution": "node", "pretty": true, "strict": false, + "skipLibCheck": true, "jsx": "react", "typeRoots": [ "./types", diff --git a/types/dbus-next/index.d.ts b/types/dbus-next/index.d.ts deleted file mode 100644 index c5ccba28..00000000 --- a/types/dbus-next/index.d.ts +++ /dev/null @@ -1,36 +0,0 @@ -declare module "dbus-next" { - export class DBusError { - static captureStackTrace(p0: any, p1: any): any; - static stackTraceLimit: number; - constructor(type: any, text: any); - name: any; - type: any; - text: any; - } - export class Variant { - constructor(signature: any, value: any); - signature: any; - value: any; - } - export function createClient(params: any): any; - export function createConnection(opts: any): any; - export function createServer(handler: any): any; - export const messageType: { - error: number; - invalid: number; - methodCall: number; - methodReturn: number; - signal: number; - }; - export function sessionBus(opts?: any): any; - export function setBigIntCompat(val: any): void; - export function systemBus(): any; - export namespace validators { - function assertInterfaceNameValid(name: any): void; - function assertMemberNameValid(name: any): void; - function assertObjectPathValid(path: any): void; - function isInterfaceNameValid(name: any): any; - function isMemberNameValid(name: any): any; - function isObjectPathValid(path: any): any; - } -} diff --git a/types/electron-redux/index.d.ts b/types/electron-redux/index.d.ts deleted file mode 100644 index e4775ad5..00000000 --- a/types/electron-redux/index.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -declare module 'electron-redux' { - import { Store, Middleware, ActionCreator, Action } from 'redux'; - - export const forwardToMainWithParams: (options: any) => Middleware; - export const forwardToMain: Middleware; - export const forwardToRenderer: Middleware; - export const triggerAlias: Middleware; - - interface AliasedAction { - type: 'ALIASED'; - payload: any[]; - meta: { trigger: string }; - } - - export function createAliasedAction( - name: string, - actionCreator: ActionCreator - ): ActionCreator; - export function replayActionMain(store: Store): void; - export function replayActionRenderer(store: Store): void; - export function getInitialStateRenderer(): any; -} diff --git a/yarn.lock b/yarn.lock index c5ccbb91..748b3f61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,11 +7,6 @@ resolved "https://registry.yarnpkg.com/7zip-bin/-/7zip-bin-5.0.3.tgz#bc5b5532ecafd923a61f2fb097e3b108c0106a3f" integrity sha512-GLyWIFBbGvpKPGo55JyRZAo4lVbnBiD52cKlw/0Vt+wnmKvWJkpZvsjVoaIolyBXDeAQKSicRtqFNPem9w0WYA== -"7zip@0.0.6": - version "0.0.6" - resolved "https://registry.yarnpkg.com/7zip/-/7zip-0.0.6.tgz#9cafb171af82329490353b4816f03347aa150a30" - integrity sha1-nK+xca+CMpSQNTtIFvAzR6oVCjA= - "@amilajack/castv2-client@Superjo149/caster": version "0.0.2-3" resolved "https://codeload.github.com/Superjo149/caster/tar.gz/d6f88a86c5f3c67f0ab3eadbd3654285a7d4a349" @@ -27,6 +22,13 @@ dependencies: "@babel/highlight" "^7.8.3" +"@babel/code-frame@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" + integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== + dependencies: + "@babel/highlight" "^7.10.4" + "@babel/compat-data@^7.8.4": version "7.8.5" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.8.5.tgz#d28ce872778c23551cbb9432fc68d28495b613b9" @@ -208,6 +210,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz#9ea293be19babc0f52ff8ca88b34c3611b208670" integrity sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ== +"@babel/helper-plugin-utils@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375" + integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg== + "@babel/helper-regex@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.8.3.tgz#139772607d51b93f23effe72105b319d2a4c6965" @@ -251,6 +258,11 @@ dependencies: "@babel/types" "^7.8.3" +"@babel/helper-validator-identifier@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2" + integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw== + "@babel/helper-wrap-function@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.8.3.tgz#9dbdb2bb55ef14aaa01fe8c99b629bd5352d8610" @@ -270,6 +282,15 @@ "@babel/traverse" "^7.8.4" "@babel/types" "^7.8.3" +"@babel/highlight@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.10.4.tgz#7d1bdfd65753538fabe6c38596cdb76d9ac60143" + integrity sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA== + dependencies: + "@babel/helper-validator-identifier" "^7.10.4" + chalk "^2.0.0" + js-tokens "^4.0.0" + "@babel/highlight@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.8.3.tgz#28f173d04223eaaa59bc1d439a3836e6d1265797" @@ -284,6 +305,11 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.8.4.tgz#d1dbe64691d60358a974295fa53da074dd2ce8e8" integrity sha512-0fKu/QqildpXmPVaRBoXOlyBb3MC+J0A66x97qEfLOMkn3u6nfY5esWogQwi/K0BjASYy4DbnsEWnpNL6qT5Mw== +"@babel/parser@^7.12.7": + version "7.12.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.7.tgz#fee7b39fe809d0e73e5b25eecaf5780ef3d73056" + integrity "sha1-/uezn+gJ0Oc+WyXuyvV4DvPXMFY= sha512-oWR02Ubp4xTLCAqPRiNIuMVgNO5Aif/xpXtabhzW2HWUD47XJsAB4Zd/Rg30+XeQA3juXigV7hlquOTmwqLiwg==" + "@babel/plugin-proposal-async-generator-functions@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.8.3.tgz#bad329c670b382589721b27540c7d288601c6e6f" @@ -366,20 +392,27 @@ "@babel/helper-create-regexp-features-plugin" "^7.8.3" "@babel/helper-plugin-utils" "^7.8.3" -"@babel/plugin-syntax-async-generators@^7.8.0": +"@babel/plugin-syntax-async-generators@^7.8.0", "@babel/plugin-syntax-async-generators@^7.8.4": version "7.8.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-bigint@^7.0.0": +"@babel/plugin-syntax-bigint@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz#4c9a6f669f5d0cdf1b90a1671e9a146be5300cea" integrity sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg== dependencies: "@babel/helper-plugin-utils" "^7.8.0" +"@babel/plugin-syntax-class-properties@^7.8.3": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.1.tgz#bcb297c5366e79bebadef509549cd93b04f19978" + integrity sha512-U40A76x5gTwmESz+qiqssqmeEsKvcSyvtgktrm0uzcARAmM9I1jR221f6Oq+GmHrcD+LvZDag1UTOTe2fL3TeA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-decorators@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.8.3.tgz#8d2c15a9f1af624b0025f961682a9d53d3001bda" @@ -394,7 +427,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-json-strings@^7.8.0": +"@babel/plugin-syntax-import-meta@^7.8.3": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" + integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-json-strings@^7.8.0", "@babel/plugin-syntax-json-strings@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== @@ -408,28 +448,42 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.3" -"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.0": +"@babel/plugin-syntax-logical-assignment-operators@^7.8.3": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" + integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.0", "@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-object-rest-spread@^7.0.0", "@babel/plugin-syntax-object-rest-spread@^7.8.0": +"@babel/plugin-syntax-numeric-separator@^7.8.3": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" + integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-object-rest-spread@^7.8.0", "@babel/plugin-syntax-object-rest-spread@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-optional-catch-binding@^7.8.0": +"@babel/plugin-syntax-optional-catch-binding@^7.8.0", "@babel/plugin-syntax-optional-catch-binding@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-optional-chaining@^7.8.0": +"@babel/plugin-syntax-optional-chaining@^7.8.0", "@babel/plugin-syntax-optional-chaining@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== @@ -836,20 +890,29 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.2.0", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.2.0", "@babel/runtime@^7.4.5": version "7.8.4" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.4.tgz#d79f5a2040f7caa24d53e563aad49cbc05581308" integrity sha512-neAp3zt80trRVBI1x0azq6c57aNBqYZH8KhMm3TaB7wEI5Q4A2SHfBHE8w9gOhI/lrqxtEbXZgQIrHP+wvSGwQ== dependencies: regenerator-runtime "^0.13.2" -"@babel/runtime@^7.4.0": - version "7.8.7" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.7.tgz#8fefce9802db54881ba59f90bb28719b4996324d" - integrity sha512-+AATMUFppJDw6aiR5NVPHqIQBlV/Pj8wY/EZH+lmvRdUo9xBaz/rF3alAwFJQavvKfeOlPE7oaaDHVbcySbCsg== +"@babel/runtime@^7.12.1": + version "7.12.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e" + integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg== dependencies: regenerator-runtime "^0.13.4" +"@babel/template@^7.3.3": + version "7.12.7" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.7.tgz#c817233696018e39fbb6c491d2fb684e05ed43bc" + integrity "sha1-yBcjNpYBjjn7tsSR0vtoTgXtQ7w= sha512-GkDzmHS6GV7ZeXfJZ0tLRBhZcMcY0/Lnb+eEbXDBfCAcZCjrZKe6p3J4we/D24O9Y8enxWAg1cWwof59yLh2ow==" + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/parser" "^7.12.7" + "@babel/types" "^7.12.7" + "@babel/template@^7.7.4", "@babel/template@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.3.tgz#e02ad04fe262a657809327f578056ca15fd4d1b8" @@ -883,6 +946,15 @@ lodash "^4.17.13" to-fast-properties "^2.0.0" +"@babel/types@^7.12.7", "@babel/types@^7.3.3": + version "7.12.7" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.7.tgz#6039ff1e242640a29452c9ae572162ec9a8f5d13" + integrity "sha1-YDn/HiQmQKKUUsmuVyFi7JqPXRM= sha512-MNyI92qZq6jrQkXvtIiykvl4WtoRrVV9MPn+ZfsoEENjiWcBQ3ZSHrkxnJWgWtLX3XXqX5hrSQ+X69wkmesXuQ==" + dependencies: + "@babel/helper-validator-identifier" "^7.10.4" + lodash "^4.17.19" + to-fast-properties "^2.0.0" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -929,6 +1001,14 @@ ajv "^6.1.0" ajv-keywords "^3.1.0" +"@develar/schema-utils@~2.6.5": + version "2.6.5" + resolved "https://registry.yarnpkg.com/@develar/schema-utils/-/schema-utils-2.6.5.tgz#3ece22c5838402419a6e0425f85742b961d9b6c6" + integrity sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig== + dependencies: + ajv "^6.12.0" + ajv-keywords "^3.4.1" + "@electron/get@^1.0.1": version "1.7.6" resolved "https://registry.yarnpkg.com/@electron/get/-/get-1.7.6.tgz#f1c8e87cbef0bce78644159b72340821d52066b3" @@ -964,151 +1044,160 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.2.tgz#26520bf09abe4a5644cd5414e37125a8954241dd" integrity sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw== -"@jest/console@^25.1.0": - version "25.1.0" - resolved "https://registry.yarnpkg.com/@jest/console/-/console-25.1.0.tgz#1fc765d44a1e11aec5029c08e798246bd37075ab" - integrity sha512-3P1DpqAMK/L07ag/Y9/Jup5iDEG9P4pRAuZiMQnU0JB3UOvCyYCjCoxr7sIA80SeyUCUKrr24fKAxVpmBgQonA== +"@jest/console@^25.5.0": + version "25.5.0" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-25.5.0.tgz#770800799d510f37329c508a9edd0b7b447d9abb" + integrity sha512-T48kZa6MK1Y6k4b89sexwmSF4YLeZS/Udqg3Jj3jG/cHH+N/sLFCEoXEDMOKugJQ9FxPN1osxIknvKkxt6MKyw== dependencies: - "@jest/source-map" "^25.1.0" + "@jest/types" "^25.5.0" chalk "^3.0.0" - jest-util "^25.1.0" + jest-message-util "^25.5.0" + jest-util "^25.5.0" slash "^3.0.0" -"@jest/core@^25.1.0": - version "25.1.0" - resolved "https://registry.yarnpkg.com/@jest/core/-/core-25.1.0.tgz#3d4634fc3348bb2d7532915d67781cdac0869e47" - integrity sha512-iz05+NmwCmZRzMXvMo6KFipW7nzhbpEawrKrkkdJzgytavPse0biEnCNr2wRlyCsp3SmKaEY+SGv7YWYQnIdig== +"@jest/core@^25.1.0", "@jest/core@^25.5.4": + version "25.5.4" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-25.5.4.tgz#3ef7412f7339210f003cdf36646bbca786efe7b4" + integrity sha512-3uSo7laYxF00Dg/DMgbn4xMJKmDdWvZnf89n8Xj/5/AeQ2dOQmn6b6Hkj/MleyzZWXpwv+WSdYWl4cLsy2JsoA== dependencies: - "@jest/console" "^25.1.0" - "@jest/reporters" "^25.1.0" - "@jest/test-result" "^25.1.0" - "@jest/transform" "^25.1.0" - "@jest/types" "^25.1.0" + "@jest/console" "^25.5.0" + "@jest/reporters" "^25.5.1" + "@jest/test-result" "^25.5.0" + "@jest/transform" "^25.5.1" + "@jest/types" "^25.5.0" ansi-escapes "^4.2.1" chalk "^3.0.0" exit "^0.1.2" - graceful-fs "^4.2.3" - jest-changed-files "^25.1.0" - jest-config "^25.1.0" - jest-haste-map "^25.1.0" - jest-message-util "^25.1.0" - jest-regex-util "^25.1.0" - jest-resolve "^25.1.0" - jest-resolve-dependencies "^25.1.0" - jest-runner "^25.1.0" - jest-runtime "^25.1.0" - jest-snapshot "^25.1.0" - jest-util "^25.1.0" - jest-validate "^25.1.0" - jest-watcher "^25.1.0" + graceful-fs "^4.2.4" + jest-changed-files "^25.5.0" + jest-config "^25.5.4" + jest-haste-map "^25.5.1" + jest-message-util "^25.5.0" + jest-regex-util "^25.2.6" + jest-resolve "^25.5.1" + jest-resolve-dependencies "^25.5.4" + jest-runner "^25.5.4" + jest-runtime "^25.5.4" + jest-snapshot "^25.5.1" + jest-util "^25.5.0" + jest-validate "^25.5.0" + jest-watcher "^25.5.0" micromatch "^4.0.2" p-each-series "^2.1.0" - realpath-native "^1.1.0" + realpath-native "^2.0.0" rimraf "^3.0.0" slash "^3.0.0" strip-ansi "^6.0.0" -"@jest/environment@^25.1.0": - version "25.1.0" - resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-25.1.0.tgz#4a97f64770c9d075f5d2b662b5169207f0a3f787" - integrity sha512-cTpUtsjU4cum53VqBDlcW0E4KbQF03Cn0jckGPW/5rrE9tb+porD3+hhLtHAwhthsqfyF+bizyodTlsRA++sHg== +"@jest/environment@^25.5.0": + version "25.5.0" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-25.5.0.tgz#aa33b0c21a716c65686638e7ef816c0e3a0c7b37" + integrity sha512-U2VXPEqL07E/V7pSZMSQCvV5Ea4lqOlT+0ZFijl/i316cRMHvZ4qC+jBdryd+lmRetjQo0YIQr6cVPNxxK87mA== dependencies: - "@jest/fake-timers" "^25.1.0" - "@jest/types" "^25.1.0" - jest-mock "^25.1.0" + "@jest/fake-timers" "^25.5.0" + "@jest/types" "^25.5.0" + jest-mock "^25.5.0" -"@jest/fake-timers@^25.1.0": - version "25.1.0" - resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-25.1.0.tgz#a1e0eff51ffdbb13ee81f35b52e0c1c11a350ce8" - integrity sha512-Eu3dysBzSAO1lD7cylZd/CVKdZZ1/43SF35iYBNV1Lvvn2Undp3Grwsv8PrzvbLhqwRzDd4zxrY4gsiHc+wygQ== +"@jest/fake-timers@^25.5.0": + version "25.5.0" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-25.5.0.tgz#46352e00533c024c90c2bc2ad9f2959f7f114185" + integrity sha512-9y2+uGnESw/oyOI3eww9yaxdZyHq7XvprfP/eeoCsjqKYts2yRlsHS/SgjPDV8FyMfn2nbMy8YzUk6nyvdLOpQ== dependencies: - "@jest/types" "^25.1.0" - jest-message-util "^25.1.0" - jest-mock "^25.1.0" - jest-util "^25.1.0" + "@jest/types" "^25.5.0" + jest-message-util "^25.5.0" + jest-mock "^25.5.0" + jest-util "^25.5.0" lolex "^5.0.0" -"@jest/reporters@^25.1.0": - version "25.1.0" - resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-25.1.0.tgz#9178ecf136c48f125674ac328f82ddea46e482b0" - integrity sha512-ORLT7hq2acJQa8N+NKfs68ZtHFnJPxsGqmofxW7v7urVhzJvpKZG9M7FAcgh9Ee1ZbCteMrirHA3m5JfBtAaDg== +"@jest/globals@^25.5.2": + version "25.5.2" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-25.5.2.tgz#5e45e9de8d228716af3257eeb3991cc2e162ca88" + integrity sha512-AgAS/Ny7Q2RCIj5kZ+0MuKM1wbF0WMLxbCVl/GOMoCNbODRdJ541IxJ98xnZdVSZXivKpJlNPIWa3QmY0l4CXA== + dependencies: + "@jest/environment" "^25.5.0" + "@jest/types" "^25.5.0" + expect "^25.5.0" + +"@jest/reporters@^25.5.1": + version "25.5.1" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-25.5.1.tgz#cb686bcc680f664c2dbaf7ed873e93aa6811538b" + integrity sha512-3jbd8pPDTuhYJ7vqiHXbSwTJQNavczPs+f1kRprRDxETeE3u6srJ+f0NPuwvOmk+lmunZzPkYWIFZDLHQPkviw== dependencies: "@bcoe/v8-coverage" "^0.2.3" - "@jest/console" "^25.1.0" - "@jest/environment" "^25.1.0" - "@jest/test-result" "^25.1.0" - "@jest/transform" "^25.1.0" - "@jest/types" "^25.1.0" + "@jest/console" "^25.5.0" + "@jest/test-result" "^25.5.0" + "@jest/transform" "^25.5.1" + "@jest/types" "^25.5.0" chalk "^3.0.0" collect-v8-coverage "^1.0.0" exit "^0.1.2" glob "^7.1.2" + graceful-fs "^4.2.4" istanbul-lib-coverage "^3.0.0" istanbul-lib-instrument "^4.0.0" istanbul-lib-report "^3.0.0" istanbul-lib-source-maps "^4.0.0" - istanbul-reports "^3.0.0" - jest-haste-map "^25.1.0" - jest-resolve "^25.1.0" - jest-runtime "^25.1.0" - jest-util "^25.1.0" - jest-worker "^25.1.0" + istanbul-reports "^3.0.2" + jest-haste-map "^25.5.1" + jest-resolve "^25.5.1" + jest-util "^25.5.0" + jest-worker "^25.5.0" slash "^3.0.0" source-map "^0.6.0" string-length "^3.1.0" terminal-link "^2.0.0" - v8-to-istanbul "^4.0.1" + v8-to-istanbul "^4.1.3" optionalDependencies: node-notifier "^6.0.0" -"@jest/source-map@^25.1.0": - version "25.1.0" - resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-25.1.0.tgz#b012e6c469ccdbc379413f5c1b1ffb7ba7034fb0" - integrity sha512-ohf2iKT0xnLWcIUhL6U6QN+CwFWf9XnrM2a6ybL9NXxJjgYijjLSitkYHIdzkd8wFliH73qj/+epIpTiWjRtAA== +"@jest/source-map@^25.5.0": + version "25.5.0" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-25.5.0.tgz#df5c20d6050aa292c2c6d3f0d2c7606af315bd1b" + integrity sha512-eIGx0xN12yVpMcPaVpjXPnn3N30QGJCJQSkEDUt9x1fI1Gdvb07Ml6K5iN2hG7NmMP6FDmtPEssE3z6doOYUwQ== dependencies: callsites "^3.0.0" - graceful-fs "^4.2.3" + graceful-fs "^4.2.4" source-map "^0.6.0" -"@jest/test-result@^25.1.0": - version "25.1.0" - resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-25.1.0.tgz#847af2972c1df9822a8200457e64be4ff62821f7" - integrity sha512-FZzSo36h++U93vNWZ0KgvlNuZ9pnDnztvaM7P/UcTx87aPDotG18bXifkf1Ji44B7k/eIatmMzkBapnAzjkJkg== +"@jest/test-result@^25.5.0": + version "25.5.0" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-25.5.0.tgz#139a043230cdeffe9ba2d8341b27f2efc77ce87c" + integrity sha512-oV+hPJgXN7IQf/fHWkcS99y0smKLU2czLBJ9WA0jHITLst58HpQMtzSYxzaBvYc6U5U6jfoMthqsUlUlbRXs0A== dependencies: - "@jest/console" "^25.1.0" - "@jest/transform" "^25.1.0" - "@jest/types" "^25.1.0" + "@jest/console" "^25.5.0" + "@jest/types" "^25.5.0" "@types/istanbul-lib-coverage" "^2.0.0" collect-v8-coverage "^1.0.0" -"@jest/test-sequencer@^25.1.0": - version "25.1.0" - resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-25.1.0.tgz#4df47208542f0065f356fcdb80026e3c042851ab" - integrity sha512-WgZLRgVr2b4l/7ED1J1RJQBOharxS11EFhmwDqknpknE0Pm87HLZVS2Asuuw+HQdfQvm2aXL2FvvBLxOD1D0iw== +"@jest/test-sequencer@^25.5.4": + version "25.5.4" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-25.5.4.tgz#9b4e685b36954c38d0f052e596d28161bdc8b737" + integrity sha512-pTJGEkSeg1EkCO2YWq6hbFvKNXk8ejqlxiOg1jBNLnWrgXOkdY6UmqZpwGFXNnRt9B8nO1uWMzLLZ4eCmhkPNA== dependencies: - "@jest/test-result" "^25.1.0" - jest-haste-map "^25.1.0" - jest-runner "^25.1.0" - jest-runtime "^25.1.0" + "@jest/test-result" "^25.5.0" + graceful-fs "^4.2.4" + jest-haste-map "^25.5.1" + jest-runner "^25.5.4" + jest-runtime "^25.5.4" -"@jest/transform@^25.1.0": - version "25.1.0" - resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-25.1.0.tgz#221f354f512b4628d88ce776d5b9e601028ea9da" - integrity sha512-4ktrQ2TPREVeM+KxB4zskAT84SnmG1vaz4S+51aTefyqn3zocZUnliLLm5Fsl85I3p/kFPN4CRp1RElIfXGegQ== +"@jest/transform@^25.5.1": + version "25.5.1" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-25.5.1.tgz#0469ddc17699dd2bf985db55fa0fb9309f5c2db3" + integrity sha512-Y8CEoVwXb4QwA6Y/9uDkn0Xfz0finGkieuV0xkdF9UtZGJeLukD5nLkaVrVsODB1ojRWlaoD0AJZpVHCSnJEvg== dependencies: "@babel/core" "^7.1.0" - "@jest/types" "^25.1.0" + "@jest/types" "^25.5.0" babel-plugin-istanbul "^6.0.0" chalk "^3.0.0" convert-source-map "^1.4.0" fast-json-stable-stringify "^2.0.0" - graceful-fs "^4.2.3" - jest-haste-map "^25.1.0" - jest-regex-util "^25.1.0" - jest-util "^25.1.0" + graceful-fs "^4.2.4" + jest-haste-map "^25.5.1" + jest-regex-util "^25.2.6" + jest-util "^25.5.0" micromatch "^4.0.2" pirates "^4.0.1" - realpath-native "^1.1.0" + realpath-native "^2.0.0" slash "^3.0.0" source-map "^0.6.1" write-file-atomic "^3.0.0" @@ -1123,6 +1212,16 @@ "@types/yargs" "^15.0.0" chalk "^3.0.0" +"@jest/types@^25.5.0": + version "25.5.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-25.5.0.tgz#4d6a4793f7b9599fc3680877b856a97dbccf2a9d" + integrity sha512-OXD0RgQ86Tu3MazKo8bnrkDRaDXXMGUqd+kTtLtK1Zb7CRzQcaSRPPPV37SvYTdevXEBVxe0HXylEjs8ibkmCw== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^1.1.1" + "@types/yargs" "^15.0.0" + chalk "^3.0.0" + "@nodelib/fs.scandir@2.1.3": version "2.1.3" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz#3a582bdb53804c6ba6d146579c46e52130cf4a3b" @@ -1230,110 +1329,106 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA= -"@sentry/apm@5.13.2": - version "5.13.2" - resolved "https://registry.yarnpkg.com/@sentry/apm/-/apm-5.13.2.tgz#3a0809912426f52e19b1f4a603e99423a0ac8fb9" - integrity sha512-Pv6PRVkcmmYYIT422gXm968F8YQyf5uN1RSHOFBjWsxI3Ke/uRgeEdIVKPDo78GklBfETyRN6GyLEZ555jRe6g== +"@sentry/browser@5.27.6": + version "5.27.6" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-5.27.6.tgz#54fe177e9986246586b0761eb38cbad1ad07ecb5" + integrity sha512-pqrojE2ZmLUVz7l/ogtogK0+M2pK3bigYm0fja7vG7F7kXnCAwqAHDYfkFXEvFI8WvNwH+niy28lSoV95lnm0Q== dependencies: - "@sentry/browser" "5.13.2" - "@sentry/hub" "5.13.2" - "@sentry/minimal" "5.13.2" - "@sentry/types" "5.13.2" - "@sentry/utils" "5.13.2" + "@sentry/core" "5.27.6" + "@sentry/types" "5.27.6" + "@sentry/utils" "5.27.6" tslib "^1.9.3" -"@sentry/browser@5.13.2", "@sentry/browser@~5.13.2": - version "5.13.2" - resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-5.13.2.tgz#fcca630c8c80447ba8392803d4e4450fd2231b92" - integrity sha512-4MeauHs8Rf1c2FF6n84wrvA4LexEL1K/Tg3r+1vigItiqyyyYBx1sPjHGZeKeilgBi+6IEV5O8sy30QIrA/NsQ== - dependencies: - "@sentry/core" "5.13.2" - "@sentry/types" "5.13.2" - "@sentry/utils" "5.13.2" +"@sentry/cli@^1.47.1", "@sentry/cli@^1.49.0": + version "1.59.0" + resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-1.59.0.tgz#8154c6426a105c6c8a2437db085837aff8e29834" + integrity "sha1-gVTGQmoQXGyKJDfbCFg3r/jimDQ= sha512-9nK4uVHW7HIbOwFZNvHRWFJcD+bqjW3kMWK2UUMqQWse0Lf3xM+2o+REGGkk0S69+E4elSiukVjUPTI5aijNlA==" + dependencies: + https-proxy-agent "^5.0.0" + mkdirp "^0.5.5" + node-fetch "^2.6.0" + progress "^2.0.3" + proxy-from-env "^1.1.0" + +"@sentry/core@5.27.6": + version "5.27.6" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.27.6.tgz#3ceeb58acd857f1e17d52d3087bfecb506adc1f7" + integrity sha512-izCS5iyc6HAfpW1AsGXLAKetx82C1Sq1siAh97tOlSK58PVJAEH/WMiej9WuZJxCDTOtj94QtoLflssrZyAtFg== + dependencies: + "@sentry/hub" "5.27.6" + "@sentry/minimal" "5.27.6" + "@sentry/types" "5.27.6" + "@sentry/utils" "5.27.6" tslib "^1.9.3" -"@sentry/cli@^1.47.1", "@sentry/cli@^1.49.0": - version "1.51.0" - resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-1.51.0.tgz#2ce7d03b7aebf3f9e9a7186bac1a777fc3dc0e82" - integrity sha512-QXdW3smFW9Wjd5gYHuA9u9tCra87VqpeFljRKdD7D2CwnYCnFDeluVk3l9O8Me6IoVBuL9Uwxx8q1vPtob4n3Q== - dependencies: - fs-copy-file-sync "^1.1.1" - https-proxy-agent "^4.0.0" - mkdirp "^0.5.1" - node-fetch "^2.1.2" - progress "2.0.0" - proxy-from-env "^1.0.0" - -"@sentry/core@5.13.2", "@sentry/core@~5.13.2": - version "5.13.2" - resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.13.2.tgz#d89e199beef612d0a01e5c4df4e0bb7efcb72c74" - integrity sha512-iB7CQSt9e0EJhSmcNOCjzJ/u7E7qYJ3mI3h44GO83n7VOmxBXKSvtUl9FpKFypbWrsdrDz8HihLgAZZoMLWpPA== - dependencies: - "@sentry/hub" "5.13.2" - "@sentry/minimal" "5.13.2" - "@sentry/types" "5.13.2" - "@sentry/utils" "5.13.2" +"@sentry/electron@2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@sentry/electron/-/electron-2.0.4.tgz#1259f5c38ea6190dd24138379518db1bb283557d" + integrity sha512-mniKLrAC2S8VdXvUYvo/ZN6pr/bQScF8c4r3hv0uLN6w6D4zPIrIsn2ph/etigwqhW4xanyPkxc+Pafl1fFr0Q== + dependencies: + "@sentry/browser" "5.27.6" + "@sentry/core" "5.27.6" + "@sentry/minimal" "5.27.6" + "@sentry/node" "5.27.6" + "@sentry/types" "5.27.6" + "@sentry/utils" "5.27.6" tslib "^1.9.3" -"@sentry/electron@1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@sentry/electron/-/electron-1.3.0.tgz#02bc5ab7d16e579cd048cdbaaed93b43584aef2d" - integrity sha512-9oNJg371A/Djk03KVBHj9BgqYCscKxzScYKlM4AYR+BxYQ3LLsZLLeD9Mkdc0hGnOszCRmO5jXRjBVYz1JkJcA== - dependencies: - "@sentry/browser" "~5.13.2" - "@sentry/core" "~5.13.2" - "@sentry/minimal" "~5.13.2" - "@sentry/node" "~5.13.2" - "@sentry/types" "~5.13.2" - "@sentry/utils" "~5.13.2" - electron-fetch "^1.4.0" - form-data "2.5.1" - util.promisify "1.0.1" - -"@sentry/hub@5.13.2": - version "5.13.2" - resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.13.2.tgz#875a5ba983d6ada5caae5b6b4decd0257ef5cdb7" - integrity sha512-/U7yq3DTuRz8SRpZVKAaenW9sD2F5wbj12kDVPxPnGspyqhy0wBWKs9j0YJfBiDXMKOwp3HX964O3ygtwjnfAw== - dependencies: - "@sentry/types" "5.13.2" - "@sentry/utils" "5.13.2" +"@sentry/hub@5.27.6": + version "5.27.6" + resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.27.6.tgz#a94adbe32c45dda7ad5adf742b82e0a022eb9c2f" + integrity sha512-bOMky3iu7zEghSaWmTayfme5tCpUok841qDCGxGKuyAtOhBDsgGNS/ApNEEDF2fyX0oo4G1cHYPWhX90ZFf/xA== + dependencies: + "@sentry/types" "5.27.6" + "@sentry/utils" "5.27.6" tslib "^1.9.3" -"@sentry/minimal@5.13.2", "@sentry/minimal@~5.13.2": - version "5.13.2" - resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.13.2.tgz#e42e33dc74fc935f8857d1a43a528afd741640fd" - integrity sha512-VV0eA3HgrnN3mac1XVPpSCLukYsU+QxegbmpnZ8UL8eIQSZ/ZikYxagDNlZbdnmXHUpOEUeag2gxVntSCo5UcA== +"@sentry/minimal@5.27.6": + version "5.27.6" + resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.27.6.tgz#783012ed94668be168f2b521e0ea6295c76ce2b0" + integrity sha512-pKhzVQX9nL4m1dcnb2i2Y47IWVNs+K3wiYLgCB9hl9+ApxppfOc+fquiFoCloST3IuaD4yly2TtbOJgAMWcMxQ== dependencies: - "@sentry/hub" "5.13.2" - "@sentry/types" "5.13.2" + "@sentry/hub" "5.27.6" + "@sentry/types" "5.27.6" tslib "^1.9.3" -"@sentry/node@~5.13.2": - version "5.13.2" - resolved "https://registry.yarnpkg.com/@sentry/node/-/node-5.13.2.tgz#3be5608e00fb3fe1b813ad8365073a465d19f5f6" - integrity sha512-LwNOUvc0+28jYfI0o4HmkDTEYdY3dWvSCnL5zggO12buon7Wc+jirXZbEQAx84HlXu7sGSjtKCTzUQOphv7sPw== - dependencies: - "@sentry/apm" "5.13.2" - "@sentry/core" "5.13.2" - "@sentry/hub" "5.13.2" - "@sentry/types" "5.13.2" - "@sentry/utils" "5.13.2" - cookie "^0.3.1" - https-proxy-agent "^4.0.0" +"@sentry/node@5.27.6": + version "5.27.6" + resolved "https://registry.yarnpkg.com/@sentry/node/-/node-5.27.6.tgz#a9ab20bf305d802914b41040ef3b328c2b681120" + integrity sha512-ogKL4F3wSZuzNeHOGKPqQPbZ87Bd/dC8wk7Rwbui3SIMgtoUmO3rSOR4Edwar6mf330cA6CY9roylWdcaSqmZA== + dependencies: + "@sentry/core" "5.27.6" + "@sentry/hub" "5.27.6" + "@sentry/tracing" "5.27.6" + "@sentry/types" "5.27.6" + "@sentry/utils" "5.27.6" + cookie "^0.4.1" + https-proxy-agent "^5.0.0" lru_map "^0.3.3" tslib "^1.9.3" -"@sentry/types@5.13.2", "@sentry/types@~5.13.2": - version "5.13.2" - resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.13.2.tgz#8e68c31f8fb99b4074374bff13ed01035b373d8c" - integrity sha512-mgAEQyc77PYBnAjnslSXUz6aKgDlunlg2c2qSK/ivKlEkTgTWWW/dE76++qVdrqM8SupnqQoiXyPDL0wUNdB3g== +"@sentry/tracing@5.27.6": + version "5.27.6" + resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-5.27.6.tgz#34a827c6e7a819b0eb0e409063203209abd19dad" + integrity sha512-ms3vprEId+hi8hcqtf8weqsNGASaDXAZzIOT4g2gASGpwLb5hLuScpM8z6Yhu5FGjb8DektlW5OrXJSsStIozw== + dependencies: + "@sentry/hub" "5.27.6" + "@sentry/minimal" "5.27.6" + "@sentry/types" "5.27.6" + "@sentry/utils" "5.27.6" + tslib "^1.9.3" -"@sentry/utils@5.13.2", "@sentry/utils@~5.13.2": - version "5.13.2" - resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.13.2.tgz#441594f4f9412bfd1690739ce986bf3a49687806" - integrity sha512-LwPQl6WRMKEnd16kg35HS3yE+VhBc8vN4+BBIlrgs7X0aoT+AbEd/sQLMisDgxNboCF44Ho3RCKtztiPb9blqg== +"@sentry/types@5.27.6": + version "5.27.6" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.27.6.tgz#b5054eafcb8ac11d4bc4787c7bc7fc113cad8b80" + integrity sha512-XOW9W8DrMk++4Hk7gWi9o5VR0o/GrqGfTKyFsHSIjqt2hL6kiMPvKeb2Hhmp7Iq37N2bDmRdWpM5m+68S2Jk6w== + +"@sentry/utils@5.27.6": + version "5.27.6" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.27.6.tgz#cd8486469ae9716a21a4bc7e828e5aeee0ed9727" + integrity sha512-/QMVLv+zrTfiIj2PU+SodSbSzD5MmamMOaljkDsRIVsj6gpkm1/VG1g2+40TZ0FbQ4hCW2F+iR7cnqzZBNmchA== dependencies: - "@sentry/types" "5.13.2" + "@sentry/types" "5.27.6" tslib "^1.9.3" "@sentry/webpack-plugin@^1.8.0": @@ -1388,24 +1483,10 @@ resolved "https://registry.yarnpkg.com/@types/autolinker/-/autolinker-0.24.28.tgz#37976f20c6e0abde283784fdc6b91cc6cb90e8dc" integrity sha1-N5dvIMbgq94oN4T9xrkcxsuQ6Nw= -"@types/aws-iot-device-sdk@^2.2.0": - version "2.2.1" - resolved "https://registry.yarnpkg.com/@types/aws-iot-device-sdk/-/aws-iot-device-sdk-2.2.1.tgz#e7faa3df41f8ca1412f44770ef1a921f7c8de223" - integrity sha512-x4VtKWlezA+r158EAqbwkBaaP93HHWyAgxAb6rBobwPaYdT/kSp/DDL/E+nKIHXtXquKEAgp8zwSi+P73m32Jg== - dependencies: - "@types/node" "*" - "@types/ws" "*" - mqtt "^2.13.0" - -"@types/aws4@^1.5.1": - version "1.5.1" - resolved "https://registry.yarnpkg.com/@types/aws4/-/aws4-1.5.1.tgz#361fadab198a030ab398269183ae3fa86e958ed9" - integrity sha1-Nh+tqxmKAwqzmCaRg64/qG6Vjtk= - -"@types/babel__core@^7.1.0": - version "7.1.4" - resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.4.tgz#5c5569cc40e5f2737dfc00692f5444e871e4a234" - integrity sha512-c/5MuRz5HM4aizqL5ViYfW4iEnmfPcfbH4Xa6GgLT21dMc1NGeNnuS6egHheOmP+kCJ9CAzC4pv4SDCWTnRkbg== +"@types/babel__core@^7.1.7": + version "7.1.12" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.12.tgz#4d8e9e51eb265552a7e4f1ff2219ab6133bdfb2d" + integrity sha512-wMTHiiTiBAAPebqaPiPDLFA4LYPKr6Ph0Xq/6rq1Ur3v66HXyG+clfR9CNETkD7MQS8ZHvpQOtA53DLws5WAEQ== dependencies: "@babel/parser" "^7.1.0" "@babel/types" "^7.0.0" @@ -1552,6 +1633,13 @@ dependencies: "@types/node" "*" +"@types/fs-extra@^9.0.1": + version "9.0.4" + resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.4.tgz#12553138cf0438db9a31cdc8b0a3aa9332eb67aa" + integrity sha512-50GO5ez44lxK5MDH90DYHFFfqxH7+fTqEEnvguQRzJ/tY9qFrMSHLiYHite+F3SNmf7+LHC1eMXojuD+E3Qcyg== + dependencies: + "@types/node" "*" + "@types/glob@^7.1.1": version "7.1.1" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575" @@ -1561,6 +1649,13 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/graceful-fs@^4.1.2": + version "4.1.4" + resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.4.tgz#4ff9f641a7c6d1a3508ff88bc3141b152772e753" + integrity sha512-mWA/4zFQhfvOA8zWkXobwJvBD7vzcxgrOQ0J5CH1votGqdq9m7+FwtGaqyCZqC3NyyBkc9z4m+iry4LlqcMWJg== + dependencies: + "@types/node" "*" + "@types/history@*": version "4.7.5" resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.5.tgz#527d20ef68571a4af02ed74350164e7a67544860" @@ -1638,7 +1733,7 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.149.tgz#1342d63d948c6062838fbf961012f74d4e638440" integrity sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ== -"@types/long@^4.0.0": +"@types/long@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9" integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w== @@ -1658,6 +1753,14 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6" integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY= +"@types/node-fetch@^2.5.7": + version "2.5.7" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c" + integrity sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw== + dependencies: + "@types/node" "*" + form-data "^3.0.0" + "@types/node-sass@^3.10.32": version "3.10.32" resolved "https://registry.yarnpkg.com/@types/node-sass/-/node-sass-3.10.32.tgz#b296cce7144ffab77b84090caad4f1e4bbea8e09" @@ -1670,7 +1773,7 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-13.7.1.tgz#238eb34a66431b71d2aaddeaa7db166f25971a0d" integrity sha512-Zq8gcQGmn4txQEJeiXo/KiLpon8TzAl0kmKH4zdWctPj05nWwp1ClMdAVEloqrQKfaC48PNLdgN/aVaLqUrluA== -"@types/node@^10.1.0", "@types/node@^10.12.0": +"@types/node@^10.12.0": version "10.17.15" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.15.tgz#bfff4e23e9e70be6eec450419d51e18de1daf8e7" integrity sha512-daFGV9GSs6USfPgxceDA8nlSe48XrVCJfDeYm7eokxq/ye7iuOH87hKXgMtEAVLFapkczbZsx868PMDT1Y0a6A== @@ -1680,6 +1783,16 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.27.tgz#d7506f73160ad30fcebbcf5b8b7d2d976e649e42" integrity sha512-odQFl/+B9idbdS0e8IxDl2ia/LP8KZLXhV3BUeI98TrZp0uoIzQPhGd+5EtzHmT0SMOIaPd7jfz6pOHLWTtl7A== +"@types/node@^13.7.0": + version "13.13.33" + resolved "https://registry.yarnpkg.com/@types/node/-/node-13.13.33.tgz#300e65e0b465bda102b9845d172d8d45726a2dd8" + integrity "sha1-MA5l4LRlvaECuYRdFy2NRXJqLdg= sha512-1B3GM1yuYsFyEvBb+ljBqWBOylsWDYioZ5wpu8AhXdIhq20neXS7eaSC8GkwHE0yQYGiOIV43lMsgRYTgKZefQ==" + +"@types/normalize-package-data@^2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" + integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA== + "@types/pino-multi-stream@^3.1.1": version "3.1.2" resolved "https://registry.yarnpkg.com/@types/pino-multi-stream/-/pino-multi-stream-3.1.2.tgz#befb1ccb3c7274f3eebee96f0580cf11f3961d62" @@ -1701,6 +1814,11 @@ "@types/pino-std-serializers" "*" "@types/sonic-boom" "*" +"@types/prettier@^1.19.0": + version "1.19.1" + resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-1.19.1.tgz#33509849f8e679e4add158959fdb086440e9553f" + integrity sha512-5qOlnZscTn4xxM5MeGXAMOsIOIKIbh9e85zJWfBRVPlRMEVawzoPhINYbRGkBZCI8LxvBe7tJCdWiarA99OZfQ== + "@types/prop-types@*": version "15.7.3" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" @@ -1789,16 +1907,16 @@ hoist-non-react-statics "^3.3.0" redux "^4.0.0" -"@types/react-router-dom@^5.1.3": - version "5.1.3" - resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.1.3.tgz#b5d28e7850bd274d944c0fbbe5d57e6b30d71196" - integrity sha512-pCq7AkOvjE65jkGS5fQwQhvUp4+4PVD9g39gXLZViP2UqFiFzsEpB3PKf0O6mdbKsewSK8N14/eegisa/0CwnA== +"@types/react-router-dom@^5.1.7": + version "5.1.7" + resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.1.7.tgz#a126d9ea76079ffbbdb0d9225073eb5797ab7271" + integrity sha512-D5mHD6TbdV/DNHYsnwBTv+y73ei+mMjrkGrla86HthE4/PVvL1J94Bu3qABU+COXzpL23T1EZapVVpwHuBXiUg== dependencies: "@types/history" "*" "@types/react" "*" "@types/react-router" "*" -"@types/react-router@*", "@types/react-router@^5.1.4": +"@types/react-router@*": version "5.1.4" resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.4.tgz#7d70bd905543cb6bcbdcc6bd98902332054f31a6" integrity sha512-PZtnBuyfL07sqCJvGg3z+0+kt6fobc/xmle08jBiezLS8FrmGeiGkJnuxL/8Zgy9L83ypUhniV5atZn/L8n9MQ== @@ -1806,10 +1924,10 @@ "@types/history" "*" "@types/react" "*" -"@types/react-stickynode@^1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@types/react-stickynode/-/react-stickynode-1.4.0.tgz#7ba60ef8af1ab11a3371c68a4b028663996b7d50" - integrity sha512-oBz1zR9w1CWmTym42BtzINVzLolvnpK1AOmWcUDYJhp/YhPT1S06nXQsrIrCSM8jiTdQN8G9YYJHmd0vFQdffg== +"@types/react-sticky-el@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@types/react-sticky-el/-/react-sticky-el-1.0.2.tgz#dd544d164566829b5857783de19ae1892e99835a" + integrity sha512-LjtfYEAKylFdTXMdoOnt2qVLf6BgrudNPeMxYu6rIqSEjN2eRE3yQkWvaodiYjMH474DQi04kwjt4SD3bdv5Bw== dependencies: "@types/react" "*" @@ -1871,12 +1989,10 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45" integrity sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ== -"@types/semver@^7.1.0": - version "7.1.0" - resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.1.0.tgz#c8c630d4c18cd326beff77404887596f96408408" - integrity sha512-pOKLaubrAEMUItGNpgwl0HMFPrSAFic8oSVIvfu1UwcgGNmNyK9gyhBHKmBnUTwwVvpZfkzUC0GaMgnL6P86uA== - dependencies: - "@types/node" "*" +"@types/semver@^7.3.1": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.4.tgz#43d7168fec6fa0988bb1a513a697b29296721afb" + integrity sha512-+nVsLKlcUCeMzD2ufHEYuJ9a2ovstb6Dp52A5VsoKxDXgvE051XgHI/33I1EymwkRGQkwnA0LkhnUzituGs4EQ== "@types/serve-static@*": version "1.13.3" @@ -1937,6 +2053,11 @@ resolved "https://registry.yarnpkg.com/@types/universal-analytics/-/universal-analytics-0.4.3.tgz#8cc5e9a69df0e3c9714bdcf101d8d93df2a6d247" integrity sha512-CCM1yPGAAg/WRlBEUIzRAqEE0G84n1btOVRybHgQ+AdA6+LgM0DeDH8yBWtcpg3HcrLHA9JWiHwPpySlo5GtZA== +"@types/uuid@^8.3.0": + version "8.3.0" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f" + integrity sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ== + "@types/webdriverio@^4.8.0": version "4.13.3" resolved "https://registry.yarnpkg.com/@types/webdriverio/-/webdriverio-4.13.3.tgz#c1571c4e62724135c0b11e7d7e36b07af5168856" @@ -1986,13 +2107,6 @@ "@types/webpack-sources" "*" source-map "^0.6.0" -"@types/ws@*": - version "7.2.1" - resolved "https://registry.yarnpkg.com/@types/ws/-/ws-7.2.1.tgz#b800f2b8aee694e2b581113643e20d79dd3b8556" - integrity sha512-UEmRNbXFGvfs/sLncf01GuVv6U1mZP3Df0iXWx4kUlikJxbFyFADp95mDn1XDTE2mXpzzoHcKlfFcbytLq4vaA== - dependencies: - "@types/node" "*" - "@types/yargs-parser@*": version "15.0.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-15.0.0.tgz#cb3f9f741869e20cce330ffbeb9271590483882d" @@ -2005,26 +2119,26 @@ dependencies: "@types/yargs-parser" "*" -"@typescript-eslint/eslint-plugin@^2.18.0": - version "2.20.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.20.0.tgz#a522d0e1e4898f7c9c6a8e1ed3579b60867693fa" - integrity sha512-cimIdVDV3MakiGJqMXw51Xci6oEDEoPkvh8ggJe2IIzcc0fYqAxOXN6Vbeanahz6dLZq64W+40iUEc9g32FLDQ== +"@types/yargs@^15.0.5": + version "15.0.10" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-15.0.10.tgz#0fe3c8173a0d5c3e780b389050140c3f5ea6ea74" + integrity sha512-z8PNtlhrj7eJNLmrAivM7rjBESG6JwC5xP3RVk12i/8HVP7Xnx/sEmERnRImyEuUaJfO942X0qMOYsoupaJbZQ== dependencies: - "@typescript-eslint/experimental-utils" "2.20.0" - eslint-utils "^1.4.3" + "@types/yargs-parser" "*" + +"@typescript-eslint/eslint-plugin@^4.9.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.9.0.tgz#8fde15743413661fdc086c9f1f5d74a80b856113" + integrity sha512-WrVzGMzzCrgrpnQMQm4Tnf+dk+wdl/YbgIgd5hKGa2P+lnJ2MON+nQnbwgbxtN9QDLi8HO+JAq0/krMnjQK6Cw== + dependencies: + "@typescript-eslint/experimental-utils" "4.9.0" + "@typescript-eslint/scope-manager" "4.9.0" + debug "^4.1.1" functional-red-black-tree "^1.0.1" regexpp "^3.0.0" + semver "^7.3.2" tsutils "^3.17.1" -"@typescript-eslint/experimental-utils@2.20.0": - version "2.20.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.20.0.tgz#3b6fa5a6b8885f126d5a4280e0d44f0f41e73e32" - integrity sha512-fEBy9xYrwG9hfBLFEwGW2lKwDRTmYzH3DwTmYbT+SMycmxAoPl0eGretnBFj/s+NfYBG63w/5c3lsvqqz5mYag== - dependencies: - "@types/json-schema" "^7.0.3" - "@typescript-eslint/typescript-estree" "2.20.0" - eslint-scope "^5.0.0" - "@typescript-eslint/experimental-utils@2.33.0": version "2.33.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.33.0.tgz#000f1e5f344fbea1323dc91cc174805d75f99a03" @@ -2035,15 +2149,17 @@ eslint-scope "^5.0.0" eslint-utils "^2.0.0" -"@typescript-eslint/parser@^2.18.0": - version "2.20.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-2.20.0.tgz#608e5bb06ba98a415b64ace994c79ab20f9772a9" - integrity sha512-o8qsKaosLh2qhMZiHNtaHKTHyCHc3Triq6aMnwnWj7budm3xAY9owSZzV1uon5T9cWmJRJGzTFa90aex4m77Lw== +"@typescript-eslint/experimental-utils@4.9.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.9.0.tgz#23a296b85d243afba24e75a43fd55aceda5141f0" + integrity sha512-0p8GnDWB3R2oGhmRXlEnCvYOtaBCijtA5uBfH5GxQKsukdSQyI4opC4NGTUb88CagsoNQ4rb/hId2JuMbzWKFQ== dependencies: - "@types/eslint-visitor-keys" "^1.0.0" - "@typescript-eslint/experimental-utils" "2.20.0" - "@typescript-eslint/typescript-estree" "2.20.0" - eslint-visitor-keys "^1.1.0" + "@types/json-schema" "^7.0.3" + "@typescript-eslint/scope-manager" "4.9.0" + "@typescript-eslint/types" "4.9.0" + "@typescript-eslint/typescript-estree" "4.9.0" + eslint-scope "^5.0.0" + eslint-utils "^2.0.0" "@typescript-eslint/parser@^2.24.0": version "2.33.0" @@ -2055,18 +2171,28 @@ "@typescript-eslint/typescript-estree" "2.33.0" eslint-visitor-keys "^1.1.0" -"@typescript-eslint/typescript-estree@2.20.0": - version "2.20.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-2.20.0.tgz#90a0f5598826b35b966ca83483b1a621b1a4d0c9" - integrity sha512-WlFk8QtI8pPaE7JGQGxU7nGcnk1ccKAJkhbVookv94ZcAef3m6oCE/jEDL6dGte3JcD7reKrA0o55XhBRiVT3A== +"@typescript-eslint/parser@^4.9.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.9.0.tgz#bb65f1214b5e221604996db53ef77c9d62b09249" + integrity sha512-QRSDAV8tGZoQye/ogp28ypb8qpsZPV6FOLD+tbN4ohKUWHD2n/u0Q2tIBnCsGwQCiD94RdtLkcqpdK4vKcLCCw== dependencies: + "@typescript-eslint/scope-manager" "4.9.0" + "@typescript-eslint/types" "4.9.0" + "@typescript-eslint/typescript-estree" "4.9.0" debug "^4.1.1" - eslint-visitor-keys "^1.1.0" - glob "^7.1.6" - is-glob "^4.0.1" - lodash "^4.17.15" - semver "^6.3.0" - tsutils "^3.17.1" + +"@typescript-eslint/scope-manager@4.9.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.9.0.tgz#5eefe305d6b71d1c85af6587b048426bfd4d3708" + integrity sha512-q/81jtmcDtMRE+nfFt5pWqO0R41k46gpVLnuefqVOXl4QV1GdQoBWfk5REcipoJNQH9+F5l+dwa9Li5fbALjzg== + dependencies: + "@typescript-eslint/types" "4.9.0" + "@typescript-eslint/visitor-keys" "4.9.0" + +"@typescript-eslint/types@4.9.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.9.0.tgz#3fe8c3632abd07095c7458f7451bd14c85d0033c" + integrity sha512-luzLKmowfiM/IoJL/rus1K9iZpSJK6GlOS/1ezKplb7MkORt2dDcfi8g9B0bsF6JoRGhqn0D3Va55b+vredFHA== "@typescript-eslint/typescript-estree@2.33.0": version "2.33.0" @@ -2081,6 +2207,28 @@ semver "^7.3.2" tsutils "^3.17.1" +"@typescript-eslint/typescript-estree@4.9.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.9.0.tgz#38a98df6ee281cfd6164d6f9d91795b37d9e508c" + integrity sha512-rmDR++PGrIyQzAtt3pPcmKWLr7MA+u/Cmq9b/rON3//t5WofNR4m/Ybft2vOLj0WtUzjn018ekHjTsnIyBsQug== + dependencies: + "@typescript-eslint/types" "4.9.0" + "@typescript-eslint/visitor-keys" "4.9.0" + debug "^4.1.1" + globby "^11.0.1" + is-glob "^4.0.1" + lodash "^4.17.15" + semver "^7.3.2" + tsutils "^3.17.1" + +"@typescript-eslint/visitor-keys@4.9.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.9.0.tgz#f284e9fac43f2d6d35094ce137473ee321f266c8" + integrity sha512-sV45zfdRqQo1A97pOSx3fsjR+3blmwtdCt8LDrXgCX36v4Vmz4KHrhpV6Fo2cRdXmyumxx11AHw0pNJqCNpDyg== + dependencies: + "@typescript-eslint/types" "4.9.0" + eslint-visitor-keys "^2.0.0" + "@webassemblyjs/ast@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359" @@ -2242,6 +2390,11 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== +"@yarnpkg/lockfile@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" + integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== + abab@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.3.tgz#623e2075e02eb2d3f2475e49f99c91846467907a" @@ -2252,6 +2405,13 @@ abbrev@1: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + abstract-socket@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/abstract-socket/-/abstract-socket-2.1.1.tgz#243a7e6e6ff65bb9eab16a22fa90699b91e528f7" @@ -2268,11 +2428,6 @@ accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7: mime-types "~2.1.24" negotiator "0.6.2" -accessibility-developer-tools@^2.11.0: - version "2.12.0" - resolved "https://registry.yarnpkg.com/accessibility-developer-tools/-/accessibility-developer-tools-2.12.0.tgz#3da0cce9d6ec6373964b84f35db7cfc3df7ab514" - integrity sha1-PaDM6dbsY3OWS4TzXbfPw996tRQ= - acorn-globals@^4.3.2: version "4.3.4" resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-4.3.4.tgz#9fa1926addc11c97308c4e66d7add0d40c3272e7" @@ -2302,9 +2457,9 @@ acorn@^6.0.1, acorn@^6.0.7, acorn@^6.2.1: integrity sha512-gac8OEcQ2Li1dxIEWGZzsp2BitJxwkwcOm0zHAJLcPJaVvm58FRnk6RkuLRpU1EujipU2ZFODv2P9DLMfnV8mw== acorn@^7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.0.tgz#949d36f2c292535da602283586c2477c57eb2d6c" - integrity sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ== + version "7.4.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" + integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== acorn@^7.1.1: version "7.2.0" @@ -2321,10 +2476,12 @@ address@^1.0.1: resolved "https://registry.yarnpkg.com/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6" integrity sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA== -agent-base@5: - version "5.1.1" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-5.1.1.tgz#e8fb3f242959db44d63be665db7a8e739537a32c" - integrity sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g== +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" aggregate-error@^3.0.0: version "3.0.1" @@ -2370,10 +2527,10 @@ ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.11.0, ajv@^6.5.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^6.12.0: - version "6.12.2" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.2.tgz#c629c5eced17baf314437918d2da88c99d5958cd" - integrity sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ== +ajv@^6.12.0, ajv@^6.12.2: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== dependencies: fast-deep-equal "^3.1.1" fast-json-stable-stringify "^2.0.0" @@ -2497,6 +2654,11 @@ anymatch@^3.0.3, anymatch@~3.1.1: normalize-path "^3.0.0" picomatch "^2.0.4" +app-builder-bin@3.5.10: + version "3.5.10" + resolved "https://registry.yarnpkg.com/app-builder-bin/-/app-builder-bin-3.5.10.tgz#4a7f9999fccc0c435b6284ae1366bc76a17c4a7d" + integrity sha512-Jd+GW68lR0NeetgZDo47PdWBEPdnD+p0jEa7XaxjRC8u6Oo/wgJsfKUkORRgr2NpkD19IFKN50P6JYy04XHFLQ== + app-builder-bin@3.5.2: version "3.5.2" resolved "https://registry.yarnpkg.com/app-builder-bin/-/app-builder-bin-3.5.2.tgz#fba56e6e9ef76fcd37816738c5f9a0b3992d7183" @@ -2530,6 +2692,34 @@ app-builder-lib@22.3.2: semver "^7.1.1" temp-file "^3.3.6" +app-builder-lib@22.9.1: + version "22.9.1" + resolved "https://registry.yarnpkg.com/app-builder-lib/-/app-builder-lib-22.9.1.tgz#ccb8f1a02b628514a5dfab9401fa2a976689415c" + integrity sha512-KfXim/fiNwFW2SKffsjEMdAU7RbbEXn62x5YyXle1b4j9X/wEHW9iwox8De6y0hJdR+/kCC/49lI+VgNwLhV7A== + dependencies: + "7zip-bin" "~5.0.3" + "@develar/schema-utils" "~2.6.5" + async-exit-hook "^2.0.1" + bluebird-lst "^1.0.9" + builder-util "22.9.1" + builder-util-runtime "8.7.2" + chromium-pickle-js "^0.2.0" + debug "^4.3.0" + ejs "^3.1.5" + electron-publish "22.9.1" + fs-extra "^9.0.1" + hosted-git-info "^3.0.5" + is-ci "^2.0.0" + isbinaryfile "^4.0.6" + js-yaml "^3.14.0" + lazy-val "^1.0.4" + minimatch "^3.0.4" + normalize-package-data "^2.5.0" + read-config-file "6.0.0" + sanitize-filename "^1.6.3" + semver "^7.3.2" + temp-file "^3.3.7" + app-builder-lib@~22.3.2: version "22.3.3" resolved "https://registry.yarnpkg.com/app-builder-lib/-/app-builder-lib-22.3.3.tgz#9a95a3c14f69fb6131834dd840fba561191c9998" @@ -2564,9 +2754,9 @@ aproba@^1.0.3, aproba@^1.1.1: integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== arch@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/arch/-/arch-2.1.1.tgz#8f5c2731aa35a30929221bb0640eed65175ec84e" - integrity sha512-BLM56aPo9vLLFVa8+/+pJLnrZ7QGGTVHWsCwieAWT9o9K8UeGaQbzZbGoabWLOo2ksBCztoXdqBZBplqLDDCSg== + version "2.2.0" + resolved "https://registry.yarnpkg.com/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11" + integrity "sha1-G8R4GPMFdk8jqzMGsL/AhsWinRE= sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==" archive-type@^4.0.0: version "4.0.0" @@ -2700,6 +2890,17 @@ array-includes@^3.0.3, array-includes@^3.1.1: es-abstract "^1.17.0" is-string "^1.0.5" +array-includes@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.2.tgz#a8db03e0b88c8c6aeddc49cb132f9bcab4ebf9c8" + integrity sha512-w2GspexNQpx+PutG3QpT437/BenZBj0M/MZGn5mzv/MofYqo0xmRHzn4lFsoDlWJ+THYsGJmFlW68WlDFx7VRw== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + es-abstract "^1.18.0-next.1" + get-intrinsic "^1.0.1" + is-string "^1.0.5" + array-union@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39" @@ -2743,6 +2944,16 @@ array.prototype.flat@^1.2.1, array.prototype.flat@^1.2.3: define-properties "^1.1.3" es-abstract "^1.17.0-next.1" +array.prototype.flatmap@^1.2.3: + version "1.2.4" + resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.2.4.tgz#94cfd47cc1556ec0747d97f7c7738c58122004c9" + integrity sha512-r9Z0zYoxqHz60vvQbWEdXIEtCwHF0yxaWfno9qzXeNHvfyl3BZqygmGzb84dsubyaXLH4husF+NFgMSdpZhk2Q== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + es-abstract "^1.18.0-next.1" + function-bind "^1.1.1" + arrify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" @@ -2835,7 +3046,7 @@ async-limiter@~1.0.0: resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== -async@^0.9.0: +async@0.9.x, async@^0.9.0: version "0.9.2" resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" integrity sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0= @@ -2862,11 +3073,21 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= +at-least-node@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== + atob@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== +atomically@^1.3.1: + version "1.7.0" + resolved "https://registry.yarnpkg.com/atomically/-/atomically-1.7.0.tgz#c07a0458432ea6dbc9a3506fffa424b48bccaafe" + integrity sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w== + autolinker@^1.8.3: version "1.8.3" resolved "https://registry.yarnpkg.com/autolinker/-/autolinker-1.8.3.tgz#8295d26700f1ad39691281c6a4f96686b64b5f30" @@ -2885,22 +3106,12 @@ autoprefixer@^9.6.1: postcss "^7.0.26" postcss-value-parser "^4.0.2" -aws-iot-device-sdk@^2.2.1: - version "2.2.3" - resolved "https://registry.yarnpkg.com/aws-iot-device-sdk/-/aws-iot-device-sdk-2.2.3.tgz#8041b7acdcbfa1b4e425cb66ac1228aac4277926" - integrity sha512-XhKPEZjwtSNJ0oV9l1gJ0YBlcg/M7QzeiUSbW2lLBa3Nuh84OOkQzcUFzNrSWro6sOdrLmLPqWKi7SsERUAbQg== - dependencies: - crypto-js "3.1.6" - minimist "1.2.0" - mqtt "2.18.8" - websocket-stream "^5.0.1" - aws-sign2@~0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= -aws4@^1.8.0, aws4@^1.9.1: +aws4@^1.8.0: version "1.9.1" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e" integrity sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug== @@ -3009,17 +3220,18 @@ babel-jest@^23.6.0: babel-plugin-istanbul "^4.1.6" babel-preset-jest "^23.2.0" -babel-jest@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-25.1.0.tgz#206093ac380a4b78c4404a05b3277391278f80fb" - integrity sha512-tz0VxUhhOE2y+g8R2oFrO/2VtVjA1lkJeavlhExuRBg3LdNJY9gwQ+Vcvqt9+cqy71MCTJhewvTB7Qtnnr9SWg== +babel-jest@^25.5.1: + version "25.5.1" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-25.5.1.tgz#bc2e6101f849d6f6aec09720ffc7bc5332e62853" + integrity sha512-9dA9+GmMjIzgPnYtkhBg73gOo/RHqPmLruP3BaGL4KEX3Dwz6pI8auSN8G8+iuEG90+GSswyKvslN+JYSaacaQ== dependencies: - "@jest/transform" "^25.1.0" - "@jest/types" "^25.1.0" - "@types/babel__core" "^7.1.0" + "@jest/transform" "^25.5.1" + "@jest/types" "^25.5.0" + "@types/babel__core" "^7.1.7" babel-plugin-istanbul "^6.0.0" - babel-preset-jest "^25.1.0" + babel-preset-jest "^25.5.0" chalk "^3.0.0" + graceful-fs "^4.2.4" slash "^3.0.0" babel-loader@^8.0.6: @@ -3077,11 +3289,13 @@ babel-plugin-jest-hoist@^23.2.0: resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-23.2.0.tgz#e61fae05a1ca8801aadee57a6d66b8cefaf44167" integrity sha1-5h+uBaHKiAGq3uV6bWa4zvr0QWc= -babel-plugin-jest-hoist@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-25.1.0.tgz#fb62d7b3b53eb36c97d1bc7fec2072f9bd115981" - integrity sha512-oIsopO41vW4YFZ9yNYoLQATnnN46lp+MZ6H4VvPKFkcc2/fkl3CfE/NZZSmnEIEsJRmJAgkVEK0R7Zbl50CpTw== +babel-plugin-jest-hoist@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-25.5.0.tgz#129c80ba5c7fc75baf3a45b93e2e372d57ca2677" + integrity sha512-u+/W+WAjMlvoocYGTwthAiQSxDcJAyHpQ6oWlHdFZaaN+Rlk8Q7iiwDPg2lN/FyJtAYnKjFxbn7xus4HCFkg5g== dependencies: + "@babel/template" "^7.3.3" + "@babel/types" "^7.3.3" "@types/babel__traverse" "^7.0.6" babel-plugin-module-resolver@^3.2.0: @@ -3170,6 +3384,23 @@ babel-plugin-transform-react-remove-prop-types@^0.4.21: resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz#f2edaf9b4c6a5fbe5c1d678bfb531078c1555f3a" integrity sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA== +babel-preset-current-node-syntax@^0.1.2: + version "0.1.4" + resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-0.1.4.tgz#826f1f8e7245ad534714ba001f84f7e906c3b615" + integrity sha512-5/INNCYhUGqw7VbVjT/hb3ucjgkVHKXY7lX3ZjlN4gm565VyFmJUrJ/h+h16ECVB38R/9SF6aACydpKMLZ/c9w== + dependencies: + "@babel/plugin-syntax-async-generators" "^7.8.4" + "@babel/plugin-syntax-bigint" "^7.8.3" + "@babel/plugin-syntax-class-properties" "^7.8.3" + "@babel/plugin-syntax-import-meta" "^7.8.3" + "@babel/plugin-syntax-json-strings" "^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators" "^7.8.3" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-syntax-numeric-separator" "^7.8.3" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + babel-preset-flow@^6.23.0: version "6.23.0" resolved "https://registry.yarnpkg.com/babel-preset-flow/-/babel-preset-flow-6.23.0.tgz#e71218887085ae9a24b5be4169affb599816c49d" @@ -3185,14 +3416,13 @@ babel-preset-jest@^23.2.0: babel-plugin-jest-hoist "^23.2.0" babel-plugin-syntax-object-rest-spread "^6.13.0" -babel-preset-jest@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-25.1.0.tgz#d0aebfebb2177a21cde710996fce8486d34f1d33" - integrity sha512-eCGn64olaqwUMaugXsTtGAM2I0QTahjEtnRu0ql8Ie+gDWAc1N6wqN0k2NilnyTunM69Pad7gJY7LOtwLimoFQ== +babel-preset-jest@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-25.5.0.tgz#c1d7f191829487a907764c65307faa0e66590b49" + integrity sha512-8ZczygctQkBU+63DtSOKGh7tFL0CeCuz+1ieud9lJ1WPQ9O6A1a/r+LGn6Y705PA6whHQ3T1XuB/PmpfNYf8Fw== dependencies: - "@babel/plugin-syntax-bigint" "^7.0.0" - "@babel/plugin-syntax-object-rest-spread" "^7.0.0" - babel-plugin-jest-hoist "^25.1.0" + babel-plugin-jest-hoist "^25.5.0" + babel-preset-current-node-syntax "^0.1.2" babel-preset-react@^6.24.1: version "6.24.1" @@ -3393,10 +3623,10 @@ bindings@^1.2.1, bindings@^1.5.0: dependencies: file-uri-to-path "1.0.0" -bl@^1.0.0, bl@^1.2.1: - version "1.2.2" - resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.2.tgz#a160911717103c07410cef63ef51b397c025af9c" - integrity sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA== +bl@^1.0.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.3.tgz#1e8dd80142eac80d7158c9dccc047fb620e035e7" + integrity "sha1-Ho3YAULqyA1xWMnczAR/tiDgNec= sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==" dependencies: readable-stream "^2.3.5" safe-buffer "^5.1.1" @@ -3467,10 +3697,10 @@ boolbase@^1.0.0, boolbase@~1.0.0: resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= -boolean@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/boolean/-/boolean-3.0.1.tgz#35ecf2b4a2ee191b0b44986f14eb5f052a5cbb4f" - integrity sha512-HRZPIjPcbwAVQvOTxR4YE3o8Xs98NqbbL1iEZDCz7CL8ql0Lt5iOyJFxfnAB0oFs8Oh02F/lLlg30Mexv46LjA== +boolean@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/boolean/-/boolean-3.0.2.tgz#df1baa18b6a2b0e70840475e1d93ec8fe75b2570" + integrity "sha1-3xuqGLaisOcIQEdeHZPsj+dbJXA= sha512-RwywHlpCRc3/Wh81MiCKun4ydaIFyW5Ea6JbL6sRCVx5q5irDw7pMXBUFYF/jArQ6YrG36q0kpovc9P/Kd3I4g==" bootstrap@^4.1.3: version "4.4.1" @@ -3727,6 +3957,14 @@ builder-util-runtime@8.6.0: debug "^4.1.1" sax "^1.2.4" +builder-util-runtime@8.7.2: + version "8.7.2" + resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-8.7.2.tgz#d93afc71428a12789b437e13850e1fa7da956d72" + integrity sha512-xBqv+8bg6cfnzAQK1k3OGpfaHg+QkPgIgpEkXNhouZ0WiUkyZCftuRc2LYzQrLucFywpa14Xbc6+hTbpq83yRA== + dependencies: + debug "^4.1.1" + sax "^1.2.4" + builder-util@22.3.2: version "22.3.2" resolved "https://registry.yarnpkg.com/builder-util/-/builder-util-22.3.2.tgz#23c61aaf0f0006f994087b33a26e47cdaec7aa8d" @@ -3766,6 +4004,26 @@ builder-util@22.3.3, builder-util@~22.3.2, builder-util@~22.3.3: stat-mode "^1.0.0" temp-file "^3.3.6" +builder-util@22.9.1: + version "22.9.1" + resolved "https://registry.yarnpkg.com/builder-util/-/builder-util-22.9.1.tgz#b7087a5cde477f90d718ca5d7fafb6ae261b16af" + integrity sha512-5hN/XOaYu4ZQUS6F+5CXE6jTo+NAnVqAxDuKGSaHWb9bejfv/rluChTLoY3/nJh7RFjkoyVjvFJv7zQDB1QmHw== + dependencies: + "7zip-bin" "~5.0.3" + "@types/debug" "^4.1.5" + "@types/fs-extra" "^9.0.1" + app-builder-bin "3.5.10" + bluebird-lst "^1.0.9" + builder-util-runtime "8.7.2" + chalk "^4.1.0" + debug "^4.3.0" + fs-extra "^9.0.1" + is-ci "^2.0.0" + js-yaml "^3.14.0" + source-map-support "^0.5.19" + stat-mode "^1.0.0" + temp-file "^3.3.7" + builtin-modules@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" @@ -3884,13 +4142,13 @@ cacheable-request@^6.0.0: normalize-url "^4.1.0" responselike "^1.0.2" -callback-stream@^1.0.2: - version "1.1.0" - resolved "https://registry.yarnpkg.com/callback-stream/-/callback-stream-1.1.0.tgz#4701a51266f06e06eaa71fc17233822d875f4908" - integrity sha1-RwGlEmbwbgbqpx/BcjOCLYdfSQg= +call-bind@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.0.tgz#24127054bb3f9bdcb4b1fb82418186072f77b8ce" + integrity sha512-AEXsYIyyDY3MCzbwdhzG3Jx1R0J2wetQyUynn6dYHAO+bg8l1k7jwZtRv4ryryFs7EP+NDlikJlVe59jr0cM2w== dependencies: - inherits "^2.0.1" - readable-stream "> 1.0.0 < 3.0.0" + function-bind "^1.1.1" + get-intrinsic "^1.0.0" caller-callsite@^2.0.0: version "2.0.0" @@ -4088,6 +4346,14 @@ chalk@^4.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" + integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + character-entities-legacy@^1.0.0: version "1.1.4" resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz#94bc1845dce70a5bb9d2ecc748725661293d8fc1" @@ -4234,7 +4500,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@^2.0.0, classnames@^2.2, classnames@^2.2.3, classnames@^2.2.5: +classnames@^2.2, classnames@^2.2.3, classnames@^2.2.5: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== @@ -4332,6 +4598,15 @@ cliui@^6.0.0: strip-ansi "^6.0.0" wrap-ansi "^6.2.0" +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + clone-deep@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" @@ -4445,7 +4720,7 @@ colour@0.7.1: resolved "https://registry.yarnpkg.com/colour/-/colour-0.7.1.tgz#9cb169917ec5d12c0736d3e8685746df1cadf778" integrity sha1-nLFpkX7F0SwHNtPoaFdG3xyt93g= -combined-stream@^1.0.6, combined-stream@~1.0.6: +combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== @@ -4462,7 +4737,7 @@ commander@2.17.x: resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf" integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg== -commander@^2.11.0, commander@^2.12.1, commander@^2.18.0, commander@^2.19.0, commander@^2.20.0, commander@^2.9.0: +commander@^2.11.0, commander@^2.12.1, commander@^2.18.0, commander@^2.19.0, commander@^2.20.0, commander@^2.8.1, commander@^2.9.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -4472,21 +4747,6 @@ commander@~2.19.0: resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a" integrity sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg== -commander@~2.8.1: - version "2.8.1" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.8.1.tgz#06be367febfda0c330aa1e2a072d3dc9762425d4" - integrity sha1-Br42f+v9oMMwqh4qBy09yXYkJdQ= - dependencies: - graceful-readlink ">= 1.0.0" - -commist@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/commist/-/commist-1.1.0.tgz#17811ec6978f6c15ee4de80c45c9beb77cee35d5" - integrity sha512-rraC8NXWOEjhADbZe9QBNzLAN5Q3fsTPQtBV+fEVj6xKIgDgNiEVE6ZNfHpZOqfQ21YUzfVNUXLOEZquYvQPPg== - dependencies: - leven "^2.1.0" - minimist "^1.1.0" - commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" @@ -4532,7 +4792,7 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= -concat-stream@1.6.2, concat-stream@^1.5.0, concat-stream@^1.6.2: +concat-stream@^1.5.0, concat-stream@^1.6.2: version "1.6.2" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== @@ -4557,21 +4817,21 @@ concurrently@^4.0.1: tree-kill "^1.2.1" yargs "^12.0.5" -conf@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/conf/-/conf-6.2.0.tgz#274d37a0a2e50757ffb89336e954d08718eb359a" - integrity sha512-fvl40R6YemHrFsNiyP7TD0tzOe3pQD2dfT2s20WvCaq57A1oV+RImbhn2Y4sQGDz1lB0wNSb7dPcPIvQB69YNA== +conf@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/conf/-/conf-7.1.2.tgz#d9678a9d8f04de8bf5cd475105da8fdae49c2ec4" + integrity sha512-r8/HEoWPFn4CztjhMJaWNAe5n+gPUCSaJ0oufbqDLFKsA1V8JjAG7G+p0pgoDFAws9Bpk2VtVLLXqOBA7WxLeg== dependencies: - ajv "^6.10.2" - debounce-fn "^3.0.1" - dot-prop "^5.0.0" + ajv "^6.12.2" + atomically "^1.3.1" + debounce-fn "^4.0.0" + dot-prop "^5.2.0" env-paths "^2.2.0" - json-schema-typed "^7.0.1" - make-dir "^3.0.0" + json-schema-typed "^7.0.3" + make-dir "^3.1.0" onetime "^5.1.0" - pkg-up "^3.0.1" - semver "^6.2.0" - write-file-atomic "^3.0.0" + pkg-up "^3.1.0" + semver "^7.3.2" config-chain@^1.1.11: version "1.1.12" @@ -4664,10 +4924,10 @@ cookie@0.4.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== -cookie@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" - integrity sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s= +cookie@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" + integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== copy-concurrently@^1.0.0: version "1.0.5" @@ -4729,7 +4989,7 @@ core-js-pure@^3.0.0: resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.6.5.tgz#c79e75f5e38dbc85a662d91eea52b8256d53b813" integrity sha512-lacdXOimsiD0QyNf9BC/mxivNJ/ybBGJXQFKzRekp1WTHoVUWsUHEn+2T8GJAzzIhyOuXA+gOxCVN3l+5PLPUA== -core-js@3, core-js@^3.6.4: +core-js@3: version "3.6.4" resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.4.tgz#440a83536b458114b9cb2ac1580ba377dc470647" integrity sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw== @@ -4744,6 +5004,11 @@ core-js@^2.4.0, core-js@^2.5.0: resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== +core-js@^3.6.5: + version "3.8.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.8.0.tgz#0fc2d4941cadf80538b030648bb64d230b4da0ce" + integrity "sha1-D8LUlByt+AU4sDBki7ZNIwtNoM4= sha512-W2VYNB0nwQQE7tKS7HzXd7r2y/y2SVJl4ga6oH/dnaLFzM0o2lB2P3zCkWj5Wc/zyMYjtgd5Hmhk0ObkQFZOIA==" + core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -4866,11 +5131,6 @@ cross-spawn@^7.0.2: shebang-command "^2.0.0" which "^2.0.1" -cross-unzip@0.0.2: - version "0.0.2" - resolved "https://registry.yarnpkg.com/cross-unzip/-/cross-unzip-0.0.2.tgz#5183bc47a09559befcf98cc4657964999359372f" - integrity sha1-UYO8R6CVWb78+YzEZXlkmZNZNy8= - crypto-browserify@3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.3.0.tgz#b9fc75bb4a0ed61dcf1cd5dae96eb30c9c3e506c" @@ -4898,11 +5158,6 @@ crypto-browserify@^3.11.0: randombytes "^2.0.0" randomfill "^1.0.3" -crypto-js@3.1.6: - version "3.1.6" - resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-3.1.6.tgz#6142651b232dbb8ebdfa9716a70a2888359da6c9" - integrity sha1-YUJlGyMtu469+pcWpwooiDWdpsk= - crypto-random-string@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" @@ -5197,14 +5452,6 @@ cyclist@^1.0.1: resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= -d@1, d@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a" - integrity sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA== - dependencies: - es5-ext "^0.10.50" - type "^1.0.1" - damerau-levenshtein@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.6.tgz#143c1641cb3d85c60c32329e26899adea8701791" @@ -5236,10 +5483,10 @@ dateformat@^3.0.3: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== -dbus-next@0.8.1: - version "0.8.1" - resolved "https://registry.yarnpkg.com/dbus-next/-/dbus-next-0.8.1.tgz#19ca46691f293fd77405fceaeaec5b64090f0fe0" - integrity sha512-/sgwpcnCkLQuMOTF9I95x6qvJZCbTK2RAbHwh7C60VMroSU7rphydj3ujpqiSy5yq04aKc3meZIHpCOrZ2791g== +dbus-next@0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/dbus-next/-/dbus-next-0.9.1.tgz#6c3cba7ed24ff3944cc9d350d0f2a5963a69c9c4" + integrity sha512-HTFKjrcNcjdjNlZR+/5LlUHBw13d+ZHQA51tfn8xUzm3WYi7uWQ99M8M4bttATCth3l4G/fxu3XC97JZ5K0oSQ== dependencies: "@nornagon/put" "0.0.8" event-stream "3.3.4" @@ -5266,12 +5513,12 @@ dbus-next@^0.5.1: optionalDependencies: abstract-socket "^2.0.0" -debounce-fn@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/debounce-fn/-/debounce-fn-3.0.1.tgz#034afe8b904d985d1ec1aa589cd15f388741d680" - integrity sha512-aBoJh5AhpqlRoHZjHmOzZlRx+wz2xVwGL9rjs+Kj0EWUrL4/h4K7OD176thl2Tdoqui/AaA4xhHrNArGLAaI3Q== +debounce-fn@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/debounce-fn/-/debounce-fn-4.0.0.tgz#ed76d206d8a50e60de0dd66d494d82835ffe61c7" + integrity sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ== dependencies: - mimic-fn "^2.1.0" + mimic-fn "^3.0.0" debug@2.6.9, debug@^2.1.3, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.8, debug@^2.6.9: version "2.6.9" @@ -5301,6 +5548,13 @@ debug@^3.0.0, debug@^3.1.1, debug@^3.2.5: dependencies: ms "^2.1.1" +debug@^4.3.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" + integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== + dependencies: + ms "2.1.2" + decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.1.2, decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" @@ -5358,9 +5612,9 @@ decompress-unzip@^4.0.1: yauzl "^2.4.2" decompress@^4.0.0, decompress@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/decompress/-/decompress-4.2.0.tgz#7aedd85427e5a92dacfe55674a7c505e96d01f9d" - integrity sha1-eu3YVCflqS2s/lVnSnxQXpbQH50= + version "4.2.1" + resolved "https://registry.yarnpkg.com/decompress/-/decompress-4.2.1.tgz#007f55cc6a62c055afa37c07eb6a4ee1b773f118" + integrity "sha1-AH9VzGpiwFWvo3wH62pO4bdz8Rg= sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==" dependencies: decompress-tar "^4.0.0" decompress-tarbz2 "^4.0.0" @@ -5405,6 +5659,11 @@ deep-is@^0.1.3, deep-is@~0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= +deepmerge@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" + integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== + deepmerge@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.0.1.tgz#25c1c24f110fb914f80001b925264dd77f3f4312" @@ -5549,20 +5808,16 @@ dev-null@^0.1.1: resolved "https://registry.yarnpkg.com/dev-null/-/dev-null-0.1.1.tgz#5a205ce3c2b2ef77b6238d6ba179eb74c6a0e818" integrity sha1-WiBc48Ky73e2I41roXnrdMag6Bg= -devtron@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/devtron/-/devtron-1.4.0.tgz#b5e748bd6e95bbe70bfcc68aae6fe696119441e1" - integrity sha1-tedIvW6Vu+cL/MaKrm/mlhGUQeE= - dependencies: - accessibility-developer-tools "^2.11.0" - highlight.js "^9.3.0" - humanize-plus "^1.8.1" - diff-sequences@^25.1.0: version "25.1.0" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-25.1.0.tgz#fd29a46f1c913fd66c22645dc75bffbe43051f32" integrity sha512-nFIfVk5B/NStCsJ+zaPO4vYuLjlzQ6uFvPxzYyHlejNZ/UGa7G/n7peOXVrVNvRuyfstt+mZQYGpjxg9Z6N8Kw== +diff-sequences@^25.2.6: + version "25.2.6" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-25.2.6.tgz#5f467c00edd35352b7bca46d7927d60e687a76dd" + integrity sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg== + diff@3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" @@ -5614,6 +5869,18 @@ dmg-builder@22.3.2: js-yaml "^3.13.1" sanitize-filename "^1.6.3" +dmg-builder@22.9.1: + version "22.9.1" + resolved "https://registry.yarnpkg.com/dmg-builder/-/dmg-builder-22.9.1.tgz#64647224f37ee47fc9bd01947c21cc010a30511f" + integrity sha512-jc+DAirqmQrNT6KbDHdfEp8D1kD0DBTnsLhwUR3MX+hMBun5bT134LQzpdK0GKvd22GqF8L1Cz/NOgaVjscAXQ== + dependencies: + app-builder-lib "22.9.1" + builder-util "22.9.1" + fs-extra "^9.0.1" + iconv-lite "^0.6.2" + js-yaml "^3.14.0" + sanitize-filename "^1.6.3" + dns-equal@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/dns-equal/-/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d" @@ -5774,7 +6041,7 @@ domutils@^2.0.0: domelementtype "^2.0.1" domhandler "^3.0.0" -dot-prop@^5.0.0, dot-prop@^5.2.0: +dot-prop@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.2.0.tgz#c34ecc29556dc45f1f4c22697b6f4904e0cc4fcb" integrity sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A== @@ -5855,7 +6122,7 @@ duplexer@^0.1.1, duplexer@~0.1.1: resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" integrity sha1-rOb/gIwc5mtX0ev5eXessCM0z8E= -duplexify@^3.4.2, duplexify@^3.5.1, duplexify@^3.6.0: +duplexify@^3.4.2, duplexify@^3.6.0: version "3.7.1" resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309" integrity sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g== @@ -5888,12 +6155,19 @@ ejs@^3.0.1: resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.0.1.tgz#30c8f6ee9948502cc32e85c37a3f8b39b5a614a5" integrity sha512-cuIMtJwxvzumSAkqaaoGY/L6Fc/t6YvoP9/VIaK0V/CyqKLEQ8sqODmYfy/cjXEdZ9+OOL8TecbJu+1RsofGDw== +ejs@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.5.tgz#aed723844dc20acb4b170cd9ab1017e476a0d93b" + integrity sha512-dldq3ZfFtgVTJMLjOe+/3sROTzALlL9E34V4/sDtUd/KlBSS0s6U1/+WPE1B4sj9CXHJpL1M6rhNJnc9Wbal9w== + dependencies: + jake "^10.6.1" + ejs@~2.5.6: version "2.5.9" resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.9.tgz#7ba254582a560d267437109a68354112475b0ce5" integrity sha512-GJCAeDBKfREgkBtgrYSf9hQy9kTb3helv0zGdzqhM7iAkW8FA/ZF97VQDbwFiwIT8MQLLOe5VlPZOEvZAqtUAQ== -electron-builder@*, electron-builder@^22.3.2: +electron-builder@*: version "22.3.2" resolved "https://registry.yarnpkg.com/electron-builder/-/electron-builder-22.3.2.tgz#902d150fc0670cb90213262e5e0aa3c4f299ffa4" integrity sha512-bDjHfKtA4DapI6qqy4FC18fzLsOJtlSVGBqjSjhrgv+gbcppp3tjR6ASsUX5K64/8L9MGjhRGdfQ7iP78OLx8g== @@ -5912,40 +6186,59 @@ electron-builder@*, electron-builder@^22.3.2: update-notifier "^4.0.0" yargs "^15.1.0" -electron-chromedriver@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/electron-chromedriver/-/electron-chromedriver-8.0.0.tgz#16f6124d481e9312cc18abc16495ddc2d61f8264" - integrity sha512-d0210ExhkGOwYLXFZHQR6LISZ8UbMqXWLwjTe8Cdh44XlO4z4+6DWQfM0p7aB2Qak/An6tN732Yl98wN1ylZww== +electron-builder@^22.9.1: + version "22.9.1" + resolved "https://registry.yarnpkg.com/electron-builder/-/electron-builder-22.9.1.tgz#a2962db6f2757bc01d02489f38fafe0809f68f60" + integrity sha512-GXPt8l5Mxwm1QKYopUM6/Tdh9W3695G6Ax+IFyj5pQ51G4SD5L1uq4/RkPSsOgs3rP7jNSV6g6OfDzdtVufPdA== + dependencies: + "@types/yargs" "^15.0.5" + app-builder-lib "22.9.1" + bluebird-lst "^1.0.9" + builder-util "22.9.1" + builder-util-runtime "8.7.2" + chalk "^4.1.0" + dmg-builder "22.9.1" + fs-extra "^9.0.1" + is-ci "^2.0.0" + lazy-val "^1.0.4" + read-config-file "6.0.0" + sanitize-filename "^1.6.3" + update-notifier "^4.1.1" + yargs "^16.0.3" + +electron-chromedriver@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/electron-chromedriver/-/electron-chromedriver-8.0.0.tgz#16f6124d481e9312cc18abc16495ddc2d61f8264" + integrity sha512-d0210ExhkGOwYLXFZHQR6LISZ8UbMqXWLwjTe8Cdh44XlO4z4+6DWQfM0p7aB2Qak/An6tN732Yl98wN1ylZww== dependencies: electron-download "^4.1.1" extract-zip "^1.6.7" electron-debug@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/electron-debug/-/electron-debug-3.0.1.tgz#95b43b968ec7dbe96300034143e58b803a1e82dc" - integrity sha512-fo3mtDM4Bxxm3DW1I+XcJKfQlUlns4QGWyWGs8OrXK1bBZ2X9HeqYMntYBx78MYRcGY5S/ualuG4GhCnPlaZEA== + version "3.1.0" + resolved "https://registry.yarnpkg.com/electron-debug/-/electron-debug-3.1.0.tgz#0df17297487fa3c82344d810812853bf67f0bd69" + integrity sha512-SWEqLj4MgfV3tGuO5eBLQ5/Nr6M+KPxsnE0bUJZvQebGJus6RAcdmvd7L+l0Ji31h2mmrN23l2tHFtCa2FvurA== dependencies: electron-is-dev "^1.1.0" electron-localshortcut "^3.1.0" -electron-devtools-installer@^2.2.4: - version "2.2.4" - resolved "https://registry.yarnpkg.com/electron-devtools-installer/-/electron-devtools-installer-2.2.4.tgz#261a50337e37121d338b966f07922eb4939a8763" - integrity sha512-b5kcM3hmUqn64+RUcHjjr8ZMpHS2WJ5YO0pnG9+P/RTdx46of/JrEjuciHWux6pE+On6ynWhHJF53j/EDJN0PA== +electron-devtools-installer@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/electron-devtools-installer/-/electron-devtools-installer-3.1.1.tgz#7b56c8c86475c5e4e10de6917d150c53c9ceb55e" + integrity sha512-g2D4J6APbpsiIcnLkFMyKZ6bOpEJ0Ltcc2m66F7oKUymyGAt628OWeU9nRZoh1cNmUs/a6Cls2UfOmsZtE496Q== dependencies: - "7zip" "0.0.6" - cross-unzip "0.0.2" - rimraf "^2.5.2" - semver "^5.3.0" + rimraf "^3.0.2" + semver "^7.2.1" + unzip-crx-3 "^0.2.0" -electron-dl@^1.14.0: - version "1.14.0" - resolved "https://registry.yarnpkg.com/electron-dl/-/electron-dl-1.14.0.tgz#1466f1b945664ca3d784268307c2b935728177bf" - integrity sha512-4okyei42a1mLsvLK7hLrIfd20EQzB18nIlLTwBV992aMSmTGLUEFRTmO1MfSslGNrzD8nuPuy1l/VxO8so4lig== +electron-dl@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/electron-dl/-/electron-dl-3.0.2.tgz#302a46f9a449ddce720cb8e7f2a24c386e19a26c" + integrity sha512-pRgE9Jbhoo5z6Vk3qi+vIrfpMDlCp2oB1UeR96SMnsfz073jj0AZGQwp69EdIcEvlUlwBSGyJK8Jt6OB6JLn+g== dependencies: ext-name "^5.0.0" - pupa "^1.0.0" - unused-filename "^1.0.0" + pupa "^2.0.1" + unused-filename "^2.1.0" electron-download@^4.1.1: version "4.1.1" @@ -5962,13 +6255,6 @@ electron-download@^4.1.1: semver "^5.4.1" sumchecker "^2.0.2" -electron-fetch@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/electron-fetch/-/electron-fetch-1.4.0.tgz#a830d400f8ad358acba9b3c591e6ed477916bac5" - integrity sha512-rednYIpMbuzekTroNndQOFl95c4I/wMEbH9jxGoDEoKrM07b7FWydy6I3pbiAbCxDcYpmHtzMY6ykyLagR7JHw== - dependencies: - encoding "^0.1.12" - electron-is-accelerator@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/electron-is-accelerator/-/electron-is-accelerator-0.1.2.tgz#509e510c26a56b55e17f863a4b04e111846ab27b" @@ -5992,7 +6278,7 @@ electron-is@^3.0.0: electron-is-dev "^0.3.0" semver "^5.5.0" -electron-localshortcut@^3.1.0: +electron-localshortcut@^3.1.0, electron-localshortcut@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/electron-localshortcut/-/electron-localshortcut-3.2.1.tgz#cfc83a3eff5e28faf98ddcc87f80a2ce4f623cd3" integrity sha512-DWvhKv36GsdXKnaFFhEiK8kZZA+24/yFLgtTwJJHc7AFgDjNRIBJZ/jq62Y/dWv9E4ypYwrVWN2bVrCYw1uv7Q== @@ -6002,13 +6288,13 @@ electron-localshortcut@^3.1.0: keyboardevent-from-electron-accelerator "^2.0.0" keyboardevents-areequal "^0.2.1" -electron-media-service@auryo/electron-media-service: - version "0.2.3" - resolved "https://codeload.github.com/auryo/electron-media-service/tar.gz/8e5af9cfcd81b13bdfa92454657191bd11134a28" +electron-media-service@tidal-engineering/electron-media-service: + version "0.2.5" + resolved "https://codeload.github.com/tidal-engineering/electron-media-service/tar.gz/165205dfe85dde37099bd63beeade617c7a7218b" dependencies: bindings "^1.5.0" - node-addon-api "^1.7.1" - semver "^5.3.0" + node-addon-api "^3.0.2" + semver "^7.3.2" electron-notarize@^0.2.1: version "0.2.1" @@ -6045,41 +6331,51 @@ electron-publish@22.3.3: lazy-val "^1.0.4" mime "^2.4.4" -electron-redux@^1.5.2: - version "1.5.2" - resolved "https://registry.yarnpkg.com/electron-redux/-/electron-redux-1.5.2.tgz#a415d4873369957640ed0536fc0641c869ba64f5" - integrity sha512-LOX+CdPkkJTUU+JBaVexH6QOmENkRtuhnZjLhpsvm/LjNaMZpfuWRsHGyxxZcIMTV2CIQLH0MXqho6dcKOjc8A== +electron-publish@22.9.1: + version "22.9.1" + resolved "https://registry.yarnpkg.com/electron-publish/-/electron-publish-22.9.1.tgz#7cc76ac4cc53efd29ee31c1e5facb9724329068e" + integrity sha512-ducLjRJLEeU87FaTCWaUyDjCoLXHkawkltP2zqS/n2PyGke54ZIql0tBuUheht4EpR8AhFbVJ11spSn1gy8r6w== dependencies: - debug "^3.0.0" - flux-standard-action "^2.0.0" - redux "^4.0.1" + "@types/fs-extra" "^9.0.1" + bluebird-lst "^1.0.9" + builder-util "22.9.1" + builder-util-runtime "8.7.2" + chalk "^4.1.0" + fs-extra "^9.0.1" + lazy-val "^1.0.4" + mime "^2.4.6" -electron-store@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/electron-store/-/electron-store-5.1.0.tgz#0b3cb66b15d0002678fc5c13e8b0c38a8678d670" - integrity sha512-uhAF/4+zDb+y0hWqlBirEPEAR4ciCZDp4fRWGFNV62bG+ArdQPpXk7jS0MEVj3CfcG5V7hx7Dpq5oD+1j6GD8Q== +"electron-redux@file:../related/electron-redux": + version "0.0.0-dev.0" dependencies: - conf "^6.2.0" - type-fest "^0.7.1" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + +electron-store@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/electron-store/-/electron-store-6.0.1.tgz#2178b9dc37aeb749d99cf9d1d1bc090890b922dc" + integrity sha512-8rdM0XEmDGsLuZM2oRABzsLX+XmD5x3rwxPMEPv0MrN9/BWanyy3ilb2v+tCrKtIZVF3MxUiZ9Bfqe8e0popKQ== + dependencies: + conf "^7.1.2" + type-fest "^0.16.0" electron-to-chromium@^1.3.349: version "1.3.354" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.354.tgz#6c6ad9ef63654c4c022269517c5a3095cebc94db" integrity sha512-24YMkNiZWOUeF6YeoscWfIGP0oMx+lJpU/miwI+lcu7plIDpyZn8Gx0lx0qTDlzGoz7hx+lpyD8QkbkX5L2Pqw== -electron-updater@^4.2.0: - version "4.2.2" - resolved "https://registry.yarnpkg.com/electron-updater/-/electron-updater-4.2.2.tgz#57e106bffad16f71b1ffa3968a52a1b71c8147e6" - integrity sha512-e/OZhr5tLW0GcgmpR5wD0ImxgKMa8pPoNWRcwRyMzTL9pGej7+ORp0t9DtI5ZBHUbObIoEbrk+6EDGUGtJf+aA== +electron-updater@^4.3.5: + version "4.3.5" + resolved "https://registry.yarnpkg.com/electron-updater/-/electron-updater-4.3.5.tgz#4fb36f593a031c87ea07ee141c9f064d5deffb15" + integrity sha512-5jjN7ebvfj1cLI0VZMdCnJk6aC4bP+dy7ryBf21vArR0JzpRVk0OZHA2QBD+H5rm6ZSeDYHOY6+8PrMEqJ4wlQ== dependencies: - "@types/semver" "^7.1.0" - builder-util-runtime "8.6.0" - fs-extra "^8.1.0" - js-yaml "^3.13.1" + "@types/semver" "^7.3.1" + builder-util-runtime "8.7.2" + fs-extra "^9.0.1" + js-yaml "^3.14.0" lazy-val "^1.0.4" lodash.isequal "^4.5.0" - pako "^1.0.11" - semver "^7.1.3" + semver "^7.3.2" electron-window-state@^5.0.3: version "5.0.3" @@ -6098,19 +6394,19 @@ electron@*: "@types/node" "^12.0.12" extract-zip "^1.0.3" -electron@^8.3.0: - version "8.3.0" - resolved "https://registry.yarnpkg.com/electron/-/electron-8.3.0.tgz#c2b565a4c10d6d287d20164bcd5a478468b940a9" - integrity sha512-XRjiIJICZCgUr2vKSUI2PTkfP0gPFqCtqJUaTJSfCTuE3nTrxBKOUNeRMuCzEqspKkpFQU3SB3MdbMSHmZARlQ== +electron@^11.0.3: + version "11.0.3" + resolved "https://registry.yarnpkg.com/electron/-/electron-11.0.3.tgz#c29eaacda38ce561890e59906ca5f507c72b3ec4" + integrity sha512-nNfbLi7Q1xfJXOEO2adck5TS6asY4Jxc332E4Te8XfQ9hcaC3GiCdeEqk9FndNCwxhJA5Lr9jfSGRTwWebFa/w== dependencies: "@electron/get" "^1.0.1" "@types/node" "^12.0.12" extract-zip "^1.0.3" elliptic@^6.0.0: - version "6.5.2" - resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.2.tgz#05c5678d7173c049d8ca433552224a495d0e3762" - integrity sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw== + version "6.5.3" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6" + integrity sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw== dependencies: bn.js "^4.4.0" brorand "^1.0.1" @@ -6145,14 +6441,14 @@ encodeurl@^1.0.2, encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= -encoding@^0.1.11, encoding@^0.1.12: +encoding@^0.1.11: version "0.1.12" resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" integrity sha1-U4tm8+5izRq1HsMjgp0flIDHS+s= dependencies: iconv-lite "~0.4.13" -end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1: +end-of-stream@^1.0.0, end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== @@ -6322,6 +6618,24 @@ es-abstract@^1.13.0, es-abstract@^1.17.0, es-abstract@^1.17.0-next.1, es-abstrac string.prototype.trimleft "^2.1.1" string.prototype.trimright "^2.1.1" +es-abstract@^1.18.0-next.1: + version "1.18.0-next.1" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.0-next.1.tgz#6e3a0a4bda717e5023ab3b8e90bec36108d22c68" + integrity sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA== + dependencies: + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + is-callable "^1.2.2" + is-negative-zero "^2.0.0" + is-regex "^1.1.1" + object-inspect "^1.8.0" + object-keys "^1.1.1" + object.assign "^4.1.1" + string.prototype.trimend "^1.0.1" + string.prototype.trimstart "^1.0.1" + es-to-primitive@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" @@ -6331,67 +6645,15 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -es5-ext@^0.10.35, es5-ext@^0.10.50, es5-ext@~0.10.14: - version "0.10.53" - resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.53.tgz#93c5a3acfdbef275220ad72644ad02ee18368de1" - integrity sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q== - dependencies: - es6-iterator "~2.0.3" - es6-symbol "~3.1.3" - next-tick "~1.0.0" - es6-error@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== -es6-iterator@~2.0.1, es6-iterator@~2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7" - integrity sha1-p96IkUGgWpSwhUQDstCg+/qY87c= - dependencies: - d "1" - es5-ext "^0.10.35" - es6-symbol "^3.1.1" - -es6-map@^0.1.5: - version "0.1.5" - resolved "https://registry.yarnpkg.com/es6-map/-/es6-map-0.1.5.tgz#9136e0503dcc06a301690f0bb14ff4e364e949f0" - integrity sha1-kTbgUD3MBqMBaQ8LsU/042TpSfA= - dependencies: - d "1" - es5-ext "~0.10.14" - es6-iterator "~2.0.1" - es6-set "~0.1.5" - es6-symbol "~3.1.1" - event-emitter "~0.3.5" - -es6-set@~0.1.5: - version "0.1.5" - resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1" - integrity sha1-0rPsXU2ADO2BjbU40ol02wpzzLE= - dependencies: - d "1" - es5-ext "~0.10.14" - es6-iterator "~2.0.1" - es6-symbol "3.1.1" - event-emitter "~0.3.5" - -es6-symbol@3.1.1: +escalade@^3.1.1: version "3.1.1" - resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.1.tgz#bf00ef4fdab6ba1b46ecb7b629b4c7ed5715cc77" - integrity sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc= - dependencies: - d "1" - es5-ext "~0.10.14" - -es6-symbol@^3.1.1, es6-symbol@~3.1.1, es6-symbol@~3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.3.tgz#bad5d3c1bcdac28269f4cb331e431c78ac705d18" - integrity sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA== - dependencies: - d "^1.0.1" - ext "^1.1.2" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== escape-goat@^2.0.0: version "2.1.1" @@ -6408,10 +6670,10 @@ escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1 resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= -escape-string-regexp@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" - integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== escodegen@^1.11.1: version "1.14.1" @@ -6596,6 +6858,23 @@ eslint-plugin-react-hooks@^4.0.2: resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.0.2.tgz#03700ca761eacc1b6436074c456f90a8e331ff28" integrity sha512-kAMRjNztrLW1rK+81X1NwMB2LqG+nc7Q8AibnG8/VyWhQK8SP6JotCFG+HL4u1EjziplxVz4jARdR8gGk8pLDA== +eslint-plugin-react@^7.19.0: + version "7.22.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.22.0.tgz#3d1c542d1d3169c45421c1215d9470e341707269" + integrity sha512-p30tuX3VS+NWv9nQot9xIGAHBXR0+xJVaZriEsHoJrASGCJZDJ8JLNM0YqKqI0AKm6Uxaa1VUHoNEibxRCMQHA== + dependencies: + array-includes "^3.1.1" + array.prototype.flatmap "^1.2.3" + doctrine "^2.1.0" + has "^1.0.3" + jsx-ast-utils "^2.4.1 || ^3.0.0" + object.entries "^1.1.2" + object.fromentries "^2.0.2" + object.values "^1.1.1" + prop-types "^15.7.2" + resolve "^1.18.1" + string.prototype.matchall "^4.0.2" + eslint-plugin-react@^7.20.0: version "7.20.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.20.0.tgz#f98712f0a5e57dfd3e5542ef0604b8739cd47be3" @@ -6634,13 +6913,6 @@ eslint-scope@^5.0.0: esrecurse "^4.1.0" estraverse "^4.1.1" -eslint-utils@^1.4.3: - version "1.4.3" - resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.3.tgz#74fec7c54d0776b6f67e0251040b5806564e981f" - integrity sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q== - dependencies: - eslint-visitor-keys "^1.1.0" - eslint-utils@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.0.0.tgz#7be1cc70f27a72a76cd14aa698bcabed6890e1cd" @@ -6653,6 +6925,11 @@ eslint-visitor-keys@^1.1.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2" integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A== +eslint-visitor-keys@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz#21fdc8fbcd9c795cc0321f0563702095751511a8" + integrity sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ== + eslint@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.0.0.tgz#c35dfd04a4372110bd78c69a8d79864273919a08" @@ -6743,14 +7020,6 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= -event-emitter@~0.3.5: - version "0.3.5" - resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" - integrity sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk= - dependencies: - d "1" - es5-ext "~0.10.14" - event-stream@3.3.4: version "3.3.4" resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-3.3.4.tgz#4ab4c9a0f5a54db9338b4c34d86bfce8f4b35571" @@ -6764,15 +7033,15 @@ event-stream@3.3.4: stream-combiner "~0.0.4" through "~2.3.1" -eventemitter3@^3.0.0: - version "3.1.2" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7" - integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q== +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== eventemitter3@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb" - integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg== + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== events@^1.0.0: version "1.1.1" @@ -6856,8 +7125,8 @@ execa@^1.0.0: execa@^3.2.0: version "3.4.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-3.4.0.tgz#c08ed4550ef65d858fac269ffc8572446f37eb89" - integrity sha512-r9vdGQk4bmCuK1yKQu1KTwcT2zwfWdbdaXfCtAh+5nU/4fSX+JAb7vZGvI5naJrQlvONrEB20jeruESI69530g== + resolved "https://registry.npmjs.org/execa/-/execa-3.4.0.tgz" + integrity "sha1-wI7UVQ72XYWPrCaf/IVyRG8364k= sha512-r9vdGQk4bmCuK1yKQu1KTwcT2zwfWdbdaXfCtAh+5nU/4fSX+JAb7vZGvI5naJrQlvONrEB20jeruESI69530g==" dependencies: cross-spawn "^7.0.0" get-stream "^5.0.0" @@ -6921,17 +7190,17 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" -expect@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/expect/-/expect-25.1.0.tgz#7e8d7b06a53f7d66ec927278db3304254ee683ee" - integrity sha512-wqHzuoapQkhc3OKPlrpetsfueuEiMf3iWh0R8+duCu9PIjXoP7HgD5aeypwTnXUAjC8aMsiVDaWwlbJ1RlQ38g== +expect@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-25.5.0.tgz#f07f848712a2813bb59167da3fb828ca21f58bba" + integrity sha512-w7KAXo0+6qqZZhovCaBVPSIqQp7/UTcx4M9uKt2m6pd2VB1voyC8JizLRqeEqud3AAVP02g+hbErDu5gu64tlA== dependencies: - "@jest/types" "^25.1.0" + "@jest/types" "^25.5.0" ansi-styles "^4.0.0" - jest-get-type "^25.1.0" - jest-matcher-utils "^25.1.0" - jest-message-util "^25.1.0" - jest-regex-util "^25.1.0" + jest-get-type "^25.2.6" + jest-matcher-utils "^25.5.0" + jest-message-util "^25.5.0" + jest-regex-util "^25.2.6" express@^4.16.3, express@^4.17.1: version "4.17.1" @@ -6984,13 +7253,6 @@ ext-name@^5.0.0: ext-list "^2.0.0" sort-keys-length "^1.0.0" -ext@^1.1.2: - version "1.4.0" - resolved "https://registry.yarnpkg.com/ext/-/ext-1.4.0.tgz#89ae7a07158f79d35517882904324077e4379244" - integrity sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A== - dependencies: - type "^2.0.0" - extend-shallow@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" @@ -7051,14 +7313,14 @@ extglob@^2.0.4: to-regex "^3.0.1" extract-zip@^1.0.3, extract-zip@^1.6.7: - version "1.6.7" - resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.6.7.tgz#a840b4b8af6403264c8db57f4f1a74333ef81fe9" - integrity sha1-qEC0uK9kAyZMjbV/Txp0Mz74H+k= + version "1.7.0" + resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.7.0.tgz#556cc3ae9df7f452c493a0cfb51cc30277940927" + integrity "sha1-VWzDrp339FLEk6DPtRzDAneUCSc= sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==" dependencies: - concat-stream "1.6.2" - debug "2.6.9" - mkdirp "0.5.1" - yauzl "2.4.1" + concat-stream "^1.6.2" + debug "^2.6.9" + mkdirp "^0.5.4" + yauzl "^2.10.0" extsprintf@1.3.0: version "1.3.0" @@ -7091,6 +7353,18 @@ fast-glob@^3.0.3: merge2 "^1.3.0" micromatch "^4.0.2" +fast-glob@^3.1.1: + version "3.2.4" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.4.tgz#d20aefbf99579383e7f3cc66529158c9b98554d3" + integrity sha512-kr/Oo6PX51265qeuCYsyGypiO5uJFgBS0jksyG7FUeCyQzNwYnzrNIMR1NXfkZXsMYXYLRAHgISHBz8gQcxKHQ== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.0" + merge2 "^1.3.0" + micromatch "^4.0.2" + picomatch "^2.2.1" + fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" @@ -7167,16 +7441,9 @@ fbjs@^0.8.9: setimmediate "^1.0.5" ua-parser-js "^0.7.18" -fd-slicer@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.0.1.tgz#8b5bcbd9ec327c5041bf9ab023fd6750f1177e65" - integrity sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU= - dependencies: - pend "~1.2.0" - fd-slicer@~1.1.0: version "1.1.0" - resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" + resolved "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4= dependencies: pend "~1.2.0" @@ -7263,6 +7530,13 @@ file-uri-to-path@1.0.0: resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== +filelist@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.1.tgz#f10d1a3ae86c1694808e8f20906f43d4c9132dbb" + integrity sha512-8zSK6Nu0DQIC08mUC46sWGXi+q3GGpKydAG36k+JDba6VRpkevvOWUW5a/PhShij4+vHT9M+ghgG7eM+a9JDUQ== + dependencies: + minimatch "^3.0.4" + filename-regex@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26" @@ -7387,8 +7661,8 @@ find-up@^3.0.0: find-up@^4.0.0, find-up@^4.1.0: version "4.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" - integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + resolved "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz" + integrity "sha1-l6/n1s3AvFkoWEt8jXsW6KmqXRk= sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==" dependencies: locate-path "^5.0.0" path-exists "^4.0.0" @@ -7400,6 +7674,14 @@ find-versions@^3.0.0: dependencies: semver-regex "^2.0.0" +find-yarn-workspace-root@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/find-yarn-workspace-root/-/find-yarn-workspace-root-1.2.1.tgz#40eb8e6e7c2502ddfaa2577c176f221422f860db" + integrity sha512-dVtfb0WuQG+8Ag2uWkbG79hOUzEsRrhBzgfn86g2sJPkzmcpGdghbNTfUKGTxymFrY/tLIodDzLoW9nOJ4FY8Q== + dependencies: + fs-extra "^4.0.3" + micromatch "^3.1.4" + findup-sync@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-3.0.0.tgz#17b108f9ee512dfb7a5c7f3c8b27ea9e1a9c08d1" @@ -7429,6 +7711,11 @@ flatted@^2.0.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.1.tgz#69e57caa8f0eacbc281d2e2cb458d46fdb449e08" integrity sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg== +flow-bin@^0.120.1: + version "0.120.1" + resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.120.1.tgz#ab051d6df71829b70a26a2c90bb81f9d43797cae" + integrity sha512-KgE+d+rKzdXzhweYVJty1QIOOZTTbtnXZf+4SLnmArLvmdfeLreQOZpeLbtq5h79m7HhDzX/HkUkoyu/fmSC2A== + flush-write-stream@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8" @@ -7437,13 +7724,6 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" -flux-standard-action@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/flux-standard-action/-/flux-standard-action-2.1.1.tgz#b2e204145ea8e4294d6b0f178d8e8c416802948f" - integrity sha512-W86GzmXmIiTVq/dpYVd2HtTIUX9c35Iq3ao3xR6qcKtuXgbu+BDEj72op5VnEIe/kpuSbhl+I8kT1iS2hpcusw== - dependencies: - lodash "^4.17.15" - follow-redirects@1.5.10: version "1.5.10" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a" @@ -7452,11 +7732,9 @@ follow-redirects@1.5.10: debug "=3.1.0" follow-redirects@^1.0.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.10.0.tgz#01f5263aee921c6a54fb91667f08f4155ce169eb" - integrity sha512-4eyLK6s6lH32nOvLLwlIOnr9zrL8Sm+OvW4pVTJNoXeGzYIkHVf+pADQi+OJ0E67hiuSLezPVPyBcIZO50TmmQ== - dependencies: - debug "^3.0.0" + version "1.13.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db" + integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA== for-in@^1.0.1, for-in@^1.0.2: version "1.0.2" @@ -7488,13 +7766,13 @@ fork-ts-checker-webpack-plugin@^4.0.3: tapable "^1.0.0" worker-rpc "^0.1.0" -form-data@2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" - integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA== +form-data@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682" + integrity sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg== dependencies: asynckit "^0.4.0" - combined-stream "^1.0.6" + combined-stream "^1.0.8" mime-types "^2.1.12" form-data@~2.3.2: @@ -7541,12 +7819,7 @@ fs-constants@^1.0.0: resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== -fs-copy-file-sync@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/fs-copy-file-sync/-/fs-copy-file-sync-1.1.1.tgz#11bf32c096c10d126e5f6b36d06eece776062918" - integrity sha512-2QY5eeqVv4m2PfyMiEuy9adxNP+ajf+8AR05cEi+OAzPcOj90hvFImeZhTmKLBgSd9EvG33jsD7ZRxsx9dThkQ== - -fs-extra@^4.0.1: +fs-extra@^4.0.1, fs-extra@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.3.tgz#0d852122e5bc5beb453fb028e9c0c9bf36340c94" integrity sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg== @@ -7555,6 +7828,15 @@ fs-extra@^4.0.1: jsonfile "^4.0.0" universalify "^0.1.0" +fs-extra@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" + integrity sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw== + dependencies: + graceful-fs "^4.1.2" + jsonfile "^4.0.0" + universalify "^0.1.0" + fs-extra@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" @@ -7564,6 +7846,16 @@ fs-extra@^8.1.0: jsonfile "^4.0.0" universalify "^0.1.0" +fs-extra@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.0.1.tgz#910da0062437ba4c39fedd863f1675ccfefcb9fc" + integrity sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^1.0.0" + fs-minipass@^1.2.5: version "1.2.7" resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7" @@ -7594,9 +7886,9 @@ fs.realpath@^1.0.0: integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= fsevents@^1.0.0, fsevents@^1.2.7: - version "1.2.11" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.11.tgz#67bf57f4758f02ede88fb2a1712fef4d15358be3" - integrity sha512-+ux3lx6peh0BpvY0JebGyZoiR4D+oYzdPZMKJwkZ+sFkNJzpL7tXc/wehS49gUAxg3tmMHPHZkA8JU2rhhgDHw== + version "1.2.13" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38" + integrity sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw== dependencies: bindings "^1.5.0" nan "^2.12.1" @@ -7678,7 +7970,7 @@ get-caller-file@^1.0.1: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w== -get-caller-file@^2.0.1: +get-caller-file@^2.0.1, get-caller-file@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== @@ -7688,6 +7980,15 @@ get-func-name@^2.0.0: resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41" integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE= +get-intrinsic@^1.0.0, get-intrinsic@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.0.2.tgz#6820da226e50b24894e08859469dc68361545d49" + integrity sha512-aeX0vrFm21ILl3+JpFFRNe9aUvp6VFZb2/CTbgLb8j75kOhvoNYjt9d8KA/tJG4gSo8nzEDedRl0h7vDmBYRVg== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + get-proxy@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/get-proxy/-/get-proxy-2.1.0.tgz#349f2b4d91d44c4d4d4e9cba2ad90143fac5ef93" @@ -7784,22 +8085,6 @@ glob-parent@^5.0.0, glob-parent@^5.1.0, glob-parent@~5.1.0: dependencies: is-glob "^4.0.1" -glob-stream@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/glob-stream/-/glob-stream-6.1.0.tgz#7045c99413b3eb94888d83ab46d0b404cc7bdde4" - integrity sha1-cEXJlBOz65SIjYOrRtC0BMx73eQ= - dependencies: - extend "^3.0.0" - glob "^7.1.1" - glob-parent "^3.1.0" - is-negated-glob "^1.0.0" - ordered-read-streams "^1.0.0" - pumpify "^1.3.5" - readable-stream "^2.1.5" - remove-trailing-separator "^1.0.1" - to-absolute-glob "^2.0.0" - unique-stream "^2.0.2" - glob@7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" @@ -7825,17 +8110,17 @@ glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, gl path-is-absolute "^1.0.0" global-agent@^2.0.2: - version "2.1.8" - resolved "https://registry.yarnpkg.com/global-agent/-/global-agent-2.1.8.tgz#99d153662b2c04cbc1199ffbc081a3aa656ac50f" - integrity sha512-VpBe/rhY6Rw2VDOTszAMNambg+4Qv8j0yiTNDYEXXXxkUNGWLHp8A3ztK4YDBbFNcWF4rgsec6/5gPyryya/+A== + version "2.1.12" + resolved "https://registry.yarnpkg.com/global-agent/-/global-agent-2.1.12.tgz#e4ae3812b731a9e81cbf825f9377ef450a8e4195" + integrity "sha1-5K44Ercxqegcv4Jfk3fvRQqOQZU= sha512-caAljRMS/qcDo69X9BfkgrihGUgGx44Fb4QQToNQjsiWh+YlQ66uqYVAdA8Olqit+5Ng0nkz09je3ZzANMZcjg==" dependencies: - boolean "^3.0.0" - core-js "^3.6.4" + boolean "^3.0.1" + core-js "^3.6.5" es6-error "^4.1.1" - matcher "^2.1.0" - roarr "^2.15.2" - semver "^7.1.2" - serialize-error "^5.0.0" + matcher "^3.0.0" + roarr "^2.15.3" + semver "^7.3.2" + serialize-error "^7.0.1" global-dirs@^2.0.1: version "2.0.1" @@ -7941,6 +8226,18 @@ globby@^10.0.0, globby@^10.0.1: merge2 "^1.2.3" slash "^3.0.0" +globby@^11.0.1: + version "11.0.1" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.1.tgz#9a2bf107a068f3ffeabc49ad702c79ede8cfd357" + integrity sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.1.1" + ignore "^5.1.4" + merge2 "^1.3.0" + slash "^3.0.0" + globby@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c" @@ -8043,15 +8340,15 @@ graceful-fs@4.1.4: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.4.tgz#ef089d2880f033b011823ce5c8fae798da775dbd" integrity sha1-7widKIDwM7ARgjzlyPrnmNp3Xb0= -graceful-fs@^4.1.0, graceful-fs@^4.1.10, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.3: +graceful-fs@^4.1.0, graceful-fs@^4.1.10, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2: version "4.2.3" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423" integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ== -"graceful-readlink@>= 1.0.0": - version "1.0.1" - resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" - integrity sha1-TK+tdrxi8C+gObL5Tpo906ORpyU= +graceful-fs@^4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" + integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== grapheme-splitter@^1.0.2: version "1.0.4" @@ -8222,16 +8519,6 @@ he@1.2.x: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== -help-me@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/help-me/-/help-me-1.1.0.tgz#8f2d508d0600b4a456da2f086556e7e5c056a3c6" - integrity sha1-jy1QjQYAtKRW2i8IZVbn5cBWo8Y= - dependencies: - callback-stream "^1.0.2" - glob-stream "^6.1.0" - through2 "^2.0.1" - xtend "^4.0.0" - hex-color-regex@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" @@ -8242,11 +8529,6 @@ hexy@^0.2.10: resolved "https://registry.yarnpkg.com/hexy/-/hexy-0.2.11.tgz#9939c25cb6f86a91302f22b8a8a72573518e25b4" integrity sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A== -highlight.js@^9.3.0: - version "9.18.1" - resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.18.1.tgz#ed21aa001fe6252bb10a3d76d47573c6539fe13c" - integrity sha512-OrVKYz70LHsnCgmbXctv/bfuvntIKDz177h0Co37DQ5jamGZLVmoCVMtjMtNZY3X9DrCcKfklHPNeA0uPZhSJg== - history@^4.10.1, history@^4.9.0: version "4.10.1" resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" @@ -8273,7 +8555,7 @@ hoist-non-react-statics@^1.2.0: resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz#aa448cf0986d55cc40773b17174b7dd066cb7cfb" integrity sha1-qkSM8JhtVcxAdzsXF0t90GbLfPs= -hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0: +hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -8312,6 +8594,13 @@ hosted-git-info@^3.0.2: dependencies: lru-cache "^5.1.1" +hosted-git-info@^3.0.5: + version "3.0.7" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-3.0.7.tgz#a30727385ea85acfcee94e0aad9e368c792e036c" + integrity sha512-fWqc0IcuXs+BmE9orLDyVykAG9GJtGLGuZAAqgcckPgv5xad4AcXGIv8galtQvlwutxSlaMcdw7BUtq2EIvqCQ== + dependencies: + lru-cache "^6.0.0" + howler@^2.1.1: version "2.1.3" resolved "https://registry.yarnpkg.com/howler/-/howler-2.1.3.tgz#07c88618f8767e879407a4d647fe2d6d5f15f121" @@ -8337,7 +8626,7 @@ hsla-regex@^1.0.0: resolved "https://registry.yarnpkg.com/hsla-regex/-/hsla-regex-1.0.0.tgz#c1ce7a3168c8c6614033a4b5f7877f3b225f9c38" integrity sha1-wc56MWjIxmFAM6S194d/OyJfnDg= -html-comment-regex@^1.1.0: +html-comment-regex@^1.1.0, html-comment-regex@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.2.tgz#97d4688aeb5c81886a364faa0cad1dda14d433a7" integrity sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ== @@ -8497,9 +8786,9 @@ http-proxy-middleware@0.19.1: micromatch "^3.1.10" http-proxy@^1.17.0: - version "1.18.0" - resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a" - integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ== + version "1.18.1" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" + integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== dependencies: eventemitter3 "^4.0.0" follow-redirects "^1.0.0" @@ -8524,12 +8813,12 @@ https-browserify@^1.0.0: resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= -https-proxy-agent@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-4.0.0.tgz#702b71fb5520a132a66de1f67541d9e62154d82b" - integrity sha512-zoDhWrkR3of1l9QAL8/scJZyLu8j/gBkcwcaQOZh7Gyh/+uJQzGVETdgT30akuwkpL8HTRfssqI3BZuV18teDg== +https-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" + integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA== dependencies: - agent-base "5" + agent-base "6" debug "4" human-signals@^1.1.1: @@ -8537,11 +8826,6 @@ human-signals@^1.1.1: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== -humanize-plus@^1.8.1: - version "1.8.2" - resolved "https://registry.yarnpkg.com/humanize-plus/-/humanize-plus-1.8.2.tgz#a65b34459ad6367adbb3707a82a3c9f916167030" - integrity sha1-pls0RZrWNnrbs3B6gqPJ+RYWcDA= - husky@^1.1.3: version "1.3.1" resolved "https://registry.yarnpkg.com/husky/-/husky-1.3.1.tgz#26823e399300388ca2afff11cfa8a86b0033fae0" @@ -8577,6 +8861,13 @@ iconv-lite@^0.5.1: dependencies: safer-buffer ">= 2.1.2 < 3" +iconv-lite@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.2.tgz#ce13d1875b0c3a674bd6a04b7f76b01b1b6ded01" + integrity sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + icss-replace-symbols@1.1.0, icss-replace-symbols@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" @@ -8621,6 +8912,11 @@ ignore@^5.1.1: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.4.tgz#84b7b3dbe64552b6ef0eca99f6743dbec6d97adf" integrity sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A== +ignore@^5.1.4: + version "5.1.8" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" + integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== + image-webpack-loader@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/image-webpack-loader/-/image-webpack-loader-6.0.0.tgz#c60ed8a1a2dc626d93cbc50f087668a3f2cb2d02" @@ -8676,12 +8972,12 @@ imagemin-pngquant@^8.0.0: pngquant-bin "^5.0.0" imagemin-svgo@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/imagemin-svgo/-/imagemin-svgo-7.0.0.tgz#a22d0a5917a0d0f37e436932c30f5e000fa91b1c" - integrity sha512-+iGJFaPIMx8TjFW6zN+EkOhlqcemdL7F3N3Y0wODvV2kCUBuUtZK7DRZc1+Zfu4U2W/lTMUyx2G8YMOrZntIWg== + version "7.1.0" + resolved "https://registry.yarnpkg.com/imagemin-svgo/-/imagemin-svgo-7.1.0.tgz#528a42fd3d55eff5d4af8fd1113f25fb61ad6d9a" + integrity "sha1-UopC/T1V7/XUr4/RET8l+2GtbZo= sha512-0JlIZNWP0Luasn1HT82uB9nU9aa+vUj6kpT+MjPW11LbprXC+iC4HDwn1r4Q2/91qj4iy9tRZNsFySMlEpLdpg==" dependencies: - is-svg "^3.0.0" - svgo "^1.0.5" + is-svg "^4.2.1" + svgo "^1.3.2" imagemin-webp@^5.1.0: version "5.1.0" @@ -8705,6 +9001,11 @@ imagemin@^7.0.0: p-pipe "^3.0.0" replace-ext "^1.0.0" +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= + import-cwd@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" @@ -8755,8 +9056,8 @@ import-local@2.0.0, import-local@^2.0.0: import-local@^3.0.2: version "3.0.2" - resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.0.2.tgz#a8cfd0431d1de4a2199703d003e3e62364fa6db6" - integrity sha512-vjL3+w0oulAVZ0hBHnxa/Nm5TAurf9YLQJDhqRZyqb+VKGOB6LU8t9H1Nr5CIo16vh9XfJTOoHwU0B71S557gA== + resolved "https://registry.npmjs.org/import-local/-/import-local-3.0.2.tgz" + integrity "sha1-qM/QQx0d5KIZlwPQA+PmI2T6bbY= sha512-vjL3+w0oulAVZ0hBHnxa/Nm5TAurf9YLQJDhqRZyqb+VKGOB6LU8t9H1Nr5CIo16vh9XfJTOoHwU0B71S557gA==" dependencies: pkg-dir "^4.2.0" resolve-cwd "^3.0.0" @@ -8962,14 +9263,6 @@ is-absolute-url@^3.0.3: resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-3.0.3.tgz#96c6a22b6a23929b11ea0afb1836c36ad4a5d698" integrity sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q== -is-absolute@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-absolute/-/is-absolute-1.0.0.tgz#395e1ae84b11f26ad1795e73c17378e48a301576" - integrity sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA== - dependencies: - is-relative "^1.0.0" - is-windows "^1.0.1" - is-accessor-descriptor@^0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" @@ -9041,6 +9334,11 @@ is-callable@^1.1.4, is-callable@^1.1.5: resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab" integrity sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q== +is-callable@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9" + integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA== + is-ci@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" @@ -9060,6 +9358,13 @@ is-color-stop@^1.0.0: rgb-regex "^1.0.1" rgba-regex "^1.0.0" +is-core-module@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.2.0.tgz#97037ef3d52224d85163f5597b2b63d9afed981a" + integrity "sha1-lwN+89UiJNhRY/VZeytj2a/tmBo= sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==" + dependencies: + has "^1.0.3" + is-cwebp-readable@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/is-cwebp-readable/-/is-cwebp-readable-2.0.1.tgz#afb93b0c0abd0a25101016ae33aea8aedf926d26" @@ -9226,10 +9531,10 @@ is-natural-number@^4.0.1: resolved "https://registry.yarnpkg.com/is-natural-number/-/is-natural-number-4.0.1.tgz#ab9d76e1db4ced51e35de0c72ebecf09f734cde8" integrity sha1-q5124dtM7VHjXeDHLr7PCfc0zeg= -is-negated-glob@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-negated-glob/-/is-negated-glob-1.0.0.tgz#6910bca5da8c95e784b5751b976cf5a10fee36d2" - integrity sha1-aRC8pdqMleeEtXUbl2z1oQ/uNtI= +is-negative-zero@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24" + integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w== is-npm@^4.0.0: version "4.0.0" @@ -9345,12 +9650,12 @@ is-regex@^1.0.4, is-regex@^1.0.5: dependencies: has "^1.0.3" -is-relative@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-relative/-/is-relative-1.0.0.tgz#a1bb6935ce8c5dba1e8b9754b9b2dcc020e2260d" - integrity sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA== +is-regex@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9" + integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg== dependencies: - is-unc-path "^1.0.0" + has-symbols "^1.0.1" is-resolvable@^1.0.0: version "1.1.0" @@ -9369,8 +9674,8 @@ is-stream@^1.0.0, is-stream@^1.0.1, is-stream@^1.1.0: is-stream@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3" - integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw== + resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz" + integrity "sha1-venDJoDW+uBBKdasnZIc54FfeOM= sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==" is-string@^1.0.5: version "1.0.5" @@ -9389,6 +9694,13 @@ is-svg@^3.0.0: dependencies: html-comment-regex "^1.1.0" +is-svg@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/is-svg/-/is-svg-4.2.1.tgz#095b496e345fec9211c2a7d5d021003e040d6f81" + integrity "sha1-CVtJbjRf7JIRwqfV0CEAPgQNb4E= sha512-PHx3ANecKsKNl5y5+Jvt53Y4J7MfMpbNZkv384QNiswMKAWIbvcqbPz+sYbFKJI8Xv3be01GSFniPmoaP+Ai5A==" + dependencies: + html-comment-regex "^1.1.2" + is-symbol@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937" @@ -9401,13 +9713,6 @@ is-typedarray@^1.0.0, is-typedarray@~1.0.0: resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= -is-unc-path@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-unc-path/-/is-unc-path-1.0.0.tgz#d731e8898ed090a12c352ad2eaed5095ad322c9d" - integrity sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ== - dependencies: - unc-path-regex "^0.1.2" - is-utf8@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" @@ -9458,6 +9763,11 @@ isbinaryfile@^4.0.4: resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.4.tgz#6803f81a8944201c642b6e17da041e24deb78712" integrity sha512-pEutbN134CzcjlLS1myKX/uxNjwU5eBVSprvkpv3+3dqhBHUZLIWJQowC40w5c0Zf19vBY8mrZl88y5J4RAPbQ== +isbinaryfile@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.6.tgz#edcb62b224e2b4710830b67498c8e4e5a4d2610b" + integrity sha512-ORrEy+SNVqUhrCaal4hA4fBzhggQQ+BaLntyPOdoEiwlKZW9BZiJXjg3RMiruE4tPEI3pyVPpySHQF/dKWperg== + isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" @@ -9547,10 +9857,10 @@ istanbul-lib-source-maps@^4.0.0: istanbul-lib-coverage "^3.0.0" source-map "^0.6.1" -istanbul-reports@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.0.0.tgz#d4d16d035db99581b6194e119bbf36c963c5eb70" - integrity sha512-2osTcC8zcOSUkImzN2EWQta3Vdi4WjjKw99P2yWx5mLnigAM0Rd5uYFn1cf2i/Ois45GkNjaoTqc5CxgMSX80A== +istanbul-reports@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.0.2.tgz#d593210e5000683750cb09fc0644e4b6e27fd53b" + integrity sha512-9tZvz7AiR3PEDNGiV9vIouQ/EAcqMXFmkcA1CDFTwOB98OZVDL0PH9glHotf5Ugp6GCOTypfzGWI/OqjWNCRUw== dependencies: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" @@ -9563,56 +9873,69 @@ isurl@^1.0.0-alpha5: has-to-string-tag-x "^1.2.0" is-object "^1.0.1" -jest-changed-files@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-25.1.0.tgz#73dae9a7d9949fdfa5c278438ce8f2ff3ec78131" - integrity sha512-bdL1aHjIVy3HaBO3eEQeemGttsq1BDlHgWcOjEOIAcga7OOEGWHD2WSu8HhL7I1F0mFFyci8VKU4tRNk+qtwDA== +jake@^10.6.1: + version "10.8.2" + resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.2.tgz#ebc9de8558160a66d82d0eadc6a2e58fbc500a7b" + integrity sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A== dependencies: - "@jest/types" "^25.1.0" + async "0.9.x" + chalk "^2.4.2" + filelist "^1.0.1" + minimatch "^3.0.4" + +jest-changed-files@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-25.5.0.tgz#141cc23567ceb3f534526f8614ba39421383634c" + integrity sha512-EOw9QEqapsDT7mKF162m8HFzRPbmP8qJQny6ldVOdOVBz3ACgPm/1nAn5fPQ/NDaYhX/AHkrGwwkCncpAVSXcw== + dependencies: + "@jest/types" "^25.5.0" execa "^3.2.0" throat "^5.0.0" jest-cli@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-25.1.0.tgz#75f0b09cf6c4f39360906bf78d580be1048e4372" - integrity sha512-p+aOfczzzKdo3AsLJlhs8J5EW6ffVidfSZZxXedJ0mHPBOln1DccqFmGCoO8JWd4xRycfmwy1eoQkMsF8oekPg== + version "25.5.4" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-25.5.4.tgz#b9f1a84d1301a92c5c217684cb79840831db9f0d" + integrity sha512-rG8uJkIiOUpnREh1768/N3n27Cm+xPFkSNFO91tgg+8o2rXeVLStz+vkXkGr4UtzH6t1SNbjwoiswd7p4AhHTw== dependencies: - "@jest/core" "^25.1.0" - "@jest/test-result" "^25.1.0" - "@jest/types" "^25.1.0" + "@jest/core" "^25.5.4" + "@jest/test-result" "^25.5.0" + "@jest/types" "^25.5.0" chalk "^3.0.0" exit "^0.1.2" + graceful-fs "^4.2.4" import-local "^3.0.2" is-ci "^2.0.0" - jest-config "^25.1.0" - jest-util "^25.1.0" - jest-validate "^25.1.0" + jest-config "^25.5.4" + jest-util "^25.5.0" + jest-validate "^25.5.0" prompts "^2.0.1" - realpath-native "^1.1.0" - yargs "^15.0.0" + realpath-native "^2.0.0" + yargs "^15.3.1" -jest-config@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-25.1.0.tgz#d114e4778c045d3ef239452213b7ad3ec1cbea90" - integrity sha512-tLmsg4SZ5H7tuhBC5bOja0HEblM0coS3Wy5LTCb2C8ZV6eWLewHyK+3qSq9Bi29zmWQ7ojdCd3pxpx4l4d2uGw== +jest-config@^25.5.4: + version "25.5.4" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-25.5.4.tgz#38e2057b3f976ef7309b2b2c8dcd2a708a67f02c" + integrity sha512-SZwR91SwcdK6bz7Gco8qL7YY2sx8tFJYzvg216DLihTWf+LKY/DoJXpM9nTzYakSyfblbqeU48p/p7Jzy05Atg== dependencies: "@babel/core" "^7.1.0" - "@jest/test-sequencer" "^25.1.0" - "@jest/types" "^25.1.0" - babel-jest "^25.1.0" + "@jest/test-sequencer" "^25.5.4" + "@jest/types" "^25.5.0" + babel-jest "^25.5.1" chalk "^3.0.0" + deepmerge "^4.2.2" glob "^7.1.1" - jest-environment-jsdom "^25.1.0" - jest-environment-node "^25.1.0" - jest-get-type "^25.1.0" - jest-jasmine2 "^25.1.0" - jest-regex-util "^25.1.0" - jest-resolve "^25.1.0" - jest-util "^25.1.0" - jest-validate "^25.1.0" + graceful-fs "^4.2.4" + jest-environment-jsdom "^25.5.0" + jest-environment-node "^25.5.0" + jest-get-type "^25.2.6" + jest-jasmine2 "^25.5.4" + jest-regex-util "^25.2.6" + jest-resolve "^25.5.1" + jest-util "^25.5.0" + jest-validate "^25.5.0" micromatch "^4.0.2" - pretty-format "^25.1.0" - realpath-native "^1.1.0" + pretty-format "^25.5.0" + realpath-native "^2.0.0" jest-diff@^25.1.0: version "25.1.0" @@ -9624,274 +9947,302 @@ jest-diff@^25.1.0: jest-get-type "^25.1.0" pretty-format "^25.1.0" -jest-docblock@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-25.1.0.tgz#0f44bea3d6ca6dfc38373d465b347c8818eccb64" - integrity sha512-370P/mh1wzoef6hUKiaMcsPtIapY25suP6JqM70V9RJvdKLrV4GaGbfUseUVk4FZJw4oTZ1qSCJNdrClKt5JQA== - dependencies: - detect-newline "^3.0.0" - -jest-each@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-25.1.0.tgz#a6b260992bdf451c2d64a0ccbb3ac25e9b44c26a" - integrity sha512-R9EL8xWzoPySJ5wa0DXFTj7NrzKpRD40Jy+zQDp3Qr/2QmevJgkN9GqioCGtAJ2bW9P/MQRznQHQQhoeAyra7A== +jest-diff@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-25.5.0.tgz#1dd26ed64f96667c068cef026b677dfa01afcfa9" + integrity sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A== dependencies: - "@jest/types" "^25.1.0" chalk "^3.0.0" - jest-get-type "^25.1.0" - jest-util "^25.1.0" - pretty-format "^25.1.0" + diff-sequences "^25.2.6" + jest-get-type "^25.2.6" + pretty-format "^25.5.0" -jest-environment-jsdom@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-25.1.0.tgz#6777ab8b3e90fd076801efd3bff8e98694ab43c3" - integrity sha512-ILb4wdrwPAOHX6W82GGDUiaXSSOE274ciuov0lztOIymTChKFtC02ddyicRRCdZlB5YSrv3vzr1Z5xjpEe1OHQ== +jest-docblock@^25.3.0: + version "25.3.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-25.3.0.tgz#8b777a27e3477cd77a168c05290c471a575623ef" + integrity sha512-aktF0kCar8+zxRHxQZwxMy70stc9R1mOmrLsT5VO3pIT0uzGRSDAXxSlz4NqQWpuLjPpuMhPRl7H+5FRsvIQAg== dependencies: - "@jest/environment" "^25.1.0" - "@jest/fake-timers" "^25.1.0" - "@jest/types" "^25.1.0" - jest-mock "^25.1.0" - jest-util "^25.1.0" - jsdom "^15.1.1" + detect-newline "^3.0.0" -jest-environment-node@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-25.1.0.tgz#797bd89b378cf0bd794dc8e3dca6ef21126776db" - integrity sha512-U9kFWTtAPvhgYY5upnH9rq8qZkj6mYLup5l1caAjjx9uNnkLHN2xgZy5mo4SyLdmrh/EtB9UPpKFShvfQHD0Iw== +jest-each@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-25.5.0.tgz#0c3c2797e8225cb7bec7e4d249dcd96b934be516" + integrity sha512-QBogUxna3D8vtiItvn54xXde7+vuzqRrEeaw8r1s+1TG9eZLVJE5ZkKoSUlqFwRjnlaA4hyKGiu9OlkFIuKnjA== dependencies: - "@jest/environment" "^25.1.0" - "@jest/fake-timers" "^25.1.0" - "@jest/types" "^25.1.0" - jest-mock "^25.1.0" - jest-util "^25.1.0" + "@jest/types" "^25.5.0" + chalk "^3.0.0" + jest-get-type "^25.2.6" + jest-util "^25.5.0" + pretty-format "^25.5.0" + +jest-environment-jsdom@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-25.5.0.tgz#dcbe4da2ea997707997040ecf6e2560aec4e9834" + integrity sha512-7Jr02ydaq4jaWMZLY+Skn8wL5nVIYpWvmeatOHL3tOcV3Zw8sjnPpx+ZdeBfc457p8jCR9J6YCc+Lga0oIy62A== + dependencies: + "@jest/environment" "^25.5.0" + "@jest/fake-timers" "^25.5.0" + "@jest/types" "^25.5.0" + jest-mock "^25.5.0" + jest-util "^25.5.0" + jsdom "^15.2.1" + +jest-environment-node@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-25.5.0.tgz#0f55270d94804902988e64adca37c6ce0f7d07a1" + integrity sha512-iuxK6rQR2En9EID+2k+IBs5fCFd919gVVK5BeND82fYeLWPqvRcFNPKu9+gxTwfB5XwBGBvZ0HFQa+cHtIoslA== + dependencies: + "@jest/environment" "^25.5.0" + "@jest/fake-timers" "^25.5.0" + "@jest/types" "^25.5.0" + jest-mock "^25.5.0" + jest-util "^25.5.0" + semver "^6.3.0" jest-get-type@^25.1.0: version "25.1.0" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-25.1.0.tgz#1cfe5fc34f148dc3a8a3b7275f6b9ce9e2e8a876" integrity sha512-yWkBnT+5tMr8ANB6V+OjmrIJufHtCAqI5ic2H40v+tRqxDmE0PGnIiTyvRWFOMtmVHYpwRqyazDbTnhpjsGvLw== -jest-haste-map@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-25.1.0.tgz#ae12163d284f19906260aa51fd405b5b2e5a4ad3" - integrity sha512-/2oYINIdnQZAqyWSn1GTku571aAfs8NxzSErGek65Iu5o8JYb+113bZysRMcC/pjE5v9w0Yz+ldbj9NxrFyPyw== +jest-get-type@^25.2.6: + version "25.2.6" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-25.2.6.tgz#0b0a32fab8908b44d508be81681487dbabb8d877" + integrity sha512-DxjtyzOHjObRM+sM1knti6or+eOgcGU4xVSb2HNP1TqO4ahsT+rqZg+nyqHWJSvWgKC5cG3QjGFBqxLghiF/Ig== + +jest-haste-map@^25.5.1: + version "25.5.1" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-25.5.1.tgz#1df10f716c1d94e60a1ebf7798c9fb3da2620943" + integrity sha512-dddgh9UZjV7SCDQUrQ+5t9yy8iEgKc1AKqZR9YDww8xsVOtzPQSMVLDChc21+g29oTRexb9/B0bIlZL+sWmvAQ== dependencies: - "@jest/types" "^25.1.0" + "@jest/types" "^25.5.0" + "@types/graceful-fs" "^4.1.2" anymatch "^3.0.3" fb-watchman "^2.0.0" - graceful-fs "^4.2.3" - jest-serializer "^25.1.0" - jest-util "^25.1.0" - jest-worker "^25.1.0" + graceful-fs "^4.2.4" + jest-serializer "^25.5.0" + jest-util "^25.5.0" + jest-worker "^25.5.0" micromatch "^4.0.2" sane "^4.0.3" walker "^1.0.7" + which "^2.0.2" optionalDependencies: fsevents "^2.1.2" -jest-jasmine2@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-25.1.0.tgz#681b59158a430f08d5d0c1cce4f01353e4b48137" - integrity sha512-GdncRq7jJ7sNIQ+dnXvpKO2MyP6j3naNK41DTTjEAhLEdpImaDA9zSAZwDhijjSF/D7cf4O5fdyUApGBZleaEg== +jest-jasmine2@^25.5.4: + version "25.5.4" + resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-25.5.4.tgz#66ca8b328fb1a3c5364816f8958f6970a8526968" + integrity sha512-9acbWEfbmS8UpdcfqnDO+uBUgKa/9hcRh983IHdM+pKmJPL77G0sWAAK0V0kr5LK3a8cSBfkFSoncXwQlRZfkQ== dependencies: "@babel/traverse" "^7.1.0" - "@jest/environment" "^25.1.0" - "@jest/source-map" "^25.1.0" - "@jest/test-result" "^25.1.0" - "@jest/types" "^25.1.0" + "@jest/environment" "^25.5.0" + "@jest/source-map" "^25.5.0" + "@jest/test-result" "^25.5.0" + "@jest/types" "^25.5.0" chalk "^3.0.0" co "^4.6.0" - expect "^25.1.0" + expect "^25.5.0" is-generator-fn "^2.0.0" - jest-each "^25.1.0" - jest-matcher-utils "^25.1.0" - jest-message-util "^25.1.0" - jest-runtime "^25.1.0" - jest-snapshot "^25.1.0" - jest-util "^25.1.0" - pretty-format "^25.1.0" + jest-each "^25.5.0" + jest-matcher-utils "^25.5.0" + jest-message-util "^25.5.0" + jest-runtime "^25.5.4" + jest-snapshot "^25.5.1" + jest-util "^25.5.0" + pretty-format "^25.5.0" throat "^5.0.0" -jest-leak-detector@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-25.1.0.tgz#ed6872d15aa1c72c0732d01bd073dacc7c38b5c6" - integrity sha512-3xRI264dnhGaMHRvkFyEKpDeaRzcEBhyNrOG5oT8xPxOyUAblIAQnpiR3QXu4wDor47MDTiHbiFcbypdLcLW5w== +jest-leak-detector@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-25.5.0.tgz#2291c6294b0ce404241bb56fe60e2d0c3e34f0bb" + integrity sha512-rV7JdLsanS8OkdDpZtgBf61L5xZ4NnYLBq72r6ldxahJWWczZjXawRsoHyXzibM5ed7C2QRjpp6ypgwGdKyoVA== dependencies: - jest-get-type "^25.1.0" - pretty-format "^25.1.0" + jest-get-type "^25.2.6" + pretty-format "^25.5.0" -jest-matcher-utils@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-25.1.0.tgz#fa5996c45c7193a3c24e73066fc14acdee020220" - integrity sha512-KGOAFcSFbclXIFE7bS4C53iYobKI20ZWleAdAFun4W1Wz1Kkej8Ng6RRbhL8leaEvIOjGXhGf/a1JjO8bkxIWQ== +jest-matcher-utils@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-25.5.0.tgz#fbc98a12d730e5d2453d7f1ed4a4d948e34b7867" + integrity sha512-VWI269+9JS5cpndnpCwm7dy7JtGQT30UHfrnM3mXl22gHGt/b7NkjBqXfbhZ8V4B7ANUsjK18PlSBmG0YH7gjw== dependencies: chalk "^3.0.0" - jest-diff "^25.1.0" - jest-get-type "^25.1.0" - pretty-format "^25.1.0" + jest-diff "^25.5.0" + jest-get-type "^25.2.6" + pretty-format "^25.5.0" -jest-message-util@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-25.1.0.tgz#702a9a5cb05c144b9aa73f06e17faa219389845e" - integrity sha512-Nr/Iwar2COfN22aCqX0kCVbXgn8IBm9nWf4xwGr5Olv/KZh0CZ32RKgZWMVDXGdOahicM10/fgjdimGNX/ttCQ== +jest-message-util@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-25.5.0.tgz#ea11d93204cc7ae97456e1d8716251185b8880ea" + integrity sha512-ezddz3YCT/LT0SKAmylVyWWIGYoKHOFOFXx3/nA4m794lfVUskMcwhip6vTgdVrOtYdjeQeis2ypzes9mZb4EA== dependencies: "@babel/code-frame" "^7.0.0" - "@jest/test-result" "^25.1.0" - "@jest/types" "^25.1.0" + "@jest/types" "^25.5.0" "@types/stack-utils" "^1.0.1" chalk "^3.0.0" + graceful-fs "^4.2.4" micromatch "^4.0.2" slash "^3.0.0" stack-utils "^1.0.1" -jest-mock@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-25.1.0.tgz#411d549e1b326b7350b2e97303a64715c28615fd" - integrity sha512-28/u0sqS+42vIfcd1mlcg4ZVDmSUYuNvImP4X2lX5hRMLW+CN0BeiKVD4p+ujKKbSPKd3rg/zuhCF+QBLJ4vag== +jest-mock@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-25.5.0.tgz#a91a54dabd14e37ecd61665d6b6e06360a55387a" + integrity sha512-eXWuTV8mKzp/ovHc5+3USJMYsTBhyQ+5A1Mak35dey/RG8GlM4YWVylZuGgVXinaW6tpvk/RSecmF37FKUlpXA== dependencies: - "@jest/types" "^25.1.0" + "@jest/types" "^25.5.0" jest-pnp-resolver@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.1.tgz#ecdae604c077a7fbc70defb6d517c3c1c898923a" integrity sha512-pgFw2tm54fzgYvc/OHrnysABEObZCUNFnhjoRjaVOCN8NYc032/gVjPaHD4Aq6ApkSieWtfKAFQtmDKAmhupnQ== -jest-regex-util@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-25.1.0.tgz#efaf75914267741838e01de24da07b2192d16d87" - integrity sha512-9lShaDmDpqwg+xAd73zHydKrBbbrIi08Kk9YryBEBybQFg/lBWR/2BDjjiSE7KIppM9C5+c03XiDaZ+m4Pgs1w== +jest-regex-util@^25.2.6: + version "25.2.6" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-25.2.6.tgz#d847d38ba15d2118d3b06390056028d0f2fd3964" + integrity sha512-KQqf7a0NrtCkYmZZzodPftn7fL1cq3GQAFVMn5Hg8uKx/fIenLEobNanUxb7abQ1sjADHBseG/2FGpsv/wr+Qw== -jest-resolve-dependencies@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-25.1.0.tgz#8a1789ec64eb6aaa77fd579a1066a783437e70d2" - integrity sha512-Cu/Je38GSsccNy4I2vL12ZnBlD170x2Oh1devzuM9TLH5rrnLW1x51lN8kpZLYTvzx9j+77Y5pqBaTqfdzVzrw== +jest-resolve-dependencies@^25.5.4: + version "25.5.4" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-25.5.4.tgz#85501f53957c8e3be446e863a74777b5a17397a7" + integrity sha512-yFmbPd+DAQjJQg88HveObcGBA32nqNZ02fjYmtL16t1xw9bAttSn5UGRRhzMHIQbsep7znWvAvnD4kDqOFM0Uw== dependencies: - "@jest/types" "^25.1.0" - jest-regex-util "^25.1.0" - jest-snapshot "^25.1.0" + "@jest/types" "^25.5.0" + jest-regex-util "^25.2.6" + jest-snapshot "^25.5.1" -jest-resolve@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-25.1.0.tgz#23d8b6a4892362baf2662877c66aa241fa2eaea3" - integrity sha512-XkBQaU1SRCHj2Evz2Lu4Czs+uIgJXWypfO57L7JYccmAXv4slXA6hzNblmcRmf7P3cQ1mE7fL3ABV6jAwk4foQ== +jest-resolve@^25.5.1: + version "25.5.1" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-25.5.1.tgz#0e6fbcfa7c26d2a5fe8f456088dc332a79266829" + integrity sha512-Hc09hYch5aWdtejsUZhA+vSzcotf7fajSlPA6EZPE1RmPBAD39XtJhvHWFStid58iit4IPDLI/Da4cwdDmAHiQ== dependencies: - "@jest/types" "^25.1.0" + "@jest/types" "^25.5.0" browser-resolve "^1.11.3" chalk "^3.0.0" + graceful-fs "^4.2.4" jest-pnp-resolver "^1.2.1" - realpath-native "^1.1.0" + read-pkg-up "^7.0.1" + realpath-native "^2.0.0" + resolve "^1.17.0" + slash "^3.0.0" -jest-runner@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-25.1.0.tgz#fef433a4d42c89ab0a6b6b268e4a4fbe6b26e812" - integrity sha512-su3O5fy0ehwgt+e8Wy7A8CaxxAOCMzL4gUBftSs0Ip32S0epxyZPDov9Znvkl1nhVOJNf4UwAsnqfc3plfQH9w== +jest-runner@^25.5.4: + version "25.5.4" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-25.5.4.tgz#ffec5df3875da5f5c878ae6d0a17b8e4ecd7c71d" + integrity sha512-V/2R7fKZo6blP8E9BL9vJ8aTU4TH2beuqGNxHbxi6t14XzTb+x90B3FRgdvuHm41GY8ch4xxvf0ATH4hdpjTqg== dependencies: - "@jest/console" "^25.1.0" - "@jest/environment" "^25.1.0" - "@jest/test-result" "^25.1.0" - "@jest/types" "^25.1.0" + "@jest/console" "^25.5.0" + "@jest/environment" "^25.5.0" + "@jest/test-result" "^25.5.0" + "@jest/types" "^25.5.0" chalk "^3.0.0" exit "^0.1.2" - graceful-fs "^4.2.3" - jest-config "^25.1.0" - jest-docblock "^25.1.0" - jest-haste-map "^25.1.0" - jest-jasmine2 "^25.1.0" - jest-leak-detector "^25.1.0" - jest-message-util "^25.1.0" - jest-resolve "^25.1.0" - jest-runtime "^25.1.0" - jest-util "^25.1.0" - jest-worker "^25.1.0" + graceful-fs "^4.2.4" + jest-config "^25.5.4" + jest-docblock "^25.3.0" + jest-haste-map "^25.5.1" + jest-jasmine2 "^25.5.4" + jest-leak-detector "^25.5.0" + jest-message-util "^25.5.0" + jest-resolve "^25.5.1" + jest-runtime "^25.5.4" + jest-util "^25.5.0" + jest-worker "^25.5.0" source-map-support "^0.5.6" throat "^5.0.0" -jest-runtime@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-25.1.0.tgz#02683218f2f95aad0f2ec1c9cdb28c1dc0ec0314" - integrity sha512-mpPYYEdbExKBIBB16ryF6FLZTc1Rbk9Nx0ryIpIMiDDkOeGa0jQOKVI/QeGvVGlunKKm62ywcioeFVzIbK03bA== - dependencies: - "@jest/console" "^25.1.0" - "@jest/environment" "^25.1.0" - "@jest/source-map" "^25.1.0" - "@jest/test-result" "^25.1.0" - "@jest/transform" "^25.1.0" - "@jest/types" "^25.1.0" +jest-runtime@^25.5.4: + version "25.5.4" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-25.5.4.tgz#dc981fe2cb2137abcd319e74ccae7f7eeffbfaab" + integrity sha512-RWTt8LeWh3GvjYtASH2eezkc8AehVoWKK20udV6n3/gC87wlTbE1kIA+opCvNWyyPeBs6ptYsc6nyHUb1GlUVQ== + dependencies: + "@jest/console" "^25.5.0" + "@jest/environment" "^25.5.0" + "@jest/globals" "^25.5.2" + "@jest/source-map" "^25.5.0" + "@jest/test-result" "^25.5.0" + "@jest/transform" "^25.5.1" + "@jest/types" "^25.5.0" "@types/yargs" "^15.0.0" chalk "^3.0.0" collect-v8-coverage "^1.0.0" exit "^0.1.2" glob "^7.1.3" - graceful-fs "^4.2.3" - jest-config "^25.1.0" - jest-haste-map "^25.1.0" - jest-message-util "^25.1.0" - jest-mock "^25.1.0" - jest-regex-util "^25.1.0" - jest-resolve "^25.1.0" - jest-snapshot "^25.1.0" - jest-util "^25.1.0" - jest-validate "^25.1.0" - realpath-native "^1.1.0" + graceful-fs "^4.2.4" + jest-config "^25.5.4" + jest-haste-map "^25.5.1" + jest-message-util "^25.5.0" + jest-mock "^25.5.0" + jest-regex-util "^25.2.6" + jest-resolve "^25.5.1" + jest-snapshot "^25.5.1" + jest-util "^25.5.0" + jest-validate "^25.5.0" + realpath-native "^2.0.0" slash "^3.0.0" strip-bom "^4.0.0" - yargs "^15.0.0" + yargs "^15.3.1" -jest-serializer@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-25.1.0.tgz#73096ba90e07d19dec4a0c1dd89c355e2f129e5d" - integrity sha512-20Wkq5j7o84kssBwvyuJ7Xhn7hdPeTXndnwIblKDR2/sy1SUm6rWWiG9kSCgJPIfkDScJCIsTtOKdlzfIHOfKA== +jest-serializer@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-25.5.0.tgz#a993f484e769b4ed54e70e0efdb74007f503072b" + integrity sha512-LxD8fY1lByomEPflwur9o4e2a5twSQ7TaVNLlFUuToIdoJuBt8tzHfCsZ42Ok6LkKXWzFWf3AGmheuLAA7LcCA== + dependencies: + graceful-fs "^4.2.4" -jest-snapshot@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-25.1.0.tgz#d5880bd4b31faea100454608e15f8d77b9d221d9" - integrity sha512-xZ73dFYN8b/+X2hKLXz4VpBZGIAn7muD/DAg+pXtDzDGw3iIV10jM7WiHqhCcpDZfGiKEj7/2HXAEPtHTj0P2A== +jest-snapshot@^25.5.1: + version "25.5.1" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-25.5.1.tgz#1a2a576491f9961eb8d00c2e5fd479bc28e5ff7f" + integrity sha512-C02JE1TUe64p2v1auUJ2ze5vcuv32tkv9PyhEb318e8XOKF7MOyXdJ7kdjbvrp3ChPLU2usI7Rjxs97Dj5P0uQ== dependencies: "@babel/types" "^7.0.0" - "@jest/types" "^25.1.0" + "@jest/types" "^25.5.0" + "@types/prettier" "^1.19.0" chalk "^3.0.0" - expect "^25.1.0" - jest-diff "^25.1.0" - jest-get-type "^25.1.0" - jest-matcher-utils "^25.1.0" - jest-message-util "^25.1.0" - jest-resolve "^25.1.0" - mkdirp "^0.5.1" + expect "^25.5.0" + graceful-fs "^4.2.4" + jest-diff "^25.5.0" + jest-get-type "^25.2.6" + jest-matcher-utils "^25.5.0" + jest-message-util "^25.5.0" + jest-resolve "^25.5.1" + make-dir "^3.0.0" natural-compare "^1.4.0" - pretty-format "^25.1.0" - semver "^7.1.1" + pretty-format "^25.5.0" + semver "^6.3.0" -jest-util@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-25.1.0.tgz#7bc56f7b2abd534910e9fa252692f50624c897d9" - integrity sha512-7did6pLQ++87Qsj26Fs/TIwZMUFBXQ+4XXSodRNy3luch2DnRXsSnmpVtxxQ0Yd6WTipGpbhh2IFP1mq6/fQGw== +jest-util@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-25.5.0.tgz#31c63b5d6e901274d264a4fec849230aa3fa35b0" + integrity sha512-KVlX+WWg1zUTB9ktvhsg2PXZVdkI1NBevOJSkTKYAyXyH4QSvh+Lay/e/v+bmaFfrkfx43xD8QTfgobzlEXdIA== dependencies: - "@jest/types" "^25.1.0" + "@jest/types" "^25.5.0" chalk "^3.0.0" + graceful-fs "^4.2.4" is-ci "^2.0.0" - mkdirp "^0.5.1" + make-dir "^3.0.0" -jest-validate@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-25.1.0.tgz#1469fa19f627bb0a9a98e289f3e9ab6a668c732a" - integrity sha512-kGbZq1f02/zVO2+t1KQGSVoCTERc5XeObLwITqC6BTRH3Adv7NZdYqCpKIZLUgpLXf2yISzQ465qOZpul8abXA== +jest-validate@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-25.5.0.tgz#fb4c93f332c2e4cf70151a628e58a35e459a413a" + integrity sha512-okUFKqhZIpo3jDdtUXUZ2LxGUZJIlfdYBvZb1aczzxrlyMlqdnnws9MOxezoLGhSaFc2XYaHNReNQfj5zPIWyQ== dependencies: - "@jest/types" "^25.1.0" + "@jest/types" "^25.5.0" camelcase "^5.3.1" chalk "^3.0.0" - jest-get-type "^25.1.0" + jest-get-type "^25.2.6" leven "^3.1.0" - pretty-format "^25.1.0" + pretty-format "^25.5.0" -jest-watcher@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-25.1.0.tgz#97cb4a937f676f64c9fad2d07b824c56808e9806" - integrity sha512-Q9eZ7pyaIr6xfU24OeTg4z1fUqBF/4MP6J801lyQfg7CsnZ/TCzAPvCfckKdL5dlBBEKBeHV0AdyjFZ5eWj4ig== +jest-watcher@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-25.5.0.tgz#d6110d101df98badebe435003956fd4a465e8456" + integrity sha512-XrSfJnVASEl+5+bb51V0Q7WQx65dTSk7NL4yDdVjPnRNpM0hG+ncFmDYJo9O8jaSRcAitVbuVawyXCRoxGrT5Q== dependencies: - "@jest/test-result" "^25.1.0" - "@jest/types" "^25.1.0" + "@jest/test-result" "^25.5.0" + "@jest/types" "^25.5.0" ansi-escapes "^4.2.1" chalk "^3.0.0" - jest-util "^25.1.0" + jest-util "^25.5.0" string-length "^3.1.0" jest-worker@^25.1.0: @@ -9902,6 +10253,14 @@ jest-worker@^25.1.0: merge-stream "^2.0.0" supports-color "^7.0.0" +jest-worker@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-25.5.0.tgz#2611d071b79cea0f43ee57a3d118593ac1547db1" + integrity sha512-/dsSmUkIy5EBGfv/IjjqmFxrNAUpBERfGs1oHROyD7yxjG/w+t0GOJDX8O1k32ySmd7+a5IhnJU2qQFcJ4n1vw== + dependencies: + merge-stream "^2.0.0" + supports-color "^7.0.0" + jest@^25.1.0: version "25.1.0" resolved "https://registry.yarnpkg.com/jest/-/jest-25.1.0.tgz#b85ef1ddba2fdb00d295deebbd13567106d35be9" @@ -9954,6 +10313,14 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" +js-yaml@^3.14.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482" + integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + jsbi@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/jsbi/-/jsbi-2.0.5.tgz#82589011da87dc59b4b549d94dcef51a9155f6fe" @@ -9964,7 +10331,7 @@ jsbn@~0.1.0: resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= -jsdom@^15.1.1: +jsdom@^15.2.1: version "15.2.1" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-15.2.1.tgz#d2feb1aef7183f86be521b8c6833ff5296d07ec5" integrity sha512-fAl1W0/7T2G5vURSyxBzrJ1LSdQn6Tr5UX/xD4PXDx/PDgwygedfW6El/KIj3xJ7FU61TTYnc/l/B7P49Eqt6g== @@ -10021,12 +10388,17 @@ json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2: resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + json-schema-traverse@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== -json-schema-typed@^7.0.1: +json-schema-typed@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/json-schema-typed/-/json-schema-typed-7.0.3.tgz#23ff481b8b4eebcd2ca123b4fa0409e66469a2d9" integrity sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A== @@ -10084,6 +10456,15 @@ jsonfile@^4.0.0: optionalDependencies: graceful-fs "^4.1.6" +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + jsprim@^1.2.2: version "1.4.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" @@ -10102,6 +10483,24 @@ jsx-ast-utils@^2.2.1, jsx-ast-utils@^2.2.3: array-includes "^3.0.3" object.assign "^4.1.0" +"jsx-ast-utils@^2.4.1 || ^3.0.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.2.0.tgz#41108d2cec408c3453c1bbe8a4aae9e1e2bd8f82" + integrity sha512-EIsmt3O3ljsU6sot/J4E1zDRxfBNrhjyf/OKjlydwgEimQuznlM4Wv7U+ueONJMyEn1WRE0K8dhi3dVAXYT24Q== + dependencies: + array-includes "^3.1.2" + object.assign "^4.1.2" + +jszip@^3.1.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.5.0.tgz#b4fd1f368245346658e781fec9675802489e15f6" + integrity sha512-WRtu7TPCmYePR1nazfrtuF216cIVon/3GWOvHS9QR5bIwSbnxtdpma6un3jyGGNhHsKCSzn5Ypk+EkDRvTGiFA== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + set-immediate-shim "~1.0.1" + junk@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1" @@ -10167,6 +10566,13 @@ kind-of@^6.0.0, kind-of@^6.0.2: resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== +klaw-sync@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/klaw-sync/-/klaw-sync-6.0.0.tgz#1fd2cfd56ebb6250181114f0a581167099c2b28c" + integrity sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ== + dependencies: + graceful-fs "^4.1.11" + kleur@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" @@ -10224,7 +10630,7 @@ lcid@^2.0.0: dependencies: invert-kv "^2.0.0" -leven@2.1.0, leven@^2.1.0: +leven@2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-2.1.0.tgz#c2e7a9f772094dee9d34202ae8acce4687875580" integrity sha1-wuep93IJTe6dNCAq6KzORoeHVYA= @@ -10257,6 +10663,18 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + +lines-and-columns@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" + integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= + load-json-file@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" @@ -10339,8 +10757,8 @@ locate-path@^3.0.0: locate-path@^5.0.0: version "5.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" - integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + resolved "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz" + integrity "sha1-Gvujlq/WdqbUJQTQpno6frn2KqA= sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==" dependencies: p-locate "^4.1.0" @@ -10467,6 +10885,16 @@ lodash.isplainobject@^3.0.0: lodash.isarguments "^3.0.0" lodash.keysin "^3.0.0" +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE= + lodash.istypedarray@^3.0.0: version "3.0.6" resolved "https://registry.yarnpkg.com/lodash.istypedarray/-/lodash.istypedarray-3.0.6.tgz#c9a477498607501d8e8494d283b87c39281cef62" @@ -10534,10 +10962,10 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@^4.0.0, lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.8.0, lodash@~4.17.12: - version "4.17.15" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" - integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== +lodash@^4.0.0, lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.8.0, lodash@~4.17.12: + version "4.17.20" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" + integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== log-symbols@^2.2.0: version "2.2.0" @@ -10644,6 +11072,13 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + lru_map@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/lru_map/-/lru_map-0.3.3.tgz#b5c8351b9464cbd750335a79650a0ec0e56118dd" @@ -10671,7 +11106,7 @@ make-dir@^3.0.0: dependencies: semver "^6.0.0" -make-dir@^3.0.2: +make-dir@^3.0.2, make-dir@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== @@ -10729,12 +11164,12 @@ markdown-escapes@^1.0.0: resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.4.tgz#c95415ef451499d7602b91095f3c8e8975f78535" integrity sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg== -matcher@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/matcher/-/matcher-2.1.0.tgz#64e1041c15b993e23b786f93320a7474bf833c28" - integrity sha512-o+nZr+vtJtgPNklyeUKkkH42OsK8WAfdgaJE2FNxcjLPg+5QbeEoT6vRj8Xq/iv18JlQ9cmKsEu0b94ixWf1YQ== +matcher@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/matcher/-/matcher-3.0.0.tgz#bd9060f4c5b70aa8041ccc6f80368760994f30ca" + integrity "sha1-vZBg9MW3CqgEHMxvgDaHYJlPMMo= sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==" dependencies: - escape-string-regexp "^2.0.0" + escape-string-regexp "^4.0.0" math-random@^1.0.1: version "1.0.4" @@ -10932,6 +11367,11 @@ mime@^2.4.4: resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5" integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA== +mime@^2.4.6: + version "2.4.6" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.6.tgz#e5b407c90db442f2beb5b162373d07b69affa4d1" + integrity sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA== + mimic-fn@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" @@ -10942,6 +11382,11 @@ mimic-fn@^2.0.0, mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +mimic-fn@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-3.1.0.tgz#65755145bbf3e36954b949c16450427451d5ca74" + integrity sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ== + mimic-response@^1.0.0, mimic-response@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" @@ -10954,14 +11399,13 @@ min-document@^2.19.0: dependencies: dom-walk "^0.1.0" -mini-create-react-context@^0.3.0: - version "0.3.2" - resolved "https://registry.yarnpkg.com/mini-create-react-context/-/mini-create-react-context-0.3.2.tgz#79fc598f283dd623da8e088b05db8cddab250189" - integrity sha512-2v+OeetEyliMt5VHMXsBhABoJ0/M4RCe7fatd/fBy6SMiKazUSEt3gxxypfnk2SHMkdBYvorHRoQxuGoiwbzAw== +mini-create-react-context@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz#072171561bfdc922da08a60c2197a497cc2d1d5e" + integrity sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ== dependencies: - "@babel/runtime" "^7.4.0" - gud "^1.0.0" - tiny-warning "^1.0.2" + "@babel/runtime" "^7.12.1" + tiny-warning "^1.0.3" mini-css-extract-plugin@^0.9.0: version "0.9.0" @@ -10992,15 +11436,10 @@ minimatch@3.0.4, minimatch@^3.0.4, minimatch@~3.0.2: minimist@0.0.8: version "0.0.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" + resolved "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= -minimist@1.2.0, minimist@^1.1.0, minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" - integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ= - -minimist@^1.2.5: +minimist@^1.1.0, minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== @@ -11079,11 +11518,18 @@ mixin-deep@^1.2.0: mkdirp@0.5.1, mkdirp@0.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: version "0.5.1" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" + resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= dependencies: minimist "0.0.8" +mkdirp@^0.5.4, mkdirp@^0.5.5: + version "0.5.5" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" + integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== + dependencies: + minimist "^1.2.5" + mocha@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/mocha/-/mocha-5.2.0.tgz#6d8ae508f59167f940f2b5b3c4a612ae50c90ae6" @@ -11146,36 +11592,6 @@ mpris-service@2.1.0: deep-equal "^1.0.1" source-map-support "^0.5.11" -mqtt-packet@^5.6.0: - version "5.6.1" - resolved "https://registry.yarnpkg.com/mqtt-packet/-/mqtt-packet-5.6.1.tgz#8ecafce091f5af460664268a22b22091c8915f7b" - integrity sha512-eaF9rO2uFrIYEHomJxziuKTDkbWW5psLBaIGCazQSKqYsTaB3n4SpvJ1PexKaDBiPnMLPIFWBIiTYT3IfEJfww== - dependencies: - bl "^1.2.1" - inherits "^2.0.3" - process-nextick-args "^2.0.0" - safe-buffer "^5.1.0" - -mqtt@2.18.8, mqtt@^2.13.0: - version "2.18.8" - resolved "https://registry.yarnpkg.com/mqtt/-/mqtt-2.18.8.tgz#9d213ccab92151accfb21ee8c0860dc6866ab259" - integrity sha512-3h6oHlPY/yWwtC2J3geraYRtVVoRM6wdI+uchF4nvSSafXPZnaKqF8xnX+S22SU/FcgEAgockVIlOaAX3fkMpA== - dependencies: - commist "^1.0.0" - concat-stream "^1.6.2" - end-of-stream "^1.4.1" - es6-map "^0.1.5" - help-me "^1.0.1" - inherits "^2.0.3" - minimist "^1.2.0" - mqtt-packet "^5.6.0" - pump "^3.0.0" - readable-stream "^2.3.6" - reinterval "^1.1.0" - split2 "^2.1.1" - websocket-stream "^5.1.2" - xtend "^4.0.1" - mri@1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/mri/-/mri-1.1.4.tgz#7cb1dd1b9b40905f1fac053abe25b6720f44744a" @@ -11191,7 +11607,7 @@ ms@2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== -ms@^2.1.1: +ms@2.1.2, ms@^2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== @@ -11247,9 +11663,9 @@ mute-stream@0.0.8: integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== nan@^2.12.1, nan@^2.13.2, nan@^2.9.2, nan@latest: - version "2.14.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" - integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== + version "2.14.2" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19" + integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ== nano-css@^5.2.1: version "5.3.0" @@ -11308,11 +11724,6 @@ neo-async@^2.5.0, neo-async@^2.6.0, neo-async@^2.6.1: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c" integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw== -next-tick@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" - integrity sha1-yobR/ogoFpsBICCOPchCS524NCw= - nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" @@ -11325,10 +11736,10 @@ no-case@^2.2.0: dependencies: lower-case "^1.1.1" -node-addon-api@^1.7.1: - version "1.7.1" - resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.7.1.tgz#cf813cd69bb8d9100f6bdca6755fc268f54ac492" - integrity sha512-2+DuKodWvwRTrCfKOeR24KIc5unKjOh8mz17NCzVnHWfjAdDqbfbjqh7gUT+BkXBRQM52+xCHciKWonJ3CbJMQ== +node-addon-api@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.0.2.tgz#04bc7b83fd845ba785bb6eae25bc857e1ef75681" + integrity sha512-+D4s2HCnxPd5PjjI0STKwncjXTUKKqm74MDMz9OPXavjsGmjkvwgLtA5yoxJUdmpj52+2u+RrXgPipahKczMKg== node-fetch@^1.0.1: version "1.7.3" @@ -11338,15 +11749,15 @@ node-fetch@^1.0.1: encoding "^0.1.11" is-stream "^1.0.1" -node-fetch@^2.1.2: - version "2.6.0" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" - integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== +node-fetch@^2.6.0, node-fetch@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== -node-forge@0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579" - integrity sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ== +node-forge@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" + integrity sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA== node-gyp@^3.8.0: version "3.8.0" @@ -11600,8 +12011,8 @@ npm-run-path@^2.0.0: npm-run-path@^4.0.0: version "4.0.1" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" - integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + resolved "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz" + integrity "sha1-t+zR5e1T2o43pV4cImnguX7XSOo= sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==" dependencies: path-key "^3.0.0" @@ -11679,6 +12090,11 @@ object-inspect@^1.7.0: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67" integrity sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw== +object-inspect@^1.8.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.9.0.tgz#c90521d74e1127b67266ded3394ad6116986533a" + integrity sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw== + object-is@^1.0.1, object-is@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.0.2.tgz#6b80eb84fe451498f65007982f035a5b445edec4" @@ -11724,6 +12140,16 @@ object.assign@^4.1.0: has-symbols "^1.0.0" object-keys "^1.0.11" +object.assign@^4.1.1, object.assign@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940" + integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + has-symbols "^1.0.1" + object-keys "^1.1.1" + object.entries@^1.1.0, object.entries@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.1.tgz#ee1cf04153de02bb093fec33683900f57ce5399b" @@ -11734,6 +12160,16 @@ object.entries@^1.1.0, object.entries@^1.1.1: function-bind "^1.1.1" has "^1.0.3" +object.entries@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.3.tgz#c601c7f168b62374541a07ddbd3e2d5e4f7711a6" + integrity sha512-ym7h7OZebNS96hn5IJeyUmaWhaSM4SVtAPPfNLQEI2MYWCO2egsITb9nab2+i/Pwibx+R0mtn+ltKJXRSeTMGg== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + es-abstract "^1.18.0-next.1" + has "^1.0.3" + object.fromentries@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.2.tgz#4a09c9b9bb3843dd0f89acdb517a794d4f355ac9" @@ -11893,13 +12329,6 @@ ora@^2.1.0: strip-ansi "^4.0.0" wcwidth "^1.0.1" -ordered-read-streams@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/ordered-read-streams/-/ordered-read-streams-1.0.1.tgz#77c0cb37c41525d64166d990ffad7ec6a0e1363e" - integrity sha1-d8DLN8QVJdZBZtmQ/61+xqDhNj4= - dependencies: - readable-stream "^2.0.1" - original@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/original/-/original-1.0.2.tgz#e442a61cffe1c5fd20a65f3261c26663b303f25f" @@ -12011,8 +12440,8 @@ p-finally@^1.0.0: p-finally@^2.0.0: version "2.0.1" - resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-2.0.1.tgz#bd6fcaa9c559a096b680806f4d657b3f0f240561" - integrity sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw== + resolved "https://registry.npmjs.org/p-finally/-/p-finally-2.0.1.tgz" + integrity "sha1-vW/KqcVZoJa2gIBvTWV7Pw8kBWE= sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==" p-is-promise@^1.1.0: version "1.1.0" @@ -12054,8 +12483,8 @@ p-locate@^3.0.0: p-locate@^4.1.0: version "4.1.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" - integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + resolved "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz" + integrity "sha1-o0KLtwiLOmApL2aRkni3wpetTwc= sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==" dependencies: p-limit "^2.2.0" @@ -12129,16 +12558,16 @@ package-json@^6.3.0: registry-url "^5.0.0" semver "^6.2.0" -pako@^1.0.11, pako@~1.0.5: - version "1.0.11" - resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" - integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== - pako@~0.2.0: version "0.2.9" resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" integrity sha1-8/dSL073gjSNqBYbrZ7P1Rv4OnU= +pako@~1.0.2, pako@~1.0.5: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + parallel-transform@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.2.0.tgz#9049ca37d6cb2182c3b1d2c720be94d14a5814fc" @@ -12211,6 +12640,16 @@ parse-json@^4.0.0: error-ex "^1.3.1" json-parse-better-errors "^1.0.1" +parse-json@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.1.0.tgz#f96088cdf24a8faa9aea9a009f2d9d942c999646" + integrity sha512-+mi/lmVVNKFNVyLXV31ERiy2CY5E1/F6QtJFEzoChPRwwngMNXRDQ9GJ5WdE2Z2P4AujsOi0/+2qHID68KwfIQ== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + parse-passwd@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" @@ -12243,6 +12682,24 @@ pascalcase@^0.1.1: resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= +patch-package@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-6.2.2.tgz#71d170d650c65c26556f0d0fbbb48d92b6cc5f39" + integrity sha512-YqScVYkVcClUY0v8fF0kWOjDYopzIM8e3bj/RU1DPeEF14+dCGm6UeOYm4jvCyxqIEQ5/eJzmbWfDWnUleFNMg== + dependencies: + "@yarnpkg/lockfile" "^1.1.0" + chalk "^2.4.2" + cross-spawn "^6.0.5" + find-yarn-workspace-root "^1.2.1" + fs-extra "^7.0.1" + is-ci "^2.0.0" + klaw-sync "^6.0.0" + minimist "^1.2.0" + rimraf "^2.6.3" + semver "^5.6.0" + slash "^2.0.0" + tmp "^0.0.33" + path-browserify@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.0.tgz#a0b870729aae214005b7d5032ec2cbbb0fb4451a" @@ -12272,8 +12729,8 @@ path-exists@^3.0.0: path-exists@^4.0.0: version "4.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" - integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz" + integrity "sha1-UTvb4tO5XXdi6METfvoZXGxhtbM= sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" path-is-absolute@^1.0.0, path-is-absolute@^1.0.1: version "1.0.1" @@ -12400,6 +12857,11 @@ picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.0.7: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.1.tgz#21bac888b6ed8601f831ce7816e335bc779f0a4a" integrity sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA== +picomatch@^2.2.1: + version "2.2.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" + integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== + pify@^2.0.0, pify@^2.2.0, pify@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -12503,7 +12965,7 @@ pkg-up@^2.0.0: dependencies: find-up "^2.1.0" -pkg-up@^3.0.1: +pkg-up@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-3.1.0.tgz#100ec235cc150e4fd42519412596a28512a0def5" integrity sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA== @@ -12538,13 +13000,13 @@ popper.js@^1.12.9, popper.js@^1.14.1, popper.js@^1.14.4, popper.js@^1.15.0: integrity sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ== portfinder@^1.0.25: - version "1.0.25" - resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.25.tgz#254fd337ffba869f4b9d37edc298059cb4d35eca" - integrity sha512-6ElJnHBbxVA1XSLgBp7G1FiCkQdlqGzuF7DswL5tcea+E8UpuvPU7beVAjjRwCioTS9ZluNbu+ZyRvgTsmqEBg== + version "1.0.28" + resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778" + integrity sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA== dependencies: async "^2.6.2" debug "^3.1.1" - mkdirp "^0.5.1" + mkdirp "^0.5.5" posix-character-classes@^0.1.0: version "0.1.1" @@ -13035,12 +13497,22 @@ pretty-format@^25.1.0: ansi-styles "^4.0.0" react-is "^16.12.0" +pretty-format@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-25.5.0.tgz#7873c1d774f682c34b8d48b6743a2bf2ac55791a" + integrity sha512-kbo/kq2LQ/A/is0PQwsEHM7Ca6//bGPPvU6UnsdDRSKTWxT/ru/xb88v4BJf6a69H+uTytOEsTusT9ksd/1iWQ== + dependencies: + "@jest/types" "^25.5.0" + ansi-regex "^5.0.0" + ansi-styles "^4.0.0" + react-is "^16.12.0" + private@^0.1.6, private@^0.1.8: version "0.1.8" resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg== -process-nextick-args@^2.0.0, process-nextick-args@~2.0.0: +process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== @@ -13058,12 +13530,7 @@ progress-stream@^1.1.0: speedometer "~0.1.2" through2 "~0.2.3" -progress@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz#8a1be366bf8fc23db2bd23f10c6fe920b4389d1f" - integrity sha1-ihvjZr+Pwj2yvSPxDG/pILQ4nR8= - -progress@^2.0.0: +progress@^2.0.0, progress@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== @@ -13097,7 +13564,7 @@ prop-types-exact@^1.2.0: object.assign "^4.1.0" reflect.ownkeys "^0.2.0" -prop-types@*, prop-types@15, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@*, prop-types@15, prop-types@>=15.7.2, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -13112,9 +13579,9 @@ proto-list@~1.2.1: integrity sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk= protobufjs@^6.8.8: - version "6.8.8" - resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.8.8.tgz#c8b4f1282fd7a90e6f5b109ed11c84af82908e7c" - integrity sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw== + version "6.10.2" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.10.2.tgz#b9cb6bd8ec8f87514592ba3fdfd28e93f33a469b" + integrity "sha1-uctr2OyPh1FFkro/39KOk/M6Rps= sha512-27yj+04uF6ya9l+qfpH187aqEzfCF4+Uit0I9ZBQVqK09hk/SQzKa2MUqUpXaVa7LOFRg1TSSr3lVxGOk6c0SQ==" dependencies: "@protobufjs/aspromise" "^1.1.2" "@protobufjs/base64" "^1.1.2" @@ -13126,8 +13593,8 @@ protobufjs@^6.8.8: "@protobufjs/path" "^1.1.2" "@protobufjs/pool" "^1.1.0" "@protobufjs/utf8" "^1.1.0" - "@types/long" "^4.0.0" - "@types/node" "^10.1.0" + "@types/long" "^4.0.1" + "@types/node" "^13.7.0" long "^4.0.0" protocol-buffers-schema@^3.1.1, protocol-buffers-schema@^3.3.1: @@ -13143,10 +13610,10 @@ proxy-addr@~2.0.5: forwarded "~0.1.2" ipaddr.js "1.9.0" -proxy-from-env@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee" - integrity sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4= +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== prr@~1.0.1: version "1.0.1" @@ -13191,7 +13658,7 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" -pumpify@^1.3.3, pumpify@^1.3.5: +pumpify@^1.3.3: version "1.5.1" resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce" integrity sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ== @@ -13215,11 +13682,6 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -pupa@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/pupa/-/pupa-1.0.0.tgz#9a9568a5af7e657b8462a6e9d5328743560ceff6" - integrity sha1-mpVopa9+ZXuEYqbp1TKHQ1YM7/Y= - pupa@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.0.1.tgz#dbdc9ff48ffbea4a26a069b6f9f7abb051008726" @@ -13284,7 +13746,7 @@ quick-format-unescaped@^3.0.3: resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-3.0.3.tgz#fb3e468ac64c01d22305806c39f121ddac0d1fb9" integrity sha512-dy1yjycmn9blucmJLXOfZDx1ikZJUi6E8bBZLnhPG5gBrVhHXx2xVyqqgKBubVNEXmx51dBACMHpoMQK/N/AXQ== -raf@^3.0.0, raf@^3.1.0, raf@^3.4.1: +raf@^3.1.0, raf@^3.4.1: version "3.4.1" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== @@ -13436,6 +13898,11 @@ react-is@^16.12.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.6, react-i resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q== +react-is@^16.13.1: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + react-is@^16.6.0: version "16.13.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.0.tgz#0f37c3613c34fe6b37cd7f763a0d6293ab15c527" @@ -13492,55 +13959,54 @@ react-popper@^1.3.6, react-popper@^1.3.7: typed-styles "^0.0.7" warning "^4.0.2" -react-redux@^7.1.1: - version "7.2.0" - resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.0.tgz#f970f62192b3981642fec46fd0db18a074fe879d" - integrity sha512-EvCAZYGfOLqwV7gh849xy9/pt55rJXPwmYvI4lilPM5rUT/1NxuuN59ipdBksRVSvz0KInbPnp4IfoXJXCqiDA== +react-redux@^7.2.2: + version "7.2.2" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.2.tgz#03862e803a30b6b9ef8582dadcc810947f74b736" + integrity sha512-8+CQ1EvIVFkYL/vu6Olo7JFLWop1qRUeb46sGtIMDCSpgwPQq8fPLpirIB0iTqFe9XYEFPHssdX8/UwN6pAkEA== dependencies: - "@babel/runtime" "^7.5.5" - hoist-non-react-statics "^3.3.0" + "@babel/runtime" "^7.12.1" + hoist-non-react-statics "^3.3.2" loose-envify "^1.4.0" prop-types "^15.7.2" - react-is "^16.9.0" + react-is "^16.13.1" -react-router-dom@^5.0.1: - version "5.1.2" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.1.2.tgz#06701b834352f44d37fbb6311f870f84c76b9c18" - integrity sha512-7BPHAaIwWpZS074UKaw1FjVdZBSVWEk8IuDXdB+OkLb8vd/WRQIpA4ag9WQk61aEfQs47wHyjWUoUGGZxpQXew== +react-router-dom@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662" + integrity sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA== dependencies: "@babel/runtime" "^7.1.2" history "^4.9.0" loose-envify "^1.3.1" prop-types "^15.6.2" - react-router "5.1.2" + react-router "5.2.0" tiny-invariant "^1.0.2" tiny-warning "^1.0.0" -react-router@5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.1.2.tgz#6ea51d789cb36a6be1ba5f7c0d48dd9e817d3418" - integrity sha512-yjEuMFy1ONK246B+rsa0cUam5OeAQ8pyclRDgpxuSCrAlJ1qN9uZ5IgyKC7gQg0w8OM50NXHEegPh/ks9YuR2A== +react-router@5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293" + integrity sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw== dependencies: "@babel/runtime" "^7.1.2" history "^4.9.0" hoist-non-react-statics "^3.1.0" loose-envify "^1.3.1" - mini-create-react-context "^0.3.0" + mini-create-react-context "^0.4.0" path-to-regexp "^1.7.0" prop-types "^15.6.2" react-is "^16.6.0" tiny-invariant "^1.0.2" tiny-warning "^1.0.0" -react-stickynode@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/react-stickynode/-/react-stickynode-2.1.1.tgz#1d5160641c72b1a954c872b26130919cb1ce4a78" - integrity sha512-yzY1mE8QYCdrg4zpmHYfe5ZaE4VF63VpbgboLntdKnQG98bNlGDXDY9vpt/3O4uIEoddeL0zBtXU2I0LyLMzFw== +react-sticky-el@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/react-sticky-el/-/react-sticky-el-2.0.5.tgz#db942dd3ed35a29429e92a947515a129c02d7e76" + integrity sha512-Tj+Cy6xdmiPR1li9Wu2KmZ2CxW7C/q1H3ls9HKqNnA1zPysB8DzBXDyEoAJDXh08TDyk4pf/4VftWKW4DrOEpA== dependencies: - classnames "^2.0.0" - prop-types "^15.6.0" - shallowequal "^1.0.0" - subscribe-ui-event "^2.0.0" + eslint-plugin-react "^7.19.0" + flow-bin "^0.120.1" + prop-types ">=15.7.2" react-test-renderer@^16.0.0-0: version "16.12.0" @@ -13650,6 +14116,17 @@ read-config-file@5.0.1: json5 "^2.1.1" lazy-val "^1.0.4" +read-config-file@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/read-config-file/-/read-config-file-6.0.0.tgz#224b5dca6a5bdc1fb19e63f89f342680efdb9299" + integrity sha512-PHjROSdpceKUmqS06wqwP92VrM46PZSTubmNIMJ5DrMwg1OgenSTSEHIkCa6TiOJ+y/J0xnG1fFwG3M+Oi1aNA== + dependencies: + dotenv "^8.2.0" + dotenv-expand "^5.1.0" + js-yaml "^3.13.1" + json5 "^2.1.2" + lazy-val "^1.0.4" + read-pkg-up@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" @@ -13666,6 +14143,15 @@ read-pkg-up@^2.0.0: find-up "^2.0.0" read-pkg "^2.0.0" +read-pkg-up@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507" + integrity sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg== + dependencies: + find-up "^4.1.0" + read-pkg "^5.2.0" + type-fest "^0.8.1" + read-pkg@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28" @@ -13693,7 +14179,17 @@ read-pkg@^4.0.1: parse-json "^4.0.0" pify "^3.0.0" -"readable-stream@1 || 2", "readable-stream@> 1.0.0 < 3.0.0", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.0, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: +read-pkg@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc" + integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg== + dependencies: + "@types/normalize-package-data" "^2.4.0" + normalize-package-data "^2.5.0" + parse-json "^5.0.0" + type-fest "^0.6.0" + +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.0, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== @@ -13741,12 +14237,10 @@ readdirp@~3.3.0: dependencies: picomatch "^2.0.7" -realpath-native@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.1.0.tgz#2003294fea23fb0672f2476ebe22fcf498a2d65c" - integrity sha512-wlgPA6cCIIg9gKz0fgAPjnzh4yR/LnXovwuo9hvyGvx3h8nX4+/iLZplfUWasXpqD8BdnGnP5njOFjkUwPzvjA== - dependencies: - util.promisify "^1.0.0" +realpath-native@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-2.0.0.tgz#7377ac429b6e1fd599dc38d08ed942d0d7beb866" + integrity sha512-v1SEYUOXXdbBZK8ZuNgO4TBjamPsiSgcFr0aP+tEKpQZK8vooEUqV6nm6Cv502mX4NF2EfsnVqtNAHG+/6Ur1Q== redent@^1.0.0: version "1.0.0" @@ -13781,16 +14275,6 @@ redux-observable@^1.2.0: resolved "https://registry.yarnpkg.com/redux-observable/-/redux-observable-1.2.0.tgz#ff51b6c6be2598e9b5e89fc36639186bb0e669c7" integrity sha512-yeR90RP2WzZzCxxnQPlh2uFzyfFLsfXu8ROh53jGDPXVqj71uNDMmvi/YKQkd9ofiVoO4OYb1snbowO49tCEMg== -redux-promise-middleware@^6.1.1: - version "6.1.2" - resolved "https://registry.yarnpkg.com/redux-promise-middleware/-/redux-promise-middleware-6.1.2.tgz#1c14222686934be243cbb292e348ef7d5b20d6d2" - integrity sha512-ZqZu/nnSzGgwTtNbGoGVontpk7LjTOv0kigtt3CcgXI9gpq+8WlfXTXRZD0WTD5yaohRq0q2nYmJXSTjwXs83Q== - -redux-thunk@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" - integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== - redux-watcher@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/redux-watcher/-/redux-watcher-1.0.1.tgz#06ff07a3cdae63389f285d74c283761fe1977365" @@ -13808,7 +14292,7 @@ redux@^3.6.0: loose-envify "^1.1.0" symbol-observable "^1.0.3" -redux@^4.0.0, redux@^4.0.1, redux@^4.0.4: +redux@^4.0.0, redux@^4.0.5: version "4.0.5" resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== @@ -13947,11 +14431,6 @@ regjsparser@^0.6.0: dependencies: jsesc "~0.5.0" -reinterval@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/reinterval/-/reinterval-1.1.0.tgz#3361ecfa3ca6c18283380dd0bb9546f390f5ece7" - integrity sha1-M2Hs+jymwYKDOA3Qu5VG85D17Oc= - relateurl@0.2.x: version "0.2.7" resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" @@ -14070,8 +14549,8 @@ require-main-filename@^1.0.1: require-main-filename@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" - integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + resolved "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz" + integrity "sha1-0LMp7MfMD2Fkn2IhW+aa9UqomJs= sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" requires-port@^1.0.0: version "1.0.0" @@ -14102,8 +14581,8 @@ resolve-cwd@^2.0.0: resolve-cwd@^3.0.0: version "3.0.0" - resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" - integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== + resolved "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz" + integrity "sha1-DwB18bslRHZs9zumpuKt/ryxPy0= sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==" dependencies: resolve-from "^5.0.0" @@ -14127,8 +14606,8 @@ resolve-from@^4.0.0: resolve-from@^5.0.0: version "5.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" - integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz" + integrity "sha1-w1IlhD3493bfIcV1V7wIfp39/Gk= sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==" resolve-pathname@^3.0.0: version "3.0.0" @@ -14166,6 +14645,14 @@ resolve@^1.15.1: dependencies: path-parse "^1.0.6" +resolve@^1.17.0, resolve@^1.18.1: + version "1.19.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.19.0.tgz#1af5bf630409734a067cae29318aac7fa29a267c" + integrity "sha1-GvW/YwQJc0oGfK4pMYqsf6KaJnw= sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==" + dependencies: + is-core-module "^2.1.0" + path-parse "^1.0.6" + responselike@1.0.2, responselike@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" @@ -14202,11 +14689,6 @@ ret@~0.1.10: resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== -retry-axios@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/retry-axios/-/retry-axios-2.1.1.tgz#6feaee38911ff736c2c772d8202bca2f27e725a0" - integrity sha512-xNpecrtnEjfDBZm0/Pue77asi8XKXu79lA2PRCtmC/qVh3+3oIFi85+X7jPOvpct7TIjSWY+kaU8dPfpimDxbg== - retry@^0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" @@ -14239,7 +14721,7 @@ right-align@^0.1.1: dependencies: align-text "^0.1.1" -rimraf@2, rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6.2, rimraf@^2.6.3, rimraf@^2.7.1: +rimraf@2, rimraf@^2.5.4, rimraf@^2.6.2, rimraf@^2.6.3, rimraf@^2.7.1: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== @@ -14253,7 +14735,7 @@ rimraf@2.6.3: dependencies: glob "^7.1.3" -rimraf@^3.0.0: +rimraf@^3.0.0, rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== @@ -14273,12 +14755,12 @@ ripemd160@^2.0.0, ripemd160@^2.0.1: hash-base "^3.0.0" inherits "^2.0.1" -roarr@^2.15.2: - version "2.15.2" - resolved "https://registry.yarnpkg.com/roarr/-/roarr-2.15.2.tgz#34f6229ae3c8c12167c4ae60f58fe75e79a1e394" - integrity sha512-jmaDhK9CO4YbQAV8zzCnq9vjAqeO489MS5ehZ+rXmFiPFFE6B+S9KYO6prjmLJ5A0zY3QxVlQdrIya7E/azz/Q== +roarr@^2.15.3: + version "2.15.4" + resolved "https://registry.yarnpkg.com/roarr/-/roarr-2.15.4.tgz#f5fe795b7b838ccfe35dc608e0282b9eba2e7afd" + integrity "sha1-9f55W3uDjM/jXcYI4Cgrnrouev0= sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==" dependencies: - boolean "^3.0.0" + boolean "^3.0.1" detect-node "^2.0.4" globalthis "^1.0.1" json-stringify-safe "^5.0.1" @@ -14341,19 +14823,26 @@ rx-lite@*, rx-lite@^4.0.8: resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444" integrity sha1-Cx4Rr4vESDbwSmQH6S2kJGe3lEQ= -rxjs@^6.5.2, rxjs@^6.5.3, rxjs@^6.5.4: +rxjs@^6.5.2, rxjs@^6.5.3: version "6.5.4" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.4.tgz#e0777fe0d184cec7872df147f303572d414e211c" integrity sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q== dependencies: tslib "^1.9.0" -safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: +rxjs@^6.6.3: + version "6.6.3" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552" + integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ== + dependencies: + tslib "^1.9.0" + +safe-buffer@5.1.2, safe-buffer@^5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: +safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== @@ -14365,7 +14854,7 @@ safe-regex@^1.1.0: dependencies: ret "~0.1.10" -"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -14487,11 +14976,11 @@ scss-tokenizer@^0.2.3: source-map "^0.4.2" seek-bzip@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/seek-bzip/-/seek-bzip-1.0.5.tgz#cfe917cb3d274bcffac792758af53173eb1fabdc" - integrity sha1-z+kXyz0nS8/6x5J1ivUxc+sfq9w= + version "1.0.6" + resolved "https://registry.yarnpkg.com/seek-bzip/-/seek-bzip-1.0.6.tgz#35c4171f55a680916b52a07859ecf3b5857f21c4" + integrity "sha1-NcQXH1WmgJFrUqB4WezztYV/IcQ= sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==" dependencies: - commander "~2.8.1" + commander "^2.8.1" select-hose@^2.0.0: version "2.0.0" @@ -14499,11 +14988,11 @@ select-hose@^2.0.0: integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= selfsigned@^1.10.7: - version "1.10.7" - resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-1.10.7.tgz#da5819fd049d5574f28e88a9bcc6dbc6e6f3906b" - integrity sha512-8M3wBCzeWIJnQfl43IKwOmC4H/RAp50S8DF60znzjW5GVqTcSe2vWclt7hmYVPkKPlHWOu5EaWOMZ2Y6W8ZXTA== + version "1.10.8" + resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-1.10.8.tgz#0d17208b7d12c33f8eac85c41835f27fc3d81a30" + integrity sha512-2P4PtieJeEwVgTU9QEcwIRDQ/mXJLX8/+I3ur+Pg16nS8oNbrGxEso9NyYWy8NAmXiNl4dlAp5MwoNeCWzON4w== dependencies: - node-forge "0.9.0" + node-forge "^0.10.0" semver-compare@^1.0.0: version "1.0.0" @@ -14544,7 +15033,7 @@ semver@^6.0.0, semver@^6.1.0, semver@^6.2.0, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== -semver@^7.1.1, semver@^7.1.2, semver@^7.1.3: +semver@^7.1.1: version "7.1.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.1.3.tgz#e4345ce73071c53f336445cfc19efb1c311df2a6" integrity sha512-ekM0zfiA9SCBlsKa2X1hxyxiI4L3B6EbVJkkdgQXnSEEaHlGdvyodMruTiulSRWMMB4NeIuYNMC9rTKTz97GxA== @@ -14578,13 +15067,6 @@ send@0.17.1: range-parser "~1.2.1" statuses "~1.5.0" -serialize-error@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/serialize-error/-/serialize-error-5.0.0.tgz#a7ebbcdb03a5d71a6ed8461ffe0fc1a1afed62ac" - integrity sha512-/VtpuyzYf82mHYTtI4QKtwHa79vAdU5OQpNPAmE/0UDdlGT0ZxHwC+J6gXkw29wwoVI8fMPsfcVHOwXtUQYYQA== - dependencies: - type-fest "^0.8.0" - serialize-error@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/serialize-error/-/serialize-error-6.0.0.tgz#ccfb887a1dd1c48d6d52d7863b92544331fd752b" @@ -14592,6 +15074,13 @@ serialize-error@^6.0.0: dependencies: type-fest "^0.12.0" +serialize-error@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/serialize-error/-/serialize-error-7.0.1.tgz#f1360b0447f61ffb483ec4157c737fab7d778e18" + integrity "sha1-8TYLBEf2H/tIPsQVfHN/q313jhg= sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==" + dependencies: + type-fest "^0.13.1" + serialize-javascript@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61" @@ -14630,6 +15119,11 @@ set-harmonic-interval@^1.0.1: resolved "https://registry.yarnpkg.com/set-harmonic-interval/-/set-harmonic-interval-1.0.1.tgz#e1773705539cdfb80ce1c3d99e7f298bb3995249" integrity sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g== +set-immediate-shim@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61" + integrity sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E= + set-value@^2.0.0, set-value@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" @@ -14675,7 +15169,7 @@ shallow-clone@^3.0.0: dependencies: kind-of "^6.0.2" -shallowequal@^1.0.0, shallowequal@^1.1.0: +shallowequal@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== @@ -14892,6 +15386,14 @@ source-map-support@^0.5.11, source-map-support@^0.5.16, source-map-support@^0.5. buffer-from "^1.0.0" source-map "^0.6.0" +source-map-support@^0.5.19: + version "0.5.19" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" + integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + source-map-url@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" @@ -15012,13 +15514,6 @@ split-string@^3.0.1, split-string@^3.0.2: dependencies: extend-shallow "^3.0.0" -split2@^2.1.1: - version "2.2.0" - resolved "https://registry.yarnpkg.com/split2/-/split2-2.2.0.tgz#186b2575bcf83e85b7d18465756238ee4ee42493" - integrity sha512-RAb22TG39LhI31MbreBgIuKiIKhVsawfTgEGqKHTK87aG+ul/PB8Sqoi3I7kVdRWiCfrKxK3uo4/YUkpNvhPbw== - dependencies: - through2 "^2.0.2" - split2@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/split2/-/split2-3.1.1.tgz#c51f18f3e06a8c4469aaab487687d8d956160bb6" @@ -15220,7 +15715,7 @@ string-length@^3.1.0: astral-regex "^1.0.0" strip-ansi "^5.2.0" -string-width@^1.0.1, string-width@^1.0.2: +string-width@^1.0.1, string-width@^1.0.2, "string-width@^1.0.2 || 2": version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= @@ -15229,7 +15724,7 @@ string-width@^1.0.1, string-width@^1.0.2: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1: +string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== @@ -15276,6 +15771,14 @@ string.prototype.trim@^1.2.1: es-abstract "^1.17.0-next.1" function-bind "^1.1.1" +string.prototype.trimend@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.3.tgz#a22bd53cca5c7cf44d7c9d5c732118873d6cd18b" + integrity sha512-ayH0pB+uf0U28CtjlLvL7NaohvR1amUvVZk+y3DYb0Ey2PUV5zPkkKy9+U1ndVEIXO8hNg18eIv9Jntbii+dKw== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + string.prototype.trimleft@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz#9bdb8ac6abd6d602b17a4ed321870d2f8dcefc74" @@ -15292,6 +15795,14 @@ string.prototype.trimright@^2.1.1: define-properties "^1.1.3" function-bind "^1.1.1" +string.prototype.trimstart@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.3.tgz#9b4cb590e123bb36564401d59824298de50fd5aa" + integrity sha512-oBIBUy5lea5tt0ovtOFiEQaBkoBBkyJhZXzJYrSmDo5IUUqbOPvVezuRs/agBIdZ2p2Eo1FD6bD9USyBLfl3xg== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + string_decoder@^0.10.25, string_decoder@~0.10.x: version "0.10.31" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" @@ -15353,8 +15864,8 @@ strip-bom@^3.0.0: strip-bom@^4.0.0: version "4.0.0" - resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" - integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== + resolved "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz" + integrity "sha1-nDUFwdtFvO3KPZz3oW9cWqOQGHg= sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==" strip-dirs@^2.0.0: version "2.1.0" @@ -15443,15 +15954,6 @@ stylis@^3.4.0: resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.4.tgz#f665f25f5e299cf3d64654ab949a57c768b73fbe" integrity sha512-8/3pSmthWM7lsPBKv7NXkzn2Uc9W7NotcwGNpJaa3k7WMM1XDCA4MgT5k/8BIexd5ydZdboXtU90XH9Ec4Bv/Q== -subscribe-ui-event@^2.0.0: - version "2.0.5" - resolved "https://registry.yarnpkg.com/subscribe-ui-event/-/subscribe-ui-event-2.0.5.tgz#f276d8eae3f82b640e69d7b9d55f7334f9ec67ec" - integrity sha512-1DasqBzDgTbkj2Yu0F8atPB6ouvvM5EdUm6zPONO8IZXVr97ommJFTp1yX/HMW5qYw/nc0huJ7r6VkeqixAdgA== - dependencies: - eventemitter3 "^3.0.0" - lodash "^4.17.10" - raf "^3.0.0" - sumchecker@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/sumchecker/-/sumchecker-2.0.2.tgz#0f42c10e5d05da5d42eea3e56c3399a37d6c5b3e" @@ -15528,7 +16030,7 @@ supports-hyperlinks@^2.0.0: has-flag "^4.0.0" supports-color "^7.0.0" -svgo@^1.0.0, svgo@^1.0.5: +svgo@^1.0.0, svgo@^1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167" integrity sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw== @@ -15625,6 +16127,14 @@ temp-file@^3.3.6: async-exit-hook "^2.0.1" fs-extra "^8.1.0" +temp-file@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/temp-file/-/temp-file-3.3.7.tgz#686885d635f872748e384e871855958470aeb18a" + integrity sha512-9tBJKt7GZAQt/Rg0QzVWA8Am8c1EFl+CAv04/aBVqlx5oyfQ508sFIABshQ0xbZu6mBrFLWIUXO/bbLYghW70g== + dependencies: + async-exit-hook "^2.0.1" + fs-extra "^8.1.0" + tempfile@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/tempfile/-/tempfile-2.0.0.tgz#6b0446856a9b1114d1856ffcbe509cccb0977265" @@ -15734,15 +16244,7 @@ throttleit@0.0.2: resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-0.0.2.tgz#cfedf88e60c00dd9697b61fdd2a8343a9b680eaf" integrity sha1-z+34jmDADdlpe2H90qg0OptoDq8= -through2-filter@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254" - integrity sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA== - dependencies: - through2 "~2.0.0" - xtend "~4.0.0" - -through2@^2.0.0, through2@^2.0.1, through2@^2.0.2, through2@~2.0.0: +through2@^2.0.0: version "2.0.5" resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== @@ -15803,7 +16305,7 @@ tiny-invariant@^1.0.2: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== -tiny-warning@^1.0.0, tiny-warning@^1.0.2: +tiny-warning@^1.0.0, tiny-warning@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== @@ -15835,14 +16337,6 @@ tmpl@1.0.x: resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= -to-absolute-glob@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz#1865f43d9e74b0822db9f145b78cff7d0f7c849b" - integrity sha1-GGX0PZ50sIItufFFt4z/fQ98hJs= - dependencies: - is-absolute "^1.0.0" - is-negated-glob "^1.0.0" - to-arraybuffer@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" @@ -16165,17 +16659,27 @@ type-fest@^0.12.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.12.0.tgz#f57a27ab81c68d136a51fd71467eff94157fa1ee" integrity sha512-53RyidyjvkGpnWPMF9bQgFtWp+Sl8O2Rp13VavmJgfAP9WWG6q6TkrKU8iyJdnwnfgHI6k2hTlgqH4aSdjoTbg== +type-fest@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.13.1.tgz#0172cb5bce80b0bd542ea348db50c7e21834d934" + integrity sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg== + +type-fest@^0.16.0: + version "0.16.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.16.0.tgz#3240b891a78b0deae910dbeb86553e552a148860" + integrity sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg== + type-fest@^0.5.1: version "0.5.2" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.5.2.tgz#d6ef42a0356c6cd45f49485c3b6281fc148e48a2" integrity sha512-DWkS49EQKVX//Tbupb9TFa19c7+MK1XmzkrZUR8TAktmE/DizXoaoJV6TZ/tSIPXipqNiRI6CyAe7x69Jb6RSw== -type-fest@^0.7.1: - version "0.7.1" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.7.1.tgz#8dda65feaf03ed78f0a3f9678f1869147f7c5c48" - integrity sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg== +type-fest@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" + integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg== -type-fest@^0.8.0, type-fest@^0.8.1: +type-fest@^0.8.1: version "0.8.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== @@ -16188,16 +16692,6 @@ type-is@~1.6.17, type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" -type@^1.0.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/type/-/type-1.2.0.tgz#848dd7698dafa3e54a6c479e759c4bc3f18847a0" - integrity sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg== - -type@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/type/-/type-2.0.0.tgz#5f16ff6ef2eb44f260494dae271033b29c09a9c3" - integrity sha512-KBt58xCHry4Cejnc2ISQAF7QY+ORngsWfxezO68+12hKV6lQY8P/psIkcbjeHWn7MqcgciWJyCCevFMJdIXpow== - typed-styles@^0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/typed-styles/-/typed-styles-0.0.7.tgz#93392a008794c4595119ff62dde6809dbc40a3d9" @@ -16220,10 +16714,10 @@ typesafe-actions@^5.1.0: resolved "https://registry.yarnpkg.com/typesafe-actions/-/typesafe-actions-5.1.0.tgz#9afe8b1e6a323af1fd59e6a57b11b7dd6623d2f1" integrity sha512-bna6Yi1pRznoo6Bz1cE6btB/Yy8Xywytyfrzu/wc+NFW3ZF0I+2iCGImhBsoYYCOWuICtRO4yHcnDlzgo1AdNg== -typescript@^3.8.3: - version "3.8.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061" - integrity sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w== +typescript@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.2.tgz#6369ef22516fe5e10304aae5a5c4862db55380e9" + integrity sha512-thGloWsGH3SOxv1SoY7QojKi0tc+8FnOmiarEGMbd/lar7QOEd3hvlx3Fp5y6FlDUGl9L+pd4n2e+oToGMmhRQ== typings-for-css-modules-loader@^1.7.0: version "1.7.0" @@ -16262,24 +16756,14 @@ uglify-to-browserify@~1.0.0: resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" integrity sha1-bgkk1r2mta/jSeOabWMoUKD4grc= -ultron@~1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c" - integrity sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og== - unbzip2-stream@^1.0.9: - version "1.3.3" - resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.3.3.tgz#d156d205e670d8d8c393e1c02ebd506422873f6a" - integrity sha512-fUlAF7U9Ah1Q6EieQ4x4zLNejrRvDWUYmxXUpN3uziFYCHapjWFaCAnreY9bGgxzaMCFAPPpYNng57CypwJVhg== + version "1.4.3" + resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7" + integrity "sha1-sNoExDcTEd93HNwhXofyEwmRrOc= sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==" dependencies: buffer "^5.2.1" through "^2.3.8" -unc-path-regex@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" - integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo= - underscore@~1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.6.0.tgz#8b38b10cacdef63337b8b24e4ff86d45aea529a8" @@ -16362,14 +16846,6 @@ unique-slug@^2.0.0: dependencies: imurmurhash "^0.1.4" -unique-stream@^2.0.2: - version "2.3.1" - resolved "https://registry.yarnpkg.com/unique-stream/-/unique-stream-2.3.1.tgz#c65d110e9a4adf9a6c5948b28053d9a8d04cbeac" - integrity sha512-2nY4TnBE70yoxHkDli7DMazpWiP7xMdCYqU2nBRO0UB+ZpEkGsSija7MvmvnZFUeC+mrgiUfcHSr3LmRFIg4+A== - dependencies: - json-stable-stringify-without-jsonify "^1.0.1" - through2-filter "^3.0.0" - unique-string@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" @@ -16427,6 +16903,16 @@ universalify@^0.1.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== +universalify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d" + integrity sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug== + +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" @@ -16445,13 +16931,22 @@ unset-value@^1.0.0: has-value "^0.3.1" isobject "^3.0.0" -unused-filename@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unused-filename/-/unused-filename-1.0.0.tgz#d340880f71ae2115ebaa1325bef05cc6684469c6" - integrity sha1-00CID3GuIRXrqhMlvvBcxmhEacY= +unused-filename@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/unused-filename/-/unused-filename-2.1.0.tgz#33719c4e8d9644f32d2dec1bc8525c6aaeb4ba51" + integrity sha512-BMiNwJbuWmqCpAM1FqxCTD7lXF97AvfQC8Kr/DIeA6VtvhJaMDupZ82+inbjl5yVP44PcxOuCSxye1QMS0wZyg== dependencies: modify-filename "^1.1.0" - path-exists "^3.0.0" + path-exists "^4.0.0" + +unzip-crx-3@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/unzip-crx-3/-/unzip-crx-3-0.2.0.tgz#d5324147b104a8aed9ae8639c95521f6f7cda292" + integrity sha512-0+JiUq/z7faJ6oifVB5nSwt589v1KCduqIJupNVDoWSXZtWDmjDGO3RAEOvwJ07w90aoXoP4enKsR7ecMrJtWQ== + dependencies: + jszip "^3.1.0" + mkdirp "^0.5.1" + yaku "^0.16.6" upath@^1.1.1: version "1.2.0" @@ -16477,6 +16972,25 @@ update-notifier@^4.0.0: semver-diff "^3.1.1" xdg-basedir "^4.0.0" +update-notifier@^4.1.1: + version "4.1.3" + resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-4.1.3.tgz#be86ee13e8ce48fb50043ff72057b5bd598e1ea3" + integrity sha512-Yld6Z0RyCYGB6ckIjffGOSOmHXj1gMeE7aROz4MG+XMkmixBX4jUngrGXNYz7wPKBmtoD4MnBa2Anu7RSKht/A== + dependencies: + boxen "^4.2.0" + chalk "^3.0.0" + configstore "^5.0.1" + has-yarn "^2.1.0" + import-lazy "^2.1.0" + is-ci "^2.0.0" + is-installed-globally "^0.3.1" + is-npm "^4.0.0" + is-yarn-global "^0.3.0" + latest-version "^5.0.0" + pupa "^2.0.1" + semver-diff "^3.1.1" + xdg-basedir "^4.0.0" + upper-case@^1.1.1: version "1.1.3" resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598" @@ -16561,7 +17075,7 @@ util.promisify@1.0.0: define-properties "^1.1.2" object.getownpropertydescriptors "^2.0.3" -util.promisify@1.0.1, util.promisify@^1.0.0, util.promisify@~1.0.0: +util.promisify@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.0.1.tgz#6baf7774b80eeb0f7520d8b81d07982a59abbaee" integrity sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA== @@ -16607,6 +17121,11 @@ uuid@^3.0.0, uuid@^3.0.1, uuid@^3.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + v8-compile-cache@2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.0.3.tgz#00f7494d2ae2b688cfe2899df6ed2c54bef91dbe" @@ -16617,10 +17136,10 @@ v8-compile-cache@^2.0.3: resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e" integrity sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g== -v8-to-istanbul@^4.0.1: - version "4.1.2" - resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-4.1.2.tgz#387d173be5383dbec209d21af033dcb892e3ac82" - integrity sha512-G9R+Hpw0ITAmPSr47lSlc5A1uekSYzXxTMlFxso2xoffwo4jQnzbv1p9yXIinO8UMZKfAFewaCHwWvnH4Jb4Ug== +v8-to-istanbul@^4.1.3: + version "4.1.4" + resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-4.1.4.tgz#b97936f21c0e2d9996d4985e5c5156e9d4e49cd6" + integrity sha512-Rw6vJHj1mbdK8edjR7+zuJrpDtKIgNdAvTSAcpYfgMIw+u2dPDntD3dgN4XQFLU2/fvFQdzj+EeSGfd/jnY5fQ== dependencies: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^1.6.0" @@ -16999,18 +17518,6 @@ websocket-extensions@>=0.1.1: resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.3.tgz#5d2ff22977003ec687a4b87073dfbbac146ccf29" integrity sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg== -websocket-stream@^5.0.1, websocket-stream@^5.1.2: - version "5.5.0" - resolved "https://registry.yarnpkg.com/websocket-stream/-/websocket-stream-5.5.0.tgz#9827f2846fc0d2b4dca7aab8f92980b2548b868e" - integrity sha512-EXy/zXb9kNHI07TIMz1oIUIrPZxQRA8aeJ5XYg5ihV8K4kD1DuA+FY6R96HfdIHzlSzS8HiISAfrm+vVQkZBug== - dependencies: - duplexify "^3.5.1" - inherits "^2.0.1" - readable-stream "^2.3.3" - safe-buffer "^5.1.2" - ws "^3.2.0" - xtend "^4.0.0" - wgxpath@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/wgxpath/-/wgxpath-1.0.0.tgz#eef8a4b9d558cc495ad3a9a2b751597ecd9af690" @@ -17059,10 +17566,10 @@ which@1, which@^1.2.14, which@^1.2.9, which@^1.3.1: dependencies: isexe "^2.0.0" -which@^2.0.1: +which@^2.0.1, which@^2.0.2: version "2.0.2" - resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" - integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz" + integrity "sha1-fGqN0KY2oDJ+ELWckobu6T8/UbE= sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==" dependencies: isexe "^2.0.0" @@ -17156,6 +17663,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -17178,15 +17694,6 @@ write@1.0.3: dependencies: mkdirp "^0.5.1" -ws@^3.2.0: - version "3.3.3" - resolved "https://registry.yarnpkg.com/ws/-/ws-3.3.3.tgz#f1cf84fe2d5e901ebce94efaece785f187a228f2" - integrity sha512-nnWLa/NwZSt4KQJu51MYlCcSQ5g7INpOrOMt4XV8j4dqTXdmlUmSHQ8/oLC069ckre0fRsgfvsKwbTdtKLCDkA== - dependencies: - async-limiter "~1.0.0" - safe-buffer "~5.1.0" - ultron "~1.1.0" - ws@^6.0.0, ws@^6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb" @@ -17239,7 +17746,7 @@ xregexp@^4.3.0: dependencies: "@babel/runtime-corejs3" "^7.8.3" -xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1: +xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== @@ -17268,6 +17775,16 @@ y18n@^3.2.1: resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== +y18n@^5.0.5: + version "5.0.5" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.5.tgz#8769ec08d03b1ea2df2500acef561743bbb9ab18" + integrity sha512-hsRUr4FFrvhhRH12wOdfs38Gy7k2FFzB9qgN9v3aLykRq0dRcdcpz5C9FxdS2NuhOrI/628b/KSTJ3rwHysYSg== + +yaku@^0.16.6: + version "0.16.7" + resolved "https://registry.yarnpkg.com/yaku/-/yaku-0.16.7.tgz#1d195c78aa9b5bf8479c895b9504fd4f0847984e" + integrity sha1-HRlceKqbW/hHnIlblQT9TwhHmE4= + yallist@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" @@ -17306,14 +17823,19 @@ yargs-parser@^13.1.0: camelcase "^5.0.0" decamelize "^1.2.0" -yargs-parser@^16.1.0: - version "16.1.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-16.1.0.tgz#73747d53ae187e7b8dbe333f95714c76ea00ecf1" - integrity sha512-H/V41UNZQPkUMIT5h5hiwg4QKIY1RPvoBV4XcjUbRM8Bk2oKqqyZ0DIEbTFZB0XjbtSPG8SAa/0DxCQmiRgzKg== +yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== dependencies: camelcase "^5.0.0" decamelize "^1.2.0" +yargs-parser@^20.2.2: + version "20.2.4" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" + integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== + yargs-parser@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-5.0.0.tgz#275ecf0d7ffe05c77e64e7c86e4cd94bf0e1228a" @@ -17356,10 +17878,10 @@ yargs@13.2.4: y18n "^4.0.0" yargs-parser "^13.1.0" -yargs@^15.0.0, yargs@^15.1.0: - version "15.1.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.1.0.tgz#e111381f5830e863a89550bd4b136bb6a5f37219" - integrity sha512-T39FNN1b6hCW4SOIk1XyTOWxtXdcen0t+XYrysQmChzSipvhBO8Bj0nK1ozAasdk24dNWuMZvr4k24nz+8HHLg== +yargs@^15.1.0, yargs@^15.3.1: + version "15.4.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" + integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== dependencies: cliui "^6.0.0" decamelize "^1.2.0" @@ -17371,7 +17893,20 @@ yargs@^15.0.0, yargs@^15.1.0: string-width "^4.2.0" which-module "^2.0.0" y18n "^4.0.0" - yargs-parser "^16.1.0" + yargs-parser "^18.1.2" + +yargs@^16.0.3: + version "16.1.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.1.1.tgz#5a4a095bd1ca806b0a50d0c03611d38034d219a1" + integrity sha512-hAD1RcFP/wfgfxgMVswPE+z3tlPFtxG8/yWUrG2i17sTWGCGqWnxKcLTF4cUKDUK8fzokwsmO9H0TDkRbMHy8w== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" yargs@^7.0.0: version "7.1.0" @@ -17402,14 +17937,7 @@ yargs@~3.10.0: decamelize "^1.0.0" window-size "0.1.0" -yauzl@2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.4.1.tgz#9528f442dab1b2284e58b4379bb194e22e0c4005" - integrity sha1-lSj0QtqxsihOWLQ3m7GU4i4MQAU= - dependencies: - fd-slicer "~1.0.1" - -yauzl@^2.4.2: +yauzl@^2.10.0, yauzl@^2.4.2: version "2.10.0" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" integrity sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk= From 61391be6c8a4f08b240fa4e59b7c730a2359b42f Mon Sep 17 00:00:00 2001 From: Jonas Snellinckx Date: Mon, 25 Jan 2021 22:07:16 +0100 Subject: [PATCH 05/13] chore: minor cleanup and refactoring --- package.json | 5 +- src/common/api/fetchComments.ts | 36 --- src/common/api/fetchPersonalised.ts | 54 ---- src/common/api/fetchPlaylist.ts | 120 ------- src/common/api/fetchPlaylists.ts | 30 -- src/common/api/fetchRemainingTracks.ts | 38 --- src/common/api/fetchSearch.ts | 39 --- src/common/api/helpers/fetchToObject.ts | 19 -- src/common/store/app/api.ts | 4 +- src/common/store/app/epics.ts | 15 +- src/common/store/appAuth/actions.ts | 2 +- src/common/store/appAuth/epics.ts | 2 +- src/common/store/auth/api.ts | 32 +- src/common/store/auth/selectors.ts | 4 + src/common/store/config/types.ts | 1 - src/common/store/entities/selectors.ts | 4 +- src/common/store/playlist/api.ts | 47 ++- src/common/store/playlist/epics.ts | 17 +- src/common/store/track/api.ts | 15 +- src/common/store/user/actions.ts | 6 +- src/common/store/user/api.ts | 21 +- src/common/store/user/epics.ts | 10 +- src/common/store/user/reducer.ts | 18 +- src/common/store/user/selectors.ts | 8 +- src/common/store/user/types.ts | 4 +- src/common/utils/soundcloudUtils.ts | 304 +----------------- src/config.ts | 3 +- src/main/app.ts | 8 +- .../core/chromecast/chromecastManager.ts | 9 +- src/main/store/epics/app.ts | 32 +- .../_shared/TracksGrid/TracksGrid.tsx | 12 +- .../_shared/context/contentContext.tsx | 36 ++- src/renderer/app/ContentWrapper.tsx | 45 ++- src/renderer/app/Layout.tsx | 84 ++--- src/renderer/app/Main.tsx | 2 +- .../app/components/Sidebar/Sidebar.tsx | 6 +- .../Sidebar/playlist/SideBarPlaylistItem.tsx | 2 +- src/renderer/pages/artist/ArtistPage.tsx | 4 +- .../ArtistProfiles/ArtistProfiles.tsx | 24 +- .../components/sections/MainSettings.tsx | 19 -- src/renderer/pages/tags/TagsPage.tsx | 42 +-- src/types/soundcloud.ts | 2 +- yarn.lock | 82 ++++- 43 files changed, 280 insertions(+), 987 deletions(-) delete mode 100755 src/common/api/fetchComments.ts delete mode 100755 src/common/api/fetchPersonalised.ts delete mode 100755 src/common/api/fetchPlaylist.ts delete mode 100755 src/common/api/fetchPlaylists.ts delete mode 100755 src/common/api/fetchRemainingTracks.ts delete mode 100755 src/common/api/fetchSearch.ts delete mode 100755 src/common/api/helpers/fetchToObject.ts diff --git a/package.json b/package.json index d3cd5f34..55c168f5 100755 --- a/package.json +++ b/package.json @@ -126,7 +126,7 @@ "detect-port": "^1.3.0", "dotenv": "^8.1.0", "dotenv-webpack": "^1.7.0", - "electron": "^11.0.3", + "electron": "^11.2.1", "electron-builder": "^22.9.1", "electron-debug": "^3.0.1", "electron-devtools-installer": "^3.1.1", @@ -210,6 +210,7 @@ "@amilajack/castv2-client": "Superjo149/caster", "@blueprintjs/core": "^3.23.1", "@blueprintjs/icons": "^3.12.0", + "@mckayla/electron-redux": "^3.0.1", "@sentry/electron": "2.0.4", "abort-controller": "^3.0.0", "autolinker": "^1.8.3", @@ -251,6 +252,7 @@ "react-fast-compare": "^2.0.2", "react-howler": "^3.7.4", "react-infinite-scroll-component": "^4.5.3", + "react-infinite-scroll-hook": "^3.0.0", "react-list": "^0.8.13", "react-markdown": "^4.0.3", "react-marquee": "^1.0.0", @@ -260,6 +262,7 @@ "react-sticky-el": "^2.0.5", "react-use": "^13.27.0", "react-virtualized-auto-sizer": "^1.0.2", + "react-virtuoso": "^1.4.0", "react-window": "^1.8.5", "react-window-infinite-loader": "^1.0.5", "reactstrap": "^8.4.0", diff --git a/src/common/api/fetchComments.ts b/src/common/api/fetchComments.ts deleted file mode 100755 index 69da0a01..00000000 --- a/src/common/api/fetchComments.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Normalized, SoundCloud } from '@types'; -import { normalize, schema } from 'normalizr'; -import { commentSchema } from '../schemas'; -import fetchToJson from './helpers/fetchToJson'; - -interface JsonResponse { - collection: SoundCloud.Comment[]; - next_href?: string; - future_href?: string; -} - -export default async function fetchComments( - url: string -): Promise<{ - json: JsonResponse; - normalized: Normalized.NormalizedResponse; -}> { - const json = await fetchToJson(url); - - const { collection } = json; - - const n = normalize( - collection, - new schema.Array( - { - comments: commentSchema - }, - input => `${input.kind}s` - ) - ); - - return { - normalized: n, - json - }; -} diff --git a/src/common/api/fetchPersonalised.ts b/src/common/api/fetchPersonalised.ts deleted file mode 100755 index 0252b379..00000000 --- a/src/common/api/fetchPersonalised.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* eslint-disable camelcase */ - -import { normalize, schema } from 'normalizr'; -import { Normalized, SoundCloud } from '@types'; -import { playlistSchema } from '../schemas'; -import fetchToJson from './helpers/fetchToJson'; - -interface JsonResponse { - collection: PersonalisedCollectionItem[]; - next_href?: string; - query_urn: string; -} - -export interface PersonalisedCollectionItem { - urn: string; - query_urn: string; - title: string; - description: string; - tracking_feature_name: string; - last_updated: string; - style: string; - social_proof: SoundCloud.CompactUser; - items: { - collection: SoundCloud.SystemPlaylist[]; - }; -} - -export default async function fetchPersonalised( - url: string -): Promise<{ - json: JsonResponse; - normalized: { - entities: Normalized.NormalizedEntities; - result: Normalized.NormalizedPersonalizedItem[]; - }; -}> { - const json = await fetchToJson(url); - - const collection = json.collection.filter(t => t.urn.indexOf('chart') === -1); - - return { - normalized: normalize( - collection, - new schema.Array( - new schema.Object({ - items: { - collection: new schema.Array(playlistSchema) - } - }) - ) - ), - json - }; -} diff --git a/src/common/api/fetchPlaylist.ts b/src/common/api/fetchPlaylist.ts deleted file mode 100755 index e57bfa92..00000000 --- a/src/common/api/fetchPlaylist.ts +++ /dev/null @@ -1,120 +0,0 @@ -/* eslint-disable camelcase */ -import { isEqual, uniqWith } from 'lodash'; -import { normalize, schema } from 'normalizr'; -import { SoundCloud, Normalized } from '@types'; -import { playlistSchema, trackSchema, userSchema } from '../schemas'; -// eslint-disable-next-line import/no-cycle -import { PlaylistTypes } from '../store/types'; -import fetchToJson from './helpers/fetchToJson'; - -interface CollectionItem { - // tslint:disable-next-line: no-reserved-keywords - type: 'playlist' | 'track' | 'track-repost' | 'playlist-repost'; - playlist?: SoundCloud.Playlist; - track?: SoundCloud.Track; - user: SoundCloud.CompactUser; -} - -interface CollectionResponse { - collection: CollectionItem[]; - next_href?: string; - future_href?: string; -} - -type JsonResponse = CollectionResponse | ChartResponse | SoundCloud.Playlist[]; - -interface ChartCollectionItem { - score: number; - track: SoundCloud.Track; -} -interface ChartResponse { - collection: ChartCollectionItem[]; - genre: string; - kind: 'top' | 'trending'; - last_updated: SoundCloud.DateString; - next_href?: string; - query_urn?: string; -} - -export default async function fetchPlaylist( - url: string, - objectId: string, - hideReposts = false -): Promise<{ - json: any; - normalized: Normalized.NormalizedResponse; -}> { - const json: JsonResponse = await fetchToJson(url); - - let normalized = null; - - if (objectId === PlaylistTypes.STREAM || objectId === PlaylistTypes.MYPLAYLISTS) { - const { collection } = json as CollectionResponse; - - const processedColletion = collection - .filter(info => { - if (objectId === PlaylistTypes.STREAM && hideReposts) { - return info.type.split('-')[1] !== 'repost'; - } - - return info.track || (info.playlist && info.playlist.track_count); - }) - .map(item => { - const obj: any = item.track || item.playlist; - - obj.fromUser = item.user; - obj.type = item.type; - - return obj; - }); - - normalized = normalize( - processedColletion, - new schema.Array( - { - playlists: playlistSchema, - tracks: trackSchema, - users: userSchema - }, - input => `${input.kind}s` - ) - ); - - // Stream could have duplicate items - normalized.result = uniqWith(normalized.result, isEqual); - } else if ((json as any).collection) { - // When charts - const { collection, genre } = json as ChartResponse; - - // tslint:disable-next-line: no-any - let items: SoundCloud.Track[] = collection as any[]; - - if (genre) { - items = collection.map(item => { - const { track } = item; - track.score = item.score; - - return track; - }); - } - - normalized = normalize( - items, - new schema.Array( - { - playlists: playlistSchema, - tracks: trackSchema, - users: userSchema - }, - input => `${input.kind}s` - ) - ); - } else { - normalized = normalize(json, playlistSchema); - } - - return { - normalized, - json - }; -} diff --git a/src/common/api/fetchPlaylists.ts b/src/common/api/fetchPlaylists.ts deleted file mode 100755 index 2e6e914a..00000000 --- a/src/common/api/fetchPlaylists.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { normalize, schema } from 'normalizr'; -import { Normalized, SoundCloud } from '@types'; -import { playlistSchema } from '../schemas'; -// eslint-disable-next-line import/no-cycle -import { SC } from '../utils'; -import fetchToJson from './helpers/fetchToJson'; - -type JsonResponse = SoundCloud.Playlist[]; - -export default async function fetchPlaylists(): Promise<{ - json: JsonResponse; - normalized: Normalized.NormalizedResponse; -}> { - const json: JsonResponse = await fetchToJson(SC.getPlaylistUrl()); - - const normalized = normalize( - json, - new schema.Array( - { - playlists: playlistSchema - }, - input => `${input.kind}s` - ) - ); - - return { - normalized, - json - }; -} diff --git a/src/common/api/fetchRemainingTracks.ts b/src/common/api/fetchRemainingTracks.ts deleted file mode 100755 index ef8fa7e2..00000000 --- a/src/common/api/fetchRemainingTracks.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { RemainingPlays } from '../store/types'; -import { SC } from '../utils'; -import fetchToJson from './helpers/fetchToJson'; - -interface JsonResponse { - statuses: Status[]; -} - -interface Status { - rate_limit: { - bucket: string; - max_nr_of_requests: number; - time_window: string; - name: 'plays' | 'search'; - }; - remaining_requests: number; - reset_time: string; -} - -export async function fetchRemainingTracks(overrideClientId?: string): Promise { - try { - const json = await fetchToJson(SC.getRemainingTracks(overrideClientId)); - if (json.statuses.length) { - const plays = json.statuses.find(t => t.rate_limit.name === 'plays'); - - if (plays) { - return { - remaining: plays.remaining_requests, - resetTime: new Date(plays.reset_time).getTime() - }; - } - } - - return null; - } catch (err) { - return null; - } -} diff --git a/src/common/api/fetchSearch.ts b/src/common/api/fetchSearch.ts deleted file mode 100755 index 4f176e11..00000000 --- a/src/common/api/fetchSearch.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* eslint-disable camelcase */ -import { normalize, schema } from 'normalizr'; -import { Normalized, SoundCloud } from '@types'; -import { playlistSchema, trackSchema, userSchema } from '../schemas'; -import fetchToJson from './helpers/fetchToJson'; - -interface JsonResponse { - collection: SearchCollectionItem[]; - next_href?: string; - future_href?: string; - query_urn: string; - total_results: number; -} - -type SearchCollectionItem = SoundCloud.Track | SoundCloud.Playlist | SoundCloud.Comment; - -export default async function fetchSearch( - url: string -): Promise<{ - json: JsonResponse; - normalized: Normalized.NormalizedResponse; -}> { - const json = await fetchToJson(url); - - return { - normalized: normalize( - json.collection, - new schema.Array( - { - playlists: playlistSchema, - tracks: trackSchema, - users: userSchema - }, - input => `${input.kind}s` - ) - ), - json - }; -} diff --git a/src/common/api/helpers/fetchToObject.ts b/src/common/api/helpers/fetchToObject.ts deleted file mode 100755 index 601e75ae..00000000 --- a/src/common/api/helpers/fetchToObject.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { AxiosRequestConfig } from 'axios'; -import fetchToJson from './fetchToJson'; - -function toObject(collection: string[]): { [key: string]: boolean } { - return collection.reduce((obj, t) => ({ ...obj, [t]: true }), {}); -} - -export default async function fetchToObject( - url: string, - options?: AxiosRequestConfig -): Promise<{ [key: string]: boolean }> { - return fetchToJson<{ collection?: [] }>(url, options).then(json => { - if (!json.collection || !json.collection.length) { - return {}; - } - - return toObject(json.collection); - }); -} diff --git a/src/common/store/app/api.ts b/src/common/store/app/api.ts index 5c7aee97..1dbfb752 100644 --- a/src/common/store/app/api.ts +++ b/src/common/store/app/api.ts @@ -17,11 +17,11 @@ interface Status { } // TODO -export async function fetchRemainingTracks(overrideClientId?: string | null): Promise { +export async function fetchRemainingTracks(): Promise { try { const json = await fetchToJsonNew({ uri: 'rate_limit_status', - clientId: overrideClientId ?? true + clientId: true }); if (!json.statuses.length) { diff --git a/src/common/store/app/epics.ts b/src/common/store/app/epics.ts index ebfc5bd4..53406461 100644 --- a/src/common/store/app/epics.ts +++ b/src/common/store/app/epics.ts @@ -33,19 +33,8 @@ export const getRemainingPlaysEpic: RootEpic = (action$, state$) => action$.pipe( filter(isActionOf(getRemainingPlays.request)), withLatestFrom(state$), - map(([, state]) => configSelector(state).app.overrideClientId), - switchMap(overrideClientId => { - if (overrideClientId) { - return map(() => - getRemainingPlays.success({ - remaining: -1, - resetTime: Date.now(), - updatedAt: Date.now() - }) - ); - } - - return from(APIService.fetchRemainingTracks(overrideClientId)).pipe( + switchMap(() => { + return from(APIService.fetchRemainingTracks()).pipe( map(response => { if (response) { return getRemainingPlays.success({ diff --git a/src/common/store/appAuth/actions.ts b/src/common/store/appAuth/actions.ts index 5a15dc0d..1874c081 100755 --- a/src/common/store/appAuth/actions.ts +++ b/src/common/store/appAuth/actions.ts @@ -13,7 +13,7 @@ export const login = createAsyncAction( wSuccess(AppAuthActionTypes.LOGIN), wError(AppAuthActionTypes.LOGIN), wCancel(AppAuthActionTypes.LOGIN) -)(); +)(); export const finishOnboarding = createAction(AppAuthActionTypes.FINISH_ONBOARDING)(); export const startLoginSession = createAction(AppAuthActionTypes.START_LOGIN_SESSION)<{ diff --git a/src/common/store/appAuth/epics.ts b/src/common/store/appAuth/epics.ts index 4e54ded8..da365d3a 100644 --- a/src/common/store/appAuth/epics.ts +++ b/src/common/store/appAuth/epics.ts @@ -74,7 +74,7 @@ export const finishOnboardingEpic: RootEpic = action$ => action$.pipe( filter(isActionOf(finishOnboarding)), tap(payload => console.log('finishOnboardingEpic', payload)), - exhaustMap(() => of(setConfigKey('lastLogin', Date.now()), login.success())) + exhaustMap(() => of(setConfigKey('lastLogin', Date.now()), login.success({}))) ); export const logoutEpic: RootEpic = action$ => diff --git a/src/common/store/auth/api.ts b/src/common/store/auth/api.ts index fd49bd67..c77a20d8 100644 --- a/src/common/store/auth/api.ts +++ b/src/common/store/auth/api.ts @@ -1,6 +1,5 @@ import fetchToJsonNew from '@common/api/helpers/fetchToJsonNew'; import { playlistSchema, userSchema } from '@common/schemas'; -import { memToken } from '@common/utils/soundcloudUtils'; import { Collection, EntitiesOf, ResultOf, SoundCloud } from '@types'; import { normalize, schema } from 'normalizr'; import { map } from 'rxjs/operators'; @@ -87,23 +86,21 @@ export function fetchPlaylists() { } // LIKES -export function toggleTrackLike(options: { trackId: string | number; userId: string | number; like: boolean }) { +export function toggleTrackLike(options: { trackId: string | number; like: boolean }) { return fetchToJsonNew>( { - uri: `users/${options.userId}/track_likes/${options.trackId}`, - oauthToken: true, - useV2Endpoint: true + uri: `likes/tracks/${options.trackId}`, + oauthToken: true }, { method: options.like ? 'PUT' : 'DELETE' } ); } -export function togglePlaylistLike(options: { playlistId: string | number; userId: string | number; like: boolean }) { +export function togglePlaylistLike(options: { playlistId: string | number; like: boolean }) { return fetchToJsonNew>( { - uri: `users/${options.userId}/playlist_likes/${options.playlistId}`, - oauthToken: true, - useV2Endpoint: true + uri: `likes/playlists/{playlist_id}/${options.playlistId}`, + oauthToken: true }, { method: options.like ? 'PUT' : 'DELETE' } ); @@ -125,9 +122,8 @@ export function toggleSystemPlaylistLike(options: { playlistUrn: string; userId: export function toggleTrackRepost(options: { trackId: string | number; repost: boolean }) { return fetchToJsonNew>( { - uri: `me/track_reposts/${options.trackId}`, - oauthToken: true, - useV2Endpoint: true + uri: `reposts/tracks/${options.trackId}`, + oauthToken: true }, { method: options.repost ? 'PUT' : 'DELETE' } ); @@ -136,9 +132,8 @@ export function toggleTrackRepost(options: { trackId: string | number; repost: b export function togglePlaylistRepost(options: { playlistId: string | number; repost: boolean }) { return fetchToJsonNew>( { - uri: `me/playlist_reposts/${options.playlistId}`, - oauthToken: true, - useV2Endpoint: true + uri: `reposts/playlists/${options.playlistId}`, + oauthToken: true }, { method: options.repost ? 'PUT' : 'DELETE' } ); @@ -148,10 +143,9 @@ export function togglePlaylistRepost(options: { playlistId: string | number; rep export async function toggleFollowing(options: { userId: string | number; follow: boolean }) { return fetchToJsonNew>( { - uri: `me/followings/${options.userId}`, - oauthToken: true, - useV2Endpoint: true + uri: `/me/followings//${options.userId}`, + oauthToken: true }, - { method: options.follow ? 'POST' : 'DELETE' } + { method: options.follow ? 'PUT' : 'DELETE' } ); } diff --git a/src/common/store/auth/selectors.ts b/src/common/store/auth/selectors.ts index 01f86ad7..1f5207af 100644 --- a/src/common/store/auth/selectors.ts +++ b/src/common/store/auth/selectors.ts @@ -19,6 +19,10 @@ export const getAuthPlaylistLikesSelector = createSelector(getAuthLikesSelector, export const getAuthRepostsSelector = createSelector(getAuth, auth => auth.reposts || {}); export const getAuthPlaylistsSelector = createSelector(getAuth, auth => auth.playlists.data); +export const getOwnedAuthPlaylistsSelector = createSelector( + getAuthPlaylistsSelector, + authPlaylists => authPlaylists.owned +); export type CombinedUserPlaylistState = { title: string; id: number } & ObjectState; diff --git a/src/common/store/config/types.ts b/src/common/store/config/types.ts index 0bbb3470..5020eae1 100644 --- a/src/common/store/config/types.ts +++ b/src/common/store/config/types.ts @@ -27,7 +27,6 @@ export interface AppConfig { crashReports: boolean; downloadPath: string; showTrackChangeNotification: boolean; - overrideClientId: string | null; theme: string; } diff --git a/src/common/store/entities/selectors.ts b/src/common/store/entities/selectors.ts index f512c260..fa566fe8 100644 --- a/src/common/store/entities/selectors.ts +++ b/src/common/store/entities/selectors.ts @@ -81,5 +81,5 @@ export const getNormalizedTrack = (id?: number | string) => } ); -export const getNormalizedUserProfiles = (userUrn?: string) => - createSelector(getUserProfilesEntities(), entities => (userUrn ? entities?.[userUrn] : null)); +export const getNormalizedUserProfiles = (userId?: string) => + createSelector(getUserProfilesEntities(), entities => (userId ? entities?.[userId] : null)); diff --git a/src/common/store/playlist/api.ts b/src/common/store/playlist/api.ts index 196a3cd1..e2082593 100644 --- a/src/common/store/playlist/api.ts +++ b/src/common/store/playlist/api.ts @@ -10,6 +10,7 @@ export interface FeedItem { user: SoundCloud.CompactUser; } +// TODO: Unable to migrate to public API because repost user is not available on the API export function fetchStream(options: { limit?: number }) { return fetchToJsonNew>({ uri: 'stream', @@ -22,18 +23,23 @@ export function fetchStream(options: { limit?: number }) { } // Likes -export interface LikeItem { - kind: 'like'; - track: SoundCloud.Track; - created_at: string; +export function fetchMyLikes(options: { limit?: number }) { + return fetchToJsonNew>({ + uri: `me/likes/tracks`, + oauthToken: true, + queryParams: { + linked_partitioning: true, + limit: options.limit ?? 20 + } + }); } -export function fetchLikes(options: { userId?: string | number; limit?: number }) { - return fetchToJsonNew>({ - uri: `users/${options.userId}/track_likes`, +export function fetchUserLikes(options: { userId: string | number; limit?: number }) { + return fetchToJsonNew>({ + uri: `users/${options.userId}/likes/tracks`, oauthToken: true, - useV2Endpoint: true, queryParams: { + linked_partitioning: true, limit: options.limit ?? 20 } }); @@ -48,6 +54,7 @@ export interface PlaylistItem { uuid: string; } +// TODO: Unable to migrate to public API because liked playlists does not exist export function fetchPlaylists(options: { limit?: number }) { return fetchToJsonNew>({ uri: `me/library/albums_playlists_and_system_playlists`, @@ -60,12 +67,12 @@ export function fetchPlaylists(options: { limit?: number }) { } // My tracks -export function fetchMyTracks(options: { userId?: string | number; limit?: number }) { +export function fetchMyTracks(options: { limit?: number }) { return fetchToJsonNew>({ - uri: `users/${options.userId}/tracks`, + uri: `me/tracks`, oauthToken: true, - useV2Endpoint: true, queryParams: { + linked_partitioning: true, limit: options.limit ?? 20 } }); @@ -77,6 +84,7 @@ export interface ChartItem { track: SoundCloud.Track; } +// TODO: Unable to migrate to public API because this does not exist yet onthere export function fetchCharts(options: { limit?: number; sort?: SortTypes; genre: string }) { return fetchToJsonNew>({ uri: `charts`, @@ -94,8 +102,8 @@ export function fetchCharts(options: { limit?: number; sort?: SortTypes; genre: export function fetchPlaylist(options: { limit?: number; playlistId: number | string }) { return fetchToJsonNew({ uri: `playlists/${options.playlistId}`, - oauthToken: true, - useV2Endpoint: true + oauthToken: true + // useV2Endpoint: true }); } @@ -104,7 +112,6 @@ export function fetchTracks(options: { ids: number[] }) { return fetchToJsonNew({ uri: `tracks`, oauthToken: true, - useV2Endpoint: true, queryParams: { ids: options.ids.join(',') } @@ -146,18 +153,6 @@ export function searchAll(options: { }); } -export function fetchPlaylistsByTag(options: { limit?: number; tag: string }) { - return fetchToJsonNew>({ - uri: `playlists/discovery`, - oauthToken: true, - useV2Endpoint: true, - queryParams: { - limit: options.limit ?? 20, - tag: options.tag - } - }); -} - export interface PersonalisedCollectionItem { urn: string; last_updated: string; diff --git a/src/common/store/playlist/epics.ts b/src/common/store/playlist/epics.ts index 7e579ae4..a63f3601 100644 --- a/src/common/store/playlist/epics.ts +++ b/src/common/store/playlist/epics.ts @@ -68,10 +68,10 @@ export const getGenericPlaylistEpic: RootEpic = (action$, state$) => ob$ = APIService.fetchStream({ limit: hideReposts ? 42 : 21 }); break; case PlaylistTypes.LIKES: - ob$ = APIService.fetchLikes({ limit: 21, userId: me?.id || '' }); + ob$ = APIService.fetchMyLikes({ limit: 21 }); break; case PlaylistTypes.MYTRACKS: - ob$ = APIService.fetchMyTracks({ limit: 21, userId: me?.id || '' }); + ob$ = APIService.fetchMyTracks({ limit: 21 }); break; case PlaylistTypes.MYPLAYLISTS: ob$ = APIService.fetchPlaylists({ limit: 21 }); @@ -81,7 +81,7 @@ export const getGenericPlaylistEpic: RootEpic = (action$, state$) => ob$ = throwError(new Error(`${playlistType}: objectId=${objectId} must be defined`)); break; } - ob$ = APIService.fetchRelatedTracks({ limit: 21, trackId: objectId, userId: me?.id || '' }); + ob$ = APIService.fetchRelatedTracks({ limit: 21, trackId: objectId }); break; case PlaylistTypes.PLAYLIST: if (!objectId) { @@ -130,7 +130,7 @@ export const getGenericPlaylistEpic: RootEpic = (action$, state$) => return processStreamItems(state)(json as Collection); case PlaylistTypes.LIKES: case PlaylistTypes.ARTIST_LIKES: - return processLikeItems(state)(json as Collection); + return processLikeItems(state)(json as Collection); case PlaylistTypes.MYTRACKS: case PlaylistTypes.RELATED: case PlaylistTypes.ARTIST_TRACKS: @@ -299,8 +299,6 @@ export const searchEpic: RootEpic = (action$, state$) => case PlaylistTypes.SEARCH_PLAYLIST: if (query && query.length) { ob$ = APIService.searchAll({ query, limit: 21, type: 'playlists_without_albums' }); - } else if (tag) { - ob$ = APIService.fetchPlaylistsByTag({ tag, limit: 21 }); } break; case PlaylistTypes.SEARCH_USER: @@ -636,11 +634,8 @@ const processStreamItems = (state: StoreState) => (json: Collection (json: Collection) => { - return normalizeCollection({ - ...json, - collection: json.collection.filter(({ track }) => !!track).map(({ track }) => track) - }); +const processLikeItems = (_state: StoreState) => (json: Collection) => { + return normalizeCollection(json); }; const processTracks = (_state: StoreState) => (json: Collection) => { diff --git a/src/common/store/track/api.ts b/src/common/store/track/api.ts index 78718583..83638794 100644 --- a/src/common/store/track/api.ts +++ b/src/common/store/track/api.ts @@ -4,33 +4,28 @@ import { Collection, SoundCloud } from '@types'; export function fetchTrack(options: { trackId: string | number }) { return fetchToJsonNew({ uri: `tracks/${options.trackId}`, - oauthToken: true, - useV2Endpoint: true + oauthToken: true }); } -// Comments export function fetchComments(options: { trackId: number; limit?: number }) { return fetchToJsonNew>({ uri: `tracks/${options.trackId}/comments`, - clientId: true, - useV2Endpoint: true, + oauthToken: true, queryParams: { limit: options.limit ?? 20, - threaded: 1, - filter_replies: 0 + linked_partitioning: true } }); } -export function fetchRelatedTracks(options: { trackId: string; userId: string | number; limit?: number }) { +export function fetchRelatedTracks(options: { trackId: string; limit?: number }) { return fetchToJsonNew>({ uri: `tracks/${options.trackId}/related`, oauthToken: true, - useV2Endpoint: true, queryParams: { limit: options.limit ?? 20, - user_id: options.userId + linked_partitioning: true } }); } diff --git a/src/common/store/user/actions.ts b/src/common/store/user/actions.ts index fe80605b..3e96b794 100755 --- a/src/common/store/user/actions.ts +++ b/src/common/store/user/actions.ts @@ -18,7 +18,7 @@ export const getUserProfiles = createAsyncAction( wSuccess(UserActionTypes.GET_USER_PROFILES), wError(UserActionTypes.GET_USER_PROFILES) )< - { userUrn: string }, - { userUrn: string; entities: EntitiesOf }, - EpicFailure & { userUrn: string } + { userId: string }, + { userId: string; entities: EntitiesOf }, + EpicFailure & { userId: string } >(); diff --git a/src/common/store/user/api.ts b/src/common/store/user/api.ts index 00d48180..679532f4 100644 --- a/src/common/store/user/api.ts +++ b/src/common/store/user/api.ts @@ -4,11 +4,11 @@ import { Collection, SoundCloud } from '@types'; export function fetchUser(options: { userId: string | number }) { return fetchToJsonNew({ uri: `users/${options.userId}`, - oauthToken: true, - useV2Endpoint: true + oauthToken: true }); } +// TODO: not available on the public api export function fetchUserTopTracks(options: { userId: number | string; limit?: number }) { return fetchToJsonNew>({ uri: `users/${options.userId}/toptracks`, @@ -16,7 +16,7 @@ export function fetchUserTopTracks(options: { userId: number | string; limit?: n useV2Endpoint: true, queryParams: { limit: options.limit ?? 20, - linked_partitioning: 1 + linked_partitioning: true } }); } @@ -24,30 +24,27 @@ export function fetchUserTracks(options: { userId: number | string; limit?: numb return fetchToJsonNew>({ uri: `users/${options.userId}/tracks`, clientId: true, - useV2Endpoint: true, queryParams: { limit: options.limit ?? 20, - linked_partitioning: 1 + linked_partitioning: true } }); } export function fetchUserLikes(options: { userId: string | string; limit?: number }) { return fetchToJsonNew>({ - uri: `users/${options.userId}/likes`, + uri: `users/${options.userId}/likes/tracks`, oauthToken: true, - useV2Endpoint: true, queryParams: { limit: options.limit ?? 20, - linked_partitioning: 1 + linked_partitioning: true } }); } -export function fetchUserProfiles(options: { userUrn: string }) { +export function fetchUserProfiles(options: { userId: string }) { return fetchToJsonNew({ - uri: `users/${options.userUrn}/web-profiles`, - oauthToken: true, - useV2Endpoint: true + uri: `users/${options.userId}/web-profiles`, + oauthToken: true }); } diff --git a/src/common/store/user/epics.ts b/src/common/store/user/epics.ts index 1d339286..b8556ff6 100644 --- a/src/common/store/user/epics.ts +++ b/src/common/store/user/epics.ts @@ -41,17 +41,17 @@ export const getUserProfilesEpic: RootEpic = action$ => filter(isActionOf(getUserProfiles.request)), tap(action => console.log(`${action.type} from ${process.type}`)), pluck('payload'), - switchMap(({ userUrn }) => { - return defer(() => from(APIService.fetchUserProfiles({ userUrn }))).pipe( + switchMap(({ userId }) => { + return defer(() => from(APIService.fetchUserProfiles({ userId }))).pipe( map(data => { const entities: EntitiesOf = { userProfileEntities: { - [userUrn]: data + [userId]: data } }; return getUserProfiles.success({ - userUrn, + userId, entities }); }), @@ -59,7 +59,7 @@ export const getUserProfilesEpic: RootEpic = action$ => handleEpicError( action$, getUserProfiles.failure({ - userUrn + userId }) ) ) diff --git a/src/common/store/user/reducer.ts b/src/common/store/user/reducer.ts index 637ad6d3..2a1e62ab 100644 --- a/src/common/store/user/reducer.ts +++ b/src/common/store/user/reducer.ts @@ -54,44 +54,44 @@ export const userReducer = createReducer(initialState) }; }) .handleAction(getUserProfiles.request, (state, action) => { - const { userUrn } = action.payload; + const { userId } = action.payload; const errors = state.userProfilesError; - delete errors[userUrn]; + delete errors[userId]; return { ...state, - userProfilesLoading: Array.from(new Set([...state.userProfilesLoading, userUrn])), + userProfilesLoading: Array.from(new Set([...state.userProfilesLoading, userId])), userProfilesError: { ...errors } }; }) .handleAction(getUserProfiles.success, (state, action) => { - const { userUrn } = action.payload; + const { userId } = action.payload; const errors = state.userProfilesError; - delete errors[userUrn]; + delete errors[userId]; return { ...state, - userProfilesLoading: state.userProfilesLoading.filter(id => id !== userUrn), + userProfilesLoading: state.userProfilesLoading.filter(id => id !== userId), userProfilesError: { ...errors } }; }) .handleAction(getUserProfiles.failure, (state, action) => { - const { userUrn, error } = action.payload; + const { userId, error } = action.payload; return { ...state, - userProfilesLoading: state.userProfilesLoading.filter(id => id !== userUrn), + userProfilesLoading: state.userProfilesLoading.filter(id => id !== userId), userProfilesError: { ...state.error, - [userUrn]: error + [userId]: error } }; }) diff --git a/src/common/store/user/selectors.ts b/src/common/store/user/selectors.ts index 9fc7716a..d1e2d639 100644 --- a/src/common/store/user/selectors.ts +++ b/src/common/store/user/selectors.ts @@ -6,7 +6,7 @@ export const getUser = (state: StoreState) => state.user; export const isUserLoading = (userId: string) => createSelector([getUser], user => user.loading.includes(+userId)); export const isUserError = (userId: number | string) => createSelector([getUser], user => user.error[userId]); -export const isUserProfilesLoading = (userUrn: string) => - createSelector([getUser], user => user.userProfilesLoading.includes(userUrn)); -export const isUserProfilesError = (userUrn: number | string) => - createSelector([getUser], user => user.userProfilesError[userUrn]); +export const isUserProfilesLoading = (userId: string) => + createSelector([getUser], user => user.userProfilesLoading.includes(userId)); +export const isUserProfilesError = (userId: number | string) => + createSelector([getUser], user => user.userProfilesError[userId]); diff --git a/src/common/store/user/types.ts b/src/common/store/user/types.ts index cea8a728..c3d7edd0 100755 --- a/src/common/store/user/types.ts +++ b/src/common/store/user/types.ts @@ -1,10 +1,10 @@ // TYPES export interface UserState { loading: number[]; - error: { [userId: string]: Error | null }; + error: { [userId: string]: Error | null | undefined }; userProfilesLoading: string[]; - userProfilesError: { [userId: string]: Error | null }; + userProfilesError: { [userId: string]: Error | null | undefined }; } // ACTIONS diff --git a/src/common/utils/soundcloudUtils.ts b/src/common/utils/soundcloudUtils.ts index e832bb3e..8da065f5 100755 --- a/src/common/utils/soundcloudUtils.ts +++ b/src/common/utils/soundcloudUtils.ts @@ -53,310 +53,12 @@ export function getTrackUrl(trackId: string | number) { ); } -export function getChartsUrl(genre: string, sort = 'top', limit = 50) { - return makeUrl( - 'charts', - { - client_id: true, - kind: sort, - genre: `soundcloud:genres:${genre}`, - limit - }, - true - ); -} - -export function getRemainingTracks(overrideClientId?: string) { +export function getRemainingTracks(g) { return makeUrl('rate_limit_status', { - client_id: overrideClientId || true - }); -} -export function registerPlayUrl() { - return makeUrl( - 'me/play-history', - { - oauth_token: true - }, - true - ); -} - -export function getUserUrl(artistID: string | number) { - return makeUrl(`users/${artistID}`, { - client_id: true - }); -} - -export function getUserTracksUrl(artistID: string | number, limit = 50) { - return makeUrl(`users/${artistID}/tracks`, { - client_id: true, - linked_partitioning: 1, - limit - }); -} -export function getPersonalizedurl() { - return makeUrl( - `mixed-selections`, - { - oauth_token: true - }, - true - ); -} - -export function getUserWebProfilesUrl(artistID: number) { - return makeUrl(`users/${artistID}/web-profiles`, { client_id: true }); } -export function getUserLikesUrl(artistID: string | number, limit = 50) { - return makeUrl(`users/${artistID}/favorites`, { - client_id: true, - linked_partitioning: 1, - limit - }); -} - -export function getAllUserPlaylistsUrl(artistID: string | number, limit = 50) { - return makeUrl( - `users/${artistID}/playlists/liked_and_owned`, - { - oauth_token: true, - linked_partitioning: 1, - limit - }, - true - ); -} - -export function getLikesUrl(limit = 50) { - return makeUrl('me/favorites', { - oauth_token: true, - linked_partitioning: 1, - limit - }); -} - -export function getLikeIdsUrl(limit = 5000) { - return makeUrl('me/favorites/ids', { - oauth_token: true, - linked_partitioning: 1, - limit - }); -} - -export function getPlaylistLikeIdsUrl(limit = 5000) { - return makeUrl( - 'me/playlist_likes/ids', - { - oauth_token: true, - linked_partitioning: 1, - limit - }, - true - ); -} - -export function getFeedUrl(limit = 15) { - return makeUrl( - 'stream', - { - linked_partitioning: 1, - limit, - oauth_token: true - }, - true - ); -} - -export function getPlaylistUrl() { - return makeUrl('me/playlists', { - oauth_token: true - }); -} - -export function getPlaylistupdateUrl(playlistId: string | number) { - return makeUrl( - `playlists/${playlistId}`, - { - oauth_token: true - }, - true - ); -} - -export function getTracks(ids: number[]) { - return makeUrl( - 'tracks', - { - ids: ids.join(','), - oauth_token: true - }, - true - ); -} - -export function getPlaylistDeleteUrl(playlistId: string | number) { - return makeUrl(`playlists/${playlistId}`, { - oauth_token: true - }); -} - -export function getPlaylistTracksUrl(playlistId: string | number) { - return makeUrl( - `playlists/${playlistId}`, - { - oauth_token: true - }, - true - ); -} - -export function getRelatedUrl(trackId: string | number, limit = 50) { - return makeUrl(`tracks/${trackId}/related`, { - client_id: true, - linked_partitioning: 1, - limit - }); -} - -export function getCommentsUrl(trackId: string | number, limit = 20) { - return makeUrl(`tracks/${trackId}/comments`, { - client_id: true, - linked_partitioning: 1, - limit - }); -} - -export function getMeUrl() { - return makeUrl('me', { - oauth_token: true - }); -} - -export function getFollowingsUrl() { - return makeUrl('me/followings/ids', { - oauth_token: true, - limit: 5000, - linked_partitioning: 1 - }); -} - -export function getRepostIdsUrl(playlist?: boolean) { - return makeUrl(`e1/me/${playlist ? 'playlist' : 'track'}_reposts/ids`, { - oauth_token: true, - limit: 5000, - linked_partitioning: 1 - }); -} - -export function updateLikeUrl(trackId: string | number) { - return makeUrl(`me/favorites/${trackId}`, { - oauth_token: true - }); -} - -export function updatePlaylistLikeUrl(userID: string | number, playlistID: string | number) { - return makeUrl( - `users/${userID}/playlist_likes/${playlistID}`, - { - oauth_token: true - }, - true - ); -} - -export function updateFollowingUrl(userID: string | number) { - return makeUrl(`me/followings/${userID}`, { - oauth_token: true - }); -} - -export function updateRepostUrl(trackId: string | number, playlist: boolean) { - return makeUrl(`e1/me/${playlist ? 'playlist' : 'track'}_reposts/${trackId}`, { - oauth_token: true - }); -} - -export function searchAllUrl(query: string, limit = 20, offset = 0) { - return makeUrl( - 'search', - { - oauth_token: true, - q: query, - limit, - offset, - linked_partitioning: 1, - facet: 'model' - }, - true - ); -} - -export function searchTracksUrl(query: string, limit = 15, offset = 0) { - return makeUrl('tracks', { - oauth_token: true, - q: query, - limit, - offset, - linked_partitioning: 1 - }); -} - -export function searchTagurl(genre: string, limit = 15, offset = 0) { - return makeUrl( - 'search/tracks', - { - oauth_token: true, - q: '', - 'filter.genre': genre, - limit, - offset, - linked_partitioning: 1 - }, - true - ); -} - -export function discoverPlaylistsUrl(tag: string, limit = 15, offset = 0) { - return makeUrl( - 'playlists/discovery', - { - oauth_token: true, - tag, - limit, - offset, - linked_partitioning: 1 - }, - true - ); -} - -export function searchUsersUrl(query: string, limit = 15, offset = 0) { - return makeUrl('users', { - oauth_token: true, - q: query, - limit, - offset, - linked_partitioning: 1 - }); -} - -export function searchPlaylistsUrl(query: string, limit = 15, offset = 0) { - return makeUrl( - 'search/playlists', - { - oauth_token: true, - q: query, - limit, - offset, - linked_partitioning: 1 - }, - true - ); -} - export function resolveUrl(url: string) { return `${endpoint}resolve?client_id=${CONFIG.CLIENT_ID}&url=${url}`; } @@ -369,8 +71,8 @@ export function appendJustToken(url: string) { return `${url}?oauth_token=${memToken}`; } -export function appendClientId(url: string, overrideClientId?: string | null) { - return `${url}?client_id=${overrideClientId || CONFIG.CLIENT_ID}`; +export function appendClientId(url: string) { + return `${url}?client_id=${CONFIG.CLIENT_ID}`; } export function getImageUrl(track: any, size: string) { diff --git a/src/config.ts b/src/config.ts index d6049baa..8145146e 100755 --- a/src/config.ts +++ b/src/config.ts @@ -65,8 +65,7 @@ export const CONFIG = { crashReports: true, theme: ThemeKeys.darkBlue, downloadPath, - showTrackChangeNotification: true, - overrideClientId: null + showTrackChangeNotification: true } } }; diff --git a/src/main/app.ts b/src/main/app.ts index ae43b21c..33c42965 100755 --- a/src/main/app.ts +++ b/src/main/app.ts @@ -217,15 +217,9 @@ export class Auryo { urls: ['http://localhost:8888/stream/*'] }, async (details, callback) => { - const { - config: { - app: { overrideClientId } - } - } = this.store.getState(); const { 1: trackId } = details.url.split('http://localhost:8888/stream/'); try { - const clientId = overrideClientId && overrideClientId.length ? overrideClientId : CONFIG.CLIENT_ID; - const mp3Url = await this.getPlayingTrackStreamUrl(trackId, clientId || ''); + const mp3Url = await this.getPlayingTrackStreamUrl(trackId, CONFIG.CLIENT_ID || ''); callback({ redirectURL: mp3Url diff --git a/src/main/features/core/chromecast/chromecastManager.ts b/src/main/features/core/chromecast/chromecastManager.ts index 6235afcc..572c9107 100755 --- a/src/main/features/core/chromecast/chromecastManager.ts +++ b/src/main/features/core/chromecast/chromecastManager.ts @@ -280,10 +280,7 @@ export default class ChromecastManager extends Feature { private async startTrack(state: StoreState, fromCurrentTime = false) { const { - player: { playingTrack, currentTime, status, currentIndex }, - config: { - app: { overrideClientId } - } + player: { playingTrack, currentTime, status, currentIndex } } = state; if (playingTrack && this.player) { @@ -295,8 +292,8 @@ export default class ChromecastManager extends Feature { if (track) { const streamUrl = track.stream_url - ? SC.appendClientId(track.stream_url, overrideClientId) - : SC.appendClientId(`${track.uri}/stream`, overrideClientId); + ? SC.appendClientId(track.stream_url) + : SC.appendClientId(`${track.uri}/stream`); const media = { contentId: streamUrl, diff --git a/src/main/store/epics/app.ts b/src/main/store/epics/app.ts index ab6f6778..16160aa8 100644 --- a/src/main/store/epics/app.ts +++ b/src/main/store/epics/app.ts @@ -1,41 +1,13 @@ -import { Intent } from '@blueprintjs/core'; -import { - addToast, - copyToClipboard, - openExternalUrl, - receiveProtocolAction, - restartApp, - setConfigKey -} from '@common/store/actions'; +import { copyToClipboard, openExternalUrl, restartApp } from '@common/store/actions'; import { RootEpic } from '@common/store/declarations'; import { Logger } from '@main/utils/logger'; // eslint-disable-next-line import/no-extraneous-dependencies import { app, clipboard, shell } from 'electron'; -import { concat, of } from 'rxjs'; -import { concatMap, filter, ignoreElements, pluck, tap } from 'rxjs/operators'; +import { filter, ignoreElements, pluck, tap } from 'rxjs/operators'; import { isActionOf } from 'typesafe-actions'; const logger = Logger.createLogger('EPIC/main/app'); -export const handleReceiveClientIdEpic: RootEpic = action$ => - // @ts-expect-error - action$.pipe( - filter(isActionOf(receiveProtocolAction)), - pluck('payload'), - filter(({ action, params }) => action === 'launch' && !!params.client_id), - concatMap(({ params }) => { - return concat( - of(setConfigKey('app.overrideClientId', params.client_id as string)), - of( - addToast({ - message: `New clientId added`, - intent: Intent.SUCCESS - }) - ) - ); - }) - ); - export const copyToClipboardEpic: RootEpic = action$ => // @ts-expect-error action$.pipe( diff --git a/src/renderer/_shared/TracksGrid/TracksGrid.tsx b/src/renderer/_shared/TracksGrid/TracksGrid.tsx index af226eb6..76a80aa3 100755 --- a/src/renderer/_shared/TracksGrid/TracksGrid.tsx +++ b/src/renderer/_shared/TracksGrid/TracksGrid.tsx @@ -29,15 +29,7 @@ function getRowsForWidth(width: number): number { const TracksGrid: FC = props => { const { items, showInfo, isItemLoaded, loadMore, hasMore, isLoading, playlistID } = props; const loaderRef = useRef(null); - const { setList } = useContentContext(); - const listRef = loaderRef?.current?._listRef; - - useEffect(() => { - if (listRef) { - setList(listRef); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [listRef]); + const { list } = useContentContext(); return (
@@ -77,7 +69,7 @@ const TracksGrid: FC = props => { <> ; // For other infinite lists applySettings(settings: Partial): void; @@ -23,7 +22,6 @@ export type InjectedContentContextProps = { settings: LayoutSettings; // For tracksgrid list?: FixedSizeList | null; - setList(list: FixedSizeList | null): void; // For other infinite lists applySettings(settings: Partial): void; @@ -31,9 +29,6 @@ export type InjectedContentContextProps = { export const ContentContext = React.createContext({ settings: INITIAL_LAYOUT_SETTINGS, - setList: () => { - throw new Error('setList() not implemented'); - }, applySettings: () => { throw new Error('applySettings() not implemented'); } @@ -43,14 +38,7 @@ export function withContentContext

(Compon return (props: Pick>) => { return ( - {context => ( - - )} + {context => } ); }; @@ -76,3 +64,19 @@ const SetLayoutSettingsComponent: React.SFC = ( export const SetLayoutSettings = withContentContext(SetLayoutSettingsComponent); export const useContentContext = () => useContext(ContentContext); + +export const ContentContextProvider: FC = ({ children }) => { + const [settings, setSettings] = useState(INITIAL_LAYOUT_SETTINGS); + const list = useRef(null); + + const value = useMemo( + (): ContentContextProps => ({ + settings, + list, + applySettings: newSettings => setSettings(oldSettings => ({ ...oldSettings, ...newSettings })) + }), + [list, settings] + ); + + return {children}; +}; diff --git a/src/renderer/app/ContentWrapper.tsx b/src/renderer/app/ContentWrapper.tsx index 24cf01f8..01ec903f 100644 --- a/src/renderer/app/ContentWrapper.tsx +++ b/src/renderer/app/ContentWrapper.tsx @@ -1,11 +1,10 @@ import { Position } from '@blueprintjs/core'; // eslint-disable-next-line import/no-cycle -import { ContentContext, INITIAL_LAYOUT_SETTINGS } from '@renderer/_shared/context/contentContext'; +import { useContentContext } from '@renderer/_shared/context/contentContext'; import { debounce } from 'lodash'; import React, { FC, useCallback, useLayoutEffect, useRef, useState } from 'react'; import Scrollbars from 'react-custom-scrollbars'; import { useHistory, useLocation } from 'react-router-dom'; -import { FixedSizeList } from 'react-window'; import ErrorBoundary from '../_shared/ErrorBoundary'; import { Toastr } from './components/Toastr'; @@ -13,11 +12,10 @@ export const ContentWrapper: FC = ({ children }) => { const contentRef = useRef(null); const history = useHistory(); const location = useLocation(); + const { list } = useContentContext(); - const [settings, setSettings] = useState(INITIAL_LAYOUT_SETTINGS); const [isScrolling, setIsScrolling] = useState(false); const [scrollLocations, setScrollLocations] = useState({}); - const [list, setList] = useState(null); // If we go back and know the scrollLocation of the previous page, scroll to it useLayoutEffect(() => { @@ -39,7 +37,8 @@ export const ContentWrapper: FC = ({ children }) => { }); return () => unregister(); - }, [history, isScrolling, scrollLocations]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const debouncedSetScrollPosition = useRef( debounce( @@ -58,8 +57,8 @@ export const ContentWrapper: FC = ({ children }) => { (e: React.ChangeEvent) => { const { scrollTop } = e.target; - if (list) { - list.scrollTo(scrollTop); + if (list?.current) { + list?.current.scrollTo(scrollTop); } debouncedSetScrollPosition.current(scrollTop, location.pathname); @@ -68,26 +67,18 @@ export const ContentWrapper: FC = ({ children }) => { ); return ( - setList(newList), - applySettings: newSettings => setSettings(oldSettings => ({ ...oldSettings, ...newSettings })) - }}> -

} - renderTrackHorizontal={() =>
} - renderTrackVertical={props =>
} - renderThumbHorizontal={() =>
} - renderThumbVertical={props =>
}> - +
} + renderTrackHorizontal={() =>
} + renderTrackVertical={props =>
} + renderThumbHorizontal={() =>
} + renderThumbVertical={props =>
}> + - {children} - - + {children} + ); }; diff --git a/src/renderer/app/Layout.tsx b/src/renderer/app/Layout.tsx index ce8d75b6..f1dade92 100644 --- a/src/renderer/app/Layout.tsx +++ b/src/renderer/app/Layout.tsx @@ -1,15 +1,14 @@ -import { IResizeEntry, ResizeSensor } from '@blueprintjs/core'; import { EVENTS } from '@common/constants/events'; -import * as actions from '@common/store/actions'; import { loadingErrorSelector } from '@common/store/app/selectors'; import { themeSelector } from '@common/store/selectors'; +import { ContentContextProvider } from '@renderer/_shared/context/contentContext'; import cn from 'classnames'; // eslint-disable-next-line import/no-extraneous-dependencies import { ipcRenderer } from 'electron'; import is from 'electron-is'; -import React, { FC, useCallback } from 'react'; +import React, { FC } from 'react'; import Theme from 'react-custom-properties'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { AudioPlayerProvider } from '../hooks/useAudioPlayer'; import AppError from './components/AppError/AppError'; import AboutModal from './components/modals/AboutModal/AboutModal'; @@ -20,61 +19,42 @@ import { Themes } from './components/Theme/themes'; import { ContentWrapper } from './ContentWrapper'; export const Layout: FC = ({ children }) => { - const dispatch = useDispatch(); - const theme = useSelector(themeSelector); const loadingError = useSelector(loadingErrorSelector); - // TODO: can this be removed? - const onResize = useCallback( - ([ - { - contentRect: { width, height } - } - ]: IResizeEntry[]) => { - // dispatch( - // actions.setDebouncedDimensions({ - // height, - // width - // }) - // ); - }, - [dispatch] - ); - return ( - - -
- {loadingError ? ( - { - ipcRenderer.send(EVENTS.APP.RELOAD); - }} - /> - ) : null} - -
- - + +
+ {loadingError ? ( + { + ipcRenderer.send(EVENTS.APP.RELOAD); + }} + /> + ) : null} + +
+ + + {children} + - - - -
+ + + +
- {/* Register Modals */} + {/* Register Modals */} - - -
-
-
+ + +
+ ); }; diff --git a/src/renderer/app/Main.tsx b/src/renderer/app/Main.tsx index 6108bcc7..fd1d9128 100644 --- a/src/renderer/app/Main.tsx +++ b/src/renderer/app/Main.tsx @@ -85,7 +85,7 @@ const Main: FC = ({ location: { search } }) => { - + diff --git a/src/renderer/app/components/Sidebar/Sidebar.tsx b/src/renderer/app/components/Sidebar/Sidebar.tsx index f594ea75..511e898a 100755 --- a/src/renderer/app/components/Sidebar/Sidebar.tsx +++ b/src/renderer/app/components/Sidebar/Sidebar.tsx @@ -1,4 +1,4 @@ -import { getAuthPlaylistsSelector, getCurrentPlaylistId } from '@common/store/selectors'; +import { getCurrentPlaylistId, getOwnedAuthPlaylistsSelector } from '@common/store/selectors'; import { PlaylistTypes } from '@common/store/types'; import React, { FC } from 'react'; import Scrollbars from 'react-custom-scrollbars'; @@ -10,8 +10,8 @@ import * as styles from './Sidebar.module.scss'; type AllProps = RouteComponentProps; const SideBar: FC = () => { - const authPlaylists = useSelector(state => getAuthPlaylistsSelector(state).owned); - const currentPlaylistId = useSelector(state => getCurrentPlaylistId(state)); + const authPlaylists = useSelector(getOwnedAuthPlaylistsSelector); + const currentPlaylistId = useSelector(getCurrentPlaylistId); return (
)} - + ); }, - [artist] + [artist, artistId] ); const artistTracksId = useMemo(() => ({ objectId: artistId, playlistType: PlaylistTypes.ARTIST_TRACKS }), [artistId]); diff --git a/src/renderer/pages/artist/components/ArtistProfiles/ArtistProfiles.tsx b/src/renderer/pages/artist/components/ArtistProfiles/ArtistProfiles.tsx index 58035031..2a9e3967 100644 --- a/src/renderer/pages/artist/components/ArtistProfiles/ArtistProfiles.tsx +++ b/src/renderer/pages/artist/components/ArtistProfiles/ArtistProfiles.tsx @@ -7,15 +7,15 @@ import { SoundCloud } from '../../../../../types'; import './ArtistProfiles.scss'; interface Props { - userUrn: string; + userId: string; className?: string; } -export const ArtistProfiles: FC = ({ userUrn, className }) => { +export const ArtistProfiles: FC = ({ userId, className }) => { const dispatch = useDispatch(); - const profiles = useSelector(getNormalizedUserProfiles(userUrn)); - const loading = useSelector(isUserProfilesLoading(userUrn)); - const error = useSelector(isUserProfilesError(userUrn)); + const profiles = useSelector(getNormalizedUserProfiles(userId)); + const loading = useSelector(isUserProfilesLoading(userId)); + const error = useSelector(isUserProfilesError(userId)); const getIcon = (service: string) => { switch (service) { @@ -56,9 +56,9 @@ export const ArtistProfiles: FC = ({ userUrn, className }) => { // Fetch user if it does not exist yet useEffect(() => { if (!profiles && !loading) { - dispatch(stopForwarding(getUserProfiles.request({ userUrn }))); + dispatch(stopForwarding(getUserProfiles.request({ userId }))); } - }, [loading, error, dispatch, profiles, userUrn]); + }, [loading, error, dispatch, profiles, userId]); if (!profiles?.length) { return null; @@ -69,20 +69,20 @@ export const ArtistProfiles: FC = ({ userUrn, className }) => { {profiles.map(profile => { const title = getTitle(profile.title); - const network = profile?.network; + const service = profile?.service; - let iconString = network.toString(); + let iconString = service.toString(); - if (network === SoundCloud.ProfileService.PERSONAL && title) { + if (service === SoundCloud.ProfileService.PERSONAL && title) { iconString = title; } const icon = `bx bx${getIcon(iconString)}`; return ( - + - {profile.title ? profile.title : network} + {profile.title ? profile.title : service} ); })} diff --git a/src/renderer/pages/settings/components/sections/MainSettings.tsx b/src/renderer/pages/settings/components/sections/MainSettings.tsx index d41dc713..47407fea 100644 --- a/src/renderer/pages/settings/components/sections/MainSettings.tsx +++ b/src/renderer/pages/settings/components/sections/MainSettings.tsx @@ -12,7 +12,6 @@ import { debounce } from 'lodash'; import React, { FC, useCallback, useMemo, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { CheckboxConfig } from '../CheckboxConfig'; -import { InputConfig } from '../InputConfig'; import { SelectConfig } from '../SelectConfig'; export interface SettingGroup { @@ -128,24 +127,6 @@ export const MainSettings: FC = ({ onShouldRestart }) => {
) - }, - { - authenticated: false, - setting: ( - - Read
here why and how. -
- } - /> - ) } ] }, diff --git a/src/renderer/pages/tags/TagsPage.tsx b/src/renderer/pages/tags/TagsPage.tsx index 315520f1..956a9d8b 100644 --- a/src/renderer/pages/tags/TagsPage.tsx +++ b/src/renderer/pages/tags/TagsPage.tsx @@ -3,74 +3,54 @@ import { searchPlaylistFetchMore } from '@common/store/actions'; import { PlaylistTypes } from '@common/store/objects'; import { getPlaylistObjectSelector } from '@common/store/selectors'; import { useLoadMorePromise } from '@renderer/hooks/useLoadMorePromise'; -import cn from 'classnames'; import React, { FC, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { NavLink, RouteComponentProps } from 'react-router-dom'; -import { Nav } from 'reactstrap'; +import { RouteComponentProps } from 'react-router-dom'; import PageHeader from '../../_shared/PageHeader/PageHeader'; import Spinner from '../../_shared/Spinner/Spinner'; import TracksGrid from '../../_shared/TracksGrid/TracksGrid'; -type Props = RouteComponentProps<{ tag: string; playlistType: PlaylistTypes }>; +type Props = RouteComponentProps<{ tag: string }>; export const TagsPage: FC = ({ match: { - params: { tag, playlistType = PlaylistTypes.SEARCH_TRACK } + params: { tag } } }) => { const dispatch = useDispatch(); - const playlistObject = useSelector(getPlaylistObjectSelector({ playlistType })); + const playlistObject = useSelector(getPlaylistObjectSelector({ playlistType: PlaylistTypes.SEARCH_TRACK })); useEffect(() => { - if (playlistType !== PlaylistTypes.SEARCH && playlistObject?.meta?.query !== tag) { + if (playlistObject?.meta?.query !== tag) { dispatch( actions.getSearchPlaylist({ - playlistType, + playlistType: PlaylistTypes.SEARCH_TRACK, refresh: true, tag }) ); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [playlistType]); + }, []); const { loadMore } = useLoadMorePromise( playlistObject?.isFetching, () => { - dispatch(searchPlaylistFetchMore({ playlistType })); + dispatch(searchPlaylistFetchMore({ playlistType: PlaylistTypes.SEARCH_TRACK })); }, - [dispatch, playlistType] + [dispatch] ); return ( <> - - -
- -
+ {!playlistObject || (!playlistObject?.items.length && playlistObject?.isFetching) ? ( ) : ( !!playlistObject.items[index]} loadMore={loadMore} diff --git a/src/types/soundcloud.ts b/src/types/soundcloud.ts index b5469c64..36fe1bde 100644 --- a/src/types/soundcloud.ts +++ b/src/types/soundcloud.ts @@ -32,7 +32,7 @@ export enum ProfileService { } export interface Profile extends Asset { - network: ProfileService; + service: ProfileService; title: string; url: string; username: string; diff --git a/yarn.lock b/yarn.lock index 748b3f61..5c9494ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1222,6 +1222,11 @@ "@types/yargs" "^15.0.0" chalk "^3.0.0" +"@mckayla/electron-redux@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@mckayla/electron-redux/-/electron-redux-3.0.1.tgz#33d592988621d71265b6e7bb2e7dd205bbec0cbe" + integrity sha512-nWbu5qg8ZdkNOj+9vuZaLQ3RzpLDMdVZBSnUEvNCVK5Pjp256ZHFnEefh9eRCHxqerH0R+ls/Jk30F+3zq4l6g== + "@nodelib/fs.scandir@2.1.3": version "2.1.3" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz#3a582bdb53804c6ba6d146579c46e52130cf4a3b" @@ -2229,6 +2234,18 @@ "@typescript-eslint/types" "4.9.0" eslint-visitor-keys "^2.0.0" +"@virtuoso.dev/react-urx@^0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@virtuoso.dev/react-urx/-/react-urx-0.2.4.tgz#68013bccc994f2756b76066d850ebc4fcebeffc6" + integrity sha512-rHUyHPGnra61i6kTCrAizB6qEcA4hbrnOtWgBw8sMotPcfKyE6FZHX06WE73An9g+YAI3ykWyXFr8EWyY0q3NA== + dependencies: + "@virtuoso.dev/urx" "^0.2.4" + +"@virtuoso.dev/urx@^0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@virtuoso.dev/urx/-/urx-0.2.4.tgz#0b74705fd41f13f55d5de274445ef7987406be61" + integrity sha512-93W+fGrK/eAddih/qJFXpUhMo4rLpu8qDmhWKOHqVhiUUzBNvkVqv8c+0z1OiEjxsBlP0XW46YGdBIJKcAjNzA== + "@webassemblyjs/ast@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359" @@ -2959,7 +2976,7 @@ arrify@^1.0.1: resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= -asap@~2.0.3: +asap@~2.0.3, asap@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= @@ -5004,6 +5021,11 @@ core-js@^2.4.0, core-js@^2.5.0: resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== +core-js@^3.5.0: + version "3.8.3" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.8.3.tgz#c21906e1f14f3689f93abcc6e26883550dd92dd0" + integrity sha512-KPYXeVZYemC2TkNEkX/01I+7yd+nX3KddKwZ1Ww7SKWdI2wQprSgLmrTddT8nw92AjEklTsPBoSdQBhbI1bQ6Q== + core-js@^3.6.5: version "3.8.0" resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.8.0.tgz#0fc2d4941cadf80538b030648bb64d230b4da0ce" @@ -6394,10 +6416,10 @@ electron@*: "@types/node" "^12.0.12" extract-zip "^1.0.3" -electron@^11.0.3: - version "11.0.3" - resolved "https://registry.yarnpkg.com/electron/-/electron-11.0.3.tgz#c29eaacda38ce561890e59906ca5f507c72b3ec4" - integrity sha512-nNfbLi7Q1xfJXOEO2adck5TS6asY4Jxc332E4Te8XfQ9hcaC3GiCdeEqk9FndNCwxhJA5Lr9jfSGRTwWebFa/w== +electron@^11.2.1: + version "11.2.1" + resolved "https://registry.yarnpkg.com/electron/-/electron-11.2.1.tgz#8641dd1a62911a1144e0c73c34fd9f37ccc65c2b" + integrity sha512-Im1y29Bnil+Nzs+FCTq01J1OtLbs+2ZGLLllaqX/9n5GgpdtDmZhS/++JHBsYZ+4+0n7asO+JKQgJD+CqPClzg== dependencies: "@electron/get" "^1.0.1" "@types/node" "^12.0.12" @@ -11737,9 +11759,9 @@ no-case@^2.2.0: lower-case "^1.1.1" node-addon-api@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.0.2.tgz#04bc7b83fd845ba785bb6eae25bc857e1ef75681" - integrity sha512-+D4s2HCnxPd5PjjI0STKwncjXTUKKqm74MDMz9OPXavjsGmjkvwgLtA5yoxJUdmpj52+2u+RrXgPipahKczMKg== + version "3.1.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.1.0.tgz#98b21931557466c6729e51cb77cd39c965f42239" + integrity sha512-flmrDNB06LIl5lywUz7YlNGZH/5p0M7W28k8hzd9Lshtdh1wshD2Y+U4h9LD6KObOy1f+fEVdgprPrEymjM5uw== node-fetch@^1.0.1: version "1.7.3" @@ -13547,6 +13569,13 @@ promise@^7.1.1: dependencies: asap "~2.0.3" +promise@^8.0.3: + version "8.1.0" + resolved "https://registry.yarnpkg.com/promise/-/promise-8.1.0.tgz#697c25c3dfe7435dd79fcd58c38a135888eaf05e" + integrity sha512-W04AqnILOL/sPRXziNicCjSNRruLAuIHEOVBazepu0545DDNGYHz7ar9ZgZ1fMU8/MA4mVxp5rkBWRi6OXIy3Q== + dependencies: + asap "~2.0.6" + prompts@^2.0.1: version "2.3.1" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.3.1.tgz#b63a9ce2809f106fa9ae1277c275b167af46ea05" @@ -13827,6 +13856,18 @@ react-addons-css-transition-group@^15.6.2: dependencies: react-transition-group "^1.2.0" +react-app-polyfill@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/react-app-polyfill/-/react-app-polyfill-1.0.6.tgz#890f8d7f2842ce6073f030b117de9130a5f385f0" + integrity sha512-OfBnObtnGgLGfweORmdZbyEz+3dgVePQBb3zipiaDsMHV1NpWm0rDFYIVXFV/AK+x4VIIfWHhrdMIeoTLyRr2g== + dependencies: + core-js "^3.5.0" + object-assign "^4.1.1" + promise "^8.0.3" + raf "^3.4.1" + regenerator-runtime "^0.13.3" + whatwg-fetch "^3.0.0" + react-custom-properties@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/react-custom-properties/-/react-custom-properties-1.2.0.tgz#e58213bd3e720c75cf1a955044c4dbddefb8f107" @@ -13893,6 +13934,11 @@ react-infinite-scroll-component@^4.5.3: resolved "https://registry.yarnpkg.com/react-infinite-scroll-component/-/react-infinite-scroll-component-4.5.3.tgz#008c2ec358628b490117ffc4aa6ce6982b26f8be" integrity sha512-8O0PIeYZx0xFVS1ChLlLl/1obn64vylzXeheLsm+t0qUibmet7U6kDaKFg6jVRQJwDikWBTcyqEFFsxrbFCO5w== +react-infinite-scroll-hook@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/react-infinite-scroll-hook/-/react-infinite-scroll-hook-3.0.0.tgz#13b1b4f95769a75da9d615905f4cbf78ccebf1f1" + integrity sha512-uLWsLAZ4jJqv6iHq4w7sySK6P059xgh1I98MSiV3UhZacMELhcdr9AycobC41ctIXU/Z9+caPNWwiJJZHwKCaw== + react-is@^16.12.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.6, react-is@^16.9.0: version "16.12.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" @@ -14063,6 +14109,16 @@ react-virtualized-auto-sizer@^1.0.2: resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz#a61dd4f756458bbf63bd895a92379f9b70f803bd" integrity sha512-MYXhTY1BZpdJFjUovvYHVBmkq79szK/k7V3MO+36gJkWGkrXKtyr4vCPtpphaTLRAdDNoYEYFZWE8LjN+PIHNg== +react-virtuoso@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-1.4.0.tgz#a3a8d8bbbfd53403f01fdeab3ae007bfdc3a01ca" + integrity sha512-t+qv9Af2dpoekyQ0Y2crXBCRY0TgKSINGhZAyM8B7suBLmAMfpnhYZP4WL72Rg1+/2XFVZM9IzJOaexlcBMK9g== + dependencies: + "@virtuoso.dev/react-urx" "^0.2.4" + "@virtuoso.dev/urx" "^0.2.4" + react-app-polyfill "^1.0.6" + resize-observer-polyfill "^1.5.1" + react-window-infinite-loader@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/react-window-infinite-loader/-/react-window-infinite-loader-1.0.5.tgz#6fe094d538a88978c2c9b623052bc50cb28c2abc" @@ -14327,6 +14383,11 @@ regenerator-runtime@^0.13.2: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5" integrity sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw== +regenerator-runtime@^0.13.3: + version "0.13.7" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" + integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== + regenerator-runtime@^0.13.4: version "0.13.5" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697" @@ -17535,6 +17596,11 @@ whatwg-fetch@>=0.10.0: resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb" integrity sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q== +whatwg-fetch@^3.0.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.5.0.tgz#605a2cd0a7146e5db141e29d1c62ab84c0c4c868" + integrity sha512-jXkLtsR42xhXg7akoDKvKWE40eJeI+2KZqcp2h3NsOrRnDvtWX36KcKl30dy+hxECivdk2BVUHVNrPtoMBUx6A== + whatwg-mimetype@^2.2.0, whatwg-mimetype@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" From eb89aa5e4bd519d75784a6fd37b652207d33db43 Mon Sep 17 00:00:00 2001 From: Jonas Snellinckx Date: Sat, 30 Jan 2021 13:18:29 +0100 Subject: [PATCH 06/13] WIP - electron-forge --- .gitignore | 3 +- .vscode/settings.json | 3 +- package.json | 109 +- src/common/api/fetchTrack.ts | 23 - src/common/api/helpers/fetchToJson.ts | 2 +- src/common/api/helpers/fetchToJsonNew.ts | 4 +- src/common/schemas/index.ts | 2 +- src/common/schemas/playlist.ts | 2 +- src/common/schemas/track.ts | 2 +- src/common/store/actions.ts | 5 +- src/common/store/app/actions.ts | 2 - src/common/store/app/api.ts | 31 +- src/common/store/app/selectors.ts | 12 +- src/common/store/appAuth/reducer.ts | 4 +- src/common/store/auth/api.ts | 2 +- src/common/store/auth/reducer.ts | 12 +- src/common/store/auth/selectors.ts | 28 +- src/common/store/auth/types.ts | 2 +- src/common/store/config/reducer.ts | 2 +- src/common/store/config/selectors.ts | 14 +- src/common/store/entities/selectors.ts | 24 +- src/common/store/index.ts | 16 +- src/common/store/objects/epics.ts | 33 - src/common/store/objects/reducer.ts | 10 +- src/common/store/objects/selectors.ts | 4 +- src/common/store/player/actions.ts | 4 +- src/common/store/player/reducer.ts | 6 +- src/common/store/player/selectors.ts | 16 +- src/common/store/track/api.ts | 14 + src/common/store/track/reducer.ts | 4 +- src/common/store/track/selectors.ts | 5 +- src/common/store/ui/reducer.ts | 4 +- src/common/store/ui/selectors.ts | 4 +- src/common/store/user/api.ts | 11 - src/common/store/user/reducer.ts | 8 +- src/common/store/user/selectors.ts | 8 +- src/common/utils/errors/EpicError.ts | 4 +- src/common/utils/soundcloudUtils.ts | 2 +- src/globals.d.ts | 1 + src/index.ejs | 17 +- src/main/app.ts | 95 +- src/main/{store => }/epics/app.ts | 10 +- src/main/{store => }/epics/auth.ts | 12 +- src/main/{store => }/epics/config.ts | 6 +- .../{store/rootEpic.ts => epics/index.ts} | 6 +- src/main/features/core/appUpdater.ts | 8 +- src/main/features/core/applicationMenu.ts | 4 +- .../core/chromecast/chromecastManager.ts | 2 +- .../features/core/chromecast/deviceScanner.ts | 14 +- .../core/chromecast/parseMdnsQuery.ts | 4 +- src/main/features/core/ipcManager.ts | 2 +- src/main/features/feature.ts | 18 +- src/main/features/index.ts | 2 +- src/main/features/linux/dbusService.ts | 2 +- src/main/features/linux/mprisService.ts | 6 +- src/main/index.ts | 2 +- src/main/utils/logger.ts | 4 +- src/main/utils/pkce.ts | 9 +- src/renderer/App.tsx | 10 +- src/renderer/_shared/InfiniteScroll.tsx | 2 +- .../TrackList/TrackListItem/TrackListItem.tsx | 2 +- .../_shared/TracksGrid/TracksGrid.tsx | 4 +- .../_shared/context/contentContext.tsx | 4 +- src/renderer/app/ContentWrapper.tsx | 8 +- .../Header/components/Search/SearchBox.tsx | 6 +- src/renderer/app/components/Queue/Queue.tsx | 8 +- .../app/components/Queue/QueueItem.tsx | 6 +- .../app/components/Sidebar/Sidebar.tsx | 8 +- .../Sidebar/playlist/SideBarPlaylistItem.tsx | 2 +- src/renderer/app/components/Toastr.tsx | 4 +- src/renderer/app/components/player/Player.tsx | 4 +- .../components/player/components/Audio.tsx | 8 +- .../player/components/CastPopover.tsx | 4 +- .../PlayerControls/PlayerControls.tsx | 2 +- .../PlayerProgress/PlayerProgress.tsx | 2 +- .../player/components/TrackInfo/TrackInfo.tsx | 2 +- .../app/epics.ts => renderer/epics/app.ts} | 27 +- .../epics.ts => renderer/epics/appAuth.ts} | 24 +- .../auth/epics.ts => renderer/epics/auth.ts} | 42 +- .../rootEpic.ts => renderer/epics/index.ts} | 18 +- .../epics => renderer/epics/player}/index.ts | 0 .../epics => renderer/epics/player}/player.ts | 60 +- .../epics => renderer/epics/player}/queue.ts | 14 +- .../epics.ts => renderer/epics/playlist.ts} | 85 +- .../epics.ts => renderer/epics/track.ts} | 32 +- .../ui/epics.ts => renderer/epics/ui.ts} | 14 +- .../user/epics.ts => renderer/epics/user.ts} | 20 +- src/renderer/hooks/useLoadMorePromise.tsx | 2 +- src/renderer/pages/GenericPlaylist/index.tsx | 2 +- .../ArtistProfiles/ArtistProfiles.tsx | 2 +- .../pages/charts/ChartsDetailsPage.tsx | 4 +- src/renderer/pages/charts/ChartsPage.tsx | 4 +- src/renderer/pages/foryou/ForYouPage.tsx | 18 +- .../PersonalizedPlaylistCard.tsx | 2 +- src/renderer/pages/playlist/PlaylistPage.tsx | 4 +- src/renderer/pages/search/SearchPage.tsx | 2 +- .../pages/settings/components/InputConfig.tsx | 2 +- .../components/sections/AdvancedSettings.tsx | 4 +- .../components/sections/MainSettings.tsx | 12 +- src/renderer/pages/tags/TagsPage.tsx | 2 +- .../pages/track/components/TrackOverview.tsx | 2 +- tsconfig.json | 10 +- webpack.main.config.js | 28 + webpack.plugins.js | 18 + webpack.renderer.config.js | 127 + webpack.rules.js | 27 + yarn.lock | 2962 +++++++++++++---- 107 files changed, 2974 insertions(+), 1347 deletions(-) delete mode 100755 src/common/api/fetchTrack.ts delete mode 100644 src/common/store/objects/epics.ts rename src/main/{store => }/epics/app.ts (77%) rename src/main/{store => }/epics/auth.ts (95%) rename src/main/{store => }/epics/config.ts (82%) rename src/main/{store/rootEpic.ts => epics/index.ts} (80%) rename src/{common/store/app/epics.ts => renderer/epics/app.ts} (62%) rename src/{common/store/appAuth/epics.ts => renderer/epics/appAuth.ts} (76%) rename src/{common/store/auth/epics.ts => renderer/epics/auth.ts} (87%) rename src/{common/store/rootEpic.ts => renderer/epics/index.ts} (51%) rename src/{common/store/player/epics => renderer/epics/player}/index.ts (100%) rename src/{common/store/player/epics => renderer/epics/player}/player.ts (92%) rename src/{common/store/player/epics => renderer/epics/player}/queue.ts (89%) rename src/{common/store/playlist/epics.ts => renderer/epics/playlist.ts} (91%) rename src/{common/store/track/epics.ts => renderer/epics/track.ts} (76%) rename src/{common/store/ui/epics.ts => renderer/epics/ui.ts} (79%) rename src/{common/store/user/epics.ts => renderer/epics/user.ts} (73%) create mode 100644 webpack.main.config.js create mode 100644 webpack.plugins.js create mode 100644 webpack.renderer.config.js create mode 100644 webpack.rules.js diff --git a/.gitignore b/.gitignore index 1a579438..e6749216 100755 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,5 @@ AUR-repo .cache-loader test/e2e/_utils/token.txt auryo-snap -dll \ No newline at end of file +dll +.webpack/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 3287616b..b4cbaf05 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,5 +22,6 @@ }, "typescript.tsdk": "node_modules/typescript/lib", "eslint.packageManager": "yarn", - "eslint.enable": true + "javascript.preferences.importModuleSpecifier": "non-relative", + "typescript.preferences.importModuleSpecifier": "non-relative" } \ No newline at end of file diff --git a/package.json b/package.json index 55c168f5..0771235b 100755 --- a/package.json +++ b/package.json @@ -3,13 +3,13 @@ "repository": "Superjo149/auryo", "homepage": "http://auryo.com", "productName": "Auryo", - "version": "2.5.3", + "version": "3.0.0", "author": { "name": "Jonas Snellinckx", "email": "jonas.snellinckx@gmail.com" }, "description": "Listen to SoundCloud® from the comfort of your desktop. Use keyboard shortcuts to navigate through your music. Be more productive.", - "main": "./dist/electron/main.js", + "main": ".webpack/main/index", "scripts": { "build": "node .electron-react/build.js", "build:dir": "cross-env IS_LOCAL=1 node .electron-react/build.js && electron-builder --dir", @@ -33,7 +33,11 @@ "release": "electron-builder -wl --ia32 --x64 --publish onTagOrDraft", "release:win": "electron-builder -w --ia32 --x64 --publish onTagOrDraft", "release:linux": "electron-builder -l --x64 --publish onTagOrDraft", - "release:mac": "electron-builder -m --x64 --publish onTagOrDraft" + "release:mac": "electron-builder -m --x64 --publish onTagOrDraft", + "start": "electron-forge start", + "package": "electron-forge package", + "make": "electron-forge make", + "publish": "electron-forge publish" }, "devDependencies": { "@babel/core": "^7.8.4", @@ -47,9 +51,16 @@ "@babel/preset-env": "^7.8.4", "@babel/preset-react": "^7.8.3", "@babel/preset-typescript": "^7.8.3", + "@electron-forge/cli": "^6.0.0-beta.54", + "@electron-forge/maker-deb": "^6.0.0-beta.54", + "@electron-forge/maker-rpm": "^6.0.0-beta.54", + "@electron-forge/maker-squirrel": "^6.0.0-beta.54", + "@electron-forge/maker-zip": "^6.0.0-beta.54", + "@electron-forge/plugin-webpack": "6.0.0-beta.54", + "@marshallofsound/webpack-asset-relocator-loader": "^0.5.0", "@sentry/cli": "^1.47.1", "@sentry/webpack-plugin": "^1.8.0", - "@teamsupercell/typings-for-css-modules-loader": "^2.0.0", + "@teamsupercell/typings-for-css-modules-loader": "^2.4.0", "@types/autolinker": "^0.24.28", "@types/classnames": "^2.2.6", "@types/electron": "^1.6.10", @@ -68,12 +79,12 @@ "@types/node-fetch": "^2.5.7", "@types/node-sass": "^3.10.32", "@types/pino-multi-stream": "^3.1.1", - "@types/react": "^16.9.19", + "@types/react": "^17.0.0", "@types/react-addons-test-utils": "^0.14.22", "@types/react-addons-transition-group": "^15.0.3", "@types/react-click-outside": "^3.0.2", "@types/react-custom-scrollbars": "^4.0.5", - "@types/react-dom": "^16.9.5", + "@types/react-dom": "^17.0.0", "@types/react-infinite-scroll-component": "^4.2.4", "@types/react-input-autosize": "^2.0.0", "@types/react-list": "^0.8.5", @@ -96,8 +107,8 @@ "@types/webpack-bundle-analyzer": "^2.13.1", "@types/webpack-merge": "^4.1.5", "@types/webpack-node-externals": "^1.6.3", - "@typescript-eslint/eslint-plugin": "^4.9.0", - "@typescript-eslint/parser": "^4.9.0", + "@typescript-eslint/eslint-plugin": "^4.0.1", + "@typescript-eslint/parser": "^4.0.1", "ajv": "^6.11.0", "asar": "^2.0.1", "autoprefixer": "^9.6.1", @@ -121,12 +132,12 @@ "core-js": "3", "cross-env": "^7.0.0", "css-hot-loader": "^1.3.9", - "css-loader": "^3.4.2", + "css-loader": "^5.0.1", "del": "^5.1.0", "detect-port": "^1.3.0", "dotenv": "^8.1.0", "dotenv-webpack": "^1.7.0", - "electron": "^11.2.1", + "electron": "11.2.1", "electron-builder": "^22.9.1", "electron-debug": "^3.0.1", "electron-devtools-installer": "^3.1.1", @@ -137,7 +148,7 @@ "enzyme-adapter-react-16": "^1.15.2", "enzyme-to-json": "^3.4.4", "escape-string-regexp": "^1.0.5", - "eslint": "^7.0.0", + "eslint": "^7.6.0", "eslint-config-airbnb": "^18.1.0", "eslint-config-airbnb-typescript": "^7.2.1", "eslint-config-prettier": "^6.11.0", @@ -147,7 +158,7 @@ "eslint-import-resolver-typescript": "^2.0.0", "eslint-loader": "^4.0.2", "eslint-plugin-html": "^6.0.2", - "eslint-plugin-import": "^2.20.2", + "eslint-plugin-import": "^2.20.0", "eslint-plugin-jsx-a11y": "^6.2.3", "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^3.1.3", @@ -156,47 +167,47 @@ "eslint-plugin-react-hooks": "^4.0.2", "eslint-plugin-standard": "^4.0.1", "file-loader": "^5.0.2", - "fork-ts-checker-webpack-plugin": "^4.0.3", + "fork-ts-checker-webpack-plugin": "^5.0.14", "html-webpack-plugin": "^3.2.0", "husky": "^1.1.3", "identity-obj-proxy": "^3.0.0", - "image-webpack-loader": "^6.0.0", + "image-webpack-loader": "^7.0.1", "inject-loader": "^4.0.1", "jest": "^25.1.0", - "mini-css-extract-plugin": "^0.9.0", + "mini-css-extract-plugin": "^1.3.5", "mocha": "^5.2.0", "multispinner": "^0.2.1", "node-gyp": "^6.1.0", - "node-loader": "^0.6.0", - "node-sass": "^4.13.1", + "node-loader": "^1.0.1", "optimize-css-assets-webpack-plugin": "^5.0.3", "ora": "^2.1.0", "patch-package": "^6.2.2", "pbf-loader": "^1.1.0", + "pino-pretty": "^4.3.0", "postcss-import": "^11.1.0", - "postcss-loader": "^2.1.5", + "postcss-loader": "^4.2.0", "postcss-modules": "^1.4.1", "postcss-url": "^7.3.2", "react-hot-loader": "^4.12.19", "redux-logger": "^3.0.6", "rimraf": "^2.6.2", - "sass": "^1.14.0", - "sass-loader": "^8.0.2", + "sass": "^1.32.5", + "sass-loader": "^10.1.1", "shebang-loader": "^0.0.1", "source-map-loader": "^0.2.4", "spec-xunit-file": "^0.0.1-3", "spectron": "^10.0.0", - "style-loader": "^1.1.3", + "style-loader": "^2.0.0", "terser-webpack-plugin": "^2.3.4", "thread-loader": "^2.1.3", "ts-jest": "^25.1.0", - "ts-loader": "^6.2.1", + "ts-loader": "^8.0.2", "ts-node": "^8.6.2", "tsconfig-paths-webpack-plugin": "^3.2.0", "tslint": "^6.0.0", - "typescript": "^4.1.2", + "typescript": "^4.0.2", "typings-for-css-modules-loader": "^1.7.0", - "url-loader": "^3.0.0", + "url-loader": "^4.1.1", "webpack": "^4.41.5", "webpack-build-notifier": "^1.0.3", "webpack-bundle-analyzer": "^3.5.2", @@ -225,6 +236,7 @@ "electron-is": "^3.0.0", "electron-localshortcut": "^3.2.1", "electron-redux": "file:../related/electron-redux", + "electron-squirrel-startup": "^1.0.0", "electron-store": "^6.0.1", "electron-updater": "^4.3.5", "electron-window-state": "^5.0.3", @@ -238,9 +250,7 @@ "node-fetch": "^2.6.1", "normalizr": "^3.2.2", "object-path-immutable": "^4.1.0", - "pino": "^5.12.5", - "pino-multi-stream": "^4.0.2", - "pino-pretty": "^3.0.1", + "pino": "^6.11.0", "popper.js": "^1.12.9", "prop-types": "^15.6.0", "react": "^16.12.0", @@ -388,5 +398,50 @@ }, "resolutions": { "**/**/nan": "^2.9.2" + }, + "config": { + "forge": { + "packagerConfig": {}, + "makers": [ + { + "name": "@electron-forge/maker-squirrel", + "config": { + "name": "my_new_app" + } + }, + { + "name": "@electron-forge/maker-zip", + "platforms": [ + "darwin" + ] + }, + { + "name": "@electron-forge/maker-deb", + "config": {} + }, + { + "name": "@electron-forge/maker-rpm", + "config": {} + } + ], + "plugins": [ + [ + "@electron-forge/plugin-webpack", + { + "mainConfig": "./webpack.main.config.js", + "renderer": { + "config": "./webpack.renderer.config.js", + "entryPoints": [ + { + "html": "./src/index.ejs", + "js": "./src/renderer/index.tsx", + "name": "main_window" + } + ] + } + } + ] + ] + } } } diff --git a/src/common/api/fetchTrack.ts b/src/common/api/fetchTrack.ts deleted file mode 100755 index a64305b9..00000000 --- a/src/common/api/fetchTrack.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { normalize } from 'normalizr'; -import { Normalized, SoundCloud } from '@types'; -import { trackSchema } from '../schemas'; -import { SC } from '../utils'; -import fetchToJson from './helpers/fetchToJson'; - -type JsonResponse = SoundCloud.Track; - -export default async function fetchTrack( - trackId: string | number -): Promise<{ - json: JsonResponse; - normalized: Normalized.NormalizedResponse; -}> { - const json = await fetchToJson(SC.getTrackUrl(trackId)); - - const normalized = normalize(json, trackSchema); - - return { - normalized, - json - }; -} diff --git a/src/common/api/helpers/fetchToJson.ts b/src/common/api/helpers/fetchToJson.ts index 74a90b0c..366a239a 100755 --- a/src/common/api/helpers/fetchToJson.ts +++ b/src/common/api/helpers/fetchToJson.ts @@ -8,5 +8,5 @@ export default async function fetchToJson(url: string, options: AxiosRequestC url, ...options }) - .then(res => res.data); + .then((res) => res.data); } diff --git a/src/common/api/helpers/fetchToJsonNew.ts b/src/common/api/helpers/fetchToJsonNew.ts index e07c064b..6bc34808 100755 --- a/src/common/api/helpers/fetchToJsonNew.ts +++ b/src/common/api/helpers/fetchToJsonNew.ts @@ -28,6 +28,8 @@ export default function fetchToJsonNew(fetchOptions: FetchOptions, options: R } if (fetchOptions.oauthToken) { + if (!memToken) throw new Error('No token'); + queryParams.oauth_token = memToken; } @@ -38,7 +40,7 @@ export default function fetchToJsonNew(fetchOptions: FetchOptions, options: R } return fromFetch(fetchOptions.url ?? `${baseUrl}${fetchOptions.uri}?${querystring.stringify(queryParams)}`, { - selector: response => { + selector: (response) => { if (!response.ok) throw response; return response.json(); diff --git a/src/common/schemas/index.ts b/src/common/schemas/index.ts index 8ddd6545..5db62638 100755 --- a/src/common/schemas/index.ts +++ b/src/common/schemas/index.ts @@ -12,7 +12,7 @@ export const genericSchema = new schema.Array( tracks: trackSchema, users: userSchema }, - input => `${input.kind}s` + (input) => `${input.kind}s` ); export const normalizeArray = (data: T[]) => { diff --git a/src/common/schemas/playlist.ts b/src/common/schemas/playlist.ts index 9009f58b..a2f43da5 100755 --- a/src/common/schemas/playlist.ts +++ b/src/common/schemas/playlist.ts @@ -8,7 +8,7 @@ const playlistSchema = new schema.Entity('playlistEntities', { { tracks: trackSchema }, - input => `${input.kind}s` + (input) => `${input.kind}s` ) }); diff --git a/src/common/schemas/track.ts b/src/common/schemas/track.ts index ba6ae72c..2dba1d31 100755 --- a/src/common/schemas/track.ts +++ b/src/common/schemas/track.ts @@ -7,7 +7,7 @@ const trackSchema = new schema.Entity( user: userSchema }, { - processStrategy: entity => { + processStrategy: (entity) => { if (entity.likes_count || entity.favoritings_count) { entity.likes_count = entity.likes_count || entity.favoritings_count; } diff --git a/src/common/store/actions.ts b/src/common/store/actions.ts index b5c0572b..81b17bb1 100644 --- a/src/common/store/actions.ts +++ b/src/common/store/actions.ts @@ -1,3 +1,5 @@ +import { goBack, push, replace } from 'connected-react-router'; + export * from './app/actions'; export * from './appAuth/actions'; export * from './auth/actions'; @@ -7,5 +9,4 @@ export * from './playlist/actions'; export * from './track/actions'; export * from './ui/actions'; export * from './user/actions'; - -export { push, replace, goBack } from 'connected-react-router'; +export { push, replace, goBack }; diff --git a/src/common/store/app/actions.ts b/src/common/store/app/actions.ts index 61b37ffa..4daeca67 100644 --- a/src/common/store/app/actions.ts +++ b/src/common/store/app/actions.ts @@ -51,8 +51,6 @@ export const setUpdateAvailable = (version: string) => version }); -const listeners: any[] = []; - // export function initWatchers(): ThunkResult { // // tslint:disable-next-line: max-func-body-length // return dispatch => { diff --git a/src/common/store/app/api.ts b/src/common/store/app/api.ts index 1dbfb752..825f30d9 100644 --- a/src/common/store/app/api.ts +++ b/src/common/store/app/api.ts @@ -1,5 +1,4 @@ import fetchToJsonNew from '@common/api/helpers/fetchToJsonNew'; -import { RemainingPlays } from './types'; interface FetchRemainingTracksResponse { statuses: Status[]; @@ -16,29 +15,9 @@ interface Status { reset_time: string; } -// TODO -export async function fetchRemainingTracks(): Promise { - try { - const json = await fetchToJsonNew({ - uri: 'rate_limit_status', - clientId: true - }); - - if (!json.statuses.length) { - return null; - } - - const plays = json.statuses.find(t => t.rate_limit.name === 'plays'); - - if (plays) { - return { - remaining: plays.remaining_requests, - resetTime: new Date(plays.reset_time).getTime() - }; - } - - return null; - } catch (err) { - return null; - } +export function fetchRemainingTracks() { + return fetchToJsonNew({ + uri: 'rate_limit_status', + clientId: true + }); } diff --git a/src/common/store/app/selectors.ts b/src/common/store/app/selectors.ts index 2382ae76..3b2c24cc 100644 --- a/src/common/store/app/selectors.ts +++ b/src/common/store/app/selectors.ts @@ -3,9 +3,9 @@ import { createSelector } from 'reselect'; export const appSelector = (state: StoreState) => state.app; -export const isUpdateAvailableSelector = createSelector(appSelector, app => app.update.available); -export const remainingPlaysSelector = createSelector(appSelector, app => app.remainingPlays); -export const castSelector = createSelector(appSelector, app => app.chromecast); -export const isPlayingOnChromecastSelector = createSelector(castSelector, chromecast => !!chromecast.castApp); -export const loadingErrorSelector = createSelector(appSelector, app => app.loadingError); -export const lastFmLoadingSelector = createSelector(appSelector, app => app.lastfmLoading); +export const isUpdateAvailableSelector = createSelector(appSelector, (app) => app.update.available); +export const remainingPlaysSelector = createSelector(appSelector, (app) => app.remainingPlays); +export const castSelector = createSelector(appSelector, (app) => app.chromecast); +export const isPlayingOnChromecastSelector = createSelector(castSelector, (chromecast) => !!chromecast.castApp); +export const loadingErrorSelector = createSelector(appSelector, (app) => app.loadingError); +export const lastFmLoadingSelector = createSelector(appSelector, (app) => app.lastfmLoading); diff --git a/src/common/store/appAuth/reducer.ts b/src/common/store/appAuth/reducer.ts index 39f78130..0670ca16 100755 --- a/src/common/store/appAuth/reducer.ts +++ b/src/common/store/appAuth/reducer.ts @@ -26,13 +26,13 @@ export const appAuthReducer = createReducer(initialState) codeVerifier: payload.codeVerifier }; }) - .handleAction(verifyLoginSession, state => { + .handleAction(verifyLoginSession, (state) => { return { ...state, isLoading: true }; }) - .handleAction([login.success, login.cancel], state => { + .handleAction([login.success, login.cancel], (state) => { return { ...state, isLoading: false diff --git a/src/common/store/auth/api.ts b/src/common/store/auth/api.ts index c77a20d8..ad1e1c22 100644 --- a/src/common/store/auth/api.ts +++ b/src/common/store/auth/api.ts @@ -64,7 +64,7 @@ export function fetchPlaylists() { }); return json$.pipe( - map(json => { + map((json) => { const normalized = normalize< FetchedPlaylistItem, EntitiesOf, diff --git a/src/common/store/auth/reducer.ts b/src/common/store/auth/reducer.ts index dc5e13e3..578f1f99 100755 --- a/src/common/store/auth/reducer.ts +++ b/src/common/store/auth/reducer.ts @@ -35,13 +35,13 @@ const initialState: AuthState = { } }, personalizedPlaylists: { - loading: false, + isLoading: false, items: null } }; export const authReducer = createReducer(initialState) - .handleAction(getCurrentUser.request, state => { + .handleAction(getCurrentUser.request, (state) => { return { ...state, me: { @@ -67,7 +67,7 @@ export const authReducer = createReducer(initialState) me: { ...state.me, isLoading: false, - error: action.payload.error + error: action.payload as any } }; }) @@ -149,7 +149,7 @@ export const authReducer = createReducer(initialState) } }; }) - .handleAction(getCurrentUserPlaylists.request, state => { + .handleAction(getCurrentUserPlaylists.request, (state) => { return { ...state, playlists: { @@ -175,11 +175,11 @@ export const authReducer = createReducer(initialState) playlists: { ...state.playlists, isLoading: false, - error: action.payload.error + error: action.payload } }; }) - .handleAction(getForYouSelection.request, state => { + .handleAction(getForYouSelection.request, (state) => { return { ...state, personalizedPlaylists: { diff --git a/src/common/store/auth/selectors.ts b/src/common/store/auth/selectors.ts index 1f5207af..11bc6e7d 100644 --- a/src/common/store/auth/selectors.ts +++ b/src/common/store/auth/selectors.ts @@ -7,21 +7,21 @@ import { getEntities } from '../entities/selectors'; export const getAuth = (state: StoreState) => state.auth; -export const isCurrentUserLoading = createSelector(getAuth, auth => auth.me.isLoading); -export const currentUserSelector = createSelector(getAuth, auth => auth.me.data); -export const currentUserErrorSelector = createSelector(getAuth, auth => auth.me.error); +export const isCurrentUserLoading = createSelector(getAuth, (auth) => auth.me.isLoading); +export const currentUserSelector = createSelector(getAuth, (auth) => auth.me.data); +export const currentUserErrorSelector = createSelector(getAuth, (auth) => auth.me.error); -export const getFollowings = createSelector(getAuth, auth => auth.followings || {}); +export const getFollowings = createSelector(getAuth, (auth) => auth.followings || {}); -export const getAuthLikesSelector = createSelector(getAuth, auth => auth.likes || {}); -export const getAuthPersonalizedPlaylistsSelector = createSelector(getAuth, auth => auth.personalizedPlaylists || {}); -export const getAuthPlaylistLikesSelector = createSelector(getAuthLikesSelector, auth => auth.playlist); -export const getAuthRepostsSelector = createSelector(getAuth, auth => auth.reposts || {}); +export const getAuthLikesSelector = createSelector(getAuth, (auth) => auth.likes || {}); +export const getAuthPersonalizedPlaylistsSelector = createSelector(getAuth, (auth) => auth.personalizedPlaylists || {}); +export const getAuthPlaylistLikesSelector = createSelector(getAuthLikesSelector, (auth) => auth.playlist); +export const getAuthRepostsSelector = createSelector(getAuth, (auth) => auth.reposts || {}); -export const getAuthPlaylistsSelector = createSelector(getAuth, auth => auth.playlists.data); +export const getAuthPlaylistsSelector = createSelector(getAuth, (auth) => auth.playlists.data); export const getOwnedAuthPlaylistsSelector = createSelector( getAuthPlaylistsSelector, - authPlaylists => authPlaylists.owned + (authPlaylists) => authPlaylists.owned ); export type CombinedUserPlaylistState = { title: string; id: number } & ObjectState; @@ -31,7 +31,7 @@ export const getUserPlaylistsCombined = createSelector( getPlaylistsObjects, getEntities, (playlists, objects, entities) => - playlists.owned.map(p => ({ + playlists.owned.map((p) => ({ ...objects[p.id], id: p.id, title: (entities.playlistEntities[p.id] || {}).title || '' @@ -39,8 +39,8 @@ export const getUserPlaylistsCombined = createSelector( ); export const isFollowing = (userId: number | string) => - createSelector(getFollowings, followings => SC.hasID(userId, followings)); + createSelector(getFollowings, (followings) => SC.hasID(userId, followings)); export const hasLiked = (trackId: number | string, type: 'playlist' | 'track' | 'systemPlaylist' = 'track') => - createSelector(getAuthLikesSelector, likes => SC.hasID(trackId, likes[type])); + createSelector(getAuthLikesSelector, (likes) => SC.hasID(trackId, likes[type])); export const hasReposted = (trackId: number | string, type: 'playlist' | 'track' = 'track') => - createSelector(getAuthRepostsSelector, reposts => SC.hasID(trackId, reposts[type])); + createSelector(getAuthRepostsSelector, (reposts) => SC.hasID(trackId, reposts[type])); diff --git a/src/common/store/auth/types.ts b/src/common/store/auth/types.ts index 0f36b77f..ef4447cf 100644 --- a/src/common/store/auth/types.ts +++ b/src/common/store/auth/types.ts @@ -18,7 +18,7 @@ export type AuthState = Readonly<{ data: AuthPlaylists; }; personalizedPlaylists: { - loading: boolean; + isLoading: boolean; error?: EpicError | Error | null; items: Normalized.NormalizedPersonalizedItem[] | null; }; diff --git a/src/common/store/config/reducer.ts b/src/common/store/config/reducer.ts index c66b8e10..ba73a034 100644 --- a/src/common/store/config/reducer.ts +++ b/src/common/store/config/reducer.ts @@ -1,4 +1,4 @@ -import immutable from 'object-path-immutable'; +import * as immutable from 'object-path-immutable'; import { createReducer } from 'typesafe-actions'; import { CONFIG } from '../../../config'; import { setConfig, setConfigKey } from './actions'; diff --git a/src/common/store/config/selectors.ts b/src/common/store/config/selectors.ts index e39ba5c7..3a59a268 100644 --- a/src/common/store/config/selectors.ts +++ b/src/common/store/config/selectors.ts @@ -3,10 +3,10 @@ import { createSelector } from 'reselect'; export const configSelector = (state: StoreState) => state.config; -export const authTokenStateSelector = createSelector([configSelector], config => config.auth); -export const shuffleSelector = createSelector([configSelector], config => config.shuffle); -export const repeatSelector = createSelector([configSelector], config => config.repeat); -export const appVersionSelector = createSelector([configSelector], config => config.version); -export const audioConfigSelector = createSelector([configSelector], config => config.audio); -export const themeSelector = createSelector([configSelector], config => config.app.theme); -export const isAuthenticatedSelector = createSelector([authTokenStateSelector], auth => !!auth.token); +export const authTokenStateSelector = createSelector([configSelector], (config) => config.auth); +export const shuffleSelector = createSelector([configSelector], (config) => config.shuffle); +export const repeatSelector = createSelector([configSelector], (config) => config.repeat); +export const appVersionSelector = createSelector([configSelector], (config) => config.version); +export const audioConfigSelector = createSelector([configSelector], (config) => config.audio); +export const themeSelector = createSelector([configSelector], (config) => config.app.theme); +export const isAuthenticatedSelector = createSelector([authTokenStateSelector], (auth) => !!auth.token); diff --git a/src/common/store/entities/selectors.ts b/src/common/store/entities/selectors.ts index fa566fe8..8ed9066f 100644 --- a/src/common/store/entities/selectors.ts +++ b/src/common/store/entities/selectors.ts @@ -10,30 +10,30 @@ export const getEntities = (state: StoreState) => state.entities; export const getPlaylistEntities = () => createSelector, EntitiesState['playlistEntities']>( getEntities, - entities => entities.playlistEntities + (entities) => entities.playlistEntities ); -export const getUserEntities = () => createSelector(getEntities, entities => entities.userEntities); -export const getUserProfilesEntities = () => createSelector(getEntities, entities => entities.userProfileEntities); +export const getUserEntities = () => createSelector(getEntities, (entities) => entities.userEntities); +export const getUserProfilesEntities = () => createSelector(getEntities, (entities) => entities.userProfileEntities); export const getCommentEntities = () => createSelector, EntitiesState['commentEntities']>( getEntities, - entities => entities.commentEntities + (entities) => entities.commentEntities ); export const getTrackEntities = () => createSelector, EntitiesState['trackEntities']>( getEntities, - entities => entities.trackEntities + (entities) => entities.trackEntities ); export const getDenormalizedEntities = (result: Normalized.NormalizedResult[]) => - createSelector, T[]>(getEntities, entities => + createSelector, T[]>(getEntities, (entities) => denormalize(result, genericSchema, entities) ); export const getDenormalizedEntity = (result: Normalized.NormalizedResult) => - createSelector(getDenormalizedEntities([result]), entities => entities[0]); + createSelector(getDenormalizedEntities([result]), (entities) => entities[0]); export const getMusicEntity = getDenormalizedEntity; export const getUserEntity = (id: number) => getDenormalizedEntity({ id, schema: 'users' }); @@ -47,17 +47,17 @@ export const getCommentEntity = (id: number) => export const getNormalizedPlaylist = (id: string | number) => createSelector( getPlaylistEntities(), - entities => entities[id] + (entities) => entities[id] ); export const getNormalizedUser = (id?: number | string) => createSelector( getUserEntities(), - entities => (id ? entities[id] : null) + (entities) => (id ? entities[id] : null) ); export const getNormalizedUserForPage = (id?: number | string) => - createSelector(getNormalizedUser(id), user => { + createSelector(getNormalizedUser(id), (user) => { if (user?.followers_count !== undefined && user?.followings_count !== undefined) { return user; } @@ -68,7 +68,7 @@ export const getNormalizedUserForPage = (id?: number | string) => export const getNormalizedTrack = (id?: number | string) => createSelector( getTrackEntities(), - entities => { + (entities) => { if (id) { const track = entities[id]; @@ -82,4 +82,4 @@ export const getNormalizedTrack = (id?: number | string) => ); export const getNormalizedUserProfiles = (userId?: string) => - createSelector(getUserProfilesEntities(), entities => (userId ? entities?.[userId] : null)); + createSelector(getUserProfilesEntities(), (entities) => (userId ? entities?.[userId] : null)); diff --git a/src/common/store/index.ts b/src/common/store/index.ts index b7b34e52..f1857a99 100755 --- a/src/common/store/index.ts +++ b/src/common/store/index.ts @@ -1,7 +1,7 @@ import { resetStore } from '@common/store/actions'; import { PlayerActionTypes } from '@common/store/player'; import { rootReducer } from '@common/store/rootReducer'; -import { mainRootEpic } from '@main/store/rootEpic'; +import { mainRootEpic } from '@main/epics'; import { Logger } from '@main/utils/logger'; import { StoreState } from 'AppReduxTypes'; import { routerMiddleware } from 'connected-react-router'; @@ -17,7 +17,7 @@ import { createLogger } from 'redux-logger'; import { createEpicMiddleware } from 'redux-observable'; import { BehaviorSubject } from 'rxjs'; import { RootAction } from './declarations'; -import { rootEpic } from './rootEpic'; +import { rootEpic } from '@renderer/epics'; export const history = createMemoryHistory(); @@ -34,7 +34,7 @@ export const configureStore = () => { collapsed: true, predicate: (_getState: () => any, action: any) => action.type !== PlayerActionTypes.SET_TIME }) - : () => next => action => { + : () => (next) => (action) => { const reduxLogger = Logger.createLogger('REDUX'); if (action.error) { reduxLogger.error(action.type, action.error); @@ -80,7 +80,7 @@ export const configureStore = () => { electron.ipcMain.on('electron-redux.ACTION', (event, action) => { const localAction = stopForwarding(action); store.dispatch(localAction); // Forward it to all of the other renderers - electron.webContents.getAllWebContents().forEach(contents => { + electron.webContents.getAllWebContents().forEach((contents) => { // Ignore the renderer that sent the action and chromium devtools if (contents.id !== event.sender.id && !contents.getURL().startsWith('devtools://')) { contents.send('electron-redux.ACTION', localAction); @@ -97,8 +97,12 @@ export const configureStore = () => { ); }); - module.hot.accept('@common/store/rootEpic', () => { - import('@common/store/rootEpic').then(({ rootEpic: nextRootEpic }) => epic$.next(nextRootEpic)); + module.hot.accept('@renderer/epics', () => { + import('@renderer/epics').then(({ rootEpic: nextRootEpic }) => epic$.next(nextRootEpic)); + }); + + module.hot.accept('@main/epics', () => { + import('@main/epics').then(({ mainRootEpic: nextRootEpic }) => epic$.next(nextRootEpic)); }); } diff --git a/src/common/store/objects/epics.ts b/src/common/store/objects/epics.ts deleted file mode 100644 index 8f85a624..00000000 --- a/src/common/store/objects/epics.ts +++ /dev/null @@ -1,33 +0,0 @@ -// import { APIService } from '@common/api'; -// import { from, of } from 'rxjs'; -// import { catchError, filter, map, switchMap } from 'rxjs/operators'; -// import { isActionOf } from 'typesafe-actions'; -// import { RootEpic } from '../types'; -// import { searchAsync } from './playlists/search/actions'; -// import { isSoundCloudUrl } from '@common/utils'; - -// export const searchEpic: RootEpic = action$ => -// action$.pipe( -// filter(isActionOf(searchAsync.request)), -// map(action => action.payload), -// filter(({ query }) => !isSoundCloudUrl(query)), -// switchMap(({ query }) => -// from(APIService.searchAll(query)).pipe( -// map(searchAsync.success), -// catchError(message => of(searchAsync.failure(message))) -// ) -// ) -// ); - -// export const resolveSoundCloudUrl: RootEpic = action$ => -// action$.pipe( -// filter(isActionOf(searchAsync.request)), -// map(action => action.payload), -// filter(({ query }) => isSoundCloudUrl(query)), -// switchMap(({ query }) => -// from(APIService.searchAll(query)).pipe( -// map(searchAsync.success), -// catchError(message => of(searchAsync.failure(message))) -// ) -// ) -// ); diff --git a/src/common/store/objects/reducer.ts b/src/common/store/objects/reducer.ts index d8764485..8cf79433 100755 --- a/src/common/store/objects/reducer.ts +++ b/src/common/store/objects/reducer.ts @@ -28,7 +28,7 @@ const initialObjectsState: ObjectState = { }; const objectState = createReducer(initialObjectsState) - .handleAction([getGenericPlaylist.request, setPlaylistLoading, getComments.request, setCommentsLoading], state => { + .handleAction([getGenericPlaylist.request, setPlaylistLoading, getComments.request, setCommentsLoading], (state) => { return { ...state, isFetching: true, @@ -55,12 +55,12 @@ const objectState = createReducer(initialObjectsState) let itemsToFetch: Normalized.NormalizedResult[] = []; if (payload.fetchedItemsIds) { - itemsToAdd = itemsToAdd.filter(i => payload.fetchedItemsIds?.includes(i.id)); + itemsToAdd = itemsToAdd.filter((i) => payload.fetchedItemsIds?.includes(i.id)); if (payload.result.length !== itemsToAdd.length) { // Filter out difference between arrays itemsToFetch = [payload.result, itemsToAdd].reduce((a, b) => - a.filter(c => !b.map(({ id }) => id).includes(c.id)) + a.filter((c) => !b.map(({ id }) => id).includes(c.id)) ); } } @@ -84,7 +84,7 @@ const objectState = createReducer(initialObjectsState) .handleAction(genericPlaylistFetchMore.success, (state, { payload }) => { const { result = [], fetchedItemsIds = [] } = payload; - const itemsToFetch = state.itemsToFetch.filter(a => !fetchedItemsIds.includes(a.id)); + const itemsToFetch = state.itemsToFetch.filter((a) => !fetchedItemsIds.includes(a.id)); return { ...state, @@ -512,7 +512,7 @@ export const objectsReducer = createReducer(initialState) const queuePlaylist = state[PlaylistTypes.QUEUE]; const newItems = [...queuePlaylist.items]; - const indexToReplace = _.findIndex(newItems, item => _.isEqual(item, playlistItem)); + const indexToReplace = _.findIndex(newItems, (item) => _.isEqual(item, playlistItem)); if (indexToReplace === -1) { return state; diff --git a/src/common/store/objects/selectors.ts b/src/common/store/objects/selectors.ts index 5e7410f3..0961f0d4 100644 --- a/src/common/store/objects/selectors.ts +++ b/src/common/store/objects/selectors.ts @@ -9,7 +9,7 @@ export const getPlaylistRootObject = (playlistType: PlaylistTypes | ObjectTypes) export const getPlaylistObjectSelector = (identifier: PlaylistIdentifier) => createSelector( [getPlaylistRootObject(identifier.playlistType)], - playlistsOrObjectState => + (playlistsOrObjectState) => identifier.objectId ? playlistsOrObjectState[identifier.objectId] : playlistsOrObjectState ); @@ -20,7 +20,7 @@ export const getQueueTrackByIndexSelector = (index: number) => (state: StoreStat export const getCommentsObjects = (state: StoreState) => state.objects[ObjectTypes.COMMENTS] || {}; export const getCommentObject = (trackId?: string | number) => - createSelector([getCommentsObjects], comments => + createSelector([getCommentsObjects], (comments) => trackId && trackId in comments ? comments[trackId] : null ); diff --git a/src/common/store/player/actions.ts b/src/common/store/player/actions.ts index 33d51258..df58869a 100755 --- a/src/common/store/player/actions.ts +++ b/src/common/store/player/actions.ts @@ -1,9 +1,9 @@ import { wError, wSuccess } from '@common/utils/reduxUtils'; -import { Normalized, SoundCloud } from '@types'; +import { Normalized } from '@types'; import { createAction, createAsyncAction } from 'typesafe-actions'; import { ObjectStateItem } from '../objects'; import { PlaylistIdentifier } from '../playlist'; -import { ChangeTypes, PlayerActionTypes, PlayerStatus, PlayingTrack } from '../types'; +import { ChangeTypes, PlayerActionTypes, PlayerStatus } from '../types'; export const toggleShuffle = createAction(PlayerActionTypes.TOGGLE_SHUFFLE)(); export const toggleStatus = createAction(PlayerActionTypes.TOGGLE_STATUS, (status?: PlayerStatus) => status)(); diff --git a/src/common/store/player/reducer.ts b/src/common/store/player/reducer.ts index d94c2635..2986cd0c 100755 --- a/src/common/store/player/reducer.ts +++ b/src/common/store/player/reducer.ts @@ -70,7 +70,7 @@ export const playerReducer = createReducer(initialState) currentTime: payload }; }) - .handleAction(restartTrack, state => { + .handleAction(restartTrack, (state) => { return { ...state, currentTime: 0 @@ -83,7 +83,7 @@ export const playerReducer = createReducer(initialState) return { ...state, - upNext: [...state.upNext, ...items.map(item => ({ ...item, un: Date.now() }))] + upNext: [...state.upNext, ...items.map((item) => ({ ...item, un: Date.now() }))] }; }) // Remove first item as it is inserted into the queue @@ -104,7 +104,7 @@ export const playerReducer = createReducer(initialState) currentIndex: position }; }) - .handleAction(clearUpNext, state => { + .handleAction(clearUpNext, (state) => { return { ...state, upNext: [] diff --git a/src/common/store/player/selectors.ts b/src/common/store/player/selectors.ts index 7506326d..2242fe29 100644 --- a/src/common/store/player/selectors.ts +++ b/src/common/store/player/selectors.ts @@ -8,14 +8,14 @@ import { PlaylistIdentifier } from '../types'; export const getPlayerNode = (state: StoreState) => state.player; -export const getPlayingTrackSelector = createSelector([getPlayerNode], player => player.playingTrack); -export const getPlayerCurrentTime = createSelector([getPlayerNode], player => player.currentTime); -export const getPlayingTrackIndex = createSelector([getPlayerNode], player => player.currentIndex); -export const getUpNextSelector = createSelector([getPlayerNode], player => player.upNext); -export const getPlayerStatusSelector = createSelector([getPlayerNode], player => player.status); -export const getCurrentPlaylistId = createSelector([getPlayerNode], player => player.currentPlaylistId || null); +export const getPlayingTrackSelector = createSelector([getPlayerNode], (player) => player.playingTrack); +export const getPlayerCurrentTime = createSelector([getPlayerNode], (player) => player.currentTime); +export const getPlayingTrackIndex = createSelector([getPlayerNode], (player) => player.currentIndex); +export const getUpNextSelector = createSelector([getPlayerNode], (player) => player.upNext); +export const getPlayerStatusSelector = createSelector([getPlayerNode], (player) => player.status); +export const getCurrentPlaylistId = createSelector([getPlayerNode], (player) => player.currentPlaylistId || null); -export const upNextStartSelector = createSelector([getPlayingTrackIndex], currentIndex => currentIndex + 1); +export const upNextStartSelector = createSelector([getPlayingTrackIndex], (currentIndex) => currentIndex + 1); export const upNextEndSelector = createSelector( [upNextStartSelector, getUpNextSelector], (upNextStart, upNext) => upNextStart + upNext.length @@ -50,7 +50,7 @@ export const getNormalizedSchemaForType = ( }); export const isPlayingSelector = (playlistId: PlaylistIdentifier, idResult?: Normalized.NormalizedResult) => - createSelector([getPlayingTrackSelector], playingTrack => { + createSelector([getPlayingTrackSelector], (playingTrack) => { if (!playingTrack) { return false; } diff --git a/src/common/store/track/api.ts b/src/common/store/track/api.ts index 83638794..657673c9 100644 --- a/src/common/store/track/api.ts +++ b/src/common/store/track/api.ts @@ -8,6 +8,20 @@ export function fetchTrack(options: { trackId: string | number }) { }); } +interface Streams { + http_mp3_128_url: string; + hls_mp3_128_url: string; + hls_opus_64_url: string; + preview_mp3_128_url: string; +} + +export function fetchStreams(options: { trackId: string | number }) { + return fetchToJsonNew({ + uri: `tracks/${options.trackId}/streams`, + oauthToken: true + }); +} + export function fetchComments(options: { trackId: number; limit?: number }) { return fetchToJsonNew>({ uri: `tracks/${options.trackId}/comments`, diff --git a/src/common/store/track/reducer.ts b/src/common/store/track/reducer.ts index 524d5cee..b33b1c19 100644 --- a/src/common/store/track/reducer.ts +++ b/src/common/store/track/reducer.ts @@ -31,7 +31,7 @@ export const trackReducer = createReducer(initialState) delete errors[trackId]; return { - loading: state.loading.filter(id => id !== trackId), + loading: state.loading.filter((id) => id !== trackId), error: { ...errors } @@ -41,7 +41,7 @@ export const trackReducer = createReducer(initialState) const { trackId, error } = action.payload; return { - loading: state.loading.filter(id => id !== trackId), + loading: state.loading.filter((id) => id !== trackId), error: { ...state.error, [trackId]: error diff --git a/src/common/store/track/selectors.ts b/src/common/store/track/selectors.ts index 49cf70ef..cfe29dfe 100644 --- a/src/common/store/track/selectors.ts +++ b/src/common/store/track/selectors.ts @@ -4,9 +4,10 @@ import { createSelector } from 'reselect'; export const getTrackNode = (state: StoreState) => state.track; export const isTrackLoading = (trackId?: string | number) => - createSelector([getTrackNode], track => { + createSelector([getTrackNode], (track) => { if (!trackId) return true; return track.loading.includes(+trackId); }); -export const isTrackError = (trackId: number | string) => createSelector([getTrackNode], track => track.error[trackId]); +export const isTrackError = (trackId: number | string) => + createSelector([getTrackNode], (track) => track.error[trackId]); diff --git a/src/common/store/ui/reducer.ts b/src/common/store/ui/reducer.ts index 856f074f..46a1f8a0 100644 --- a/src/common/store/ui/reducer.ts +++ b/src/common/store/ui/reducer.ts @@ -12,7 +12,7 @@ const initialState: UIState = { }; export const uiReducer = createReducer(initialState) - .handleAction(clearToasts, state => { + .handleAction(clearToasts, (state) => { return { ...state, toasts: [] @@ -27,7 +27,7 @@ export const uiReducer = createReducer(initialState) .handleAction(removeToast, (state, action) => { return { ...state, - toasts: [...state.toasts.filter(t => t.key === action.payload)] + toasts: [...state.toasts.filter((t) => t.key === action.payload)] }; }) // .handleAction(setDimensions, (state, action) => { diff --git a/src/common/store/ui/selectors.ts b/src/common/store/ui/selectors.ts index f76ee2c3..be59ea89 100644 --- a/src/common/store/ui/selectors.ts +++ b/src/common/store/ui/selectors.ts @@ -3,5 +3,5 @@ import { createSelector } from 'reselect'; export const getUi = (state: StoreState) => state.ui; -export const getSearchQuerySelector = createSelector(getUi, state => state.searchQuery); -export const getToastsSelector = createSelector(getUi, state => state.toasts); +export const getSearchQuerySelector = createSelector(getUi, (state) => state.searchQuery); +export const getToastsSelector = createSelector(getUi, (state) => state.toasts); diff --git a/src/common/store/user/api.ts b/src/common/store/user/api.ts index 679532f4..6b02f9e6 100644 --- a/src/common/store/user/api.ts +++ b/src/common/store/user/api.ts @@ -31,17 +31,6 @@ export function fetchUserTracks(options: { userId: number | string; limit?: numb }); } -export function fetchUserLikes(options: { userId: string | string; limit?: number }) { - return fetchToJsonNew>({ - uri: `users/${options.userId}/likes/tracks`, - oauthToken: true, - queryParams: { - limit: options.limit ?? 20, - linked_partitioning: true - } - }); -} - export function fetchUserProfiles(options: { userId: string }) { return fetchToJsonNew({ uri: `users/${options.userId}/web-profiles`, diff --git a/src/common/store/user/reducer.ts b/src/common/store/user/reducer.ts index 2a1e62ab..866293fb 100644 --- a/src/common/store/user/reducer.ts +++ b/src/common/store/user/reducer.ts @@ -35,7 +35,7 @@ export const userReducer = createReducer(initialState) return { ...state, - loading: state.loading.filter(id => id !== userId), + loading: state.loading.filter((id) => id !== userId), error: { ...errors } @@ -46,7 +46,7 @@ export const userReducer = createReducer(initialState) return { ...state, - loading: state.loading.filter(id => id !== userId), + loading: state.loading.filter((id) => id !== userId), error: { ...state.error, [userId]: error @@ -77,7 +77,7 @@ export const userReducer = createReducer(initialState) return { ...state, - userProfilesLoading: state.userProfilesLoading.filter(id => id !== userId), + userProfilesLoading: state.userProfilesLoading.filter((id) => id !== userId), userProfilesError: { ...errors } @@ -88,7 +88,7 @@ export const userReducer = createReducer(initialState) return { ...state, - userProfilesLoading: state.userProfilesLoading.filter(id => id !== userId), + userProfilesLoading: state.userProfilesLoading.filter((id) => id !== userId), userProfilesError: { ...state.error, [userId]: error diff --git a/src/common/store/user/selectors.ts b/src/common/store/user/selectors.ts index d1e2d639..9a4570a7 100644 --- a/src/common/store/user/selectors.ts +++ b/src/common/store/user/selectors.ts @@ -3,10 +3,10 @@ import { createSelector } from 'reselect'; export const getUser = (state: StoreState) => state.user; -export const isUserLoading = (userId: string) => createSelector([getUser], user => user.loading.includes(+userId)); -export const isUserError = (userId: number | string) => createSelector([getUser], user => user.error[userId]); +export const isUserLoading = (userId: string) => createSelector([getUser], (user) => user.loading.includes(+userId)); +export const isUserError = (userId: number | string) => createSelector([getUser], (user) => user.error[userId]); export const isUserProfilesLoading = (userId: string) => - createSelector([getUser], user => user.userProfilesLoading.includes(userId)); + createSelector([getUser], (user) => user.userProfilesLoading.includes(userId)); export const isUserProfilesError = (userId: number | string) => - createSelector([getUser], user => user.userProfilesError[userId]); + createSelector([getUser], (user) => user.userProfilesError[userId]); diff --git a/src/common/utils/errors/EpicError.ts b/src/common/utils/errors/EpicError.ts index d858c204..c5d317b9 100644 --- a/src/common/utils/errors/EpicError.ts +++ b/src/common/utils/errors/EpicError.ts @@ -1,6 +1,6 @@ import { logout, tokenRefresh } from '@common/store/actions'; -import { _RootAction } from '@common/store/declarations'; import { Logger } from '@main/utils/logger'; +import { RootAction } from 'AppReduxTypes'; import { ActionsObservable } from 'redux-observable'; import { EMPTY, ObservableInput, of } from 'rxjs'; import { filter, mergeMapTo, startWith, take, takeUntil } from 'rxjs/operators'; @@ -20,7 +20,7 @@ export class EpicError extends Error { const logger = Logger.createLogger('EPIC'); -export const handleEpicError = (action$: ActionsObservable<_RootAction>, actionOnFail?: A) => ( +export const handleEpicError = (action$: ActionsObservable, actionOnFail?: A) => ( error: any, source: ObservableInput ) => { diff --git a/src/common/utils/soundcloudUtils.ts b/src/common/utils/soundcloudUtils.ts index 8da065f5..e072f60d 100755 --- a/src/common/utils/soundcloudUtils.ts +++ b/src/common/utils/soundcloudUtils.ts @@ -37,7 +37,7 @@ function makeUrl(uri: string, opts: any, v2 = false) { // Add query params url += `?${Object.keys(options) - .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(options[k])}`) + .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(options[k])}`) .join('&')}`; return url; diff --git a/src/globals.d.ts b/src/globals.d.ts index b00b5147..1740154c 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -7,6 +7,7 @@ declare namespace NodeJS { __static: any; fetch: any; AbortController: any; + MAIN_WINDOW_WEBPACK_ENTRY: string; } interface ProcessEnv { readonly NODE_ENV: 'development' | 'production' | 'test'; diff --git a/src/index.ejs b/src/index.ejs index 5db40570..96d49255 100644 --- a/src/index.ejs +++ b/src/index.ejs @@ -13,24 +13,11 @@ - <% if (htmlWebpackPlugin.options.nodeModules) { %> - - - <% } %> +
- - <% if (!process.browser) { %> - - <% } %> + diff --git a/src/main/app.ts b/src/main/app.ts index 33c42965..8475909f 100755 --- a/src/main/app.ts +++ b/src/main/app.ts @@ -1,9 +1,7 @@ -import { Intent } from '@blueprintjs/core'; -import fetchTrack from '@common/api/fetchTrack'; -import { addToast, push, receiveProtocolAction } from '@common/store/actions'; +import { push, receiveProtocolAction } from '@common/store/actions'; +import { fetchStreams } from '@common/store/api'; // eslint-disable-next-line import/no-unresolved import { StoreState } from 'AppReduxTypes'; -import Axios from 'axios'; // eslint-disable-next-line import/no-extraneous-dependencies import { app, BrowserWindow, BrowserWindowConstructorOptions, Event, Menu, nativeImage, shell } from 'electron'; import { stopForwarding } from 'electron-redux'; @@ -13,8 +11,8 @@ import * as os from 'os'; import * as path from 'path'; import * as querystring from 'querystring'; import { Store } from 'redux'; -import { Track } from 'src/types/soundcloud'; -import { CONFIG } from '../config'; +import { EMPTY, from, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; // eslint-disable-next-line import/no-cycle import { Feature } from './features/feature'; import { Logger, LoggerInstance } from './utils/logger'; @@ -170,9 +168,7 @@ export class Auryo { return; } - const winURL = process.env.NODE_ENV === 'development' ? 'http://localhost:9080' : `file://${__dirname}/index.html`; - - await this.mainWindow.loadURL(winURL); + await this.mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY); this.mainWindow.webContents.on('will-navigate', async (event, url) => { event.preventDefault(); @@ -210,71 +206,30 @@ export class Auryo { } }); - // SoundCloud's API gave a lot of 401s using the /stream to get the audio file - // This is a hacky way to circumvent this :) + // Resolve the trackId for a stream url this.mainWindow.webContents.session.webRequest.onBeforeRequest( { - urls: ['http://localhost:8888/stream/*'] + urls: ['http://resolve-stream/*'] }, - async (details, callback) => { - const { 1: trackId } = details.url.split('http://localhost:8888/stream/'); - try { - const mp3Url = await this.getPlayingTrackStreamUrl(trackId, CONFIG.CLIENT_ID || ''); - - callback({ - redirectURL: mp3Url - }); - } catch (err) { - this.logger.error('Soundcloud stream hack', err); - callback({ cancel: true }); - } + (details, callback) => { + const { 1: trackId } = details.url.split('http://resolve-stream/'); + + return from(fetchStreams({ trackId })) + .pipe( + map((streams) => { + callback({ + redirectURL: streams.http_mp3_128_url + }); + }), + catchError((err) => { + this.logger.error(err, 'Soundcloud stream hack'); + callback({ cancel: true }); + return of(EMPTY); + }) + ) + .toPromise(); } ); - - this.mainWindow.webContents.session.webRequest.onCompleted(details => { - if ( - this.mainWindow && - (details.url.indexOf('/stream?client_id=') !== -1 || details.url.indexOf('cf-media.sndcdn.com') !== -1) - ) { - if (details.statusCode < 200 && details.statusCode > 300) { - if (details.statusCode === 404) { - this.store.dispatch( - addToast({ - message: 'This resource might not exists anymore', - intent: Intent.DANGER - }) - ); - } - } - } - }); - } - - public async getPlayingTrackStreamUrl(trackId: string, clientId: string) { - const { - entities: { trackEntities } - } = this.store.getState(); - - let track: Track = trackEntities[trackId]; - - if (!track?.media?.transcodings) { - const { json } = await fetchTrack(trackId); - - track = json; - } - - const streamUrl = track?.media?.transcodings?.filter( - (transcoding: any) => transcoding.format.protocol === 'progressive' - )[0]?.url; - - if (!streamUrl) { - return null; - } - - const response = await Axios.get(`${streamUrl}?client_id=${clientId}`); - const mp3Url = response.data.url; - - return mp3Url; } private readonly registerListeners = () => { @@ -291,7 +246,7 @@ export class Auryo { this.mainWindow = undefined; }); - this.mainWindow.on('close', event => { + this.mainWindow.on('close', (event) => { if (process.platform === 'darwin') { if (this.quitting) { this.mainWindow = undefined; diff --git a/src/main/store/epics/app.ts b/src/main/epics/app.ts similarity index 77% rename from src/main/store/epics/app.ts rename to src/main/epics/app.ts index 16160aa8..07b4c9d0 100644 --- a/src/main/store/epics/app.ts +++ b/src/main/epics/app.ts @@ -8,25 +8,25 @@ import { isActionOf } from 'typesafe-actions'; const logger = Logger.createLogger('EPIC/main/app'); -export const copyToClipboardEpic: RootEpic = action$ => +export const copyToClipboardEpic: RootEpic = (action$) => // @ts-expect-error action$.pipe( filter(isActionOf(copyToClipboard)), pluck('payload'), - tap(text => clipboard.writeText(text)), + tap((text) => clipboard.writeText(text)), ignoreElements() ); -export const openExternalEpic: RootEpic = action$ => +export const openExternalEpic: RootEpic = (action$) => // @ts-expect-error action$.pipe( filter(isActionOf(openExternalUrl)), pluck('payload'), - tap(url => shell.openExternal(url).catch(logger.error)), + tap((url) => shell.openExternal(url).catch(logger.error)), ignoreElements() ); -export const restartAppEpic: RootEpic = action$ => +export const restartAppEpic: RootEpic = (action$) => // @ts-expect-error action$.pipe( filter(isActionOf(restartApp)), diff --git a/src/main/store/epics/auth.ts b/src/main/epics/auth.ts similarity index 95% rename from src/main/store/epics/auth.ts rename to src/main/epics/auth.ts index 853717aa..8b1488be 100644 --- a/src/main/store/epics/auth.ts +++ b/src/main/epics/auth.ts @@ -33,11 +33,11 @@ import { } from 'rxjs/operators'; import { isActionOf } from 'typesafe-actions'; import { v4 } from 'uuid'; -import { CONFIG } from '../../../config'; +import { CONFIG } from '../../config'; const logger = Logger.createLogger('EPIC/main/auth'); -export const loginEpic: RootEpic = action$ => +export const loginEpic: RootEpic = (action$) => // @ts-expect-error action$.pipe( filter(isActionOf(login.request)), @@ -92,7 +92,7 @@ export const loginEpic: RootEpic = action$ => ).pipe( pluck('data'), map(login.success), - catchError(err => { + catchError((err) => { logger.error('Error during login', err); return of(logout(), login.failure({})); }) @@ -102,7 +102,7 @@ export const loginEpic: RootEpic = action$ => of(login.failure({ message: 'The session may have expired, please try logging in again.' })) ) ), - catchError(err => { + catchError((err) => { if (err.name === 'TimeoutError') { return of(login.cancel({})); } @@ -131,14 +131,14 @@ export const tokenRefreshEpic: RootEpic = (action$, state$) => headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - selector: res => { + selector: (res) => { if (!res.ok) throw res as any; return res.json(); } }).pipe( map(tokenRefresh.success), - catchError(err => { + catchError((err) => { logger.error('Error refreshing token', err); return of(logout(), tokenRefresh.failure({})); }) diff --git a/src/main/store/epics/config.ts b/src/main/epics/config.ts similarity index 82% rename from src/main/store/epics/config.ts rename to src/main/epics/config.ts index e0cbdc25..df07a22b 100644 --- a/src/main/store/epics/config.ts +++ b/src/main/epics/config.ts @@ -1,5 +1,6 @@ import { setConfig, setConfigKey } from '@common/store/actions'; import { RootEpic } from '@common/store/declarations'; +import { SC } from '@common/utils'; import { settings } from '@main/settings'; import { Logger } from '@main/utils/logger'; import { debounceTime, filter, ignoreElements, map, tap, withLatestFrom } from 'rxjs/operators'; @@ -14,7 +15,10 @@ export const saveSettingsEpic: RootEpic = (action$, state$) => withLatestFrom(state$), debounceTime(500), map(([, state]) => state.config), - tap(latestConfig => settings.set(latestConfig)), + tap((latestConfig) => { + settings.set(latestConfig); + SC.initialize(latestConfig.auth.token); + }), tap(() => logger.trace('Settings saved')), ignoreElements() ); diff --git a/src/main/store/rootEpic.ts b/src/main/epics/index.ts similarity index 80% rename from src/main/store/rootEpic.ts rename to src/main/epics/index.ts index 7b9c47de..7f7cf668 100644 --- a/src/main/store/rootEpic.ts +++ b/src/main/epics/index.ts @@ -3,9 +3,9 @@ import AbortController from 'abort-controller'; import { StoreState } from 'AppReduxTypes'; import fetch from 'node-fetch'; import { combineEpics } from 'redux-observable'; -import * as app from './epics/app'; -import * as auth from './epics/auth'; -import * as config from './epics/config'; +import * as app from './app'; +import * as auth from './auth'; +import * as config from './config'; // This is a polyfill for rxjs fetch global.fetch = fetch; diff --git a/src/main/features/core/appUpdater.ts b/src/main/features/core/appUpdater.ts index 166170ff..5612e0de 100755 --- a/src/main/features/core/appUpdater.ts +++ b/src/main/features/core/appUpdater.ts @@ -64,12 +64,12 @@ export default class AppUpdater extends Feature { this.logger.info('New update available'); }); - autoUpdater.addListener('update-downloaded', info => { + autoUpdater.addListener('update-downloaded', (info) => { this.notify(info.version); this.listenUpdate(); }); - autoUpdater.addListener('error', error => { + autoUpdater.addListener('error', (error) => { this.logger.error(error); }); autoUpdater.addListener('checking-for-update', () => { @@ -108,8 +108,8 @@ export default class AppUpdater extends Feature { public updateLinux = () => { axios .get(CONFIG.UPDATE_SERVER_HOST) - .then(res => res.data) - .then(body => { + .then((res) => res.data) + .then((body) => { if (!body || body.draft || !body.tag_name) { return; } diff --git a/src/main/features/core/applicationMenu.ts b/src/main/features/core/applicationMenu.ts index 96a3ec2a..735a8c6c 100755 --- a/src/main/features/core/applicationMenu.ts +++ b/src/main/features/core/applicationMenu.ts @@ -236,7 +236,7 @@ export default class ApplicationMenu extends Feature { const trackId = playingTrack.id; const track = trackEntities[trackId]; - const index = template.findIndex(r => r.label === 'Track'); + const index = template.findIndex((r) => r.label === 'Track'); if (trackId && track) { const liked = SC.hasID(track.id, likes.track); @@ -261,7 +261,7 @@ export default class ApplicationMenu extends Feature { } } } else { - (template[index].submenu as MenuItemConstructorOptions[]).map(s => { + (template[index].submenu as MenuItemConstructorOptions[]).map((s) => { s.enabled = false; // eslint-disable-line return s; diff --git a/src/main/features/core/chromecast/chromecastManager.ts b/src/main/features/core/chromecast/chromecastManager.ts index 572c9107..68515114 100755 --- a/src/main/features/core/chromecast/chromecastManager.ts +++ b/src/main/features/core/chromecast/chromecastManager.ts @@ -54,7 +54,7 @@ export default class ChromecastManager extends Feature { } if (currentValue) { - const device = devices.find(d => d.id === currentValue); + const device = devices.find((d) => d.id === currentValue); if (!device) { return; diff --git a/src/main/features/core/chromecast/deviceScanner.ts b/src/main/features/core/chromecast/deviceScanner.ts index f43b7783..4889e4c0 100644 --- a/src/main/features/core/chromecast/deviceScanner.ts +++ b/src/main/features/core/chromecast/deviceScanner.ts @@ -16,13 +16,13 @@ const handler: MDNSHandler = { onResponse: () => {} }; mdns.on('response', (res: any) => handler.onResponse(res)); export async function scanDevices(store: Store): Promise { - return new Promise(resolve => { + return new Promise((resolve) => { const deviceUpdates: ChromeCastDevice[] = []; // update mDNS handler with this iteration's instance of deviceUpdates - handler.onResponse = res => { + handler.onResponse = (res) => { const device = parseResponse(res); - if (device && !deviceUpdates.find(d => d.id === device.id)) { + if (device && !deviceUpdates.find((d) => d.id === device.id)) { // Add device to redux store.dispatch(addChromeCastDevice(device)); deviceUpdates.push(device); @@ -40,21 +40,21 @@ export async function scanDevices(store: Store): Promise { } = store.getState(); const absentDevices = previousDevices.filter( - d => !deviceUpdates.map(device => device.id).includes(d.id) && d.status === 'searching' + (d) => !deviceUpdates.map((device) => device.id).includes(d.id) && d.status === 'searching' ); // ...and mark them as 'offline' - absentDevices.forEach(device => { + absentDevices.forEach((device) => { store.dispatch(removeChromeCastDevice(device.id)); }); // find all previously 'online' devices that aren't accounted for.. const hidingDevices = previousDevices.filter( - d => !deviceUpdates.map(device => device.id).includes(d.id) && d.status === 'online' + (d) => !deviceUpdates.map((device) => device.id).includes(d.id) && d.status === 'online' ); // ...and mark them as 'searching' - hidingDevices.forEach(device => { + hidingDevices.forEach((device) => { store.dispatch( addChromeCastDevice({ ...device, diff --git a/src/main/features/core/chromecast/parseMdnsQuery.ts b/src/main/features/core/chromecast/parseMdnsQuery.ts index 766e92a8..a99b8f78 100644 --- a/src/main/features/core/chromecast/parseMdnsQuery.ts +++ b/src/main/features/core/chromecast/parseMdnsQuery.ts @@ -45,7 +45,7 @@ export function validateCastResponse({ additionals }: MDNSResponse): CastDeviceR TXT: null }; - additionals.forEach(record => { + additionals.forEach((record) => { switch (record.type) { case 'A': records.A = record as Response<'A'>; @@ -83,7 +83,7 @@ export function parseResponse(res: MDNSResponse): ChromeCastDevice | null { return null; } - const kvp = records.TXT.data.map(buf => buf.toString('utf-8')); + const kvp = records.TXT.data.map((buf) => buf.toString('utf-8')); const info = objectFromKeyValuePairs(kvp); return { diff --git a/src/main/features/core/ipcManager.ts b/src/main/features/core/ipcManager.ts index c04a71fb..74c06f1e 100755 --- a/src/main/features/core/ipcManager.ts +++ b/src/main/features/core/ipcManager.ts @@ -45,7 +45,7 @@ export default class IPCManager extends Feature { if (this.win) { download(this.win, url, downloadSettings) - .then(dl => this.logger.info('filed saved to', dl.getSavePath())) + .then((dl) => this.logger.info('filed saved to', dl.getSavePath())) .catch(this.logger.error); } }); diff --git a/src/main/features/feature.ts b/src/main/features/feature.ts index 492813aa..2ad86848 100755 --- a/src/main/features/feature.ts +++ b/src/main/features/feature.ts @@ -64,7 +64,7 @@ export class Feature { this.watcher = new ReduxWatcher(app.store); - this.store$ = new Observable(observer => { + this.store$ = new Observable((observer) => { // emit the current state as first value: observer.next(app.store.getState()); const unsubscribe = app.store.subscribe(() => { @@ -107,7 +107,7 @@ export class Feature { ({ auth: authA, player: playerA }, { auth: authB, player: playerB }) => authA.likes === authB.likes && playerA.playingTrack === playerB.playingTrack ), - map(store => ({ + map((store) => ({ value: store.player.playingTrack ? hasLiked(store.player.playingTrack.id, 'track')(store) : false, store })), @@ -118,8 +118,8 @@ export class Feature { ({ auth: authA, player: playerA }, { auth: authB, player: playerB }) => authA.reposts === authB.reposts && playerA.playingTrack === playerB.playingTrack ), - filter(store => !!store.player.playingTrack), - map(store => ({ + filter((store) => !!store.player.playingTrack), + map((store) => ({ value: hasReposted((store.player.playingTrack as PlayingTrack).id, 'track')(store), store })), @@ -177,21 +177,21 @@ export class Feature { public unregister(path?: string[] | string) { if (path) { - const ipcListener = this.ipclisteners.find(l => isEqual(l.name, path)); + const ipcListener = this.ipclisteners.find((l) => isEqual(l.name, path)); if (typeof path === 'string') { if (ipcListener) { ipcMain.removeAllListeners(ipcListener.name); } } else { - const listener = this.listeners.find(l => isEqual(l.path, path)); + const listener = this.listeners.find((l) => isEqual(l.path, path)); if (listener) { this.watcher.off(listener.path, listener.handler); } } } else { - this.listeners.forEach(listener => { + this.listeners.forEach((listener) => { try { this.watcher.off(listener.path, listener.handler); } catch (err) { @@ -201,12 +201,12 @@ export class Feature { } }); - this.ipclisteners.forEach(listener => { + this.ipclisteners.forEach((listener) => { ipcMain.removeAllListeners(listener.name); }); } - this.timers.forEach(timeout => { + this.timers.forEach((timeout) => { clearTimeout(timeout); }); } diff --git a/src/main/features/index.ts b/src/main/features/index.ts index cff3dbbe..2a6ba1ed 100644 --- a/src/main/features/index.ts +++ b/src/main/features/index.ts @@ -37,4 +37,4 @@ export const tools: typeof Feature[] = [ DbusService ]; -export const getTools = (app: Auryo) => tools.map(FeatureClass => new FeatureClass(app)).filter(o => o.shouldRun()); +export const getTools = (app: Auryo) => tools.map((FeatureClass) => new FeatureClass(app)).filter((o) => o.shouldRun()); diff --git a/src/main/features/linux/dbusService.ts b/src/main/features/linux/dbusService.ts index d08abe06..224641c8 100755 --- a/src/main/features/linux/dbusService.ts +++ b/src/main/features/linux/dbusService.ts @@ -22,7 +22,7 @@ export default class DbusService extends LinuxFeature { const session = dbus.sessionBus(); try { - await Promise.all(['mate', 'gnome'].map(platform => this.registerBindings(platform, session))); + await Promise.all(['mate', 'gnome'].map((platform) => this.registerBindings(platform, session))); } catch (err) { this.logger.trace({ err }, 'Error registering platform'); } diff --git a/src/main/features/linux/mprisService.ts b/src/main/features/linux/mprisService.ts index 7ae3204a..5f5a5dcf 100755 --- a/src/main/features/linux/mprisService.ts +++ b/src/main/features/linux/mprisService.ts @@ -139,10 +139,8 @@ export default class MprisService extends LinuxFeature { this.observables.statusChanged.subscribe(({ value: status }) => { if (status && this.player) { - this.player.playbackStatus = (status - .toLowerCase() - .charAt(0) - .toUpperCase() + status.toLowerCase().slice(1)) as any; + this.player.playbackStatus = (status.toLowerCase().charAt(0).toUpperCase() + + status.toLowerCase().slice(1)) as any; } }); diff --git a/src/main/index.ts b/src/main/index.ts index 21de0a21..1b83d312 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,7 +6,7 @@ if (process.env.TOKEN) { process.env.ENV = 'test'; } -if (process.argv.some(arg => arg === '--development') || process.argv.some(arg => arg === '--dev')) { +if (process.argv.some((arg) => arg === '--development') || process.argv.some((arg) => arg === '--dev')) { process.env.ENV = 'development'; } diff --git a/src/main/utils/logger.ts b/src/main/utils/logger.ts index bcf41d6a..03a8aeab 100644 --- a/src/main/utils/logger.ts +++ b/src/main/utils/logger.ts @@ -3,9 +3,7 @@ import pino from 'pino'; const isProd = process.env.NODE_ENV === 'production' && process.env.ENV !== 'development'; const config: pino.LoggerOptions = { - prettyPrint: { - colorize: !isProd - }, + prettyPrint: !isProd, base: null, level: isProd ? 'info' : process.env.LOG_LEVEL ?? 'debug' }; diff --git a/src/main/utils/pkce.ts b/src/main/utils/pkce.ts index 9bc5383f..9d083366 100644 --- a/src/main/utils/pkce.ts +++ b/src/main/utils/pkce.ts @@ -27,10 +27,7 @@ function random(size: number, mask: string | any[]) { * @returns {string} The base64 url encoded string */ function base64UrlEncode(base64: string) { - return base64 - .replace(/=/g, '') - .replace(/\+/g, '-') - .replace(/\//g, '_'); + return base64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); } /** Generate a PKCE challenge verifier @@ -47,9 +44,7 @@ function generateVerifier(length: number) { * @returns {string} The base64 url encoded code challenge */ function generateChallenge(code_verifier: string) { - const hash = createHash('sha256') - .update(code_verifier) - .digest('base64'); + const hash = createHash('sha256').update(code_verifier).digest('base64'); return base64UrlEncode(hash); } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d08de3c4..f678090f 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -8,7 +8,7 @@ import { ipcRenderer, remote } from 'electron'; import { UnregisterCallback } from 'history'; import React, { FC, useEffect } from 'react'; // eslint-disable-next-line import/no-extraneous-dependencies -import { hot } from 'react-hot-loader/root'; +import { hot } from 'react-hot-loader'; import { useDispatch, useSelector } from 'react-redux'; import { Route, Switch } from 'react-router-dom'; import { useKey } from 'react-use'; @@ -17,12 +17,12 @@ import { OnBoarding } from './pages/onboarding/OnBoarding'; export const App: FC = () => { const dispatch = useDispatch(); - const analyticsEnabled = useSelector(state => configSelector(state).app.analytics); + const analyticsEnabled = useSelector((state) => configSelector(state).app.analytics); // Toggle player on Space useKey( ' ', - event => { + (event) => { // Only toggle status when not in input field if (!(event?.target instanceof HTMLInputElement)) { dispatch(toggleStatus()); @@ -53,7 +53,7 @@ export const App: FC = () => { if (analyticsEnabled) { ua.pv('/').send(); - unregister = history.listen(location => { + unregister = history.listen((location) => { ua.pv(location.pathname).send(); }); } @@ -72,4 +72,4 @@ export const App: FC = () => { ); }; -export default hot(App); +export default hot(module)(App); diff --git a/src/renderer/_shared/InfiniteScroll.tsx b/src/renderer/_shared/InfiniteScroll.tsx index 9afc9607..9d125909 100644 --- a/src/renderer/_shared/InfiniteScroll.tsx +++ b/src/renderer/_shared/InfiniteScroll.tsx @@ -12,7 +12,7 @@ export const InfiniteScroll: FC = ({ isFetching, hasMore, loadMore, child useEffect(() => { const currentRef = bottomRef.current; const currentObserver = new IntersectionObserver( - entries => { + (entries) => { const firstEntry = entries[0]; if (firstEntry.isIntersecting && !isFetching) { diff --git a/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.tsx b/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.tsx index 91084866..519d6f92 100755 --- a/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.tsx +++ b/src/renderer/_shared/TrackList/TrackListItem/TrackListItem.tsx @@ -36,7 +36,7 @@ export const TrackListItem: FC = ({ playlistID, idResult }) => { return ( { + onDoubleClick={(e) => { playTrack(); }}> diff --git a/src/renderer/_shared/TracksGrid/TracksGrid.tsx b/src/renderer/_shared/TracksGrid/TracksGrid.tsx index 76a80aa3..a91fd059 100755 --- a/src/renderer/_shared/TracksGrid/TracksGrid.tsx +++ b/src/renderer/_shared/TracksGrid/TracksGrid.tsx @@ -8,7 +8,7 @@ import InfiniteLoader from 'react-window-infinite-loader'; import { useContentContext } from '../context/contentContext'; import Spinner from '../Spinner/Spinner'; import { TrackGridRow } from './TrackGridRow'; -import * as styles from './TracksGrid.module.scss'; +import styles from './TracksGrid.module.scss'; interface Props { playlistID: PlaylistIdentifier; @@ -26,7 +26,7 @@ function getRowsForWidth(width: number): number { return Math.floor(width / 255); } -const TracksGrid: FC = props => { +const TracksGrid: FC = (props) => { const { items, showInfo, isItemLoaded, loadMore, hasMore, isLoading, playlistID } = props; const loaderRef = useRef(null); const { list } = useContentContext(); diff --git a/src/renderer/_shared/context/contentContext.tsx b/src/renderer/_shared/context/contentContext.tsx index 2eef45b6..f7bd57ee 100644 --- a/src/renderer/_shared/context/contentContext.tsx +++ b/src/renderer/_shared/context/contentContext.tsx @@ -38,7 +38,7 @@ export function withContentContext

(Compon return (props: Pick>) => { return ( - {context => } + {(context) => } ); }; @@ -73,7 +73,7 @@ export const ContentContextProvider: FC = ({ children }) => { (): ContentContextProps => ({ settings, list, - applySettings: newSettings => setSettings(oldSettings => ({ ...oldSettings, ...newSettings })) + applySettings: (newSettings) => setSettings((oldSettings) => ({ ...oldSettings, ...newSettings })) }), [list, settings] ); diff --git a/src/renderer/app/ContentWrapper.tsx b/src/renderer/app/ContentWrapper.tsx index 01ec903f..790fae4e 100644 --- a/src/renderer/app/ContentWrapper.tsx +++ b/src/renderer/app/ContentWrapper.tsx @@ -43,7 +43,7 @@ export const ContentWrapper: FC = ({ children }) => { const debouncedSetScrollPosition = useRef( debounce( (scrollTop: number, pathname: string) => { - setScrollLocations(scrollLocations => ({ + setScrollLocations((scrollLocations) => ({ ...scrollLocations, [pathname]: scrollTop })); @@ -71,11 +71,11 @@ export const ContentWrapper: FC = ({ children }) => { className="content" ref={contentRef} onScroll={handleScroll as any} - renderView={props =>

} + renderView={(props) =>
} renderTrackHorizontal={() =>
} - renderTrackVertical={props =>
} + renderTrackVertical={(props) =>
} renderThumbHorizontal={() =>
} - renderThumbVertical={props =>
}> + renderThumbVertical={(props) =>
}> {children} diff --git a/src/renderer/app/components/Header/components/Search/SearchBox.tsx b/src/renderer/app/components/Header/components/Search/SearchBox.tsx index b09e53d3..f766bdd8 100644 --- a/src/renderer/app/components/Header/components/Search/SearchBox.tsx +++ b/src/renderer/app/components/Header/components/Search/SearchBox.tsx @@ -42,13 +42,13 @@ export const SearchBox: FC = () => { className="form-control" placeholder="Search people, tracks and albums" value={query} - onKeyPress={event => { + onKeyPress={(event) => { if (event.key === 'Enter') { dispatch(setDebouncedSearchQuery(query)); } }} - onKeyUp={e => e.preventDefault()} - onChange={event => updateQuery(event.target.value)} + onKeyUp={(e) => e.preventDefault()} + onChange={(event) => updateQuery(event.target.value)} />
diff --git a/src/renderer/app/components/Queue/Queue.tsx b/src/renderer/app/components/Queue/Queue.tsx index a26a8281..1975f88b 100644 --- a/src/renderer/app/components/Queue/Queue.tsx +++ b/src/renderer/app/components/Queue/Queue.tsx @@ -66,7 +66,7 @@ export const Queue: FC = () => { if ( !playlist.isFetching && !!playlist.itemsToFetch.length && - !playlistsToFetch.find(p => p.objectId === item.parentPlaylistID?.objectId) + !playlistsToFetch.find((p) => p.objectId === item.parentPlaylistID?.objectId) ) { playlistsToFetch.push(item.parentPlaylistID); } @@ -77,7 +77,7 @@ export const Queue: FC = () => { [] ); - playlistIDsToFetch.forEach(playlistID => { + playlistIDsToFetch.forEach((playlistID) => { dispatch(stopForwarding(genericPlaylistFetchMore.request(playlistID))); }); @@ -126,9 +126,9 @@ export const Queue: FC = () => {
} - renderTrackVertical={props =>
} + renderTrackVertical={(props) =>
} renderThumbHorizontal={() =>
} - renderThumbVertical={props =>
}> + renderThumbVertical={(props) =>
}> = ({ playing, played, item, index }) => { played, playing })} - onClick={e => { + onClick={(e) => { if ((e.target as any).className !== 'bx bx-dots-horizontal-rounded') { dispatch(stopForwarding(playTrackFromQueue({ idResult: item, index }))); } @@ -81,7 +81,7 @@ export const QueueItem: FC = ({ playing, played, item, index }) => {
{ + onClick={(e) => { e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); }} @@ -91,7 +91,7 @@ export const QueueItem: FC = ({ playing, played, item, index }) => {
{ + onClick={(e) => { e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); }} diff --git a/src/renderer/app/components/Sidebar/Sidebar.tsx b/src/renderer/app/components/Sidebar/Sidebar.tsx index 511e898a..acf8ee4f 100755 --- a/src/renderer/app/components/Sidebar/Sidebar.tsx +++ b/src/renderer/app/components/Sidebar/Sidebar.tsx @@ -5,7 +5,7 @@ import Scrollbars from 'react-custom-scrollbars'; import { useSelector } from 'react-redux'; import { NavLink, RouteComponentProps, withRouter } from 'react-router-dom'; import SideBarPlaylistItem from './playlist/SideBarPlaylistItem'; -import * as styles from './Sidebar.module.scss'; +import styles from './Sidebar.module.scss'; type AllProps = RouteComponentProps; @@ -19,9 +19,9 @@ const SideBar: FC = () => {
} - renderTrackVertical={props =>
} + renderTrackVertical={(props) =>
} renderThumbHorizontal={() =>
} - renderThumbVertical={props =>
}> + renderThumbVertical={(props) =>
}>