mirror of
https://github.com/mastodon/mastodon.git
synced 2025-05-07 12:16:14 +00:00
Refactor context reducer to TypeScript (#34506)
This commit is contained in:
parent
bd9223f0b9
commit
17d8e2b6e3
|
@ -4,8 +4,11 @@ import api from '../api';
|
||||||
|
|
||||||
import { ensureComposeIsVisible, setComposeToStatus } from './compose';
|
import { ensureComposeIsVisible, setComposeToStatus } from './compose';
|
||||||
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
|
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
|
||||||
|
import { fetchContext } from './statuses_typed';
|
||||||
import { deleteFromTimelines } from './timelines';
|
import { deleteFromTimelines } from './timelines';
|
||||||
|
|
||||||
|
export * from './statuses_typed';
|
||||||
|
|
||||||
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
|
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
|
||||||
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
|
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
|
||||||
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
|
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
|
||||||
|
@ -14,10 +17,6 @@ export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
|
||||||
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
|
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
|
||||||
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
|
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
|
||||||
|
|
||||||
export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
|
|
||||||
export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
|
|
||||||
export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
|
|
||||||
|
|
||||||
export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST';
|
export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST';
|
||||||
export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS';
|
export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS';
|
||||||
export const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL';
|
export const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL';
|
||||||
|
@ -54,7 +53,7 @@ export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
|
||||||
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
|
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
|
||||||
|
|
||||||
if (alsoFetchContext) {
|
if (alsoFetchContext) {
|
||||||
dispatch(fetchContext(id));
|
dispatch(fetchContext({ statusId: id }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (skipLoading) {
|
if (skipLoading) {
|
||||||
|
@ -178,50 +177,6 @@ export function deleteStatusFail(id, error) {
|
||||||
export const updateStatus = status => dispatch =>
|
export const updateStatus = status => dispatch =>
|
||||||
dispatch(importFetchedStatus(status));
|
dispatch(importFetchedStatus(status));
|
||||||
|
|
||||||
export function fetchContext(id) {
|
|
||||||
return (dispatch) => {
|
|
||||||
dispatch(fetchContextRequest(id));
|
|
||||||
|
|
||||||
api().get(`/api/v1/statuses/${id}/context`).then(response => {
|
|
||||||
dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants)));
|
|
||||||
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
|
|
||||||
|
|
||||||
}).catch(error => {
|
|
||||||
if (error.response && error.response.status === 404) {
|
|
||||||
dispatch(deleteFromTimelines(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(fetchContextFail(id, error));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchContextRequest(id) {
|
|
||||||
return {
|
|
||||||
type: CONTEXT_FETCH_REQUEST,
|
|
||||||
id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchContextSuccess(id, ancestors, descendants) {
|
|
||||||
return {
|
|
||||||
type: CONTEXT_FETCH_SUCCESS,
|
|
||||||
id,
|
|
||||||
ancestors,
|
|
||||||
descendants,
|
|
||||||
statuses: ancestors.concat(descendants),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchContextFail(id, error) {
|
|
||||||
return {
|
|
||||||
type: CONTEXT_FETCH_FAIL,
|
|
||||||
id,
|
|
||||||
error,
|
|
||||||
skipAlert: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function muteStatus(id) {
|
export function muteStatus(id) {
|
||||||
return (dispatch) => {
|
return (dispatch) => {
|
||||||
dispatch(muteStatusRequest(id));
|
dispatch(muteStatusRequest(id));
|
||||||
|
|
18
app/javascript/mastodon/actions/statuses_typed.ts
Normal file
18
app/javascript/mastodon/actions/statuses_typed.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { apiGetContext } from 'mastodon/api/statuses';
|
||||||
|
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
|
import { importFetchedStatuses } from './importer';
|
||||||
|
|
||||||
|
export const fetchContext = createDataLoadingThunk(
|
||||||
|
'status/context',
|
||||||
|
({ statusId }: { statusId: string }) => apiGetContext(statusId),
|
||||||
|
(context, { dispatch }) => {
|
||||||
|
const statuses = context.ancestors.concat(context.descendants);
|
||||||
|
|
||||||
|
dispatch(importFetchedStatuses(statuses));
|
||||||
|
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
5
app/javascript/mastodon/api/statuses.ts
Normal file
5
app/javascript/mastodon/api/statuses.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import { apiRequestGet } from 'mastodon/api';
|
||||||
|
import type { ApiContextJSON } from 'mastodon/api_types/statuses';
|
||||||
|
|
||||||
|
export const apiGetContext = (statusId: string) =>
|
||||||
|
apiRequestGet<ApiContextJSON>(`v1/statuses/${statusId}/context`);
|
|
@ -119,3 +119,8 @@ export interface ApiStatusJSON {
|
||||||
card?: ApiPreviewCardJSON;
|
card?: ApiPreviewCardJSON;
|
||||||
poll?: ApiPollJSON;
|
poll?: ApiPollJSON;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiContextJSON {
|
||||||
|
ancestors: ApiStatusJSON[];
|
||||||
|
descendants: ApiStatusJSON[];
|
||||||
|
}
|
||||||
|
|
|
@ -39,9 +39,9 @@ export const NotificationMention: React.FC<{
|
||||||
unread: boolean;
|
unread: boolean;
|
||||||
}> = ({ notification, unread }) => {
|
}> = ({ notification, unread }) => {
|
||||||
const [isDirect, isReply] = useAppSelector((state) => {
|
const [isDirect, isReply] = useAppSelector((state) => {
|
||||||
const status = state.statuses.get(notification.statusId) as
|
const status = notification.statusId
|
||||||
| Status
|
? (state.statuses.get(notification.statusId) as Status | undefined)
|
||||||
| undefined;
|
: undefined;
|
||||||
|
|
||||||
if (!status) return [false, false] as const;
|
if (!status) return [false, false] as const;
|
||||||
|
|
||||||
|
|
|
@ -65,6 +65,7 @@ import { textForScreenReader, defaultMediaVisibility } from '../../components/st
|
||||||
import StatusContainer from '../../containers/status_container';
|
import StatusContainer from '../../containers/status_container';
|
||||||
import { deleteModal } from '../../initial_state';
|
import { deleteModal } from '../../initial_state';
|
||||||
import { makeGetStatus, makeGetPictureInPicture } from '../../selectors';
|
import { makeGetStatus, makeGetPictureInPicture } from '../../selectors';
|
||||||
|
import { getAncestorsIds, getDescendantsIds } from 'mastodon/selectors/contexts';
|
||||||
import Column from '../ui/components/column';
|
import Column from '../ui/components/column';
|
||||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
|
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
|
||||||
|
|
||||||
|
@ -83,69 +84,15 @@ const makeMapStateToProps = () => {
|
||||||
const getStatus = makeGetStatus();
|
const getStatus = makeGetStatus();
|
||||||
const getPictureInPicture = makeGetPictureInPicture();
|
const getPictureInPicture = makeGetPictureInPicture();
|
||||||
|
|
||||||
const getAncestorsIds = createSelector([
|
|
||||||
(_, { id }) => id,
|
|
||||||
state => state.getIn(['contexts', 'inReplyTos']),
|
|
||||||
], (statusId, inReplyTos) => {
|
|
||||||
let ancestorsIds = ImmutableList();
|
|
||||||
ancestorsIds = ancestorsIds.withMutations(mutable => {
|
|
||||||
let id = statusId;
|
|
||||||
|
|
||||||
while (id && !mutable.includes(id)) {
|
|
||||||
mutable.unshift(id);
|
|
||||||
id = inReplyTos.get(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return ancestorsIds;
|
|
||||||
});
|
|
||||||
|
|
||||||
const getDescendantsIds = createSelector([
|
|
||||||
(_, { id }) => id,
|
|
||||||
state => state.getIn(['contexts', 'replies']),
|
|
||||||
state => state.get('statuses'),
|
|
||||||
], (statusId, contextReplies, statuses) => {
|
|
||||||
let descendantsIds = [];
|
|
||||||
const ids = [statusId];
|
|
||||||
|
|
||||||
while (ids.length > 0) {
|
|
||||||
let id = ids.pop();
|
|
||||||
const replies = contextReplies.get(id);
|
|
||||||
|
|
||||||
if (statusId !== id) {
|
|
||||||
descendantsIds.push(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replies) {
|
|
||||||
replies.reverse().forEach(reply => {
|
|
||||||
if (!ids.includes(reply) && !descendantsIds.includes(reply) && statusId !== reply) ids.push(reply);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account'));
|
|
||||||
if (insertAt !== -1) {
|
|
||||||
descendantsIds.forEach((id, idx) => {
|
|
||||||
if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) {
|
|
||||||
descendantsIds.splice(idx, 1);
|
|
||||||
descendantsIds.splice(insertAt, 0, id);
|
|
||||||
insertAt += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return ImmutableList(descendantsIds);
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => {
|
const mapStateToProps = (state, props) => {
|
||||||
const status = getStatus(state, { id: props.params.statusId, contextType: 'detailed' });
|
const status = getStatus(state, { id: props.params.statusId, contextType: 'detailed' });
|
||||||
|
|
||||||
let ancestorsIds = ImmutableList();
|
let ancestorsIds = [];
|
||||||
let descendantsIds = ImmutableList();
|
let descendantsIds = [];
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
|
ancestorsIds = getAncestorsIds(state, status.get('in_reply_to_id'));
|
||||||
descendantsIds = getDescendantsIds(state, { id: status.get('id') });
|
descendantsIds = getDescendantsIds(state, status.get('id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -188,8 +135,8 @@ class Status extends ImmutablePureComponent {
|
||||||
dispatch: PropTypes.func.isRequired,
|
dispatch: PropTypes.func.isRequired,
|
||||||
status: ImmutablePropTypes.map,
|
status: ImmutablePropTypes.map,
|
||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
ancestorsIds: ImmutablePropTypes.list.isRequired,
|
ancestorsIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||||
descendantsIds: ImmutablePropTypes.list.isRequired,
|
descendantsIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
askReplyConfirmation: PropTypes.bool,
|
askReplyConfirmation: PropTypes.bool,
|
||||||
multiColumn: PropTypes.bool,
|
multiColumn: PropTypes.bool,
|
||||||
|
@ -383,7 +330,7 @@ class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
handleToggleAll = () => {
|
handleToggleAll = () => {
|
||||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||||
const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
|
const statusIds = [status.get('id')].concat(ancestorsIds, descendantsIds);
|
||||||
|
|
||||||
if (status.get('hidden')) {
|
if (status.get('hidden')) {
|
||||||
this.props.dispatch(revealStatus(statusIds));
|
this.props.dispatch(revealStatus(statusIds));
|
||||||
|
@ -482,13 +429,13 @@ class Status extends ImmutablePureComponent {
|
||||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||||
|
|
||||||
if (id === status.get('id')) {
|
if (id === status.get('id')) {
|
||||||
this._selectChild(ancestorsIds.size - 1, true);
|
this._selectChild(ancestorsIds.length - 1, true);
|
||||||
} else {
|
} else {
|
||||||
let index = ancestorsIds.indexOf(id);
|
let index = ancestorsIds.indexOf(id);
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
index = descendantsIds.indexOf(id);
|
index = descendantsIds.indexOf(id);
|
||||||
this._selectChild(ancestorsIds.size + index, true);
|
this._selectChild(ancestorsIds.length + index, true);
|
||||||
} else {
|
} else {
|
||||||
this._selectChild(index - 1, true);
|
this._selectChild(index - 1, true);
|
||||||
}
|
}
|
||||||
|
@ -499,13 +446,13 @@ class Status extends ImmutablePureComponent {
|
||||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||||
|
|
||||||
if (id === status.get('id')) {
|
if (id === status.get('id')) {
|
||||||
this._selectChild(ancestorsIds.size + 1, false);
|
this._selectChild(ancestorsIds.length + 1, false);
|
||||||
} else {
|
} else {
|
||||||
let index = ancestorsIds.indexOf(id);
|
let index = ancestorsIds.indexOf(id);
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
index = descendantsIds.indexOf(id);
|
index = descendantsIds.indexOf(id);
|
||||||
this._selectChild(ancestorsIds.size + index + 2, false);
|
this._selectChild(ancestorsIds.length + index + 2, false);
|
||||||
} else {
|
} else {
|
||||||
this._selectChild(index + 1, false);
|
this._selectChild(index + 1, false);
|
||||||
}
|
}
|
||||||
|
@ -536,8 +483,8 @@ class Status extends ImmutablePureComponent {
|
||||||
onMoveUp={this.handleMoveUp}
|
onMoveUp={this.handleMoveUp}
|
||||||
onMoveDown={this.handleMoveDown}
|
onMoveDown={this.handleMoveDown}
|
||||||
contextType='thread'
|
contextType='thread'
|
||||||
previousId={i > 0 ? list.get(i - 1) : undefined}
|
previousId={i > 0 ? list[i - 1] : undefined}
|
||||||
nextId={list.get(i + 1) || (ancestors && statusId)}
|
nextId={list[i + 1] || (ancestors && statusId)}
|
||||||
rootId={statusId}
|
rootId={statusId}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
@ -574,7 +521,7 @@ class Status extends ImmutablePureComponent {
|
||||||
componentDidUpdate (prevProps) {
|
componentDidUpdate (prevProps) {
|
||||||
const { status, ancestorsIds } = this.props;
|
const { status, ancestorsIds } = this.props;
|
||||||
|
|
||||||
if (status && (ancestorsIds.size > prevProps.ancestorsIds.size || prevProps.status?.get('id') !== status.get('id'))) {
|
if (status && (ancestorsIds.length > prevProps.ancestorsIds.length || prevProps.status?.get('id') !== status.get('id'))) {
|
||||||
this._scrollStatusIntoView();
|
this._scrollStatusIntoView();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -621,11 +568,11 @@ class Status extends ImmutablePureComponent {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ancestorsIds && ancestorsIds.size > 0) {
|
if (ancestorsIds && ancestorsIds.length > 0) {
|
||||||
ancestors = <>{this.renderChildren(ancestorsIds, true)}</>;
|
ancestors = <>{this.renderChildren(ancestorsIds, true)}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (descendantsIds && descendantsIds.size > 0) {
|
if (descendantsIds && descendantsIds.length > 0) {
|
||||||
descendants = <>{this.renderChildren(descendantsIds)}</>;
|
descendants = <>{this.renderChildren(descendantsIds)}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,109 +0,0 @@
|
||||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
|
||||||
|
|
||||||
import { timelineDelete } from 'mastodon/actions/timelines_typed';
|
|
||||||
|
|
||||||
import {
|
|
||||||
blockAccountSuccess,
|
|
||||||
muteAccountSuccess,
|
|
||||||
} from '../actions/accounts';
|
|
||||||
import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses';
|
|
||||||
import { TIMELINE_UPDATE } from '../actions/timelines';
|
|
||||||
import { compareId } from '../compare_id';
|
|
||||||
|
|
||||||
const initialState = ImmutableMap({
|
|
||||||
inReplyTos: 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 && !inReplyTos.has(id)) {
|
|
||||||
|
|
||||||
replies.update(in_reply_to_id, ImmutableList(), siblings => {
|
|
||||||
const index = siblings.findLastIndex(sibling => compareId(sibling, id) < 0);
|
|
||||||
return siblings.insert(index + 1, id);
|
|
||||||
});
|
|
||||||
|
|
||||||
inReplyTos.set(id, in_reply_to_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We know in_reply_to_id of statuses but `id` itself.
|
|
||||||
// So we assume that the status of the id replies to last ancestors.
|
|
||||||
|
|
||||||
ancestors.forEach(addReply);
|
|
||||||
|
|
||||||
if (ancestors[0]) {
|
|
||||||
addReply({ id, in_reply_to_id: ancestors[ancestors.length - 1].id });
|
|
||||||
}
|
|
||||||
|
|
||||||
descendants.forEach(addReply);
|
|
||||||
}));
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
if (siblings) {
|
|
||||||
replies.set(inReplyToIdOfId, siblings.filterNot(sibling => sibling === id));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (repliesOfId) {
|
|
||||||
repliesOfId.forEach(reply => inReplyTos.delete(reply));
|
|
||||||
}
|
|
||||||
|
|
||||||
inReplyTos.delete(id);
|
|
||||||
replies.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) => {
|
|
||||||
if (status.in_reply_to_id) {
|
|
||||||
return state.withMutations(mutable => {
|
|
||||||
const replies = mutable.getIn(['replies', status.in_reply_to_id], ImmutableList());
|
|
||||||
|
|
||||||
mutable.setIn(['inReplyTos', status.id], status.in_reply_to_id);
|
|
||||||
|
|
||||||
if (!replies.includes(status.id)) {
|
|
||||||
mutable.setIn(['replies', status.in_reply_to_id], replies.push(status.id));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return state;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function replies(state = initialState, action) {
|
|
||||||
switch(action.type) {
|
|
||||||
case blockAccountSuccess.type:
|
|
||||||
case muteAccountSuccess.type:
|
|
||||||
return filterContexts(state, action.payload.relationship, action.payload.statuses);
|
|
||||||
case CONTEXT_FETCH_SUCCESS:
|
|
||||||
return normalizeContext(state, action.id, action.ancestors, action.descendants);
|
|
||||||
case timelineDelete.type:
|
|
||||||
return deleteFromContexts(state, [action.payload.statusId]);
|
|
||||||
case TIMELINE_UPDATE:
|
|
||||||
return updateContext(state, action.status);
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
155
app/javascript/mastodon/reducers/contexts.ts
Normal file
155
app/javascript/mastodon/reducers/contexts.ts
Normal file
|
@ -0,0 +1,155 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-dynamic-delete */
|
||||||
|
import { createReducer } from '@reduxjs/toolkit';
|
||||||
|
import type { Draft, UnknownAction } from '@reduxjs/toolkit';
|
||||||
|
import type { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
|
import { timelineDelete } from 'mastodon/actions/timelines_typed';
|
||||||
|
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
|
||||||
|
import type {
|
||||||
|
ApiStatusJSON,
|
||||||
|
ApiContextJSON,
|
||||||
|
} from 'mastodon/api_types/statuses';
|
||||||
|
import type { Status } from 'mastodon/models/status';
|
||||||
|
|
||||||
|
import { blockAccountSuccess, muteAccountSuccess } from '../actions/accounts';
|
||||||
|
import { fetchContext } from '../actions/statuses';
|
||||||
|
import { TIMELINE_UPDATE } from '../actions/timelines';
|
||||||
|
import { compareId } from '../compare_id';
|
||||||
|
|
||||||
|
interface TimelineUpdateAction extends UnknownAction {
|
||||||
|
timeline: string;
|
||||||
|
status: ApiStatusJSON;
|
||||||
|
usePendingItems: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
inReplyTos: Record<string, string>;
|
||||||
|
replies: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: State = {
|
||||||
|
inReplyTos: {},
|
||||||
|
replies: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeContext = (
|
||||||
|
state: Draft<State>,
|
||||||
|
id: string,
|
||||||
|
{ ancestors, descendants }: ApiContextJSON,
|
||||||
|
): void => {
|
||||||
|
const addReply = ({
|
||||||
|
id,
|
||||||
|
in_reply_to_id,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
in_reply_to_id?: string;
|
||||||
|
}) => {
|
||||||
|
if (!in_reply_to_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.inReplyTos[id]) {
|
||||||
|
const siblings = (state.replies[in_reply_to_id] ??= []);
|
||||||
|
const index = siblings.findIndex((sibling) => compareId(sibling, id) < 0);
|
||||||
|
siblings.splice(index + 1, 0, id);
|
||||||
|
state.inReplyTos[id] = in_reply_to_id;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// We know in_reply_to_id of statuses but `id` itself.
|
||||||
|
// So we assume that the status of the id replies to last ancestors.
|
||||||
|
|
||||||
|
ancestors.forEach(addReply);
|
||||||
|
|
||||||
|
if (ancestors[0]) {
|
||||||
|
addReply({
|
||||||
|
id,
|
||||||
|
in_reply_to_id: ancestors[ancestors.length - 1]?.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
descendants.forEach(addReply);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFromContexts = (state: Draft<State>, ids: string[]): void => {
|
||||||
|
ids.forEach((id) => {
|
||||||
|
const inReplyToIdOfId = state.inReplyTos[id];
|
||||||
|
const repliesOfId = state.replies[id];
|
||||||
|
|
||||||
|
if (inReplyToIdOfId) {
|
||||||
|
const siblings = state.replies[inReplyToIdOfId];
|
||||||
|
|
||||||
|
if (siblings) {
|
||||||
|
state.replies[inReplyToIdOfId] = siblings.filter(
|
||||||
|
(sibling) => sibling !== id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repliesOfId) {
|
||||||
|
repliesOfId.forEach((reply) => {
|
||||||
|
delete state.inReplyTos[reply];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete state.inReplyTos[id];
|
||||||
|
delete state.replies[id];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterContexts = (
|
||||||
|
state: Draft<State>,
|
||||||
|
relationship: ApiRelationshipJSON,
|
||||||
|
statuses: ImmutableList<Status>,
|
||||||
|
): void => {
|
||||||
|
const ownedStatusIds = statuses
|
||||||
|
.filter((status) => (status.get('account') as string) === relationship.id)
|
||||||
|
.map((status) => status.get('id') as string);
|
||||||
|
|
||||||
|
deleteFromContexts(state, ownedStatusIds.toArray());
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateContext = (state: Draft<State>, status: ApiStatusJSON): void => {
|
||||||
|
if (!status.in_reply_to_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const siblings = (state.replies[status.in_reply_to_id] ??= []);
|
||||||
|
|
||||||
|
state.inReplyTos[status.id] = status.in_reply_to_id;
|
||||||
|
|
||||||
|
if (!siblings.includes(status.id)) {
|
||||||
|
siblings.push(status.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const contextsReducer = createReducer(initialState, (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchContext.fulfilled, (state, action) => {
|
||||||
|
normalizeContext(state, action.meta.arg.statusId, action.payload.context);
|
||||||
|
})
|
||||||
|
.addCase(blockAccountSuccess, (state, action) => {
|
||||||
|
filterContexts(
|
||||||
|
state,
|
||||||
|
action.payload.relationship,
|
||||||
|
action.payload.statuses as ImmutableList<Status>,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.addCase(muteAccountSuccess, (state, action) => {
|
||||||
|
filterContexts(
|
||||||
|
state,
|
||||||
|
action.payload.relationship,
|
||||||
|
action.payload.statuses as ImmutableList<Status>,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.addCase(timelineDelete, (state, action) => {
|
||||||
|
deleteFromContexts(state, [action.payload.statusId]);
|
||||||
|
})
|
||||||
|
.addMatcher(
|
||||||
|
(action: UnknownAction): action is TimelineUpdateAction =>
|
||||||
|
action.type === TIMELINE_UPDATE,
|
||||||
|
(state, action) => {
|
||||||
|
updateContext(state, action.status);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
|
@ -8,7 +8,7 @@ import accounts_map from './accounts_map';
|
||||||
import { alertsReducer } from './alerts';
|
import { alertsReducer } from './alerts';
|
||||||
import announcements from './announcements';
|
import announcements from './announcements';
|
||||||
import { composeReducer } from './compose';
|
import { composeReducer } from './compose';
|
||||||
import contexts from './contexts';
|
import { contextsReducer } from './contexts';
|
||||||
import conversations from './conversations';
|
import conversations from './conversations';
|
||||||
import custom_emojis from './custom_emojis';
|
import custom_emojis from './custom_emojis';
|
||||||
import { dropdownMenuReducer } from './dropdown_menu';
|
import { dropdownMenuReducer } from './dropdown_menu';
|
||||||
|
@ -55,7 +55,7 @@ const reducers = {
|
||||||
settings,
|
settings,
|
||||||
push_notifications,
|
push_notifications,
|
||||||
server,
|
server,
|
||||||
contexts,
|
contexts: contextsReducer,
|
||||||
compose: composeReducer,
|
compose: composeReducer,
|
||||||
search: searchReducer,
|
search: searchReducer,
|
||||||
media_attachments,
|
media_attachments,
|
||||||
|
|
|
@ -64,6 +64,7 @@ const statusTranslateUndo = (state, id) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @type {ImmutableMap<string, ImmutableMap<string, any>>} */
|
||||||
const initialState = ImmutableMap();
|
const initialState = ImmutableMap();
|
||||||
|
|
||||||
/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */
|
/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */
|
||||||
|
|
94
app/javascript/mastodon/selectors/contexts.ts
Normal file
94
app/javascript/mastodon/selectors/contexts.ts
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import { createAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
|
export const getAncestorsIds = createAppSelector(
|
||||||
|
[(_, id: string) => id, (state) => state.contexts.inReplyTos],
|
||||||
|
(statusId, inReplyTos) => {
|
||||||
|
const ancestorsIds: string[] = [];
|
||||||
|
|
||||||
|
let currentId: string | undefined = statusId;
|
||||||
|
|
||||||
|
while (currentId && !ancestorsIds.includes(currentId)) {
|
||||||
|
ancestorsIds.unshift(currentId);
|
||||||
|
currentId = inReplyTos[currentId];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ancestorsIds;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getDescendantsIds = createAppSelector(
|
||||||
|
[
|
||||||
|
(_, id: string) => id,
|
||||||
|
(state) => state.contexts.replies,
|
||||||
|
(state) => state.statuses,
|
||||||
|
],
|
||||||
|
(statusId, contextReplies, statuses) => {
|
||||||
|
const descendantsIds: string[] = [];
|
||||||
|
|
||||||
|
const visitIds = [statusId];
|
||||||
|
|
||||||
|
while (visitIds.length > 0) {
|
||||||
|
const id = visitIds.pop();
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replies = contextReplies[id];
|
||||||
|
|
||||||
|
if (statusId !== id) {
|
||||||
|
descendantsIds.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replies) {
|
||||||
|
replies.reverse().forEach((replyId) => {
|
||||||
|
if (
|
||||||
|
!visitIds.includes(replyId) &&
|
||||||
|
!descendantsIds.includes(replyId) &&
|
||||||
|
statusId !== replyId
|
||||||
|
) {
|
||||||
|
visitIds.push(replyId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let insertAt = descendantsIds.findIndex((id) => {
|
||||||
|
const status = statuses.get(id);
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inReplyToAccountId = status.get('in_reply_to_account_id') as
|
||||||
|
| string
|
||||||
|
| null;
|
||||||
|
const accountId = status.get('account') as string;
|
||||||
|
|
||||||
|
return inReplyToAccountId !== accountId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (insertAt !== -1) {
|
||||||
|
descendantsIds.forEach((id, idx) => {
|
||||||
|
const status = statuses.get(id);
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inReplyToAccountId = status.get('in_reply_to_account_id') as
|
||||||
|
| string
|
||||||
|
| null;
|
||||||
|
const accountId = status.get('account') as string;
|
||||||
|
|
||||||
|
if (idx > insertAt && inReplyToAccountId === accountId) {
|
||||||
|
descendantsIds.splice(idx, 1);
|
||||||
|
descendantsIds.splice(insertAt, 0, id);
|
||||||
|
insertAt += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return descendantsIds;
|
||||||
|
},
|
||||||
|
);
|
|
@ -3,6 +3,7 @@ export type { GetState, AppDispatch, RootState } from './store';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createAppAsyncThunk,
|
createAppAsyncThunk,
|
||||||
|
createAppSelector,
|
||||||
useAppDispatch,
|
useAppDispatch,
|
||||||
useAppSelector,
|
useAppSelector,
|
||||||
} from './typed_functions';
|
} from './typed_functions';
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { GetThunkAPI } from '@reduxjs/toolkit';
|
import type { GetThunkAPI } from '@reduxjs/toolkit';
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk, createSelector } from '@reduxjs/toolkit';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
@ -24,6 +24,8 @@ export const createAppAsyncThunk = createAsyncThunk.withTypes<{
|
||||||
rejectValue: AsyncThunkRejectValue;
|
rejectValue: AsyncThunkRejectValue;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
export const createAppSelector = createSelector.withTypes<RootState>();
|
||||||
|
|
||||||
interface AppThunkConfig {
|
interface AppThunkConfig {
|
||||||
state: RootState;
|
state: RootState;
|
||||||
dispatch: AppDispatch;
|
dispatch: AppDispatch;
|
||||||
|
|
Loading…
Reference in New Issue
Block a user