From 2290bfb334d5fb4ca0b858057e523ed426454c3b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 13 May 2022 14:30:11 -0500 Subject: [PATCH] Contexts reducer: convert to TypeScript --- app/soapbox/reducers/contexts.js | 189 ------------------------ app/soapbox/reducers/contexts.ts | 243 +++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 189 deletions(-) delete mode 100644 app/soapbox/reducers/contexts.js create mode 100644 app/soapbox/reducers/contexts.ts diff --git a/app/soapbox/reducers/contexts.js b/app/soapbox/reducers/contexts.js deleted file mode 100644 index 18ee80caf..000000000 --- a/app/soapbox/reducers/contexts.js +++ /dev/null @@ -1,189 +0,0 @@ -import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; - -import { STATUS_IMPORT, STATUSES_IMPORT } from 'soapbox/actions/importer'; - -import { - ACCOUNT_BLOCK_SUCCESS, - ACCOUNT_MUTE_SUCCESS, -} from '../actions/accounts'; -import { - STATUS_CREATE_REQUEST, - STATUS_CREATE_SUCCESS, -} from '../actions/statuses'; -import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses'; -import { TIMELINE_DELETE } from '../actions/timelines'; - -const initialState = ImmutableMap({ - inReplyTos: ImmutableMap(), - replies: ImmutableMap(), -}); - -const importStatus = (state, status, idempotencyKey) => { - const { id, in_reply_to_id } = status; - if (!in_reply_to_id) return state; - - return state.withMutations(state => { - state.setIn(['inReplyTos', id], in_reply_to_id); - - state.updateIn(['replies', in_reply_to_id], ImmutableOrderedSet(), ids => { - return ids.add(id).sort(); - }); - - if (idempotencyKey) { - deletePendingStatus(state, status, idempotencyKey); - } - }); -}; - -const importStatuses = (state, statuses) => { - return state.withMutations(state => { - statuses.forEach(status => importStatus(state, status)); - }); -}; - -const isReplyTo = (state, childId, parentId, initialId = null) => { - if (!childId) return false; - - // Prevent cycles - if (childId === initialId) return false; - initialId = initialId || childId; - - if (childId === parentId) { - return true; - } else { - const nextId = state.getIn(['inReplyTos', childId]); - return isReplyTo(state, nextId, parentId, initialId); - } -}; - -const insertTombstone = (state, ancestorId, descendantId) => { - // Prevent infinite loop if the API returns a bogus response - if (isReplyTo(state, ancestorId, descendantId)) return state; - - const tombstoneId = `${descendantId}-tombstone`; - return state.withMutations(state => { - importStatus(state, { id: tombstoneId, in_reply_to_id: ancestorId }); - importStatus(state, { id: descendantId, in_reply_to_id: tombstoneId }); - }); -}; - -/** Find the highest level status from this statusId. */ -const getRootNode = (state, statusId, initialId = statusId) => { - const parent = state.getIn(['inReplyTos', statusId]); - - if (!parent) { - return statusId; - } else if (parent === initialId) { - // Prevent cycles - return parent; - } else { - return getRootNode(state, parent, initialId); - } -}; - -/** Route fromId to toId by inserting tombstones. */ -const connectNodes = (state, fromId, toId) => { - const root = getRootNode(state, fromId); - - if (root !== toId) { - return insertTombstone(state, toId, fromId); - } else { - return state; - } -}; - -const importBranch = (state, statuses, statusId) => { - return state.withMutations(state => { - statuses.forEach((status, i) => { - const prevId = statusId && i === 0 ? statusId : (statuses[i - 1] || {}).id; - - if (status.in_reply_to_id) { - importStatus(state, status); - connectNodes(state, status.id, statusId); - } else if (prevId) { - insertTombstone(state, prevId, status.id); - } - }); - }); -}; - -const normalizeContext = (state, id, ancestors, descendants) => state.withMutations(state => { - importBranch(state, ancestors); - importBranch(state, descendants, id); - - if (ancestors.length > 0 && !state.getIn(['inReplyTos', id])) { - insertTombstone(state, ancestors[ancestors.length - 1].id, id); - } -}); - -const deleteStatus = (state, id) => { - return state.withMutations(state => { - const parentId = state.getIn(['inReplyTos', id]); - const replies = state.getIn(['replies', id], ImmutableOrderedSet()); - - // Delete from its parent's tree - state.updateIn(['replies', parentId], ImmutableOrderedSet(), ids => ids.delete(id)); - - // Dereference children - replies.forEach(reply => state.deleteIn(['inReplyTos', reply])); - - state.deleteIn(['inReplyTos', id]); - state.deleteIn(['replies', id]); - }); -}; - -const deleteStatuses = (state, ids) => { - return state.withMutations(state => { - ids.forEach(id => deleteStatus(state, id)); - }); -}; - -const filterContexts = (state, relationship, statuses) => { - const ownedStatusIds = statuses - .filter(status => status.get('account') === relationship.id) - .map(status => status.get('id')); - - return deleteStatuses(state, ownedStatusIds); -}; - -const importPendingStatus = (state, params, idempotencyKey) => { - const id = `末pending-${idempotencyKey}`; - const { in_reply_to_id } = params; - return importStatus(state, { id, in_reply_to_id }); -}; - -const deletePendingStatus = (state, { in_reply_to_id }, idempotencyKey) => { - const id = `末pending-${idempotencyKey}`; - - return state.withMutations(state => { - state.deleteIn(['inReplyTos', id]); - - if (in_reply_to_id) { - state.updateIn(['replies', in_reply_to_id], ImmutableOrderedSet(), ids => { - return ids.delete(id).sort(); - }); - } - }); -}; - -export default function replies(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 deleteStatuses(state, [action.id]); - case STATUS_CREATE_REQUEST: - return importPendingStatus(state, action.params, action.idempotencyKey); - case STATUS_CREATE_SUCCESS: - return deletePendingStatus(state, action.status, action.idempotencyKey); - case STATUS_IMPORT: - return importStatus(state, action.status, action.idempotencyKey); - case STATUSES_IMPORT: - return importStatuses(state, action.statuses); - default: - return state; - } -} diff --git a/app/soapbox/reducers/contexts.ts b/app/soapbox/reducers/contexts.ts new file mode 100644 index 000000000..b8ddb5ea3 --- /dev/null +++ b/app/soapbox/reducers/contexts.ts @@ -0,0 +1,243 @@ +import { + Map as ImmutableMap, + Record as ImmutableRecord, + OrderedSet as ImmutableOrderedSet, +} from 'immutable'; + +import { STATUS_IMPORT, STATUSES_IMPORT } from 'soapbox/actions/importer'; + +import { + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, +} from '../actions/accounts'; +import { + STATUS_CREATE_REQUEST, + STATUS_CREATE_SUCCESS, +} from '../actions/statuses'; +import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses'; +import { TIMELINE_DELETE } from '../actions/timelines'; + +import type { ReducerStatus } from './statuses'; +import type { AnyAction } from 'redux'; + +const ReducerRecord = ImmutableRecord({ + inReplyTos: ImmutableMap(), + replies: ImmutableMap>(), +}); + +type State = ReturnType; + +/** Minimal status fields needed to process context. */ +type ContextStatus = { + id: string, + in_reply_to_id: string | null, +} + +/** Import a single status into the reducer, setting replies and replyTos. */ +const importStatus = (state: State, status: ContextStatus, idempotencyKey?: string): State => { + const { id, in_reply_to_id: inReplyToId } = status; + if (!inReplyToId) return state; + + return state.withMutations(state => { + const replies = state.replies.get(inReplyToId) || ImmutableOrderedSet(); + const newReplies = replies.add(id).sort(); + + state.setIn(['replies', inReplyToId], newReplies); + state.setIn(['inReplyTos', id], inReplyToId); + + if (idempotencyKey) { + deletePendingStatus(state, status, idempotencyKey); + } + }); +}; + +/** Import multiple statuses into the state. */ +const importStatuses = (state: State, statuses: ContextStatus[]): State => { + return state.withMutations(state => { + statuses.forEach(status => importStatus(state, status)); + }); +}; + +const isReplyTo = ( + state: State, + childId: string | undefined, + parentId: string, + initialId: string | null = null, +): boolean => { + if (!childId) return false; + + // Prevent cycles + if (childId === initialId) return false; + initialId = initialId || childId; + + if (childId === parentId) { + return true; + } else { + const nextId = state.inReplyTos.get(childId); + return isReplyTo(state, nextId, parentId, initialId); + } +}; + +/** Insert a fake status ID connecting descendant to ancestor. */ +const insertTombstone = (state: State, ancestorId: string, descendantId: string): State => { + // Prevent infinite loop if the API returns a bogus response + if (isReplyTo(state, ancestorId, descendantId)) return state; + + const tombstoneId = `${descendantId}-tombstone`; + return state.withMutations(state => { + importStatus(state, { id: tombstoneId, in_reply_to_id: ancestorId }); + importStatus(state, { id: descendantId, in_reply_to_id: tombstoneId }); + }); +}; + +/** Find the highest level status from this statusId. */ +const getRootNode = (state: State, statusId: string, initialId = statusId): string => { + const parent = state.inReplyTos.get(statusId); + + if (!parent) { + return statusId; + } else if (parent === initialId) { + // Prevent cycles + return parent; + } else { + return getRootNode(state, parent, initialId); + } +}; + +/** Route fromId to toId by inserting tombstones. */ +const connectNodes = (state: State, fromId: string, toId: string): State => { + const root = getRootNode(state, fromId); + + if (root !== toId) { + return insertTombstone(state, toId, fromId); + } else { + return state; + } +}; + +/** Import a branch of ancestors or descendants, in relation to statusId. */ +const importBranch = (state: State, statuses: ContextStatus[], statusId?: string): State => { + return state.withMutations(state => { + statuses.forEach((status, i) => { + const prevId = statusId && i === 0 ? statusId : (statuses[i - 1] || {}).id; + + if (status.in_reply_to_id) { + importStatus(state, status); + + // On Mastodon, in_reply_to_id can refer to an unavailable status, + // so traverse the tree up and insert a connecting tombstone if needed. + if (statusId) { + connectNodes(state, status.id, statusId); + } + } else if (prevId) { + // On Pleroma, in_reply_to_id will be null if the parent is unavailable, + // so insert the tombstone now. + insertTombstone(state, prevId, status.id); + } + }); + }); +}; + +/** Import a status's ancestors and descendants. */ +const normalizeContext = ( + state: State, + id: string, + ancestors: ContextStatus[], + descendants: ContextStatus[], +) => state.withMutations(state => { + importBranch(state, ancestors); + importBranch(state, descendants, id); + + if (ancestors.length > 0 && !state.getIn(['inReplyTos', id])) { + insertTombstone(state, ancestors[ancestors.length - 1].id, id); + } +}); + +/** Remove a status from the reducer. */ +const deleteStatus = (state: State, id: string): State => { + return state.withMutations(state => { + // Delete from its parent's tree + const parentId = state.inReplyTos.get(id); + if (parentId) { + const parentReplies = state.replies.get(parentId) || ImmutableOrderedSet(); + const newParentReplies = parentReplies.delete(id); + state.setIn(['replies', parentId], newParentReplies); + } + + // Dereference children + const replies = state.replies.get(id) || ImmutableOrderedSet(); + replies.forEach(reply => state.deleteIn(['inReplyTos', reply])); + + state.deleteIn(['inReplyTos', id]); + state.deleteIn(['replies', id]); + }); +}; + +/** Delete multiple statuses from the reducer. */ +const deleteStatuses = (state: State, ids: string[]): State => { + return state.withMutations(state => { + ids.forEach(id => deleteStatus(state, id)); + }); +}; + +/** Delete statuses upon blocking or muting a user. */ +const filterContexts = ( + state: State, + relationship: { id: string }, + /** The entire statuses map from the store. */ + statuses: ImmutableMap, +): State => { + const ownedStatusIds = statuses + .filter(status => status.account === relationship.id) + .map(status => status.id) + .toList() + .toArray(); + + return deleteStatuses(state, ownedStatusIds); +}; + +/** Add a fake status ID for a pending status. */ +const importPendingStatus = (state: State, params: ContextStatus, idempotencyKey: string): State => { + const id = `末pending-${idempotencyKey}`; + const { in_reply_to_id } = params; + return importStatus(state, { id, in_reply_to_id }); +}; + +/** Delete a pending status from the reducer. */ +const deletePendingStatus = (state: State, params: ContextStatus, idempotencyKey: string): State => { + const id = `末pending-${idempotencyKey}`; + const { in_reply_to_id: inReplyToId } = params; + + return state.withMutations(state => { + state.deleteIn(['inReplyTos', id]); + + if (inReplyToId) { + const replies = state.replies.get(inReplyToId) || ImmutableOrderedSet(); + const newReplies = replies.delete(id).sort(); + state.setIn(['replies', inReplyToId], newReplies); + } + }); +}; + +/** Contexts reducer. Used for building a nested tree structure for threads. */ +export default function replies(state = ReducerRecord(), action: AnyAction) { + 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 deleteStatuses(state, [action.id]); + case STATUS_CREATE_REQUEST: + return importPendingStatus(state, action.params, action.idempotencyKey); + case STATUS_CREATE_SUCCESS: + return deletePendingStatus(state, action.status, action.idempotencyKey); + case STATUS_IMPORT: + return importStatus(state, action.status, action.idempotencyKey); + case STATUSES_IMPORT: + return importStatuses(state, action.statuses); + default: + return state; + } +}