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;
+ }
+}