Add "new conversation" composer for direct messages
This commit is contained in:
parent
84dc166b63
commit
06fb4fd0bc
61 changed files with 5960 additions and 3887 deletions
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2019-2020 Signal Messenger, LLC
|
||||
// Copyright 2019-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
@ -11,13 +11,19 @@ import {
|
|||
import {
|
||||
_getConversationComparator,
|
||||
_getLeftPaneLists,
|
||||
getComposeContacts,
|
||||
getComposerContactSearchTerm,
|
||||
getConversationSelector,
|
||||
getIsConversationEmptySelector,
|
||||
getPlaceholderContact,
|
||||
getSelectedConversation,
|
||||
getSelectedConversationId,
|
||||
isComposing,
|
||||
} from '../../../state/selectors/conversations';
|
||||
import { noopAction } from '../../../state/ducks/noop';
|
||||
import { StateType, reducer as rootReducer } from '../../../state/reducer';
|
||||
import { setup as setupI18n } from '../../../../js/modules/i18n';
|
||||
import enMessages from '../../../../_locales/en/messages.json';
|
||||
|
||||
describe('both/state/selectors/conversations', () => {
|
||||
const getEmptyRootState = (): StateType => {
|
||||
|
@ -32,6 +38,8 @@ describe('both/state/selectors/conversations', () => {
|
|||
};
|
||||
}
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
describe('#getConversationSelector', () => {
|
||||
it('returns empty placeholder if falsey id provided', () => {
|
||||
const state = getEmptyRootState();
|
||||
|
@ -211,6 +219,217 @@ describe('both/state/selectors/conversations', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('#getIsConversationEmptySelector', () => {
|
||||
it('returns a selector that returns true for conversations that have no messages', () => {
|
||||
const state = {
|
||||
...getEmptyRootState(),
|
||||
conversations: {
|
||||
...getEmptyState(),
|
||||
messagesByConversation: {
|
||||
abc123: {
|
||||
heightChangeMessageIds: [],
|
||||
isLoadingMessages: false,
|
||||
messageIds: [],
|
||||
metrics: { totalUnread: 0 },
|
||||
resetCounter: 0,
|
||||
scrollToMessageCounter: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const selector = getIsConversationEmptySelector(state);
|
||||
|
||||
assert.isTrue(selector('abc123'));
|
||||
});
|
||||
|
||||
it('returns a selector that returns true for conversations that have no messages, even if loading', () => {
|
||||
const state = {
|
||||
...getEmptyRootState(),
|
||||
conversations: {
|
||||
...getEmptyState(),
|
||||
messagesByConversation: {
|
||||
abc123: {
|
||||
heightChangeMessageIds: [],
|
||||
isLoadingMessages: true,
|
||||
messageIds: [],
|
||||
metrics: { totalUnread: 0 },
|
||||
resetCounter: 0,
|
||||
scrollToMessageCounter: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const selector = getIsConversationEmptySelector(state);
|
||||
|
||||
assert.isTrue(selector('abc123'));
|
||||
});
|
||||
|
||||
it('returns a selector that returns false for conversations that have messages', () => {
|
||||
const state = {
|
||||
...getEmptyRootState(),
|
||||
conversations: {
|
||||
...getEmptyState(),
|
||||
messagesByConversation: {
|
||||
abc123: {
|
||||
heightChangeMessageIds: [],
|
||||
isLoadingMessages: false,
|
||||
messageIds: ['xyz'],
|
||||
metrics: { totalUnread: 0 },
|
||||
resetCounter: 0,
|
||||
scrollToMessageCounter: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const selector = getIsConversationEmptySelector(state);
|
||||
|
||||
assert.isFalse(selector('abc123'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isComposing', () => {
|
||||
it('returns false if there is no composer state', () => {
|
||||
assert.isFalse(isComposing(getEmptyRootState()));
|
||||
});
|
||||
|
||||
it('returns true if there is composer state', () => {
|
||||
assert.isTrue(
|
||||
isComposing({
|
||||
...getEmptyRootState(),
|
||||
conversations: {
|
||||
...getEmptyState(),
|
||||
composer: {
|
||||
contactSearchTerm: '',
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getComposeContacts', () => {
|
||||
const getRootState = (contactSearchTerm = ''): StateType => {
|
||||
const rootState = getEmptyRootState();
|
||||
return {
|
||||
...rootState,
|
||||
conversations: {
|
||||
...getEmptyState(),
|
||||
conversationLookup: {
|
||||
'our-conversation-id': {
|
||||
...getDefaultConversation('our-conversation-id'),
|
||||
isMe: true,
|
||||
},
|
||||
},
|
||||
composer: {
|
||||
contactSearchTerm,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
...rootState.user,
|
||||
ourConversationId: 'our-conversation-id',
|
||||
i18n,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getRootStateWithConverastions = (
|
||||
contactSearchTerm = ''
|
||||
): StateType => {
|
||||
const result = getRootState(contactSearchTerm);
|
||||
Object.assign(result.conversations.conversationLookup, {
|
||||
'convo-1': {
|
||||
...getDefaultConversation('convo-1'),
|
||||
name: 'In System Contacts',
|
||||
title: 'A. Sorted First',
|
||||
},
|
||||
'convo-2': {
|
||||
...getDefaultConversation('convo-2'),
|
||||
title: 'Should Be Dropped (no name, no profile sharing)',
|
||||
},
|
||||
'convo-3': {
|
||||
...getDefaultConversation('convo-3'),
|
||||
type: 'group',
|
||||
title: 'Should Be Dropped (group)',
|
||||
},
|
||||
'convo-4': {
|
||||
...getDefaultConversation('convo-4'),
|
||||
isBlocked: true,
|
||||
title: 'Should Be Dropped (blocked)',
|
||||
},
|
||||
'convo-5': {
|
||||
...getDefaultConversation('convo-5'),
|
||||
discoveredUnregisteredAt: new Date(1999, 3, 20).getTime(),
|
||||
title: 'Should Be Dropped (unregistered)',
|
||||
},
|
||||
'convo-6': {
|
||||
...getDefaultConversation('convo-6'),
|
||||
profileSharing: true,
|
||||
title: 'C. Has Profile Sharing',
|
||||
},
|
||||
'convo-7': {
|
||||
...getDefaultConversation('convo-7'),
|
||||
discoveredUnregisteredAt: Date.now(),
|
||||
name: 'In System Contacts (and only recently unregistered)',
|
||||
title: 'B. Sorted Second',
|
||||
},
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
it('only returns Note to Self when there are no other contacts', () => {
|
||||
const state = getRootState();
|
||||
const result = getComposeContacts(state);
|
||||
|
||||
assert.lengthOf(result, 1);
|
||||
assert.strictEqual(result[0]?.id, 'our-conversation-id');
|
||||
});
|
||||
|
||||
it("returns no results when search doesn't match Note to Self and there are no other contacts", () => {
|
||||
const state = getRootState('foo bar baz');
|
||||
const result = getComposeContacts(state);
|
||||
|
||||
assert.isEmpty(result);
|
||||
});
|
||||
|
||||
it('returns contacts with Note to Self at the end when there is no search term', () => {
|
||||
const state = getRootStateWithConverastions();
|
||||
const result = getComposeContacts(state);
|
||||
|
||||
const ids = result.map(contact => contact.id);
|
||||
assert.deepEqual(ids, [
|
||||
'convo-1',
|
||||
'convo-7',
|
||||
'convo-6',
|
||||
'our-conversation-id',
|
||||
]);
|
||||
});
|
||||
|
||||
it('can search for contacts', () => {
|
||||
const state = getRootStateWithConverastions('in system');
|
||||
const result = getComposeContacts(state);
|
||||
|
||||
const ids = result.map(contact => contact.id);
|
||||
assert.deepEqual(ids, ['convo-1', 'convo-7']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getComposerContactSearchTerm', () => {
|
||||
it("returns the composer's contact search term", () => {
|
||||
assert.strictEqual(
|
||||
getComposerContactSearchTerm({
|
||||
...getEmptyRootState(),
|
||||
conversations: {
|
||||
...getEmptyState(),
|
||||
composer: {
|
||||
contactSearchTerm: 'foo bar',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'foo bar'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getLeftPaneList', () => {
|
||||
it('sorts conversations based on timestamp then by intl-friendly title', () => {
|
||||
const data: ConversationLookupType = {
|
||||
|
|
|
@ -9,9 +9,16 @@ import {
|
|||
MessageType,
|
||||
} from '../../../state/ducks/conversations';
|
||||
import { noopAction } from '../../../state/ducks/noop';
|
||||
import { getEmptyState as getEmptySearchState } from '../../../state/ducks/search';
|
||||
import {
|
||||
getEmptyState as getEmptySearchState,
|
||||
MessageSearchResultType,
|
||||
} from '../../../state/ducks/search';
|
||||
import { getEmptyState as getEmptyUserState } from '../../../state/ducks/user';
|
||||
import { getMessageSearchResultSelector } from '../../../state/selectors/search';
|
||||
import {
|
||||
getMessageSearchResultSelector,
|
||||
getSearchResults,
|
||||
} from '../../../state/selectors/search';
|
||||
import { makeLookup } from '../../../util/makeLookup';
|
||||
|
||||
import { StateType, reducer as rootReducer } from '../../../state/reducer';
|
||||
|
||||
|
@ -34,6 +41,13 @@ describe('both/state/selectors/search', () => {
|
|||
};
|
||||
}
|
||||
|
||||
function getDefaultSearchMessage(id: string): MessageSearchResultType {
|
||||
return {
|
||||
...getDefaultMessage(id),
|
||||
snippet: 'foo bar',
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultConversation(id: string): ConversationType {
|
||||
return {
|
||||
id,
|
||||
|
@ -209,4 +223,81 @@ describe('both/state/selectors/search', () => {
|
|||
assert.notStrictEqual(actual, thirdActual);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getSearchResults', () => {
|
||||
it("returns loading search results when they're loading", () => {
|
||||
const state = {
|
||||
...getEmptyRootState(),
|
||||
search: {
|
||||
...getEmptySearchState(),
|
||||
query: 'foo bar',
|
||||
discussionsLoading: true,
|
||||
messagesLoading: true,
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(getSearchResults(state), {
|
||||
conversationResults: { isLoading: true },
|
||||
contactResults: { isLoading: true },
|
||||
messageResults: { isLoading: true },
|
||||
searchConversationName: undefined,
|
||||
searchTerm: 'foo bar',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns loaded search results', () => {
|
||||
const conversations: Array<ConversationType> = [
|
||||
getDefaultConversation('1'),
|
||||
getDefaultConversation('2'),
|
||||
];
|
||||
const contacts: Array<ConversationType> = [
|
||||
getDefaultConversation('3'),
|
||||
getDefaultConversation('4'),
|
||||
getDefaultConversation('5'),
|
||||
];
|
||||
const messages: Array<MessageSearchResultType> = [
|
||||
getDefaultSearchMessage('a'),
|
||||
getDefaultSearchMessage('b'),
|
||||
getDefaultSearchMessage('c'),
|
||||
];
|
||||
|
||||
const getId = ({ id }: Readonly<{ id: string }>) => id;
|
||||
|
||||
const state: StateType = {
|
||||
...getEmptyRootState(),
|
||||
conversations: {
|
||||
// This test state is invalid, but is good enough for this test.
|
||||
...getEmptyConversationState(),
|
||||
conversationLookup: makeLookup([...conversations, ...contacts], 'id'),
|
||||
},
|
||||
search: {
|
||||
...getEmptySearchState(),
|
||||
query: 'foo bar',
|
||||
conversationIds: conversations.map(getId),
|
||||
contactIds: contacts.map(getId),
|
||||
messageIds: messages.map(getId),
|
||||
messageLookup: makeLookup(messages, 'id'),
|
||||
discussionsLoading: false,
|
||||
messagesLoading: false,
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(getSearchResults(state), {
|
||||
conversationResults: {
|
||||
isLoading: false,
|
||||
results: conversations,
|
||||
},
|
||||
contactResults: {
|
||||
isLoading: false,
|
||||
results: contacts,
|
||||
},
|
||||
messageResults: {
|
||||
isLoading: false,
|
||||
results: messages,
|
||||
},
|
||||
searchConversationName: undefined,
|
||||
searchTerm: 'foo bar',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
19
ts/test-both/util/deconstructLookup_test.ts
Normal file
19
ts/test-both/util/deconstructLookup_test.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import { deconstructLookup } from '../../util/deconstructLookup';
|
||||
|
||||
describe('deconstructLookup', () => {
|
||||
it('looks up an array of properties in a lookup', () => {
|
||||
const lookup = {
|
||||
high: 5,
|
||||
seven: 89,
|
||||
big: 999,
|
||||
};
|
||||
const keys = ['seven', 'high'];
|
||||
|
||||
assert.deepEqual(deconstructLookup(lookup, keys), [89, 5]);
|
||||
});
|
||||
});
|
46
ts/test-both/util/isConversationUnread_test.ts
Normal file
46
ts/test-both/util/isConversationUnread_test.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import { isConversationUnread } from '../../util/isConversationUnread';
|
||||
|
||||
describe('isConversationUnread', () => {
|
||||
it('returns false if both markedUnread and unreadCount are undefined', () => {
|
||||
assert.isFalse(isConversationUnread({}));
|
||||
assert.isFalse(
|
||||
isConversationUnread({
|
||||
markedUnread: undefined,
|
||||
unreadCount: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false if markedUnread is false', () => {
|
||||
assert.isFalse(isConversationUnread({ markedUnread: false }));
|
||||
});
|
||||
|
||||
it('returns false if unreadCount is 0', () => {
|
||||
assert.isFalse(isConversationUnread({ unreadCount: 0 }));
|
||||
});
|
||||
|
||||
it('returns true if markedUnread is true, regardless of unreadCount', () => {
|
||||
assert.isTrue(isConversationUnread({ markedUnread: true }));
|
||||
assert.isTrue(isConversationUnread({ markedUnread: true, unreadCount: 0 }));
|
||||
assert.isTrue(
|
||||
isConversationUnread({ markedUnread: true, unreadCount: 100 })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns true if unreadCount is positive, regardless of markedUnread', () => {
|
||||
assert.isTrue(isConversationUnread({ unreadCount: 1 }));
|
||||
assert.isTrue(isConversationUnread({ unreadCount: 99 }));
|
||||
assert.isTrue(
|
||||
isConversationUnread({ markedUnread: false, unreadCount: 2 })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns true if both markedUnread is true and unreadCount is positive', () => {
|
||||
assert.isTrue(isConversationUnread({ markedUnread: true, unreadCount: 1 }));
|
||||
});
|
||||
});
|
50
ts/test-both/util/isConversationUnregistered_test.ts
Normal file
50
ts/test-both/util/isConversationUnregistered_test.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import { isConversationUnregistered } from '../../util/isConversationUnregistered';
|
||||
|
||||
describe('isConversationUnregistered', () => {
|
||||
it('returns false if passed an undefined discoveredUnregisteredAt', () => {
|
||||
assert.isFalse(isConversationUnregistered({}));
|
||||
assert.isFalse(
|
||||
isConversationUnregistered({ discoveredUnregisteredAt: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false if passed a time fewer than 6 hours ago', () => {
|
||||
assert.isFalse(
|
||||
isConversationUnregistered({ discoveredUnregisteredAt: Date.now() })
|
||||
);
|
||||
|
||||
const fiveHours = 1000 * 60 * 60 * 5;
|
||||
assert.isFalse(
|
||||
isConversationUnregistered({
|
||||
discoveredUnregisteredAt: Date.now() - fiveHours,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false if passed a time in the future', () => {
|
||||
assert.isFalse(
|
||||
isConversationUnregistered({ discoveredUnregisteredAt: Date.now() + 123 })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns true if passed a time more than 6 hours ago', () => {
|
||||
const oneMinute = 1000 * 60;
|
||||
const sixHours = 1000 * 60 * 60 * 6;
|
||||
|
||||
assert.isTrue(
|
||||
isConversationUnregistered({
|
||||
discoveredUnregisteredAt: Date.now() - sixHours - oneMinute,
|
||||
})
|
||||
);
|
||||
assert.isTrue(
|
||||
isConversationUnregistered({
|
||||
discoveredUnregisteredAt: new Date(1999, 3, 20).getTime(),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue