Contexts reducer: convert to TypeScript
This commit is contained in:
parent
02726cfcc3
commit
2290bfb334
|
@ -1,189 +0,0 @@
|
|||
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
|
||||
import { STATUS_IMPORT, STATUSES_IMPORT } from 'soapbox/actions/importer';
|
||||
|
||||
import {
|
||||
ACCOUNT_BLOCK_SUCCESS,
|
||||
ACCOUNT_MUTE_SUCCESS,
|
||||
} from '../actions/accounts';
|
||||
import {
|
||||
STATUS_CREATE_REQUEST,
|
||||
STATUS_CREATE_SUCCESS,
|
||||
} from '../actions/statuses';
|
||||
import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses';
|
||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
inReplyTos: ImmutableMap(),
|
||||
replies: ImmutableMap(),
|
||||
});
|
||||
|
||||
const importStatus = (state, status, idempotencyKey) => {
|
||||
const { id, in_reply_to_id } = status;
|
||||
if (!in_reply_to_id) return state;
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
if (idempotencyKey) {
|
||||
deletePendingStatus(state, status, idempotencyKey);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const importStatuses = (state, statuses) => {
|
||||
return state.withMutations(state => {
|
||||
statuses.forEach(status => importStatus(state, status));
|
||||
});
|
||||
};
|
||||
|
||||
const isReplyTo = (state, childId, parentId, initialId = null) => {
|
||||
if (!childId) return false;
|
||||
|
||||
// Prevent cycles
|
||||
if (childId === initialId) return false;
|
||||
initialId = initialId || childId;
|
||||
|
||||
if (childId === parentId) {
|
||||
return true;
|
||||
} else {
|
||||
const nextId = state.getIn(['inReplyTos', childId]);
|
||||
return isReplyTo(state, nextId, parentId, initialId);
|
||||
}
|
||||
};
|
||||
|
||||
const insertTombstone = (state, ancestorId, descendantId) => {
|
||||
// Prevent infinite loop if the API returns a bogus response
|
||||
if (isReplyTo(state, ancestorId, descendantId)) return state;
|
||||
|
||||
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 });
|
||||
});
|
||||
};
|
||||
|
||||
/** Find the highest level status from this statusId. */
|
||||
const getRootNode = (state, statusId, initialId = statusId) => {
|
||||
const parent = state.getIn(['inReplyTos', statusId]);
|
||||
|
||||
if (!parent) {
|
||||
return statusId;
|
||||
} else if (parent === initialId) {
|
||||
// Prevent cycles
|
||||
return parent;
|
||||
} else {
|
||||
return getRootNode(state, parent, initialId);
|
||||
}
|
||||
};
|
||||
|
||||
/** Route fromId to toId by inserting tombstones. */
|
||||
const connectNodes = (state, fromId, toId) => {
|
||||
const root = getRootNode(state, fromId);
|
||||
|
||||
if (root !== toId) {
|
||||
return insertTombstone(state, toId, fromId);
|
||||
} else {
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const importBranch = (state, statuses, statusId) => {
|
||||
return state.withMutations(state => {
|
||||
statuses.forEach((status, i) => {
|
||||
const prevId = statusId && i === 0 ? statusId : (statuses[i - 1] || {}).id;
|
||||
|
||||
if (status.in_reply_to_id) {
|
||||
importStatus(state, status);
|
||||
connectNodes(state, status.id, statusId);
|
||||
} else if (prevId) {
|
||||
insertTombstone(state, prevId, 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);
|
||||
}
|
||||
});
|
||||
|
||||
const deleteStatus = (state, id) => {
|
||||
return state.withMutations(state => {
|
||||
const parentId = state.getIn(['inReplyTos', id]);
|
||||
const replies = state.getIn(['replies', id], ImmutableOrderedSet());
|
||||
|
||||
// Delete from its parent's tree
|
||||
state.updateIn(['replies', parentId], ImmutableOrderedSet(), ids => ids.delete(id));
|
||||
|
||||
// Dereference children
|
||||
replies.forEach(reply => state.deleteIn(['inReplyTos', reply]));
|
||||
|
||||
state.deleteIn(['inReplyTos', id]);
|
||||
state.deleteIn(['replies', 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 deleteStatuses(state, ownedStatusIds);
|
||||
};
|
||||
|
||||
const importPendingStatus = (state, params, idempotencyKey) => {
|
||||
const id = `末pending-${idempotencyKey}`;
|
||||
const { in_reply_to_id } = params;
|
||||
return importStatus(state, { id, in_reply_to_id });
|
||||
};
|
||||
|
||||
const deletePendingStatus = (state, { in_reply_to_id }, idempotencyKey) => {
|
||||
const id = `末pending-${idempotencyKey}`;
|
||||
|
||||
return state.withMutations(state => {
|
||||
state.deleteIn(['inReplyTos', id]);
|
||||
|
||||
if (in_reply_to_id) {
|
||||
state.updateIn(['replies', in_reply_to_id], ImmutableOrderedSet(), ids => {
|
||||
return ids.delete(id).sort();
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default function replies(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case ACCOUNT_BLOCK_SUCCESS:
|
||||
case ACCOUNT_MUTE_SUCCESS:
|
||||
return filterContexts(state, action.relationship, action.statuses);
|
||||
case CONTEXT_FETCH_SUCCESS:
|
||||
return normalizeContext(state, action.id, action.ancestors, action.descendants);
|
||||
case TIMELINE_DELETE:
|
||||
return deleteStatuses(state, [action.id]);
|
||||
case STATUS_CREATE_REQUEST:
|
||||
return importPendingStatus(state, action.params, action.idempotencyKey);
|
||||
case STATUS_CREATE_SUCCESS:
|
||||
return deletePendingStatus(state, action.status, action.idempotencyKey);
|
||||
case STATUS_IMPORT:
|
||||
return importStatus(state, action.status, action.idempotencyKey);
|
||||
case STATUSES_IMPORT:
|
||||
return importStatuses(state, action.statuses);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,243 @@
|
|||
import {
|
||||
Map as ImmutableMap,
|
||||
Record as ImmutableRecord,
|
||||
OrderedSet as ImmutableOrderedSet,
|
||||
} from 'immutable';
|
||||
|
||||
import { STATUS_IMPORT, STATUSES_IMPORT } from 'soapbox/actions/importer';
|
||||
|
||||
import {
|
||||
ACCOUNT_BLOCK_SUCCESS,
|
||||
ACCOUNT_MUTE_SUCCESS,
|
||||
} from '../actions/accounts';
|
||||
import {
|
||||
STATUS_CREATE_REQUEST,
|
||||
STATUS_CREATE_SUCCESS,
|
||||
} from '../actions/statuses';
|
||||
import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses';
|
||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||
|
||||
import type { ReducerStatus } from './statuses';
|
||||
import type { AnyAction } from 'redux';
|
||||
|
||||
const ReducerRecord = ImmutableRecord({
|
||||
inReplyTos: ImmutableMap<string, string>(),
|
||||
replies: ImmutableMap<string, ImmutableOrderedSet<string>>(),
|
||||
});
|
||||
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
|
||||
/** Minimal status fields needed to process context. */
|
||||
type ContextStatus = {
|
||||
id: string,
|
||||
in_reply_to_id: string | null,
|
||||
}
|
||||
|
||||
/** Import a single status into the reducer, setting replies and replyTos. */
|
||||
const importStatus = (state: State, status: ContextStatus, idempotencyKey?: string): State => {
|
||||
const { id, in_reply_to_id: inReplyToId } = status;
|
||||
if (!inReplyToId) return state;
|
||||
|
||||
return state.withMutations(state => {
|
||||
const replies = state.replies.get(inReplyToId) || ImmutableOrderedSet();
|
||||
const newReplies = replies.add(id).sort();
|
||||
|
||||
state.setIn(['replies', inReplyToId], newReplies);
|
||||
state.setIn(['inReplyTos', id], inReplyToId);
|
||||
|
||||
if (idempotencyKey) {
|
||||
deletePendingStatus(state, status, idempotencyKey);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** Import multiple statuses into the state. */
|
||||
const importStatuses = (state: State, statuses: ContextStatus[]): State => {
|
||||
return state.withMutations(state => {
|
||||
statuses.forEach(status => importStatus(state, status));
|
||||
});
|
||||
};
|
||||
|
||||
const isReplyTo = (
|
||||
state: State,
|
||||
childId: string | undefined,
|
||||
parentId: string,
|
||||
initialId: string | null = null,
|
||||
): boolean => {
|
||||
if (!childId) return false;
|
||||
|
||||
// Prevent cycles
|
||||
if (childId === initialId) return false;
|
||||
initialId = initialId || childId;
|
||||
|
||||
if (childId === parentId) {
|
||||
return true;
|
||||
} else {
|
||||
const nextId = state.inReplyTos.get(childId);
|
||||
return isReplyTo(state, nextId, parentId, initialId);
|
||||
}
|
||||
};
|
||||
|
||||
/** Insert a fake status ID connecting descendant to ancestor. */
|
||||
const insertTombstone = (state: State, ancestorId: string, descendantId: string): State => {
|
||||
// Prevent infinite loop if the API returns a bogus response
|
||||
if (isReplyTo(state, ancestorId, descendantId)) return state;
|
||||
|
||||
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 });
|
||||
});
|
||||
};
|
||||
|
||||
/** Find the highest level status from this statusId. */
|
||||
const getRootNode = (state: State, statusId: string, initialId = statusId): string => {
|
||||
const parent = state.inReplyTos.get(statusId);
|
||||
|
||||
if (!parent) {
|
||||
return statusId;
|
||||
} else if (parent === initialId) {
|
||||
// Prevent cycles
|
||||
return parent;
|
||||
} else {
|
||||
return getRootNode(state, parent, initialId);
|
||||
}
|
||||
};
|
||||
|
||||
/** Route fromId to toId by inserting tombstones. */
|
||||
const connectNodes = (state: State, fromId: string, toId: string): State => {
|
||||
const root = getRootNode(state, fromId);
|
||||
|
||||
if (root !== toId) {
|
||||
return insertTombstone(state, toId, fromId);
|
||||
} else {
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
/** Import a branch of ancestors or descendants, in relation to statusId. */
|
||||
const importBranch = (state: State, statuses: ContextStatus[], statusId?: string): State => {
|
||||
return state.withMutations(state => {
|
||||
statuses.forEach((status, i) => {
|
||||
const prevId = statusId && i === 0 ? statusId : (statuses[i - 1] || {}).id;
|
||||
|
||||
if (status.in_reply_to_id) {
|
||||
importStatus(state, status);
|
||||
|
||||
// On Mastodon, in_reply_to_id can refer to an unavailable status,
|
||||
// so traverse the tree up and insert a connecting tombstone if needed.
|
||||
if (statusId) {
|
||||
connectNodes(state, status.id, statusId);
|
||||
}
|
||||
} else if (prevId) {
|
||||
// On Pleroma, in_reply_to_id will be null if the parent is unavailable,
|
||||
// so insert the tombstone now.
|
||||
insertTombstone(state, prevId, status.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/** Import a status's ancestors and descendants. */
|
||||
const normalizeContext = (
|
||||
state: State,
|
||||
id: string,
|
||||
ancestors: ContextStatus[],
|
||||
descendants: ContextStatus[],
|
||||
) => 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);
|
||||
}
|
||||
});
|
||||
|
||||
/** Remove a status from the reducer. */
|
||||
const deleteStatus = (state: State, id: string): State => {
|
||||
return state.withMutations(state => {
|
||||
// Delete from its parent's tree
|
||||
const parentId = state.inReplyTos.get(id);
|
||||
if (parentId) {
|
||||
const parentReplies = state.replies.get(parentId) || ImmutableOrderedSet();
|
||||
const newParentReplies = parentReplies.delete(id);
|
||||
state.setIn(['replies', parentId], newParentReplies);
|
||||
}
|
||||
|
||||
// Dereference children
|
||||
const replies = state.replies.get(id) || ImmutableOrderedSet();
|
||||
replies.forEach(reply => state.deleteIn(['inReplyTos', reply]));
|
||||
|
||||
state.deleteIn(['inReplyTos', id]);
|
||||
state.deleteIn(['replies', id]);
|
||||
});
|
||||
};
|
||||
|
||||
/** Delete multiple statuses from the reducer. */
|
||||
const deleteStatuses = (state: State, ids: string[]): State => {
|
||||
return state.withMutations(state => {
|
||||
ids.forEach(id => deleteStatus(state, id));
|
||||
});
|
||||
};
|
||||
|
||||
/** Delete statuses upon blocking or muting a user. */
|
||||
const filterContexts = (
|
||||
state: State,
|
||||
relationship: { id: string },
|
||||
/** The entire statuses map from the store. */
|
||||
statuses: ImmutableMap<string, ReducerStatus>,
|
||||
): State => {
|
||||
const ownedStatusIds = statuses
|
||||
.filter(status => status.account === relationship.id)
|
||||
.map(status => status.id)
|
||||
.toList()
|
||||
.toArray();
|
||||
|
||||
return deleteStatuses(state, ownedStatusIds);
|
||||
};
|
||||
|
||||
/** Add a fake status ID for a pending status. */
|
||||
const importPendingStatus = (state: State, params: ContextStatus, idempotencyKey: string): State => {
|
||||
const id = `末pending-${idempotencyKey}`;
|
||||
const { in_reply_to_id } = params;
|
||||
return importStatus(state, { id, in_reply_to_id });
|
||||
};
|
||||
|
||||
/** Delete a pending status from the reducer. */
|
||||
const deletePendingStatus = (state: State, params: ContextStatus, idempotencyKey: string): State => {
|
||||
const id = `末pending-${idempotencyKey}`;
|
||||
const { in_reply_to_id: inReplyToId } = params;
|
||||
|
||||
return state.withMutations(state => {
|
||||
state.deleteIn(['inReplyTos', id]);
|
||||
|
||||
if (inReplyToId) {
|
||||
const replies = state.replies.get(inReplyToId) || ImmutableOrderedSet();
|
||||
const newReplies = replies.delete(id).sort();
|
||||
state.setIn(['replies', inReplyToId], newReplies);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** Contexts reducer. Used for building a nested tree structure for threads. */
|
||||
export default function replies(state = ReducerRecord(), action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case ACCOUNT_BLOCK_SUCCESS:
|
||||
case ACCOUNT_MUTE_SUCCESS:
|
||||
return filterContexts(state, action.relationship, action.statuses);
|
||||
case CONTEXT_FETCH_SUCCESS:
|
||||
return normalizeContext(state, action.id, action.ancestors, action.descendants);
|
||||
case TIMELINE_DELETE:
|
||||
return deleteStatuses(state, [action.id]);
|
||||
case STATUS_CREATE_REQUEST:
|
||||
return importPendingStatus(state, action.params, action.idempotencyKey);
|
||||
case STATUS_CREATE_SUCCESS:
|
||||
return deletePendingStatus(state, action.status, action.idempotencyKey);
|
||||
case STATUS_IMPORT:
|
||||
return importStatus(state, action.status, action.idempotencyKey);
|
||||
case STATUSES_IMPORT:
|
||||
return importStatuses(state, action.statuses);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue