From 87029e8abfad4fad63481e2db1e61baf25bea0be Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 21 Apr 2021 14:47:39 -0500 Subject: [PATCH 1/9] Display tombstone in place of deleted post, #138 --- app/soapbox/features/status/index.js | 26 ++++++++++++++++++++++---- app/soapbox/reducers/contexts.js | 9 +++++++++ app/styles/components/status.scss | 11 +++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/app/soapbox/features/status/index.js b/app/soapbox/features/status/index.js index 44992b426..8683ab9dd 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.startsWith('tombstone-')) { + return this.renderTombstone(id); + } else { + return this.renderStatus(id); + } + }); } setRef = c => { diff --git a/app/soapbox/reducers/contexts.js b/app/soapbox/reducers/contexts.js index 844f9f78f..c0266d853 100644 --- a/app/soapbox/reducers/contexts.js +++ b/app/soapbox/reducers/contexts.js @@ -27,6 +27,15 @@ const normalizeContext = (immutableState, id, ancestors, descendants) => immutab ancestors.forEach(addReply); descendants.forEach(addReply); + + if (ancestors.length > 0 && !inReplyTos.get(id)) { + const tombstoneId = `tombstone-${id}`; + const { id: lastId } = ancestors[ancestors.length - 1]; + replies.update(tombstoneId, ImmutableOrderedSet(), siblings => siblings.add(id).sort()); + replies.update(lastId, ImmutableOrderedSet(), siblings => siblings.add(tombstoneId).sort()); + inReplyTos.set(id, tombstoneId); + inReplyTos.set(tombstoneId, lastId); + } })); })); }); diff --git a/app/styles/components/status.scss b/app/styles/components/status.scss index 88912cc35..fc020d498 100644 --- a/app/styles/components/status.scss +++ b/app/styles/components/status.scss @@ -658,3 +658,14 @@ a.status-card.compact:hover { max-height: 100%; } } + +.tombstone { + padding: 20px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-size: 14px; + border-bottom: 1px solid var(--brand-color--faint); + color: var(--primary-text-color--faint); +} From 217fbea7a32df8bae8a4e42d7b0911ae8c82a3c6 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 21 Apr 2021 15:03:00 -0500 Subject: [PATCH 2/9] Tombstone: fix context tests --- app/soapbox/reducers/__tests__/contexts-test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/soapbox/reducers/__tests__/contexts-test.js b/app/soapbox/reducers/__tests__/contexts-test.js index 8b5a30d50..b0ce933be 100644 --- a/app/soapbox/reducers/__tests__/contexts-test.js +++ b/app/soapbox/reducers/__tests__/contexts-test.js @@ -25,6 +25,7 @@ describe('contexts reducer', () => { '9zIH9GTCDWEFSRt2um': '9zIH7PUdhK3Ircg4hM', '9zIH9fhaP9atiJoOJc': '9zIH8WYwtnUx4yDzUm', '9zIH8WYwtnUx4yDzUm': '9zIH7PUdhK3Ircg4hM', + 'tombstone-9zIH8WYwtnUx4yDzUm': '9zIH7mMGgc1RmJwDLM', }), replies: ImmutableMap({ '9zIH6kDXA10YqhMKqO': ImmutableOrderedSet([ @@ -38,6 +39,12 @@ describe('contexts reducer', () => { '9zIH8WYwtnUx4yDzUm': ImmutableOrderedSet([ '9zIH9fhaP9atiJoOJc', ]), + 'tombstone-9zIH8WYwtnUx4yDzUm': ImmutableOrderedSet([ + '9zIH8WYwtnUx4yDzUm', + ]), + '9zIH7mMGgc1RmJwDLM': ImmutableOrderedSet([ + 'tombstone-9zIH8WYwtnUx4yDzUm', + ]), }), })); }); From 8cdb0581d39fd596cf00168887d2e938e7a48845 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 21 Apr 2021 15:25:18 -0500 Subject: [PATCH 3/9] Tombstone: work for descendants, too --- app/soapbox/features/status/index.js | 2 +- app/soapbox/reducers/contexts.js | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/app/soapbox/features/status/index.js b/app/soapbox/features/status/index.js index 8683ab9dd..21fb5342b 100644 --- a/app/soapbox/features/status/index.js +++ b/app/soapbox/features/status/index.js @@ -411,7 +411,7 @@ class Status extends ImmutablePureComponent { renderTombstone(id) { return ( -
+

); diff --git a/app/soapbox/reducers/contexts.js b/app/soapbox/reducers/contexts.js index c0266d853..470c5b4e2 100644 --- a/app/soapbox/reducers/contexts.js +++ b/app/soapbox/reducers/contexts.js @@ -26,15 +26,22 @@ const normalizeContext = (immutableState, id, ancestors, descendants) => immutab } ancestors.forEach(addReply); - descendants.forEach(addReply); + + descendants.forEach(status => { + if (status.in_reply_to_id) { + addReply(status); + } else { + addReply({ id: `tombstone-${status.id}`, in_reply_to_id: id }); + addReply({ id: status.id, in_reply_to_id: `tombstone-${status.id}` }); + } + }); if (ancestors.length > 0 && !inReplyTos.get(id)) { - const tombstoneId = `tombstone-${id}`; const { id: lastId } = ancestors[ancestors.length - 1]; - replies.update(tombstoneId, ImmutableOrderedSet(), siblings => siblings.add(id).sort()); - replies.update(lastId, ImmutableOrderedSet(), siblings => siblings.add(tombstoneId).sort()); - inReplyTos.set(id, tombstoneId); - inReplyTos.set(tombstoneId, lastId); + replies.update(`tombstone-${id}`, ImmutableOrderedSet(), siblings => siblings.add(id).sort()); + replies.update(lastId, ImmutableOrderedSet(), siblings => siblings.add(`tombstone-${id}`).sort()); + inReplyTos.set(id, `tombstone-${id}`); + inReplyTos.set(`tombstone-${id}`, lastId); } })); })); From d186aefeec577a80087dac144e70db9c8295ec66 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 21 Apr 2021 15:37:30 -0500 Subject: [PATCH 4/9] "no longer available" --> "unavailable" --- app/soapbox/features/status/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/features/status/index.js b/app/soapbox/features/status/index.js index 21fb5342b..b647a0e7d 100644 --- a/app/soapbox/features/status/index.js +++ b/app/soapbox/features/status/index.js @@ -412,7 +412,7 @@ class Status extends ImmutablePureComponent { renderTombstone(id) { return (
-

+

); } From dfcf0e191fde553ed90a014dd555a227a69b9838 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 21 Apr 2021 15:46:28 -0500 Subject: [PATCH 5/9] Tombstone: improve styling --- app/styles/components/status.scss | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/styles/components/status.scss b/app/styles/components/status.scss index fc020d498..430c301a0 100644 --- a/app/styles/components/status.scss +++ b/app/styles/components/status.scss @@ -660,12 +660,15 @@ a.status-card.compact:hover { } .tombstone { - padding: 20px; - display: flex; - align-items: center; - justify-content: center; + 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; + } } From b2fa82dcd009b2a5e2e4b3c597df00abf22f54a2 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 21 Apr 2021 16:40:32 -0500 Subject: [PATCH 6/9] Contexts: refactor importStatus --- app/soapbox/reducers/contexts.js | 58 +++++++++++++------------------- 1 file changed, 24 insertions(+), 34 deletions(-) diff --git a/app/soapbox/reducers/contexts.js b/app/soapbox/reducers/contexts.js index 470c5b4e2..a949a4400 100644 --- a/app/soapbox/reducers/contexts.js +++ b/app/soapbox/reducers/contexts.js @@ -12,27 +12,35 @@ const initialState = ImmutableMap({ replies: ImmutableMap(), }); +const importStatus = (state, { id, in_reply_to_id }) => { + if (!in_reply_to_id) return state; + + return state.withMutation(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 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(); - }); - - inReplyTos.set(id, in_reply_to_id); - } - } - - ancestors.forEach(addReply); + ancestors.forEach(status => importStatus(state, status)); descendants.forEach(status => { if (status.in_reply_to_id) { - addReply(status); + importStatus(state, status); } else { - addReply({ id: `tombstone-${status.id}`, in_reply_to_id: id }); - addReply({ id: status.id, in_reply_to_id: `tombstone-${status.id}` }); + importStatus(state, { id: `tombstone-${status.id}`, in_reply_to_id: id }); + importStatus(state, { id: status.id, in_reply_to_id: `tombstone-${status.id}` }); } }); @@ -79,22 +87,6 @@ const filterContexts = (state, relationship, statuses) => { 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; -}; - export default function replies(state = initialState, action) { switch(action.type) { case ACCOUNT_BLOCK_SUCCESS: @@ -105,11 +97,9 @@ export default function replies(state = initialState, action) { case TIMELINE_DELETE: return deleteFromContexts(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; } From cc278f7ca6be50f95f1ee6551ee57cb35fd39e55 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 21 Apr 2021 17:35:01 -0500 Subject: [PATCH 7/9] Contexts: more refactoring --- .../reducers/__tests__/contexts-test.js | 33 ++++++- app/soapbox/reducers/contexts.js | 85 +++++++++---------- 2 files changed, 73 insertions(+), 45 deletions(-) diff --git a/app/soapbox/reducers/__tests__/contexts-test.js b/app/soapbox/reducers/__tests__/contexts-test.js index b0ce933be..03e4d1b1b 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'; @@ -48,4 +53,30 @@ describe('contexts reducer', () => { }), })); }); + + 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 a949a4400..703e911b7 100644 --- a/app/soapbox/reducers/contexts.js +++ b/app/soapbox/reducers/contexts.js @@ -15,8 +15,8 @@ const initialState = ImmutableMap({ const importStatus = (state, { id, in_reply_to_id }) => { if (!in_reply_to_id) return state; - return state.withMutation(state => { - state.setIn(['inReplyTos', 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(); @@ -30,61 +30,58 @@ const importStatuses = (state, statuses) => { }); }; -const normalizeContext = (immutableState, id, ancestors, descendants) => immutableState.withMutations(state => { - state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => { - state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => { - ancestors.forEach(status => importStatus(state, status)); +const insertTombstone = (state, ancestorId, descendantId) => { + const tombstoneId = `tombstone-${descendantId}`; + return state.withMutations(state => { + importStatus(state, { id: tombstoneId, in_reply_to_id: ancestorId }); + importStatus(state, { id: descendantId, in_reply_to_id: tombstoneId }); + }); +}; - descendants.forEach(status => { - if (status.in_reply_to_id) { - importStatus(state, status); - } else { - importStatus(state, { id: `tombstone-${status.id}`, in_reply_to_id: id }); - importStatus(state, { id: status.id, in_reply_to_id: `tombstone-${status.id}` }); - } - }); +const normalizeContext = (state, id, ancestors, descendants) => state.withMutations(state => { + importStatuses(state, ancestors); - if (ancestors.length > 0 && !inReplyTos.get(id)) { - const { id: lastId } = ancestors[ancestors.length - 1]; - replies.update(`tombstone-${id}`, ImmutableOrderedSet(), siblings => siblings.add(id).sort()); - replies.update(lastId, ImmutableOrderedSet(), siblings => siblings.add(`tombstone-${id}`).sort()); - inReplyTos.set(id, `tombstone-${id}`); - inReplyTos.set(`tombstone-${id}`, lastId); - } - })); - })); + descendants.forEach(status => { + if (status.in_reply_to_id) { + importStatus(state, status); + } else { + insertTombstone(state, id, status.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); + return deleteStatuses(state, ownedStatusIds); }; export default function replies(state = initialState, action) { @@ -95,7 +92,7 @@ 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 importStatus(state, action.status); case STATUSES_IMPORT: From cc6d2599cba1906c1a119579ee8ab639787d04cb Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 21 Apr 2021 18:14:20 -0500 Subject: [PATCH 8/9] Wholistic context import (builds tree from anywhere in the thread) --- app/soapbox/reducers/contexts.js | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/app/soapbox/reducers/contexts.js b/app/soapbox/reducers/contexts.js index 703e911b7..cff3636eb 100644 --- a/app/soapbox/reducers/contexts.js +++ b/app/soapbox/reducers/contexts.js @@ -38,16 +38,23 @@ const insertTombstone = (state, ancestorId, descendantId) => { }); }; -const normalizeContext = (state, id, ancestors, descendants) => state.withMutations(state => { - importStatuses(state, ancestors); +const importBranch = (state, statuses, rootId) => { + return state.withMutations(state => { + statuses.forEach((status, i) => { + const lastId = rootId && i === 0 ? rootId : (statuses[i - 1] || {}).id; - descendants.forEach(status => { - if (status.in_reply_to_id) { - importStatus(state, status); - } else { - insertTombstone(state, id, status.id); - } + if (status.in_reply_to_id) { + importStatus(state, status); + } else if (lastId) { + insertTombstone(state, lastId, 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); From 4e7d7ac378403035d10779542f057b07d0c17ab9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 21 Apr 2021 18:28:43 -0500 Subject: [PATCH 9/9] startsWith('tombstone-') --> endsWith('-tombstone') --- app/soapbox/features/status/index.js | 2 +- app/soapbox/reducers/__tests__/contexts-test.js | 6 +++--- app/soapbox/reducers/contexts.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/soapbox/features/status/index.js b/app/soapbox/features/status/index.js index b647a0e7d..2654891e2 100644 --- a/app/soapbox/features/status/index.js +++ b/app/soapbox/features/status/index.js @@ -431,7 +431,7 @@ class Status extends ImmutablePureComponent { renderChildren(list) { return list.map(id => { - if (id.startsWith('tombstone-')) { + if (id.endsWith('-tombstone')) { return this.renderTombstone(id); } else { return this.renderStatus(id); diff --git a/app/soapbox/reducers/__tests__/contexts-test.js b/app/soapbox/reducers/__tests__/contexts-test.js index 03e4d1b1b..054888cec 100644 --- a/app/soapbox/reducers/__tests__/contexts-test.js +++ b/app/soapbox/reducers/__tests__/contexts-test.js @@ -30,7 +30,7 @@ describe('contexts reducer', () => { '9zIH9GTCDWEFSRt2um': '9zIH7PUdhK3Ircg4hM', '9zIH9fhaP9atiJoOJc': '9zIH8WYwtnUx4yDzUm', '9zIH8WYwtnUx4yDzUm': '9zIH7PUdhK3Ircg4hM', - 'tombstone-9zIH8WYwtnUx4yDzUm': '9zIH7mMGgc1RmJwDLM', + '9zIH8WYwtnUx4yDzUm-tombstone': '9zIH7mMGgc1RmJwDLM', }), replies: ImmutableMap({ '9zIH6kDXA10YqhMKqO': ImmutableOrderedSet([ @@ -44,11 +44,11 @@ describe('contexts reducer', () => { '9zIH8WYwtnUx4yDzUm': ImmutableOrderedSet([ '9zIH9fhaP9atiJoOJc', ]), - 'tombstone-9zIH8WYwtnUx4yDzUm': ImmutableOrderedSet([ + '9zIH8WYwtnUx4yDzUm-tombstone': ImmutableOrderedSet([ '9zIH8WYwtnUx4yDzUm', ]), '9zIH7mMGgc1RmJwDLM': ImmutableOrderedSet([ - 'tombstone-9zIH8WYwtnUx4yDzUm', + '9zIH8WYwtnUx4yDzUm-tombstone', ]), }), })); diff --git a/app/soapbox/reducers/contexts.js b/app/soapbox/reducers/contexts.js index cff3636eb..1dd93d119 100644 --- a/app/soapbox/reducers/contexts.js +++ b/app/soapbox/reducers/contexts.js @@ -31,7 +31,7 @@ const importStatuses = (state, statuses) => { }; const insertTombstone = (state, ancestorId, descendantId) => { - const tombstoneId = `tombstone-${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 });