diff options
Diffstat (limited to 'app/javascript/flavours/glitch/reducers')
30 files changed, 2426 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/reducers/accounts.js b/app/javascript/flavours/glitch/reducers/accounts.js new file mode 100644 index 000000000..c2f016a87 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/accounts.js @@ -0,0 +1,172 @@ +import { + ACCOUNT_FETCH_SUCCESS, + FOLLOWERS_FETCH_SUCCESS, + FOLLOWERS_EXPAND_SUCCESS, + FOLLOWING_FETCH_SUCCESS, + FOLLOWING_EXPAND_SUCCESS, + FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_EXPAND_SUCCESS, + PINNED_ACCOUNTS_FETCH_SUCCESS, + PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY, +} from 'flavours/glitch/actions/accounts'; +import { + BLOCKS_FETCH_SUCCESS, + BLOCKS_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/blocks'; +import { + MUTES_FETCH_SUCCESS, + MUTES_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/mutes'; +import { COMPOSE_SUGGESTIONS_READY } from 'flavours/glitch/actions/compose'; +import { + REBLOG_SUCCESS, + UNREBLOG_SUCCESS, + FAVOURITE_SUCCESS, + UNFAVOURITE_SUCCESS, + BOOKMARK_SUCCESS, + UNBOOKMARK_SUCCESS, + REBLOGS_FETCH_SUCCESS, + FAVOURITES_FETCH_SUCCESS, +} from 'flavours/glitch/actions/interactions'; +import { + TIMELINE_UPDATE, + TIMELINE_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/timelines'; +import { + STATUS_FETCH_SUCCESS, + CONTEXT_FETCH_SUCCESS, +} from 'flavours/glitch/actions/statuses'; +import { SEARCH_FETCH_SUCCESS } from 'flavours/glitch/actions/search'; +import { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/notifications'; +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/favourites'; +import { + BOOKMARKED_STATUSES_FETCH_SUCCESS, + BOOKMARKED_STATUSES_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/bookmarks'; +import { + LIST_ACCOUNTS_FETCH_SUCCESS, + LIST_EDITOR_SUGGESTIONS_READY, +} from 'flavours/glitch/actions/lists'; +import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; +import emojify from 'flavours/glitch/util/emoji'; +import { Map as ImmutableMap, fromJS } from 'immutable'; +import escapeTextContentForBrowser from 'escape-html'; +import { unescapeHTML } from 'flavours/glitch/util/html'; + +const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => { + obj[`:${emoji.shortcode}:`] = emoji; + return obj; +}, {}); + +const normalizeAccount = (state, account) => { + account = { ...account }; + + delete account.followers_count; + delete account.following_count; + delete account.statuses_count; + + const emojiMap = makeEmojiMap(account); + const displayName = account.display_name.length === 0 ? account.username : account.display_name; + account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap); + account.note_emojified = emojify(account.note, emojiMap); + + if (account.fields) { + account.fields = account.fields.map(pair => ({ + ...pair, + name_emojified: emojify(escapeTextContentForBrowser(pair.name)), + value_emojified: emojify(pair.value, emojiMap), + value_plain: unescapeHTML(pair.value), + })); + } + + if (account.moved) { + state = normalizeAccount(state, account.moved); + account.moved = account.moved.id; + } + + return state.set(account.id, 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 = ImmutableMap(); + +export default function accounts(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return normalizeAccounts(state, Object.values(action.state.get('accounts').toJS())); + 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: + case LIST_ACCOUNTS_FETCH_SUCCESS: + case LIST_EDITOR_SUGGESTIONS_READY: + case PINNED_ACCOUNTS_FETCH_SUCCESS: + case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY: + return action.accounts ? normalizeAccounts(state, action.accounts) : state; + case NOTIFICATIONS_EXPAND_SUCCESS: + case SEARCH_FETCH_SUCCESS: + return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); + case TIMELINE_EXPAND_SUCCESS: + case CONTEXT_FETCH_SUCCESS: + case FAVOURITED_STATUSES_FETCH_SUCCESS: + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + case BOOKMARKED_STATUSES_FETCH_SUCCESS: + case BOOKMARKED_STATUSES_EXPAND_SUCCESS: + return normalizeAccountsFromStatuses(state, action.statuses); + case REBLOG_SUCCESS: + case FAVOURITE_SUCCESS: + case UNREBLOG_SUCCESS: + case UNFAVOURITE_SUCCESS: + case BOOKMARK_SUCCESS: + case UNBOOKMARK_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/flavours/glitch/reducers/accounts_counters.js b/app/javascript/flavours/glitch/reducers/accounts_counters.js new file mode 100644 index 000000000..64dff9b55 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/accounts_counters.js @@ -0,0 +1,150 @@ +import { + ACCOUNT_FETCH_SUCCESS, + FOLLOWERS_FETCH_SUCCESS, + FOLLOWERS_EXPAND_SUCCESS, + FOLLOWING_FETCH_SUCCESS, + FOLLOWING_EXPAND_SUCCESS, + FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_EXPAND_SUCCESS, + ACCOUNT_FOLLOW_SUCCESS, + ACCOUNT_UNFOLLOW_SUCCESS, +} from 'flavours/glitch/actions/accounts'; +import { + BLOCKS_FETCH_SUCCESS, + BLOCKS_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/blocks'; +import { + MUTES_FETCH_SUCCESS, + MUTES_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/mutes'; +import { COMPOSE_SUGGESTIONS_READY } from 'flavours/glitch/actions/compose'; +import { + REBLOG_SUCCESS, + UNREBLOG_SUCCESS, + FAVOURITE_SUCCESS, + UNFAVOURITE_SUCCESS, + BOOKMARK_SUCCESS, + UNBOOKMARK_SUCCESS, + REBLOGS_FETCH_SUCCESS, + FAVOURITES_FETCH_SUCCESS, +} from 'flavours/glitch/actions/interactions'; +import { + TIMELINE_UPDATE, + TIMELINE_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/timelines'; +import { + STATUS_FETCH_SUCCESS, + CONTEXT_FETCH_SUCCESS, +} from 'flavours/glitch/actions/statuses'; +import { SEARCH_FETCH_SUCCESS } from 'flavours/glitch/actions/search'; +import { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/notifications'; +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/favourites'; +import { + BOOKMARKED_STATUSES_FETCH_SUCCESS, + BOOKMARKED_STATUSES_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/bookmarks'; +import { + LIST_ACCOUNTS_FETCH_SUCCESS, + LIST_EDITOR_SUGGESTIONS_READY, +} from 'flavours/glitch/actions/lists'; +import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const normalizeAccount = (state, account) => state.set(account.id, 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 = ImmutableMap(); + +export default function accountsCounters(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.merge(action.state.get('accounts').map(item => fromJS({ + followers_count: item.get('followers_count'), + following_count: item.get('following_count'), + statuses_count: item.get('statuses_count'), + }))); + 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: + case LIST_ACCOUNTS_FETCH_SUCCESS: + case LIST_EDITOR_SUGGESTIONS_READY: + return action.accounts ? normalizeAccounts(state, action.accounts) : state; + case NOTIFICATIONS_EXPAND_SUCCESS: + case SEARCH_FETCH_SUCCESS: + return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); + case TIMELINE_EXPAND_SUCCESS: + case CONTEXT_FETCH_SUCCESS: + case FAVOURITED_STATUSES_FETCH_SUCCESS: + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + case BOOKMARKED_STATUSES_FETCH_SUCCESS: + case BOOKMARKED_STATUSES_EXPAND_SUCCESS: + return normalizeAccountsFromStatuses(state, action.statuses); + case REBLOG_SUCCESS: + case FAVOURITE_SUCCESS: + case UNREBLOG_SUCCESS: + case UNFAVOURITE_SUCCESS: + case BOOKMARK_SUCCESS: + case UNBOOKMARK_SUCCESS: + return normalizeAccountFromStatus(state, action.response); + case TIMELINE_UPDATE: + case STATUS_FETCH_SUCCESS: + return normalizeAccountFromStatus(state, action.status); + case ACCOUNT_FOLLOW_SUCCESS: + if (action.alreadyFollowing) { + return state; + } + 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/flavours/glitch/reducers/alerts.js b/app/javascript/flavours/glitch/reducers/alerts.js new file mode 100644 index 000000000..50f8d30f7 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/alerts.js @@ -0,0 +1,25 @@ +import { + ALERT_SHOW, + ALERT_DISMISS, + ALERT_CLEAR, +} from 'flavours/glitch/actions/alerts'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +const initialState = ImmutableList([]); + +export default function alerts(state = initialState, action) { + switch(action.type) { + case ALERT_SHOW: + return state.push(ImmutableMap({ + 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/flavours/glitch/reducers/cards.js b/app/javascript/flavours/glitch/reducers/cards.js new file mode 100644 index 000000000..92ecfd086 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/cards.js @@ -0,0 +1,14 @@ +import { STATUS_CARD_FETCH_SUCCESS } from 'flavours/glitch/actions/cards'; + +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const initialState = ImmutableMap(); + +export default function cards(state = initialState, action) { + switch(action.type) { + case STATUS_CARD_FETCH_SUCCESS: + return state.set(action.id, fromJS(action.card)); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js new file mode 100644 index 000000000..0ddff707e --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -0,0 +1,427 @@ +import { + COMPOSE_MOUNT, + COMPOSE_UNMOUNT, + COMPOSE_CHANGE, + COMPOSE_CYCLE_ELEFRIEND, + COMPOSE_REPLY, + COMPOSE_REPLY_CANCEL, + COMPOSE_DIRECT, + 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_SUGGESTION_TAGS_UPDATE, + COMPOSE_TAG_HISTORY_UPDATE, + COMPOSE_ADVANCED_OPTIONS_CHANGE, + COMPOSE_SENSITIVITY_CHANGE, + COMPOSE_SPOILERNESS_CHANGE, + COMPOSE_SPOILER_TEXT_CHANGE, + COMPOSE_VISIBILITY_CHANGE, + COMPOSE_EMOJI_INSERT, + COMPOSE_UPLOAD_CHANGE_REQUEST, + COMPOSE_UPLOAD_CHANGE_SUCCESS, + COMPOSE_UPLOAD_CHANGE_FAIL, + COMPOSE_DOODLE_SET, + COMPOSE_RESET, +} from 'flavours/glitch/actions/compose'; +import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines'; +import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; +import { REDRAFT } from 'flavours/glitch/actions/statuses'; +import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; +import uuid from 'flavours/glitch/util/uuid'; +import { privacyPreference } from 'flavours/glitch/util/privacy_preference'; +import { me } from 'flavours/glitch/util/initial_state'; +import { overwrite } from 'flavours/glitch/util/js_helpers'; +import { unescapeHTML } from 'flavours/glitch/util/html'; +import { recoverHashtags } from 'flavours/glitch/util/hashtag'; + +const totalElefriends = 3; + +// ~4% chance you'll end up with an unexpected friend +// glitch-soc/mastodon repo created_at date: 2017-04-20T21:55:28Z +const glitchProbability = 1 - 0.0420215528; + +const initialState = ImmutableMap({ + mounted: false, + advanced_options: ImmutableMap({ + do_not_federate: false, + threaded_mode: false, + }), + sensitive: false, + elefriend: Math.random() < glitchProbability ? Math.floor(Math.random() * totalElefriends) : totalElefriends, + spoiler: false, + spoiler_text: '', + privacy: null, + text: '', + focusDate: null, + caretPosition: null, + preselectDate: null, + in_reply_to: null, + is_submitting: false, + is_uploading: false, + progress: 0, + media_attachments: ImmutableList(), + suggestion_token: null, + suggestions: ImmutableList(), + default_advanced_options: ImmutableMap({ + do_not_federate: false, + threaded_mode: null, // Do not reset + }), + default_privacy: 'public', + default_sensitive: false, + resetFileKey: Math.floor((Math.random() * 0x10000)), + idempotencyKey: null, + tagHistory: ImmutableList(), + doodle: ImmutableMap({ + fg: 'rgb( 0, 0, 0)', + bg: 'rgb(255, 255, 255)', + swapped: false, + mode: 'draw', + size: 'normal', + weight: 2, + opacity: 1, + adaptiveStroke: true, + smoothing: false, + }), +}); + +function statusToTextMentions(state, status) { + let set = ImmutableOrderedSet([]); + + 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 apiStatusToTextMentions (state, status) { + let set = ImmutableOrderedSet([]); + + if (status.account.id !== me) { + set = set.add(`@${status.account.acct} `); + } + + return set.union(status.mentions.filter( + mention => mention.id !== me + ).map( + mention => `@${mention.acct} ` + )).join(''); +} + +function apiStatusToTextHashtags (state, status) { + const text = unescapeHTML(status.content); + return ImmutableOrderedSet([]).union(recoverHashtags(status.tags, text).map( + (name) => `#${name} ` + )).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.update( + 'advanced_options', + map => map.mergeWith(overwrite, state.get('default_advanced_options')) + ); + map.set('privacy', state.get('default_privacy')); + map.set('sensitive', false); + map.update('media_attachments', list => list.clear()); + map.set('idempotencyKey', uuid()); + }); +}; + +function continueThread (state, status) { + return state.withMutations(function (map) { + let text = apiStatusToTextMentions(state, status); + text = text + apiStatusToTextHashtags(state, status); + map.set('text', text); + if (status.spoiler_text) { + map.set('spoiler', true); + map.set('spoiler_text', status.spoiler_text); + } else { + map.set('spoiler', false); + map.set('spoiler_text', ''); + } + map.set('is_submitting', false); + map.set('in_reply_to', status.id); + map.update( + 'advanced_options', + map => map.merge(new ImmutableMap({ do_not_federate: /👁\ufe0f?\u200b?(?:<\/p>)?$/.test(status.content) })) + ); + map.set('privacy', status.visibility); + map.set('sensitive', false); + map.update('media_attachments', list => list.clear()); + map.set('idempotencyKey', uuid()); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('preselectDate', new Date()); + }); +} + +function appendMedia(state, media) { + const prevSize = state.get('media_attachments').size; + + 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.set('idempotencyKey', uuid()); + + if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) { + map.set('sensitive', true); + } + }); +}; + +function removeMedia(state, 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.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}${completion[0] === ':' ? '\u200B' : ' '}${oldText.slice(position + token.length)}`); + map.set('suggestion_token', null); + map.update('suggestions', ImmutableList(), list => list.clear()); + map.set('focusDate', new Date()); + map.set('caretPosition', position + completion.length + 1); + map.set('idempotencyKey', uuid()); + }); +}; + +const updateSuggestionTags = (state, token) => { + const prefix = token.slice(1); + + return state.merge({ + suggestions: state.get('tagHistory') + .filter(tag => tag.toLowerCase().startsWith(prefix.toLowerCase())) + .slice(0, 4) + .map(tag => '#' + tag), + suggestion_token: token, + }); +}; + +const insertEmoji = (state, position, emojiData) => { + const emoji = emojiData.native; + + return state.withMutations(map => { + map.update('text', oldText => `${oldText.slice(0, position)}${emoji}\u200B${oldText.slice(position)}`); + map.set('focusDate', new Date()); + map.set('caretPosition', position + emoji.length + 1); + map.set('idempotencyKey', uuid()); + }); +}; + +const hydrate = (state, hydratedState) => { + state = clearAll(state.merge(hydratedState)); + + if (hydratedState.has('text')) { + state = state.set('text', hydratedState.get('text')); + } + + return state; +}; + +const domParser = new DOMParser(); + +const expandMentions = status => { + const fragment = domParser.parseFromString(status.get('content'), 'text/html').documentElement; + + status.get('mentions').forEach(mention => { + fragment.querySelector(`a[href="${mention.get('url')}"]`).textContent = `@${mention.get('acct')}`; + }); + + return fragment.innerHTML; +}; + +export default function compose(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return hydrate(state, action.state.get('compose')); + case COMPOSE_MOUNT: + return state.set('mounted', true); + case COMPOSE_UNMOUNT: + return state.set('mounted', false); + case COMPOSE_ADVANCED_OPTIONS_CHANGE: + return state + .set('advanced_options', state.get('advanced_options').set(action.option, !!overwrite(!state.getIn(['advanced_options', action.option]), action.value))) + .set('idempotencyKey', uuid()); + case COMPOSE_SENSITIVITY_CHANGE: + return state.withMutations(map => { + if (!state.get('spoiler')) { + map.set('sensitive', !state.get('sensitive')); + } + + map.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()); + + if (!state.get('sensitive') && state.get('media_attachments').size >= 1) { + map.set('sensitive', true); + } + }); + 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_CYCLE_ELEFRIEND: + return state + .set('elefriend', (state.get('elefriend') + 1) % totalElefriends); + 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.update( + 'advanced_options', + map => map.merge(new ImmutableMap({ do_not_federate: /👁\ufe0f?\u200b?(?:<\/p>)?$/.test(action.status.get('content')) })) + ); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('preselectDate', new Date()); + map.set('idempotencyKey', uuid()); + + if (action.status.get('spoiler_text').length > 0) { + let spoiler_text = action.status.get('spoiler_text'); + if (!spoiler_text.match(/^re[: ]/i)) { + spoiler_text = 're: '.concat(spoiler_text); + } + map.set('spoiler', true); + map.set('spoiler_text', spoiler_text); + } else { + map.set('spoiler', false); + map.set('spoiler_text', ''); + } + }); + case COMPOSE_REPLY_CANCEL: + state = state.setIn(['advanced_options', 'threaded_mode'], false); + case COMPOSE_RESET: + 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.update( + 'advanced_options', + map => map.mergeWith(overwrite, state.get('default_advanced_options')) + ); + map.set('idempotencyKey', uuid()); + }); + case COMPOSE_SUBMIT_REQUEST: + case COMPOSE_UPLOAD_CHANGE_REQUEST: + return state.set('is_submitting', true); + case COMPOSE_SUBMIT_SUCCESS: + return action.status && state.getIn(['advanced_options', 'threaded_mode']) ? continueThread(state, action.status) : clearAll(state); + case COMPOSE_SUBMIT_FAIL: + case COMPOSE_UPLOAD_CHANGE_FAIL: + return state.set('is_submitting', false); + case COMPOSE_UPLOAD_REQUEST: + return state.set('is_uploading', true); + case COMPOSE_UPLOAD_SUCCESS: + return appendMedia(state, 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.withMutations(map => { + map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' ')); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('idempotencyKey', uuid()); + }); + case COMPOSE_DIRECT: + return state.withMutations(map => { + map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' ')); + map.set('privacy', 'direct'); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('idempotencyKey', uuid()); + }); + case COMPOSE_SUGGESTIONS_CLEAR: + return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null); + case COMPOSE_SUGGESTIONS_READY: + return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token); + case COMPOSE_SUGGESTION_SELECT: + return insertSuggestion(state, action.position, action.token, action.completion); + case COMPOSE_SUGGESTION_TAGS_UPDATE: + return updateSuggestionTags(state, action.token); + case COMPOSE_TAG_HISTORY_UPDATE: + return state.set('tagHistory', fromJS(action.tags)); + 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); + case COMPOSE_UPLOAD_CHANGE_SUCCESS: + return state + .set('is_submitting', false) + .update('media_attachments', list => list.map(item => { + if (item.get('id') === action.media.id) { + return fromJS(action.media); + } + + return item; + })); + case COMPOSE_DOODLE_SET: + return state.mergeIn(['doodle'], action.options); + case REDRAFT: + return state.withMutations(map => { + map.set('text', unescapeHTML(expandMentions(action.status))); + map.set('in_reply_to', action.status.get('in_reply_to_id')); + map.set('privacy', action.status.get('visibility')); + map.set('media_attachments', action.status.get('media_attachments')); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + 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', ''); + } + }); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/contexts.js b/app/javascript/flavours/glitch/reducers/contexts.js new file mode 100644 index 000000000..effd70756 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/contexts.js @@ -0,0 +1,80 @@ +import { + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, +} from 'flavours/glitch/actions/accounts'; +import { CONTEXT_FETCH_SUCCESS } from 'flavours/glitch/actions/statuses'; +import { TIMELINE_DELETE, TIMELINE_CONTEXT_UPDATE } from 'flavours/glitch/actions/timelines'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +const initialState = ImmutableMap({ + ancestors: ImmutableMap(), + descendants: ImmutableMap(), +}); + +const normalizeContext = (state, id, ancestors, descendants) => { + const ancestorsIds = ImmutableList(ancestors.map(ancestor => ancestor.id)); + const descendantsIds = ImmutableList(descendants.map(descendant => descendant.id)); + + return state.withMutations(map => { + map.setIn(['ancestors', id], ancestorsIds); + map.setIn(['descendants', id], descendantsIds); + }); +}; + +const deleteFromContexts = (immutableState, ids) => immutableState.withMutations(state => { + state.update('ancestors', immutableAncestors => immutableAncestors.withMutations(ancestors => { + state.update('descendants', immutableDescendants => immutableDescendants.withMutations(descendants => { + ids.forEach(id => { + descendants.get(id, ImmutableList()).forEach(descendantId => { + ancestors.update(descendantId, ImmutableList(), list => list.filterNot(itemId => itemId === id)); + }); + + ancestors.get(id, ImmutableList()).forEach(ancestorId => { + descendants.update(ancestorId, ImmutableList(), list => list.filterNot(itemId => itemId === id)); + }); + + descendants.delete(id); + ancestors.delete(id); + }); + })); + })); +}); + +const filterContexts = (state, relationship, statuses) => { + const ownedStatusIds = statuses.filter(status => status.get('account') === relationship.id) + .map(status => status.get('id')); + + return deleteFromContexts(state, ownedStatusIds); +}; + +const updateContext = (state, status, references) => { + return state.update('descendants', map => { + references.forEach(parentId => { + map = map.update(parentId, ImmutableList(), list => { + if (list.includes(status.id)) { + return list; + } + + return list.push(status.id); + }); + }); + + return map; + }); +}; + +export default function contexts(state = initialState, action) { + switch(action.type) { + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + return filterContexts(state, action.relationship, action.statuses); + case CONTEXT_FETCH_SUCCESS: + return normalizeContext(state, action.id, action.ancestors, action.descendants); + case TIMELINE_DELETE: + return deleteFromContexts(state, [action.id]); + case TIMELINE_CONTEXT_UPDATE: + return updateContext(state, action.status, action.references); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/custom_emojis.js b/app/javascript/flavours/glitch/reducers/custom_emojis.js new file mode 100644 index 000000000..90e3040a4 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/custom_emojis.js @@ -0,0 +1,15 @@ +import { List as ImmutableList, fromJS as ConvertToImmutable } from 'immutable'; +import { CUSTOM_EMOJIS_FETCH_SUCCESS } from 'flavours/glitch/actions/custom_emojis'; +import { search as emojiSearch } from 'flavours/glitch/util/emoji/emoji_mart_search_light'; +import { buildCustomEmojis } from 'flavours/glitch/util/emoji'; + +const initialState = ImmutableList([]); + +export default function custom_emojis(state = initialState, action) { + if(action.type === CUSTOM_EMOJIS_FETCH_SUCCESS) { + state = ConvertToImmutable(action.custom_emojis); + emojiSearch('', { custom: buildCustomEmojis(state) }); + } + + return state; +}; diff --git a/app/javascript/flavours/glitch/reducers/domain_lists.js b/app/javascript/flavours/glitch/reducers/domain_lists.js new file mode 100644 index 000000000..eff97fbd6 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/domain_lists.js @@ -0,0 +1,25 @@ +import { + DOMAIN_BLOCKS_FETCH_SUCCESS, + DOMAIN_BLOCKS_EXPAND_SUCCESS, + DOMAIN_UNBLOCK_SUCCESS, +} from '../actions/domain_blocks'; +import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; + +const initialState = ImmutableMap({ + blocks: ImmutableMap({ + items: ImmutableOrderedSet(), + }), +}); + +export default function domainLists(state = initialState, action) { + switch(action.type) { + case DOMAIN_BLOCKS_FETCH_SUCCESS: + return state.setIn(['blocks', 'items'], ImmutableOrderedSet(action.domains)).setIn(['blocks', 'next'], action.next); + case DOMAIN_BLOCKS_EXPAND_SUCCESS: + return state.updateIn(['blocks', 'items'], set => set.union(action.domains)).setIn(['blocks', 'next'], action.next); + case DOMAIN_UNBLOCK_SUCCESS: + return state.updateIn(['blocks', 'items'], set => set.delete(action.domain)); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/dropdown_menu.js b/app/javascript/flavours/glitch/reducers/dropdown_menu.js new file mode 100644 index 000000000..36fd4f132 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/dropdown_menu.js @@ -0,0 +1,18 @@ +import Immutable from 'immutable'; +import { + DROPDOWN_MENU_OPEN, + DROPDOWN_MENU_CLOSE, +} from '../actions/dropdown_menu'; + +const initialState = Immutable.Map({ openId: null, placement: null, keyboard: false }); + +export default function dropdownMenu(state = initialState, action) { + switch (action.type) { + case DROPDOWN_MENU_OPEN: + return state.merge({ openId: action.id, placement: action.placement, keyboard: action.keyboard }); + case DROPDOWN_MENU_CLOSE: + return state.get('openId') === action.id ? state.set('openId', null) : state; + default: + return state; + } +} diff --git a/app/javascript/flavours/glitch/reducers/filters.js b/app/javascript/flavours/glitch/reducers/filters.js new file mode 100644 index 000000000..33f0c6732 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/filters.js @@ -0,0 +1,11 @@ +import { FILTERS_FETCH_SUCCESS } from '../actions/filters'; +import { List as ImmutableList, fromJS } from 'immutable'; + +export default function filters(state = ImmutableList(), action) { + switch(action.type) { + case FILTERS_FETCH_SUCCESS: + return fromJS(action.filters); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/height_cache.js b/app/javascript/flavours/glitch/reducers/height_cache.js new file mode 100644 index 000000000..8b05e0b19 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/height_cache.js @@ -0,0 +1,23 @@ +import { Map as ImmutableMap } from 'immutable'; +import { HEIGHT_CACHE_SET, HEIGHT_CACHE_CLEAR } from 'flavours/glitch/actions/height_cache'; + +const initialState = ImmutableMap(); + +const setHeight = (state, key, id, height) => { + return state.update(key, ImmutableMap(), map => map.set(id, height)); +}; + +const clearHeights = () => { + return ImmutableMap(); +}; + +export default function statuses(state = initialState, action) { + switch(action.type) { + case HEIGHT_CACHE_SET: + return setHeight(state, action.key, action.id, action.height); + case HEIGHT_CACHE_CLEAR: + return clearHeights(); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js new file mode 100644 index 000000000..218a5ac8f --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/index.js @@ -0,0 +1,66 @@ +import { combineReducers } from 'redux-immutable'; +import dropdown_menu from './dropdown_menu'; +import timelines from './timelines'; +import meta from './meta'; +import alerts from './alerts'; +import { loadingBarReducer } from 'react-redux-loading-bar'; +import modal from './modal'; +import user_lists from './user_lists'; +import domain_lists from './domain_lists'; +import accounts from './accounts'; +import accounts_counters from './accounts_counters'; +import statuses from './statuses'; +import relationships from './relationships'; +import settings from './settings'; +import local_settings from './local_settings'; +import push_notifications from './push_notifications'; +import status_lists from './status_lists'; +import cards from './cards'; +import mutes from './mutes'; +import reports from './reports'; +import contexts from './contexts'; +import compose from './compose'; +import search from './search'; +import media_attachments from './media_attachments'; +import notifications from './notifications'; +import height_cache from './height_cache'; +import custom_emojis from './custom_emojis'; +import lists from './lists'; +import listEditor from './list_editor'; +import filters from './filters'; +import pinnedAccountsEditor from './pinned_accounts_editor'; + +const reducers = { + dropdown_menu, + timelines, + meta, + alerts, + loadingBar: loadingBarReducer, + modal, + user_lists, + domain_lists, + status_lists, + accounts, + accounts_counters, + statuses, + relationships, + settings, + local_settings, + push_notifications, + cards, + mutes, + reports, + contexts, + compose, + search, + media_attachments, + notifications, + height_cache, + custom_emojis, + lists, + listEditor, + filters, + pinnedAccountsEditor, +}; + +export default combineReducers(reducers); diff --git a/app/javascript/flavours/glitch/reducers/list_editor.js b/app/javascript/flavours/glitch/reducers/list_editor.js new file mode 100644 index 000000000..02a0dabb1 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/list_editor.js @@ -0,0 +1,89 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { + LIST_CREATE_REQUEST, + LIST_CREATE_FAIL, + LIST_CREATE_SUCCESS, + LIST_UPDATE_REQUEST, + LIST_UPDATE_FAIL, + LIST_UPDATE_SUCCESS, + LIST_EDITOR_RESET, + LIST_EDITOR_SETUP, + LIST_EDITOR_TITLE_CHANGE, + LIST_ACCOUNTS_FETCH_REQUEST, + LIST_ACCOUNTS_FETCH_SUCCESS, + LIST_ACCOUNTS_FETCH_FAIL, + LIST_EDITOR_SUGGESTIONS_READY, + LIST_EDITOR_SUGGESTIONS_CLEAR, + LIST_EDITOR_SUGGESTIONS_CHANGE, + LIST_EDITOR_ADD_SUCCESS, + LIST_EDITOR_REMOVE_SUCCESS, +} from '../actions/lists'; + +const initialState = ImmutableMap({ + listId: null, + isSubmitting: false, + title: '', + + accounts: ImmutableMap({ + items: ImmutableList(), + loaded: false, + isLoading: false, + }), + + suggestions: ImmutableMap({ + value: '', + items: ImmutableList(), + }), +}); + +export default function listEditorReducer(state = initialState, action) { + switch(action.type) { + case LIST_EDITOR_RESET: + return initialState; + case LIST_EDITOR_SETUP: + return state.withMutations(map => { + map.set('listId', action.list.get('id')); + map.set('title', action.list.get('title')); + map.set('isSubmitting', false); + }); + case LIST_EDITOR_TITLE_CHANGE: + return state.set('title', action.value); + case LIST_CREATE_REQUEST: + case LIST_UPDATE_REQUEST: + return state.set('isSubmitting', true); + case LIST_CREATE_FAIL: + case LIST_UPDATE_FAIL: + return state.set('isSubmitting', false); + case LIST_CREATE_SUCCESS: + case LIST_UPDATE_SUCCESS: + return state.withMutations(map => { + map.set('isSubmitting', false); + map.set('listId', action.list.id); + }); + case LIST_ACCOUNTS_FETCH_REQUEST: + return state.setIn(['accounts', 'isLoading'], true); + case LIST_ACCOUNTS_FETCH_FAIL: + return state.setIn(['accounts', 'isLoading'], false); + case LIST_ACCOUNTS_FETCH_SUCCESS: + return state.update('accounts', accounts => accounts.withMutations(map => { + map.set('isLoading', false); + map.set('loaded', true); + map.set('items', ImmutableList(action.accounts.map(item => item.id))); + })); + case LIST_EDITOR_SUGGESTIONS_CHANGE: + return state.setIn(['suggestions', 'value'], action.value); + case LIST_EDITOR_SUGGESTIONS_READY: + return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id))); + case LIST_EDITOR_SUGGESTIONS_CLEAR: + return state.update('suggestions', suggestions => suggestions.withMutations(map => { + map.set('items', ImmutableList()); + map.set('value', ''); + })); + case LIST_EDITOR_ADD_SUCCESS: + return state.updateIn(['accounts', 'items'], list => list.unshift(action.accountId)); + case LIST_EDITOR_REMOVE_SUCCESS: + return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.accountId)); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/lists.js b/app/javascript/flavours/glitch/reducers/lists.js new file mode 100644 index 000000000..f30ffbcbd --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/lists.js @@ -0,0 +1,37 @@ +import { + LIST_FETCH_SUCCESS, + LIST_FETCH_FAIL, + LISTS_FETCH_SUCCESS, + LIST_CREATE_SUCCESS, + LIST_UPDATE_SUCCESS, + LIST_DELETE_SUCCESS, +} from '../actions/lists'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const initialState = ImmutableMap(); + +const normalizeList = (state, list) => state.set(list.id, fromJS(list)); + +const normalizeLists = (state, lists) => { + lists.forEach(list => { + state = normalizeList(state, list); + }); + + return state; +}; + +export default function lists(state = initialState, action) { + switch(action.type) { + case LIST_FETCH_SUCCESS: + case LIST_CREATE_SUCCESS: + case LIST_UPDATE_SUCCESS: + return normalizeList(state, action.list); + case LISTS_FETCH_SUCCESS: + return normalizeLists(state, action.lists); + case LIST_DELETE_SUCCESS: + case LIST_FETCH_FAIL: + return state.set(action.id, false); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js new file mode 100644 index 000000000..19233a963 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/local_settings.js @@ -0,0 +1,59 @@ +// Package imports. +import { Map as ImmutableMap } from 'immutable'; + +// Our imports. +import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; +import { LOCAL_SETTING_CHANGE } from 'flavours/glitch/actions/local_settings'; + +const initialState = ImmutableMap({ + layout : 'auto', + stretch : true, + navbar_under : false, + side_arm : 'none', + side_arm_reply_mode : 'keep', + show_reply_count : false, + always_show_spoilers_field: false, + confirm_missing_media_description: false, + preselect_on_reply: true, + content_warnings : ImmutableMap({ + auto_unfold : false, + filter : null, + }), + collapsed : ImmutableMap({ + enabled : true, + auto : ImmutableMap({ + all : false, + notifications : true, + lengthy : true, + reblogs : false, + replies : false, + media : false, + }), + backgrounds : ImmutableMap({ + user_backgrounds : false, + preview_images : false, + }), + show_action_bar : true, + }), + media : ImmutableMap({ + letterbox : true, + fullwidth : true, + }), + notifications : ImmutableMap({ + favicon_badge : false, + tab_badge : true, + }), +}); + +const hydrate = (state, localSettings) => state.mergeDeep(localSettings); + +export default function localSettings(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return hydrate(state, action.state.get('local_settings')); + case LOCAL_SETTING_CHANGE: + return state.setIn(action.key, action.value); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/media_attachments.js b/app/javascript/flavours/glitch/reducers/media_attachments.js new file mode 100644 index 000000000..6e6058576 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/media_attachments.js @@ -0,0 +1,15 @@ +import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; +import { Map as ImmutableMap } from 'immutable'; + +const initialState = ImmutableMap({ + accept_content_types: [], +}); + +export default function meta(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.merge(action.state.get('media_attachments')); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/meta.js b/app/javascript/flavours/glitch/reducers/meta.js new file mode 100644 index 000000000..a98dc436a --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/meta.js @@ -0,0 +1,16 @@ +import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; +import { Map as ImmutableMap } from 'immutable'; + +const initialState = ImmutableMap({ + streaming_api_base_url: null, + access_token: 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/flavours/glitch/reducers/modal.js b/app/javascript/flavours/glitch/reducers/modal.js new file mode 100644 index 000000000..80bc11dda --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/modal.js @@ -0,0 +1,17 @@ +import { MODAL_OPEN, MODAL_CLOSE } from 'flavours/glitch/actions/modal'; + +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/flavours/glitch/reducers/mutes.js b/app/javascript/flavours/glitch/reducers/mutes.js new file mode 100644 index 000000000..8f52a7704 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/mutes.js @@ -0,0 +1,29 @@ +import Immutable from 'immutable'; + +import { + MUTES_INIT_MODAL, + MUTES_TOGGLE_HIDE_NOTIFICATIONS, +} from 'flavours/glitch/actions/mutes'; + +const initialState = Immutable.Map({ + new: Immutable.Map({ + isSubmitting: false, + account: null, + notifications: true, + }), +}); + +export default function mutes(state = initialState, action) { + switch (action.type) { + case MUTES_INIT_MODAL: + return state.withMutations((state) => { + state.setIn(['new', 'isSubmitting'], false); + state.setIn(['new', 'account'], action.account); + state.setIn(['new', 'notifications'], true); + }); + case MUTES_TOGGLE_HIDE_NOTIFICATIONS: + return state.updateIn(['new', 'notifications'], (old) => !old); + default: + return state; + } +} diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js new file mode 100644 index 000000000..0b816e85e --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/notifications.js @@ -0,0 +1,245 @@ +import { + NOTIFICATIONS_MOUNT, + NOTIFICATIONS_UNMOUNT, + NOTIFICATIONS_SET_VISIBILITY, + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_EXPAND_SUCCESS, + NOTIFICATIONS_EXPAND_REQUEST, + NOTIFICATIONS_EXPAND_FAIL, + NOTIFICATIONS_CLEAR, + NOTIFICATIONS_SCROLL_TOP, + NOTIFICATIONS_DELETE_MARKED_REQUEST, + NOTIFICATIONS_DELETE_MARKED_SUCCESS, + NOTIFICATION_MARK_FOR_DELETE, + NOTIFICATIONS_DELETE_MARKED_FAIL, + NOTIFICATIONS_ENTER_CLEARING_MODE, + NOTIFICATIONS_MARK_ALL_FOR_DELETE, +} from 'flavours/glitch/actions/notifications'; +import { + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, +} from 'flavours/glitch/actions/accounts'; +import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from 'flavours/glitch/actions/timelines'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import compareId from 'flavours/glitch/util/compare_id'; + +const initialState = ImmutableMap({ + items: ImmutableList(), + hasMore: true, + top: true, + mounted: 0, + unread: 0, + lastReadId: '0', + isLoading: false, + cleaningMode: false, + isTabVisible: true, + // notification removal mark of new notifs loaded whilst cleaningMode is true. + markNewForDelete: false, +}); + +const notificationToMap = (state, notification) => ImmutableMap({ + id: notification.id, + type: notification.type, + account: notification.account.id, + markedForDelete: state.get('markNewForDelete'), + status: notification.status ? notification.status.id : null, +}); + +const normalizeNotification = (state, notification) => { + const top = !shouldCountUnreadNotifications(state); + + if (top) { + state = state.set('lastReadId', notification.id); + } else { + state = state.update('unread', unread => unread + 1); + } + + return state.update('items', list => { + if (top && list.size > 40) { + list = list.take(20); + } + + return list.unshift(notificationToMap(state, notification)); + }); +}; + +const expandNormalizedNotifications = (state, notifications, next) => { + const top = !(shouldCountUnreadNotifications(state)); + const lastReadId = state.get('lastReadId'); + let items = ImmutableList(); + + notifications.forEach((n, i) => { + items = items.set(i, notificationToMap(state, n)); + }); + + return state.withMutations(mutable => { + if (!items.isEmpty()) { + mutable.update('items', list => { + const lastIndex = 1 + list.findLastIndex( + item => item !== null && (compareId(item.get('id'), items.last().get('id')) > 0 || item.get('id') === items.last().get('id')) + ); + + const firstIndex = 1 + list.take(lastIndex).findLastIndex( + item => item !== null && compareId(item.get('id'), items.first().get('id')) > 0 + ); + + return list.take(firstIndex).concat(items, list.skip(lastIndex)); + }); + } + + if (top) { + if (!items.isEmpty()) { + mutable.update('lastReadId', id => compareId(id, items.first().get('id')) > 0 ? id : items.first().get('id')); + } + } else { + mutable.update('unread', unread => unread + items.filter(item => compareId(item.get('id'), lastReadId) > 0).size); + } + + if (!next) { + mutable.set('hasMore', true); + } + + mutable.set('isLoading', false); + }); +}; + +const filterNotifications = (state, relationship) => { + return state.update('items', list => list.filterNot(item => item !== null && item.get('account') === relationship.id)); +}; + +const clearUnread = (state) => { + state = state.set('unread', 0); + const lastNotification = state.get('items').find(item => item !== null); + return state.set('lastReadId', lastNotification ? lastNotification.get('id') : '0'); +} + +const updateTop = (state, top) => { + state = state.set('top', top); + + if (!shouldCountUnreadNotifications(state)) { + state = clearUnread(state); + } + + return state.set('top', top); +}; + +const deleteByStatus = (state, statusId) => { + const top = !(shouldCountUnreadNotifications(state)); + if (!top) { + const lastReadId = state.get('lastReadId'); + const deletedUnread = state.get('items').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0); + state = state.update('unread', unread => unread - deletedUnread.size); + } + return state.update('items', list => list.filterNot(item => item !== null && item.get('status') === statusId)); +}; + +const markForDelete = (state, notificationId, yes) => { + return state.update('items', list => list.map(item => { + if(item.get('id') === notificationId) { + return item.set('markedForDelete', yes); + } else { + return item; + } + })); +}; + +const markAllForDelete = (state, yes) => { + return state.update('items', list => list.map(item => { + if(yes !== null) { + return item.set('markedForDelete', yes); + } else { + return item.set('markedForDelete', !item.get('markedForDelete')); + } + })); +}; + +const unmarkAllForDelete = (state) => { + return state.update('items', list => list.map(item => item.set('markedForDelete', false))); +}; + +const deleteMarkedNotifs = (state) => { + return state.update('items', list => list.filterNot(item => item.get('markedForDelete'))); +}; + +const updateMounted = (state) => { + state = state.update('mounted', count => count + 1); + if (!shouldCountUnreadNotifications(state)) { + state = clearUnread(state); + } + return state; +}; + +const updateVisibility = (state, visibility) => { + state = state.set('isTabVisible', visibility); + if (!shouldCountUnreadNotifications(state)) { + state = clearUnread(state); + } + return state; +}; + +const shouldCountUnreadNotifications = (state) => { + return !(state.get('isTabVisible') && state.get('top') && state.get('mounted') > 0); +}; + +export default function notifications(state = initialState, action) { + let st; + + switch(action.type) { + case NOTIFICATIONS_MOUNT: + return updateMounted(state); + case NOTIFICATIONS_UNMOUNT: + return state.update('mounted', count => count - 1); + case NOTIFICATIONS_SET_VISIBILITY: + return updateVisibility(state, action.visibility); + case NOTIFICATIONS_EXPAND_REQUEST: + case NOTIFICATIONS_DELETE_MARKED_REQUEST: + return state.set('isLoading', true); + case NOTIFICATIONS_DELETE_MARKED_FAIL: + case NOTIFICATIONS_EXPAND_FAIL: + return state.set('isLoading', false); + case NOTIFICATIONS_SCROLL_TOP: + return updateTop(state, action.top); + case NOTIFICATIONS_UPDATE: + return normalizeNotification(state, action.notification); + case NOTIFICATIONS_EXPAND_SUCCESS: + return expandNormalizedNotifications(state, action.notifications, action.next); + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + return filterNotifications(state, action.relationship); + case NOTIFICATIONS_CLEAR: + return state.set('items', ImmutableList()).set('hasMore', false); + case TIMELINE_DELETE: + return deleteByStatus(state, action.id); + case TIMELINE_DISCONNECT: + return action.timeline === 'home' ? + state.update('items', items => items.first() ? items.unshift(null) : items) : + state; + + case NOTIFICATION_MARK_FOR_DELETE: + return markForDelete(state, action.id, action.yes); + + case NOTIFICATIONS_DELETE_MARKED_SUCCESS: + return deleteMarkedNotifs(state).set('isLoading', false); + + case NOTIFICATIONS_ENTER_CLEARING_MODE: + st = state.set('cleaningMode', action.yes); + if (!action.yes) { + return unmarkAllForDelete(st).set('markNewForDelete', false); + } else { + return st; + } + + case NOTIFICATIONS_MARK_ALL_FOR_DELETE: + st = state; + if (action.yes === null) { + // Toggle - this is a bit confusing, as it toggles the all-none mode + //st = st.set('markNewForDelete', !st.get('markNewForDelete')); + } else { + st = st.set('markNewForDelete', action.yes); + } + return markAllForDelete(st, action.yes); + + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/pinned_accounts_editor.js b/app/javascript/flavours/glitch/reducers/pinned_accounts_editor.js new file mode 100644 index 000000000..267521bb8 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/pinned_accounts_editor.js @@ -0,0 +1,57 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { + PINNED_ACCOUNTS_EDITOR_RESET, + PINNED_ACCOUNTS_FETCH_REQUEST, + PINNED_ACCOUNTS_FETCH_SUCCESS, + PINNED_ACCOUNTS_FETCH_FAIL, + PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY, + PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR, + PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE, + ACCOUNT_PIN_SUCCESS, + ACCOUNT_UNPIN_SUCCESS, +} from '../actions/accounts'; + +const initialState = ImmutableMap({ + accounts: ImmutableMap({ + items: ImmutableList(), + loaded: false, + isLoading: false, + }), + + suggestions: ImmutableMap({ + value: '', + items: ImmutableList(), + }), +}); + +export default function listEditorReducer(state = initialState, action) { + switch(action.type) { + case PINNED_ACCOUNTS_EDITOR_RESET: + return initialState; + case PINNED_ACCOUNTS_FETCH_REQUEST: + return state.setIn(['accounts', 'isLoading'], true); + case PINNED_ACCOUNTS_FETCH_FAIL: + return state.setIn(['accounts', 'isLoading'], false); + case PINNED_ACCOUNTS_FETCH_SUCCESS: + return state.update('accounts', accounts => accounts.withMutations(map => { + map.set('isLoading', false); + map.set('loaded', true); + map.set('items', ImmutableList(action.accounts.map(item => item.id))); + })); + case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE: + return state.setIn(['suggestions', 'value'], action.value); + case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY: + return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id))); + case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR: + return state.update('suggestions', suggestions => suggestions.withMutations(map => { + map.set('items', ImmutableList()); + map.set('value', ''); + })); + case ACCOUNT_PIN_SUCCESS: + return state.updateIn(['accounts', 'items'], list => list.unshift(action.relationship.id)); + case ACCOUNT_UNPIN_SUCCESS: + return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.relationship.id)); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/push_notifications.js b/app/javascript/flavours/glitch/reducers/push_notifications.js new file mode 100644 index 000000000..1b47ca962 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/push_notifications.js @@ -0,0 +1,51 @@ +import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; +import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, SET_ALERTS } from 'flavours/glitch/actions/push_notifications'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + subscription: null, + alerts: new Immutable.Map({ + follow: false, + favourite: false, + reblog: false, + mention: false, + }), + isSubscribed: false, + browserSupport: false, +}); + +export default function push_subscriptions(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: { + const push_subscription = action.state.get('push_subscription'); + + if (push_subscription) { + return state + .set('subscription', new Immutable.Map({ + id: push_subscription.get('id'), + endpoint: push_subscription.get('endpoint'), + })) + .set('alerts', push_subscription.get('alerts') || initialState.get('alerts')) + .set('isSubscribed', true); + } + + return state; + } + case SET_SUBSCRIPTION: + return state + .set('subscription', new Immutable.Map({ + id: action.subscription.id, + endpoint: action.subscription.endpoint, + })) + .set('alerts', new Immutable.Map(action.subscription.alerts)) + .set('isSubscribed', true); + case SET_BROWSER_SUPPORT: + return state.set('browserSupport', action.value); + case CLEAR_SUBSCRIPTION: + return initialState; + case SET_ALERTS: + return state.setIn(action.path, action.value); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/relationships.js b/app/javascript/flavours/glitch/reducers/relationships.js new file mode 100644 index 000000000..4652bbc14 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/relationships.js @@ -0,0 +1,58 @@ +import { + ACCOUNT_FOLLOW_SUCCESS, + ACCOUNT_UNFOLLOW_SUCCESS, + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_UNBLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, + ACCOUNT_UNMUTE_SUCCESS, + ACCOUNT_PIN_SUCCESS, + ACCOUNT_UNPIN_SUCCESS, + RELATIONSHIPS_FETCH_SUCCESS, +} from 'flavours/glitch/actions/accounts'; +import { + DOMAIN_BLOCK_SUCCESS, + DOMAIN_UNBLOCK_SUCCESS, +} from 'flavours/glitch/actions/domain_blocks'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship)); + +const normalizeRelationships = (state, relationships) => { + relationships.forEach(relationship => { + state = normalizeRelationship(state, relationship); + }); + + return state; +}; + +const setDomainBlocking = (state, accounts, blocking) => { + return state.withMutations(map => { + accounts.forEach(id => { + map.setIn([id, 'domain_blocking'], blocking); + }); + }); +}; + +const initialState = ImmutableMap(); + +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: + case ACCOUNT_PIN_SUCCESS: + case ACCOUNT_UNPIN_SUCCESS: + return normalizeRelationship(state, action.relationship); + case RELATIONSHIPS_FETCH_SUCCESS: + return normalizeRelationships(state, action.relationships); + case DOMAIN_BLOCK_SUCCESS: + return setDomainBlocking(state, action.accounts, true); + case DOMAIN_UNBLOCK_SUCCESS: + return setDomainBlocking(state, action.accounts, false); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/reports.js b/app/javascript/flavours/glitch/reducers/reports.js new file mode 100644 index 000000000..fdcfb14a0 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/reports.js @@ -0,0 +1,64 @@ +import { + REPORT_INIT, + REPORT_SUBMIT_REQUEST, + REPORT_SUBMIT_SUCCESS, + REPORT_SUBMIT_FAIL, + REPORT_CANCEL, + REPORT_STATUS_TOGGLE, + REPORT_COMMENT_CHANGE, + REPORT_FORWARD_CHANGE, +} from 'flavours/glitch/actions/reports'; +import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable'; + +const initialState = ImmutableMap({ + new: ImmutableMap({ + isSubmitting: false, + account_id: null, + status_ids: ImmutableSet(), + comment: '', + forward: false, + }), +}); + +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 ? ImmutableSet([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : ImmutableSet()); + map.setIn(['new', 'comment'], ''); + } else if (action.status) { + map.updateIn(['new', 'status_ids'], ImmutableSet(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id')))); + } + }); + case REPORT_STATUS_TOGGLE: + return state.updateIn(['new', 'status_ids'], ImmutableSet(), 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_FORWARD_CHANGE: + return state.setIn(['new', 'forward'], action.forward); + 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'], ImmutableSet()); + map.setIn(['new', 'comment'], ''); + map.setIn(['new', 'isSubmitting'], false); + }); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/search.js b/app/javascript/flavours/glitch/reducers/search.js new file mode 100644 index 000000000..9a525bf47 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/search.js @@ -0,0 +1,47 @@ +import { + SEARCH_CHANGE, + SEARCH_CLEAR, + SEARCH_FETCH_SUCCESS, + SEARCH_SHOW, +} from 'flavours/glitch/actions/search'; +import { + COMPOSE_MENTION, + COMPOSE_REPLY, + COMPOSE_DIRECT, +} from 'flavours/glitch/actions/compose'; +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; + +const initialState = ImmutableMap({ + value: '', + submitted: false, + hidden: false, + results: ImmutableMap(), +}); + +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', ImmutableMap()); + map.set('submitted', false); + map.set('hidden', false); + }); + case SEARCH_SHOW: + return state.set('hidden', false); + case COMPOSE_REPLY: + case COMPOSE_MENTION: + case COMPOSE_DIRECT: + return state.set('hidden', true); + case SEARCH_FETCH_SUCCESS: + return state.set('results', ImmutableMap({ + accounts: ImmutableList(action.results.accounts.map(item => item.id)), + statuses: ImmutableList(action.results.statuses.map(item => item.id)), + hashtags: fromJS(action.results.hashtags), + })).set('submitted', true); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js new file mode 100644 index 000000000..c04f262da --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/settings.js @@ -0,0 +1,127 @@ +import { SETTING_CHANGE, SETTING_SAVE } from 'flavours/glitch/actions/settings'; +import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from 'flavours/glitch/actions/columns'; +import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; +import { EMOJI_USE } from 'flavours/glitch/actions/emojis'; +import { LIST_DELETE_SUCCESS, LIST_FETCH_FAIL } from '../actions/lists'; +import { Map as ImmutableMap, fromJS } from 'immutable'; +import uuid from 'flavours/glitch/util/uuid'; + +const initialState = ImmutableMap({ + saved: true, + + onboarded: false, + layout: 'auto', + + skinTone: 1, + + home: ImmutableMap({ + shows: ImmutableMap({ + reblog: true, + reply: true, + direct: true, + }), + + regex: ImmutableMap({ + body: '', + }), + }), + + notifications: ImmutableMap({ + alerts: ImmutableMap({ + follow: true, + favourite: true, + reblog: true, + mention: true, + }), + + shows: ImmutableMap({ + follow: true, + favourite: true, + reblog: true, + mention: true, + }), + + sounds: ImmutableMap({ + follow: true, + favourite: true, + reblog: true, + mention: true, + }), + }), + + community: ImmutableMap({ + regex: ImmutableMap({ + body: '', + }), + }), + + public: ImmutableMap({ + regex: ImmutableMap({ + body: '', + }), + }), + + direct: ImmutableMap({ + regex: ImmutableMap({ + body: '', + }), + }), +}); + +const defaultColumns = fromJS([ + { id: 'COMPOSE', uuid: uuid(), params: {} }, + { id: 'HOME', uuid: uuid(), params: {} }, + { id: 'NOTIFICATIONS', uuid: uuid(), params: {} }, +]); + +const hydrate = (state, settings) => state.mergeDeep(settings).update('columns', (val = defaultColumns) => val); + +const moveColumn = (state, uuid, direction) => { + const columns = state.get('columns'); + const index = columns.findIndex(item => item.get('uuid') === uuid); + const newIndex = index + direction; + + let newColumns; + + newColumns = columns.splice(index, 1); + newColumns = newColumns.splice(newIndex, 0, columns.get(index)); + + return state + .set('columns', newColumns) + .set('saved', false); +}; + +const updateFrequentEmojis = (state, emoji) => state.update('frequentlyUsedEmojis', ImmutableMap(), map => map.update(emoji.id, 0, count => count + 1)).set('saved', false); + +const filterDeadListColumns = (state, listId) => state.update('columns', columns => columns.filterNot(column => column.get('id') === 'LIST' && column.get('params').get('id') === listId)); + +export default function settings(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return hydrate(state, action.state.get('settings')); + case SETTING_CHANGE: + return state + .setIn(action.path, action.value) + .set('saved', false); + case COLUMN_ADD: + return state + .update('columns', list => list.push(fromJS({ id: action.id, uuid: uuid(), params: action.params }))) + .set('saved', false); + case COLUMN_REMOVE: + return state + .update('columns', list => list.filterNot(item => item.get('uuid') === action.uuid)) + .set('saved', false); + case COLUMN_MOVE: + return moveColumn(state, action.uuid, action.direction); + case EMOJI_USE: + return updateFrequentEmojis(state, action.emoji); + case SETTING_SAVE: + return state.set('saved', true); + case LIST_FETCH_FAIL: + return action.error.response.status === 404 ? filterDeadListColumns(state, action.id) : state; + case LIST_DELETE_SUCCESS: + return filterDeadListColumns(state, action.id); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/status_lists.js b/app/javascript/flavours/glitch/reducers/status_lists.js new file mode 100644 index 000000000..241833bfe --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/status_lists.js @@ -0,0 +1,116 @@ +import { + FAVOURITED_STATUSES_FETCH_REQUEST, + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_FETCH_FAIL, + FAVOURITED_STATUSES_EXPAND_REQUEST, + FAVOURITED_STATUSES_EXPAND_SUCCESS, + FAVOURITED_STATUSES_EXPAND_FAIL, +} from 'flavours/glitch/actions/favourites'; +import { + BOOKMARKED_STATUSES_FETCH_REQUEST, + BOOKMARKED_STATUSES_FETCH_SUCCESS, + BOOKMARKED_STATUSES_FETCH_FAIL, + BOOKMARKED_STATUSES_EXPAND_REQUEST, + BOOKMARKED_STATUSES_EXPAND_SUCCESS, + BOOKMARKED_STATUSES_EXPAND_FAIL, +} from 'flavours/glitch/actions/bookmarks'; +import { + PINNED_STATUSES_FETCH_SUCCESS, +} from 'flavours/glitch/actions/pin_statuses'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { + FAVOURITE_SUCCESS, + UNFAVOURITE_SUCCESS, + BOOKMARK_SUCCESS, + UNBOOKMARK_SUCCESS, + PIN_SUCCESS, + UNPIN_SUCCESS, +} from 'flavours/glitch/actions/interactions'; + +const initialState = ImmutableMap({ + favourites: ImmutableMap({ + next: null, + loaded: false, + items: ImmutableList(), + }), + bookmarks: ImmutableMap({ + next: null, + loaded: false, + items: ImmutableList(), + }), + pins: ImmutableMap({ + next: null, + loaded: false, + items: ImmutableList(), + }), +}); + +const normalizeList = (state, listType, statuses, next) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('loaded', true); + map.set('isLoading', false); + map.set('items', ImmutableList(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('isLoading', false); + map.set('items', map.get('items').concat(statuses.map(item => item.id))); + })); +}; + +const prependOneToList = (state, listType, status) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('items', map.get('items').unshift(status.get('id'))); + })); +}; + +const removeOneFromList = (state, listType, status) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('items', map.get('items').filter(item => item !== status.get('id'))); + })); +}; + +export default function statusLists(state = initialState, action) { + switch(action.type) { + case FAVOURITED_STATUSES_FETCH_REQUEST: + case FAVOURITED_STATUSES_EXPAND_REQUEST: + return state.setIn(['favourites', 'isLoading'], true); + case FAVOURITED_STATUSES_FETCH_FAIL: + case FAVOURITED_STATUSES_EXPAND_FAIL: + return state.setIn(['favourites', 'isLoading'], false); + 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); + case BOOKMARKED_STATUSES_FETCH_REQUEST: + case BOOKMARKED_STATUSES_EXPAND_REQUEST: + return state.setIn(['bookmarks', 'isLoading'], true); + case BOOKMARKED_STATUSES_FETCH_FAIL: + case BOOKMARKED_STATUSES_EXPAND_FAIL: + return state.setIn(['bookmarks', 'isLoading'], false); + case BOOKMARKED_STATUSES_FETCH_SUCCESS: + return normalizeList(state, 'bookmarks', action.statuses, action.next); + case BOOKMARKED_STATUSES_EXPAND_SUCCESS: + return appendToList(state, 'bookmarks', action.statuses, action.next); + case FAVOURITE_SUCCESS: + return prependOneToList(state, 'favourites', action.status); + case UNFAVOURITE_SUCCESS: + return removeOneFromList(state, 'favourites', action.status); + case BOOKMARK_SUCCESS: + return prependOneToList(state, 'bookmarks', action.status); + case UNBOOKMARK_SUCCESS: + return removeOneFromList(state, 'bookmarks', action.status); + case PINNED_STATUSES_FETCH_SUCCESS: + return normalizeList(state, 'pins', action.statuses, action.next); + case PIN_SUCCESS: + return prependOneToList(state, 'pins', action.status); + case UNPIN_SUCCESS: + return removeOneFromList(state, 'pins', action.status); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js new file mode 100644 index 000000000..617d96e5d --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/statuses.js @@ -0,0 +1,149 @@ +import { + REBLOG_REQUEST, + REBLOG_SUCCESS, + REBLOG_FAIL, + UNREBLOG_SUCCESS, + FAVOURITE_REQUEST, + FAVOURITE_SUCCESS, + FAVOURITE_FAIL, + UNFAVOURITE_SUCCESS, + BOOKMARK_REQUEST, + BOOKMARK_SUCCESS, + BOOKMARK_FAIL, + UNBOOKMARK_SUCCESS, + PIN_SUCCESS, + UNPIN_SUCCESS, +} from 'flavours/glitch/actions/interactions'; +import { + COMPOSE_SUBMIT_SUCCESS, +} from 'flavours/glitch/actions/compose'; +import { + STATUS_FETCH_SUCCESS, + CONTEXT_FETCH_SUCCESS, + STATUS_MUTE_SUCCESS, + STATUS_UNMUTE_SUCCESS, +} from 'flavours/glitch/actions/statuses'; +import { + TIMELINE_UPDATE, + TIMELINE_DELETE, + TIMELINE_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/timelines'; +import { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/notifications'; +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/favourites'; +import { + BOOKMARKED_STATUSES_FETCH_SUCCESS, + BOOKMARKED_STATUSES_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/bookmarks'; +import { + PINNED_STATUSES_FETCH_SUCCESS, +} from 'flavours/glitch/actions/pin_statuses'; +import { SEARCH_FETCH_SUCCESS } from 'flavours/glitch/actions/search'; +import emojify from 'flavours/glitch/util/emoji'; +import { Map as ImmutableMap, fromJS } from 'immutable'; +import escapeTextContentForBrowser from 'escape-html'; + +const domParser = new DOMParser(); + +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; + } + + // Only calculate these values when status first encountered + // Otherwise keep the ones already in the reducer + if (!state.has(status.id)) { + const searchContent = [status.spoiler_text, status.content].join('\n\n').replace(/<br \/>/g, '\n').replace(/<\/p><p>/g, '\n\n'); + + const emojiMap = normalStatus.emojis.reduce((obj, emoji) => { + obj[`:${emoji.shortcode}:`] = emoji; + return obj; + }, {}); + + normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; + normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); + normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''), emojiMap); + } + + return state.update(status.id, ImmutableMap(), map => map.mergeDeep(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 initialState = ImmutableMap(); + +export default function statuses(state = initialState, action) { + switch(action.type) { + case TIMELINE_UPDATE: + case STATUS_FETCH_SUCCESS: + case NOTIFICATIONS_UPDATE: + case COMPOSE_SUBMIT_SUCCESS: + return normalizeStatus(state, action.status); + case REBLOG_SUCCESS: + case UNREBLOG_SUCCESS: + case FAVOURITE_SUCCESS: + case UNFAVOURITE_SUCCESS: + case BOOKMARK_SUCCESS: + case UNBOOKMARK_SUCCESS: + case PIN_SUCCESS: + case UNPIN_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 BOOKMARK_REQUEST: + return state.setIn([action.status.get('id'), 'bookmarked'], true); + case BOOKMARK_FAIL: + return state.setIn([action.status.get('id'), 'bookmarked'], 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 STATUS_MUTE_SUCCESS: + return state.setIn([action.id, 'muted'], true); + case STATUS_UNMUTE_SUCCESS: + return state.setIn([action.id, 'muted'], false); + case TIMELINE_EXPAND_SUCCESS: + case CONTEXT_FETCH_SUCCESS: + case NOTIFICATIONS_EXPAND_SUCCESS: + case FAVOURITED_STATUSES_FETCH_SUCCESS: + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + case BOOKMARKED_STATUSES_FETCH_SUCCESS: + case BOOKMARKED_STATUSES_EXPAND_SUCCESS: + case PINNED_STATUSES_FETCH_SUCCESS: + case SEARCH_FETCH_SUCCESS: + return normalizeStatuses(state, action.statuses); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.references); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/timelines.js b/app/javascript/flavours/glitch/reducers/timelines.js new file mode 100644 index 000000000..19e400b19 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/timelines.js @@ -0,0 +1,144 @@ +import { + TIMELINE_UPDATE, + TIMELINE_DELETE, + TIMELINE_EXPAND_SUCCESS, + TIMELINE_EXPAND_REQUEST, + TIMELINE_EXPAND_FAIL, + TIMELINE_SCROLL_TOP, + TIMELINE_DISCONNECT, +} from 'flavours/glitch/actions/timelines'; +import { + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, + ACCOUNT_UNFOLLOW_SUCCESS, +} from 'flavours/glitch/actions/accounts'; +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; +import compareId from 'flavours/glitch/util/compare_id'; + +const initialState = ImmutableMap(); + +const initialTimeline = ImmutableMap({ + unread: 0, + top: true, + isLoading: false, + hasMore: true, + items: ImmutableList(), +}); + +const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial) => { + return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { + mMap.set('isLoading', false); + if (!next) mMap.set('hasMore', false); + + if (!statuses.isEmpty()) { + mMap.update('items', ImmutableList(), oldIds => { + const newIds = statuses.map(status => status.get('id')); + const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1; + const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0); + + if (firstIndex < 0) { + return (isPartial ? newIds.unshift(null) : newIds).concat(oldIds.skip(lastIndex)); + } + + return oldIds.take(firstIndex + 1).concat( + isPartial && oldIds.get(firstIndex) !== null ? newIds.unshift(null) : newIds, + oldIds.skip(lastIndex) + ); + }); + } + })); +}; + +const updateTimeline = (state, timeline, status, references) => { + const top = state.getIn([timeline, 'top']); + const ids = state.getIn([timeline, 'items'], ImmutableList()); + const includesId = ids.includes(status.get('id')); + const unread = state.getIn([timeline, 'unread'], 0); + + if (includesId) { + return state; + } + + let newIds = ids; + + return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { + if (!top) mMap.set('unread', unread + 1); + if (top && ids.size > 40) newIds = newIds.take(20); + if (status.getIn(['reblog', 'id'], null) !== null) newIds = newIds.filterNot(item => references.includes(item)); + mMap.set('items', newIds.unshift(status.get('id'))); + })); +}; + +const deleteStatus = (state, id, accountId, references) => { + state.keySeq().forEach(timeline => { + state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === 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 filterTimeline = (timeline, state, relationship, statuses) => + state.updateIn([timeline, 'items'], ImmutableList(), list => + list.filterNot(statusId => + statuses.getIn([statusId, 'account']) === relationship.id + )); + +const updateTop = (state, timeline, top) => { + return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { + if (top) mMap.set('unread', 0); + mMap.set('top', top); + })); +}; + +export default function timelines(state = initialState, action) { + switch(action.type) { + case TIMELINE_EXPAND_REQUEST: + return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true)); + case TIMELINE_EXPAND_FAIL: + return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false)); + case TIMELINE_EXPAND_SUCCESS: + return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial); + case TIMELINE_UPDATE: + return updateTimeline(state, action.timeline, fromJS(action.status), action.references); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf); + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + return filterTimelines(state, action.relationship, action.statuses); + case ACCOUNT_UNFOLLOW_SUCCESS: + return filterTimeline('home', state, action.relationship, action.statuses); + case TIMELINE_SCROLL_TOP: + return updateTop(state, action.timeline, action.top); + case TIMELINE_DISCONNECT: + return state.update( + action.timeline, + initialTimeline, + map => map.update( + 'items', + items => items.first() ? items.unshift(null) : items + ) + ); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/user_lists.js b/app/javascript/flavours/glitch/reducers/user_lists.js new file mode 100644 index 000000000..a4df9ec8d --- /dev/null +++ b/app/javascript/flavours/glitch/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 'flavours/glitch/actions/accounts'; +import { + REBLOGS_FETCH_SUCCESS, + FAVOURITES_FETCH_SUCCESS, +} from 'flavours/glitch/actions/interactions'; +import { + BLOCKS_FETCH_SUCCESS, + BLOCKS_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/blocks'; +import { + MUTES_FETCH_SUCCESS, + MUTES_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/mutes'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +const initialState = ImmutableMap({ + followers: ImmutableMap(), + following: ImmutableMap(), + reblogged_by: ImmutableMap(), + favourited_by: ImmutableMap(), + follow_requests: ImmutableMap(), + blocks: ImmutableMap(), + mutes: ImmutableMap(), +}); + +const normalizeList = (state, type, id, accounts, next) => { + return state.setIn([type, id], ImmutableMap({ + next, + items: ImmutableList(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], ImmutableList(action.accounts.map(item => item.id))); + case FAVOURITES_FETCH_SUCCESS: + return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id))); + case FOLLOW_REQUESTS_FETCH_SUCCESS: + return state.setIn(['follow_requests', 'items'], ImmutableList(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'], ImmutableList(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'], ImmutableList(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; + } +}; |