Batch redux conversation changed / added actions

This commit is contained in:
trevor-signal 2024-11-11 19:37:10 -05:00 committed by GitHub
parent 84b7cb4116
commit 22d4b1d194
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 504 additions and 186 deletions

View file

@ -3,10 +3,10 @@
import { isNumber, groupBy, throttle } from 'lodash';
import { render } from 'react-dom';
import { batch as batchDispatch } from 'react-redux';
import PQueue from 'p-queue';
import pMap from 'p-map';
import { v7 as generateUuid } from 'uuid';
import { batch as batchDispatch } from 'react-redux';
import * as Registration from './util/registration';
import MessageReceiver from './textsecure/MessageReceiver';
@ -1166,68 +1166,107 @@ export async function startApp(): Promise<void> {
const convoCollection = window.getConversations();
const {
conversationAdded,
conversationChanged,
conversationsUpdated,
conversationRemoved,
removeAllConversations,
onConversationClosed,
} = window.reduxActions.conversations;
convoCollection.on('remove', conversation => {
const { id } = conversation || {};
onConversationClosed(id, 'removed');
conversationRemoved(id);
});
convoCollection.on('add', conversation => {
if (!conversation) {
return;
}
conversationAdded(conversation.id, conversation.format());
});
const changedConvoBatcher = createBatcher<ConversationModel>({
// Conversation add/update/remove actions are batched in this batcher to ensure
// that we retain correct orderings
const convoUpdateBatcher = createBatcher<
| { type: 'change' | 'add'; conversation: ConversationModel }
| { type: 'remove'; id: string }
>({
name: 'changedConvoBatcher',
processBatch(batch) {
const deduped = new Set(batch);
log.info(
'changedConvoBatcher: deduped ' +
`${batch.length} into ${deduped.size}`
);
let changedOrAddedBatch = new Array<ConversationModel>();
function flushChangedOrAddedBatch() {
if (!changedOrAddedBatch.length) {
return;
}
conversationsUpdated(
changedOrAddedBatch.map(conversation => conversation.format())
);
changedOrAddedBatch = [];
}
batchDispatch(() => {
deduped.forEach(conversation => {
conversationChanged(conversation.id, conversation.format());
});
for (const item of batch) {
if (item.type === 'add' || item.type === 'change') {
changedOrAddedBatch.push(item.conversation);
} else {
strictAssert(item.type === 'remove', 'must be remove');
flushChangedOrAddedBatch();
onConversationClosed(item.id, 'removed');
conversationRemoved(item.id);
}
}
flushChangedOrAddedBatch();
});
},
// This delay ensures that the .format() call isn't synchronous as a
// Backbone property is changed. Important because our _byUuid/_byE164
// lookups aren't up-to-date as the change happens; just a little bit
// after.
wait: 1,
wait: () => {
if (backupsService.isImportRunning()) {
return 500;
}
if (messageReceiver && !messageReceiver.hasEmptied()) {
return 250;
}
// This delay ensures that the .format() call isn't synchronous as a
// Backbone property is changed. Important because our _byUuid/_byE164
// lookups aren't up-to-date as the change happens; just a little bit
// after.
return 1;
},
maxSize: Infinity,
});
convoCollection.on('props-change', (conversation, isBatched) => {
convoCollection.on('add', (conversation: ConversationModel | undefined) => {
if (!conversation) {
return;
}
// `isBatched` is true when the `.set()` call on the conversation model
// already runs from within `react-redux`'s batch. Instead of batching
// the redux update for later - clear all queued updates and update
// immediately.
if (isBatched) {
changedConvoBatcher.removeAll(conversation);
conversationChanged(conversation.id, conversation.format());
return;
if (
backupsService.isImportRunning() ||
!window.reduxStore.getState().app.hasInitialLoadCompleted
) {
convoUpdateBatcher.add({ type: 'add', conversation });
} else {
// During normal app usage, we require conversations to be added synchronously
conversationsUpdated([conversation.format()]);
}
changedConvoBatcher.add(conversation);
});
convoCollection.on('remove', conversation => {
const { id } = conversation || {};
convoUpdateBatcher.add({ type: 'remove', id });
});
convoCollection.on(
'props-change',
(conversation: ConversationModel | undefined, isBatched?: boolean) => {
if (!conversation) {
return;
}
// `isBatched` is true when the `.set()` call on the conversation model already
// runs from within `react-redux`'s batch. Instead of batching the redux update
// for later, update immediately. To ensure correct update ordering, only do this
// optimization if there are no other pending conversation updates
if (isBatched && !convoUpdateBatcher.anyPending()) {
conversationsUpdated([conversation.format()]);
return;
}
convoUpdateBatcher.add({ type: 'change', conversation });
}
);
// Called by SignalProtocolStore#removeAllData()
convoCollection.on('reset', removeAllConversations);

View file

@ -79,7 +79,7 @@ export type ImportOptionsType = Readonly<{
export class BackupsService {
private isStarted = false;
private isRunning = false;
private isRunning: 'import' | 'export' | false = false;
private downloadController: AbortController | undefined;
private downloadRetryPromise:
| ExplodePromiseResultType<RetryBackupImportValue>
@ -275,7 +275,7 @@ export class BackupsService {
window.IPC.startTrackingQueryStats();
log.info(`importBackup: starting ${backupType}...`);
this.isRunning = true;
this.isRunning = 'import';
try {
const importStream = await BackupImportStream.create(backupType);
@ -531,7 +531,7 @@ export class BackupsService {
strictAssert(!this.isRunning, 'BackupService is already running');
log.info('exportBackup: starting...');
this.isRunning = true;
this.isRunning = 'export';
try {
// TODO (DESKTOP-7168): Update mock-server to support this endpoint
@ -594,6 +594,13 @@ export class BackupsService {
log.error('Backup: periodic refresh failed', Errors.toLogFormat(error));
}
}
public isImportRunning(): boolean {
return this.isRunning === 'import';
}
public isExportRunning(): boolean {
return this.isRunning === 'export';
}
}
export const backupsService = new BackupsService();

View file

@ -14,8 +14,7 @@ export function reloadSelectedConversation(): void {
return;
}
conversation.cachedProps = undefined;
window.reduxActions.conversations.conversationChanged(
conversation.id,
conversation.format()
);
window.reduxActions.conversations.conversationsUpdated([
conversation.format(),
]);
}

View file

@ -19,7 +19,7 @@ import type {
MessageDeletedActionType,
MessageChangedActionType,
TargetedConversationChangedActionType,
ConversationChangedActionType,
ConversationsUpdatedActionType,
} from './conversations';
import * as log from '../../logging/log';
import { isAudio } from '../../types/Attachment';
@ -184,7 +184,7 @@ function setPlaybackRate(
void,
RootStateType,
unknown,
SetPlaybackRate | ConversationChangedActionType
SetPlaybackRate | ConversationsUpdatedActionType
> {
return (dispatch, getState) => {
const { audioPlayer } = getState();

View file

@ -67,7 +67,7 @@ import { sleep } from '../../util/sleep';
import { LatestQueue } from '../../util/LatestQueue';
import type { AciString, ServiceIdString } from '../../types/ServiceId';
import type {
ConversationChangedActionType,
ConversationsUpdatedActionType,
ConversationRemovedActionType,
} from './conversations';
import { getConversationCallMode, updateLastMessage } from './conversations';
@ -959,7 +959,7 @@ export type CallingActionType =
| CallStateChangeFulfilledActionType
| ChangeIODeviceFulfilledActionType
| CloseNeedPermissionScreenActionType
| ConversationChangedActionType
| ConversationsUpdatedActionType
| ConversationRemovedActionType
| DeclineCallActionType
| GroupCallAudioLevelsChangeActionType
@ -3071,16 +3071,28 @@ export function reducer(
};
}
if (action.type === 'CONVERSATION_CHANGED') {
if (action.type === 'CONVERSATIONS_UPDATED') {
const activeCall = getActiveCall(state);
const { activeCallState } = state;
if (
activeCallState?.state === 'Waiting' ||
!activeCallState?.outgoingRing ||
activeCallState.conversationId !== action.payload.id ||
!isGroupOrAdhocCallState(activeCall) ||
activeCall.joinState !== GroupCallJoinState.NotJoined ||
!isConversationTooBigToRing(action.payload.data)
activeCall.joinState !== GroupCallJoinState.NotJoined
) {
return state;
}
const conversationForActiveCall = action.payload.data
.slice()
// reverse list since last update takes precedence
.reverse()
.find(conversation => conversation.id === activeCall?.conversationId);
if (
!conversationForActiveCall ||
!isConversationTooBigToRing(conversationForActiveCall)
) {
return state;
}

View file

@ -711,18 +711,10 @@ type SetPreJoinConversationActionType = ReadonlyDeep<{
};
}>;
type ConversationAddedActionType = ReadonlyDeep<{
type: 'CONVERSATION_ADDED';
export type ConversationsUpdatedActionType = ReadonlyDeep<{
type: 'CONVERSATIONS_UPDATED';
payload: {
id: string;
data: ConversationType;
};
}>;
export type ConversationChangedActionType = ReadonlyDeep<{
type: 'CONVERSATION_CHANGED';
payload: {
id: string;
data: ConversationType;
data: Array<ConversationType>;
};
}>;
export type ConversationRemovedActionType = ReadonlyDeep<{
@ -1025,8 +1017,7 @@ export type ConversationActionType =
| ComposeReplaceAvatarsActionType
| ComposeSaveAvatarActionType
| ConsumePreloadDataActionType
| ConversationAddedActionType
| ConversationChangedActionType
| ConversationsUpdatedActionType
| ConversationRemovedActionType
| ConversationStoppedByMissingVerificationActionType
| ConversationUnloadedActionType
@ -1107,8 +1098,7 @@ export const actions = {
composeReplaceAvatar,
composeSaveAvatarToDisk,
consumePreloadData,
conversationAdded,
conversationChanged,
conversationsUpdated,
conversationRemoved,
conversationStoppedByMissingVerification,
createGroup,
@ -2448,7 +2438,7 @@ export function setVoiceNotePlaybackRate({
}: {
conversationId: string;
rate: number;
}): ThunkAction<void, RootStateType, unknown, ConversationChangedActionType> {
}): ThunkAction<void, RootStateType, unknown, ConversationsUpdatedActionType> {
return async dispatch => {
const conversationModel = window.ConversationController.get(conversationId);
if (conversationModel) {
@ -2462,13 +2452,14 @@ export function setVoiceNotePlaybackRate({
if (conversation) {
dispatch({
type: 'CONVERSATION_CHANGED',
type: 'CONVERSATIONS_UPDATED',
payload: {
id: conversationId,
data: {
...conversation,
voiceNotePlaybackRate: rate,
},
data: [
{
...conversation,
voiceNotePlaybackRate: rate,
},
],
},
});
}
@ -2688,29 +2679,17 @@ function setPreJoinConversation(
},
};
}
function conversationAdded(
id: string,
data: ConversationType
): ConversationAddedActionType {
return {
type: 'CONVERSATION_ADDED',
payload: {
id,
data,
},
};
}
function conversationChanged(
id: string,
data: ConversationType
): ThunkAction<void, RootStateType, unknown, ConversationChangedActionType> {
return dispatch => {
calling.groupMembersChanged(id);
function conversationsUpdated(
data: Array<ConversationType>
): ThunkAction<void, RootStateType, unknown, ConversationsUpdatedActionType> {
return dispatch => {
for (const conversation of data) {
calling.groupMembersChanged(conversation.id);
}
dispatch({
type: 'CONVERSATION_CHANGED',
type: 'CONVERSATIONS_UPDATED',
payload: {
id,
data,
},
});
@ -4684,8 +4663,8 @@ export function getEmptyState(): ConversationsStateType {
}
export function updateConversationLookups(
added: ConversationType | undefined,
removed: ConversationType | undefined,
added: ReadonlyArray<ConversationType> | undefined,
removed: ReadonlyArray<ConversationType> | undefined,
state: ConversationsStateType
): Pick<
ConversationsStateType,
@ -4700,69 +4679,137 @@ export function updateConversationLookups(
conversationsByGroupId: state.conversationsByGroupId,
conversationsByUsername: state.conversationsByUsername,
};
const removedE164s = removed?.map(convo => convo.e164).filter(isNotNil);
const removedServiceIds = removed
?.map(convo => convo.serviceId)
.filter(isNotNil);
const removedPnis = removed?.map(convo => convo.pni).filter(isNotNil);
const removedGroupIds = removed?.map(convo => convo.groupId).filter(isNotNil);
const removedUsernames = removed
?.map(convo => convo.username)
.filter(isNotNil);
if (removed && removed.e164) {
result.conversationsByE164 = omit(result.conversationsByE164, removed.e164);
if (removedE164s?.length) {
result.conversationsByE164 = omit(result.conversationsByE164, removedE164s);
}
if (removed && removed.serviceId) {
if (removedServiceIds?.length) {
result.conversationsByServiceId = omit(
result.conversationsByServiceId,
removed.serviceId
removedServiceIds
);
}
if (removed && removed.pni) {
if (removedPnis?.length) {
result.conversationsByServiceId = omit(
result.conversationsByServiceId,
removed.pni
removedPnis
);
}
if (removed && removed.groupId) {
if (removedGroupIds?.length) {
result.conversationsByGroupId = omit(
result.conversationsByGroupId,
removed.groupId
removedGroupIds
);
}
if (removed && removed.username) {
if (removedUsernames?.length) {
result.conversationsByUsername = omit(
result.conversationsByUsername,
removed.username
removedUsernames
);
}
if (added && added.e164) {
function isFirstElementNotNil(val: Array<unknown>) {
return val[0] != null;
}
const addedE164s = added
?.map(convo => [convo.e164, convo])
.filter(isFirstElementNotNil);
const addedServiceIds = added
?.map(convo => [convo.serviceId, convo])
.filter(isFirstElementNotNil);
const addedPnis = added
?.map(convo => [convo.pni, convo])
.filter(isFirstElementNotNil);
const addedGroupIds = added
?.map(convo => [convo.groupId, convo])
.filter(isFirstElementNotNil);
const addedUsernames = added
?.map(convo => [convo.username, convo])
.filter(isFirstElementNotNil);
if (addedE164s?.length) {
result.conversationsByE164 = {
...result.conversationsByE164,
[added.e164]: added,
...Object.fromEntries(addedE164s),
};
}
if (added && added.serviceId) {
if (addedServiceIds?.length) {
result.conversationsByServiceId = {
...result.conversationsByServiceId,
[added.serviceId]: added,
...Object.fromEntries(addedServiceIds),
};
}
if (added && added.pni) {
if (addedPnis?.length) {
result.conversationsByServiceId = {
...result.conversationsByServiceId,
[added.pni]: added,
...Object.fromEntries(addedPnis),
};
}
if (added && added.groupId) {
if (addedGroupIds?.length) {
result.conversationsByGroupId = {
...result.conversationsByGroupId,
[added.groupId]: added,
...Object.fromEntries(addedGroupIds),
};
}
if (added && added.username) {
if (addedUsernames?.length) {
result.conversationsByUsername = {
...result.conversationsByUsername,
[added.username]: added,
...Object.fromEntries(addedUsernames),
};
}
return result;
}
function updateRootStateDueToConversationUpdate(
state: ConversationsStateType,
conversation: ConversationType
): ConversationsStateType {
if (state.selectedConversationId !== conversation.id) {
return state;
}
let { showArchived } = state;
const { selectedConversationId, conversationLookup } = state;
const existing = conversationLookup[conversation.id];
const keysToOmit: Array<keyof ConversationsStateType> = [];
const keyValuesToAdd: { hasContactSpoofingReview?: false } = {};
// Archived -> Inbox: we go back to the normal inbox view
if (existing.isArchived && !conversation.isArchived) {
showArchived = false;
}
// Inbox -> Archived: no conversation is selected
// Note: With today's stacked conversations architecture, this can result in weird
// behavior - no selected conversation in the left pane, but a conversation show
// in the right pane.
if (!existing.isArchived && conversation.isArchived) {
keysToOmit.push('selectedConversationId');
}
if (!existing.isBlocked && conversation.isBlocked) {
keyValuesToAdd.hasContactSpoofingReview = false;
}
return {
...omit(state, keysToOmit),
...keyValuesToAdd,
selectedConversationId,
showArchived,
};
}
function closeComposerModal(
state: Readonly<ConversationsStateType>,
modalToClose: 'maximumGroupSizeModalState' | 'recommendedGroupSizeModalState'
@ -4992,7 +5039,7 @@ function updateNicknameAndNote(
});
await DataWriter.updateConversation(conversationModel.attributes);
const conversation = conversationModel.format();
dispatch(conversationChanged(conversationId, conversation));
dispatch(conversationsUpdated([conversation]));
conversationModel.captureChange('nicknameAndNote');
};
}
@ -5277,66 +5324,39 @@ export function reducer(
preJoinConversation: data,
};
}
if (action.type === 'CONVERSATION_ADDED') {
if (action.type === 'CONVERSATIONS_UPDATED') {
const { payload } = action;
const { id, data } = payload;
const { conversationLookup } = state;
return {
...state,
conversationLookup: {
...conversationLookup,
[id]: data,
},
...updateConversationLookups(data, undefined, state),
};
}
if (action.type === 'CONVERSATION_CHANGED') {
const { payload } = action;
const { id, data } = payload;
const { data: conversations } = payload;
const { conversationLookup } = state;
const { selectedConversationId } = state;
let { showArchived } = state;
const existing = conversationLookup[id];
// We only modify the lookup if we already had that conversation and the conversation
// changed.
if (!existing || data === existing) {
return state;
const selectedConversation = conversations.find(
convo => convo.id === selectedConversationId
);
let updatedState = state;
if (selectedConversation) {
updatedState = updateRootStateDueToConversationUpdate(
state,
selectedConversation
);
}
const keysToOmit: Array<keyof ConversationsStateType> = [];
const keyValuesToAdd: { hasContactSpoofingReview?: false } = {};
const existingConversations = conversations
.map(conversation => conversationLookup[conversation.id])
.filter(isNotNil);
if (selectedConversationId === id) {
// Archived -> Inbox: we go back to the normal inbox view
if (existing.isArchived && !data.isArchived) {
showArchived = false;
}
// Inbox -> Archived: no conversation is selected
// Note: With today's stacked conversations architecture, this can result in weird
// behavior - no selected conversation in the left pane, but a conversation show
// in the right pane.
if (!existing.isArchived && data.isArchived) {
keysToOmit.push('selectedConversationId');
}
if (!existing.isBlocked && data.isBlocked) {
keyValuesToAdd.hasContactSpoofingReview = false;
}
const newConversationLookup = { ...conversationLookup };
for (const conversation of conversations) {
newConversationLookup[conversation.id] = conversation;
}
return {
...omit(state, keysToOmit),
...keyValuesToAdd,
selectedConversationId,
showArchived,
conversationLookup: {
...conversationLookup,
[id]: data,
},
...updateConversationLookups(data, existing, state),
...updatedState,
conversationLookup: newConversationLookup,
...updateConversationLookups(conversations, existingConversations, state),
};
}
if (action.type === 'CONVERSATION_REMOVED') {
@ -5345,6 +5365,7 @@ export function reducer(
const { conversationLookup } = state;
const existing = getOwn(conversationLookup, id);
onConversationClosed(id, 'removed');
// No need to make a change if we didn't have a record of this conversation!
if (!existing) {
return state;
@ -5353,7 +5374,7 @@ export function reducer(
return {
...state,
conversationLookup: omit(conversationLookup, [id]),
...updateConversationLookups(undefined, existing, state),
...updateConversationLookups(undefined, [existing], state),
};
}
if (action.type === CONVERSATION_UNLOADED) {
@ -6383,7 +6404,7 @@ export function reducer(
...conversationLookup,
[id]: data,
},
...updateConversationLookups(data, undefined, state),
...updateConversationLookups([data], undefined, state),
};
}
@ -6844,7 +6865,7 @@ export function reducer(
Object.assign(
nextState,
updateConversationLookups(added, existing, nextState),
updateConversationLookups([added], [existing], nextState),
{
conversationLookup: {
...nextState.conversationLookup,
@ -6880,7 +6901,7 @@ export function reducer(
...conversationLookup,
[conversationId]: changed,
},
...updateConversationLookups(changed, existing, state),
...updateConversationLookups([changed], [existing], state),
};
}
@ -6908,7 +6929,7 @@ export function reducer(
Object.assign(
nextState,
updateConversationLookups(changed, existing, nextState),
updateConversationLookups([changed], [existing], nextState),
{
conversationLookup: {
...nextState.conversationLookup,
@ -6941,7 +6962,7 @@ export function reducer(
...conversationLookup,
[conversationId]: changed,
},
...updateConversationLookups(changed, conversation, state),
...updateConversationLookups([changed], [conversation], state),
};
}

View file

@ -23,6 +23,7 @@ import type {
TargetedConversationChangedActionType,
ToggleConversationInChooseMembersActionType,
MessageChangedActionType,
ConversationsUpdatedActionType,
} from '../../../state/ducks/conversations';
import {
TARGETED_CONVERSATION_CHANGED,
@ -37,7 +38,12 @@ import {
import { ReadStatus } from '../../../messages/MessageReadStatus';
import type { SingleServePromiseIdString } from '../../../services/singleServePromise';
import { CallMode } from '../../../types/CallDisposition';
import { generateAci, getAciFromPrefix } from '../../../types/ServiceId';
import {
type AciString,
type PniString,
generateAci,
getAciFromPrefix,
} from '../../../types/ServiceId';
import { generateStoryDistributionId } from '../../../types/StoryDistributionId';
import {
getDefaultConversation,
@ -62,6 +68,7 @@ import {
} from '../../../state/ducks/storyDistributionLists';
import { MY_STORY_ID } from '../../../types/Stories';
import type { ReadonlyMessageAttributesType } from '../../../model-types.d';
import { strictAssert } from '../../../util/assert';
const {
clearGroupCreationError,
@ -255,7 +262,7 @@ describe('both/state/ducks/conversations', () => {
'e164-added': added,
};
const actual = updateConversationLookups(added, removed, state);
const actual = updateConversationLookups([added], [removed], state);
assert.deepEqual(actual.conversationsByE164, expected);
assert.strictEqual(
@ -289,7 +296,7 @@ describe('both/state/ducks/conversations', () => {
[added.serviceId]: added,
};
const actual = updateConversationLookups(added, removed, state);
const actual = updateConversationLookups([added], [removed], state);
assert.strictEqual(
state.conversationsByE164,
@ -310,9 +317,9 @@ describe('both/state/ducks/conversations', () => {
serviceId: undefined,
});
const state = {
const state: ConversationsStateType = {
...getEmptyState(),
conversationsBygroupId: {
conversationsByGroupId: {
'groupId-removed': removed,
},
};
@ -327,7 +334,7 @@ describe('both/state/ducks/conversations', () => {
'groupId-added': added,
};
const actual = updateConversationLookups(added, removed, state);
const actual = updateConversationLookups([added], [removed], state);
assert.strictEqual(
state.conversationsByE164,
@ -339,6 +346,93 @@ describe('both/state/ducks/conversations', () => {
);
assert.deepEqual(actual.conversationsByGroupId, expected);
});
it('adds and removes multiple conversations', () => {
const removed = getDefaultConversation({
id: 'id-removed',
groupId: 'groupId-removed',
e164: 'e164-removed',
serviceId: 'serviceId-removed' as unknown as AciString,
pni: 'pni-removed' as unknown as PniString,
username: 'username-removed',
});
const stable = getDefaultConversation({
id: 'id-stable',
groupId: 'groupId-stable',
e164: 'e164-stable',
serviceId: 'serviceId-stable' as unknown as AciString,
pni: 'pni-stable' as unknown as PniString,
username: 'username-stable',
});
const state: ConversationsStateType = {
...getEmptyState(),
conversationsByServiceId: {
'serviceId-removed': removed,
'serviceId-stable': stable,
'pni-removed': removed,
'pni-stable': stable,
},
conversationsByE164: {
'e164-removed': removed,
'e164-stable': stable,
},
conversationsByGroupId: {
'groupId-removed': removed,
'groupId-stable': stable,
},
conversationsByUsername: {
'username-removed': removed,
'username-stable': stable,
},
};
const added1 = getDefaultConversation({
id: 'id-added1',
groupId: 'groupId-added1',
e164: 'e164-added1',
serviceId: 'serviceId-added1' as unknown as AciString,
pni: 'pni-added1' as unknown as PniString,
username: 'username-added1',
});
const added2 = getDefaultConversation({
id: 'id-added2',
groupId: 'groupId-added2',
e164: undefined,
serviceId: undefined,
pni: undefined,
username: undefined,
});
const actual = {
...state,
...updateConversationLookups([added1, added2], [removed], state),
};
const expected = {
...getEmptyState(),
conversationsByServiceId: {
'serviceId-added1': added1,
'pni-added1': added1,
'serviceId-stable': stable,
'pni-stable': stable,
},
conversationsByE164: {
'e164-added1': added1,
'e164-stable': stable,
},
conversationsByGroupId: {
'groupId-added1': added1,
'groupId-stable': stable,
'groupId-added2': added2,
},
conversationsByUsername: {
'username-added1': added1,
'username-stable': stable,
},
};
assert.deepEqual(actual, expected);
});
});
});
@ -2498,5 +2592,143 @@ describe('both/state/ducks/conversations', () => {
});
});
});
describe('CONVERSATIONS_UPDATED', () => {
it('adds and updates multiple conversations', () => {
const conversation1 = getDefaultConversation();
const conversation2 = getDefaultConversation();
const newConversation = getDefaultConversation();
strictAssert(conversation1.serviceId, 'must exist');
strictAssert(conversation1.e164, 'must exist');
strictAssert(conversation2.serviceId, 'must exist');
strictAssert(conversation2.e164, 'must exist');
strictAssert(newConversation.serviceId, 'must exist');
strictAssert(newConversation.e164, 'must exist');
const state = {
...getEmptyState(),
conversationLookup: {
[conversation1.id]: conversation1,
[conversation2.id]: conversation2,
},
conversationsByE164: {
[conversation1.e164]: conversation1,
[conversation2.e164]: conversation2,
},
conversationsByServiceId: {
[conversation1.serviceId]: conversation1,
[conversation2.serviceId]: conversation2,
},
};
const updatedConversation1 = {
...conversation1,
e164: undefined,
title: 'new title',
};
const updatedConversation2 = {
...conversation2,
active_at: 12345,
};
const updatedConversation2Again = {
...conversation2,
active_at: 98765,
};
const action: ConversationsUpdatedActionType = {
type: 'CONVERSATIONS_UPDATED',
payload: {
data: [
updatedConversation1,
updatedConversation2,
newConversation,
updatedConversation2Again,
],
},
};
const actual = reducer(state, action);
const expected: ConversationsStateType = {
...state,
conversationLookup: {
[conversation1.id]: updatedConversation1,
[conversation2.id]: updatedConversation2Again,
[newConversation.id]: newConversation,
},
conversationsByE164: {
[conversation2.e164]: updatedConversation2Again,
[newConversation.e164]: newConversation,
},
conversationsByServiceId: {
[conversation1.serviceId]: updatedConversation1,
[conversation2.serviceId]: updatedConversation2Again,
[newConversation.serviceId]: newConversation,
},
};
assert.deepEqual(actual, expected);
});
it('updates root state if conversation is selected', () => {
const conversation1 = getDefaultConversation({ isArchived: true });
const conversation2 = getDefaultConversation();
strictAssert(conversation1.serviceId, 'must exist');
strictAssert(conversation1.e164, 'must exist');
strictAssert(conversation2.serviceId, 'must exist');
strictAssert(conversation2.e164, 'must exist');
const state: ConversationsStateType = {
...getEmptyState(),
selectedConversationId: conversation1.id,
showArchived: true,
conversationLookup: {
[conversation1.id]: conversation1,
[conversation2.id]: conversation2,
},
conversationsByE164: {
[conversation1.e164]: conversation1,
[conversation2.e164]: conversation2,
},
conversationsByServiceId: {
[conversation1.serviceId]: conversation1,
[conversation2.serviceId]: conversation2,
},
};
const updatedConversation1 = {
...conversation1,
isArchived: false,
};
const updatedConversation2 = {
...conversation2,
active_at: 12345,
};
const action: ConversationsUpdatedActionType = {
type: 'CONVERSATIONS_UPDATED',
payload: {
data: [updatedConversation1, updatedConversation2],
},
};
const actual = reducer(state, action);
const expected: ConversationsStateType = {
...state,
showArchived: false,
conversationLookup: {
[conversation1.id]: updatedConversation1,
[conversation2.id]: updatedConversation2,
},
conversationsByE164: {
[conversation1.e164]: updatedConversation1,
[conversation2.e164]: updatedConversation2,
},
conversationsByServiceId: {
[conversation1.serviceId]: updatedConversation1,
[conversation2.serviceId]: updatedConversation2,
},
};
assert.deepEqual(actual, expected);
});
});
});
});

View file

@ -36,7 +36,7 @@ window.waitForAllBatchers = async () => {
export type BatcherOptionsType<ItemType> = {
name: string;
wait: number;
wait: number | (() => number);
maxSize: number;
processBatch: (items: Array<ItemType>) => void | Promise<void>;
};
@ -56,12 +56,20 @@ export function createBatcher<ItemType>(
let batcher: BatcherType<ItemType>;
let timeout: NodeJS.Timeout | null;
let items: Array<ItemType> = [];
const queue = new PQueue({
concurrency: 1,
timeout: MINUTE * 30,
throwOnTimeout: true,
});
function _getWait() {
if (typeof options.wait === 'number') {
return options.wait;
}
return options.wait();
}
function _kickBatchOff() {
clearTimeoutIfNecessary(timeout);
timeout = null;
@ -81,7 +89,7 @@ export function createBatcher<ItemType>(
if (items.length === 1) {
// Set timeout once when we just pushed the first item so that the wait
// time is bounded by `options.wait` and not extended by further pushes.
timeout = setTimeout(_kickBatchOff, options.wait);
timeout = setTimeout(_kickBatchOff, _getWait());
} else if (items.length >= options.maxSize) {
_kickBatchOff();
}
@ -104,7 +112,7 @@ export function createBatcher<ItemType>(
if (items.length > 0) {
// eslint-disable-next-line no-await-in-loop
await sleep(options.wait * 2);
await sleep(_getWait() * 2);
}
}
}