diff --git a/app/soapbox/features/status/index.js b/app/soapbox/features/status/index.js index 44992b426..2654891e2 100644 --- a/app/soapbox/features/status/index.js +++ b/app/soapbox/features/status/index.js @@ -77,7 +77,7 @@ const makeMapStateToProps = () => { if (status) { ancestorsIds = ancestorsIds.withMutations(mutable => { - let id = status.get('in_reply_to_id'); + let id = state.getIn(['contexts', 'inReplyTos', status.get('id')]); while (id) { mutable.unshift(id); @@ -409,8 +409,16 @@ class Status extends ImmutablePureComponent { } } - renderChildren(list) { - return list.map(id => ( + renderTombstone(id) { + return ( +
+

+
+ ); + } + + renderStatus(id) { + return ( - )); + ); + } + + renderChildren(list) { + return list.map(id => { + if (id.endsWith('-tombstone')) { + return this.renderTombstone(id); + } else { + return this.renderStatus(id); + } + }); } setRef = c => { diff --git a/app/soapbox/reducers/__tests__/contexts-test.js b/app/soapbox/reducers/__tests__/contexts-test.js index 8b5a30d50..054888cec 100644 --- a/app/soapbox/reducers/__tests__/contexts-test.js +++ b/app/soapbox/reducers/__tests__/contexts-test.js @@ -1,6 +1,11 @@ import reducer from '../contexts'; import { CONTEXT_FETCH_SUCCESS } from 'soapbox/actions/statuses'; -import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; +import { TIMELINE_DELETE } from 'soapbox/actions/timelines'; +import { + Map as ImmutableMap, + OrderedSet as ImmutableOrderedSet, + fromJS, +} from 'immutable'; import context1 from 'soapbox/__fixtures__/context_1.json'; import context2 from 'soapbox/__fixtures__/context_2.json'; @@ -25,6 +30,7 @@ describe('contexts reducer', () => { '9zIH9GTCDWEFSRt2um': '9zIH7PUdhK3Ircg4hM', '9zIH9fhaP9atiJoOJc': '9zIH8WYwtnUx4yDzUm', '9zIH8WYwtnUx4yDzUm': '9zIH7PUdhK3Ircg4hM', + '9zIH8WYwtnUx4yDzUm-tombstone': '9zIH7mMGgc1RmJwDLM', }), replies: ImmutableMap({ '9zIH6kDXA10YqhMKqO': ImmutableOrderedSet([ @@ -38,7 +44,39 @@ describe('contexts reducer', () => { '9zIH8WYwtnUx4yDzUm': ImmutableOrderedSet([ '9zIH9fhaP9atiJoOJc', ]), + '9zIH8WYwtnUx4yDzUm-tombstone': ImmutableOrderedSet([ + '9zIH8WYwtnUx4yDzUm', + ]), + '9zIH7mMGgc1RmJwDLM': ImmutableOrderedSet([ + '9zIH8WYwtnUx4yDzUm-tombstone', + ]), }), })); }); + + describe(TIMELINE_DELETE, () => { + it('deletes the status', () => { + const action = { type: TIMELINE_DELETE, id: 'B' }; + + const state = fromJS({ + inReplyTos: { + B: 'A', + C: 'B', + }, + replies: { + A: ImmutableOrderedSet(['B']), + B: ImmutableOrderedSet(['C']), + }, + }); + + const expected = fromJS({ + inReplyTos: {}, + replies: { + A: ImmutableOrderedSet(), + }, + }); + + expect(reducer(state, action)).toEqual(expected); + }); + }); }); diff --git a/app/soapbox/reducers/contexts.js b/app/soapbox/reducers/contexts.js index 844f9f78f..1dd93d119 100644 --- a/app/soapbox/reducers/contexts.js +++ b/app/soapbox/reducers/contexts.js @@ -12,71 +12,83 @@ const initialState = ImmutableMap({ replies: ImmutableMap(), }); -const normalizeContext = (immutableState, id, ancestors, descendants) => immutableState.withMutations(state => { - state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => { - state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => { - function addReply({ id, in_reply_to_id }) { - if (in_reply_to_id) { - replies.update(in_reply_to_id, ImmutableOrderedSet(), siblings => { - return siblings.add(id).sort(); - }); +const importStatus = (state, { id, in_reply_to_id }) => { + if (!in_reply_to_id) return state; - inReplyTos.set(id, in_reply_to_id); - } + 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(); + }); + }); +}; + +const importStatuses = (state, statuses) => { + return state.withMutations(state => { + statuses.forEach(status => importStatus(state, status)); + }); +}; + +const insertTombstone = (state, ancestorId, descendantId) => { + 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 }); + }); +}; + +const importBranch = (state, statuses, rootId) => { + return state.withMutations(state => { + statuses.forEach((status, i) => { + const lastId = rootId && i === 0 ? rootId : (statuses[i - 1] || {}).id; + + if (status.in_reply_to_id) { + importStatus(state, status); + } else if (lastId) { + insertTombstone(state, lastId, status.id); } + }); + }); +}; - ancestors.forEach(addReply); - descendants.forEach(addReply); - })); - })); +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 deleteFromContexts = (immutableState, ids) => immutableState.withMutations(state => { - state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => { - state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => { - ids.forEach(id => { - const inReplyToIdOfId = inReplyTos.get(id); - const repliesOfId = replies.get(id); - const siblings = replies.get(inReplyToIdOfId); +const deleteStatus = (state, id) => { + return state.withMutations(state => { + const parentId = state.getIn(['inReplyTos', id]); + const replies = state.getIn(['replies', id], ImmutableOrderedSet()); - if (siblings) { - replies.set(inReplyToIdOfId, siblings.filterNot(sibling => sibling === id)); - } + // Delete from its parent's tree + state.updateIn(['replies', parentId], ImmutableOrderedSet(), ids => ids.delete(id)); + // Dereference children + replies.forEach(reply => state.deleteIn(['inReplyTos', reply])); - if (repliesOfId) { - repliesOfId.forEach(reply => inReplyTos.delete(reply)); - } + state.deleteIn(['inReplyTos', id]); + state.deleteIn(['replies', id]); + }); +}; - inReplyTos.delete(id); - replies.delete(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 deleteFromContexts(state, ownedStatusIds); -}; - -const updateContext = (state, status) => { - if (status.in_reply_to_id) { - return state.withMutations(mutable => { - const replies = mutable.getIn(['replies', status.in_reply_to_id], ImmutableOrderedSet()); - - mutable.setIn(['inReplyTos', status.id], status.in_reply_to_id); - - if (!replies.includes(status.id)) { - mutable.setIn(['replies', status.in_reply_to_id], replies.add(status.id).sort()); - } - }); - } - - return state; + return deleteStatuses(state, ownedStatusIds); }; export default function replies(state = initialState, action) { @@ -87,13 +99,11 @@ export default function replies(state = initialState, action) { case CONTEXT_FETCH_SUCCESS: return normalizeContext(state, action.id, action.ancestors, action.descendants); case TIMELINE_DELETE: - return deleteFromContexts(state, [action.id]); + return deleteStatuses(state, [action.id]); case STATUS_IMPORT: - return updateContext(state, action.status); + return importStatus(state, action.status); case STATUSES_IMPORT: - return state.withMutations(mutable => - action.statuses.forEach(status => updateContext(mutable, status))); - + return importStatuses(state, action.statuses); default: return state; } diff --git a/app/styles/components/status.scss b/app/styles/components/status.scss index 88912cc35..430c301a0 100644 --- a/app/styles/components/status.scss +++ b/app/styles/components/status.scss @@ -658,3 +658,17 @@ a.status-card.compact:hover { max-height: 100%; } } + +.tombstone { + padding: 10px; + text-align: center; + font-size: 14px; + border-bottom: 1px solid var(--brand-color--faint); + color: var(--primary-text-color--faint); + + p { + padding: 10px; + background: var(--background-color); + border-radius: 4px; + } +}