diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2017-05-03 02:04:16 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-05-03 02:04:16 +0200 |
commit | f5bf5ebb82e3af420dcd23d602b1be6cc86838e1 (patch) | |
tree | 92eef08642a038cf44ccbc6d16a884293e7a0814 /app/javascript/mastodon/reducers | |
parent | 26bc5915727e0a0173c03cb49f5193dd612fb888 (diff) |
Replace sprockets/browserify with Webpack (#2617)
* Replace browserify with webpack * Add react-intl-translations-manager * Do not minify in development, add offline-plugin for ServiceWorker background cache updates * Adjust tests and dependencies * Fix production deployments * Fix tests * More optimizations * Improve travis cache for npm stuff * Re-run travis * Add back support for custom.scss as before * Remove offline-plugin and babili * Fix issue with Immutable.List().unshift(...values) not working as expected * Make travis load schema instead of running all migrations in sequence * Fix missing React import in WarningContainer. Optimize rendering performance by using ImmutablePureComponent instead of React.PureComponent. ImmutablePureComponent uses Immutable.is() to compare props. Replace dynamic callback bindings in <UI /> * Add react definitions to places that use JSX * Add Procfile.dev for running rails, webpack and streaming API at the same time
Diffstat (limited to 'app/javascript/mastodon/reducers')
-rw-r--r-- | app/javascript/mastodon/reducers/accounts.js | 133 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/accounts_counters.js | 135 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/alerts.js | 25 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/cards.js | 14 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/compose.js | 232 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/index.js | 38 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/meta.js | 17 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/modal.js | 18 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/notifications.js | 104 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/relationships.js | 38 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/reports.js | 60 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/search.js | 96 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/settings.js | 52 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/status_lists.js | 39 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/statuses.js | 124 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/timelines.js | 317 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/user_lists.js | 80 |
17 files changed, 1522 insertions, 0 deletions
diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js new file mode 100644 index 000000000..b3c2b6d88 --- /dev/null +++ b/app/javascript/mastodon/reducers/accounts.js @@ -0,0 +1,133 @@ +import { + ACCOUNT_FETCH_SUCCESS, + FOLLOWERS_FETCH_SUCCESS, + FOLLOWERS_EXPAND_SUCCESS, + FOLLOWING_FETCH_SUCCESS, + FOLLOWING_EXPAND_SUCCESS, + ACCOUNT_TIMELINE_FETCH_SUCCESS, + ACCOUNT_TIMELINE_EXPAND_SUCCESS, + FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_EXPAND_SUCCESS +} from '../actions/accounts'; +import { + BLOCKS_FETCH_SUCCESS, + BLOCKS_EXPAND_SUCCESS +} from '../actions/blocks'; +import { + MUTES_FETCH_SUCCESS, + MUTES_EXPAND_SUCCESS +} from '../actions/mutes'; +import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose'; +import { + REBLOG_SUCCESS, + UNREBLOG_SUCCESS, + FAVOURITE_SUCCESS, + UNFAVOURITE_SUCCESS, + REBLOGS_FETCH_SUCCESS, + FAVOURITES_FETCH_SUCCESS +} from '../actions/interactions'; +import { + TIMELINE_REFRESH_SUCCESS, + TIMELINE_UPDATE, + TIMELINE_EXPAND_SUCCESS +} from '../actions/timelines'; +import { + STATUS_FETCH_SUCCESS, + CONTEXT_FETCH_SUCCESS +} from '../actions/statuses'; +import { SEARCH_FETCH_SUCCESS } from '../actions/search'; +import { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_REFRESH_SUCCESS, + NOTIFICATIONS_EXPAND_SUCCESS +} from '../actions/notifications'; +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS +} from '../actions/favourites'; +import { STORE_HYDRATE } from '../actions/store'; +import Immutable from 'immutable'; + +const normalizeAccount = (state, account) => { + account = { ...account }; + + delete account.followers_count; + delete account.following_count; + delete account.statuses_count; + + return state.set(account.id, Immutable.fromJS(account)) +}; + +const normalizeAccounts = (state, accounts) => { + accounts.forEach(account => { + state = normalizeAccount(state, account); + }); + + return state; +}; + +const normalizeAccountFromStatus = (state, status) => { + state = normalizeAccount(state, status.account); + + if (status.reblog && status.reblog.account) { + state = normalizeAccount(state, status.reblog.account); + } + + return state; +}; + +const normalizeAccountsFromStatuses = (state, statuses) => { + statuses.forEach(status => { + state = normalizeAccountFromStatus(state, status); + }); + + return state; +}; + +const initialState = Immutable.Map(); + +export default function accounts(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.merge(action.state.get('accounts')); + case ACCOUNT_FETCH_SUCCESS: + case NOTIFICATIONS_UPDATE: + return normalizeAccount(state, action.account); + case FOLLOWERS_FETCH_SUCCESS: + case FOLLOWERS_EXPAND_SUCCESS: + case FOLLOWING_FETCH_SUCCESS: + case FOLLOWING_EXPAND_SUCCESS: + case REBLOGS_FETCH_SUCCESS: + case FAVOURITES_FETCH_SUCCESS: + case COMPOSE_SUGGESTIONS_READY: + case FOLLOW_REQUESTS_FETCH_SUCCESS: + case FOLLOW_REQUESTS_EXPAND_SUCCESS: + case BLOCKS_FETCH_SUCCESS: + case BLOCKS_EXPAND_SUCCESS: + case MUTES_FETCH_SUCCESS: + case MUTES_EXPAND_SUCCESS: + return normalizeAccounts(state, action.accounts); + case NOTIFICATIONS_REFRESH_SUCCESS: + case NOTIFICATIONS_EXPAND_SUCCESS: + case SEARCH_FETCH_SUCCESS: + return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); + case TIMELINE_REFRESH_SUCCESS: + case TIMELINE_EXPAND_SUCCESS: + case ACCOUNT_TIMELINE_FETCH_SUCCESS: + case ACCOUNT_TIMELINE_EXPAND_SUCCESS: + case CONTEXT_FETCH_SUCCESS: + case FAVOURITED_STATUSES_FETCH_SUCCESS: + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + return normalizeAccountsFromStatuses(state, action.statuses); + case REBLOG_SUCCESS: + case FAVOURITE_SUCCESS: + case UNREBLOG_SUCCESS: + case UNFAVOURITE_SUCCESS: + return normalizeAccountFromStatus(state, action.response); + case TIMELINE_UPDATE: + case STATUS_FETCH_SUCCESS: + return normalizeAccountFromStatus(state, action.status); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/accounts_counters.js b/app/javascript/mastodon/reducers/accounts_counters.js new file mode 100644 index 000000000..2afc6c3d9 --- /dev/null +++ b/app/javascript/mastodon/reducers/accounts_counters.js @@ -0,0 +1,135 @@ +import { + ACCOUNT_FETCH_SUCCESS, + FOLLOWERS_FETCH_SUCCESS, + FOLLOWERS_EXPAND_SUCCESS, + FOLLOWING_FETCH_SUCCESS, + FOLLOWING_EXPAND_SUCCESS, + ACCOUNT_TIMELINE_FETCH_SUCCESS, + ACCOUNT_TIMELINE_EXPAND_SUCCESS, + FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_EXPAND_SUCCESS, + ACCOUNT_FOLLOW_SUCCESS, + ACCOUNT_UNFOLLOW_SUCCESS +} from '../actions/accounts'; +import { + BLOCKS_FETCH_SUCCESS, + BLOCKS_EXPAND_SUCCESS +} from '../actions/blocks'; +import { + MUTES_FETCH_SUCCESS, + MUTES_EXPAND_SUCCESS +} from '../actions/mutes'; +import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose'; +import { + REBLOG_SUCCESS, + UNREBLOG_SUCCESS, + FAVOURITE_SUCCESS, + UNFAVOURITE_SUCCESS, + REBLOGS_FETCH_SUCCESS, + FAVOURITES_FETCH_SUCCESS +} from '../actions/interactions'; +import { + TIMELINE_REFRESH_SUCCESS, + TIMELINE_UPDATE, + TIMELINE_EXPAND_SUCCESS +} from '../actions/timelines'; +import { + STATUS_FETCH_SUCCESS, + CONTEXT_FETCH_SUCCESS +} from '../actions/statuses'; +import { SEARCH_FETCH_SUCCESS } from '../actions/search'; +import { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_REFRESH_SUCCESS, + NOTIFICATIONS_EXPAND_SUCCESS +} from '../actions/notifications'; +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS +} from '../actions/favourites'; +import { STORE_HYDRATE } from '../actions/store'; +import Immutable from 'immutable'; + +const normalizeAccount = (state, account) => state.set(account.id, Immutable.fromJS({ + followers_count: account.followers_count, + following_count: account.following_count, + statuses_count: account.statuses_count, +})); + +const normalizeAccounts = (state, accounts) => { + accounts.forEach(account => { + state = normalizeAccount(state, account); + }); + + return state; +}; + +const normalizeAccountFromStatus = (state, status) => { + state = normalizeAccount(state, status.account); + + if (status.reblog && status.reblog.account) { + state = normalizeAccount(state, status.reblog.account); + } + + return state; +}; + +const normalizeAccountsFromStatuses = (state, statuses) => { + statuses.forEach(status => { + state = normalizeAccountFromStatus(state, status); + }); + + return state; +}; + +const initialState = Immutable.Map(); + +export default function accountsCounters(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.merge(action.state.get('accounts_counters')); + case ACCOUNT_FETCH_SUCCESS: + case NOTIFICATIONS_UPDATE: + return normalizeAccount(state, action.account); + case FOLLOWERS_FETCH_SUCCESS: + case FOLLOWERS_EXPAND_SUCCESS: + case FOLLOWING_FETCH_SUCCESS: + case FOLLOWING_EXPAND_SUCCESS: + case REBLOGS_FETCH_SUCCESS: + case FAVOURITES_FETCH_SUCCESS: + case COMPOSE_SUGGESTIONS_READY: + case FOLLOW_REQUESTS_FETCH_SUCCESS: + case FOLLOW_REQUESTS_EXPAND_SUCCESS: + case BLOCKS_FETCH_SUCCESS: + case BLOCKS_EXPAND_SUCCESS: + case MUTES_FETCH_SUCCESS: + case MUTES_EXPAND_SUCCESS: + return normalizeAccounts(state, action.accounts); + case NOTIFICATIONS_REFRESH_SUCCESS: + case NOTIFICATIONS_EXPAND_SUCCESS: + case SEARCH_FETCH_SUCCESS: + return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); + case TIMELINE_REFRESH_SUCCESS: + case TIMELINE_EXPAND_SUCCESS: + case ACCOUNT_TIMELINE_FETCH_SUCCESS: + case ACCOUNT_TIMELINE_EXPAND_SUCCESS: + case CONTEXT_FETCH_SUCCESS: + case FAVOURITED_STATUSES_FETCH_SUCCESS: + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + return normalizeAccountsFromStatuses(state, action.statuses); + case REBLOG_SUCCESS: + case FAVOURITE_SUCCESS: + case UNREBLOG_SUCCESS: + case UNFAVOURITE_SUCCESS: + return normalizeAccountFromStatus(state, action.response); + case TIMELINE_UPDATE: + case STATUS_FETCH_SUCCESS: + return normalizeAccountFromStatus(state, action.status); + case ACCOUNT_FOLLOW_SUCCESS: + return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1); + case ACCOUNT_UNFOLLOW_SUCCESS: + return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1)); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/alerts.js b/app/javascript/mastodon/reducers/alerts.js new file mode 100644 index 000000000..dc0145824 --- /dev/null +++ b/app/javascript/mastodon/reducers/alerts.js @@ -0,0 +1,25 @@ +import { + ALERT_SHOW, + ALERT_DISMISS, + ALERT_CLEAR +} from '../actions/alerts'; +import Immutable from 'immutable'; + +const initialState = Immutable.List([]); + +export default function alerts(state = initialState, action) { + switch(action.type) { + case ALERT_SHOW: + return state.push(Immutable.Map({ + key: state.size > 0 ? state.last().get('key') + 1 : 0, + title: action.title, + message: action.message + })); + case ALERT_DISMISS: + return state.filterNot(item => item.get('key') === action.alert.key); + case ALERT_CLEAR: + return state.clear(); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/cards.js b/app/javascript/mastodon/reducers/cards.js new file mode 100644 index 000000000..3c9395011 --- /dev/null +++ b/app/javascript/mastodon/reducers/cards.js @@ -0,0 +1,14 @@ +import { STATUS_CARD_FETCH_SUCCESS } from '../actions/cards'; + +import Immutable from 'immutable'; + +const initialState = Immutable.Map(); + +export default function cards(state = initialState, action) { + switch(action.type) { + case STATUS_CARD_FETCH_SUCCESS: + return state.set(action.id, Immutable.fromJS(action.card)); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js new file mode 100644 index 000000000..c87384780 --- /dev/null +++ b/app/javascript/mastodon/reducers/compose.js @@ -0,0 +1,232 @@ +import { + COMPOSE_MOUNT, + COMPOSE_UNMOUNT, + COMPOSE_CHANGE, + COMPOSE_REPLY, + COMPOSE_REPLY_CANCEL, + COMPOSE_MENTION, + COMPOSE_SUBMIT_REQUEST, + COMPOSE_SUBMIT_SUCCESS, + COMPOSE_SUBMIT_FAIL, + COMPOSE_UPLOAD_REQUEST, + COMPOSE_UPLOAD_SUCCESS, + COMPOSE_UPLOAD_FAIL, + COMPOSE_UPLOAD_UNDO, + COMPOSE_UPLOAD_PROGRESS, + COMPOSE_SUGGESTIONS_CLEAR, + COMPOSE_SUGGESTIONS_READY, + COMPOSE_SUGGESTION_SELECT, + COMPOSE_SENSITIVITY_CHANGE, + COMPOSE_SPOILERNESS_CHANGE, + COMPOSE_SPOILER_TEXT_CHANGE, + COMPOSE_VISIBILITY_CHANGE, + COMPOSE_LISTABILITY_CHANGE, + COMPOSE_EMOJI_INSERT +} from '../actions/compose'; +import { TIMELINE_DELETE } from '../actions/timelines'; +import { STORE_HYDRATE } from '../actions/store'; +import Immutable from 'immutable'; +import uuid from '../uuid'; + +const initialState = Immutable.Map({ + mounted: false, + sensitive: false, + spoiler: false, + spoiler_text: '', + privacy: null, + text: '', + focusDate: null, + preselectDate: null, + in_reply_to: null, + is_submitting: false, + is_uploading: false, + progress: 0, + media_attachments: Immutable.List(), + suggestion_token: null, + suggestions: Immutable.List(), + me: null, + default_privacy: 'public', + resetFileKey: Math.floor((Math.random() * 0x10000)), + idempotencyKey: null +}); + +function statusToTextMentions(state, status) { + let set = Immutable.OrderedSet([]); + let me = state.get('me'); + + if (status.getIn(['account', 'id']) !== me) { + set = set.add(`@${status.getIn(['account', 'acct'])} `); + } + + return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join(''); +}; + +function clearAll(state) { + return state.withMutations(map => { + map.set('text', ''); + map.set('spoiler', false); + map.set('spoiler_text', ''); + map.set('is_submitting', false); + map.set('in_reply_to', null); + map.set('privacy', state.get('default_privacy')); + map.set('sensitive', false); + map.update('media_attachments', list => list.clear()); + map.set('idempotencyKey', uuid()); + }); +}; + +function appendMedia(state, media) { + return state.withMutations(map => { + map.update('media_attachments', list => list.push(media)); + map.set('is_uploading', false); + map.set('resetFileKey', Math.floor((Math.random() * 0x10000))); + map.update('text', oldText => `${oldText.trim()} ${media.get('text_url')}`); + map.set('focusDate', new Date()); + map.set('idempotencyKey', uuid()); + }); +}; + +function removeMedia(state, mediaId) { + const media = state.get('media_attachments').find(item => item.get('id') === mediaId); + const prevSize = state.get('media_attachments').size; + + return state.withMutations(map => { + map.update('media_attachments', list => list.filterNot(item => item.get('id') === mediaId)); + map.update('text', text => text.replace(media.get('text_url'), '').trim()); + map.set('idempotencyKey', uuid()); + + if (prevSize === 1) { + map.set('sensitive', false); + } + }); +}; + +const insertSuggestion = (state, position, token, completion) => { + return state.withMutations(map => { + map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`); + map.set('suggestion_token', null); + map.update('suggestions', Immutable.List(), list => list.clear()); + map.set('focusDate', new Date()); + map.set('idempotencyKey', uuid()); + }); +}; + +const insertEmoji = (state, position, emojiData) => { + const emoji = emojiData.shortname; + + return state.withMutations(map => { + map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`); + map.set('focusDate', new Date()); + map.set('idempotencyKey', uuid()); + }); +}; + +const privacyPreference = (a, b) => { + if (a === 'direct' || b === 'direct') { + return 'direct'; + } else if (a === 'private' || b === 'private') { + return 'private'; + } else if (a === 'unlisted' || b === 'unlisted') { + return 'unlisted'; + } else { + return 'public'; + } +}; + +export default function compose(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return clearAll(state.merge(action.state.get('compose'))); + case COMPOSE_MOUNT: + return state.set('mounted', true); + case COMPOSE_UNMOUNT: + return state.set('mounted', false); + case COMPOSE_SENSITIVITY_CHANGE: + return state + .set('sensitive', !state.get('sensitive')) + .set('idempotencyKey', uuid()); + case COMPOSE_SPOILERNESS_CHANGE: + return state.withMutations(map => { + map.set('spoiler_text', ''); + map.set('spoiler', !state.get('spoiler')); + map.set('idempotencyKey', uuid()); + }); + case COMPOSE_SPOILER_TEXT_CHANGE: + return state + .set('spoiler_text', action.text) + .set('idempotencyKey', uuid()); + case COMPOSE_VISIBILITY_CHANGE: + return state + .set('privacy', action.value) + .set('idempotencyKey', uuid()); + case COMPOSE_CHANGE: + return state + .set('text', action.text) + .set('idempotencyKey', uuid()); + case COMPOSE_REPLY: + return state.withMutations(map => { + map.set('in_reply_to', action.status.get('id')); + map.set('text', statusToTextMentions(state, action.status)); + map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); + map.set('focusDate', new Date()); + map.set('preselectDate', new Date()); + map.set('idempotencyKey', uuid()); + + if (action.status.get('spoiler_text').length > 0) { + map.set('spoiler', true); + map.set('spoiler_text', action.status.get('spoiler_text')); + } else { + map.set('spoiler', false); + map.set('spoiler_text', ''); + } + }); + case COMPOSE_REPLY_CANCEL: + return state.withMutations(map => { + map.set('in_reply_to', null); + map.set('text', ''); + map.set('spoiler', false); + map.set('spoiler_text', ''); + map.set('privacy', state.get('default_privacy')); + map.set('idempotencyKey', uuid()); + }); + case COMPOSE_SUBMIT_REQUEST: + return state.set('is_submitting', true); + case COMPOSE_SUBMIT_SUCCESS: + return clearAll(state); + case COMPOSE_SUBMIT_FAIL: + return state.set('is_submitting', false); + case COMPOSE_UPLOAD_REQUEST: + return state.withMutations(map => { + map.set('is_uploading', true); + }); + case COMPOSE_UPLOAD_SUCCESS: + return appendMedia(state, Immutable.fromJS(action.media)); + case COMPOSE_UPLOAD_FAIL: + return state.set('is_uploading', false); + case COMPOSE_UPLOAD_UNDO: + return removeMedia(state, action.media_id); + case COMPOSE_UPLOAD_PROGRESS: + return state.set('progress', Math.round((action.loaded / action.total) * 100)); + case COMPOSE_MENTION: + return state + .update('text', text => `${text}@${action.account.get('acct')} `) + .set('focusDate', new Date()) + .set('idempotencyKey', uuid()); + case COMPOSE_SUGGESTIONS_CLEAR: + return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null); + case COMPOSE_SUGGESTIONS_READY: + return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token); + case COMPOSE_SUGGESTION_SELECT: + return insertSuggestion(state, action.position, action.token, action.completion); + case TIMELINE_DELETE: + if (action.id === state.get('in_reply_to')) { + return state.set('in_reply_to', null); + } else { + return state; + } + case COMPOSE_EMOJI_INSERT: + return insertEmoji(state, action.position, action.emoji); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js new file mode 100644 index 000000000..f05067c47 --- /dev/null +++ b/app/javascript/mastodon/reducers/index.js @@ -0,0 +1,38 @@ +import { combineReducers } from 'redux-immutable'; +import timelines from './timelines'; +import meta from './meta'; +import compose from './compose'; +import alerts from './alerts'; +import { loadingBarReducer } from 'react-redux-loading-bar'; +import modal from './modal'; +import user_lists from './user_lists'; +import accounts from './accounts'; +import accounts_counters from './accounts_counters'; +import statuses from './statuses'; +import relationships from './relationships'; +import search from './search'; +import notifications from './notifications'; +import settings from './settings'; +import status_lists from './status_lists'; +import cards from './cards'; +import reports from './reports'; + +export default combineReducers({ + timelines, + meta, + compose, + alerts, + loadingBar: loadingBarReducer, + modal, + user_lists, + status_lists, + accounts, + accounts_counters, + statuses, + relationships, + search, + notifications, + settings, + cards, + reports +}); diff --git a/app/javascript/mastodon/reducers/meta.js b/app/javascript/mastodon/reducers/meta.js new file mode 100644 index 000000000..acf6d4be1 --- /dev/null +++ b/app/javascript/mastodon/reducers/meta.js @@ -0,0 +1,17 @@ +import { STORE_HYDRATE } from '../actions/store'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + streaming_api_base_url: null, + access_token: null, + me: null +}); + +export default function meta(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.merge(action.state.get('meta')); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/modal.js b/app/javascript/mastodon/reducers/modal.js new file mode 100644 index 000000000..3566820ef --- /dev/null +++ b/app/javascript/mastodon/reducers/modal.js @@ -0,0 +1,18 @@ +import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal'; +import Immutable from 'immutable'; + +const initialState = { + modalType: null, + modalProps: {} +}; + +export default function modal(state = initialState, action) { + switch(action.type) { + case MODAL_OPEN: + return { modalType: action.modalType, modalProps: action.modalProps }; + case MODAL_CLOSE: + return initialState; + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js new file mode 100644 index 000000000..c567a3a59 --- /dev/null +++ b/app/javascript/mastodon/reducers/notifications.js @@ -0,0 +1,104 @@ +import { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_REFRESH_SUCCESS, + NOTIFICATIONS_EXPAND_SUCCESS, + NOTIFICATIONS_REFRESH_REQUEST, + NOTIFICATIONS_EXPAND_REQUEST, + NOTIFICATIONS_REFRESH_FAIL, + NOTIFICATIONS_EXPAND_FAIL, + NOTIFICATIONS_CLEAR, + NOTIFICATIONS_SCROLL_TOP +} from '../actions/notifications'; +import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + items: Immutable.List(), + next: null, + top: true, + unread: 0, + loaded: false, + isLoading: true +}); + +const notificationToMap = notification => Immutable.Map({ + id: notification.id, + type: notification.type, + account: notification.account.id, + status: notification.status ? notification.status.id : null +}); + +const normalizeNotification = (state, notification) => { + if (!state.get('top')) { + state = state.update('unread', unread => unread + 1); + } + + return state.update('items', list => list.unshift(notificationToMap(notification))); +}; + +const normalizeNotifications = (state, notifications, next) => { + let items = Immutable.List(); + const loaded = state.get('loaded'); + + notifications.forEach((n, i) => { + items = items.set(i, notificationToMap(n)); + }); + + if (state.get('next') === null) { + state = state.set('next', next); + } + + return state + .update('items', list => loaded ? items.concat(list) : list.concat(items)) + .set('loaded', true) + .set('isLoading', false); +}; + +const appendNormalizedNotifications = (state, notifications, next) => { + let items = Immutable.List(); + + notifications.forEach((n, i) => { + items = items.set(i, notificationToMap(n)); + }); + + return state + .update('items', list => list.concat(items)) + .set('next', next) + .set('isLoading', false); +}; + +const filterNotifications = (state, relationship) => { + return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id)); +}; + +const updateTop = (state, top) => { + if (top) { + state = state.set('unread', 0); + } + + return state.set('top', top); +}; + +export default function notifications(state = initialState, action) { + switch(action.type) { + case NOTIFICATIONS_REFRESH_REQUEST: + case NOTIFICATIONS_EXPAND_REQUEST: + case NOTIFICATIONS_REFRESH_FAIL: + case NOTIFICATIONS_EXPAND_FAIL: + return state.set('isLoading', true); + case NOTIFICATIONS_SCROLL_TOP: + return updateTop(state, action.top); + case NOTIFICATIONS_UPDATE: + return normalizeNotification(state, action.notification); + case NOTIFICATIONS_REFRESH_SUCCESS: + return normalizeNotifications(state, action.notifications, action.next); + case NOTIFICATIONS_EXPAND_SUCCESS: + return appendNormalizedNotifications(state, action.notifications, action.next); + case ACCOUNT_BLOCK_SUCCESS: + return filterNotifications(state, action.relationship); + case NOTIFICATIONS_CLEAR: + return state.set('items', Immutable.List()).set('next', null); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js new file mode 100644 index 000000000..c65c48b43 --- /dev/null +++ b/app/javascript/mastodon/reducers/relationships.js @@ -0,0 +1,38 @@ +import { + ACCOUNT_FOLLOW_SUCCESS, + ACCOUNT_UNFOLLOW_SUCCESS, + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_UNBLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, + ACCOUNT_UNMUTE_SUCCESS, + RELATIONSHIPS_FETCH_SUCCESS +} from '../actions/accounts'; +import Immutable from 'immutable'; + +const normalizeRelationship = (state, relationship) => state.set(relationship.id, Immutable.fromJS(relationship)); + +const normalizeRelationships = (state, relationships) => { + relationships.forEach(relationship => { + state = normalizeRelationship(state, relationship); + }); + + return state; +}; + +const initialState = Immutable.Map(); + +export default function relationships(state = initialState, action) { + switch(action.type) { + case ACCOUNT_FOLLOW_SUCCESS: + case ACCOUNT_UNFOLLOW_SUCCESS: + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_UNBLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + case ACCOUNT_UNMUTE_SUCCESS: + return normalizeRelationship(state, action.relationship); + case RELATIONSHIPS_FETCH_SUCCESS: + return normalizeRelationships(state, action.relationships); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/reports.js b/app/javascript/mastodon/reducers/reports.js new file mode 100644 index 000000000..eab004377 --- /dev/null +++ b/app/javascript/mastodon/reducers/reports.js @@ -0,0 +1,60 @@ +import { + REPORT_INIT, + REPORT_SUBMIT_REQUEST, + REPORT_SUBMIT_SUCCESS, + REPORT_SUBMIT_FAIL, + REPORT_CANCEL, + REPORT_STATUS_TOGGLE, + REPORT_COMMENT_CHANGE +} from '../actions/reports'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + new: Immutable.Map({ + isSubmitting: false, + account_id: null, + status_ids: Immutable.Set(), + comment: '' + }) +}); + +export default function reports(state = initialState, action) { + switch(action.type) { + case REPORT_INIT: + return state.withMutations(map => { + map.setIn(['new', 'isSubmitting'], false); + map.setIn(['new', 'account_id'], action.account.get('id')); + + if (state.getIn(['new', 'account_id']) !== action.account.get('id')) { + map.setIn(['new', 'status_ids'], action.status ? Immutable.Set([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : Immutable.Set()); + map.setIn(['new', 'comment'], ''); + } else { + map.updateIn(['new', 'status_ids'], Immutable.Set(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id')))); + } + }); + case REPORT_STATUS_TOGGLE: + return state.updateIn(['new', 'status_ids'], Immutable.Set(), set => { + if (action.checked) { + return set.add(action.statusId); + } + + return set.remove(action.statusId); + }); + case REPORT_COMMENT_CHANGE: + return state.setIn(['new', 'comment'], action.comment); + case REPORT_SUBMIT_REQUEST: + return state.setIn(['new', 'isSubmitting'], true); + case REPORT_SUBMIT_FAIL: + return state.setIn(['new', 'isSubmitting'], false); + case REPORT_CANCEL: + case REPORT_SUBMIT_SUCCESS: + return state.withMutations(map => { + map.setIn(['new', 'account_id'], null); + map.setIn(['new', 'status_ids'], Immutable.Set()); + map.setIn(['new', 'comment'], ''); + map.setIn(['new', 'isSubmitting'], false); + }); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js new file mode 100644 index 000000000..b3fe6c7be --- /dev/null +++ b/app/javascript/mastodon/reducers/search.js @@ -0,0 +1,96 @@ +import { + SEARCH_CHANGE, + SEARCH_CLEAR, + SEARCH_FETCH_SUCCESS, + SEARCH_SHOW +} from '../actions/search'; +import { COMPOSE_MENTION, COMPOSE_REPLY } from '../actions/compose'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + value: '', + submitted: false, + hidden: false, + results: Immutable.Map() +}); + +const normalizeSuggestions = (state, value, accounts, hashtags, statuses) => { + let newSuggestions = []; + + if (accounts.length > 0) { + newSuggestions.push({ + title: 'account', + items: accounts.map(item => ({ + type: 'account', + id: item.id, + value: item.acct + })) + }); + } + + if (value.indexOf('@') === -1 && value.indexOf(' ') === -1 || hashtags.length > 0) { + let hashtagItems = hashtags.map(item => ({ + type: 'hashtag', + id: item, + value: `#${item}` + })); + + if (value.indexOf('@') === -1 && value.indexOf(' ') === -1 && !value.startsWith('http://') && !value.startsWith('https://') && hashtags.indexOf(value) === -1) { + hashtagItems.unshift({ + type: 'hashtag', + id: value, + value: `#${value}` + }); + } + + if (hashtagItems.length > 0) { + newSuggestions.push({ + title: 'hashtag', + items: hashtagItems + }); + } + } + + if (statuses.length > 0) { + newSuggestions.push({ + title: 'status', + items: statuses.map(item => ({ + type: 'status', + id: item.id, + value: item.id + })) + }); + } + + return state.withMutations(map => { + map.set('suggestions', newSuggestions); + map.set('loaded_value', value); + }); +}; + +export default function search(state = initialState, action) { + switch(action.type) { + case SEARCH_CHANGE: + return state.set('value', action.value); + case SEARCH_CLEAR: + return state.withMutations(map => { + map.set('value', ''); + map.set('results', Immutable.Map()); + map.set('submitted', false); + map.set('hidden', false); + }); + case SEARCH_SHOW: + return state.set('hidden', false); + case COMPOSE_REPLY: + case COMPOSE_MENTION: + return state.set('hidden', true); + case SEARCH_FETCH_SUCCESS: + return state.set('results', Immutable.Map({ + accounts: Immutable.List(action.results.accounts.map(item => item.id)), + statuses: Immutable.List(action.results.statuses.map(item => item.id)), + hashtags: Immutable.List(action.results.hashtags) + })).set('submitted', true); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js new file mode 100644 index 000000000..b255aabc4 --- /dev/null +++ b/app/javascript/mastodon/reducers/settings.js @@ -0,0 +1,52 @@ +import { SETTING_CHANGE } from '../actions/settings'; +import { STORE_HYDRATE } from '../actions/store'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + onboarded: false, + + home: Immutable.Map({ + shows: Immutable.Map({ + reblog: true, + reply: true + }), + + regex: Immutable.Map({ + body: '' + }) + }), + + notifications: Immutable.Map({ + alerts: Immutable.Map({ + follow: true, + favourite: true, + reblog: true, + mention: true + }), + + shows: Immutable.Map({ + follow: true, + favourite: true, + reblog: true, + mention: true + }), + + sounds: Immutable.Map({ + follow: true, + favourite: true, + reblog: true, + mention: true + }) + }) +}); + +export default function settings(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.mergeDeep(action.state.get('settings')); + case SETTING_CHANGE: + return state.setIn(action.key, action.value); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js new file mode 100644 index 000000000..fd463cd63 --- /dev/null +++ b/app/javascript/mastodon/reducers/status_lists.js @@ -0,0 +1,39 @@ +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS +} from '../actions/favourites'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + favourites: Immutable.Map({ + next: null, + loaded: false, + items: Immutable.List() + }) +}); + +const normalizeList = (state, listType, statuses, next) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('loaded', true); + map.set('items', Immutable.List(statuses.map(item => item.id))); + })); +}; + +const appendToList = (state, listType, statuses, next) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('items', map.get('items').concat(statuses.map(item => item.id))); + })); +}; + +export default function statusLists(state = initialState, action) { + switch(action.type) { + case FAVOURITED_STATUSES_FETCH_SUCCESS: + return normalizeList(state, 'favourites', action.statuses, action.next); + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + return appendToList(state, 'favourites', action.statuses, action.next); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js new file mode 100644 index 000000000..2002d2223 --- /dev/null +++ b/app/javascript/mastodon/reducers/statuses.js @@ -0,0 +1,124 @@ +import { + REBLOG_REQUEST, + REBLOG_SUCCESS, + REBLOG_FAIL, + UNREBLOG_SUCCESS, + FAVOURITE_REQUEST, + FAVOURITE_SUCCESS, + FAVOURITE_FAIL, + UNFAVOURITE_SUCCESS +} from '../actions/interactions'; +import { + STATUS_FETCH_SUCCESS, + CONTEXT_FETCH_SUCCESS +} from '../actions/statuses'; +import { + TIMELINE_REFRESH_SUCCESS, + TIMELINE_UPDATE, + TIMELINE_DELETE, + TIMELINE_EXPAND_SUCCESS +} from '../actions/timelines'; +import { + ACCOUNT_TIMELINE_FETCH_SUCCESS, + ACCOUNT_TIMELINE_EXPAND_SUCCESS, + ACCOUNT_BLOCK_SUCCESS +} from '../actions/accounts'; +import { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_REFRESH_SUCCESS, + NOTIFICATIONS_EXPAND_SUCCESS +} from '../actions/notifications'; +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS +} from '../actions/favourites'; +import { SEARCH_FETCH_SUCCESS } from '../actions/search'; +import Immutable from 'immutable'; + +const normalizeStatus = (state, status) => { + if (!status) { + return state; + } + + const normalStatus = { ...status }; + normalStatus.account = status.account.id; + + if (status.reblog && status.reblog.id) { + state = normalizeStatus(state, status.reblog); + normalStatus.reblog = status.reblog.id; + } + + const linebreakComplemented = status.content.replace(/<br \/>/g, '\n').replace(/<\/p><p>/g, '\n\n'); + normalStatus.unescaped_content = new DOMParser().parseFromString(linebreakComplemented, 'text/html').documentElement.textContent; + + return state.update(status.id, Immutable.Map(), map => map.mergeDeep(Immutable.fromJS(normalStatus))); +}; + +const normalizeStatuses = (state, statuses) => { + statuses.forEach(status => { + state = normalizeStatus(state, status); + }); + + return state; +}; + +const deleteStatus = (state, id, references) => { + references.forEach(ref => { + state = deleteStatus(state, ref[0], []); + }); + + return state.delete(id); +}; + +const filterStatuses = (state, relationship) => { + state.forEach(status => { + if (status.get('account') !== relationship.id) { + return; + } + + state = deleteStatus(state, status.get('id'), state.filter(item => item.get('reblog') === status.get('id'))); + }); + + return state; +}; + +const initialState = Immutable.Map(); + +export default function statuses(state = initialState, action) { + switch(action.type) { + case TIMELINE_UPDATE: + case STATUS_FETCH_SUCCESS: + case NOTIFICATIONS_UPDATE: + return normalizeStatus(state, action.status); + case REBLOG_SUCCESS: + case UNREBLOG_SUCCESS: + case FAVOURITE_SUCCESS: + case UNFAVOURITE_SUCCESS: + return normalizeStatus(state, action.response); + case FAVOURITE_REQUEST: + return state.setIn([action.status.get('id'), 'favourited'], true); + case FAVOURITE_FAIL: + return state.setIn([action.status.get('id'), 'favourited'], false); + case REBLOG_REQUEST: + return state.setIn([action.status.get('id'), 'reblogged'], true); + case REBLOG_FAIL: + return state.setIn([action.status.get('id'), 'reblogged'], false); + case TIMELINE_REFRESH_SUCCESS: + case TIMELINE_EXPAND_SUCCESS: + case ACCOUNT_TIMELINE_FETCH_SUCCESS: + case ACCOUNT_TIMELINE_EXPAND_SUCCESS: + case CONTEXT_FETCH_SUCCESS: + case NOTIFICATIONS_REFRESH_SUCCESS: + case NOTIFICATIONS_EXPAND_SUCCESS: + case FAVOURITED_STATUSES_FETCH_SUCCESS: + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + case SEARCH_FETCH_SUCCESS: + return normalizeStatuses(state, action.statuses); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.references); + case ACCOUNT_BLOCK_SUCCESS: + return filterStatuses(state, action.relationship); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js new file mode 100644 index 000000000..31e79f9f6 --- /dev/null +++ b/app/javascript/mastodon/reducers/timelines.js @@ -0,0 +1,317 @@ +import { + TIMELINE_REFRESH_REQUEST, + TIMELINE_REFRESH_SUCCESS, + TIMELINE_REFRESH_FAIL, + TIMELINE_UPDATE, + TIMELINE_DELETE, + TIMELINE_EXPAND_SUCCESS, + TIMELINE_EXPAND_REQUEST, + TIMELINE_EXPAND_FAIL, + TIMELINE_SCROLL_TOP, + TIMELINE_CONNECT, + TIMELINE_DISCONNECT +} from '../actions/timelines'; +import { + REBLOG_SUCCESS, + UNREBLOG_SUCCESS, + FAVOURITE_SUCCESS, + UNFAVOURITE_SUCCESS +} from '../actions/interactions'; +import { + ACCOUNT_TIMELINE_FETCH_REQUEST, + ACCOUNT_TIMELINE_FETCH_SUCCESS, + ACCOUNT_TIMELINE_FETCH_FAIL, + ACCOUNT_TIMELINE_EXPAND_REQUEST, + ACCOUNT_TIMELINE_EXPAND_SUCCESS, + ACCOUNT_TIMELINE_EXPAND_FAIL, + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS +} from '../actions/accounts'; +import { + CONTEXT_FETCH_SUCCESS +} from '../actions/statuses'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + home: Immutable.Map({ + path: () => '/api/v1/timelines/home', + next: null, + isLoading: false, + online: false, + loaded: false, + top: true, + unread: 0, + items: Immutable.List() + }), + + public: Immutable.Map({ + path: () => '/api/v1/timelines/public', + next: null, + isLoading: false, + online: false, + loaded: false, + top: true, + unread: 0, + items: Immutable.List() + }), + + community: Immutable.Map({ + path: () => '/api/v1/timelines/public', + next: null, + params: { local: true }, + isLoading: false, + online: false, + loaded: false, + top: true, + unread: 0, + items: Immutable.List() + }), + + tag: Immutable.Map({ + path: (id) => `/api/v1/timelines/tag/${id}`, + next: null, + isLoading: false, + id: null, + loaded: false, + top: true, + unread: 0, + items: Immutable.List() + }), + + accounts_timelines: Immutable.Map(), + ancestors: Immutable.Map(), + descendants: Immutable.Map() +}); + +const normalizeStatus = (state, status) => { + const replyToId = status.get('in_reply_to_id'); + const id = status.get('id'); + + if (replyToId) { + if (!state.getIn(['descendants', replyToId], Immutable.List()).includes(id)) { + state = state.updateIn(['descendants', replyToId], Immutable.List(), set => set.push(id)); + } + + if (!state.getIn(['ancestors', id], Immutable.List()).includes(replyToId)) { + state = state.updateIn(['ancestors', id], Immutable.List(), set => set.push(replyToId)); + } + } + + return state; +}; + +const normalizeTimeline = (state, timeline, statuses, next) => { + let ids = Immutable.List(); + const loaded = state.getIn([timeline, 'loaded']); + + statuses.forEach((status, i) => { + state = normalizeStatus(state, status); + ids = ids.set(i, status.get('id')); + }); + + state = state.setIn([timeline, 'loaded'], true); + state = state.setIn([timeline, 'isLoading'], false); + + if (state.getIn([timeline, 'next']) === null) { + state = state.setIn([timeline, 'next'], next); + } + + return state.updateIn([timeline, 'items'], Immutable.List(), list => (loaded ? ids.concat(list) : ids)); +}; + +const appendNormalizedTimeline = (state, timeline, statuses, next) => { + let moreIds = Immutable.List(); + + statuses.forEach((status, i) => { + state = normalizeStatus(state, status); + moreIds = moreIds.set(i, status.get('id')); + }); + + state = state.setIn([timeline, 'isLoading'], false); + state = state.setIn([timeline, 'next'], next); + + return state.updateIn([timeline, 'items'], Immutable.List(), list => list.concat(moreIds)); +}; + +const normalizeAccountTimeline = (state, accountId, statuses, replace = false) => { + let ids = Immutable.List(); + + statuses.forEach((status, i) => { + state = normalizeStatus(state, status); + ids = ids.set(i, status.get('id')); + }); + + return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map + .set('isLoading', false) + .set('loaded', true) + .set('next', true) + .update('items', Immutable.List(), list => (replace ? ids : ids.concat(list)))); +}; + +const appendNormalizedAccountTimeline = (state, accountId, statuses, next) => { + let moreIds = Immutable.List([]); + + statuses.forEach((status, i) => { + state = normalizeStatus(state, status); + moreIds = moreIds.set(i, status.get('id')); + }); + + return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map + .set('isLoading', false) + .set('next', next) + .update('items', list => list.concat(moreIds))); +}; + +const updateTimeline = (state, timeline, status, references) => { + const top = state.getIn([timeline, 'top']); + + state = normalizeStatus(state, status); + + if (!top) { + state = state.updateIn([timeline, 'unread'], unread => unread + 1); + } + + state = state.updateIn([timeline, 'items'], Immutable.List(), list => { + if (top && list.size > 40) { + list = list.take(20); + } + + if (list.includes(status.get('id'))) { + return list; + } + + const reblogOfId = status.getIn(['reblog', 'id'], null); + + if (reblogOfId !== null) { + list = list.filterNot(itemId => references.includes(itemId)); + } + + return list.unshift(status.get('id')); + }); + + return state; +}; + +const deleteStatus = (state, id, accountId, references, reblogOf) => { + if (reblogOf) { + // If we are deleting a reblog, just replace reblog with its original + return state.updateIn(['home', 'items'], list => list.map(item => item === id ? reblogOf : item)); + } + + // Remove references from timelines + ['home', 'public', 'community', 'tag'].forEach(function (timeline) { + state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id)); + }); + + // Remove references from account timelines + state = state.updateIn(['accounts_timelines', accountId, 'items'], Immutable.List([]), list => list.filterNot(item => item === id)); + + // Remove references from context + state.getIn(['descendants', id], Immutable.List()).forEach(descendantId => { + state = state.updateIn(['ancestors', descendantId], Immutable.List(), list => list.filterNot(itemId => itemId === id)); + }); + + state.getIn(['ancestors', id], Immutable.List()).forEach(ancestorId => { + state = state.updateIn(['descendants', ancestorId], Immutable.List(), list => list.filterNot(itemId => itemId === id)); + }); + + state = state.deleteIn(['descendants', id]).deleteIn(['ancestors', id]); + + // Remove reblogs of deleted status + references.forEach(ref => { + state = deleteStatus(state, ref[0], ref[1], []); + }); + + return state; +}; + +const filterTimelines = (state, relationship, statuses) => { + let references; + + statuses.forEach(status => { + if (status.get('account') !== relationship.id) { + return; + } + + references = statuses.filter(item => item.get('reblog') === status.get('id')).map(item => [item.get('id'), item.get('account')]); + state = deleteStatus(state, status.get('id'), status.get('account'), references); + }); + + return state; +}; + +const normalizeContext = (state, id, ancestors, descendants) => { + const ancestorsIds = ancestors.map(ancestor => ancestor.get('id')); + const descendantsIds = descendants.map(descendant => descendant.get('id')); + + return state.withMutations(map => { + map.setIn(['ancestors', id], ancestorsIds); + map.setIn(['descendants', id], descendantsIds); + }); +}; + +const resetTimeline = (state, timeline, id) => { + if (timeline === 'tag' && typeof id !== 'undefined' && state.getIn([timeline, 'id']) !== id) { + state = state.update(timeline, map => map + .set('id', id) + .set('isLoading', true) + .set('loaded', false) + .set('next', null) + .set('top', true) + .update('items', list => list.clear())); + } else { + state = state.setIn([timeline, 'isLoading'], true); + } + + return state; +}; + +const updateTop = (state, timeline, top) => { + if (top) { + state = state.setIn([timeline, 'unread'], 0); + } + + return state.setIn([timeline, 'top'], top); +}; + +export default function timelines(state = initialState, action) { + switch(action.type) { + case TIMELINE_REFRESH_REQUEST: + case TIMELINE_EXPAND_REQUEST: + return resetTimeline(state, action.timeline, action.id); + case TIMELINE_REFRESH_FAIL: + case TIMELINE_EXPAND_FAIL: + return state.setIn([action.timeline, 'isLoading'], false); + case TIMELINE_REFRESH_SUCCESS: + return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next); + case TIMELINE_EXPAND_SUCCESS: + return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next); + case TIMELINE_UPDATE: + return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf); + case CONTEXT_FETCH_SUCCESS: + return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants)); + case ACCOUNT_TIMELINE_FETCH_REQUEST: + case ACCOUNT_TIMELINE_EXPAND_REQUEST: + return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', true)); + case ACCOUNT_TIMELINE_FETCH_FAIL: + case ACCOUNT_TIMELINE_EXPAND_FAIL: + return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', false)); + case ACCOUNT_TIMELINE_FETCH_SUCCESS: + return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace); + case ACCOUNT_TIMELINE_EXPAND_SUCCESS: + return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.next); + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + return filterTimelines(state, action.relationship, action.statuses); + case TIMELINE_SCROLL_TOP: + return updateTop(state, action.timeline, action.top); + case TIMELINE_CONNECT: + return state.setIn([action.timeline, 'online'], true); + case TIMELINE_DISCONNECT: + return state.setIn([action.timeline, 'online'], false); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js new file mode 100644 index 000000000..af9492119 --- /dev/null +++ b/app/javascript/mastodon/reducers/user_lists.js @@ -0,0 +1,80 @@ +import { + FOLLOWERS_FETCH_SUCCESS, + FOLLOWERS_EXPAND_SUCCESS, + FOLLOWING_FETCH_SUCCESS, + FOLLOWING_EXPAND_SUCCESS, + FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_EXPAND_SUCCESS, + FOLLOW_REQUEST_AUTHORIZE_SUCCESS, + FOLLOW_REQUEST_REJECT_SUCCESS +} from '../actions/accounts'; +import { + REBLOGS_FETCH_SUCCESS, + FAVOURITES_FETCH_SUCCESS +} from '../actions/interactions'; +import { + BLOCKS_FETCH_SUCCESS, + BLOCKS_EXPAND_SUCCESS +} from '../actions/blocks'; +import { + MUTES_FETCH_SUCCESS, + MUTES_EXPAND_SUCCESS +} from '../actions/mutes'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + followers: Immutable.Map(), + following: Immutable.Map(), + reblogged_by: Immutable.Map(), + favourited_by: Immutable.Map(), + follow_requests: Immutable.Map(), + blocks: Immutable.Map(), + mutes: Immutable.Map() +}); + +const normalizeList = (state, type, id, accounts, next) => { + return state.setIn([type, id], Immutable.Map({ + next, + items: Immutable.List(accounts.map(item => item.id)) + })); +}; + +const appendToList = (state, type, id, accounts, next) => { + return state.updateIn([type, id], map => { + return map.set('next', next).update('items', list => list.concat(accounts.map(item => item.id))); + }); +}; + +export default function userLists(state = initialState, action) { + switch(action.type) { + case FOLLOWERS_FETCH_SUCCESS: + return normalizeList(state, 'followers', action.id, action.accounts, action.next); + case FOLLOWERS_EXPAND_SUCCESS: + return appendToList(state, 'followers', action.id, action.accounts, action.next); + case FOLLOWING_FETCH_SUCCESS: + return normalizeList(state, 'following', action.id, action.accounts, action.next); + case FOLLOWING_EXPAND_SUCCESS: + return appendToList(state, 'following', action.id, action.accounts, action.next); + case REBLOGS_FETCH_SUCCESS: + return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id))); + case FAVOURITES_FETCH_SUCCESS: + return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id))); + case FOLLOW_REQUESTS_FETCH_SUCCESS: + return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next); + case FOLLOW_REQUESTS_EXPAND_SUCCESS: + return state.updateIn(['follow_requests', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next); + case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: + case FOLLOW_REQUEST_REJECT_SUCCESS: + return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id)); + case BLOCKS_FETCH_SUCCESS: + return state.setIn(['blocks', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next); + case BLOCKS_EXPAND_SUCCESS: + return state.updateIn(['blocks', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next); + case MUTES_FETCH_SUCCESS: + return state.setIn(['mutes', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); + case MUTES_EXPAND_SUCCESS: + return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); + default: + return state; + } +}; |