Skip to content

Commit

Permalink
Merge branch 'develop'
Browse files Browse the repository at this point in the history
  • Loading branch information
jakubito committed Oct 14, 2021
2 parents 6cbe144 + aca2324 commit 0f29e5b
Show file tree
Hide file tree
Showing 12 changed files with 359 additions and 699 deletions.
4 changes: 2 additions & 2 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ There are two external services being used by the app - Spotify and Sentry.

### Spotify

[Spotify Web API](https://developer.spotify.com/documentation/web-api/) is used to fetch all data about the artists you follow and their releases. It uses [Implicit Grant Flow](https://developer.spotify.com/documentation/general/guides/authorization-guide/#implicit-grant-flow) as authorization mechanism. This means all communication is happening strictly between your browser and Spotify servers.
[Spotify Web API](https://developer.spotify.com/documentation/web-api/) is used to fetch all data about the artists you follow and their releases. It uses [Authorization Code Flow with Proof Key for Code Exchange (PKCE)](https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce) as authorization mechanism. All communication is happening strictly between your browser and Spotify servers.

### Sentry

Expand All @@ -31,7 +31,7 @@ All scopes are asked for progressively (only when they are needed). You can use

Your existing playlist data is never touched. Feel free to check the code.

If you want to remove previously authorized access, you can do it anytime in your [Spotify profile](https://spotify.com/account/apps).
If you want to remove previously authorized access, you can do it anytime on your [Spotify profile page](https://spotify.com/account/apps).

[Official authorization scopes documentation](https://developer.spotify.com/documentation/general/guides/scopes)

Expand Down
44 changes: 22 additions & 22 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,55 +1,55 @@
{
"name": "spotify-release-list",
"author": "Jakub Dobes <dobes.jakub@gmail.com>",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"repository": "github:jakubito/spotify-release-list",
"license": "ISC",
"dependencies": {
"@reach/router": "^1.2.1",
"@sentry/browser": "^6.11.0",
"ajv": "^8.6.2",
"@sentry/browser": "^6.13.3",
"ajv": "^8.6.3",
"bulma": "^0.9.3",
"bulma-checkradio": "^1.1.1",
"bulma-checkradio": "^2.1.2",
"classnames": "^2.3.1",
"colord": "^2.6.0",
"colord": "^2.8.0",
"fast_array_intersect": "^1.1.0",
"fuse.js": "^6.4.6",
"js-base64": "^3.6.1",
"localforage": "^1.9.0",
"js-base64": "^3.7.2",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"moment": "^2.29.1",
"query-string": "^7.0.1",
"react": "^17.0.2",
"react-colorful": "^5.3.0",
"react-colorful": "^5.5.0",
"react-dates": "^21.8.0",
"react-dom": "^17.0.2",
"react-hook-form": "^7.12.2",
"react-hotkeys-hook": "^3.4.0",
"react-hook-form": "^7.17.4",
"react-hotkeys-hook": "^3.4.3",
"react-ios-pwa-prompt": "^1.8.4",
"react-media": "^1.10.0",
"react-redux": "^7.2.4",
"react-redux": "^7.2.5",
"react-scripts": "^4.0.3",
"react-waypoint": "^10.1.0",
"redux": "^4.1.1",
"redux-persist": "^6.0.0",
"redux-saga": "^1.1.1",
"reselect": "^4.0.0",
"workbox-cacheable-response": "^6.2.2",
"workbox-core": "^6.2.2",
"workbox-expiration": "^6.2.2",
"workbox-precaching": "^6.2.2",
"workbox-routing": "^6.2.2",
"workbox-strategies": "^6.2.2"
"workbox-cacheable-response": "^6.3.0",
"workbox-core": "^6.3.0",
"workbox-expiration": "^6.3.0",
"workbox-precaching": "^6.3.0",
"workbox-routing": "^6.3.0",
"workbox-strategies": "^6.3.0"
},
"devDependencies": {
"@opengovsg/credits-generator": "^1.0.6",
"@types/jest": "^26.0.24",
"@types/node": "^16.4.13",
"@types/react": "^17.0.16",
"@types/jest": "^27.0.2",
"@types/node": "^16.10.9",
"@types/react": "^17.0.29",
"@types/react-dom": "^17.0.9",
"node-sass": "^4.14.1",
"prettier": "^2.3.2",
"prettier": "^2.4.1",
"sass": "^1.43.2",
"source-map-explorer": "^2.5.2"
},
"scripts": {
Expand Down
18 changes: 13 additions & 5 deletions src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@ const AUTH_REDIRECT_URL = process.env.REACT_APP_URL + '/auth'
* Represents an error encountered during authorization
*/
export class AuthError extends Error {
/** @param {string} [message] */
constructor(message) {
/**
* @param {string} [message]
* @param {SentryContexts} [contexts]
*/
constructor(message, contexts) {
super(message)
this.name = 'AuthError'
this.contexts = contexts
}
}

Expand Down Expand Up @@ -60,18 +64,22 @@ export function validateAuthRequest(locationSearch, originalNonce) {
const { code, state, error } = queryString.parse(locationSearch)

if (error) {
throw new AuthError(`Authorization failed (${error})`)
if (error === 'access_denied') {
throw new AuthError('Access denied')
}

throw new AuthError('Authorization failed', { extra: { error } })
}

if (!code || !state) {
throw new AuthError('Invalid request')
throw new AuthError('Authorization failed', { extra: { locationSearch, code, state } })
}

/** @type {{ action?: Action, nonce?: string }} */
const { action, nonce } = JSON.parse(Base64.decode(state))

if (nonce !== originalNonce) {
throw new AuthError('Invalid request')
throw new AuthError('Authorization failed', { extra: { locationSearch, nonce, originalNonce } })
}

return { code, action }
Expand Down
4 changes: 2 additions & 2 deletions src/components/common/ColorInput.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useRef, useState } from 'react'
import { HexColorInput, HexColorPicker } from 'react-colorful'
import classNames from 'classnames'
import { useClickOutside } from 'hooks'
import { useClickOutside, useFocusOutside } from 'hooks'

/**
* Render color picker input
Expand All @@ -22,6 +22,7 @@ function ColorInput({ id, name, className, color, onChange }) {
const closePicker = () => setPickerVisible(false)

useClickOutside(containerRef, closePicker)
useFocusOutside(containerRef, closePicker)

return (
<div
Expand All @@ -37,7 +38,6 @@ function ColorInput({ id, name, className, color, onChange }) {
color={color}
onChange={onChange}
onFocus={openPicker}
onBlur={closePicker}
/>
<span className="ColorInput__icon icon is-small is-left">
<i className="fas fa-hashtag" />
Expand Down
10 changes: 10 additions & 0 deletions src/helpers.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import mergeWith from 'lodash/mergeWith'
import random from 'lodash/random'
import { colord } from 'colord'
import * as Sentry from '@sentry/browser'
import { AlbumGroup, MomentFormat } from 'enums'

const { ISO_DATE } = MomentFormat
Expand Down Expand Up @@ -270,3 +271,12 @@ export function createNotification(title, body) {
export function modalsClosed() {
return !document.documentElement.classList.contains('is-modal-open')
}

/**
* Sentry captureException wrapper
*
* @param {Error & { contexts?: SentryContexts }} error
*/
export function captureException(error) {
Sentry.captureException(error, { contexts: error.contexts })
}
58 changes: 38 additions & 20 deletions src/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,41 +57,59 @@ export function useRefChangeKey(value) {
/**
* Call `handler` when clicked outside of `ref`
*
* Taken from https://codesandbox.io/s/opmco?file=/src/useClickOutside.js
* Improved version of https://usehooks.com/useOnClickOutside/
*
* @param {React.MutableRefObject} ref
* @param {(event: MouseEvent) => void} handler
*/
export function useClickOutside(ref, handler) {
useEffect(() => {
let startedWhenMounted = false
let startedInside = false

/** @type {(event: MouseEvent | TouchEvent) => void} */
const interactionListener = (event) => {
startedInside = ref.current?.contains(event.target)
}

/** @type {(event: MouseEvent) => void} */
const listener = (event) => {
// Do nothing if `mousedown` or `touchstart` started inside ref element
if (startedInside || !startedWhenMounted) return
// Do nothing if clicking ref's element or descendent elements
if (!ref.current || ref.current.contains(event.target)) return
const clickListener = (event) => {
if (!ref.current) return
if (ref.current.contains(event.target)) return
if (startedInside) return

handler(event)
}

/** @type {(event: MouseEvent | TouchEvent) => void} */
const validateEventStart = (event) => {
startedWhenMounted = ref.current
startedInside = ref.current && ref.current.contains(event.target)
document.addEventListener('mousedown', interactionListener)
document.addEventListener('touchstart', interactionListener)
document.addEventListener('click', clickListener)

return () => {
document.removeEventListener('mousedown', interactionListener)
document.removeEventListener('touchstart', interactionListener)
document.removeEventListener('click', clickListener)
}
}, [])
}

document.addEventListener('mousedown', validateEventStart)
document.addEventListener('touchstart', validateEventStart)
document.addEventListener('click', listener)
/**
* Call `handler` when focused outside of `ref`
*
* @param {React.MutableRefObject} ref
* @param {(event: FocusEvent) => void} handler
*/
export function useFocusOutside(ref, handler) {
useEffect(() => {
/** @type {(event: FocusEvent) => void} */
const focusListener = (event) => {
if (!ref.current) return
if (ref.current.contains(event.target)) return

handler(event)
}

document.addEventListener('focusin', focusListener)

return () => {
document.removeEventListener('mousedown', validateEventStart)
document.removeEventListener('touchstart', validateEventStart)
document.removeEventListener('click', listener)
document.removeEventListener('focusin', focusListener)
}
}, [ref])
}, [])
}
6 changes: 3 additions & 3 deletions src/sagas/auth.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as Sentry from '@sentry/browser'
import { call, fork, put, select, take } from 'redux-saga/effects'
import { navigate } from '@reach/router'
import { captureException } from 'helpers'
import {
AuthError,
createCodeChallenge,
Expand Down Expand Up @@ -35,7 +35,7 @@ export function* authorizeSaga(action) {
} catch (error) {
yield put(showErrorMessage(error instanceof AuthError ? error.message : undefined))
yield put(authorizeError(error instanceof AuthError))
Sentry.captureException(error)
yield call(captureException, error)
}
}

Expand Down Expand Up @@ -91,7 +91,7 @@ export function authorize(action, scopes, saga, ...args) {
}
} catch (error) {
yield put(authorizeError(error instanceof AuthError))
Sentry.captureException(error)
yield call(captureException, error)

throw error
}
Expand Down
17 changes: 11 additions & 6 deletions src/state/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Fuse from 'fuse.js'
import intersect from 'fast_array_intersect'
import last from 'lodash/last'
import isEqual from 'lodash/isEqual'
import escapeRegExp from 'lodash/escapeRegExp'
import { AlbumGroup } from 'enums'
import { includesTruthy, getReleasesBetween, merge } from 'helpers'
import { buildReleases, buildReleasesMap } from './helpers'
Expand Down Expand Up @@ -249,20 +250,24 @@ const getNonVariousArtistsAlbumIds = createSelector(getAlbumsArray, (albums) =>
/**
* Get album IDs with duplicates removed
*/
const getNoDuplicatesAlbumIds = createSelector(getOriginalReleases, (releases) =>
releases.reduce((ids, { albums }) => {
const getNoDuplicatesAlbumIds = createSelector(getOriginalReleases, (releases) => {
const charsMap = { '[': '(', ']': ')', '’': "'" }
const escapedChars = escapeRegExp(Object.keys(charsMap).join(''))
const charsRegex = new RegExp(`[${escapedChars}]`, 'g')

return releases.reduce((ids, { albums }) => {
/** @type {Record<string, string>} */
const namesMap = {}

for (const album of albums) {
const name = album.name.toLowerCase()
if (name in namesMap) continue
namesMap[name] = album.id
const unifiedName = album.name.replace(charsRegex, (key) => charsMap[key]).toLowerCase()
if (unifiedName in namesMap) continue
namesMap[unifiedName] = album.id
}

return ids.concat(Object.values(namesMap))
}, /** @type {string[]} */ ([]))
)
})

/**
* Get album IDs based on search filter
Expand Down
5 changes: 3 additions & 2 deletions src/state/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { createStore, applyMiddleware, compose } from 'redux'
import { persistStore, persistReducer, createMigrate } from 'redux-persist'
import autoMergeLevel2 from 'redux-persist/lib/stateReconciler/autoMergeLevel2'
import localForage from 'localforage'
import * as Sentry from '@sentry/browser'
import createSagaMiddleware from 'redux-saga'
import { rootSaga } from 'sagas'
import { captureException } from 'helpers'
import migrations from './migrations'
import rootReducer from './reducer'

Expand All @@ -17,6 +17,7 @@ const persistConfig = {
storage: localForage,
stateReconciler: autoMergeLevel2,
migrate: createMigrate(migrations),
writeFailHandler: captureException,
whitelist: [
'authData',
'albums',
Expand All @@ -33,7 +34,7 @@ const persistConfig = {
}

const composeEnhancers = /** @type {any} */ (window).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
const sagaMiddleware = createSagaMiddleware({ onError: (error) => Sentry.captureException(error) })
const sagaMiddleware = createSagaMiddleware({ onError: captureException })

/** @type {import('redux').Store<State>} */
const store = createStore(
Expand Down
2 changes: 1 addition & 1 deletion src/styles/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

@import '~bulma/bulma';
@import '~bulma-checkradio/dist/css/bulma-checkradio';
@import '~react-dates/lib/css/_datepicker';
@import '~react-dates/lib/css/_datepicker.css';

@import './mixins';
@import './base';
Expand Down
2 changes: 2 additions & 0 deletions src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@
* @typedef {Channel<RequestChannelMessage>} RequestChannel
* @typedef {(data: Settings) => string} SettingsSerializer
* @typedef {JTDParser<Settings>} SettingsParser
* @typedef {Record<string, unknown>} SentryContext
* @typedef {Record<string, SentryContext>} SentryContexts
*/

/**
Expand Down
Loading

0 comments on commit 0f29e5b

Please sign in to comment.