2058 lines
62 KiB
TypeScript
2058 lines
62 KiB
TypeScript
// Copyright 2020-2022 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import { assert } from 'chai';
|
|
import * as sinon from 'sinon';
|
|
import { v4 as uuid } from 'uuid';
|
|
import { times } from 'lodash';
|
|
import { reducer as rootReducer } from '../../../state/reducer';
|
|
import { noopAction } from '../../../state/ducks/noop';
|
|
import {
|
|
ComposerStep,
|
|
ConversationVerificationState,
|
|
OneTimeModalState,
|
|
} from '../../../state/ducks/conversationsEnums';
|
|
import type {
|
|
CancelVerificationDataByConversationActionType,
|
|
ConversationMessageType,
|
|
ConversationType,
|
|
ConversationsStateType,
|
|
MessageType,
|
|
SelectedConversationChangedActionType,
|
|
ToggleConversationInChooseMembersActionType,
|
|
} from '../../../state/ducks/conversations';
|
|
import {
|
|
SELECTED_CONVERSATION_CHANGED,
|
|
actions,
|
|
cancelConversationVerification,
|
|
clearCancelledConversationVerification,
|
|
getConversationCallMode,
|
|
getEmptyState,
|
|
reducer,
|
|
updateConversationLookups,
|
|
} from '../../../state/ducks/conversations';
|
|
import { ReadStatus } from '../../../messages/MessageReadStatus';
|
|
import { ContactSpoofingType } from '../../../util/contactSpoofing';
|
|
import { CallMode } from '../../../types/Calling';
|
|
import { UUID } from '../../../types/UUID';
|
|
import {
|
|
getDefaultConversation,
|
|
getDefaultConversationWithUuid,
|
|
} from '../../../test-both/helpers/getDefaultConversation';
|
|
import { getDefaultAvatars } from '../../../types/Avatar';
|
|
import {
|
|
defaultStartDirectConversationComposerState,
|
|
defaultChooseGroupMembersComposerState,
|
|
defaultSetGroupMetadataComposerState,
|
|
} from '../../../test-both/helpers/defaultComposerStates';
|
|
import { updateRemoteConfig } from '../../../test-both/helpers/RemoteConfigStub';
|
|
|
|
const {
|
|
clearGroupCreationError,
|
|
clearInvitedUuidsForNewlyCreatedGroup,
|
|
closeContactSpoofingReview,
|
|
closeMaximumGroupSizeModal,
|
|
closeRecommendedGroupSizeModal,
|
|
conversationStoppedByMissingVerification,
|
|
createGroup,
|
|
discardMessages,
|
|
messageChanged,
|
|
repairNewestMessage,
|
|
repairOldestMessage,
|
|
resetAllChatColors,
|
|
reviewGroupMemberNameCollision,
|
|
reviewMessageRequestNameCollision,
|
|
setComposeGroupAvatar,
|
|
setComposeGroupName,
|
|
setComposeSearchTerm,
|
|
setPreJoinConversation,
|
|
showArchivedConversations,
|
|
showChooseGroupMembers,
|
|
showConversation,
|
|
showInbox,
|
|
startComposing,
|
|
startSettingGroupMetadata,
|
|
toggleConversationInChooseMembers,
|
|
} = actions;
|
|
|
|
describe('both/state/ducks/conversations', () => {
|
|
const getEmptyRootState = () => rootReducer(undefined, noopAction());
|
|
|
|
let sinonSandbox: sinon.SinonSandbox;
|
|
let createGroupStub: sinon.SinonStub;
|
|
|
|
beforeEach(() => {
|
|
sinonSandbox = sinon.createSandbox();
|
|
|
|
sinonSandbox.stub(window.Whisper.events, 'trigger');
|
|
|
|
createGroupStub = sinon.stub();
|
|
});
|
|
|
|
afterEach(() => {
|
|
sinonSandbox.restore();
|
|
});
|
|
|
|
describe('helpers', () => {
|
|
describe('getConversationCallMode', () => {
|
|
const fakeConversation: ConversationType = getDefaultConversation();
|
|
|
|
it("returns CallMode.None if you've left the conversation", () => {
|
|
assert.strictEqual(
|
|
getConversationCallMode({
|
|
...fakeConversation,
|
|
left: true,
|
|
}),
|
|
CallMode.None
|
|
);
|
|
});
|
|
|
|
it("returns CallMode.None if you've blocked the other person", () => {
|
|
assert.strictEqual(
|
|
getConversationCallMode({
|
|
...fakeConversation,
|
|
isBlocked: true,
|
|
}),
|
|
CallMode.None
|
|
);
|
|
});
|
|
|
|
it("returns CallMode.None if you haven't accepted message requests", () => {
|
|
assert.strictEqual(
|
|
getConversationCallMode({
|
|
...fakeConversation,
|
|
acceptedMessageRequest: false,
|
|
}),
|
|
CallMode.None
|
|
);
|
|
});
|
|
|
|
it('returns CallMode.None if the conversation is Note to Self', () => {
|
|
assert.strictEqual(
|
|
getConversationCallMode({
|
|
...fakeConversation,
|
|
isMe: true,
|
|
}),
|
|
CallMode.None
|
|
);
|
|
});
|
|
|
|
it('returns CallMode.None for v1 groups', () => {
|
|
assert.strictEqual(
|
|
getConversationCallMode({
|
|
...fakeConversation,
|
|
type: 'group',
|
|
groupVersion: 1,
|
|
sharedGroupNames: [],
|
|
}),
|
|
CallMode.None
|
|
);
|
|
|
|
assert.strictEqual(
|
|
getConversationCallMode({
|
|
...fakeConversation,
|
|
type: 'group',
|
|
sharedGroupNames: [],
|
|
}),
|
|
CallMode.None
|
|
);
|
|
});
|
|
|
|
it('returns CallMode.Direct if the conversation is a normal direct conversation', () => {
|
|
assert.strictEqual(
|
|
getConversationCallMode(fakeConversation),
|
|
CallMode.Direct
|
|
);
|
|
});
|
|
|
|
it('returns CallMode.Group if the conversation is a v2 group', () => {
|
|
assert.strictEqual(
|
|
getConversationCallMode({
|
|
...fakeConversation,
|
|
type: 'group',
|
|
groupVersion: 2,
|
|
sharedGroupNames: [],
|
|
}),
|
|
CallMode.Group
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('updateConversationLookups', () => {
|
|
it('does not change lookups if no conversations provided', () => {
|
|
const state = getEmptyState();
|
|
const result = updateConversationLookups(undefined, undefined, state);
|
|
|
|
assert.strictEqual(
|
|
state.conversationsByE164,
|
|
result.conversationsByE164
|
|
);
|
|
assert.strictEqual(
|
|
state.conversationsByUuid,
|
|
result.conversationsByUuid
|
|
);
|
|
assert.strictEqual(
|
|
state.conversationsByGroupId,
|
|
result.conversationsByGroupId
|
|
);
|
|
});
|
|
|
|
it('adds and removes e164-only contact', () => {
|
|
const removed = getDefaultConversation({
|
|
id: 'id-removed',
|
|
e164: 'e164-removed',
|
|
uuid: undefined,
|
|
});
|
|
|
|
const state = {
|
|
...getEmptyState(),
|
|
conversationsByE164: {
|
|
'e164-removed': removed,
|
|
},
|
|
};
|
|
const added = getDefaultConversation({
|
|
id: 'id-added',
|
|
e164: 'e164-added',
|
|
uuid: undefined,
|
|
});
|
|
|
|
const expected = {
|
|
'e164-added': added,
|
|
};
|
|
|
|
const actual = updateConversationLookups(added, removed, state);
|
|
|
|
assert.deepEqual(actual.conversationsByE164, expected);
|
|
assert.strictEqual(
|
|
state.conversationsByUuid,
|
|
actual.conversationsByUuid
|
|
);
|
|
assert.strictEqual(
|
|
state.conversationsByGroupId,
|
|
actual.conversationsByGroupId
|
|
);
|
|
});
|
|
|
|
it('adds and removes uuid-only contact', () => {
|
|
const removed = getDefaultConversationWithUuid({
|
|
id: 'id-removed',
|
|
e164: undefined,
|
|
});
|
|
|
|
const state = {
|
|
...getEmptyState(),
|
|
conversationsByuuid: {
|
|
[removed.uuid]: removed,
|
|
},
|
|
};
|
|
const added = getDefaultConversationWithUuid({
|
|
id: 'id-added',
|
|
e164: undefined,
|
|
});
|
|
|
|
const expected = {
|
|
[added.uuid]: added,
|
|
};
|
|
|
|
const actual = updateConversationLookups(added, removed, state);
|
|
|
|
assert.strictEqual(
|
|
state.conversationsByE164,
|
|
actual.conversationsByE164
|
|
);
|
|
assert.deepEqual(actual.conversationsByUuid, expected);
|
|
assert.strictEqual(
|
|
state.conversationsByGroupId,
|
|
actual.conversationsByGroupId
|
|
);
|
|
});
|
|
|
|
it('adds and removes groupId-only contact', () => {
|
|
const removed = getDefaultConversation({
|
|
id: 'id-removed',
|
|
groupId: 'groupId-removed',
|
|
e164: undefined,
|
|
uuid: undefined,
|
|
});
|
|
|
|
const state = {
|
|
...getEmptyState(),
|
|
conversationsBygroupId: {
|
|
'groupId-removed': removed,
|
|
},
|
|
};
|
|
const added = getDefaultConversation({
|
|
id: 'id-added',
|
|
groupId: 'groupId-added',
|
|
e164: undefined,
|
|
uuid: undefined,
|
|
});
|
|
|
|
const expected = {
|
|
'groupId-added': added,
|
|
};
|
|
|
|
const actual = updateConversationLookups(added, removed, state);
|
|
|
|
assert.strictEqual(
|
|
state.conversationsByE164,
|
|
actual.conversationsByE164
|
|
);
|
|
assert.strictEqual(
|
|
state.conversationsByUuid,
|
|
actual.conversationsByUuid
|
|
);
|
|
assert.deepEqual(actual.conversationsByGroupId, expected);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('reducer', () => {
|
|
const time = Date.now();
|
|
const previousTime = time - 1;
|
|
const conversationId = 'conversation-guid-1';
|
|
const messageId = 'message-guid-1';
|
|
const messageIdTwo = 'message-guid-2';
|
|
const messageIdThree = 'message-guid-3';
|
|
const sourceUuid = UUID.generate().toString();
|
|
|
|
function getDefaultMessage(id: string): MessageType {
|
|
return {
|
|
attachments: [],
|
|
conversationId,
|
|
id,
|
|
received_at: previousTime,
|
|
sent_at: previousTime,
|
|
source: 'source',
|
|
sourceUuid,
|
|
timestamp: previousTime,
|
|
type: 'incoming' as const,
|
|
readStatus: ReadStatus.Read,
|
|
};
|
|
}
|
|
|
|
function getDefaultConversationMessage(): ConversationMessageType {
|
|
return {
|
|
messageChangeCounter: 0,
|
|
messageIds: [],
|
|
metrics: {
|
|
totalUnseen: 0,
|
|
},
|
|
scrollToMessageCounter: 0,
|
|
};
|
|
}
|
|
|
|
describe('showConversation', () => {
|
|
it('selects a conversation id', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
};
|
|
const action = showConversation({ conversationId: 'abc123' });
|
|
const nextState = reducer(state, action);
|
|
|
|
assert.equal(nextState.selectedConversationId, 'abc123');
|
|
assert.isUndefined(nextState.selectedMessage);
|
|
});
|
|
|
|
it('selects a conversation and a message', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
};
|
|
const action = showConversation({
|
|
conversationId: 'abc123',
|
|
messageId: 'xyz987',
|
|
});
|
|
const nextState = reducer(state, action);
|
|
|
|
assert.equal(nextState.selectedConversationId, 'abc123');
|
|
assert.equal(nextState.selectedMessage, 'xyz987');
|
|
});
|
|
|
|
describe('showConversation switchToAssociatedView=true', () => {
|
|
let action: SelectedConversationChangedActionType;
|
|
|
|
beforeEach(() => {
|
|
action = showConversation({
|
|
conversationId: 'fake-conversation-id',
|
|
switchToAssociatedView: true,
|
|
});
|
|
});
|
|
|
|
it('shows the inbox if the conversation is not archived', () => {
|
|
const conversation = getDefaultConversation({
|
|
id: 'fake-conversation-id',
|
|
});
|
|
const state = {
|
|
...getEmptyState(),
|
|
conversationLookup: {
|
|
[conversation.id]: conversation,
|
|
},
|
|
};
|
|
const result = reducer(state, action);
|
|
|
|
assert.isUndefined(result.composer);
|
|
assert.isFalse(result.showArchived);
|
|
});
|
|
|
|
it('shows the archive if the conversation is archived', () => {
|
|
const conversation = getDefaultConversation({
|
|
id: 'fake-conversation-id',
|
|
isArchived: true,
|
|
});
|
|
const state = {
|
|
...getEmptyState(),
|
|
conversationLookup: {
|
|
[conversation.id]: conversation,
|
|
},
|
|
};
|
|
const result = reducer(state, action);
|
|
|
|
assert.isUndefined(result.composer);
|
|
assert.isTrue(result.showArchived);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('CLEAR_GROUP_CREATION_ERROR', () => {
|
|
it('clears the group creation error', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: {
|
|
...defaultSetGroupMetadataComposerState,
|
|
hasError: true as const,
|
|
},
|
|
};
|
|
const action = clearGroupCreationError();
|
|
const result = reducer(state, action);
|
|
|
|
assert(
|
|
result.composer?.step === ComposerStep.SetGroupMetadata &&
|
|
result.composer.hasError === false
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('CLEAR_INVITED_UUIDS_FOR_NEWLY_CREATED_GROUP', () => {
|
|
it('clears the list of invited conversation UUIDs', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
invitedUuidsForNewlyCreatedGroup: [
|
|
UUID.generate().toString(),
|
|
UUID.generate().toString(),
|
|
],
|
|
};
|
|
const action = clearInvitedUuidsForNewlyCreatedGroup();
|
|
const result = reducer(state, action);
|
|
|
|
assert.isUndefined(result.invitedUuidsForNewlyCreatedGroup);
|
|
});
|
|
});
|
|
|
|
describe('CLOSE_CONTACT_SPOOFING_REVIEW', () => {
|
|
it('closes the contact spoofing review modal if it was open', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
contactSpoofingReview: {
|
|
type: ContactSpoofingType.DirectConversationWithSameTitle as const,
|
|
safeConversationId: 'abc123',
|
|
},
|
|
};
|
|
const action = closeContactSpoofingReview();
|
|
const actual = reducer(state, action);
|
|
|
|
assert.isUndefined(actual.contactSpoofingReview);
|
|
});
|
|
|
|
it("does nothing if the modal wasn't already open", () => {
|
|
const state = getEmptyState();
|
|
const action = closeContactSpoofingReview();
|
|
const actual = reducer(state, action);
|
|
|
|
assert.deepEqual(actual, state);
|
|
});
|
|
});
|
|
|
|
describe('CLOSE_MAXIMUM_GROUP_SIZE_MODAL', () => {
|
|
it('closes the maximum group size modal if it was open', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: {
|
|
...defaultChooseGroupMembersComposerState,
|
|
maximumGroupSizeModalState: OneTimeModalState.Showing,
|
|
},
|
|
};
|
|
const action = closeMaximumGroupSizeModal();
|
|
const result = reducer(state, action);
|
|
|
|
assert(
|
|
result.composer?.step === ComposerStep.ChooseGroupMembers &&
|
|
result.composer.maximumGroupSizeModalState ===
|
|
OneTimeModalState.Shown,
|
|
'Expected the modal to be closed'
|
|
);
|
|
});
|
|
|
|
it('does nothing if the maximum group size modal was never shown', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: defaultChooseGroupMembersComposerState,
|
|
};
|
|
const action = closeMaximumGroupSizeModal();
|
|
const result = reducer(state, action);
|
|
|
|
assert.strictEqual(result, state);
|
|
});
|
|
|
|
it('does nothing if the maximum group size modal already closed', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: {
|
|
...defaultChooseGroupMembersComposerState,
|
|
maximumGroupSizeModalState: OneTimeModalState.Shown,
|
|
},
|
|
};
|
|
const action = closeMaximumGroupSizeModal();
|
|
const result = reducer(state, action);
|
|
|
|
assert.strictEqual(result, state);
|
|
});
|
|
});
|
|
|
|
describe('CLOSE_RECOMMENDED_GROUP_SIZE_MODAL', () => {
|
|
it('closes the recommended group size modal if it was open', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: {
|
|
...defaultChooseGroupMembersComposerState,
|
|
recommendedGroupSizeModalState: OneTimeModalState.Showing,
|
|
},
|
|
};
|
|
const action = closeRecommendedGroupSizeModal();
|
|
const result = reducer(state, action);
|
|
|
|
assert(
|
|
result.composer?.step === ComposerStep.ChooseGroupMembers &&
|
|
result.composer.recommendedGroupSizeModalState ===
|
|
OneTimeModalState.Shown,
|
|
'Expected the modal to be closed'
|
|
);
|
|
});
|
|
|
|
it('does nothing if the recommended group size modal was never shown', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: defaultChooseGroupMembersComposerState,
|
|
};
|
|
const action = closeRecommendedGroupSizeModal();
|
|
const result = reducer(state, action);
|
|
|
|
assert.strictEqual(result, state);
|
|
});
|
|
|
|
it('does nothing if the recommended group size modal already closed', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: {
|
|
...defaultChooseGroupMembersComposerState,
|
|
recommendedGroupSizeModalState: OneTimeModalState.Shown,
|
|
},
|
|
};
|
|
const action = closeRecommendedGroupSizeModal();
|
|
const result = reducer(state, action);
|
|
|
|
assert.strictEqual(result, state);
|
|
});
|
|
});
|
|
|
|
describe('createGroup', () => {
|
|
const conversationsState = {
|
|
...getEmptyState(),
|
|
composer: {
|
|
...defaultSetGroupMetadataComposerState,
|
|
selectedConversationIds: ['abc123'],
|
|
groupName: 'Foo Bar Group',
|
|
groupAvatar: new Uint8Array([1, 2, 3]),
|
|
},
|
|
};
|
|
|
|
it('immediately dispatches a CREATE_GROUP_PENDING action, which puts the composer in a loading state', () => {
|
|
const dispatch = sinon.spy();
|
|
|
|
createGroup()(
|
|
dispatch,
|
|
() => ({
|
|
...getEmptyRootState(),
|
|
conversations: conversationsState,
|
|
}),
|
|
null
|
|
);
|
|
|
|
sinon.assert.calledOnce(dispatch);
|
|
sinon.assert.calledWith(dispatch, { type: 'CREATE_GROUP_PENDING' });
|
|
|
|
const action = dispatch.getCall(0).args[0];
|
|
|
|
const result = reducer(conversationsState, action);
|
|
|
|
assert(
|
|
result.composer?.step === ComposerStep.SetGroupMetadata &&
|
|
result.composer.isCreating &&
|
|
!result.composer.hasError
|
|
);
|
|
});
|
|
|
|
it('calls groups.createGroupV2', async () => {
|
|
await createGroup(createGroupStub)(
|
|
sinon.spy(),
|
|
() => ({
|
|
...getEmptyRootState(),
|
|
conversations: conversationsState,
|
|
}),
|
|
null
|
|
);
|
|
|
|
sinon.assert.calledOnce(createGroupStub);
|
|
sinon.assert.calledWith(createGroupStub, {
|
|
name: 'Foo Bar Group',
|
|
avatar: new Uint8Array([1, 2, 3]),
|
|
avatars: [],
|
|
expireTimer: 0,
|
|
conversationIds: ['abc123'],
|
|
});
|
|
});
|
|
|
|
it("trims the group's title before calling groups.createGroupV2", async () => {
|
|
await createGroup(createGroupStub)(
|
|
sinon.spy(),
|
|
() => ({
|
|
...getEmptyRootState(),
|
|
conversations: {
|
|
...conversationsState,
|
|
composer: {
|
|
...conversationsState.composer,
|
|
groupName: ' To Trim \t',
|
|
},
|
|
},
|
|
}),
|
|
null
|
|
);
|
|
|
|
sinon.assert.calledWith(
|
|
createGroupStub,
|
|
sinon.match({ name: 'To Trim' })
|
|
);
|
|
});
|
|
|
|
it('dispatches a CREATE_GROUP_REJECTED action if group creation fails, which marks the state with an error', async () => {
|
|
createGroupStub.rejects(new Error('uh oh'));
|
|
|
|
const dispatch = sinon.spy();
|
|
|
|
const createGroupPromise = createGroup(createGroupStub)(
|
|
dispatch,
|
|
() => ({
|
|
...getEmptyRootState(),
|
|
conversations: conversationsState,
|
|
}),
|
|
null
|
|
);
|
|
|
|
const pendingAction = dispatch.getCall(0).args[0];
|
|
const stateAfterPending = reducer(conversationsState, pendingAction);
|
|
|
|
await createGroupPromise;
|
|
|
|
sinon.assert.calledTwice(dispatch);
|
|
sinon.assert.calledWith(dispatch, { type: 'CREATE_GROUP_REJECTED' });
|
|
|
|
const rejectedAction = dispatch.getCall(1).args[0];
|
|
const result = reducer(stateAfterPending, rejectedAction);
|
|
|
|
assert(
|
|
result.composer?.step === ComposerStep.SetGroupMetadata &&
|
|
!result.composer.isCreating &&
|
|
result.composer.hasError
|
|
);
|
|
});
|
|
|
|
it("when rejecting, does nothing to the left pane if it's no longer in this composer state", async () => {
|
|
createGroupStub.rejects(new Error('uh oh'));
|
|
|
|
const dispatch = sinon.spy();
|
|
|
|
const createGroupPromise = createGroup(createGroupStub)(
|
|
dispatch,
|
|
() => ({
|
|
...getEmptyRootState(),
|
|
conversations: conversationsState,
|
|
}),
|
|
null
|
|
);
|
|
|
|
await createGroupPromise;
|
|
|
|
const state = getEmptyState();
|
|
const rejectedAction = dispatch.getCall(1).args[0];
|
|
const result = reducer(state, rejectedAction);
|
|
|
|
assert.strictEqual(result, state);
|
|
});
|
|
|
|
it('dispatches a CREATE_GROUP_FULFILLED event (which updates the newly-created conversation IDs), triggers a showConversation event and switches to the associated conversation on success', async () => {
|
|
const abc = UUID.fromPrefix('abc').toString();
|
|
createGroupStub.resolves({
|
|
id: '9876',
|
|
get: (key: string) => {
|
|
if (key !== 'pendingMembersV2') {
|
|
throw new Error('This getter is not set up for this test');
|
|
}
|
|
return [{ uuid: abc }];
|
|
},
|
|
});
|
|
|
|
const dispatch = sinon.spy();
|
|
|
|
await createGroup(createGroupStub)(
|
|
dispatch,
|
|
() => ({
|
|
...getEmptyRootState(),
|
|
conversations: conversationsState,
|
|
}),
|
|
null
|
|
);
|
|
|
|
sinon.assert.calledWith(dispatch, {
|
|
type: 'CREATE_GROUP_FULFILLED',
|
|
payload: { invitedUuids: [abc] },
|
|
});
|
|
|
|
sinon.assert.calledWith(dispatch, {
|
|
type: SELECTED_CONVERSATION_CHANGED,
|
|
payload: {
|
|
id: '9876',
|
|
messageId: undefined,
|
|
switchToAssociatedView: true,
|
|
},
|
|
});
|
|
|
|
const fulfilledAction = dispatch.getCall(1).args[0];
|
|
const result = reducer(conversationsState, fulfilledAction);
|
|
assert.deepEqual(result.invitedUuidsForNewlyCreatedGroup, [abc]);
|
|
});
|
|
});
|
|
|
|
describe('CONVERSATION_STOPPED_BY_MISSING_VERIFICATION', () => {
|
|
it('adds to state, removing duplicates', () => {
|
|
const first = reducer(
|
|
getEmptyState(),
|
|
conversationStoppedByMissingVerification({
|
|
conversationId: 'convo A',
|
|
untrustedUuids: ['convo 1'],
|
|
})
|
|
);
|
|
const second = reducer(
|
|
first,
|
|
conversationStoppedByMissingVerification({
|
|
conversationId: 'convo A',
|
|
untrustedUuids: ['convo 2'],
|
|
})
|
|
);
|
|
const third = reducer(
|
|
second,
|
|
conversationStoppedByMissingVerification({
|
|
conversationId: 'convo A',
|
|
untrustedUuids: ['convo 1', 'convo 3'],
|
|
})
|
|
);
|
|
|
|
assert.deepStrictEqual(third.verificationDataByConversation, {
|
|
'convo A': {
|
|
type: ConversationVerificationState.PendingVerification,
|
|
uuidsNeedingVerification: ['convo 1', 'convo 2', 'convo 3'],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('stomps on VerificationCancelled state', () => {
|
|
const state: ConversationsStateType = {
|
|
...getEmptyState(),
|
|
verificationDataByConversation: {
|
|
'convo A': {
|
|
type: ConversationVerificationState.VerificationCancelled,
|
|
canceledAt: Date.now(),
|
|
},
|
|
},
|
|
};
|
|
const actual = reducer(
|
|
state,
|
|
conversationStoppedByMissingVerification({
|
|
conversationId: 'convo A',
|
|
untrustedUuids: ['convo 1', 'convo 2'],
|
|
})
|
|
);
|
|
|
|
assert.deepStrictEqual(actual.verificationDataByConversation, {
|
|
'convo A': {
|
|
type: ConversationVerificationState.PendingVerification,
|
|
uuidsNeedingVerification: ['convo 1', 'convo 2'],
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('CANCEL_CONVERSATION_PENDING_VERIFICATION', () => {
|
|
function getAction(
|
|
timestamp: number,
|
|
conversationsState: ConversationsStateType
|
|
): CancelVerificationDataByConversationActionType {
|
|
const dispatch = sinon.spy();
|
|
|
|
cancelConversationVerification(timestamp)(
|
|
dispatch,
|
|
() => ({
|
|
...getEmptyRootState(),
|
|
conversations: conversationsState,
|
|
}),
|
|
null
|
|
);
|
|
|
|
return dispatch.getCall(0).args[0];
|
|
}
|
|
|
|
it('replaces existing PendingVerification state', () => {
|
|
const now = Date.now();
|
|
const state: ConversationsStateType = {
|
|
...getEmptyState(),
|
|
verificationDataByConversation: {
|
|
'convo A': {
|
|
type: ConversationVerificationState.PendingVerification,
|
|
uuidsNeedingVerification: ['convo 1', 'convo 2'],
|
|
},
|
|
},
|
|
};
|
|
const action = getAction(now, state);
|
|
const actual = reducer(state, action);
|
|
|
|
assert.deepStrictEqual(actual.verificationDataByConversation, {
|
|
'convo A': {
|
|
type: ConversationVerificationState.VerificationCancelled,
|
|
canceledAt: now,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('updates timestamp for existing VerificationCancelled state', () => {
|
|
const now = Date.now();
|
|
const state: ConversationsStateType = {
|
|
...getEmptyState(),
|
|
verificationDataByConversation: {
|
|
'convo A': {
|
|
type: ConversationVerificationState.VerificationCancelled,
|
|
canceledAt: now - 1,
|
|
},
|
|
},
|
|
};
|
|
const action = getAction(now, state);
|
|
const actual = reducer(state, action);
|
|
|
|
assert.deepStrictEqual(actual.verificationDataByConversation, {
|
|
'convo A': {
|
|
type: ConversationVerificationState.VerificationCancelled,
|
|
canceledAt: now,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('uses newest timestamp when updating existing VerificationCancelled state', () => {
|
|
const now = Date.now();
|
|
const state: ConversationsStateType = {
|
|
...getEmptyState(),
|
|
verificationDataByConversation: {
|
|
'convo A': {
|
|
type: ConversationVerificationState.VerificationCancelled,
|
|
canceledAt: now,
|
|
},
|
|
},
|
|
};
|
|
const action = getAction(now, state);
|
|
const actual = reducer(state, action);
|
|
|
|
assert.deepStrictEqual(actual.verificationDataByConversation, {
|
|
'convo A': {
|
|
type: ConversationVerificationState.VerificationCancelled,
|
|
canceledAt: now,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('does nothing if no existing state', () => {
|
|
const state: ConversationsStateType = getEmptyState();
|
|
const action = getAction(Date.now(), state);
|
|
const actual = reducer(state, action);
|
|
|
|
assert.strictEqual(actual, state);
|
|
});
|
|
});
|
|
|
|
describe('CANCEL_CONVERSATION_PENDING_VERIFICATION', () => {
|
|
it('removes existing VerificationCancelled state', () => {
|
|
const now = Date.now();
|
|
const state: ConversationsStateType = {
|
|
...getEmptyState(),
|
|
verificationDataByConversation: {
|
|
'convo A': {
|
|
type: ConversationVerificationState.VerificationCancelled,
|
|
canceledAt: now,
|
|
},
|
|
},
|
|
};
|
|
const actual = reducer(
|
|
state,
|
|
clearCancelledConversationVerification('convo A')
|
|
);
|
|
|
|
assert.deepStrictEqual(actual.verificationDataByConversation, {});
|
|
});
|
|
|
|
it('leaves existing PendingVerification state', () => {
|
|
const state: ConversationsStateType = {
|
|
...getEmptyState(),
|
|
verificationDataByConversation: {
|
|
'convo A': {
|
|
type: ConversationVerificationState.PendingVerification,
|
|
uuidsNeedingVerification: ['convo 1', 'convo 2'],
|
|
},
|
|
},
|
|
};
|
|
const actual = reducer(
|
|
state,
|
|
clearCancelledConversationVerification('convo A')
|
|
);
|
|
|
|
assert.deepStrictEqual(actual, state);
|
|
});
|
|
|
|
it('does nothing with empty state', () => {
|
|
const state: ConversationsStateType = getEmptyState();
|
|
const actual = reducer(
|
|
state,
|
|
clearCancelledConversationVerification('convo A')
|
|
);
|
|
|
|
assert.deepStrictEqual(actual, state);
|
|
});
|
|
});
|
|
|
|
describe('REPAIR_NEWEST_MESSAGE', () => {
|
|
it('updates newest', () => {
|
|
const action = repairNewestMessage(conversationId);
|
|
const state: ConversationsStateType = {
|
|
...getEmptyState(),
|
|
messagesLookup: {
|
|
[messageId]: {
|
|
...getDefaultMessage(messageId),
|
|
received_at: time,
|
|
sent_at: time,
|
|
},
|
|
},
|
|
messagesByConversation: {
|
|
[conversationId]: {
|
|
...getDefaultConversationMessage(),
|
|
messageIds: [messageIdThree, messageIdTwo, messageId],
|
|
metrics: {
|
|
totalUnseen: 0,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const expected: ConversationsStateType = {
|
|
...getEmptyState(),
|
|
messagesLookup: {
|
|
[messageId]: {
|
|
...getDefaultMessage(messageId),
|
|
received_at: time,
|
|
sent_at: time,
|
|
},
|
|
},
|
|
messagesByConversation: {
|
|
[conversationId]: {
|
|
...getDefaultConversationMessage(),
|
|
messageIds: [messageIdThree, messageIdTwo, messageId],
|
|
metrics: {
|
|
totalUnseen: 0,
|
|
newest: {
|
|
id: messageId,
|
|
received_at: time,
|
|
sent_at: time,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const actual = reducer(state, action);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('clears newest', () => {
|
|
const action = repairNewestMessage(conversationId);
|
|
const state: ConversationsStateType = {
|
|
...getEmptyState(),
|
|
messagesLookup: {
|
|
[messageId]: {
|
|
...getDefaultMessage(messageId),
|
|
received_at: time,
|
|
},
|
|
},
|
|
messagesByConversation: {
|
|
[conversationId]: {
|
|
...getDefaultConversationMessage(),
|
|
messageIds: [],
|
|
metrics: {
|
|
totalUnseen: 0,
|
|
newest: {
|
|
id: messageId,
|
|
received_at: time,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const expected: ConversationsStateType = {
|
|
...getEmptyState(),
|
|
messagesLookup: {
|
|
[messageId]: {
|
|
...getDefaultMessage(messageId),
|
|
received_at: time,
|
|
},
|
|
},
|
|
messagesByConversation: {
|
|
[conversationId]: {
|
|
...getDefaultConversationMessage(),
|
|
messageIds: [],
|
|
metrics: {
|
|
newest: undefined,
|
|
totalUnseen: 0,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const actual = reducer(state, action);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('returns state if conversation not present', () => {
|
|
const action = repairNewestMessage(conversationId);
|
|
const state: ConversationsStateType = getEmptyState();
|
|
const actual = reducer(state, action);
|
|
|
|
assert.equal(actual, state);
|
|
});
|
|
});
|
|
|
|
describe('REPAIR_OLDEST_MESSAGE', () => {
|
|
it('updates oldest', () => {
|
|
const action = repairOldestMessage(conversationId);
|
|
const state: ConversationsStateType = {
|
|
...getEmptyState(),
|
|
messagesLookup: {
|
|
[messageId]: {
|
|
...getDefaultMessage(messageId),
|
|
received_at: time,
|
|
sent_at: time,
|
|
},
|
|
},
|
|
messagesByConversation: {
|
|
[conversationId]: {
|
|
...getDefaultConversationMessage(),
|
|
messageIds: [messageId, messageIdTwo, messageIdThree],
|
|
metrics: {
|
|
totalUnseen: 0,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const expected: ConversationsStateType = {
|
|
...getEmptyState(),
|
|
messagesLookup: {
|
|
[messageId]: {
|
|
...getDefaultMessage(messageId),
|
|
received_at: time,
|
|
sent_at: time,
|
|
},
|
|
},
|
|
messagesByConversation: {
|
|
[conversationId]: {
|
|
...getDefaultConversationMessage(),
|
|
messageIds: [messageId, messageIdTwo, messageIdThree],
|
|
metrics: {
|
|
totalUnseen: 0,
|
|
oldest: {
|
|
id: messageId,
|
|
received_at: time,
|
|
sent_at: time,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const actual = reducer(state, action);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('clears oldest', () => {
|
|
const action = repairOldestMessage(conversationId);
|
|
const state: ConversationsStateType = {
|
|
...getEmptyState(),
|
|
messagesLookup: {
|
|
[messageId]: {
|
|
...getDefaultMessage(messageId),
|
|
received_at: time,
|
|
},
|
|
},
|
|
messagesByConversation: {
|
|
[conversationId]: {
|
|
...getDefaultConversationMessage(),
|
|
messageIds: [],
|
|
metrics: {
|
|
totalUnseen: 0,
|
|
oldest: {
|
|
id: messageId,
|
|
received_at: time,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const expected: ConversationsStateType = {
|
|
...getEmptyState(),
|
|
messagesLookup: {
|
|
[messageId]: {
|
|
...getDefaultMessage(messageId),
|
|
received_at: time,
|
|
},
|
|
},
|
|
messagesByConversation: {
|
|
[conversationId]: {
|
|
...getDefaultConversationMessage(),
|
|
messageIds: [],
|
|
metrics: {
|
|
oldest: undefined,
|
|
totalUnseen: 0,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const actual = reducer(state, action);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('returns state if conversation not present', () => {
|
|
const action = repairOldestMessage(conversationId);
|
|
const state: ConversationsStateType = getEmptyState();
|
|
const actual = reducer(state, action);
|
|
|
|
assert.equal(actual, state);
|
|
});
|
|
});
|
|
|
|
describe('REVIEW_GROUP_MEMBER_NAME_COLLISION', () => {
|
|
it('starts reviewing a group member name collision', () => {
|
|
const state = getEmptyState();
|
|
const action = reviewGroupMemberNameCollision('abc123');
|
|
const actual = reducer(state, action);
|
|
|
|
assert.deepEqual(actual.contactSpoofingReview, {
|
|
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle as const,
|
|
groupConversationId: 'abc123',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('REVIEW_MESSAGE_REQUEST_NAME_COLLISION', () => {
|
|
it('starts reviewing a message request name collision', () => {
|
|
const state = getEmptyState();
|
|
const action = reviewMessageRequestNameCollision({
|
|
safeConversationId: 'def',
|
|
});
|
|
const actual = reducer(state, action);
|
|
|
|
assert.deepEqual(actual.contactSpoofingReview, {
|
|
type: ContactSpoofingType.DirectConversationWithSameTitle as const,
|
|
safeConversationId: 'def',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('SET_COMPOSE_GROUP_AVATAR', () => {
|
|
it("can clear the composer's group avatar", () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: {
|
|
...defaultSetGroupMetadataComposerState,
|
|
groupAvatar: new Uint8Array(2),
|
|
},
|
|
};
|
|
const action = setComposeGroupAvatar(undefined);
|
|
const result = reducer(state, action);
|
|
|
|
assert(
|
|
result.composer?.step === ComposerStep.SetGroupMetadata &&
|
|
result.composer.groupAvatar === undefined
|
|
);
|
|
});
|
|
|
|
it("can set the composer's group avatar", () => {
|
|
const avatar = new Uint8Array([1, 2, 3]);
|
|
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: defaultSetGroupMetadataComposerState,
|
|
};
|
|
const action = setComposeGroupAvatar(avatar);
|
|
const result = reducer(state, action);
|
|
|
|
assert(
|
|
result.composer?.step === ComposerStep.SetGroupMetadata &&
|
|
result.composer.groupAvatar === avatar
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('SET_COMPOSE_GROUP_NAME', () => {
|
|
it("can set the composer's group name", () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: defaultSetGroupMetadataComposerState,
|
|
};
|
|
const action = setComposeGroupName('bing bong');
|
|
const result = reducer(state, action);
|
|
|
|
assert(
|
|
result.composer?.step === ComposerStep.SetGroupMetadata &&
|
|
result.composer.groupName === 'bing bong'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('SET_COMPOSE_SEARCH_TERM', () => {
|
|
it('updates the contact search term', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: defaultStartDirectConversationComposerState,
|
|
};
|
|
|
|
const result = reducer(state, setComposeSearchTerm('foo bar'));
|
|
|
|
assert.deepEqual(result.composer, {
|
|
...defaultStartDirectConversationComposerState,
|
|
searchTerm: 'foo bar',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('DISCARD_MESSAGES', () => {
|
|
const startState: ConversationsStateType = {
|
|
...getEmptyState(),
|
|
messagesLookup: {
|
|
[messageId]: getDefaultMessage(messageId),
|
|
[messageIdTwo]: getDefaultMessage(messageIdTwo),
|
|
[messageIdThree]: getDefaultMessage(messageIdThree),
|
|
},
|
|
messagesByConversation: {
|
|
[conversationId]: {
|
|
messageChangeCounter: 0,
|
|
metrics: {
|
|
totalUnseen: 0,
|
|
},
|
|
scrollToMessageCounter: 0,
|
|
messageIds: [messageId, messageIdTwo, messageIdThree],
|
|
},
|
|
},
|
|
};
|
|
|
|
it('eliminates older messages', () => {
|
|
const toDiscard = {
|
|
conversationId,
|
|
numberToKeepAtBottom: 2,
|
|
};
|
|
const state = reducer(startState, discardMessages(toDiscard));
|
|
|
|
assert.deepEqual(
|
|
state.messagesByConversation[conversationId]?.messageIds,
|
|
[messageIdTwo, messageIdThree]
|
|
);
|
|
});
|
|
|
|
it('eliminates newer messages', () => {
|
|
const toDiscard = {
|
|
conversationId,
|
|
numberToKeepAtTop: 2,
|
|
};
|
|
const state = reducer(startState, discardMessages(toDiscard));
|
|
|
|
assert.deepEqual(
|
|
state.messagesByConversation[conversationId]?.messageIds,
|
|
[messageId, messageIdTwo]
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('SET_PRE_JOIN_CONVERSATION', () => {
|
|
const startState = {
|
|
...getEmptyState(),
|
|
};
|
|
|
|
it('starts with empty value', () => {
|
|
assert.isUndefined(startState.preJoinConversation);
|
|
});
|
|
|
|
it('sets value as provided', () => {
|
|
const preJoinConversation = {
|
|
title: 'Pre-join group!',
|
|
memberCount: 4,
|
|
approvalRequired: false,
|
|
};
|
|
const stateWithData = reducer(
|
|
startState,
|
|
setPreJoinConversation(preJoinConversation)
|
|
);
|
|
|
|
assert.deepEqual(
|
|
stateWithData.preJoinConversation,
|
|
preJoinConversation
|
|
);
|
|
|
|
const resetState = reducer(
|
|
stateWithData,
|
|
setPreJoinConversation(undefined)
|
|
);
|
|
|
|
assert.isUndefined(resetState.preJoinConversation);
|
|
});
|
|
});
|
|
|
|
describe('MESSAGE_CHANGED', () => {
|
|
const startState: ConversationsStateType = {
|
|
...getEmptyState(),
|
|
conversationLookup: {
|
|
[conversationId]: {
|
|
...getDefaultConversation(),
|
|
id: conversationId,
|
|
groupVersion: 2,
|
|
groupId: 'dGhpc2lzYWdyb3VwaWR0aGlzaXNhZ3JvdXBpZHRoaXM=',
|
|
},
|
|
},
|
|
messagesByConversation: {
|
|
[conversationId]: {
|
|
messageChangeCounter: 0,
|
|
messageIds: [messageId, messageIdTwo, messageIdThree],
|
|
metrics: {
|
|
totalUnseen: 0,
|
|
},
|
|
scrollToMessageCounter: 0,
|
|
},
|
|
},
|
|
messagesLookup: {
|
|
[messageId]: {
|
|
...getDefaultMessage(messageId),
|
|
displayLimit: undefined,
|
|
},
|
|
[messageIdTwo]: {
|
|
...getDefaultMessage(messageIdTwo),
|
|
displayLimit: undefined,
|
|
},
|
|
[messageIdThree]: {
|
|
...getDefaultMessage(messageIdThree),
|
|
displayLimit: undefined,
|
|
},
|
|
},
|
|
};
|
|
const changedMessage = {
|
|
...getDefaultMessage(messageId),
|
|
body: 'changed',
|
|
displayLimit: undefined,
|
|
};
|
|
|
|
it('updates message data', () => {
|
|
const state = reducer(
|
|
startState,
|
|
messageChanged(messageId, conversationId, changedMessage)
|
|
);
|
|
|
|
assert.deepEqual(state.messagesLookup[messageId], changedMessage);
|
|
assert.strictEqual(
|
|
state.messagesByConversation[conversationId]?.messageChangeCounter,
|
|
0
|
|
);
|
|
});
|
|
|
|
it('does not update lookup if it is a story reply', () => {
|
|
const state = reducer(
|
|
startState,
|
|
messageChanged(messageId, conversationId, {
|
|
...changedMessage,
|
|
storyId: 'story-id',
|
|
})
|
|
);
|
|
|
|
assert.deepEqual(
|
|
state.messagesLookup[messageId],
|
|
startState.messagesLookup[messageId]
|
|
);
|
|
assert.strictEqual(
|
|
state.messagesByConversation[conversationId]?.messageChangeCounter,
|
|
0
|
|
);
|
|
});
|
|
|
|
it('increments message change counter if new message has reactions', () => {
|
|
const changedMessageWithReaction: MessageType = {
|
|
...changedMessage,
|
|
reactions: [
|
|
{
|
|
emoji: '🎁',
|
|
fromId: 'some-other-id',
|
|
timestamp: 2222,
|
|
targetTimestamp: 1111,
|
|
targetAuthorUuid: 'author-uuid',
|
|
},
|
|
],
|
|
};
|
|
const state = reducer(
|
|
startState,
|
|
messageChanged(messageId, conversationId, changedMessageWithReaction)
|
|
);
|
|
|
|
assert.deepEqual(
|
|
state.messagesLookup[messageId],
|
|
changedMessageWithReaction
|
|
);
|
|
assert.strictEqual(
|
|
state.messagesByConversation[conversationId]?.messageChangeCounter,
|
|
1
|
|
);
|
|
});
|
|
|
|
it('does not increment message change counter if only old message had reactions', () => {
|
|
const updatedStartState = {
|
|
...startState,
|
|
messagesLookup: {
|
|
[messageId]: {
|
|
...startState.messagesLookup[messageId],
|
|
reactions: [
|
|
{
|
|
emoji: '🎁',
|
|
fromId: 'some-other-id',
|
|
timestamp: 2222,
|
|
targetTimestamp: 1111,
|
|
targetAuthorUuid: 'author-uuid',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
const state = reducer(
|
|
updatedStartState,
|
|
messageChanged(messageId, conversationId, changedMessage)
|
|
);
|
|
|
|
assert.deepEqual(state.messagesLookup[messageId], changedMessage);
|
|
assert.strictEqual(
|
|
state.messagesByConversation[conversationId]?.messageChangeCounter,
|
|
0
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('SHOW_ARCHIVED_CONVERSATIONS', () => {
|
|
it('is a no-op when already at the archive', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
showArchived: true,
|
|
};
|
|
const action = showArchivedConversations();
|
|
const result = reducer(state, action);
|
|
|
|
assert.isTrue(result.showArchived);
|
|
assert.isUndefined(result.composer);
|
|
});
|
|
|
|
it('switches from the inbox to the archive', () => {
|
|
const state = getEmptyState();
|
|
const action = showArchivedConversations();
|
|
const result = reducer(state, action);
|
|
|
|
assert.isTrue(result.showArchived);
|
|
assert.isUndefined(result.composer);
|
|
});
|
|
|
|
it('switches from the composer to the archive', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: defaultStartDirectConversationComposerState,
|
|
};
|
|
const action = showArchivedConversations();
|
|
const result = reducer(state, action);
|
|
|
|
assert.isTrue(result.showArchived);
|
|
assert.isUndefined(result.composer);
|
|
});
|
|
});
|
|
|
|
describe('SHOW_INBOX', () => {
|
|
it('is a no-op when already at the inbox', () => {
|
|
const state = getEmptyState();
|
|
const action = showInbox();
|
|
const result = reducer(state, action);
|
|
|
|
assert.isFalse(result.showArchived);
|
|
assert.isUndefined(result.composer);
|
|
});
|
|
|
|
it('switches from the archive to the inbox', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
showArchived: true,
|
|
};
|
|
const action = showInbox();
|
|
const result = reducer(state, action);
|
|
|
|
assert.isFalse(result.showArchived);
|
|
assert.isUndefined(result.composer);
|
|
});
|
|
|
|
it('switches from the composer to the inbox', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: defaultStartDirectConversationComposerState,
|
|
};
|
|
const action = showInbox();
|
|
const result = reducer(state, action);
|
|
|
|
assert.isFalse(result.showArchived);
|
|
assert.isUndefined(result.composer);
|
|
});
|
|
});
|
|
|
|
describe('START_COMPOSING', () => {
|
|
it('does nothing if on the first step of the composer', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: defaultStartDirectConversationComposerState,
|
|
};
|
|
const action = startComposing();
|
|
const result = reducer(state, action);
|
|
|
|
assert.isFalse(result.showArchived);
|
|
assert.deepEqual(
|
|
result.composer,
|
|
defaultStartDirectConversationComposerState
|
|
);
|
|
});
|
|
|
|
it('if on the second step of the composer, goes back to the first step, clearing the search term', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: {
|
|
...defaultChooseGroupMembersComposerState,
|
|
searchTerm: 'to be cleared',
|
|
},
|
|
};
|
|
const action = startComposing();
|
|
const result = reducer(state, action);
|
|
|
|
assert.isFalse(result.showArchived);
|
|
assert.deepEqual(
|
|
result.composer,
|
|
defaultStartDirectConversationComposerState
|
|
);
|
|
});
|
|
|
|
it('if on the third step of the composer, goes back to the first step, clearing everything', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: defaultSetGroupMetadataComposerState,
|
|
};
|
|
const action = startComposing();
|
|
const result = reducer(state, action);
|
|
|
|
assert.isFalse(result.showArchived);
|
|
assert.deepEqual(
|
|
result.composer,
|
|
defaultStartDirectConversationComposerState
|
|
);
|
|
});
|
|
|
|
it('switches from the inbox to the composer', () => {
|
|
const state = getEmptyState();
|
|
const action = startComposing();
|
|
const result = reducer(state, action);
|
|
|
|
assert.isFalse(result.showArchived);
|
|
assert.deepEqual(
|
|
result.composer,
|
|
defaultStartDirectConversationComposerState
|
|
);
|
|
});
|
|
|
|
it('switches from the archive to the inbox', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
showArchived: true,
|
|
};
|
|
const action = startComposing();
|
|
const result = reducer(state, action);
|
|
|
|
assert.isFalse(result.showArchived);
|
|
assert.deepEqual(
|
|
result.composer,
|
|
defaultStartDirectConversationComposerState
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('SHOW_CHOOSE_GROUP_MEMBERS', () => {
|
|
it('switches to the second step of the composer if on the first step', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: {
|
|
...defaultStartDirectConversationComposerState,
|
|
searchTerm: 'to be cleared',
|
|
},
|
|
};
|
|
const action = showChooseGroupMembers();
|
|
const result = reducer(state, action);
|
|
|
|
assert.isFalse(result.showArchived);
|
|
assert.deepEqual(result.composer, {
|
|
...defaultChooseGroupMembersComposerState,
|
|
userAvatarData: getDefaultAvatars(true),
|
|
});
|
|
});
|
|
|
|
it('does nothing if already on the second step of the composer', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: defaultChooseGroupMembersComposerState,
|
|
};
|
|
const action = showChooseGroupMembers();
|
|
const result = reducer(state, action);
|
|
|
|
assert.strictEqual(result, state);
|
|
});
|
|
|
|
it('returns to the second step if on the third step of the composer', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: {
|
|
...defaultSetGroupMetadataComposerState,
|
|
groupName: 'Foo Bar Group',
|
|
groupAvatar: new Uint8Array([4, 2]),
|
|
},
|
|
};
|
|
const action = showChooseGroupMembers();
|
|
const result = reducer(state, action);
|
|
|
|
assert.isFalse(result.showArchived);
|
|
assert.deepEqual(result.composer, {
|
|
...defaultChooseGroupMembersComposerState,
|
|
groupName: 'Foo Bar Group',
|
|
groupAvatar: new Uint8Array([4, 2]),
|
|
});
|
|
});
|
|
|
|
it('switches from the inbox to the second step of the composer', () => {
|
|
const state = getEmptyState();
|
|
const action = showChooseGroupMembers();
|
|
const result = reducer(state, action);
|
|
|
|
assert.isFalse(result.showArchived);
|
|
assert.deepEqual(result.composer, {
|
|
...defaultChooseGroupMembersComposerState,
|
|
userAvatarData: getDefaultAvatars(true),
|
|
});
|
|
});
|
|
|
|
it('switches from the archive to the second step of the composer', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
showArchived: true,
|
|
};
|
|
const action = showChooseGroupMembers();
|
|
const result = reducer(state, action);
|
|
|
|
assert.isFalse(result.showArchived);
|
|
assert.deepEqual(result.composer, {
|
|
...defaultChooseGroupMembersComposerState,
|
|
userAvatarData: getDefaultAvatars(true),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('START_SETTING_GROUP_METADATA', () => {
|
|
it('moves from the second to the third step of the composer', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: {
|
|
...defaultChooseGroupMembersComposerState,
|
|
selectedConversationIds: ['abc', 'def'],
|
|
},
|
|
};
|
|
const action = startSettingGroupMetadata();
|
|
const result = reducer(state, action);
|
|
|
|
assert.deepEqual(result.composer, {
|
|
...defaultSetGroupMetadataComposerState,
|
|
selectedConversationIds: ['abc', 'def'],
|
|
});
|
|
});
|
|
|
|
it('maintains state when going from the second to third steps of the composer, if the second step already had some data (likely from a previous visit)', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: {
|
|
...defaultChooseGroupMembersComposerState,
|
|
searchTerm: 'foo bar',
|
|
selectedConversationIds: ['abc', 'def'],
|
|
groupName: 'Foo Bar Group',
|
|
groupAvatar: new Uint8Array([6, 9]),
|
|
},
|
|
};
|
|
const action = startSettingGroupMetadata();
|
|
const result = reducer(state, action);
|
|
|
|
assert.deepEqual(result.composer, {
|
|
...defaultSetGroupMetadataComposerState,
|
|
selectedConversationIds: ['abc', 'def'],
|
|
groupName: 'Foo Bar Group',
|
|
groupAvatar: new Uint8Array([6, 9]),
|
|
});
|
|
});
|
|
|
|
it('does nothing if already on the third step of the composer', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: defaultSetGroupMetadataComposerState,
|
|
};
|
|
const action = startSettingGroupMetadata();
|
|
const result = reducer(state, action);
|
|
|
|
assert.strictEqual(result, state);
|
|
});
|
|
});
|
|
|
|
describe('TOGGLE_CONVERSATION_IN_CHOOSE_MEMBERS', () => {
|
|
function getAction(
|
|
id: string,
|
|
conversationsState: ConversationsStateType
|
|
): ToggleConversationInChooseMembersActionType {
|
|
const dispatch = sinon.spy();
|
|
|
|
toggleConversationInChooseMembers(id)(
|
|
dispatch,
|
|
() => ({
|
|
...getEmptyRootState(),
|
|
conversations: conversationsState,
|
|
}),
|
|
null
|
|
);
|
|
|
|
return dispatch.getCall(0).args[0];
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await updateRemoteConfig([
|
|
{ name: 'global.groupsv2.maxGroupSize', value: '22', enabled: true },
|
|
{
|
|
name: 'global.groupsv2.groupSizeHardLimit',
|
|
value: '33',
|
|
enabled: true,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('adds conversation IDs to the list', () => {
|
|
const zero = {
|
|
...getEmptyState(),
|
|
composer: defaultChooseGroupMembersComposerState,
|
|
};
|
|
const one = reducer(zero, getAction('abc', zero));
|
|
const two = reducer(one, getAction('def', one));
|
|
|
|
assert.deepEqual(two.composer, {
|
|
...defaultChooseGroupMembersComposerState,
|
|
selectedConversationIds: ['abc', 'def'],
|
|
});
|
|
});
|
|
|
|
it('removes conversation IDs from the list', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: {
|
|
...defaultChooseGroupMembersComposerState,
|
|
selectedConversationIds: ['abc', 'def'],
|
|
},
|
|
};
|
|
const action = getAction('abc', state);
|
|
const result = reducer(state, action);
|
|
|
|
assert.deepEqual(result.composer, {
|
|
...defaultChooseGroupMembersComposerState,
|
|
selectedConversationIds: ['def'],
|
|
});
|
|
});
|
|
|
|
it('shows the recommended group size modal when first crossing the maximum recommended group size', () => {
|
|
const oldSelectedConversationIds = times(21, () => uuid());
|
|
const newUuid = uuid();
|
|
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: {
|
|
...defaultChooseGroupMembersComposerState,
|
|
selectedConversationIds: oldSelectedConversationIds,
|
|
},
|
|
};
|
|
const action = getAction(newUuid, state);
|
|
const result = reducer(state, action);
|
|
|
|
assert.deepEqual(result.composer, {
|
|
...defaultChooseGroupMembersComposerState,
|
|
selectedConversationIds: [...oldSelectedConversationIds, newUuid],
|
|
recommendedGroupSizeModalState: OneTimeModalState.Showing,
|
|
});
|
|
});
|
|
|
|
it("doesn't show the recommended group size modal twice", () => {
|
|
const oldSelectedConversationIds = times(21, () => uuid());
|
|
const newUuid = uuid();
|
|
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: {
|
|
...defaultChooseGroupMembersComposerState,
|
|
selectedConversationIds: oldSelectedConversationIds,
|
|
recommendedGroupSizeModalState: OneTimeModalState.Shown,
|
|
},
|
|
};
|
|
const action = getAction(newUuid, state);
|
|
const result = reducer(state, action);
|
|
|
|
assert.deepEqual(result.composer, {
|
|
...defaultChooseGroupMembersComposerState,
|
|
selectedConversationIds: [...oldSelectedConversationIds, newUuid],
|
|
recommendedGroupSizeModalState: OneTimeModalState.Shown,
|
|
});
|
|
});
|
|
|
|
it('defaults the maximum recommended size to 151', async () => {
|
|
for (const value of [null, 'xyz']) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await updateRemoteConfig([
|
|
{
|
|
name: 'global.groupsv2.maxGroupSize',
|
|
value,
|
|
enabled: true,
|
|
},
|
|
{
|
|
name: 'global.groupsv2.groupSizeHardLimit',
|
|
value: '33',
|
|
enabled: true,
|
|
},
|
|
]);
|
|
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: defaultChooseGroupMembersComposerState,
|
|
};
|
|
const action = getAction(uuid(), state);
|
|
|
|
assert.strictEqual(action.payload.maxRecommendedGroupSize, 151);
|
|
}
|
|
});
|
|
|
|
it('shows the maximum group size modal when first reaching the maximum group size', () => {
|
|
const oldSelectedConversationIds = times(31, () => uuid());
|
|
const newUuid = uuid();
|
|
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: {
|
|
...defaultChooseGroupMembersComposerState,
|
|
selectedConversationIds: oldSelectedConversationIds,
|
|
recommendedGroupSizeModalState: OneTimeModalState.Shown,
|
|
maximumGroupSizeModalState: OneTimeModalState.NeverShown,
|
|
},
|
|
};
|
|
const action = getAction(newUuid, state);
|
|
const result = reducer(state, action);
|
|
|
|
assert.deepEqual(result.composer, {
|
|
...defaultChooseGroupMembersComposerState,
|
|
selectedConversationIds: [...oldSelectedConversationIds, newUuid],
|
|
recommendedGroupSizeModalState: OneTimeModalState.Shown,
|
|
maximumGroupSizeModalState: OneTimeModalState.Showing,
|
|
});
|
|
});
|
|
|
|
it("doesn't show the maximum group size modal twice", () => {
|
|
const oldSelectedConversationIds = times(31, () => uuid());
|
|
const newUuid = uuid();
|
|
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: {
|
|
...defaultChooseGroupMembersComposerState,
|
|
selectedConversationIds: oldSelectedConversationIds,
|
|
recommendedGroupSizeModalState: OneTimeModalState.Shown,
|
|
maximumGroupSizeModalState: OneTimeModalState.Shown,
|
|
},
|
|
};
|
|
const action = getAction(newUuid, state);
|
|
const result = reducer(state, action);
|
|
|
|
assert.deepEqual(result.composer, {
|
|
...defaultChooseGroupMembersComposerState,
|
|
selectedConversationIds: [...oldSelectedConversationIds, newUuid],
|
|
recommendedGroupSizeModalState: OneTimeModalState.Shown,
|
|
maximumGroupSizeModalState: OneTimeModalState.Shown,
|
|
});
|
|
});
|
|
|
|
it('cannot select more than the maximum number of conversations', () => {
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: {
|
|
...defaultChooseGroupMembersComposerState,
|
|
selectedConversationIds: times(1000, () => uuid()),
|
|
},
|
|
};
|
|
const action = getAction(uuid(), state);
|
|
const result = reducer(state, action);
|
|
|
|
assert.deepEqual(result, state);
|
|
});
|
|
|
|
it('defaults the maximum group size to 1001 if the recommended maximum is smaller', async () => {
|
|
for (const value of [null, 'xyz']) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await updateRemoteConfig([
|
|
{ name: 'global.groupsv2.maxGroupSize', value: '2', enabled: true },
|
|
{
|
|
name: 'global.groupsv2.groupSizeHardLimit',
|
|
value,
|
|
enabled: true,
|
|
},
|
|
]);
|
|
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: defaultChooseGroupMembersComposerState,
|
|
};
|
|
const action = getAction(uuid(), state);
|
|
|
|
assert.strictEqual(action.payload.maxGroupSize, 1001);
|
|
}
|
|
});
|
|
|
|
it('defaults the maximum group size to (recommended maximum + 1) if the recommended maximum is more than 1001', async () => {
|
|
await updateRemoteConfig([
|
|
{
|
|
name: 'global.groupsv2.maxGroupSize',
|
|
value: '1234',
|
|
enabled: true,
|
|
},
|
|
{
|
|
name: 'global.groupsv2.groupSizeHardLimit',
|
|
value: '2',
|
|
enabled: true,
|
|
},
|
|
]);
|
|
|
|
const state = {
|
|
...getEmptyState(),
|
|
composer: defaultChooseGroupMembersComposerState,
|
|
};
|
|
const action = getAction(uuid(), state);
|
|
|
|
assert.strictEqual(action.payload.maxGroupSize, 1235);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('COLORS_CHANGED', () => {
|
|
const abc = getDefaultConversationWithUuid({
|
|
id: 'abc',
|
|
conversationColor: 'wintergreen',
|
|
});
|
|
const def = getDefaultConversationWithUuid({
|
|
id: 'def',
|
|
conversationColor: 'infrared',
|
|
});
|
|
const ghi = getDefaultConversation({
|
|
id: 'ghi',
|
|
e164: 'ghi',
|
|
conversationColor: 'ember',
|
|
});
|
|
const jkl = getDefaultConversation({
|
|
id: 'jkl',
|
|
groupId: 'jkl',
|
|
conversationColor: 'plum',
|
|
});
|
|
const getState = () => ({
|
|
...getEmptyRootState(),
|
|
conversations: {
|
|
...getEmptyState(),
|
|
conversationLookup: {
|
|
abc,
|
|
def,
|
|
ghi,
|
|
jkl,
|
|
},
|
|
conversationsByUuid: {
|
|
abc,
|
|
def,
|
|
},
|
|
conversationsByE164: {
|
|
ghi,
|
|
},
|
|
conversationsByGroupId: {
|
|
jkl,
|
|
},
|
|
},
|
|
});
|
|
|
|
it('resetAllChatColors', async () => {
|
|
const dispatch = sinon.spy();
|
|
await resetAllChatColors()(dispatch, getState, null);
|
|
|
|
const [action] = dispatch.getCall(0).args;
|
|
const nextState = reducer(getState().conversations, action);
|
|
|
|
sinon.assert.calledOnce(dispatch);
|
|
assert.isUndefined(nextState.conversationLookup.abc.conversationColor);
|
|
assert.isUndefined(nextState.conversationLookup.def.conversationColor);
|
|
assert.isUndefined(nextState.conversationLookup.ghi.conversationColor);
|
|
assert.isUndefined(nextState.conversationLookup.jkl.conversationColor);
|
|
assert.isUndefined(
|
|
nextState.conversationsByUuid[abc.uuid].conversationColor
|
|
);
|
|
assert.isUndefined(
|
|
nextState.conversationsByUuid[def.uuid].conversationColor
|
|
);
|
|
assert.isUndefined(nextState.conversationsByE164.ghi.conversationColor);
|
|
assert.isUndefined(
|
|
nextState.conversationsByGroupId.jkl.conversationColor
|
|
);
|
|
window.storage.remove('defaultConversationColor');
|
|
});
|
|
});
|
|
});
|