Refactor context reducer to TypeScript (#34506)
This commit is contained in:
		@@ -4,8 +4,11 @@ import api from '../api';
 | 
			
		||||
 | 
			
		||||
import { ensureComposeIsVisible, setComposeToStatus } from './compose';
 | 
			
		||||
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
 | 
			
		||||
import { fetchContext } from './statuses_typed';
 | 
			
		||||
import { deleteFromTimelines } from './timelines';
 | 
			
		||||
 | 
			
		||||
export * from './statuses_typed';
 | 
			
		||||
 | 
			
		||||
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
 | 
			
		||||
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
 | 
			
		||||
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_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_SUCCESS = 'STATUS_MUTE_SUCCESS';
 | 
			
		||||
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;
 | 
			
		||||
 | 
			
		||||
    if (alsoFetchContext) {
 | 
			
		||||
      dispatch(fetchContext(id));
 | 
			
		||||
      dispatch(fetchContext({ statusId: id }));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (skipLoading) {
 | 
			
		||||
@@ -178,50 +177,6 @@ export function deleteStatusFail(id, error) {
 | 
			
		||||
export const updateStatus = status => dispatch =>
 | 
			
		||||
  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) {
 | 
			
		||||
  return (dispatch) => {
 | 
			
		||||
    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;
 | 
			
		||||
  poll?: ApiPollJSON;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ApiContextJSON {
 | 
			
		||||
  ancestors: ApiStatusJSON[];
 | 
			
		||||
  descendants: ApiStatusJSON[];
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -39,9 +39,9 @@ export const NotificationMention: React.FC<{
 | 
			
		||||
  unread: boolean;
 | 
			
		||||
}> = ({ notification, unread }) => {
 | 
			
		||||
  const [isDirect, isReply] = useAppSelector((state) => {
 | 
			
		||||
    const status = state.statuses.get(notification.statusId) as
 | 
			
		||||
      | Status
 | 
			
		||||
      | undefined;
 | 
			
		||||
    const status = notification.statusId
 | 
			
		||||
      ? (state.statuses.get(notification.statusId) as Status | undefined)
 | 
			
		||||
      : undefined;
 | 
			
		||||
 | 
			
		||||
    if (!status) return [false, false] as const;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -65,6 +65,7 @@ import { textForScreenReader, defaultMediaVisibility } from '../../components/st
 | 
			
		||||
import StatusContainer from '../../containers/status_container';
 | 
			
		||||
import { deleteModal } from '../../initial_state';
 | 
			
		||||
import { makeGetStatus, makeGetPictureInPicture } from '../../selectors';
 | 
			
		||||
import { getAncestorsIds, getDescendantsIds } from 'mastodon/selectors/contexts';
 | 
			
		||||
import Column from '../ui/components/column';
 | 
			
		||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
 | 
			
		||||
 | 
			
		||||
@@ -83,69 +84,15 @@ const makeMapStateToProps = () => {
 | 
			
		||||
  const getStatus = makeGetStatus();
 | 
			
		||||
  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 status = getStatus(state, { id: props.params.statusId, contextType: 'detailed' });
 | 
			
		||||
 | 
			
		||||
    let ancestorsIds   = ImmutableList();
 | 
			
		||||
    let descendantsIds = ImmutableList();
 | 
			
		||||
    let ancestorsIds   = [];
 | 
			
		||||
    let descendantsIds = [];
 | 
			
		||||
 | 
			
		||||
    if (status) {
 | 
			
		||||
      ancestorsIds   = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
 | 
			
		||||
      descendantsIds = getDescendantsIds(state, { id: status.get('id') });
 | 
			
		||||
      ancestorsIds   = getAncestorsIds(state, status.get('in_reply_to_id'));
 | 
			
		||||
      descendantsIds = getDescendantsIds(state, status.get('id'));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
@@ -188,8 +135,8 @@ class Status extends ImmutablePureComponent {
 | 
			
		||||
    dispatch: PropTypes.func.isRequired,
 | 
			
		||||
    status: ImmutablePropTypes.map,
 | 
			
		||||
    isLoading: PropTypes.bool,
 | 
			
		||||
    ancestorsIds: ImmutablePropTypes.list.isRequired,
 | 
			
		||||
    descendantsIds: ImmutablePropTypes.list.isRequired,
 | 
			
		||||
    ancestorsIds: PropTypes.arrayOf(PropTypes.string).isRequired,
 | 
			
		||||
    descendantsIds: PropTypes.arrayOf(PropTypes.string).isRequired,
 | 
			
		||||
    intl: PropTypes.object.isRequired,
 | 
			
		||||
    askReplyConfirmation: PropTypes.bool,
 | 
			
		||||
    multiColumn: PropTypes.bool,
 | 
			
		||||
@@ -383,7 +330,7 @@ class Status extends ImmutablePureComponent {
 | 
			
		||||
 | 
			
		||||
  handleToggleAll = () => {
 | 
			
		||||
    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')) {
 | 
			
		||||
      this.props.dispatch(revealStatus(statusIds));
 | 
			
		||||
@@ -482,13 +429,13 @@ class Status extends ImmutablePureComponent {
 | 
			
		||||
    const { status, ancestorsIds, descendantsIds } = this.props;
 | 
			
		||||
 | 
			
		||||
    if (id === status.get('id')) {
 | 
			
		||||
      this._selectChild(ancestorsIds.size - 1, true);
 | 
			
		||||
      this._selectChild(ancestorsIds.length - 1, true);
 | 
			
		||||
    } else {
 | 
			
		||||
      let index = ancestorsIds.indexOf(id);
 | 
			
		||||
 | 
			
		||||
      if (index === -1) {
 | 
			
		||||
        index = descendantsIds.indexOf(id);
 | 
			
		||||
        this._selectChild(ancestorsIds.size + index, true);
 | 
			
		||||
        this._selectChild(ancestorsIds.length + index, true);
 | 
			
		||||
      } else {
 | 
			
		||||
        this._selectChild(index - 1, true);
 | 
			
		||||
      }
 | 
			
		||||
@@ -499,13 +446,13 @@ class Status extends ImmutablePureComponent {
 | 
			
		||||
    const { status, ancestorsIds, descendantsIds } = this.props;
 | 
			
		||||
 | 
			
		||||
    if (id === status.get('id')) {
 | 
			
		||||
      this._selectChild(ancestorsIds.size + 1, false);
 | 
			
		||||
      this._selectChild(ancestorsIds.length + 1, false);
 | 
			
		||||
    } else {
 | 
			
		||||
      let index = ancestorsIds.indexOf(id);
 | 
			
		||||
 | 
			
		||||
      if (index === -1) {
 | 
			
		||||
        index = descendantsIds.indexOf(id);
 | 
			
		||||
        this._selectChild(ancestorsIds.size + index + 2, false);
 | 
			
		||||
        this._selectChild(ancestorsIds.length + index + 2, false);
 | 
			
		||||
      } else {
 | 
			
		||||
        this._selectChild(index + 1, false);
 | 
			
		||||
      }
 | 
			
		||||
@@ -536,8 +483,8 @@ class Status extends ImmutablePureComponent {
 | 
			
		||||
        onMoveUp={this.handleMoveUp}
 | 
			
		||||
        onMoveDown={this.handleMoveDown}
 | 
			
		||||
        contextType='thread'
 | 
			
		||||
        previousId={i > 0 ? list.get(i - 1) : undefined}
 | 
			
		||||
        nextId={list.get(i + 1) || (ancestors && statusId)}
 | 
			
		||||
        previousId={i > 0 ? list[i - 1] : undefined}
 | 
			
		||||
        nextId={list[i + 1] || (ancestors && statusId)}
 | 
			
		||||
        rootId={statusId}
 | 
			
		||||
      />
 | 
			
		||||
    ));
 | 
			
		||||
@@ -574,7 +521,7 @@ class Status extends ImmutablePureComponent {
 | 
			
		||||
  componentDidUpdate (prevProps) {
 | 
			
		||||
    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();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -621,11 +568,11 @@ class Status extends ImmutablePureComponent {
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (ancestorsIds && ancestorsIds.size > 0) {
 | 
			
		||||
    if (ancestorsIds && ancestorsIds.length > 0) {
 | 
			
		||||
      ancestors = <>{this.renderChildren(ancestorsIds, true)}</>;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (descendantsIds && descendantsIds.size > 0) {
 | 
			
		||||
    if (descendantsIds && descendantsIds.length > 0) {
 | 
			
		||||
      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 announcements from './announcements';
 | 
			
		||||
import { composeReducer } from './compose';
 | 
			
		||||
import contexts from './contexts';
 | 
			
		||||
import { contextsReducer } from './contexts';
 | 
			
		||||
import conversations from './conversations';
 | 
			
		||||
import custom_emojis from './custom_emojis';
 | 
			
		||||
import { dropdownMenuReducer } from './dropdown_menu';
 | 
			
		||||
@@ -55,7 +55,7 @@ const reducers = {
 | 
			
		||||
  settings,
 | 
			
		||||
  push_notifications,
 | 
			
		||||
  server,
 | 
			
		||||
  contexts,
 | 
			
		||||
  contexts: contextsReducer,
 | 
			
		||||
  compose: composeReducer,
 | 
			
		||||
  search: searchReducer,
 | 
			
		||||
  media_attachments,
 | 
			
		||||
 
 | 
			
		||||
@@ -64,6 +64,7 @@ const statusTranslateUndo = (state, id) => {
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/** @type {ImmutableMap<string, ImmutableMap<string, any>>} */
 | 
			
		||||
const initialState = ImmutableMap();
 | 
			
		||||
 | 
			
		||||
/** @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 {
 | 
			
		||||
  createAppAsyncThunk,
 | 
			
		||||
  createAppSelector,
 | 
			
		||||
  useAppDispatch,
 | 
			
		||||
  useAppSelector,
 | 
			
		||||
} from './typed_functions';
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
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
 | 
			
		||||
import { useDispatch, useSelector } from 'react-redux';
 | 
			
		||||
 | 
			
		||||
@@ -24,6 +24,8 @@ export const createAppAsyncThunk = createAsyncThunk.withTypes<{
 | 
			
		||||
  rejectValue: AsyncThunkRejectValue;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
export const createAppSelector = createSelector.withTypes<RootState>();
 | 
			
		||||
 | 
			
		||||
interface AppThunkConfig {
 | 
			
		||||
  state: RootState;
 | 
			
		||||
  dispatch: AppDispatch;
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user