├── .babelrc ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── app ├── README.md ├── src │ ├── actions │ │ ├── browse │ │ │ └── index.js │ │ ├── comments │ │ │ └── index.js │ │ ├── entities │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── filter │ │ │ └── index.js │ │ ├── following │ │ │ └── index.js │ │ ├── index.js │ │ ├── paginate │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── player │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── request │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── session │ │ │ └── index.js │ │ ├── sort │ │ │ └── index.js │ │ ├── toggle │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── track │ │ │ ├── index.js │ │ │ └── spec.js │ │ └── user │ │ │ ├── index.js │ │ │ └── spec.js │ ├── components │ │ ├── Activities │ │ │ └── index.js │ │ ├── App │ │ │ └── index.js │ │ ├── Artwork │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── ArtworkAction │ │ │ └── index.js │ │ ├── Browse │ │ │ └── index.js │ │ ├── ButtonActive │ │ │ └── index.js │ │ ├── ButtonGhost │ │ │ └── index.js │ │ ├── ButtonInline │ │ │ └── index.js │ │ ├── ButtonMore │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── Callback │ │ │ └── index.js │ │ ├── CommentExtension │ │ │ └── index.js │ │ ├── Dashboard │ │ │ └── index.js │ │ ├── DateSort │ │ │ └── index.js │ │ ├── FavoritesList │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── FilterDuration │ │ │ └── index.js │ │ ├── FilterName │ │ │ └── index.js │ │ ├── FollowersList │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── FollowingsList │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── Header │ │ │ └── index.js │ │ ├── HoverActions │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── InfoList │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── InputMenu │ │ │ └── index.js │ │ ├── List │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── LoadingSpinner │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── Permalink │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── Player │ │ │ └── index.js │ │ ├── Playlist │ │ │ └── index.js │ │ ├── Sort │ │ │ └── index.js │ │ ├── StreamActivities │ │ │ └── index.js │ │ ├── StreamInteractions │ │ │ └── index.js │ │ ├── Track │ │ │ ├── index.js │ │ │ ├── playlist.js │ │ │ ├── preview.js │ │ │ └── stream.js │ │ ├── TrackActions │ │ │ └── index.js │ │ ├── TrackExtension │ │ │ └── index.js │ │ ├── User │ │ │ ├── index.js │ │ │ └── preview.js │ │ ├── Volume │ │ │ └── index.js │ │ ├── WaveformSc │ │ │ └── index.js │ │ ├── withFetchOnScroll │ │ │ └── index.js │ │ └── withLoadingSpinner │ │ │ └── index.js │ ├── constants │ │ ├── actionTypes.js │ │ ├── artistFilter.js │ │ ├── authentication.js │ │ ├── dateSortTypes.js │ │ ├── durationFilter.js │ │ ├── filterTypes.js │ │ ├── genre.js │ │ ├── nameFilter.js │ │ ├── paginateLinkTypes.js │ │ ├── pathnames.js │ │ ├── requestTypes.js │ │ ├── schemas.js │ │ ├── sort.js │ │ ├── sortTypes.js │ │ ├── toggleTypes.js │ │ ├── trackAttributes.js │ │ └── trackTypes.js │ ├── index.html │ ├── index.js │ ├── reducers │ │ ├── browse │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── comment │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── entities │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── filter │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── index.js │ │ ├── paginate │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── player │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── request │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── session │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── sort │ │ │ ├── index.js │ │ │ └── spec.js │ │ ├── toggle │ │ │ ├── index.js │ │ │ └── spec.js │ │ └── user │ │ │ ├── index.js │ │ │ └── spec.js │ ├── schemas │ │ ├── comment.js │ │ ├── track.js │ │ └── user.js │ ├── services │ │ ├── api.js │ │ ├── filter.js │ │ ├── map.js │ │ ├── player.js │ │ ├── pluralize.js │ │ ├── string.js │ │ └── track.js │ ├── stores │ │ └── configureStore.js │ └── waveform.js ├── styles │ ├── components │ │ ├── actions.scss │ │ ├── artworkAction.scss │ │ ├── browse.scss │ │ ├── buttonActive.scss │ │ ├── buttonGhost.scss │ │ ├── buttonInline.scss │ │ ├── buttonMore.scss │ │ ├── comment.scss │ │ ├── dashboard.scss │ │ ├── header.scss │ │ ├── infoList.scss │ │ ├── inputMenu.scss │ │ ├── item.scss │ │ ├── list.scss │ │ ├── loadingSpinner.scss │ │ ├── player.scss │ │ ├── playlist.scss │ │ ├── playlistTrack.scss │ │ ├── streamInteractions.scss │ │ ├── track.scss │ │ ├── trackActions.scss │ │ └── volume.scss │ ├── custom │ │ ├── main.scss │ │ └── variable.scss │ └── index.scss └── test │ └── setup.js ├── dist ├── rollup │ ├── index.html │ └── waveform.js └── webpack │ ├── index.html │ └── waveform.js ├── package.json ├── rollup ├── development.js └── production.js ├── webpack-advanced ├── development.js └── production.js ├── webpack ├── development.js └── production.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/env", { "modules": false, "loose": true }], 4 | "@babel/preset-react" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .cache 3 | node_modules 4 | yarn-error.log 5 | stats.html 6 | 7 | dist/webpack/bundle* 8 | dist/parcel/* 9 | dist/rollup/bundle* 10 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 8.15.0 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 TehCookies 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Performance and usage comparison of Webpack 4, Parcel and Rollup bundlers. 4 | 5 | All bundlers were used for a big enough open-source [SoundCloud app](https://github.com/rwieruch/favesound-redux) implemented in React. The app was slightly tweaked so that it could work similarly with all the bundlers. 6 | 7 | This comparison doesn't pretend to be objective and was done for personal purpose only. I didn't try to setup the most optimized output results for each bundler. I used minimal setup for the comparison. If you feel that you can improve results please send PRs. 8 | 9 | ## Machine Specs 10 | 11 | | Attribute | Description | 12 | |-----------|-----------------------------------------| 13 | | Name | MacBook Pro (Retina, 15-inch, Mid 2015) | 14 | | Processor | 2,2 GHz Intel Core i7 | 15 | | Memory | 16 GB 1600 MHz DDR3 | 16 | | Graphics | Intel Iris Pro 1536 MB | 17 | | OS | macOS Mojave v10.14 | 18 | 19 | ## Bundle Size 20 | 21 | Here are the results for production JavaScript bundle size. 22 | 23 | | Bundler | Minified | Gzipped | 24 | |--------------------|----------|---------| 25 | | Webpack | 697 kB | 191 kB | 26 | | Webpack (advanced) | 697 kB | 191 kB | 27 | | Parcel | 687 kB | 180 kB | 28 | | Rollup | 461 kB | 138 kB | 29 | 30 | ## Development Build 31 | 32 | Here are the results for development build times. For Parcel there are two values for start since it has built-in cache. All the values is an average over 10 runs. 33 | 34 | | Bundler | Start | Reload | 35 | |--------------------|-------------------|---------| 36 | | Webpack | 4776 ms | 923 ms | 37 | | Webpack (advanced) | 3745 ms | 195 ms | 38 | | Parcel | 8409 ms (2459 ms) | 585 ms | 39 | | Rollup | 11570 ms | 3790 ms | 40 | 41 | ## Production Build 42 | 43 | Here are the results for production build times. For Parcel and Webpack there are two values since both have cache. Webpack has cache for [Terser Plugin](https://github.com/webpack-contrib/terser-webpack-plugin). All the values is an average over 10 runs. 44 | 45 | | Bundler | Time | 46 | |--------------------|--------------------| 47 | | Webpack | 15991 ms (3555 ms) | 48 | | Webpack (advanced) | 16089 ms (3617 ms) | 49 | | Parcel | 12098 ms (1301 ms) | 50 | | Rollup | 16200 ms | 51 | 52 | ## Usage Notes 53 | 54 | All of the bundlers require Babel to build JavaScript properly. Here are the common packages for all the bundlers: 55 | 56 | ``` 57 | @babel/core 58 | @babel/register 59 | @babel/preset-react 60 | @babel/preset-env 61 | node-sass 62 | ``` 63 | 64 | ### Webpack 65 | 66 | Webpack is probably the most solid option for app development. Especially when v4 is out. It cuts a good chunk of boilerplate from config comparing to v3. 67 | 68 | Webpack doesn't require much dependencies. You'll mostly need plugins and loaders installed additionally. Last time Webpack documentation was improved a lot, so it's easy to achieve what you need. 69 | 70 | While the configuration part is confusing at first, it's actually becomes a lot easier when you are familiar with the main concepts. 71 | 72 | There are plugins for literally everything you need. It's much more flexible than Parcel and it's less complicated to setup than Rollup. 73 | 74 | ##### Dependencies: 75 | 76 | ``` 77 | webpack 78 | webpack-cli 79 | webpack-dev-server 80 | babel-loader 81 | style-loader 82 | css-loader 83 | sass-loader 84 | ``` 85 | 86 | Advanced configuration also requires 87 | 88 | ``` 89 | cache-loader 90 | thread-loader 91 | ``` 92 | 93 | ### Parcel 94 | 95 | This is probably the easiest one to setup. Only one dependency was required to bundle the app. And zero config for both development and production. 96 | 97 | It also has a nice cache feature built-in. So for the subsequent runs it bundles faster than for the cold run. Though sometimes it's buggy and you need to clear cache in order to get proper build result. 98 | 99 | While it sounds very cool, in reality Parcel may be very limiting in some usage scenarios. For example, there is no control over hierarchy of output files. When you need to accomplish something specific, there may be no way to do so. It also seems to be less reliable than the others since it's quite new comparing to the others. 100 | 101 | ##### Dependencies: 102 | 103 | ``` 104 | parcel-bundler 105 | ``` 106 | 107 | ### Rollup 108 | 109 | It was really painful to setup Rollup for this particular app. It required tons of plugins to be installed in order to achieve the same result as the other bundlers. I didn't figure out how to bundle Soundcloud's libraries properly with Rollup, so I had to move them out of the bundling pipeline. 110 | 111 | Overall it's just too complicated. It requires a very careful setup. It may be a good thing for experienced developers though, since it's a very minimalistic tool, it doesn't bloat your bundle without a reason. 112 | 113 | There is a rule of thumb that Rollup should be used for libraries. I would agree, but it has a potential to become a good choice for apps too, if it would have sane defaults as Webpack has. It also should be more clear how to deal with CommonJS modules, because for the first-time users it's very confusing. 114 | 115 | ##### Dependencies: 116 | 117 | ``` 118 | rollup 119 | rollup-plugin-babel 120 | rollup-plugin-commonjs 121 | rollup-plugin-json 122 | rollup-plugin-livereload 123 | rollup-plugin-node-resolve 124 | rollup-plugin-progress 125 | rollup-plugin-re 126 | rollup-plugin-replace 127 | rollup-plugin-scss 128 | rollup-plugin-serve 129 | rollup-plugin-uglify 130 | rollup-plugin-visualizer 131 | ``` 132 | 133 | ## Conclusion 134 | 135 | - Use Webpack 4 by default. It's flexible and user-friendly enough for app development. There is a learning curve, but once you get it, it's not very complicated to use. The documentation became a lot better last time and the community is very big. After getting familiar with core concepts you can get a much smoother and snappier work flow than with other bundlers. 136 | - Use Parcel for simple scenarios. It's easy to setup and very fast. It's also a good option for beginners. Don't use it if you wish to do customizations and tweaks for your builds, in the long term it may cause problems. The documentation may lack some of the important details and it's a pain to fix little quirks. It's also still immature, so expect to face with bugs. 137 | - Use Rollup for library development and if bundle size is something very critical for you. The developer's experience is not the best here and you need to understand the tradeoffs for a small bundle size and minimalistic philosophy behind it. It's a solid tool though. The documentation would be better if it could provide more real-life examples of usage for common scenarios. 138 | -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | # favesound-redux 2 | 3 | [](https://travis-ci.org/rwieruch/favesound-redux) [](https://slack-the-road-to-learn-react.wieruch.com/) 4 | 5 | The SoundCloud Client in React + Redux made with passion. [Demo](http://www.favesound.de/), [Sibling Project: favesound-mobx](https://github.com/rwieruch/favesound-mobx) 6 | 7 | ## Get started on your own! 8 | 9 | * [Comprehensive Guide: The SoundCloud Client in React + Redux](http://www.robinwieruch.de/the-soundcloud-client-in-react-redux/) 10 | * [Boilerplate Project](https://github.com/rwieruch/react-redux-soundcloud) 11 | 12 | ## Includes 13 | 14 | * react v. 16 15 | * react-router v. 4 16 | * redux 17 | * react-redux 18 | * redux-thunk 19 | * normalizr 20 | * lodash-fp 21 | * airbnb-extended eslint 22 | * enzyme v. 3 23 | * Soundcloud API. 24 | 25 | ## Features 26 | 27 | * login to SoundCloud 28 | * show your personal stream 29 | * show favorite tracks, followers and followings 30 | * infinite scroll + paginated fetching 31 | * follow people 32 | * like tracks 33 | * player play/stop/forward/backward track 34 | * player with shuffle tracks, share link and volume 35 | * player with duration bar for tracks and navigation 36 | * playlist 37 | * sort tracks by plays, likes, comments, reposts, downloads 38 | * filter tracks by duration 39 | * search tracks by name and artist 40 | 41 | ## Run on your Machine 42 | 43 | 1. Clone Repository: `git clone git@github.com:rwieruch/favesound-redux.git` 44 | 2. `npm install` 45 | 3. `npm start` 46 | 4. visit http://localhost:8080/ 47 | 4. `npm test` 48 | 49 | ## Contribute 50 | 51 | I am looking actively for contributors to make this project awesome! 52 | 53 | I wouldn't want to extend the project with new routes like: that's my profile page and that's my favorite track page. Rather I see more value in improving the status quo of the app: Improving the player, the playlist or the interaction overall. I would love to see a GitHub issue to see where you want to work on. Moreover I will try to find the time to raise some more issues where people can contribute. At the end it is a perfect project to get started in open source! 54 | 55 | ## Improve 56 | 57 | Feedback is more than appreciated via [GitHub](https://github.com/rwieruch) or [Twitter](https://twitter.com/rwieruch) 58 | -------------------------------------------------------------------------------- /app/src/actions/browse/index.js: -------------------------------------------------------------------------------- 1 | import { arrayOf, normalize } from 'normalizr'; 2 | import trackSchema from '../../schemas/track'; 3 | import * as actionTypes from '../../constants/actionTypes'; 4 | import * as requestTypes from '../../constants/requestTypes'; 5 | import { unauthApiUrl } from '../../services/api'; 6 | import { setRequestInProcess } from '../../actions/request'; 7 | import { setPaginateLink } from '../../actions/paginate'; 8 | import { mergeEntities } from '../../actions/entities'; 9 | 10 | export function setSelectedGenre(genre) { 11 | return { 12 | type: actionTypes.SET_SELECTED_GENRE, 13 | genre 14 | }; 15 | } 16 | 17 | function mergeActivitiesByGenre(activities, genre) { 18 | return { 19 | type: actionTypes.MERGE_GENRE_ACTIVITIES, 20 | activities, 21 | genre 22 | }; 23 | } 24 | 25 | export const fetchActivitiesByGenre = (nextHref, genre) => (dispatch, getState) => { 26 | const requestType = requestTypes.GENRES; 27 | const initHref = unauthApiUrl(`tracks?linked_partitioning=1&limit=20&offset=0&tags=${genre}`, '&'); 28 | const url = nextHref || initHref; 29 | const requestInProcess = getState().request[requestType]; 30 | 31 | if (requestInProcess) { return; } 32 | 33 | dispatch(setRequestInProcess(true, requestType)); 34 | 35 | return fetch(url) 36 | .then(response => response.json()) 37 | .then(data => { 38 | const normalized = normalize(data.collection, arrayOf(trackSchema)); 39 | dispatch(mergeEntities(normalized.entities)); 40 | dispatch(mergeActivitiesByGenre(normalized.result, genre)); 41 | dispatch(setPaginateLink(data.next_href, genre)); 42 | dispatch(setRequestInProcess(false, requestType)); 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /app/src/actions/comments/index.js: -------------------------------------------------------------------------------- 1 | import { arrayOf, normalize } from 'normalizr'; 2 | import commentSchema from '../../schemas/comment'; 3 | import * as actionTypes from '../../constants/actionTypes'; 4 | import { mergeEntities } from '../../actions/entities'; 5 | import { setRequestInProcess } from '../../actions/request'; 6 | import { setPaginateLink } from '../../actions/paginate'; 7 | import { getLazyLoadingCommentsUrl } from '../../services/api'; 8 | import { getCommentProperty } from '../../services/string'; 9 | 10 | export function setOpenComments(trackId) { 11 | return { 12 | type: actionTypes.OPEN_COMMENTS, 13 | trackId 14 | }; 15 | } 16 | 17 | export function mergeComments(comments, trackId) { 18 | return { 19 | type: actionTypes.MERGE_COMMENTS, 20 | comments, 21 | trackId 22 | }; 23 | } 24 | 25 | export const fetchComments = (trackId, nextHref) => (dispatch, getState) => { 26 | const requestProperty = getCommentProperty(trackId); 27 | const initUrl = 'tracks/' + trackId + '/comments?linked_partitioning=1&limit=20&offset=0'; 28 | const url = getLazyLoadingCommentsUrl(nextHref, initUrl); 29 | const requestInProcess = getState().request[requestProperty]; 30 | 31 | if (requestInProcess) { return; } 32 | 33 | dispatch(setRequestInProcess(true, requestProperty)); 34 | 35 | return fetch(url) 36 | .then(response => response.json()) 37 | .then(data => { 38 | const normalized = normalize(data.collection, arrayOf(commentSchema)); 39 | dispatch(mergeEntities(normalized.entities)); 40 | dispatch(mergeComments(normalized.result, trackId)); 41 | dispatch(setPaginateLink(data.next_href, requestProperty)); 42 | dispatch(setRequestInProcess(false, requestProperty)); 43 | }); 44 | }; 45 | 46 | export const openComments = (trackId) => (dispatch, getState) => { 47 | const comments = getState().comment.comments[trackId]; 48 | 49 | dispatch(setOpenComments(trackId)); 50 | 51 | if (!comments) { 52 | dispatch(fetchComments(trackId)); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /app/src/actions/entities/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | export function mergeEntities(entities) { 4 | return { 5 | type: actionTypes.MERGE_ENTITIES, 6 | entities 7 | }; 8 | } 9 | 10 | export function syncEntities(entity, key) { 11 | return { 12 | type: actionTypes.SYNC_ENTITIES, 13 | entity, 14 | key 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /app/src/actions/entities/spec.js: -------------------------------------------------------------------------------- 1 | import * as actions from './index'; 2 | import * as actionTypes from '../../constants/actionTypes'; 3 | 4 | describe('mergeEntities()', () => { 5 | 6 | it('creates an action to merge entities', () => { 7 | const entities = [{ name: 'x', name: 'y' }]; 8 | const expectedAction = { 9 | type: actionTypes.MERGE_ENTITIES, 10 | entities 11 | }; 12 | 13 | expect(actions.mergeEntities(entities)).to.eql(expectedAction); 14 | }); 15 | 16 | }); 17 | 18 | describe('syncEntities()', () => { 19 | 20 | it('creates an action to sync an entity', () => { 21 | const key = 'users'; 22 | const entity = { name: 'x' }; 23 | const expectedAction = { 24 | type: actionTypes.SYNC_ENTITIES, 25 | entity, 26 | key 27 | }; 28 | 29 | expect(actions.syncEntities(entity, key)).to.eql(expectedAction); 30 | }); 31 | 32 | }); -------------------------------------------------------------------------------- /app/src/actions/filter/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | export function filterDuration(filterType) { 4 | return { 5 | type: actionTypes.FILTER_DURATION, 6 | filterType 7 | }; 8 | } 9 | 10 | export function filterName(filterNameQuery) { 11 | return { 12 | type: actionTypes.FILTER_NAME, 13 | filterNameQuery 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /app/src/actions/following/index.js: -------------------------------------------------------------------------------- 1 | import { find } from 'lodash'; 2 | import * as actionTypes from '../../constants/actionTypes'; 3 | import { apiUrl } from '../../services/api'; 4 | import { mergeFollowings } from '../../actions/user'; 5 | 6 | export function removeFromFollowings(userId) { 7 | return { 8 | type: actionTypes.REMOVE_FROM_FOLLOWINGS, 9 | userId 10 | }; 11 | } 12 | 13 | export const follow = (user) => (dispatch, getState) => { 14 | const followings = getState().user.followings; 15 | const isFollowing = find(followings, (following) => following === user.id); 16 | 17 | fetch(apiUrl(`me/followings/${user.id}`, '?'), { method: isFollowing ? 'delete' : 'put' }) 18 | .then(response => response.json()) 19 | .then(() => { 20 | if (isFollowing) { 21 | dispatch(removeFromFollowings(user.id)); 22 | } else { 23 | dispatch(mergeFollowings([user.id])); 24 | } 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /app/src/actions/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | import { login, logout } from './session'; 3 | import { fetchActivities, fetchFollowers, fetchFavorites, fetchFollowings } from './user'; 4 | import { fetchActivitiesByGenre, setSelectedGenre } from './browse'; 5 | import { like } from './track'; 6 | import { follow } from './following'; 7 | import { setToggle } from './toggle'; 8 | import { activateTrack, activateIteratedPlaylistTrack, activateIteratedStreamTrack, addTrackToPlaylist, removeTrackFromPlaylist, clearPlaylist, togglePlayTrack, toggleShuffleMode, toggleRepeatMode, changeVolume } from './player'; 9 | import { openComments, fetchComments } from './comments'; 10 | import { filterDuration, filterName } from './filter'; 11 | import { sortStream, dateSortStream } from './sort'; 12 | /* eslint-enable max-len */ 13 | 14 | export { 15 | login, 16 | logout, 17 | fetchActivities, 18 | fetchFollowings, 19 | fetchFollowers, 20 | fetchFavorites, 21 | activateTrack, 22 | togglePlayTrack, 23 | addTrackToPlaylist, 24 | removeTrackFromPlaylist, 25 | clearPlaylist, 26 | activateIteratedPlaylistTrack, 27 | activateIteratedStreamTrack, 28 | like, 29 | follow, 30 | setToggle, 31 | setSelectedGenre, 32 | fetchActivitiesByGenre, 33 | openComments, 34 | fetchComments, 35 | filterDuration, 36 | filterName, 37 | sortStream, 38 | dateSortStream, 39 | toggleShuffleMode, 40 | toggleRepeatMode, 41 | changeVolume, 42 | }; 43 | -------------------------------------------------------------------------------- /app/src/actions/paginate/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | export function setPaginateLink(nextHref, paginateType) { 4 | return { 5 | type: actionTypes.SET_PAGINATE_LINK, 6 | paginateType, 7 | nextHref 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /app/src/actions/paginate/spec.js: -------------------------------------------------------------------------------- 1 | import * as actions from './index'; 2 | import * as actionTypes from '../../constants/actionTypes'; 3 | 4 | describe('setPaginateLink()', () => { 5 | 6 | it('creates an action to set a paginate link', () => { 7 | const nextHref = '/foo'; 8 | const paginateType = 'FOO_PAGINATE'; 9 | const expectedAction = { 10 | type: actionTypes.SET_PAGINATE_LINK, 11 | nextHref, 12 | paginateType 13 | }; 14 | 15 | expect(actions.setPaginateLink(nextHref, paginateType)).to.eql(expectedAction); 16 | }); 17 | 18 | }); -------------------------------------------------------------------------------- /app/src/actions/player/index.js: -------------------------------------------------------------------------------- 1 | import find from 'lodash/fp/find'; 2 | import findIndex from 'lodash/fp/findIndex'; 3 | import * as actionTypes from '../../constants/actionTypes'; 4 | import * as toggleTypes from '../../constants/toggleTypes'; 5 | import { resetToggle } from '../../actions/toggle'; 6 | import { isSameTrackAndPlaying, isSameTrack } from '../../services/player'; 7 | 8 | export function setActiveTrack(activeTrackId) { 9 | return { 10 | type: actionTypes.SET_ACTIVE_TRACK, 11 | activeTrackId 12 | }; 13 | } 14 | 15 | export function setIsPlaying(isPlaying) { 16 | return { 17 | type: actionTypes.SET_IS_PLAYING, 18 | isPlaying 19 | }; 20 | } 21 | 22 | export function setTrackInPlaylist(trackId) { 23 | return { 24 | type: actionTypes.SET_TRACK_IN_PLAYLIST, 25 | trackId 26 | }; 27 | } 28 | 29 | export function removeFromPlaylist(trackId) { 30 | return { 31 | type: actionTypes.REMOVE_TRACK_FROM_PLAYLIST, 32 | trackId 33 | }; 34 | } 35 | 36 | export function deactivateTrack() { 37 | return { 38 | type: actionTypes.RESET_ACTIVE_TRACK, 39 | }; 40 | } 41 | 42 | export function emptyPlaylist() { 43 | return { 44 | type: actionTypes.RESET_PLAYLIST, 45 | }; 46 | } 47 | 48 | export function setIsInShuffleMode() { 49 | return { 50 | type: actionTypes.SET_SHUFFLE_MODE, 51 | }; 52 | } 53 | 54 | export function setIsInRepeatMode() { 55 | return { 56 | type: actionTypes.SET_REPEAT_MODE, 57 | }; 58 | } 59 | 60 | export function setTrackVolume(volume) { 61 | return { 62 | type: actionTypes.SET_VOLUME, 63 | volume 64 | }; 65 | } 66 | 67 | export const clearPlaylist = () => (dispatch) => { 68 | dispatch(emptyPlaylist()); 69 | dispatch(deactivateTrack()); 70 | dispatch(togglePlayTrack(false)); 71 | dispatch(resetToggle(toggleTypes.PLAYLIST)); 72 | dispatch(resetToggle(toggleTypes.VOLUME)); 73 | }; 74 | 75 | function isInPlaylist(playlist, trackId) { 76 | return find(isSameTrack(trackId), playlist); 77 | } 78 | 79 | export const togglePlayTrack = (isPlaying) => (dispatch) => { 80 | dispatch(setIsPlaying(isPlaying)); 81 | }; 82 | 83 | export const activateTrack = (trackId) => (dispatch, getState) => { 84 | const playlist = getState().player.playlist; 85 | const previousActiveTrackId = getState().player.activeTrackId; 86 | const isCurrentlyPlaying = getState().player.isPlaying; 87 | const isPlaying = !isSameTrackAndPlaying(previousActiveTrackId, trackId, isCurrentlyPlaying); 88 | 89 | dispatch(togglePlayTrack(isPlaying)); 90 | dispatch(setActiveTrack(trackId)); 91 | 92 | if (!isInPlaylist(playlist, trackId)) { 93 | dispatch(setTrackInPlaylist(trackId)); 94 | } 95 | }; 96 | 97 | export const addTrackToPlaylist = (track) => (dispatch, getState) => { 98 | const playlist = getState().player.playlist; 99 | 100 | if (!isInPlaylist(playlist, track.id)) { 101 | dispatch(setTrackInPlaylist(track.id)); 102 | } 103 | 104 | if (!playlist.length) { 105 | dispatch(activateTrack(track.id)); 106 | } 107 | }; 108 | 109 | function getIteratedTrack(playlist, currentActiveTrackId, iterate) { 110 | const index = findIndex(isSameTrack(currentActiveTrackId), playlist); 111 | const nextIndex = (index + iterate) % playlist.length; 112 | return playlist[nextIndex]; 113 | } 114 | 115 | function getRandomTrack(playlist, currentActiveTrackId) { 116 | const index = findIndex(isSameTrack(currentActiveTrackId), playlist); 117 | 118 | function getRandomIndex() { 119 | const randNum = Math.floor(Math.random() * playlist.length); 120 | if (randNum === index && playlist.length > 1) { 121 | return getRandomIndex(); 122 | } else { 123 | return randNum; 124 | } 125 | } 126 | return playlist[getRandomIndex()]; 127 | } 128 | 129 | export const activateIteratedPlaylistTrack = (currentActiveTrackId, iterate) => (dispatch, getState) => { 130 | const playlist = getState().player.playlist; 131 | const nextActiveTrackId = getIteratedTrack(playlist, currentActiveTrackId, iterate); 132 | const isInShuffleMode = getState().player.isInShuffleMode; 133 | 134 | if (nextActiveTrackId && isInShuffleMode === false) { 135 | dispatch(activateTrack(nextActiveTrackId)); 136 | } else if (isInShuffleMode) { 137 | dispatchRandomTrack(playlist, currentActiveTrackId, dispatch); 138 | } 139 | }; 140 | 141 | function dispatchRandomTrack(playlist, currentActiveTrackId, dispatch) { 142 | const randomActiveTrackId = getRandomTrack(playlist, currentActiveTrackId); 143 | dispatch(activateTrack(randomActiveTrackId)); 144 | } 145 | 146 | export const activateIteratedStreamTrack = (currentActiveTrackId, iterate) => (dispatch, getState) => { 147 | const isInShuffleMode = getState().player.isInShuffleMode; 148 | const playlist = getState().player.playlist; 149 | 150 | const streamList = getStreamList(getState); 151 | 152 | if (isInShuffleMode) { 153 | dispatchRandomTrack(streamList, currentActiveTrackId, dispatch); 154 | } else { 155 | const nextStreamTrackId = findNextStreamTrackId(streamList, playlist, currentActiveTrackId, iterate); 156 | if (nextStreamTrackId) { 157 | dispatch(activateTrack(nextStreamTrackId)); 158 | } else { 159 | dispatch(togglePlayTrack(false)); 160 | } 161 | } 162 | }; 163 | 164 | function getStreamList(getState) { 165 | const selectedGenre = getState().browse.selectedGenre; 166 | if (selectedGenre) { 167 | return getState().browse[selectedGenre]; 168 | } 169 | return getState().user.activities; 170 | } 171 | 172 | function findNextStreamTrackId(streamList, playlist, currentActiveTrackId, iterate) { 173 | let nextStreamTrackId = getIteratedTrack(streamList, currentActiveTrackId, iterate); 174 | while (playlist.includes(nextStreamTrackId)) { 175 | nextStreamTrackId = getIteratedTrack(streamList, nextStreamTrackId, iterate); 176 | } 177 | return nextStreamTrackId; 178 | } 179 | 180 | export const removeTrackFromPlaylist = (track) => (dispatch, getState) => { 181 | const activeTrackId = getState().player.activeTrackId; 182 | const isPlaying = getState().player.isPlaying; 183 | const isRelevantTrack = isSameTrackAndPlaying(activeTrackId, track.id, isPlaying); 184 | 185 | if (isRelevantTrack) { 186 | dispatch(activateIteratedPlaylistTrack(activeTrackId, 1)); 187 | } 188 | 189 | const playlistSize = getState().player.playlist.length; 190 | if (playlistSize < 2) { 191 | dispatch(deactivateTrack()); 192 | dispatch(togglePlayTrack(false)); 193 | dispatch(resetToggle(toggleTypes.PLAYLIST)); 194 | dispatch(resetToggle(toggleTypes.VOLUME)); 195 | } 196 | 197 | dispatch(removeFromPlaylist(track.id)); 198 | }; 199 | 200 | export const toggleShuffleMode = (isInShuffleMode) => (dispatch) => { 201 | dispatch(setIsInShuffleMode(isInShuffleMode)); 202 | }; 203 | 204 | export const toggleRepeatMode = (isInRepeatMode) => (dispatch) => { 205 | dispatch(setIsInRepeatMode(isInRepeatMode)); 206 | }; 207 | 208 | 209 | export const changeVolume = (volume) => (dispatch) => { 210 | dispatch(setTrackVolume(volume)); 211 | }; 212 | -------------------------------------------------------------------------------- /app/src/actions/player/spec.js: -------------------------------------------------------------------------------- 1 | import * as actions from './index'; 2 | import * as actionTypes from '../../constants/actionTypes'; 3 | 4 | describe('setActiveTrack()', () => { 5 | 6 | it('creates an action to set an active track', () => { 7 | const activeTrackId = 4; 8 | const expectedAction = { 9 | type: actionTypes.SET_ACTIVE_TRACK, 10 | activeTrackId 11 | }; 12 | 13 | expect(actions.setActiveTrack(activeTrackId)).to.eql(expectedAction); 14 | }); 15 | 16 | }); -------------------------------------------------------------------------------- /app/src/actions/request/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | export function setRequestInProcess(inProcess, requestType) { 4 | return { 5 | type: actionTypes.SET_REQUEST_IN_PROCESS, 6 | requestType, 7 | inProcess 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /app/src/actions/request/spec.js: -------------------------------------------------------------------------------- 1 | import * as actions from './index'; 2 | import * as actionTypes from '../../constants/actionTypes'; 3 | 4 | describe('setRequestInProcess()', () => { 5 | 6 | it('creates an action to set a toggle', () => { 7 | const requestType = 'FOO_REQUEST'; 8 | const inProcess = true; 9 | const expectedAction = { 10 | type: actionTypes.SET_REQUEST_IN_PROCESS, 11 | inProcess, 12 | requestType 13 | }; 14 | 15 | expect(actions.setRequestInProcess(inProcess, requestType)).to.eql(expectedAction); 16 | }); 17 | 18 | }); -------------------------------------------------------------------------------- /app/src/actions/session/index.js: -------------------------------------------------------------------------------- 1 | import Cookies from 'js-cookie'; 2 | import { CLIENT_ID, OAUTH_TOKEN, REDIRECT_URI } from '../../constants/authentication'; 3 | import * as actionTypes from '../../constants/actionTypes'; 4 | import { apiUrl } from '../../services/api'; 5 | import { fetchFollowings, fetchActivities, fetchFollowers, fetchFavorites } from '../../actions/user'; 6 | import { setRequestInProcess } from '../../actions/request'; 7 | import * as requestTypes from '../../constants/requestTypes'; 8 | 9 | function setSession(session) { 10 | return { 11 | type: actionTypes.SET_SESSION, 12 | session 13 | }; 14 | } 15 | 16 | function setUser(user) { 17 | return { 18 | type: actionTypes.SET_USER, 19 | user 20 | }; 21 | } 22 | 23 | export function resetSession() { 24 | return { 25 | type: actionTypes.RESET_SESSION 26 | }; 27 | } 28 | 29 | const fetchUser = () => (dispatch) => { 30 | fetch(apiUrl(`me`, '?')) 31 | .then(response => response.json()) 32 | .then(me => { 33 | dispatch(setUser(me)); 34 | dispatch(fetchActivities()); 35 | dispatch(fetchFavorites(me)); 36 | dispatch(fetchFollowings(me)); 37 | dispatch(fetchFollowers(me)); 38 | }); 39 | }; 40 | 41 | export const login = () => (dispatch) => { 42 | const client_id = CLIENT_ID; 43 | const redirect_uri = REDIRECT_URI; 44 | dispatch(setRequestInProcess(true, requestTypes.AUTH)); 45 | SC.initialize({ client_id, redirect_uri }); 46 | SC.connect().then((session) => { 47 | Cookies.set(OAUTH_TOKEN, session.oauth_token); 48 | dispatch(setSession(session)); 49 | dispatch(fetchUser()); 50 | dispatch(setRequestInProcess(false, requestTypes.AUTH)); 51 | }).catch(() => { 52 | dispatch(setRequestInProcess(false, requestTypes.AUTH)); 53 | }); 54 | }; 55 | 56 | export const logout = () => (dispatch) => { 57 | Cookies.remove(OAUTH_TOKEN); 58 | dispatch(resetSession()); 59 | }; 60 | -------------------------------------------------------------------------------- /app/src/actions/sort/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | export function sortStream(sortType) { 4 | return { 5 | type: actionTypes.SORT_STREAM, 6 | sortType 7 | }; 8 | } 9 | export function dateSortStream(dateSortType) { 10 | return { 11 | type: actionTypes.DATE_SORT_STREAM, 12 | dateSortType 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /app/src/actions/toggle/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | export function setToggle(toggleType) { 4 | return { 5 | type: actionTypes.SET_TOGGLED, 6 | toggleType 7 | }; 8 | } 9 | 10 | export function resetToggle(toggleType) { 11 | return { 12 | type: actionTypes.RESET_TOGGLED, 13 | toggleType 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /app/src/actions/toggle/spec.js: -------------------------------------------------------------------------------- 1 | import * as actions from './index'; 2 | import * as actionTypes from '../../constants/actionTypes'; 3 | 4 | describe('setToggle()', () => { 5 | 6 | it('creates an action to set a toggle', () => { 7 | const toggleType = 'FOO_TOGGLE'; 8 | const expectedAction = { 9 | type: actionTypes.SET_TOGGLED, 10 | toggleType 11 | }; 12 | 13 | expect(actions.setToggle(toggleType)).to.eql(expectedAction); 14 | }); 15 | 16 | }); 17 | 18 | describe('resetToggle()', () => { 19 | 20 | it('creates an action to set a toggle', () => { 21 | const toggleType = 'FOO_TOGGLE'; 22 | const expectedAction = { 23 | type: actionTypes.RESET_TOGGLED, 24 | toggleType 25 | }; 26 | 27 | expect(actions.resetToggle(toggleType)).to.eql(expectedAction); 28 | }); 29 | 30 | }); -------------------------------------------------------------------------------- /app/src/actions/track/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | import { apiUrl } from '../../services/api'; 3 | import { mergeFavorites } from '../../actions/user'; 4 | import { syncEntities } from '../../actions/entities'; 5 | 6 | export function removeFromFavorites(trackId) { 7 | return { 8 | type: actionTypes.REMOVE_FROM_FAVORITES, 9 | trackId 10 | }; 11 | } 12 | 13 | export const like = (track) => (dispatch) => { 14 | fetch(apiUrl(`me/favorites/${track.id}`, '?'), { method: track.user_favorite ? 'delete' : 'put' }) 15 | .then(response => response.json()) 16 | .then(() => { 17 | if (track.user_favorite) { 18 | dispatch(removeFromFavorites(track.id)); 19 | } else { 20 | dispatch(mergeFavorites([track.id])); 21 | } 22 | 23 | const updateEntity = Object.assign({}, track, { user_favorite: !track.user_favorite }); 24 | dispatch(syncEntities(updateEntity, 'tracks')); 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /app/src/actions/track/spec.js: -------------------------------------------------------------------------------- 1 | import * as actions from './index'; 2 | import * as actionTypes from '../../constants/actionTypes'; 3 | 4 | describe('removeFromFavorites()', () => { 5 | 6 | it('creates an action to remove a track from favorites', () => { 7 | const trackId = 7; 8 | const expectedAction = { 9 | type: actionTypes.REMOVE_FROM_FAVORITES, 10 | trackId 11 | }; 12 | 13 | expect(actions.removeFromFavorites(trackId)).to.eql(expectedAction); 14 | }); 15 | 16 | }); -------------------------------------------------------------------------------- /app/src/actions/user/index.js: -------------------------------------------------------------------------------- 1 | import flow from 'lodash/fp/flow'; 2 | import map from 'lodash/fp/map'; 3 | import filter from 'lodash/fp/filter'; 4 | import { arrayOf, normalize } from 'normalizr'; 5 | import userSchema from '../../schemas/user'; 6 | import trackSchema from '../../schemas/track'; 7 | import * as trackTypes from '../../constants/trackTypes'; 8 | import * as actionTypes from '../../constants/actionTypes'; 9 | import * as requestTypes from '../../constants/requestTypes'; 10 | import * as paginateLinkTypes from '../../constants/paginateLinkTypes'; 11 | import { setRequestInProcess } from '../../actions/request'; 12 | import { setPaginateLink } from '../../actions/paginate'; 13 | import { mergeEntities } from '../../actions/entities'; 14 | import { isTrack, toIdAndType } from '../../services/track'; 15 | import { getLazyLoadingUsersUrl } from '../../services/api'; 16 | 17 | export function mergeFollowings(followings) { 18 | return { 19 | type: actionTypes.MERGE_FOLLOWINGS, 20 | followings 21 | }; 22 | } 23 | 24 | export const fetchFollowings = (user, nextHref) => (dispatch, getState) => { 25 | const requestType = requestTypes.FOLLOWINGS; 26 | const url = getLazyLoadingUsersUrl(user, nextHref, 'followings?limit=20&offset=0'); 27 | const requestInProcess = getState().request[requestType]; 28 | 29 | if (requestInProcess) { return; } 30 | 31 | dispatch(setRequestInProcess(true, requestType)); 32 | 33 | return fetch(url) 34 | .then(response => response.json()) 35 | .then(data => { 36 | const normalized = normalize(data.collection, arrayOf(userSchema)); 37 | dispatch(mergeEntities(normalized.entities)); 38 | dispatch(mergeFollowings(normalized.result)); 39 | dispatch(setPaginateLink(data.next_href, paginateLinkTypes.FOLLOWINGS)); 40 | dispatch(setRequestInProcess(false, requestType)); 41 | }); 42 | }; 43 | 44 | export function mergeActivities(activities) { 45 | return { 46 | type: actionTypes.MERGE_ACTIVITIES, 47 | activities 48 | }; 49 | } 50 | 51 | function mergeTrackTypesTrack(tracks) { 52 | return { 53 | type: actionTypes.MERGE_TRACK_TYPES_TRACK, 54 | tracks 55 | }; 56 | } 57 | 58 | function mergeTrackTypesRepost(reposts) { 59 | return { 60 | type: actionTypes.MERGE_TRACK_TYPES_REPOST, 61 | reposts 62 | }; 63 | } 64 | 65 | export const fetchActivities = (user, nextHref) => (dispatch, getState) => { 66 | const requestType = requestTypes.ACTIVITIES; 67 | const url = getLazyLoadingUsersUrl(user, nextHref, 'activities?limit=20&offset=0'); 68 | const requestInProcess = getState().request[requestType]; 69 | 70 | if (requestInProcess) { return; } 71 | 72 | dispatch(setRequestInProcess(true, requestType)); 73 | 74 | return fetch(url) 75 | .then(response => response.json()) 76 | .then(data => { 77 | const typeMap = flow( 78 | filter(isTrack), 79 | map(toIdAndType) 80 | )(data.collection); 81 | 82 | dispatch(mergeTrackTypesTrack(filter((value) => value.type === trackTypes.TRACK, typeMap))); 83 | dispatch(mergeTrackTypesRepost(filter((value) => value.type === trackTypes.TRACK_REPOST, typeMap))); 84 | 85 | const activitiesMap = flow( 86 | filter(isTrack), 87 | map('origin') 88 | )(data.collection); 89 | 90 | const normalized = normalize(activitiesMap, arrayOf(trackSchema)); 91 | dispatch(mergeEntities(normalized.entities)); 92 | dispatch(mergeActivities(normalized.result)); 93 | 94 | dispatch(setPaginateLink(data.next_href, paginateLinkTypes.ACTIVITIES)); 95 | dispatch(setRequestInProcess(false, requestType)); 96 | }); 97 | }; 98 | 99 | export function mergeFollowers(followers) { 100 | return { 101 | type: actionTypes.MERGE_FOLLOWERS, 102 | followers 103 | }; 104 | } 105 | 106 | export const fetchFollowers = (user, nextHref) => (dispatch, getState) => { 107 | const requestType = requestTypes.FOLLOWERS; 108 | const url = getLazyLoadingUsersUrl(user, nextHref, 'followers?limit=20&offset=0'); 109 | const requestInProcess = getState().request[requestType]; 110 | 111 | if (requestInProcess) { return; } 112 | 113 | dispatch(setRequestInProcess(true, requestType)); 114 | 115 | return fetch(url) 116 | .then(response => response.json()) 117 | .then(data => { 118 | const normalized = normalize(data.collection, arrayOf(userSchema)); 119 | dispatch(mergeEntities(normalized.entities)); 120 | dispatch(mergeFollowers(normalized.result)); 121 | dispatch(setPaginateLink(data.next_href, paginateLinkTypes.FOLLOWERS)); 122 | dispatch(setRequestInProcess(false, requestType)); 123 | }); 124 | }; 125 | 126 | export function mergeFavorites(favorites) { 127 | return { 128 | type: actionTypes.MERGE_FAVORITES, 129 | favorites 130 | }; 131 | } 132 | 133 | export const fetchFavorites = (user, nextHref) => (dispatch, getState) => { 134 | const requestType = requestTypes.FAVORITES; 135 | const url = getLazyLoadingUsersUrl(user, nextHref, 'favorites?linked_partitioning=1&limit=20&offset=0'); 136 | const requestInProcess = getState().request[requestType]; 137 | 138 | if (requestInProcess) { return; } 139 | 140 | dispatch(setRequestInProcess(true, requestType)); 141 | 142 | return fetch(url) 143 | .then(response => response.json()) 144 | .then(data => { 145 | const normalized = normalize(data.collection, arrayOf(trackSchema)); 146 | dispatch(mergeEntities(normalized.entities)); 147 | dispatch(mergeFavorites(normalized.result)); 148 | dispatch(setPaginateLink(data.next_href, paginateLinkTypes.FAVORITES)); 149 | dispatch(setRequestInProcess(false, requestType)); 150 | }); 151 | }; 152 | -------------------------------------------------------------------------------- /app/src/actions/user/spec.js: -------------------------------------------------------------------------------- 1 | import * as actions from './index'; 2 | import * as actionTypes from '../../constants/actionTypes'; 3 | 4 | describe('mergeFollowings()', () => { 5 | 6 | it('creates an action to merge followings', () => { 7 | const followings = ['x', 'y']; 8 | const expectedAction = { 9 | type: actionTypes.MERGE_FOLLOWINGS, 10 | followings 11 | }; 12 | 13 | expect(actions.mergeFollowings(followings)).to.eql(expectedAction); 14 | }); 15 | 16 | }); 17 | 18 | describe('mergeFavorites()', () => { 19 | 20 | it('creates an action to merge favorites', () => { 21 | const favorites = ['x', 'y']; 22 | const expectedAction = { 23 | type: actionTypes.MERGE_FAVORITES, 24 | favorites 25 | }; 26 | 27 | expect(actions.mergeFavorites(favorites)).to.eql(expectedAction); 28 | }); 29 | 30 | }); 31 | 32 | describe('mergeFollowers()', () => { 33 | 34 | it('creates an action to merge followers', () => { 35 | const followers = ['x', 'y']; 36 | const expectedAction = { 37 | type: actionTypes.MERGE_FOLLOWERS, 38 | followers 39 | }; 40 | 41 | expect(actions.mergeFollowers(followers)).to.eql(expectedAction); 42 | }); 43 | 44 | }); 45 | 46 | describe('mergeActivities()', () => { 47 | 48 | it('creates an action to merge activities', () => { 49 | const activities = ['x', 'y']; 50 | const expectedAction = { 51 | type: actionTypes.MERGE_ACTIVITIES, 52 | activities 53 | }; 54 | 55 | expect(actions.mergeActivities(activities)).to.eql(expectedAction); 56 | }); 57 | 58 | }); 59 | -------------------------------------------------------------------------------- /app/src/components/Activities/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import map from '../../services/map'; 4 | import withFetchOnScroll from '../../components/withFetchOnScroll'; 5 | import withLoadingSpinner from '../../components/withLoadingSpinner'; 6 | import TrackExtension from '../../components/TrackExtension'; 7 | import { TrackStreamContainer } from '../../components/Track'; 8 | 9 | function Activity({ 10 | activity, 11 | idx 12 | }) { 13 | return ( 14 |
13 | This page should close soon. 14 |
15 |Fork Me on Github
28 | 29 |